关于啥也不会的新手尝试使用不知名框架的折腾故事
Let's use Lisp and Reagent to Build An Electron App
Why?
- Why Lisp? And Why Not JS?
请听我狡辩
其实我也说不好为什么要用 ClojureScript 这样的 Lisp 系列的语言, 明明 JavaScript 和 TypeScript 已经足够好了, 并且 ClojureScript 是一个超级小众的语言… 最重要的是, 我并不是很会用 ClojureScript, 这简直就是找死.
实际上, 使用 JS 对我来说, 已经是去年此时的事情了 (指计科导的写网页的事情 How to Make a Website). 经过一年的孜孜不倦的忘记, 已经达到了完全看不懂原来代码的程度了. 而最近我又非常好奇 Lisp 系列的一个编程思想. (怎么说我的梦想都还是能够将我的 りlang 变成一个真正的合理的, 并且是一个能够进行辅助物理学进行科学计算的一个平台. )
所以哪怕是在吃屎, 毕竟是我自己选择的道路, 既然决定了追求刺激, 那么就贯彻到底呗. (虽然对同组的人可能并不是很友好, 但是在我的最终设计中, 这个节点编辑器应该可以接受任意语言作为后台… 希望如此)
- Why Node Editor? And Why Not Use Existence Lib?
别说你甚至听都没有听过节点编辑器
节点编辑器实际上已经有非常多的现成的框架了, 自己写基本很难超过现成的框架. 并且考虑用节点编程, 听上去就像是 LabView 这样的多此一举的坑爹玩意.
节点编辑器的关键在于抽象, 至少我是这么认为的. 至少当前我用过的大部分的节点编辑器, 都有这样的一个问题: 不够直观, 或者说, 虽然能够直观的将代码使用网状的类似于流程图的形式来表示, 但是当规模变大之后, 就会像是一滩烂泥一样向外摊开. 然后这种非线性的代码组织逻辑的优点就变成了缺点. (比如 LabVIEW, Blender 的节点图等), 对于小工程来说, 这样的节点图很妙, 但是大工程来说, 还是线性的代码好一点…
或者类似于 Modelica 这样的提供了抽象节点的编辑器: 能够将多个节点组合在一起作为一个新的元件复用, 这我觉得是非常棒的. 尽管 Modelica 并不能说是一个编程语言吧… 但是它的节点用图形和记号来表示含义而不是用填表的形式, 实际上就是我想要的一个状态.
现有的很多的 JS 的库, 貌似大多数都是前一种的思路: 一个填表节点, 一堆连线. (react-diagram, rete, lightgraph.js, 虽然 lightgraph.js 还有 subgraph 的概念); 并且貌似都不太能够自定义节点 (react-diagram 貌似可以). 所以我的思路是这样的:
- 学习 Modelica 的图标直观表示
- 同时通过点击节点弹出编辑菜单
- Why Not Ask AI?
嘿嘿, 子非鱼, 安知鱼之乐?
实际上一开始我并没有使用 AI, 所以在经历了约两周的一个边学边改, 最后得到的是一个不能添加, 一堆报错的一个垃圾玩意:

不过现在浅浅地尝试了一下 AI 之后, 只能说真香. (在最后也许我会考虑将我和 AI 聊天的记录放上来, 或者部分放上来 (如果太大了的话))
使用 AI 的一个好处就是可以让你从对于一个东西半生不熟的状态, 很快地就能够进入到一个能用的中等水平吧… 虽然不能说完美, 但是能用就行了, 我将会考虑在之后用 AI 来辅助我学习和干活.
So, what's this about?
一个节点编辑器, 使用 ClojureScript, Reagent 和 Re-frame 来制作. 以下是具体的技术栈介绍以及相关的链接:
- ClojureScript (实际上是将 Clojure 编译成 JavaScript 的实现)
如果想要非常快速地掌握 ClojureScript
这里是我学习 ClojureScript 做的一些简单的笔记, 如果相信我的话, 可以试试看看这个来学习. (不过不如用 AI 辅助学习… )
不过至于为什么是洋文, 这个就任君想象了. 一个比较靠谱的故事是这样的:
当时刚刚接触 ClojureScript 的时候, 我还很年轻, 不知道天高地厚; 更不知道未来的学习压力将会把我拖垮. 我只是普通地好奇 Lisp, 普通地摆大烂. 但是摆大烂怎么说也是不太名正言顺, 所以我认为, 不妨乘机学习一下英文表达能力呢? 嘿嘿嘿, 反正是摆大烂, 不如贯彻到底.

(嘿嘿嘿… )
- About
This is a quick note form Clojure Unrevaled. Aimed to quick ref of Clojure (personal use mainly).
A more faster Cheatsheet from ClojureScript official site.
- Basic about Clojure
Data
- Data Expression
Types EBNF Description Numbers (+¦-)? [\d]+ (.[\d])? (e-?[\d]+)?Keywords :[\w]+or(keyword <string>)Namespaced keywoards ::[\w]+,:<namespace>/[\w]+or(keyword <namespace> <string>)namespace/keyword Symbols [\w\*\+\!-_'\?]Strings ~”.*”~ Characters \<charname>charname for example: \newlineList (list <items>)or ~'(<items>)~for backquote: `(~(+ 1 2) 3)$→$(3 3)Vectors [<items>],(vector <items>)or(vec <list>)Maps {(<key> <val> ,?)+}, or(hash-map (<key> <val>)+)Like hash table Sets #{<items>}or(set [<items>])Union Note: (<set> <item>)test whether<item>in<set>. Note that literal specific don't allow duplicate item.Queues #queue []Persistent and immutable queue. Atom (ClojureScript) (atom <val>)Mutable value within. Volatile (ClojureScript) (volatile! <val>)Like Atom but not observation and validation ability. - Data Manipulate
Functions Rule Description type(type [x])Type of x, named symbol.<type>?Like list?,vector?, test value type.- List
Function Example Descriptions cons(cons 0 (cons 1 ()))Linked List conj(conj '(1 2) 0) ;; => (0 1 2),(conj [1 2] 0) ;; => [1 2 0]Note the different between list and vector. pop(pop stack)Return like (rest stack), won't modifystack, similar function:rest,peek(pop last one). - Vector
Function Example Description nth(nth [1 2 3] 0) ;; => 1Start from 0. Likely(<vector> <index>).assoc(assoc <vector> <pos> <val>)Insert valatposinvector. Raise error when out of bounds.mapv(mapv <func> <seq>)Like map, but return Vector. - Map
Function Example Description conj(conj <map> ([<key> <val>])+)Append to map. assocanddissoc(assoc <map> <key> <val>)assocappend by key;dissocdelete by key.(<map> <key>)Read by key. keys(keys <map>)List of keys. - Sets
Function Example Description clojure.set/difference(clojure.set/difference <set> <set>)Difference of two sets. clojure.set/unionUnion of two sets. clojure.set/intersectionIntersection of two sets. contains?or(<set> <item>)Test if contains. - Queue (like a changeable list)
- Atom and Volatile (ClojureScript)
Functions Rule Description deref(deref <atom>)or@<atom>Read the value of Atom. swap!(swap! <atom> update <key> <func>)See if atomis(atom {:key val}), and(swap! atom update :key inc)will increaseval.reset!(reset! <atom> <val>)Replace atomvalue with newval.add-watch,remove-watch(add-watch <atom> <key> <func>),(remove-watch <atom> <key>)Call funcifkeyofatomis changed.Functions Rule Description volatile?(volatile? <obj>)Test if volatile. vswapSame as atom. vreset!Same as atom. - Destructing
(let [[x _ y] [0 1 2]] [y x]) ;; => [2 0], likemultiple-value-bindin common lisp.- it works like function parameter assignment:
(let [[x y & more] [0 1 2 3 4]] ...).Note: the total could be passed in as
(let [[_ & para :as total] [1 2 3]] ...). - Also, it could be named assignment:
(let [{x :xvar} {:xvar ...}] ...).And unbound will fall back to
nilunless specified with:or:(let [{name :name :or {name ...}} {...}] ...).Note: the total map could be assigned by:
(let [{name :name :as total} {...}] ...). - For more, please refer Clojure Destructuring Tutorial and Cheat Sheet
- List
Assignment
- Basic Assignment
- Variable assignment:
(def <var> <val>) - Local bound:
(let [(<var> <val>)+] body), like other Lisp but less(). - Function assignment:
(defn <fname> <docstring>? body ...) - Single form:
(defn 1+ "Return 1 + given number." [x] (+ 1 x))
- Multi form:
(defn n+ "Return 1 + given number `(n+ x)' or n + given number `(n+ x 1)'." ([x] (+ 1 x)) ([x n] (+ n x)))
Note: Kinda like functions in Mathematica.
- Multimethods
(defmulti multi-func "docstring" (fn [param] (:key param)) :default :key) (defmethod multi-func :key-1 [param] (...)) (defmethod multi-func :key-2 [param] (...)) (multi-func {:key :key-1}) (multi-func {:key :key-2})
Add
:hierarchykeyword can describe hierarchy relation for multimethods. If not provided, it will use global hierarchy instead.- Multi param:
[& params]treat params as a list. - Protocol like Java's interface.
- Multi param:
- Anonymous Functions
(fn [params] body ...)#(body)and use%Nfor Nth param. Note:%is equal to%,%&is likely to[& param]. Looks like#&in Mathematica.(def var #(func))Like Scheme, could assign variable with function.
- Variable assignment:
- Namespace
(ns namespace (:require other-package [other-package :as alias-other-package] [other-package :refer [symbols-in-others-package]] [other-package :exclude [excluded-symbols]]) (:refer-clojure :exclude [func]))
nsto self-define namespace(require pkg)or(:require ...)innsload other packages with alias name.(:refer ...)innsload symbols in other packages for easy use.use
(:refer-clojure ...)dealing withcljs.corenamespace.- load symbols by
namespace/symbols. - The file should be orgnized like below:
app └── src └── namespace ├── core.cljs └── main.cljswith namespace of
namespace.coreandnamespace.main.
- Protocol
Note: to understand, referring to 「標準実装」という概念はない and
Java Interface would help.
(defprotocol protocol-name "docstring about the protocol" (method [this] "docstring")) (extend-type type-name protocol-name (function-from-protocol [this] ...) another-protocol-name (function-from-another-protocol [this] ...)) (extend-protocol protocol-name type-name (function-from-protocol [this] ...))
- Protocol works like giving different objects likely interface.
Kinda like Duck-type in Ruby. And
extend-typeandextend-protocolworks like Ruby'smix-inand Python's__int__like definition.Although I think I'd prefer Ruby's flavor more. - To test if an object having specific protocol:
(statisfies? protocol object).
- Protocol works like giving different objects likely interface.
Kinda like Duck-type in Ruby. And
- Hierarchy and Type System
- Hierarchy
(derive [children parent])Like
class Children < Parentin Ruby.Provide a local hierachy
(derive [h children parent])wherehis a hierarchy space create by(make-hierarchy).(ancestors [tag])or(ancestors [h tag])for local hierarchy.(descendants [tag])or(descendants [h tag])for local hierarchy.(isa? [child parent])or(isa? [h child parent])for local hierarchy.
- Type
(deftype [name [& fields] & opts+specs])Like
struct name { fields }in C. Or more likely of defstruct in Common Lisp. Provides reader and constructor.- Constructor
(name. <fields>)append a.after the type name will be a constructor function name. Or use->nameor(new name ...). - Reader
(.-fieldname <object-of-type>)prefix.-of a field name will read field value of<object-of-type>. - Reify
(reify [& opt-spec])for quick create objector without pre-defining a type.
- Constructor
- Record
(defrecord [name [& fields] & opts+specs])Like type but a little different.
- Constructor
(name. ...),(->name ...),(new name ...). An additionalmap->nameconstructor function. - Reader
(<record-object> :key). - Hierarchy
(defrecord name [field] h (...)).
- Constructor
- Hierarchy
Flow Control
- Condition
Command EBNF Description if(if <cond> <true> <false>?)cond(cond (<cond> <exp>)+ (:else <exp>)?)Like (cond ((= x 1) x)), but less().case(case <exp> (<val> <exp>)+ <fallback>?)condp(condp = <exp> (<val> <exp>)+ <fallback>?)Maps each <val>with(= <exp> <val>). Return<fallback>if fails. Likecase.filter(filter <func> <list>)Select <list>if<func>true. - Iteration
Command EBNF Description do(do <exp>+)Block of code, return value of last exp.for(for [(<var> <range>)+ (:(while¦when) <cond>)? (:let [<var> <val>])?] <body>)(for [x [1 2 3] y [4 5]] [x y])doseqLike forbut returnnil.loop(loop [<init-key-val>] body)Assign initial variable and loop recur#(if (= % 1) 1 (+ (recur (inc %)) %))Like Y-combinder in λ calcus map(map <func> <data>)<data>can be vector or listrun!Like mapbut returnnil.reduce(reduce <func> <init> <data>)#(if data (recur %1 (%1 init (car data)) (cdr data)) init) - Threading Macro
Used to quickly build nested function calls.
Marcos Name Examples Descriptions ->thread-first macro (-> x f1 f2 f3)equal to (f3 (f2 (f1 x)))->>thread-last macro (->> x f1 f2 f3)equal to (f3 (f2 (f1 x)))as->thread-as macro (as-> x $ (map inc $) (...))assign a temp name for xsome->,some->>thread-some macro same as ->could be used to avoid null cond->,cond->>thread-cond macro (cond-> x (<cond> <exp>)+)apply expifcond
ClojureScript and JavaScript
- Conversion between JavaScript
JavaScript methods are stored in
jsnamespace, and can be accessed like(js/parseInt "...").Some shorthand of writing function call:
Shorthand Equal to JavaScript Description and Notes (.log js/console ...)(js/console.log ...)console.log(...)Function call (.-PI js/Math)js/Math.PIMath.PIRead attribute, see Hierarchy and Type System for more #js {:key val}(js-obj key val){key: val}Like record, use clj->js,js->cljfor convert. - Google Closure Library
(ns app.core (:require [goog.dom :as dom])) (def element (dom/getElement "body"))
for more information, see Closure Generated API Docs
- Load JavaScript via Google Closure Library:
In js file, provide module like:
goog.provide("javascript.util"); goog.scope(function() { var module = javascript.util; module.func = function(para) { /* ... */ }; });
Then could call in ClojureScript:
(require '[javascript.util :as util]) (util/func ...)
- Load JavaScript via Google Closure Library:
In js file, provide module like:
- Hot Load
- Electron
- Data Expression
- About
- Reagent, 是 React 的 ClojureScript 的绑定
- Re-frame, 一个类似于简化 Reagent 设计的一个框架.
- shadow-cljs, 用于配置和与 npm 进行沟通
- Electron, 一个类似于将网页作为本地软件的一个框架 (如果说难听点, 就是 chromium 内核. )
Overview of the Structure
File and shadow-cljs
一个使用 shadow-cljs 以及 npm 共同管理的 ClojureScript 应有类似如下的文件结构:
(注: 不一定所有的都是这样的结构, 所以折叠了吧. )
文件结构 (展开)
.
├── package-lock.json
├── package.json
├── rescources
│ ├── main.js
│ └── public
│ ├── css
│ │ └── main.css
│ ├── index.html
│ └── js
├── shadow-cljs.edn
└── src
└── app
├── main
│ └── core.cljs
└── renderer
└── core.cljs
一点简单的解释:
- 依赖文件
package.json可以通过npm init来新建, 通过npm install会生成package-lock.json.package.json 的一些设置
略去了一些无关的东西…
{ ... "main": "rescources/main.js", "scripts": { "build": "shadow-cljs compile main renderer", "test": "echo \"Error: no test specified\" && exit 1" }, "devDependencies": { "electron": "^24.0.0", "electron-devtools-installer": "^3.2.0" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0", "shadow-cljs": "^2.22.9" } }
shadow-cljs.edn里面包含了 shadow-cljs 的依赖, 一些动作的设定之类的东西.shadow-cljs.edn
;; shadow-cljs configuration {:source-paths ["src"] ; where clojurescript file stores :dev-http {4000 "rescources/public"} ; run shadow-cljs watch ... ; open localhost:4000 to preview :dependencies ; package dependencies [ [cider/cider-nrepl "0.30.0"] ; for Cider in Emacs to use [reagent "1.2.0"] ; Reagent [re-frame "1.3.0"]z ; re-frame [binaryage/devtools "1.0.7"] ; devtools ] :builds ; run shadow-cljs compile ... to build {:main ; shadow-cljs compile main { :target :node-script ; for electron usage :output-to "rescources/main.js" ; output dir :main app.main.core/main ; main function entry } :renderer ; shadow-cljs compile renderer { :target :browser ; for browser ; NOTE: The render process in Electron ; runs under the browser enviroment. :output-dir "rescources/public/js" ; output path :asset-path "js" :modules { :renderer ; compile to renderer.js {:init-fn app.renderer.core/start!}}}}}
- 代码
src下面包含着 ClojureScript 的代码文件, 通过文件夹来设置 namespace, 如:src/app/main/core.cljs来表示(ns app.main.core).app/main作为 Electron 的入口app/main/core.js
(ns app.main.core (:require ["electron" :refer [app BrowserWindow]])) (def main-window (atom nil)) ; main window (defn init-browser [] (reset! main-window ; set the window with size 800x600 (BrowserWindow. (clj->js {:width 800 :height 600}))) ;; it should be resource/public/index.html, ;; __dirname in javascript tells Electron where to find file (.loadURL ^js/electron.BrowserWindow @main-window (str "file://" js/__dirname "/public/index.html")) ;; Deal with situation when closing window (.on ^js/electron.BrowserWindow @main-window "closed" #(reset! main-window nil))) (defn main "Main function for Electron app." [] (.on app "window-all-closed" #(.quit app)) ; quit app (.on app "ready" init-browser))
app/renderer作为 Electron 的界面渲染部分, 以下主要关注这部分的代码.
re-frame
(注: 这里仅仅只是说是作为前端界面的一个结构, 不是网页结构. )
这里使用如下的一个事件结构 (re-frame 的一个结构) 来进行软件基本框架的搭建:
对上面的结构的一个说明
这个结构在我看来十分类似 Rails 里面的 MVC 结构.
About MVC…
注: 并非 re-frame 使用的就是 MVC 模型来进行软件工程建构. 只是我觉得它使用的方式和 Rails 里面的 MVC 模型很像… 不妨类比一下.
以下是 Claude 对 re-frame 中 MVC 模型的的介绍 (注: 括号内是我的注记)
- 用户与 Components 交互
(对应
view.cljs中的代码, 通过使用 Reagent 的 Hiccup 构建) - Components 触发事件, 事件被 Event dispatcher 接收并交给对应的 Event Handler 处理.
(对应
event.cljs中的代码, 通过reg-event-db和reg-event-fx来处理事件并更新数据库) - Event Handler 会更新应用状态 Subscription, 并产生 Side Effect.
- Components 监听到 Subscription 的改变, 更新 View.
(对应
sub.cljs中的代码) - Side Effect 被执行, 进行必要的副作用操作 (更新数据库之类的).
简单来说就是, 在设计的时候, 考虑将视图 (View), 数据逻辑 (Model), 和控制 (Controller) 进行分离来进行构建, 通过标准的接口调用, 实现模块化.
- 在
view.cljs中, 我将要绘制控制界面 (Art-board), 节点 (Nodes), 连线 (Arcs) 以及接口 (Ports). (应该一些最基本的事件是在其中处理) - 在
events.cljs中, 我将要处理事件逻辑. - 在
sub.cljs中, 将会声明并使得在db.cljs中定义的数据库通过接口暴露给其他部分调用. - View and Hiccup: 在 Reagent 中通过使用 Hiccup 来进行描述 HTML 的形式:
详细介绍和例子
- Hiccup 的形式
[:div.class-name ; <div> [:h1#id-name ; <h1 id="id-name"> "Lucky Me"] ; Lucky Me ; </h1> [:p {:style {:color :yellow}} ; <p style="color: yellow;"> "Haha"] ; Haha ; </p> [:span ; <span> <!-- with click event --> {:on-click #(println "Clicked")}]] ; </span> ; </div>
- 最简单的形式:
(defn view [] [:h1 "Hello"]) (reagent.dom/render ; Render into `app-container'. [view] (js/document.getElementById "app-container"))
- 稍微复杂一点但是会比较高效的做法:
(defn view [] (let [nodes (subscribe [:nodes])] (fn [] ...)))
使用这样的方式的好处在于一开始会计算得到
(fn [])的结果, 然后之后就不会再计算, 而是一直沿用. - 关于
key通过给元素设立一个
key的 metadata 可以让 Reagent 在更新的时候, 只渲染对应的key而不是全部渲染:(for [node nodes] ^{:key node-id} [draw-node node])
- Hiccup 的形式
- Subscribe Data: 在
re-frame框架中, 有subscribe和cursor的两个关于数据的方法.详细介绍和例子
subscribe的例子:- 比如在
sub.cljs中如下注册数据接口:;;; db, for example: `{:nodes [1 2 3]}'. (reg-sub :nodes (fn [db] (db :nodes)))
那么在
view.cljs中通过subscribe来进行注册信息:(subscribe [:node])
- 或者如果想要传入参数, 可以使用下面的形式:
;;; db, for example: `{:nodes {:key1 1 :key2 2}}'. (reg-sub :node (fn [db [_ key]] ((db :nodes) key)))
在
view.cljs中:(subscribe [:node :key1])
然而这样的做法不如使用
cursor更加高明. 可以精确更新某一路径而不是整个结构. subscribe实际上得到的是一个atom容器, 通过@(subscribe ...)的形式可以得到值.
- 比如在
cursor的例子:- 通过
(cursor :state)的形式新建, 使用(get-in cursor [:nested ...])进入深层的路径 - 通过
(assoc-in cursor [:key] value)进行修改状态 cursor的感觉更像是 C 语言里面的指针, 但是并不完全一样.
- 通过
- Dispatch Events and Update Database:
通过
reg-event-db和reg-event-fx来定义如何处理事件, 并且通过dispatch来进行调用.详细介绍和例子
reg-event-db和reg-event-fx的使用例子如下注册一个事件:
(reg-event-db ; regist event using database :update-mouse-position ; event name (fn [db [_ mouse]] ; event arguments (-> db (assoc :mx (.-clientX mouse)) (assoc :my (.-clientY mouse)))))
然后在
view.cljs中使用(dispatch [:update-mouse-position mouse])这样的形式来进行调用事件.相比
reg-event-db其仅仅只修改database状态,reg-event-fx除了修改database, 还可以进行其他带有 Side Effect 的事情.
The Project
该部分暂时还请移步 仓库 吧…
(写完之后再同步进来… )