TT Lab
开始
学习 学习路径 课程

状态管理 — 自己写一个库

两百行的 store

在 TT Lab 中继续学习

目标

亲手解决 Redux、Zustand、Jotai 共同要解决的问题。只有一个文件,不到 200 行。

要创建的内容

在 /root/work/state/store.mjs 中导出 createStore。

export function createStore(initial) {
  return { getState, dispatch, subscribe, select }
}
函数 契约
getState() 立即返回当前状态
dispatch(fn) fn(현재)(占位符为当前状态)的返回值就是新状态。不修改原对象
subscribe(l) 返回取消订阅函数
select(sel) 返回 sel(현재)(占位符为当前状态),但如果状态没有变化,就不重新计算

评分方式

评分器会 import 你的 store.mjs,逐条确认契约。既不使用浏览器,也不使用框架。

node --version        # v22
node store.mjs        # 문법 확인용 (아무것도 출력 안 해도 됩니다)

步骤

  1. getState、dispatch
  2. 禁止原地修改
  3. subscribe → 取消订阅函数
  4. 引用相同就不通知
  5. select——派生值靠计算
  6. 记忆化
  7. 微任务批处理
  8. 遍历过程中取消订阅的安全性
  9. 总结 → 09-notes.md

参考

store 的骨架

在 /root/work/state/store.mjs 中导出 createStore(initial)。它返回的对象中必须有 getState() 和 dispatch(fn),并且 dispatch 把 fn(현재상태)(占位符为当前状态)的返回值作为新状态。

mkdir -p /root/work/state。因为是 ESM,所以写成 export function createStore(initial) { ... }。扩展名是 .mjs,所以不需要 package.json。不要在 dispatch 中原地修改状态——下一步会检查这一点。

阻止原地修改

让 dispatch 绝对不要修改传入的状态对象。评分器会在 dispatch 前后比较原对象。

用 Object.freeze 来保护自己也是个好办法——如果不小心修改,会报错,而不是被悄悄忽略。更新状态时,始终使用新对象({...s, n: s.n+1})。

订阅与取消订阅

添加 subscribe(listener),并返回取消订阅函数。取消订阅之后,不能再被调用。

返回值必须是函数。取消订阅无论调用多少次都必须是安全的(调用两次就会删掉别的监听器,是很常见的陷阱)。

没有变化就不通知

像 dispatch(s => s) 这样返回了相同引用时,不要调用监听器。

就是 if (next === prev) return 这一行。如果没有它,状态明明没变,渲染却会运行,而这次渲染如果再次 dispatch,就成了无限循环。

派生状态靠计算

添加 select(selector)。store.select(s => s.items.length) 必须始终以当前状态为基准返回值。不能把派生值存进状态。

你会想在状态里放一个 count 之类的字段,但这样总会有两者错位的时刻。评分器会在只修改 items 之后,检查派生值是否跟着变化。

输入相同就不重新计算

给 select 加上记忆化。状态没有变化时,即使用同一个 selector 询问多次,selector 函数也必须只执行一次。

按 selector 分别记住最后的状态引用和最后的结果(Map 或 WeakMap)。不可变性的价值就体现在这里——状态是不可变的,所以只用一次引用比较就能知道“没有变化”。

一个 tick 内修改三次,渲染也只有一次

把连续的 dispatch 合并进一个微任务。dispatch(a); dispatch(b); dispatch(c) 之后,监听器必须恰好被调用一次,而且这时的状态必须是三次全部反映之后的最终值。

只需要 queueMicrotask 和一个 scheduled 标志。React 18 的 automatic batching 就是这个。注意:getState() 必须与批处理无关,立即给出最新值——不能把 dispatch 本身推迟。

遍历过程中取消订阅也是安全的

即使在监听器内部取消订阅,也不能让其他监听器被跳过。

[...listeners].forEach(...)——遍历副本。虽然只有一行,但如果没有它,就会成为“偶尔有一个没被调用”这种复现率低、难以找到原因的缺陷。

库为什么是这个样子

在 09-notes.md 中写三行以上。不可变性让什么变成了 O(1),存储派生状态会让什么错位,没有批处理会浪费什么。

正文中必须包含 불변(韩文,意为“不可变”)、파생(韩文,意为“派生”)、배치(韩文,意为“批处理”)。无论 Redux、Zustand、Vue 长得多么不同,这三点都是它们以同样方式解决的问题。