不可变不是口味,是性能装置
一句话总结
状态库强制进行不可变更新,不是因为纯函数更优雅,而是为了只用一次 === 就判断是否发生了变化。
先看问题
把状态绘制到界面的应用程序,都面临一个问题。
什么发生了变化,因此需要重新绘制什么?
得到答案的方法只有三种。
- 全部重新绘制——准确,但速度慢。
- 执行深度比较——准确,但状态变大后,比较本身会更昂贵。
- 强制让发生变化的内容成为新对象——只需比较一次
prev === next。
第三种就是不可变性。只要约定绝不原地修改状态,一次引用比较就能完成变更检测。 复杂度为 O(1)。
// ❌ 제자리 변경 — 참조가 그대로라 아무도 눈치채지 못한다
state.items.push(x)
// ✅ 새 배열 — 참조가 달라진다
state = { ...state, items: [...state.items, x] }
原地修改的症状是“界面偶尔不更新”。 数据正确,渲染却没有发生。这是最难查找原因的一类错误。
订阅必须能够取消
const unsub = store.subscribe(render)
// ... 컴포넌트가 사라질 때
unsub()
如果不取消,已经消失的界面仍会继续渲染。 反复打开和关闭列表后,监听器不断累积,一次点击可能触发数百次渲染。React 让 useEffect 返回清理函数、Vue 提供 onUnmounted,原因就在这里。
创建订阅与取消订阅的代码,必须在同一个界面中可见。 如果相距太远,必然会遗漏其中一项。
没有变化就不要通知
dispatch(s => s) // 같은 참조를 돌려줬다
此时不应调用监听器。否则会产生“状态没变却仍在渲染”的浪费;如果这次渲染再次 dispatch,就会形成无限循环。
不要保存派生状态
这是状态设计中最常见的错误。
// ❌ 두 개의 진실
{ items: [...], count: 3 }
// ✅ 하나의 진실 + 계산
{ items: [...] }
const count = items.length
一旦保存,两者就必然会在某个时刻不一致。 某处只修改 items,却忘记修改 count。该错误表现为“数字偶尔不正确”,而且无法稳定复现。
React 文档明确要求不要通过 useEffect 同步派生状态,也是同一原因。如果能够计算,就直接计算。
如果计算成本很高怎么办
使用记忆化。输入没有变化时,直接返回上次结果。
function memo(fn) {
let lastArg, lastOut, has = false
return arg => {
if (has && arg === lastArg) return lastOut // 참조 비교 하나
lastArg = arg; lastOut = fn(arg); has = true
return lastOut
}
}
不可变性在这里再次体现价值。输入保持不可变时,只需 arg === lastArg 就能知道“没有变化”。对于原地修改的状态,这种优化无法成立。
useMemo、reselect 和 Vue 的 computed,本质上都是这三行逻辑。
批处理——一个时刻修改多次,也只渲染一次
dispatch(a); dispatch(b); dispatch(c) // 리스너는 몇 번 불려야 하나?
如果调用三次,界面就会渲染三次。中间两种状态用户根本没有机会看到。因此,状态库会把多次修改合并到一个微任务中。
let scheduled = false
function notify() {
if (scheduled) return
scheduled = true
queueMicrotask(() => { scheduled = false; listeners.forEach(l => l()) })
}
React 18 的 automatic batching 就是这种机制。在此之前,事件处理器之外,例如 setTimeout 内部的 setState 不会被批处理,因此会多次渲染。
遍历期间取消订阅时
在监听器内部调用 unsub() 很常见,例如只监听一次后就停止。如果直接遍历数组并修改原数组,下一个监听器会被跳过。
[...listeners].forEach(l => l()) // 복사본을 순회한다
虽然只有一行,但缺少它就会出现“偶尔有一个监听器没有被调用”的错误。由于复现概率低,查找原因可能耗费数天。
生产现场——选择状态库时
更换状态库的原因,通常不是“API 更漂亮”,而是遇到了上述六项问题之一。但如果迁移之后,保存派生值或不取消订阅的习惯仍然保留,同样的错误也会原样跟过来。这不是更换工具就能消失的问题。
选择状态库的标准不是 API 外形,而是它如何处理上述六项问题。亲手实现一次之后,文档中的每句话都会显现出具体理由。