你追踪了一个 bug,穿过了三个文件,最后发现竟然是一个无害的辅助函数重命名了一个用户。你明明没有动过原始数据。或者说,你以为你没动。在 JavaScript 中,将一个变量赋值给另一个变量并不总是意味着你所预期的结果。该语言将数据分为两种存储策略,而忘记了当前正在使用哪一种,正是那些隐蔽的突变(mutations)悄然潜入生产代码的原因。

为了防止这种情况,你需要像引擎那样准确地理解原始值(primitive values)与对象(objects)之间的区别。

原始值:实际的副本

原始值是一个单一、不可分割的数据。它不能被分解为更小的 JavaScript 值。该语言定义了七种原始类型:String、Number、Boolean、Undefined、Null、Symbol 和 BigInt。

因为原始值是原子性的,它们通常直接存储在变量绑定中。当你将一个原始变量复制到另一个变量时,引擎会复制实际的数据。每个变量在内存中都拥有自己独立的存储空间。

let a = "Alina";
let b = a;
b = "Ali";

console.log(a); // "Alina"
console.log(b); // "Ali"

在这里,a 保持不变。重新为 b 赋值创建了一个全新的值并让 b 指向它,而 a 仍然持有其原始字符串。这种行为被称为“按值复制”(copy by value)。对于数字、布尔值、Symbol 以及其他原始类型家族,其工作原理完全相同。你可以将它们传递给函数、重新赋值或返回它们,而无需担心对同级变量产生副作用。

对象:共享地址

对象则不同。对象是一个复合容器,将多个数据项组合在一起。这一类别包括普通对象、数组、函数、日期以及所有其他非原始类型。由于这些结构可能会变得庞大且具有嵌套性,JavaScript 不会将整个对象存储在变量中。相反,变量持有的是一个引用,本质上是一个指向存储在别处的实际数据的内存地址。

当你将一个对象赋值给一个新变量时,引擎复制的是地址,而不是对象本身。现在,两个变量指向的是同一间“房子”。

const user = { name: "Alina" };
const copy = user;
copy.name = "Ali";

console.log(user.name); // "Ali"

修改 copy.name 也会改变 user.name,因为这两个名称都指向同一个底层对象。这就是“按引用复制”(copy by reference)。同样的意外也会出现在数组中:

const scores = [82, 91, 74];
const backup = scores;
backup.push(88);

console.log(scores); // [82, 91, 74, 88]

内存中只有一个数组。scoresbackup 仅仅是两个指向它的标识。

const 关键字增加了一层困惑。使用 const 声明对象会锁定变量绑定,使其无法指向新的地址,但它并不会锁定对象本身。

const settings = { theme: "dark" };
settings.theme = "light";        // Works perfectly.
settings = { theme: "dark" };    // TypeError

开发者通常期望 const 能保证不可变性(immutability)。事实并非如此。它仅防止引用的重新赋值。如果你想让内容保持不变,必须有目的地进行复制。

真正的 Bug 在哪里

引用相关的 bug 很少表现为明显的变量重新赋值。它们往往隐藏在函数调用之中。

function addTimestamp(record) {
  record.timestamp = Date.now();
  return record;
}

const original = { id: 1 };
addTimestamp(original);

console.log(original.timestamp); // A number now exists here. Oops.

参数 record 接收的是引用的副本。函数内部的任何属性变更都会直接写入调用者的对象中。这个函数看起来像是一个简单的转换操作,却在作用域边界之间泄露了状态。

这种模式在 React 等 UI 框架中尤其令人头疼,因为这些框架的状态更新依赖于