林子豪的 PKM
← 返回 Blog

re-dash:ClojureDart 里的单向数据流

Flutter 的状态管理可以离开模拟器测试。re-dash 把 re-frame 的六个 domino 搬进 ClojureDart:view 是纯函数,用户行为是数据。

5 min read

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 的官方文档,两边概念是对齐的。