JavaScript 和 TypeScript 让开发者极易将对象引用视为“一次性”物品,这非常危险。你创建一个对象,将其传递给函数,存储在缓存中,稍后又用一个全新的实例替换了该变量。语言本身并不会报错。旧的引用仍然存在于程序的其他地方,指向不再是最新的数据。这并不是程序崩溃,而是更糟糕的情况:代码库中两个都认为自己掌握着“事实真相”的部分之间出现了无声的分歧。
防止这种情况的纪律被称为“硬对象引用”(Hard Object References)。它不是一个库,也不是编译器的一项功能,而是一种你在编写代码时必须遵守的契约。
陈旧别名问题 (The Stale Alias Problem)
当一个模块持有某个对象的引用,而另一个模块用一个新对象替换了该对象时,就会发生“陈旧别名”问题。第一个引用在代码层面仍然有效,但它指向的不再是当前的数据。
想象一个典型 Web 应用中的用户记录:
const user = {
name: "Alice",
address: {
city: "Seoul",
country: "KR"
}
};
一个物流组件在早期捕获了地址:
const shippingAddress = user.address;
随后,收到了个人资料更新。一个 reducer 或服务处理器决定替换整个对象:
user.address = { city: "Tokyo", country: "JP" };
此时,user.address 指向东京。但 shippingAddress 仍然指向位于首尔的旧对象。程序没有抛出异常。TypeScript 也很满意,因为类型仍然匹配。UI 可能会在个人资料页面显示更新后的城市,而物流标签却悄无声息地打印出旧地址。只有当用户投诉包裹寄到了错误的国家时,这个 Bug 才会浮出水面。
这种情况之所以发生,是因为 JavaScript 将“身份”(identity)与“值”(value)分离开来。当你用一个新的对象字面量替换对象属性时,你就切断了引用链。旧对象并没有被销毁,它只是变成了“孤儿”。任何仍然持有它的代码都在处理一个“幽灵”。
何谓硬对象引用 (Hard Object References)
规则很简单:可以替换原始值(primitive values),但绝不要替换对象或数组的引用。当新数据到达时,你应该将其拷贝到现有的容器中,而不是更换容器本身。
这需要养成三个具体的习惯。
首先,使用 const 声明对象和数组。这可以消除将顶层变量重新绑定到新实例的诱惑。变量在作用域的生命周期内应当保持固定。
其次,永远不要用一个新创建的对象去替换持有对象或数组的属性。如果你需要更新地址,请修改其内部的属性。
第三,如果你需要清除或重置状态,请清空现有结构,而不是将其丢弃并换成一个新的空对象或空数组。
回到地址的例子,正确的更新方式如下:
user.address.city = "Tokyo";
user.address.country = "JP";
如果传入的数据是部分更新或动态的,请使用 Object.assign 将其写入现有的目标对象中:
Object.assign(user.address, incomingAddressData);
shippingAddress 变量指向内存中完全相同的对象,现在它能立即看到新字段。只有一个唯一的规范对象(canonical object)作为实时的“事实来源”(source of truth)。
何处最为关键
对于一个扁平的配置对象来说,这种纪律似乎有些大材小用。但一旦你的状态增长为一个图(graph)结构,其中多个子系统持有指向重叠节点的指针时,它就变得至关重要。
以富文本编辑器为例。文档模型是一个节点树。选择模型持有对起始节点和结束节点的引用。历史缓冲区持有对上次操作中发生变化的节点的引用。渲染层持有对用于布局测量节点的引用。如果状态管理器因为文本改变而用一个新对象替换了段落节点,那么所有这些子系统现在都持有一个陈旧的别名。选区高亮了错误的区域。历史系统无法正确回退。渲染器崩溃,或者更糟——显示出“幻影光标”。
同样的风险也出现在用户资料中,其嵌套的设置和权限被 UI、访问控制层和自动保存程序引用。它也出现在布局引擎中,父容器会缓存子节点的测量值。它还出现在视觉编辑器和画布工具中,运行时控制器通过引用来跟踪活动实体。在所有这些领域中,组件获取一个对象的句柄,并期望该句柄能始终作为真相的实时视图。
硬对象引用将对象视为一个稳定的地址。屋里的家具可以更换,但门的位置保持不变。任何持有该地址的人都可以走进去,看到当前的布局。
以响应式代替替换
如果你曾使用过 Redux 或类似的不可变状态库,这种模型听起来可能有些反直觉。在那些系统中,通过生成一个新对象来发出变更信号。引用变化本身就是信号。组件通过比较 prevProps.data === nextProps.data 来判断是否需要重新渲染。
“硬对象引用”(Hard Object References)要求你颠覆这一假设。由于引用保持不变,引用相等性无法告诉你数据是否发生了变化。你需要一种不同的方式来广播更新。
在实践中,这意味着要依赖响应式系统、显式观察者或脏标记(dirty flags)。修改 user.address.city 可以触发一个 setter,从而通知订阅者。一个对象可以通过事件总线(event bus)发出变更事件。游戏循环或画布工具可能会设置一个全局脏标记,并在帧末尾重新扫描图结构。由于引用是稳定的,你必须通过其他机制使数据流变得可见。
这种架构上的转变,也是为什么该方法最适合用于复杂的业务前端状态、大型组件局部状态、可视化编辑器、画布工具以及运行时控制器。这些系统本身就已经依赖于细粒度更新、直接变更或命令式 API。在这些系统之上强行引入不可变性,往往会带来过度的分配压力和引用频繁变动(reference churn),却无法换取成比例的代码清晰度。当每一帧都至关重要时,仅仅为了移动一个滑块就分配一个新的对象图是极其浪费的。保持引用不变并修改内部属性,更符合问题的实际处理机制。
落地实践
这一规则中一个被忽视的好处是
