re-dash:ClojureDart 里的单向数据流
Flutter 的状态管理可以离开模拟器测试。re-dash 把 re-frame 的六个 domino 搬进 ClojureDart:view 是纯函数,用户行为是数据。
Flutter 的状态如果散在 widget 树里之后,逻辑想测一下都得起 widget tester。我的两个 app 都用了 re-dash——re-frame 思想在 ClojureDart 的移植。view 是纯函数,用户行为是数据,副作用被推到系统边缘。
re-dash 的核心概念有6个
1. Event dispatch:用户行为是数据
在主流的很多 UI 框架/库里面,用户操作一般对应一个回调函数,例如 React 的 setState。最简单的 counter 例子:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
count is {count}
</button>
);
}用户点击了按钮,于是 state 变成了原来的 count + 1。onClick 里这个闭包其实揉着两件相关的事情:
1. 用户的意图是什么(让 count 加一)
2. 这个意图要导致 state 怎么变化(count 变成 count + 1)
这两件事是可以拆开测试的。上面的写法里它们揉在同一个闭包里,想验证"加一"这个逻辑,得把组件渲染出来、找到按钮、触发点击。拆开之后——意图单独成为一份数据,state 变化单独成为一个函数——两件事都可以离开 UI 直接验证。
event dispatch 的核心就是,不直接操作 state,而是发出一个 value 描述用户意图,这样单测可以验证是否发出了正确的意图。counter 在 re-dash 里就是:
(rd/dispatch [::increment])对比上面 React 的写法:这里没有 count + 1,只有"用户想加一"这个意图本身。意图是数据,意味着它可以被打印、被记录、被重放、被测试构造——验证"点击发出了正确的意图",在测试里就是断言一个向量字面量,不需要模拟一次真实的点击。
counter 适合把概念讲清楚,但太玩具了。后面的章节换一个真实一点的场景:我的植物养护 app 里,用户可以给植物记养护日志,比如浇水。植物的 id 是 "PL-001",日志挂在植物的 :logs 下面。
2. Event handling:handler 是纯函数
接着上面的故事:意图被描述成数据之后,剩下的一半是"这个意图要导致 state 怎么变化"。
f(意图, 旧状态) = 新状态
在 re-dash 里,这后半部分就是一个纯函数:
(rd/reg-event-db
::water-plant
(fn [db [_ plant-id]]
(update-in db [:plants plant-id :logs] (fnil conj [])
{:id (str plant-id "-log-1")
:action "浇水"})))handler 收到两个输入:当前的 db,和用户意图(event);输出是新的 db。没有 IO,没有隐藏输入——同样的 db 和 event,必然得到同样的新 db。前面拆出来的第 2 件事,就这样完整地落在了一个纯函数上:给它一个 db 和一个 event,断言返回的新 db,这就是单元测试的全部。
3. Effect handling:副作用关在边缘
不是所有用户意图都只靠改 db 就能完成。"浇水"要改 state,但"把这条记录存下来"要碰存储,"加载远程数据"要发网络请求,"三秒后刷新"要起定时器——这些意图天然带着副作用。
这里也是一样的套路, 前面是把用户意图, 和实际执行状态更新分开. 这里先返回一个 value 描述副作用是什么, 单测的时候可以 assert 这个 value. 把副作用如何产生和如何执行分开测.
re-dash 的处理方式是让 handler 继续返回数据:
reg-event-fx 类似 reg-event-db, 但是返回值里面多了 :db 和其他 effect. 其中 :db 是描述状态要怎么变化
effect 的描述是类似 ::persist-logs [...] 后面的参数描述实际要 log 的内容, ::network-request [...] 后面描述网络请求的内容.
(rd/reg-event-fx
::water-plant-and-save
(fn [{:keys [db]} [_ plant-id]]
{:db ...
::persist-logs [...]})) ;; <== 返回效果描述,不是真的执行真正的执行发生在 effect handler 里,::persist-logs 对应的实现才是唯一碰存储的地方:
(rd/reg-fx
::persist-logs
(fn [entries]
(storage/save-logs! entries))) ;; <== 唯一允许 IO 的地方内置 effect 有 :db、:fx、:dispatch、:dispatch-later,够覆盖大多数场景;网络请求、存储、定时器都收敛到 reg-fx。想知道 app 有哪些副作用?看 reg-fx 注册表就是全部清单。
4. Subscription:派生状态
view 需要的大多不是原始状态,而是派生状态。reg-sub 注册查询,内部用 ClojureDart 的 Cells 做响应式,只有受影响的订阅会重算:
(rd/reg-sub
::logs
(fn [db [_ plant-id]]
(get-in db [:plants plant-id :logs])))订阅可以带参数、可以层叠(一个订阅建立在另一个订阅之上,形成 signal graph),view 永远只问自己需要的那份数据。
5. View:纯函数
view 这一层只剩两件事:订阅数据、渲染。widget 里没有任何对全局状态的直接引用:
(f/widget
:watch [logs (rd/subscribe [::model/logs "PL-001"])]
(m/Text (str "浇水记录:" (count logs) " 次")))输入是订阅的值,输出是 widget 树。同样的输入必然渲染出同样的界面——这就是"纯"的含义。
6. Canvas
Flutter 负责把这棵树画到屏幕上。框架的工作到此为止。
于是,测试长这样
六个 domino 串起来之后,测试的故事就顺理成章了:handler 是纯函数,直接调;event 是数据,直接写。
但实践中还可以更进一步:把核心逻辑从 handler 里再抽出来,变成独立的纯函数,测试连 re-dash 都不用 require:
(defn add-todo-plan
[db text now]
(let [todo {:id (str "todo-" now)
:text text
:done? false}]
{:db (update db :todos (fnil conj []) todo)
:created [todo]}))
(rd/reg-event-fx
::add-todo
(fn [{:keys [db]} [_ text]]
(let [now (.-millisecondsSinceEpoch (DateTime.now))
{:keys [db created]} (add-todo-plan db text now)]
{:db db
::persist-todos created})))handler 只剩两件不纯的事:取当前时间、把 plan 的结果翻译成效果描述。所有的领域逻辑都在 add-todo-plan 里,测试它是普通的单元测试:
(deftest add-todo-plan-appends-a-normalized-todo
(let [{:keys [db created]}
(model/add-todo-plan {:todos []} "写 re-dash 文章" 1234)]
(is (= [{:id "todo-1234" :text "写 re-dash 文章" :done? false}]
(:todos db)))
(is (= ["todo-1234"] (mapv :id created)))))这个测试里没有 mock,没有 widget tester,没有 pump。
这不是玩具规模才有的奢侈。我的 plant journal app 里,"给选中的几棵植物记一条养护日志"就是这样测的,生产代码里的真实测试:
(deftest record-entries-plan-adds-one-log-per-selected-plant
(let [db {:plants [{:id "plant-1" :logs [{:id "old-1"}]}
{:id "plant-2" :logs []}
{:id "plant-3" :logs [{:id "old-3"}]}]}
{:keys [db entries]}
(model/record-entries-plan
db
{:plant-ids ["plant-1" "plant-3"]
:kind :care
:action-label "浇水"
:time "今天 09:42"
:note ""
:occurred-at 1234
:id-suffix 5678
:media-ids ["media-1"]
:media-metadata [{:id "media-1" :source :camera :captured-at 1200}]})]
(testing "one independently persisted entry is planned per selected plant"
(is (= ["plant-1-log-5678" "plant-3-log-5678"]
(mapv :id entries))))
(testing "selected plants receive their own entry and unselected plants stay unchanged"
(is (= ["plant-1-log-5678" "old-1"]
(mapv :id (get-in db [:plants 0 :logs]))))
(is (= [] (get-in db [:plants 1 :logs]))))))多选植物、默认文案、照片关联,全是普通的 is (= ...)。这个思路和 Testing UI with Pure Functions 一脉相承:UI 模块的大部分逻辑根本不需要 UI 在场。
三个工程注意点
- 注册代码会被 tree-shake。Dart 编译器会把 namespace 顶层的 reg-* 调用当成无用代码摇掉,所以所有注册要包在一个 register! 函数里,从 main 调用。代价是 model namespace 的改动需要 hot restart 而不是 hot reload(view 的热重载不受影响)。
- widget 局部状态别进 app-db。输入框的光标、临时的展开收起,用本地 atom 就好,全局状态只放真正跨页面共享的东西。
- Flows 还是 alpha。更复杂的派生计算管道可以关注,但生产代码先别押上去。
什么时候它是对的
如果你认同单向数据流,想让逻辑离开 widget 树、被普通的单元测试覆盖,re-dash 在 ClojureDart 里就是现成的答案,API 和 re-frame 几乎一致,迁移成本很低。
代码和 sample(counter、fetch、signals、coeffects)都在 re-dash 仓库;概念更系统的讲解可以直接看 re-frame 的官方文档,两边概念是对齐的。