欢迎来到今天的技术讲座。我们将深入探讨JavaScript中一个看似简单却充满细微差别的关键字——const。它的引入,旨在提升代码的可预测性和稳定性,但其作用常常被误解为赋予变量“不可变性”。今天的核心议题,便是要精确区分const所提供的“只读属性”与编程领域中更广泛的“不可变性”概念。
作为一名前端或后端开发者,你可能每天都在使用const。但你是否真正理解它在幕后是如何工作的?它究竟阻止了什么?又允许了什么?理解这些,不仅能帮助你写出更健壮、更易维护的代码,也是深入理解JavaScript内存管理和数据结构的关键一步。
我们将从const的基础用法开始,逐步深入到它与不同数据类型的交互,特别是对象类型。然后,我们将清晰地界定“只读属性”和“不可变性”的边界,并探讨如何在JavaScript中真正实现不可变性。
一、 const 关键字的诞生与基础作用
在ES2015(ES6)之前,JavaScript只有var一个声明变量的关键字,这导致了变量提升(hoisting)、作用域穿透等一系列问题,使得代码难以预测和维护。为了解决这些问题,ES6引入了let和const。
const,顾名思定,是“constant”(常量)的缩写。它的核心作用是声明一个块级作用域的常量。这里“常量”的含义是:一旦一个变量被const声明并初始化,它的标识符就不能被重新赋值。
让我们看一个简单的例子:
// 示例 1.1: const 的基本用法
const PI = 3.14159;
console.log(PI); // 输出: 3.14159
// 尝试重新赋值,会抛出错误
try {
PI = 3.14; // 这一行会抛出 TypeError
} catch (error) {
console.error(error.name + ": " + error.message); // 输出: TypeError: Assignment to constant variable.
}
const GREETING = "Hello";
// GREETING = "Hi"; // 同样会抛出 TypeError
从这个例子我们可以清晰地看到,const声明的变量不能被重新赋值。这是const最直接、最基础的“只读属性”体现:它确保了变量标识符(binding)在声明周期内始终指向同一个值或同一个内存地址。
关键点:
- 必须初始化:
const声明的变量在声明时必须进行初始化。// const MY_VAR; // SyntaxError: Missing initializer in const declaration - 块级作用域: 像
let一样,const声明的变量具有块级作用域。这意味着它们只在声明它们的代码块内可见。if (true) { const blockScopedVar = "I am in a block"; console.log(blockScopedVar); // 输出: I am in a block } // console.log(blockScopedVar); // ReferenceError: blockScopedVar is not defined - 不可重新赋值 (No Reassignment): 这是
const提供的核心保证。
二、 const 与原始数据类型:看似的“不可变性”
JavaScript的数据类型分为两大类:原始数据类型(Primitives)和对象数据类型(Objects)。理解const在这两类数据上的行为差异,是区分“只读属性”与“不可变性”的关键。
原始数据类型包括:string、number、bigint、boolean、symbol、undefined 和 null。
当const用于声明原始数据类型的变量时,它所体现的行为非常接近于“不可变性”,以至于许多初学者会误认为const赋予了原始值不可变性。
// 示例 2.1: const 与原始数据类型
const myNumber = 100;
// myNumber = 200; // TypeError: Assignment to constant variable.
const myString = "JavaScript";
// myString = "TypeScript"; // TypeError: Assignment to constant variable.
const myBoolean = true;
// myBoolean = false; // TypeError: Assignment to constant variable.
为什么这里会给人一种“不可变性”的错觉呢?
在JavaScript中,原始值本身就是不可变的。当你操作一个原始值变量时,你实际上是在操作它的值。例如,当你有一个字符串"hello",你不能改变这个字符串本身,你只能创建新的字符串,比如"hello" + " world",这会生成一个新的字符串"hello world",而不是修改原有的"hello"。
因此,对于原始数据类型:
const确保变量标识符(myNumber,myString等)始终指向同一个内存地址。- 而该内存地址中存储的原始值本身就是不可变的。
这两者结合起来,使得const声明的原始值变量表现出完全的不可变性:你不能改变变量指向的值,也不能改变值本身。
总结原始类型与const:
const阻止了变量标识符的重新绑定。- 原始值类型本身就不可变。
- 结果是,
const声明的原始值变量在语义上表现为不可变。
三、 const 与对象数据类型:只读属性的真相
这才是区分“只读属性”与“不可变性”的核心所在。对象数据类型包括Object、Array、Function等。
当const用于声明一个对象(包括数组和函数)时,它仍然遵循“不可重新赋值”的原则。然而,这并不意味着该对象本身的内容是不可变的。
const阻止的是变量标识符的重新绑定,而不是变量所指向的内存地址中的对象内容的修改。
让我们通过代码深入理解这一点:
// 示例 3.1: const 与对象类型
const myObject = {
name: "Alice",
age: 30
};
console.log(myObject); // { name: 'Alice', age: 30 }
// 尝试重新赋值整个对象,会抛出错误
try {
myObject = {
name: "Bob",
age: 31
}; // TypeError: Assignment to constant variable.
} catch (error) {
console.error(error.name + ": " + error.message);
}
// 但是,我们可以修改对象内部的属性!
myObject.age = 31; // 允许!
myObject.city = "New York"; // 允许!添加新属性
console.log(myObject); // 输出: { name: 'Alice', age: 31, city: 'New York' }
// 甚至可以删除属性
delete myObject.name; // 允许!
console.log(myObject); // 输出: { age: 31, city: 'New York' }
这个例子清晰地展示了const的限制范围:它阻止了myObject这个变量名指向一个全新的对象,但它不阻止我们修改myObject当前指向的那个对象内部的属性。
我们可以这样理解:const声明的变量myObject就像一个贴在特定“盒子”(内存地址)上的标签。这个标签一旦贴上,就不能撕下来贴到另一个盒子上。但是,这个盒子里的东西(对象的属性和值)是可以随意增删改查的。
对于数组也是如此:
// 示例 3.2: const 与数组
const myArray = [1, 2, 3];
console.log(myArray); // [1, 2, 3]
// 尝试重新赋值整个数组,会抛出错误
try {
myArray = [4, 5, 6]; // TypeError: Assignment to constant variable.
} catch (error) {
console.error(error.name + ": " + error.message);
}
// 但是,我们可以修改数组内部的元素!
myArray.push(4); // 允许!
myArray[0] = 10; // 允许!
myArray.pop(); // 允许!
console.log(myArray); // 输出: [10, 2, 3]
同样,const myArray确保myArray这个变量名始终指向同一个数组对象。但该数组对象本身是可变的,你可以向其中添加、删除或修改元素。
对于函数也是如此:
// 示例 3.3: const 与函数
const myFunction = function(a, b) {
return a + b;
};
console.log(myFunction(1, 2)); // 3
// 尝试重新赋值函数变量,会抛出错误
try {
myFunction = function(a, b) {
return a * b;
}; // TypeError: Assignment to constant variable.
} catch (error) {
console.error(error.name + ": " + error.message);
}
// 虽然不能重新赋值函数变量,但如果函数是作为对象的方法,并且对象本身可变,
// 那么该方法可能会被修改(虽然不常见且不推荐)
const calculator = {
add: function(a, b) { return a + b; },
subtract: function(a, b) { return a - b; }
};
Object.freeze(calculator); // 这里我们将使用 Object.freeze() 来演示不可变性,稍后会详细解释
// calculator.add = function(a, b) { return a * b; }; // 如果没有freeze,这里是允许的
// 如果 freeze 了,这里会报错或静默失败
总结对象类型与const:
const阻止了变量标识符的重新绑定。- 对象值类型本身是可变的。
- 结果是,
const声明的对象变量提供了“只读的引用”,即你不能改变它指向哪个对象,但你可以改变它所指向的那个对象的内容。
四、 只读属性(Read-only Binding)与不可变性(Immutability)的精确区分
现在,我们已经通过具体的例子看到了const在原始类型和对象类型上的不同行为。是时候为“只读属性”和“不可变性”这两个概念提供一个精确的定义,并明确它们之间的区别了。
4.1 只读属性(Read-only Binding)
当一个变量具有“只读属性”时,意味着:
- 变量的绑定(Binding)是不可变的。
- 你不能将该变量重新赋值以指向另一个值或对象。
const关键字所提供的,正是这种“只读绑定”。它保证了变量标识符(例如myObject或myArray)在声明后,将永远指向初始化时所指向的那个内存地址。你无法通过赋值操作来改变这种指向。
const = 只读绑定
4.2 不可变性(Immutability)
当一个值或对象具有“不可变性”时,意味着:
- 一旦被创建,它的内部状态就不能被改变。
- 任何试图修改它的操作,都会导致创建一个新的值或对象,而不是修改原有的。
原始数据类型(如数字、字符串、布尔值)在JavaScript中天生就是不可变的。你不能改变数字5的值,也不能改变字符串"hello"的字符序列。任何看起来像修改的操作,实际上都是在内存中创建了一个新的原始值。
然而,JavaScript中的对象(包括普通对象、数组、函数等)默认是可变的。这意味着在对象被创建后,你可以添加、删除或修改它的属性,而无需创建新的对象。
const 不等于 不可变性
下表总结了它们之间的关键差异:
| 特性 / 概念 | 只读绑定(const提供) |
不可变性(Immutability) |
|---|---|---|
| 作用对象 | 变量标识符(Binding) | 值(Value)或对象(Object)的内部状态 |
| 阻止什么 | 变量被重新赋值 | 值或对象在创建后被修改 |
| 对原始类型 | 变量不能重新指向新的原始值 | 原始值本身不可变 |
| 对对象类型 | 变量不能重新指向新的对象 | 对象内部属性不能被修改 |
| 例子 | const obj = {}; obj = {}; (错误) |
obj.prop = 'new'; (如果对象是不可变的,这将失败) |
| 修改对象内部 | 允许 | 不允许 |
核心理念差异:
const关注的是变量的“容器”:这个容器一旦装了某个东西,就不能再装别的东西了。- 不可变性关注的是“容器里的东西”:这个东西一旦放进去,就不能被改变了。
五、 在 JavaScript 中实现不可变性
既然const本身不能赋予对象不可变性,那么在JavaScript中,我们如何才能实现对象的不可变性呢?实现不可变性对于编写可预测、易测试和并发友好的代码至关重要。
JavaScript提供了几种内置机制和模式,以及第三方库来帮助我们实现不可变性。
5.1 Object.freeze():浅层不可变性
Object.freeze()方法可以冻结一个对象。冻结一个对象可以阻止向对象添加新属性、删除现有属性、修改现有属性的可枚举性、可配置性或可写性,以及修改现有属性的值。换句话说,该对象将变得不可变。
然而,Object.freeze()是浅层的(shallow)。 这意味着它只会冻结对象本身的第一层属性。如果对象中包含其他对象(嵌套对象),那么这些嵌套对象仍然是可变的。
// 示例 5.1: Object.freeze() 的使用
const userProfile = {
name: "Jane Doe",
settings: {
theme: "dark",
notifications: true
},
hobbies: ["reading", "hiking"]
};
// 冻结 userProfile 对象
Object.freeze(userProfile);
console.log("冻结后的对象:", userProfile);
// 尝试修改顶层属性的值 - 失败 (严格模式下抛出 TypeError)
try {
userProfile.name = "John Doe"; // 静默失败 (非严格模式) 或 TypeError (严格模式)
} catch (error) {
console.error(error.name + ": " + error.message); // TypeError: Cannot assign to read only property 'name' of object '#<Object>'
}
// 尝试添加新属性 - 失败 (严格模式下抛出 TypeError)
try {
userProfile.email = "[email protected]"; // 静默失败 (非严格模式) 或 TypeError (严格模式)
} catch (error) {
console.error(error.name + ": " + error.message); // TypeError: Cannot add property email, object is not extensible
}
// 尝试删除属性 - 失败 (严格模式下抛出 TypeError)
try {
delete userProfile.hobbies; // 静默失败 (非严格模式) 或 TypeError (严格模式)
} catch (error) {
console.error(error.name + ": " + error.message); // TypeError: Cannot delete property 'hobbies' of object '#<Object>'
}
console.log("修改尝试后的对象:", userProfile); // 依然是原始状态,name没有改变,email没有添加,hobbies没有删除
// 注意:嵌套对象仍然可变!
userProfile.settings.theme = "light"; // 允许!
userProfile.hobbies.push("swimming"); // 允许!
console.log("修改嵌套属性后的对象:", userProfile);
/*
输出:
{
name: 'Jane Doe',
settings: { theme: 'light', notifications: true },
hobbies: [ 'reading', 'hiking', 'swimming' ]
}
*/
从上面的例子可以看出,Object.freeze()虽然冻结了userProfile的顶层属性,但它的settings对象和hobbies数组仍然可以被修改。
5.2 深度冻结(Deep Freeze)
为了实现真正的不可变性,我们需要一个“深度冻结”的机制,它能递归地遍历对象的所有嵌套属性,并对所有嵌套对象和数组都调用Object.freeze()。
// 示例 5.2: 实现一个深度冻结函数
function deepFreeze(obj) {
// 获取对象的所有属性名
const propNames = Object.getOwnPropertyNames(obj);
// 遍历所有属性
for (const name of propNames) {
const value = obj[name];
// 如果属性值是对象(且不是null),则递归冻结
if (typeof value === 'object' && value !== null) {
deepFreeze(value);
}
}
// 冻结对象本身
return Object.freeze(obj);
}
const deepUserProfile = {
name: "Jane Doe",
settings: {
theme: "dark",
notifications: true
},
hobbies: ["reading", "hiking"]
};
deepFreeze(deepUserProfile);
console.log("深度冻结后的对象:", deepUserProfile);
// 尝试修改顶层属性 - 失败
try {
deepUserProfile.name = "John Doe";
} catch (error) {
console.error(error.name + ": " + error.message);
}
// 尝试修改嵌套属性 - 失败
try {
deepUserProfile.settings.theme = "light";
} catch (error) {
console.error(error.name + ": " + error.message); // TypeError: Cannot assign to read only property 'theme' of object '#<Object>'
}
// 尝试修改嵌套数组元素 - 失败
try {
deepUserProfile.hobbies.push("swimming");
} catch (error) {
console.error(error.name + ": " + error.message); // TypeError: Cannot add property 2, object is not extensible
}
console.log("修改尝试后的深度冻结对象:", deepUserProfile);
/*
输出 (所有修改尝试都会报错):
{
name: 'Jane Doe',
settings: { theme: 'dark', notifications: true },
hobbies: [ 'reading', 'hiking' ]
}
*/
现在,deepUserProfile及其所有嵌套对象和数组都真正地变得不可变了。这种深度冻结在需要严格不可变性的场景下非常有用,但它也有性能开销,因为它需要递归遍历整个对象结构。
5.3 结构共享(Structural Sharing)与复制技术
在实际开发中,尤其是在前端框架(如React/Redux)中,更常见和高效的实现不可变性的方式是结构共享。这意味着当我们想要“修改”一个对象时,我们不是真的去修改它,而是创建一个它的新版本,同时尽可能地复用原对象中未改变的部分。
这种方法避免了深度克隆整个对象(可能非常昂贵),而是只克隆被修改的部分,并将未修改的部分引用到新对象中。
常用技术:
-
对象展开运算符 (
...) 和Object.assign(): 用于创建新对象,合并现有对象的属性。// 示例 5.3.1: 对象复制与更新 const originalUser = { id: 1, name: "Alice", address: { street: "123 Main St", city: "Anytown" }, roles: ["admin", "editor"] }; // 更新 name,同时保留其他属性 const updatedUser = { ...originalUser, name: "Alicia" }; console.log("Original User:", originalUser); console.log("Updated User (name):", updatedUser); console.log("originalUser === updatedUser:", originalUser === updatedUser); // false // 更新 address (需要深度复制,否则 address 仍然是共享引用) const updatedUserAddress = { ...originalUser, address: { ...originalUser.address, // 复制嵌套的 address 对象 city: "Newtown" } }; console.log("Updated User (address):", updatedUserAddress); console.log("originalUser.address === updatedUserAddress.address:", originalUser.address === updatedUserAddress.address); // false console.log("originalUser.roles === updatedUserAddress.roles:", originalUser.roles === updatedUserAddress.roles); // true (roles 数组被共享了) // 使用 Object.assign() 也是类似的效果 const userWithNewEmail = Object.assign({}, originalUser, { email: "[email protected]" }); console.log("User with new email:", userWithNewEmail); -
数组展开运算符 (
...) 和数组方法: 用于创建新数组,避免修改原数组。- 添加元素:
[...originalArray, newElement] - 删除元素:
originalArray.filter(item => item.id !== idToRemove) - 更新元素:
originalArray.map(item => item.id === idToUpdate ? { ...item, newProp: 'value' } : item) slice(),concat()也都是返回新数组。
// 示例 5.3.2: 数组复制与更新 const numbers = [1, 2, 3]; // 添加元素 const newNumbers = [...numbers, 4]; console.log("Original numbers:", numbers); // [1, 2, 3] console.log("New numbers (added):", newNumbers); // [1, 2, 3, 4] // 更新元素 const updatedNumbers = numbers.map(num => num === 2 ? 20 : num); console.log("Updated numbers:", updatedNumbers); // [1, 20, 3] // 删除元素 const filteredNumbers = numbers.filter(num => num !== 2); console.log("Filtered numbers:", filteredNumbers); // [1, 3] - 添加元素:
5.4 不可变数据结构库
对于大型应用或需要更复杂不可变操作的场景,使用专门的不可变数据结构库会更方便和高效。
-
Immutable.js: 由Facebook开发,提供了一系列不可变的数据结构,如
List、Map、Set等。它通过Trie数据结构实现高效的结构共享,性能优秀。// 示例 5.4.1: Immutable.js 示例 (需安装: npm install immutable) // import { Map, List } from 'immutable'; // const user = Map({ // name: 'Alice', // age: 30, // hobbies: List(['reading', 'hiking']) // }); // const updatedUser = user.set('age', 31).update('hobbies', hobbies => hobbies.push('swimming')); // console.log(user.toJS()); // { name: 'Alice', age: 30, hobbies: ['reading', 'hiking'] } // console.log(updatedUser.toJS()); // { name: 'Alice', age: 31, hobbies: ['reading', 'hiking', 'swimming'] } // console.log(user === updatedUser); // false // console.log(user.get('hobbies') === updatedUser.get('hobbies')); // false (因为 hobbies 也被修改了) -
Immer: 这是一个更轻量级的库,它允许你像修改普通JavaScript对象一样修改数据,Immer会在后台为你处理不可变更新,生成一个新的不可变状态。这使得编写不可变代码变得非常直观。
// 示例 5.4.2: Immer 示例 (需安装: npm install immer) // import { produce } from 'immer'; // const baseState = { // name: 'Alice', // age: 30, // hobbies: ['reading', 'hiking'] // }; // const nextState = produce(baseState, draft => { // draft.age = 31; // draft.hobbies.push('swimming'); // }); // console.log(baseState); // { name: 'Alice', age: 30, hobbies: ['reading', 'hiking'] } // console.log(nextState); // { name: 'Alice', age: 31, hobbies: ['reading', 'hiking', 'swimming'] } // console.log(baseState === nextState); // false // console.log(baseState.hobbies === nextState.hobbies); // false (Immer 自动处理了嵌套修改)
这两个库都有各自的优缺点,Immutable.js提供了完整的数据结构,而Immer则更注重提供一种更自然的不可变更新方式。
六、 const 的最佳实践与使用场景
理解了const的真正含义及其与不可变性的区别后,我们如何更好地利用它呢?
1. 优先使用 const:
这几乎是现代JavaScript开发中的黄金法则。除非你明确知道变量需要被重新赋值,否则一律使用const。
- 优点: 提高代码可读性,一眼就能看出这个变量的引用不会改变。减少意外的副作用和bug。
- 什么时候不使用: 只有当你确定变量会在其生命周期内被重新赋值时(例如循环计数器、用户输入状态更新等),才使用
let。几乎永远不要使用var。
2. 结合 const 和不可变模式:
对于对象,const确保了对对象的引用不变。如果你还需要确保对象内容本身不变,则需要结合Object.freeze()(浅层或深层)或结构共享(如展开运算符、Immutable.js/Immer)。
- 配置对象: 声明一个配置对象时,通常希望它在应用运行时保持不变。
const APP_CONFIG = Object.freeze({ API_BASE_URL: "https://api.example.com", TIMEOUT: 5000 }); // APP_CONFIG.TIMEOUT = 10000; // 严格模式下会报错 - 函数参数: 如果函数接收一个对象作为参数,并且不希望修改原始对象,那么在函数内部应该创建该对象的副本进行操作。
function processUser(user) { const newUser = { ...user, status: 'processed' }; // 创建副本 // ...对 newUser 进行操作... return newUser; } const originalUser = { id: 1, name: 'Test' }; const processedUser = processUser(originalUser); console.log(originalUser); // { id: 1, name: 'Test' } - 保持不变 console.log(processedUser); // { id: 1, name: 'Test', status: 'processed' }
3. const 在循环中的表现:
const在for...of和for...in循环中表现良好,因为每次迭代都会创建一个新的块级作用域的绑定。
const numbers = [1, 2, 3];
for (const num of numbers) {
console.log(num); // 1, 2, 3
// num = 10; // TypeError: Assignment to constant variable.
}
const obj = { a: 1, b: 2 };
for (const key in obj) {
console.log(key, obj[key]); // a 1, b 2
// key = 'c'; // TypeError
}
但在传统的for循环中,const无法用于声明迭代变量,因为迭代变量需要被重新赋值。
// for (const i = 0; i < 3; i++) { // TypeError: Assignment to constant variable.
// console.log(i);
// }
for (let i = 0; i < 3; i++) {
console.log(i); // 0, 1, 2
}
七、 总结与展望
通过今天的讲座,我们深入剖析了JavaScript中const关键字的实现细节,并清晰地区分了“只读属性”和“不可变性”这两个概念。const提供的是一个只读的变量绑定,它确保了变量标识符一旦被初始化,就不能再指向另一个值。然而,这并不意味着该变量所指向的对象本身是不可变的。对于对象类型,const变量的内部属性仍然可以被修改。
为了在JavaScript中真正实现不可变性,我们需要借助Object.freeze()(及其深度冻结的变体),或者利用结构共享的模式(如展开运算符、数组方法),甚至可以引入专门的不可变数据结构库(如Immutable.js、Immer)。
理解这些细微之处对于编写高质量、可维护的JavaScript代码至关重要。拥抱const作为变量声明的首选,并结合适当的不可变模式,将显著提升你的代码的健壮性和可预测性。
const 关键字是现代 JavaScript 的基石之一,它赋予了变量绑定不可变性,从而增强了代码的清晰度和安全性。然而,要实现数据的真正不可变性,我们还需要结合其他语言特性和编程范式。深入理解这些概念,是成为一名优秀 JavaScript 开发者的必经之路。