两百行的 store
目标
亲手解决 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 # 문법 확인용 (아무것도 출력 안 해도 됩니다)
步骤
getState、dispatch- 禁止原地修改
subscribe→ 取消订阅函数- 引用相同就不通知
select——派生值靠计算- 记忆化
- 微任务批处理
- 遍历过程中取消订阅的安全性
- 总结 →
09-notes.md
参考
- 不能使用 npm install。 没有网络,也不需要。
- 状态的形状不限。评分器会放入像
{ n: 0, items: [] }这样普通的对象,只确认契约。
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 长得多么不同,这三点都是它们以同样方式解决的问题。