JavaScript `const` 关键字的实现:只读属性与不可变性的区别

欢迎来到今天的技术讲座。我们将深入探讨JavaScript中一个看似简单却充满细微差别的关键字——const。它的引入,旨在提升代码的可预测性和稳定性,但其作用常常被误解为赋予变量“不可变性”。今天的核心议题,便是要精确区分const所提供的“只读属性”与编程领域中更广泛的“不可变性”概念。

作为一名前端或后端开发者,你可能每天都在使用const。但你是否真正理解它在幕后是如何工作的?它究竟阻止了什么?又允许了什么?理解这些,不仅能帮助你写出更健壮、更易维护的代码,也是深入理解JavaScript内存管理和数据结构的关键一步。

我们将从const的基础用法开始,逐步深入到它与不同数据类型的交互,特别是对象类型。然后,我们将清晰地界定“只读属性”和“不可变性”的边界,并探讨如何在JavaScript中真正实现不可变性。


一、 const 关键字的诞生与基础作用

在ES2015(ES6)之前,JavaScript只有var一个声明变量的关键字,这导致了变量提升(hoisting)、作用域穿透等一系列问题,使得代码难以预测和维护。为了解决这些问题,ES6引入了letconst

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在这两类数据上的行为差异,是区分“只读属性”与“不可变性”的关键。

原始数据类型包括:stringnumberbigintbooleansymbolundefinednull

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"

因此,对于原始数据类型:

  1. const确保变量标识符(myNumber, myString等)始终指向同一个内存地址。
  2. 而该内存地址中存储的原始值本身就是不可变的。

这两者结合起来,使得const声明的原始值变量表现出完全的不可变性:你不能改变变量指向的值,也不能改变值本身。

总结原始类型与const:

  • const 阻止了变量标识符的重新绑定。
  • 原始值类型本身就不可变。
  • 结果是,const声明的原始值变量在语义上表现为不可变。

三、 const 与对象数据类型:只读属性的真相

这才是区分“只读属性”与“不可变性”的核心所在。对象数据类型包括ObjectArrayFunction等。

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关键字所提供的,正是这种“只读绑定”。它保证了变量标识符(例如myObjectmyArray)在声明后,将永远指向初始化时所指向的那个内存地址。你无法通过赋值操作来改变这种指向。

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)中,更常见和高效的实现不可变性的方式是结构共享。这意味着当我们想要“修改”一个对象时,我们不是真的去修改它,而是创建一个它的新版本,同时尽可能地复用原对象中未改变的部分。

这种方法避免了深度克隆整个对象(可能非常昂贵),而是只克隆被修改的部分,并将未修改的部分引用到新对象中。

常用技术:

  1. 对象展开运算符 (...) 和 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);
  2. 数组展开运算符 (...) 和数组方法: 用于创建新数组,避免修改原数组。

    • 添加元素: [...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开发,提供了一系列不可变的数据结构,如ListMapSet等。它通过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 在循环中的表现:
constfor...offor...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 开发者的必经之路。

发表回复

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