为什么 const 定义的对象可以修改?深度理解栈内存与堆内存的存储差异

各位开发者、技术爱好者,大家好!

今天,我们将深入探讨一个在JavaScript学习和实践中常常引起困惑的话题:为什么使用 const 关键字定义的对象,其内部属性却可以被修改?这个问题初看起来似乎与 const 的“常量”含义相悖,但其背后蕴含着对JavaScript内存管理机制——特别是栈内存与堆内存存储差异的深刻理解。

在我看来,掌握这一点,是迈向更高级JavaScript编程,编写出更健壮、更可预测代码的关键一步。我们将以一场技术讲座的形式,逐步揭开这个“谜团”。


破除迷思:const 究竟意味着什么?

我们从最基础的问题开始:const 到底是什么?在很多编程语言中,const 通常意味着“常量”,即一旦赋值,其值就不可更改。在JavaScript中,const 确实也提供了这种“不可更改”的特性,但其作用的范围和具体机制,对于原始类型和引用类型(对象、数组、函数等)而言,有着本质的区别。

1. const 与原始类型值

首先,让我们看一个简单的例子,使用 const 声明一个原始类型(如数字、字符串、布尔值)。

// 示例 1.1: const 与原始类型
const MAX_VALUE = 100;
console.log(MAX_VALUE); // 输出: 100

// 尝试修改 const 声明的原始类型变量
// MAX_VALUE = 200; // 这行代码会报错:TypeError: Assignment to constant variable.

在这个例子中,当我们尝试重新给 MAX_VALUE 赋值时,JavaScript引擎会立即抛出一个 TypeError。这符合我们对 const 的直观理解:它创建了一个不可变的绑定,一旦 MAX_VALUE 被绑定到值 100,它就不能再被绑定到其他值了。这里的“值”就是原始类型 100 本身。

2. const 与引用类型值(对象)

现在,让我们转向问题的核心:当 const 声明一个对象时会发生什么?

// 示例 1.2: const 与对象
const user = {
  name: "Alice",
  age: 30
};

console.log(user); // 输出: { name: 'Alice', age: 30 }

// 尝试修改 const 声明的对象属性
user.age = 31;
user.city = "New York"; // 甚至可以添加新属性
console.log(user); // 输出: { name: 'Alice', age: 31, city: 'New York' }

// 尝试重新赋值 const 声明的对象变量
// user = { name: "Bob", age: 25 }; // 这行代码会报错:TypeError: Assignment to constant variable.

观察上面的代码,我们发现了一个“奇怪”的现象:

  1. user.age = 31;user.city = "New York"; 这样的操作,成功地修改了 user 对象的属性,并且没有报错。
  2. 然而,当我们尝试 user = { name: "Bob", age: 25 }; 时,却像原始类型一样,抛出了 TypeError

这正是我们需要深入理解的“悖论”:const 声明的对象,其内部属性可以修改,但对象本身却不能被重新赋值。要解释这个现象,我们必须跳出表象,深入到JavaScript的内存管理模型。


内存的舞台:栈内存与堆内存

在大多数编程语言中,内存通常被划分为几个区域,其中最核心且与我们今天主题密切相关的是栈内存(Stack Memory)堆内存(Heap Memory)。理解这两者的差异,是理解 const 行为的关键。

想象一下一个图书馆:

  • 栈内存就像图书馆的前台,那里有许多小抽屉,每个抽屉都有一个编号。这些抽屉用来存放一些小而规整的东西,比如便签条(原始值)或者指向某个书架位置的指引卡片(引用地址)。抽屉里的东西是按顺序存取和清理的,非常高效。
  • 堆内存就像图书馆的广阔书架区,那里存放着各种大小不一的书籍、杂志、文件盒(对象、数组等复杂数据结构)。这些东西没有固定的顺序,可以随时增删改查。要找到一本书,你需要通过前台给的指引卡片去对应的书架位置。

1. 栈内存(Stack Memory)的特性

  • 结构: 是一种后进先出(LIFO – Last In, First Out)的数据结构。就像一叠盘子,最后放上去的盘子最先被拿走。
  • 存储内容:
    • 函数调用上下文(执行栈)。
    • 原始类型的值number, string, boolean, null, undefined, symbol, bigint。它们的值直接存储在栈中。
    • 引用类型变量的指针/地址:对于对象、数组等引用类型,栈中存储的不是对象本身,而是指向堆内存中实际对象数据的内存地址。
  • 分配与回收:
    • 自动分配与回收:当函数调用时,会为其创建一个栈帧,其中包含局部变量。函数执行完毕,其栈帧就会被销毁,变量也随之释放。
    • 固定大小/编译时已知:栈内存通常用于存储大小已知且生命周期较短的数据。
  • 访问速度: 极快,因为其结构规整,内存地址连续。

2. 堆内存(Heap Memory)的特性

  • 结构: 是一种无序的、动态分配的内存区域。
  • 存储内容:
    • 引用类型的值:对象(Object)、数组(Array)、函数(Function)等。这些复杂的数据结构,它们的大小在创建时可能不确定,或者在运行时会发生变化。
  • 分配与回收:
    • 动态分配:程序运行时按需分配内存。
    • 垃圾回收(Garbage Collection):由JavaScript引擎的垃圾回收器负责,当堆中的某个对象不再被任何变量引用时,垃圾回收器会在适当的时候自动将其回收。
  • 访问速度: 相对较慢,因为其无序性,需要通过指针寻址。

3. 内存存储差异总结表格

特性 栈内存(Stack Memory) 堆内存(Heap Memory)
结构 LIFO (后进先出) 无序的、动态的
内容 原始类型值、引用类型变量的内存地址/指针 引用类型(对象、数组、函数等)的实际数据内容
大小 通常较小,固定或编译时已知 较大,动态增长
分配 自动分配,快速 动态分配,相对较慢
回收 自动回收(函数执行完毕即释放) 垃圾回收器自动回收(当无引用时)
访问 快速 相对较慢(通过地址间接访问)
举例 let x = 10; (x和10都在栈中) let obj = {}; (obj在栈中,{}在堆中)

const 悖论的真相:绑定与引用

现在,我们有了栈内存和堆内存的基础知识,就可以彻底解开 const 对象的谜团了。关键在于理解以下两个核心概念:

  1. 变量存储的是什么?
  2. const 究竟限制了什么?

1. 原始类型变量:值直接存储在栈中

当您声明一个原始类型变量时,比如 const MAX_VALUE = 100;

  • 在栈内存中会开辟一个名为 MAX_VALUE 的存储空间(一个“抽屉”)。
  • 100 会直接存储在这个 MAX_VALUE 的存储空间中。
栈内存 (Stack)
+-------------------+
| MAX_VALUE: 100    |  <-- 原始值直接存储
+-------------------+

当您尝试 MAX_VALUE = 200; 时,const 的限制生效了:它不允许您改变 MAX_VALUE 这个“抽屉”里存储的。这个“抽屉”永远只能放 100。所以,会报错。

2. 引用类型变量:栈中存储地址,堆中存储数据

当您声明一个引用类型变量时,比如 const user = { name: "Alice", age: 30 };

  • 第一步:在堆内存中开辟空间。JavaScript引擎会在堆内存中创建一个实际的对象 { name: "Alice", age: 30 }。假设这个对象在堆内存中的地址是 0xABC001
  • 第二步:在栈内存中开辟变量空间。在栈内存中会开辟一个名为 user 的存储空间(一个“抽屉”)。
  • 第三步:将堆内存地址存储到栈变量中。栈中的 user 变量不会存储整个对象,而是存储了指向堆内存中对象的内存地址 0xABC001
栈内存 (Stack)                      堆内存 (Heap)
+-------------------+             +---------------------------------+
| user: 0xABC001    | ----------> | 地址: 0xABC001                  |
+-------------------+             | { name: "Alice", age: 30 }      |  <-- 实际对象数据
                                  +---------------------------------+

现在,我们来分析 const 在这里的行为:

  • const 限制的是什么? const 限制的是栈内存中 user 这个“抽屉”里存储的内容。这意味着 user 这个“抽屉”永远只能存放 0xABC001 这个地址。你不能让 user 指向其他地址。

    • 当您尝试 user = { name: "Bob", age: 25 }; 时,您实际上是想让 user 这个“抽屉”里存放一个新的地址(比如 0xDEF002,指向新创建的 { name: "Bob", age: 25 } 对象)。这违反了 const 的限制,所以会报错。
  • 为什么可以修改对象属性? 当您执行 user.age = 31; 时,您做了什么?

    1. JavaScript引擎首先会查找栈中的 user 变量,获取到它存储的地址 0xABC001
    2. 然后,引擎使用这个地址去堆内存中找到对应的对象。
    3. 最后,它直接修改堆内存中这个对象内部的 age 属性,将其从 30 改为 31

    这个过程并没有改变栈中 user 变量所存储的地址 0xABC001user 变量仍然指向同一个对象,只是这个对象内部的“家具”发生了变化。

// 初始状态
栈内存 (Stack)                      堆内存 (Heap)
+-------------------+             +---------------------------------+
| user: 0xABC001    | ----------> | 地址: 0xABC001                  |
+-------------------+             | { name: "Alice", age: 30 }      |
                                  +---------------------------------+

// 执行 user.age = 31; 之后
栈内存 (Stack)                      堆内存 (Heap)
+-------------------+             +---------------------------------+
| user: 0xABC001    | ----------> | 地址: 0xABC001                  |
+-------------------+             | { name: "Alice", age: 31 }      |  <-- 对象内部属性被修改
                                  +---------------------------------+

// 尝试 user = { name: "Bob" }; (失败)
栈内存 (Stack)                      堆内存 (Heap)
+-------------------+             +---------------------------------+
| user: 0xABC001    | X---------- | 地址: 0xDEF002                  | (新对象,尝试让user指向它)
+-------------------+             | { name: "Bob", age: 25 }        |
 (const 不允许改变)                +---------------------------------+

这就是 const 作用于引用类型的核心机制:它确保了变量与内存地址的绑定关系不变,而不是保证内存地址所指向的对象内容不可变。

3. 数组的行为与对象相同

数组在JavaScript中本质上也是一种特殊的对象,所以 const 对数组的行为与对普通对象的行为是完全一致的。

// 示例 3.1: const 与数组
const numbers = [1, 2, 3];
console.log(numbers); // 输出: [1, 2, 3]

// 可以修改数组元素
numbers[0] = 10;
console.log(numbers); // 输出: [10, 2, 3]

// 可以添加/删除元素
numbers.push(4);
console.log(numbers); // 输出: [10, 2, 3, 4]

// 尝试重新赋值整个数组
// numbers = [5, 6, 7]; // 报错:TypeError: Assignment to constant variable.

这里 numbers 变量在栈中存储的是指向堆中数组实际数据的地址。const 保证这个地址不会改变,但通过这个地址访问到的堆中数组内容是可以被修改的。


深入理解:为什么JavaScript这样设计?

你可能会问,为什么JavaScript要这样设计 const 呢?这背后有几个考量:

  1. 性能与效率:
    • 原始类型: 原始类型的值通常很小,直接存储在栈中可以实现最快的访问速度。每次赋值都是值的完整拷贝。
    • 引用类型: 对象和数组可能非常大,如果每次都将整个对象从堆拷贝到栈(或在栈中完整存储),会极大地消耗内存和性能。因此,在栈中只存储一个固定大小的内存地址(指针)是更高效的策略。这样,无论对象多大,变量本身占用的栈空间都是固定的。
  2. 语义与灵活性:
    • const 的设计旨在防止变量被意外地重新绑定到新的值或对象,这提高了代码的健壮性。例如,你声明了一个配置对象 const config = { ... };,你可能不希望在程序运行过程中 config 突然指向了另一个完全不同的配置对象。
    • 同时,允许修改对象的内部属性,提供了操作复杂数据结构的灵活性。在实际开发中,我们经常需要修改对象或数组的内部状态,而不是替换整个对象。如果 const 连内部属性都禁止修改,那么它的实用性会大打折扣,因为你需要不断创建新的对象来模拟修改,这在某些场景下会变得非常繁琐和低效。

追求真正的“不可变”:实现深度冻结

了解了 const 的真正含义后,如果你确实需要一个完全不可变的对象(即不仅不能重新赋值,其内部属性也不能被修改,甚至嵌套属性也不能被修改),那么 const 本身是不够的。你需要借助其他机制。

1. Object.freeze():浅层冻结

Object.freeze() 是JavaScript提供的一个内置方法,它可以冻结一个对象。冻结后,该对象:

  • 不能添加新属性。
  • 不能删除已有属性。
  • 不能修改已有属性的值。
  • 不能修改属性的枚举性、可配置性、可写性。
// 示例 4.1: Object.freeze()
const person = {
  name: "Charlie",
  details: {
    age: 25,
    city: "London"
  },
  hobbies: ["reading", "coding"]
};

Object.freeze(person);

// 尝试修改顶层属性
// person.name = "David"; // 在严格模式下会报错,非严格模式下静默失败
console.log(person.name); // 输出: Charlie

// 尝试添加新属性
// person.country = "UK"; // 在严格模式下会报错,非严格模式下静默失败
console.log(person.country); // 输出: undefined

// 尝试删除属性
// delete person.details; // 在严格模式下会报错,非严格模式下静默失败
console.log(person.details); // 输出: { age: 25, city: 'London' }

// 重点:Object.freeze() 是浅层冻结!
// 嵌套对象或数组仍然可以被修改
person.details.age = 26;
person.hobbies.push("gaming");
console.log(person.details.age); // 输出: 26 (修改成功)
console.log(person.hobbies);    // 输出: ['reading', 'coding', 'gaming'] (修改成功)

Object.freeze() 的局限性: 它是浅层冻结。这意味着如果对象内部包含其他对象或数组,那些嵌套的引用类型仍然是可变的。在上面的例子中,person 对象被冻结了,但 person.detailsperson.hobbies 这两个嵌套对象/数组本身并没有被冻结,因此它们的内部属性/元素仍然可以被修改。

2. 实现深度冻结(Deep Freeze)

如果需要完全不可变的对象,包括所有嵌套的属性,你需要实现一个递归的深度冻结函数。

// 示例 4.2: 实现深度冻结
function deepFreeze(obj) {
  // 获取对象的所有属性名
  const propNames = Object.getOwnPropertyNames(obj);

  // 遍历所有属性,如果属性值是对象或数组,则递归冻结
  for (const name of propNames) {
    const value = obj[name];

    if (value && typeof value === "object" && !Object.isFrozen(value)) {
      deepFreeze(value);
    }
  }

  // 冻结对象本身
  return Object.freeze(obj);
}

const immutablePerson = {
  name: "Eve",
  details: {
    age: 40,
    address: {
      street: "Main St",
      number: 123
    }
  },
  hobbies: ["swimming", "hiking"]
};

deepFreeze(immutablePerson);

// 尝试修改顶层属性 (失败)
// immutablePerson.name = "Frank"; // 报错或静默失败

// 尝试修改嵌套属性 (失败)
// immutablePerson.details.age = 41; // 报错或静默失败

// 尝试修改更深层嵌套属性 (失败)
// immutablePerson.details.address.street = "Elm St"; // 报错或静默失败

// 尝试修改数组元素 (失败)
// immutablePerson.hobbies.push("cycling"); // 报错或静默失败

console.log(immutablePerson.name); // 输出: Eve
console.log(immutablePerson.details.age); // 输出: 40
console.log(immutablePerson.details.address.street); // 输出: Main St
console.log(immutablePerson.hobbies); // 输出: ['swimming', 'hiking']

通过 deepFreeze 函数,我们现在拥有了一个真正意义上的不可变对象。

3. 其他不可变数据模式

在大型应用中,手动实现深度冻结可能不够灵活或高效。社区发展出了许多模式和库来处理不可变数据:

  • 创建新对象代替修改:
    • 使用展开语法(Spread Syntax):const newObj = { ...oldObj, newProp: 'value' };
    • 使用 Object.assign()const newObj = Object.assign({}, oldObj, { newProp: 'value' });
      这两种方法都会创建一个新对象,而不是修改原始对象。
  • Immutable.js: Facebook开发的一个库,提供了持久化不可变数据结构(List, Map, Set等)。每次操作都会返回一个新的不可变数据结构,而不会修改原始数据。
  • Immer.js: 另一个流行的库,它允许你以“可变”的方式操作数据,但内部会生成一个不可变的新状态,非常方便与React等框架结合使用。

这些工具和模式的出现,都源于对JavaScript const 关键字在引用类型上“浅层”不可变性的理解。


实际应用中的 const

虽然 const 对于对象来说,并没有实现真正的“不可变”,但它仍然是一个非常有用的关键字,并且是现代JavaScript开发中的推荐实践。

  1. 意图表达: 使用 const 明确地告诉代码的读者,这个变量的引用在它的生命周期内不会改变。这是一种重要的代码可读性信号。
  2. 防止意外的重新赋值: 尽管对象内部可变,但 const 仍然能防止你意外地将一个对象变量重新指向另一个完全不同的对象。这避免了许多潜在的逻辑错误。
  3. 安全性: 在某些情况下,防止引用被改变可以增加一层安全性,例如,确保一个配置对象变量始终指向最初加载的配置,而不是某个不相关的对象。
  4. let 的区分:
    • const 用于声明其引用(或值,对于原始类型)在声明后不应改变的变量。
    • let 用于声明其值(或引用)可能在程序执行过程中改变的变量。
    • var 已被 letconst 很大程度上取代,因为其存在变量提升、作用域混乱等问题。

最佳实践: 优先使用 const。只有当你确实需要重新赋值变量时,才使用 let。永远不要使用 var


结语

至此,我们已经深入剖析了 const 关键字在JavaScript中的行为,特别是它对于引用类型(对象、数组)的“浅层”不可变性。这个看似矛盾的现象,其根源在于JavaScript的内存管理机制:栈内存用于存储原始值和引用地址,而堆内存用于存储实际的复杂对象数据。const 关键字限制的是变量在栈中存储的引用地址不能被改变,而不是堆中对象实际内容不能被修改。

理解这一根本区别,不仅能帮助我们消除对 const 的误解,更能引导我们在实际开发中,根据需求选择合适的不可变性策略,无论是使用 Object.freeze() 进行浅层冻结,还是实现深度冻结,亦或是借助专业的不可变数据库。最终目标是编写出更清晰、更可靠、更易于维护的JavaScript代码。

感谢大家的聆听!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注