不可变集合 — 为什么「不许改」成了现代 JS 的铁律,以及三个兑现手段¶
这一章讲一件事: 集合的「可变/不可变」之争——为什么现代前端框架要求你把状态当不可变对待, 以及在 JavaScript 这门一切皆可变的语言里,怎么真正做到。三个兑现手段由轻到重: 拷贝语法(展开/解构)→ 编译期上锁(TypeScript 的
as const/Readonly)→ Immer 的「草稿」魔法(Proxy)。 读完你会明白:const stateCopy = { ...state }为什么救不了嵌套状态,以及 Immer 是怎么骗人的。
1. 先看现象:一个 push,React 视而不见¶
第 11 章为止,所有集合都是可变的(mutable)——内容可以直接改(术语:mutate,变更)。 可变本身没错;错的是「改了就没人知道」。书里用 React 待办列表给出了这个时代的招牌事故:1
const [todos, setTodos] = useState([]);
const addTodo = (newTodo) => {
todos.push(newTodo); // ← 别这么干!
};
push 原地改了数组,数组的引用没变。而 React 判断「状态变了没」比的就是引用:
setTodos 被调用时收到的是同一个数组——「没变化」,不重渲染,新待办人间蒸发。
正确姿势是造一个新数组:2
这一个改动背后是两个深层的理由(书里逐条给了):
- 可检测:setTodos 收到的是新引用,React 立刻知道「状态变了」,触发重渲染; 理论上 React 可以拿 Proxy 包住所有状态来侦测变化,但全体包 Proxy 的性能开销 unacceptable;
- 可回溯:收到新状态时,旧状态原封未动还在手里——并发渲染、批量更新时, React 可能还需要旧值对照;你把旧值原地改了,它的世界就塌了。
「不可变集合,设置于石」的定义由此而来:内容永不被原地修改; 想「改」,只能造一个带改动的新集合,旧集合原样保留。3
代价也当场说清:造新集合比原地改费 CPU 和内存,代码也可能更啰嗦。 这一章剩下的篇幅,全在压这个代价。
主线例子:一份忍者总档案¶
全章的承重对象是一份「应用状态」——忍者名单(数组)、忍者元数据(Map)、最后修改时间(数字)。 一次业务变更要同时改三处:名单加人、元数据加档、时间戳刷新。 要求:全程不可变,旧档案随时可查。三个兑现手段各自走一遍这份档案:4
① 手写拷贝:展开与解构 —— 一层够用,两层翻车(§2)
② 编译期上锁:as const 与 Readonly 家族 —— 防住自己和队友(§3)
③ Immer 草稿:可变写法、不可变结果 —— Proxy 在后面替你拷贝(§4)
2. 手段一:拷贝语法,和它的「浅」字陷阱¶
新增一个元素/属性,展开运算符一行解决:
删除也优雅——解构的 rest(剩余)模式:把不要的那个名字拆出来扔掉,剩下的收进新对象/数组:5
const { omittedKey: _unusedVariable, ...newObject } = oldObject; // 拷一份、少了 omittedKey
const [ _unusedItem, ...newArray ] = oldArray; // 拷一份、少了第一项
(被拆出来的废值要赋给变量,可能触发 no-unused-vars;按惯例把变量名起成 _ 开头,
并在规则配置里忽略 _ 前缀——书给了配置方法的链接。)
然后是这个手段的天花板(书里安排得明明白白):三处变更如果嵌套在状态内部,
{ ...state } 就露馅了——6
// 经典翻车:浅拷贝只拷第一层
const stateCopy = { ...state };
stateCopy.array.push(newItem); // stateCopy.array 和 state.array 是**同一个数组**!
// 两边都被改了——不可变原则当场破产
浅拷贝(shallow copy):展开/解构拷贝的是第一层;嵌套的对象和数组拷进去的是引用—— 新旧状态共享同一个内层集合,改一边等于改两边。
那「深拷贝」呢?structuredClone(state) 或手写递归把每一层都复制——对 deeply nested 状态,
把没改动的大部分也复制了一遍,CPU 和内存双重浪费。「我只想改名单,为什么要复制时间戳?」
这个痛点(想要「只复制改动路径」)正是 Immer(§4)的登场理由。 在见到它之前,先解决另一个问题:就算写法正确,怎么防止有人(尤其是三个月后的自己) 手滑写回原地修改?——交给类型系统。
3. 手段二:TypeScript 的只读类型:编译期的锁¶
as const(注意:是 TypeScript 语法,不是 JavaScript 的 const 关键字):加在字面量后面,
做两件相关联的事:7
const ninjas = ["Kuma", "Hattori", "Yagyu"] as const;
// 不加 as const:类型是 Array<string>——普通可变数组
// 加了:类型是 readonly ["Kuma", "Hattori", "Yagyu"] ——
// ① 不可变(编译期禁 push、禁按下标赋值)
// ② 精确(三个元素各自的名字都成了类型!)
ninjas.push("Waluigi"); // TypeScript 报错 ✓
type NinjaName = (typeof ninjas)[number]; // "Kuma" | "Hattori" | "Yagyu" —— 从值提炼联合类型
第二件能力值得单独鼓掌:as const 把字面量的具体内容保留成类型信息,
于是 (typeof ninjas)[number](「数组所有下标的类型的联合」)精确得到
三个字符串字面量的联合类型——「让类型匹配数组内容」这个常见需求,零样板解决。
对象的版本:keyof typeof someObject(前提也是 as const)。8
只读家族对四种集合的覆盖(书中演示):9
| 可变类型 | 只读对应 | 上锁效果 |
|---|---|---|
Array<T> / T[] |
readonly T[] / ReadonlyArray<T> |
禁 push/下标赋值 |
Map<K,V> |
ReadonlyMap<K,V> |
禁 set/delete |
Set<T> |
ReadonlySet<T> |
禁 add/delete |
| 对象属性 | Readonly<T> |
禁属性赋值 |
as const 只作用于字面量,所以 Map 要用另一条路:声明变量时直接标 ReadonlyMap
(书里的代码演示了从 Object.entries 初始化时的类型断言曲折——Object.entries
会把精确的类型摊平成 string,这是 TypeScript 的已知局限,书里照实演示了绕过姿势)。10
两把锁各自的脾气(书里点出的细则):
- 只读类型只防自己人:它是编译期的,编译后的 JavaScript 里数组照常可变; 你把只读数组传给外部代码,TypeScript 无从知道对方会干什么——(对方若也是 TypeScript, 它的参数应标 readonly,契约互相看得见)。
- 只读 ≠ 不可重新赋值:
let a: ReadonlyArray<number> = [1,2]; a = [3,4];合法—— 换一个新只读值没问题。反过来,把可变值赋给只读变量是非法的:a = mutableArray报错,因为对方还能通过旧引用改它。11
4. 手段三:Immer 的「草稿」——可变写法,不可变结果¶
安装:npm install immer@10。然后见证矛盾合体(书里的完整例子,我们带数走):12
import { enableMapSet, produce } from "immer";
enableMapSet(); // Immer 默认只管对象和数组;Map/Set 要开插件
interface NinjaMetadata { homeIsland: string }
type NinjaState = Readonly<{ // 类型层:全部只读
ninjas: ReadonlyArray<string>;
ninjaMetadata: ReadonlyMap<string, NinjaMetadata>;
lastModified: number;
}>;
const initialState: NinjaState = { // 初始档案
ninjas: ["Kuma", "Hattori", "Yagyu"],
ninjaMetadata: new Map(Object.entries({
Kuma: { homeIsland: "Honshu" },
Hattori: { homeIsland: "Hokkaido" },
})),
lastModified: Date.now(),
};
const getStateWithNewNinja = (
previousState: NinjaState, newNinja: string, meta: NinjaMetadata,
): NinjaState =>
produce(previousState, (state) => { // ← 「看起来在违反规则」的地方
state.ninjas.push(newNinja); // 只读数组上 push?!
state.ninjaMetadata.set(newNinja, meta); // 只读 Map 上 set?!
state.lastModified = Date.now(); // 只读属性上赋值?!
});
const nextState = getStateWithNewNinja(initialState, "Yoshi", { homeIsland: "Yoshi's Island" });
nextState.ninjas.length === initialState.ninjas.length + 1; // 新档案多一人 ✓
nextState.ninjaMetadata.size === initialState.ninjaMetadata.size + 1; // 多一档 ✓
produce 的回调里,state 明明被类型声明为只读,却照改不误——这是 Immer 的核心戏法,机关在 Proxy(第 9 章的全能拦截器,这里终于见到它的正名用途):13
produce(旧状态, 回调):
1. 用 Proxy 包一份旧状态的「草稿(draft)」——看起来像、摸起来也像真的状态
2. 把你的回调跑在草稿上;你对草稿的一切「修改」,被 Proxy 陷阱逐条记下手账
3. 回调返回后,照手账构造:只复制被碰过的路径,改动应用上去——得到全新状态
4. 没碰过的分支与原状态共享引用(零拷贝)——这正是手写深拷贝给不了的效率
回到主线档案带数走:回调碰了三处——ninjas(push)、ninjaMetadata(set)、lastModified(赋值)——
Immer 复制名单数组(新)、复制元数据 Map(新)、复制状态对象本身(新),其余什么也没动;
initialState 完好如初,逐引用对比即可验证。三个断言(书里的)确认新档案多了人、多了档。14
这套戏法的一个细节是「类型层面的善意」:produce 回调里的 state 参数类型,
是把你传的只读类型「解开只读」的可变版本——所以你在草稿上怎么写都过类型检查;
若你在回调里误改了闭包外的 initialState 而不是 state,类型检查器会当场拦下。15
Immer 的代价清单(书里诚实给出):16
- 默认只支持对象和数组;Map/Set 要
enableMapSet()插件; - Proxy 有运行时开销,Immer 永远慢于手写等价拷贝——高频路径要掂量;
- 「魔术」是有学习成本的:团队成员得知道 draft 不是真状态。
尽管如此,原书的定调是:「Immer 是对一个常见问题的优雅解,也是这门语言灵活性的明证。」—— 用第 9 章的拦截器机制,补上这门语言缺失的「结构化不可变」,这就是生态工具对语言本身的反哺。17
5. 作者的判断与证据¶
书内可验的机制:
- React push 不触发更新的现象与
[...todos, newTodo]的对照(框架行为,可实测); - 浅拷贝共享内层引用的翻车(
stateCopy.array.push波及原状态),一行代码可验证; - Immer 的三个断言(名单 +1、元数据 +1)与原状态不动,可直接跑。18
作者立场(当立场读):
- 「不可变集合是默认该追求的」是 React 生态的世界观——不是所有 UI 框架都如此 (如 Solid/Svelte 的响应式 Proxy 方案就不靠引用比较);
- 「Immer 永远慢于手写」是定性判断,没有给出比例数据;
- as const 的「联合类型提炼」被列为 valuable tool,没有展开更多用例(留给读者探索)。
6. 边界与局限¶
- React 只是不可变性的最大雇主,不是唯一雇主:时间旅行调试、乐观更新、状态共享 都受益于不可变——原书只从 React 切入。
Object.freeze这个原生「冻结」API 本章完全没有出现(它存在、浅、非类型感知); TypeScript 只读类型是更常用的现代姿势。structuredClone做深拷贝的标准 API 书中没提(写于 Immer 光环下,手写深拷贝被当成反面教材)。- 10.5 Summary 是原书第 10 章的收尾清单(数组/Map/Set/可变性的要点重列), 无新机制——本拆解已把其要点分散并入各节,无独立对应节。
7. 可带走的(合上这一章,你应该有这八条)¶
- 框架时代的状态必须不可变:React 靠引用比较侦测变化,原地 push = 变化不可见 + 旧状态被毁。
- 新增用展开、删除用解构 rest:一行造出新集合;废值变量起
_前缀并配置 lint 忽略。 - 展开/解构是浅拷贝:嵌套层共享引用——
{ ...state }之后改内层,两边一起变。 - 深拷贝贵在没有选择性:没改的分支也复制一遍,这正是 Immer 要解决的问题。
as const一句两得:字面量变只读 + 内容升格为类型;(typeof arr)[number]提炼联合类型。- 只读类型家族:ReadonlyArray/ReadonlyMap/ReadonlySet/Readonly——编译期上锁,运行时照常可变;防自己人不防外部代码。
- 只读变量可以换新值,但不能接收可变引用——两条方向相反的赋值规则别记混。
- Immer = Proxy 草稿:可变写法、不可变结果,只复制改动路径;代价是运行时开销与一门新「魔术」的学习成本。
8. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 可变的定义与反模式 | 10.4 Mutable and immutable collections | text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:4(搜「mutable collections」) |
| React push 事故 | 同上 | :19(搜「perils of mutability」) · :29(搜「Don't do this」) |
| 不可变集合的定义与代价 | 同上 | :11(搜「set in stone」) · :13(搜「tradeoff」) |
| 两个深层理由 | 同上 | :41(搜「knows it's receiving new state」) · :46(搜「still has access to the old state」) |
| 展开新增与解构删除 | 同上 | :145(搜「spread operator」) · :151(搜「destructuring」) |
| 浅拷贝翻车现场 | 同上 | :182(搜「wouldn't work as intended」) · :185(搜「they're the same array」) |
| as const 两作用 | 同上 | :68(搜「as const」) · :97(搜「preserve the exact contents」) |
| 联合类型提炼 | 同上 | :111(搜「NinjaName type is defined」) |
| ReadonlyMap 的初始化曲折 | 同上 | :80(搜「ReadonlyMap」) · :117(搜「TypeScript's limitations」) |
| 只读赋值的两个方向 | 同上 | :127(搜「doesn't prevent a variable from being reassigned」) |
| Immer 安装与 produce 示例 | 同上 | :191(搜「immer」) · :202(搜「Listing 10.15」) |
| draft 与 Proxy 机制 | text/17-ch10-05-10-5-summary.txt:21(搜「calls this object a draft」) · text/17-ch10-05-10-5-summary.txt:23(搜「Under the hood, the draft is a Proxy」) |
|
| produce 回调的类型善意 | text/17-ch10-05-10-5-summary.txt:26(搜「modified version of the」) |
|
| Immer 的代价 | text/17-ch10-05-10-5-summary.txt:29(搜「some caveats」) |
|
| 「灵活性的明证」定调 | text/17-ch10-05-10-5-summary.txt:34(搜「testament to the」) |
|
| 10.5 总结清单(原书收尾) | 同上 | :37(搜「10.5 Summary」) |
-
出处:「10.4 Mutable and immutable collections」第 18–44 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:27,搜「Don't do this」)。React 待办列表与 push 反模式。 ↩ -
出处:「10.4 Mutable and immutable collections」第 32–39 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:34,搜「setTodos([...todos, newTodo])」)。正确姿势与「immutable operation」的定性。 ↩ -
出处:「10.4 Mutable and immutable collections」第 11–16 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:10,搜「set in stone」)。不可变集合定义;CPU/内存代价见第 13–14 段。 ↩ -
出处:「10.4 Mutable and immutable collections」第 172–181 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:174,搜「Update a field」)。三处嵌套变更的场景设定。 ↩ -
出处:「10.4 Mutable and immutable collections」第 145–170 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:148,搜「const newObject = {...oldObject, newKey: newValue }」)。解构 rest 的删除写法与_前缀约定(边栏第 166–170 段)。 ↩ -
出处:「10.4 Mutable and immutable collections」第 182–190 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:183,搜「wouldn't work as intended」)。浅拷贝翻车的原文解释:「they're the same array」。 ↩ -
出处:「10.4 Mutable and immutable collections」第 63–109 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:70,搜「as const」)。as const 的示例与两个作用(只读 + 精确类型,第 97–101 段)。 ↩ -
出处:「10.4 Mutable and immutable collections」第 111–116 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:112,搜「NinjaName type is defined」)。从数组内容提炼联合类型与 keyof typeof 的对象版本。 ↩ -
出处:「10.4 Mutable and immutable collections」第 80–96 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:80,搜「ReadonlyMap」)。只读 Map 的声明与编译期报错示例。 ↩ -
出处:「10.4 Mutable and immutable collections」第 117–125 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:118,搜「TypeScript's limitations」)。Object.entries 摊平类型与类型断言的绕过。 ↩ -
出处:「10.4 Mutable and immutable collections」第 127–139 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:127,搜「doesn't prevent a variable from being reassigned」)。只读变量可换新值、不可接收可变引用的两条规则。 ↩ -
出处:「10.4 Mutable and immutable collections」第 191–246 段(
text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:202,搜「Listing 10.15」)。produce 完整示例与三处「看似违规」的修改。 ↩ -
出处:「10.5 Summary」第 17–25 段(
text/17-ch10-05-10-5-summary.txt:23,搜「Under the hood, the draft is a Proxy」)。draft 的定义与「拦截所有交互、回调返回后构造拷贝」的机制。 ↩ -
出处:「10.5 Summary」第 1–15 段(
text/17-ch10-05-10-5-summary.txt:2,搜「nextState.ninjas.length」)。新旧档案的断言对照。 ↩ -
出处:「10.5 Summary」第 26–28 段(
text/17-ch10-05-10-5-summary.txt:26,搜「If you look at the TypeScript type for」)。produce 回调参数类型的「解开只读」设计与误改 initialState 被拦。 ↩ -
出处:「10.5 Summary」第 29–33 段(
text/17-ch10-05-10-5-summary.txt:29,搜「some caveats」)。插件需求、Proxy 开销与「always be slower」的诚实代价。 ↩ -
出处:「10.5 Summary」第 34–35 段(
text/17-ch10-05-10-5-summary.txt:34,搜「With all of that said, Immer is a」)。「a clever solution to a common problem, and a testament to the flexibility of the JavaScript language」。 ↩ -
出处:「10.5 Summary」第 1–15 段(
text/17-ch10-05-10-5-summary.txt:2,搜「nextState.ninjas.length」);「10.4 Mutable and immutable collections」第 182–189 段(text/16-ch10-04-10-4-mutable-and-immutable-collections.txt:184,搜「shallow」)。可复现断言与翻车现场的代表处。 ↩