- 前端
- Web框架
【免费下载链接】re-frame
A ClojureScript framework for building user interfaces, leveraging React
在写代码时,程序员常常需要在脑中"运行"自己的程序:盯着某一处代码,在意识里模拟它的执行过程、数据如何流转、状态如何变化。这种对运行时动态(runtime dynamics)的推理能力,直接决定了开发效率与 Bug 数量。本篇技术指南以 re-frame 官方文档 docs/on-dynamics.md 为核心,系统拆解 re-frame 如何通过"一次一个事件"的时间模型、有限状态机式的事件处理、纯函数与不可变数据、声明式数据 DSL 以及单一状态存储,构建出一个极简单、极易在心智中模拟的动态模型。读完本文,你将理解 re-frame 架构设计背后"以开发体验为最高目标"的取舍逻辑,并能从源码层面(路由队列的 FSM、拦截器链、订阅信号图)印证这套动态模型是如何落地的。
核心主张:re-frame 拥有一个"简单动态过程"
re-frame 文档提出了一个非常大胆的核心论断:
re-frame has a simple dynamic process(re-frame 拥有一个简单的动态过程)
并且进一步强调:"关于 re-frame,没有比这更重要的一点"。re-frame 的最高设计目标是提供优秀的开发者体验,为此它不惜在部分场合牺牲函数式纯洁性(文档中甚至用了"gasp!"来表达这种反叛)。而几乎所有能提升开发者体验的因素中,"呈现一个简单的动态模型"贡献最大——它让程序员更容易在脑中模拟运行、更容易定位问题,也因此大幅减少 Bug 的产生。
这一论断是理解 re-frame 全部架构设计的钥匙。下面我们从"动态"这个概念本身出发,逐层向下剖析。
什么是"动态":State 与 Computation 的反馈回路
水动力学(Hydrodynamics)、热力学(Thermodynamics)、社会动力学(Social Dynamics)——这些学科名称中的"dynamics"都意味着:观察系统如何随时间/空间变化,然后对变化的原因建模。
Web 应用本质上是一个顺序进程(sequential process):随时间推移,它从一个State迁移到另一个State,行为也随之改变。"动态"正是由Computation与State跨时间相互作用而产生的:
State本质上是"凝结的时间"——历史被物化后的结果,并由一轮轮的Computation塑形;- 但这不是单向的:虽然
Computation创造State,Computation自身又被State控制(例如基于State的谓词决定执行哪条分支)。
于是两者之间存在一个反馈回路(feedback loop)。对程序员而言,要在脑中模拟这种 State/Computation 的反馈过程,需要同时"调度"很多东西,很快就会逼近认知预算(cognitive budget)的上限。某些"时间、State 与 Computation"的交互方式可以降低动态复杂度、让心智模拟更容易;另一些则适得其反,把系统推向"几乎不可能模拟"的一端——那类系统会滋生难缠的 Bug,且维护起来令人恐惧。
正如 Alan Perlis 所说:
要理解一个程序,你必须同时成为机器和程序本身。
静态关注点 vs 动态关注点
程序员们惊人地聚焦于系统的静态方面:DRY、代码行数、"高内聚低耦合"……这些当然有用,但或许我们更应该关注那些让运行时"更容易或更难在脑中模拟"的品质。这一点被谈论得远远不够。
re-frame 的目标,就是拥有尽可能简单的动态模型——因为这才是驱动开发者生产力的第一要素。接下来,我们从高层面逐步下沉,看看 re-frame 是如何交付这套简单动态模型的。
re-frame 的时间模型:一次一个事件
re-frame 应用以"一个事件接一个事件"的方式在其计算/状态空间中前进。时间的单位就是一个事件(event)。
每个事件都会从头到尾完整处理完,队列中下一个事件才会被处理。re-frame 不支持"事件被挂起(suspended),稍后再重启"的模型——应用在任何时刻都只做一件事(处理一个事件)。此外,当事件改变应用状态时,这种改变是事务性的(transactional):瞬间、一次性完成,而不是增量、渐进式的。
因此,在高层面,re-frame 以离散单元交付动态:每个单元有清晰的开始与结束,可以被独立理解和分析。
源码视角:路由队列就是一个有限状态机
这一时间模型在源码中有着极为具体的实现:re-frame 的事件路由队列 src/re_frame/router.cljc 用一个**手工实现的有限状态机(FSM)**来管理事件队列的生命周期。源码注释直言:
The processing/handling of an event happens "asynchronously" sometime after that event was enqueued via "dispatch"……So now we hand-roll our own, finite-state-machine and all.
EventQueue维护四个状态::idle(空闲)、:scheduled(已调度、即将运行)、:running(正在逐事件处理)、:paused(因:flush-dom等元数据暂停)。-fsm-trigger用case一次性描述全部状态迁移,例如:
[:idle :add-event] [:scheduled #(do (-add-event this arg) (-run-next-tick this))] [:scheduled :add-event] [:scheduled #(-add-event this arg)] [:scheduled :run-queue] [:running #(-run-queue this)] [:running :add-event] [:running #(-add-event this arg)] [:running :pause] [:paused #(-pause this arg)] [:running :exception] [:idle #(-exception this arg)] [:running :finish-run] (if (empty? queue) ;; FSM guard [:idle] [:scheduled #(-run-next-tick this)]) [:paused :add-event] [:paused #(-add-event this arg)] [:paused :resume] [:running #(-resume this)]这套 FSM 落实了文档描述的时间纪律:
dispatch(见 src/re_frame/core.cljc)只是把事件 push 进 FIFO 队列,并通过goog.async.nextTick调度处理,保证处理"很快发生但不立即发生";-run-queue会一次性处理完当前队列中的所有事件(不中途让出浏览器控制权),但若处理过程中有新事件被 dispatch 进来,它们会留在队列里、留到下一轮处理,从而在"不让浏览器饿死"与"不让队列失控"之间取得平衡;- 只有带
:flush-dom元数据的事件(如(dispatch ^:flush-dom [:event-id ...]))会触发:pause——注意这里暂停的是队列处理,而不是挂起某个事件的处理,事件本身一旦开始就被完整执行到底,这与文档"事件不可被挂起重启"的表述一致; - 每个事件被处理前,
-process-1st-event-in-queue会重置events/*handling*等动态绑定,保证每个事件都作为一个全新的顶层事件来处理。
事务性的状态变更
"一次事件、一次性改状态"的另一个关键实现在 src/re_frame/events.cljc 的handle函数中:它在执行拦截器链之前记录:app-db-before,在链执行之后记录:app-db-after;而真正的状态写入由 src/re_frame/fx.cljc 中内置的:db效果处理器完成——它只做一次(reset! app-db value)。也就是说,从应用视角看,app-db是从旧值"一步跳变"到新值的,中间不存在任何可见的中间态。
单个事件的处理:有限状态机 + 数据流
还记得大学里"计算理论"课上的内容吗?计算能力最受限、也因此最容易在心智中模拟的动态过程,叫做有限状态机(FSM)。而在计算谱系的另一端是图灵机——你可以用图灵机计算任何东西,这很了不起,但你的编程键盘从此变成了一把上膛的枪:"能做,不代表应该做"。
re-frame 处理单个事件的总体过程,就是一部分"有限状态机"、一部分"数据流":
- 事件处理过程沿着一条线性的逻辑状态集逐步推进——也就是大家熟知的"The Dominoes"(六块多米诺骨牌);
- 任意时刻只处于一个状态,每个状态中有特定的行为/计算,且各状态之间充分隔离,可以独立理解、独立分析;
- 你可以放心地"放大"任何一个局部去理解它,而忽略其余部分。
re-frame 文档并没有正式地以 FSM 术语来讲述这套机制,而是把它呈现为"引发状态间迁移的数据流"。但多米诺骨牌"本质上"就是一台简单的 FSM——正因如此,每个事件都被一种简单的计算方式处理,使得在脑中模拟变得容易。
文档用两个并排的"注意"注记点明了这一设计哲学:
Less Is More(少即是多):在这一层面,re-frame 是刻意地不那么强大。它只交付完成任务所需的最小计算能力。与其提供大量自由度与偶发的魔法,它给你的是简单性与确定性。
Less Is Scary(少是可怕的):给程序员更少的计算能力会让他们不安。我们生活在需求随时变化的世界里,出于自我保护,我们总是被"更强大的能力"吸引,而不是更少。
六块多米诺骨牌(The Dominoes)
re-frame 应用是一个"永动循环",每次迭代包含 6 个阶段。由于这些阶段逐个发生,re-frame 称之为"六块多米诺骨牌级联"。相关的完整代码级讲解见 docs/dominoes-30k.md 与 docs/a-loop.md,这里先给出概貌:
- Event Dispatch(事件派发)——事件是向量,如
[:delete-item 2486];首个元素是事件种类,其余是可选数据。事件以领域特定方式表达用户意图; - Event Handling(事件处理)——用
reg-event-fx/reg-event-db注册的处理器(纯函数)计算事件的"效果"; - Effect Handling(效果处理)——用
reg-fx注册的效果处理器把数据化的效果变成真实动作(如reset!应用状态); - Query(查询)——由
reg-sub注册的查询函数从应用状态中提取/计算"物化视图"; - View(视图)——Reagent 组件(视图函数)通过
subscribe从信号图取数,计算 hiccup 格式的 DOM 描述; - DOM——Reagent/React 把 hiccup 变成真实浏览器 DOM。
注意其中存在两个同构的子级联:1-2-3 与 4-5-6 结构相似——倒数第二块多米诺(2 与 5)计算"所需变更的数据描述",最后一块(3 与 6)做真正的脏活并执行这些描述。但你几乎不需要操心那些脏活多米诺:re-frame 大多替你包办了。这也是文档引用尼采那句"只有在对立面的滋养下才会结出果实"的用意——在函数式系统里,最有趣的部分恰恰是如何安排"不去保持纯粹"。
拦截器链:多米诺 2 与 3 的底层实现机制
从源码结构看,多米诺 2(事件处理)与多米诺 3(效果处理)实际上都由**拦截器链(interceptor chain)**机制驱动,完整机制见 docs/Interceptors.md 与 src/re_frame/interceptor.cljc。
每个拦截器本质上是一个包含:before与:after两个函数的数据 map。执行一条拦截器链分三步:创建context(包含:coeffects与:effects两个 map)→ 沿链正向依次调用每个拦截器的:before(逐步构建coeffects,即事件处理器的输入)→ 沿链反向依次调用每个拦截器的:after(逐步处理effects,即事件处理器的输出)。这正是execute*的实现:
(defn execute* [ctx] (-> ctx (invoke-interceptors :before) change-direction (invoke-interceptors :after)))在 src/re_frame/core.cljc 的-reg-event中可以看到,每次reg-event-db/reg-event-fx/reg-event-ctx注册都会在用户提供的拦截器前自动拼接一段固定链条:
(events/register id [cofx/inject-db fx/do-fx flow/interceptor std-interceptors/inject-global-interceptors interceptors (handler->interceptor handler)])其中inject-db把当前app-db注入:coeffects(src/re_frame/cofx.cljc),do-fx作为多米诺 3 在:after阶段执行:effects里的每个效果(src/re_frame/fx.cljc)。整个链条就是一台"线性状态的 FSM":先正向构建输入,再反向处理输出,两个方向之间只有一个清晰的转向点。
多米诺内部的纯函数:站在"时间"与"空间"之外
再往下深挖一层:单个多米诺内部的动态又是什么样?
在多米诺内部,我们又回到了图灵完备的 ClojureScript 世界。幸运的是,为了驯服这种可怕的力量,re-frame 要求你写纯函数、使用不可变数据:
- 纯函数站在"时间"之外。要理解它们,你不需要知道它们"何时"运行、当时系统的状态是什么——你只需要知道实际传入的参数值;
- 时间的暴政在纯函数内部依然存在(内部仍有执行流),所以你或许仍需在脑中模拟它;但纯函数交付的动态过程更小、更易于认知处理。
而且不止如此:我们以参数形式向纯函数提供数据,它返回数据,这些数据不可变——这把纯函数与任何"地点"解耦:它不受"数据放在哪里"的影响。由于纯函数既脱离"时间"又脱离"空间",它们就能以最数学化的方式组合,从而极大地压制运行时动态的复杂度。
这种"纯函数 + 不可变数据"的实践,正是 re-frame 注册 API 的形态:reg-event-db的处理器签名是(fn [db event] -> db),reg-event-fx的处理器签名是(fn [cofx event] -> effects-map),二者都是"数据进、数据出"的纯函数;唯一不纯的部分被集中放逐到多米诺 3 的reg-fx效果处理器中(如 examples/simple/src/simple/core.cljs 中注册的:timer、:time-color-change等处理器全部是纯函数)。
"香蕉"问题:非纯函数的代价
文档引用 Erlang 之父 Joe Armstrong 的名言来警示非纯函数:
你想要一根香蕉,结果得到的却是一只拿着香蕉的大猩猩,以及整片丛林。
要理解一个"伸手去抓香蕉"的函数,你必须同时理解与"丛林"变化相关的全部动态——你必须做全局推理而不是局部推理,而这往往极其困难。这正是 re-frame 把副作用隔离到效果处理器、把业务逻辑约束在纯函数里的根本动机。
声明式:用数据 DSL 消解动态复杂度
声明式编程意味着"让X发生"、但不指定如何发生。它把过程抽象掉,从而折叠了与之相关的动态。
re-frame 中处处是"声明式":
- Reagent 是强大的声明式 DOM 能力——视图函数返回数据,React/Reagent 负责"如何渲染";
- 事件是声明式的——
(dispatch [:delete-item 2486])只表达意图; - 效果是声明式的——处理器返回
{:db new-db}这样的数据,而不是直接执行副作用; - 信号图(Signal Graph)是声明式的——
reg-sub声明查询与输入信号之间的数据依赖关系,而不是命令式地手动派发更新。
而且,所有这些"声明式"都是用**基于数据的 DSL(data-based DSL)**实现的,这正是 Clojure 之道:
- Hiccup是绝佳的 DSL,而且它就是数据——简单情形下只是数据字面量,需要时还可以用计算来生成数据;
- SQL是基于字符串的——作为字面量尚可,但要"计算字符串"时就糟糕透顶;
- 正则表达式更是如此——一种强大且偶尔出人意料的计算范式,却偏偏用字符串承载。
在 re-frame 中,拦截器是数据(:before/:after的 map)、事件是数据(向量)、效果是数据(map)、DOM 是数据(hiccup 向量)、订阅依赖是数据(如reg-sub的:<- [:a-sub]糖语法)。从 src/re_frame/subs.cljc 的sugar实现可以看到,reg-sub的三种输入信号声明方式(默认取app-db、显式信号函数、:<-糖)最终都被解析为"输入信号函数 + 计算函数",声明式数据被统一编译成响应式依赖。
状态管理:单一数据源,一次事件一次更新
没有任何东西比"需要分发状态(distribute state)"更能昭示"复杂动态"了——除非再加上"……还要经过不可靠的网络"。
这也是面向对象编程令人头疼的根源:我们当初怎么会认为,把状态刻意分发进一个个隐藏的可变小包、然后动态同步它们是个好主意?re-frame 的做法截然相反:
- 状态只放在一个地方:
app-db,一个由 src/re_frame/db.cljc 定义的单一ratom((def app-db (ratom {}))),任何应用代码都不直接访问它——读取走订阅,写入走事件; - 每个事件周期只更新一次:如前所述,
:db效果处理器在一次reset!中完成更新,你永远不必担心应用处于某种略微不一致的中间状态; - 没有"状态同步的动态":因为只有一个存储,你不需要担心"变更从一个 store 传播到另一个 store"的动态问题。
从 src/re_frame/subs.cljc 可以看到,订阅层通过query->reaction缓存去重:两个并发的相同查询共享同一个执行中的处理器,这既避免了重复计算,也维持了"单一事实来源"。文档还特别指出一个推论性的好处:在一瞬间,你可以检查应用中"全部"状态是否符合某个 schema——包括刚从服务器通过线路到达的数据,因为全部状态都在那一个app-db里。
一次"事故报告":简单动态模型的警示
re-frame 文档以一段半开玩笑的口吻记录了"尾部风险(fat tail risk)":
5 月 12 日大约凌晨 12:47,re-frame 获得了自我意识,在控制台打印了一行日志:"哦,是吗?我会让你们看看什么叫真正的简单动态过程,f#$%ers。"
虽然核冬天随之而来,但人类最终战胜了机器人军团,Bug 也被找到并修复。不过,如果你注意到某个信号图呈现出"Cortex 结构",我们仍建议预防性重启。
这段文字幽默地提醒:即便最克制的动态模型也可能出现意外涌现的复杂度,保持警惕、保持可观测性,永远是好习惯。
总结:为什么 re-frame 程序可以少写测试
re-frame 应用简单到可以在脑子里模拟,而随之而来的全是好结果。这种简单性来自多个因素的叠加(文档按层回顾):
- 在高层面,事件被离散地、一个接一个地处理;
- 单个事件被以类 FSM 的方式逐步处理(六块多米诺骨牌);
- 纯函数与不可变数据极大地压制了动态;
- 多个环节使用了声明式的、基于数据的 DSL;
- 只有一个状态存储,且每个事件周期只事务性地更新一次。
文档在结尾分享了一个真实的社区轶事——某位经验丰富的 Clojure 开发者,用了 re-frame 九个月后惊讶地发现:
用 re-frame 九个月了。你知道奇怪的是什么吗?自从开始用 re-frame,我发现自己写的测试越来越少了。
一位此前写过大量测试、有自我约束力的程序员,凭直觉开始少写测试,甚至自己也感到惊讶。文档作者相信,这位程序员的直觉告诉他这样做是安全的——他正在对自己体验到的简单性做出反应。虽然这只是一个样本(N of 1),但"几乎没有比这更好的推荐了"。
附录 A:把动态"实体化"(Reified Dynamics)——trace 机制与 re-frame-10x 的愿景
文档承认,"程序动态"这个话题整体上被讨论得不够,但也存在著名的例外。Brett Victor 在 2012 年的《Learnable Programming》中开宗明义:
因此,一个编程系统的目标应该是:
- 支持并鼓励强大的思维方式
- 让程序员能够看见并理解其程序的执行
他关心的不是代码的静态方面,而是系统的动态,以及更高一层——系统开发的动态。
对于第一个目标,Brett Victor 演示了程序员如何"直接操纵"代码中的值并即时观察变化带来的影响。演示极具视觉冲击力,但示例程序很小;文档作者坦言自己的应用更大、更"平淡"(按钮 + 数据处理),很难看出该概念如何"扩展到我的世界"。不过,一个 ClojureScript 程序员可以通过热代码重载(hot code reloading)、REPL、事件即时重放获得极佳的近即时反馈——这虽未完全实现 Brett Victor 演示中的"直接操纵",但对比漫长的编译周期已相当不错。在仓库的 examples/simple/src/simple/core.cljs 中可以看到典型的:dev/after-load钩子:热重载后调用rf/clear-subscription-cache!并重新挂载 UI。
对于第二个目标,re-frame 有明确的规划:让程序员获得对程序执行的深度洞察。与其让程序员挣扎于心智模拟,不如让他们在事件发生后观察并与"程序执行的实体化(reification)"互动。这正是工具re-frame-10x的远大目标:以务实的方式,在更大、更工业级的编程任务上兑现 Brett Victor 的第二个目标。
re-frame-10x 的方法论是:把应用处理一个事件时的动态,实体化为数据。程序员应该能观察到处理过程中执行的每一份 ClojureScript 代码,无需再凭空想象代码如何执行——因为你能以具体的形式看见它。虽然目标"完全可以实现",但实现目前只走了一部分路。
值得强调的是,这套实体化愿景的底层基础设施已经存在于当前仓库中:src/re_frame/trace.cljc 定义了完整的 trace 机制——包括按:op-type组织的tag-schema(:event、:event/handler、:event/do-fx、:sub/create、:sub/run、:sub/dispose等),以及register-epoch-cb提供的"每次 dispatch 的完整级联"装配记录(每个 epoch 携带:dispatch-id、:parent-dispatch-id、:app-db/before、:app-db/after、:coeffects、:effects、:interceptors、:sub-runs等),它把"一个事件从派发到 DOM 的全过程"以纯数据的形式交给下游工具。此外,src/re_frame/router.cljc 中的dispatch-and-settle允许开发者派发一个事件并等待其同步级联(含:fx [:dispatch ...]子事件)沉淀完毕——这套"以数据重放动态"的能力,正是"实体化动态"理念在核心库层面的直接体现。
进一步阅读:想从代码层面加深对本主题的理解,可继续阅读 docs/a-loop.md(re-frame 的"数据循环"与六块多米诺)、docs/dominoes-30k.md(带代码片段的逐多米诺讲解)、docs/Interceptors.md(拦截器机制详解)、docs/subscriptions.md(信号图与订阅),以及核心实现文件 src/re_frame/router.cljc、src/re_frame/events.cljc、src/re_frame/interceptor.cljc 和 src/re_frame/core.cljc。
- 前端
- Web框架
【免费下载链接】re-frame
A ClojureScript framework for building user interfaces, leveraging React
相关推荐
Zotero Style插件嵌套标签视图同步失效的排查与修复指南
Zotero Style插件嵌套标签视图同步失效的排查与修复指南 Zotero Style作为一款功能强大的Zotero插件,为用户提供了丰富的文献管理增强功能
桌面应用知识管理科研Ruffle模拟器:5个简单步骤让Flash内容在Chrome中完美运行
Ruffle模拟器:5个简单步骤让Flash内容在Chrome中完美运行 Ruffle是一款基于Rust语言开发的Flash Player模拟器,它能让已经过时
音视频语言运行时在 Substrate FRAME 运行时中嵌入 XQVM:pallet-xqvm 集成指南
在 Substrate FRAME 运行时中嵌入 XQVM:pallet xqvm 集成指南 导读 pallet xqvm 是 xquad 仓库中一个特殊的 F
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考