LabHub
学习 学习路径 课程

状态管理 — 自己写一个库

不可变不是口味,是性能装置

在 LabHub 中继续学习

一句话总结

状态库强制进行不可变更新,不是因为纯函数更优雅,而是为了只用一次 === 就判断是否发生了变化

对比图: 只用一次 === 就判断是否发生了变化 · 什么发生了变化,因此需要重新绘制什么? · 全部重新绘制 · 执行深度比较

先看问题

把状态绘制到界面的应用程序,都面临一个问题。

什么发生了变化,因此需要重新绘制什么?

得到答案的方法只有三种。

  1. 全部重新绘制——准确,但速度慢。
  2. 执行深度比较——准确,但状态变大后,比较本身会更昂贵。
  3. 强制让发生变化的内容成为新对象——只需比较一次 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 就能知道“没有变化”。对于原地修改的状态,这种优化无法成立。

useMemoreselect 和 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 外形,而是它如何处理上述六项问题。亲手实现一次之后,文档中的每句话都会显现出具体理由。