面试官最爱问的 const 问题:‘不可变’到底是值不变还是地址不变?一篇搞懂

11,781 阅读7分钟

作为前端开发者,我在学习ES6特性时,总被const的"善变"搞得一头雾水——为什么用const声明的数组还能push元素?为什么基本类型赋值就会报错?直到翻遍MDN文档、对着内存图反复推敲,才终于理清其中的底层逻辑。今天就结合实际代码和学习笔记,和大家聊聊这个让新手又爱又恨的const


一、为什么需要const?从JS的"成长烦恼"说起

时间回到2015年之前,那时候JS开发者只能用var声明变量。这种"上古语法"有两个让人头疼的问题:

  1. 全局污染:用var声明的变量会默认挂在window对象上。想象一下,你在全局作用域声明了一个name变量,结果引入的第三方库也声明了同名变量——这种"变量打架"的情况在大型项目中简直是灾难。

  2. 变量提升的歧义var存在"变量提升"机制,比如这段代码:

    console.log(a); // 输出undefined,而不是报错
    var a = 10;
    

    代码的实际执行顺序是先声明a再赋值,但阅读时很容易产生"变量未声明就使用"的误解,尤其在复杂逻辑中非常影响代码可读性。

ES6(2015年发布)的出现,正是为了解决这些问题。作为ES6的第一个新特性,const(常量声明)和let(块级变量声明)的加入,让JS终于具备了现代编程语言的特性——这也标志着JS从"网页脚本语言"向"企业级开发语言"的转型。


二、const的基础规则:先记住这三个关键点

学习const,首先要明确它的核心规则:

  1. 必须初始化:声明时必须赋值,否则会报错:

    const age; // 报错:Missing initializer in const declaration
    
  2. 块级作用域:和let一样,const声明的变量只在所在的块级作用域({}内)有效。比如:

    if (true) {
      const name = '张三';
    }
    console.log(name); // 报错:name is not defined
    
  3. "不可变"的本质:这是const最容易误解的点——它限制的是变量绑定(即变量指向的内存地址不可变),而不是变量的值不可变。


三、简单类型vs复杂类型:const的"双标"行为

理解const的关键,是搞清楚JS中简单数据类型复杂数据类型的内存存储机制。

1. 简单数据类型:值不可变

JS的简单数据类型包括:NumberStringBooleanUndefinedNullSymbol(ES6新增)、BigInt(ES2020新增)。它们的特点是:直接存储在内存栈中

内存栈的空间小但读取快,像酒店的"小格子储物柜",每个变量对应一个独立的格子。用const声明简单类型时,相当于给这个"格子"上了锁:

const age = 18;
age = 19; // 报错:Assignment to constant variable

因为age指向的栈内存地址被锁定,无法修改存储的值。

2. 复杂数据类型:地址不可变,内容可变

复杂数据类型(如ObjectArrayFunction)的存储方式不同:变量在栈中存储堆内存地址,实际数据存放在内存堆中

内存堆像酒店的"大仓库",空间大但需要通过"地址牌"(栈中的引用)访问。const对复杂类型的限制是:栈中的地址牌不能换,但仓库里的东西可以改

看一段实际代码(来自我的学习示例):

<script>
  const friends = [
    { name: 'hh', home: '江西' },
    { name: 'xx', home: '河南' }
  ];

  // 可以向数组中添加元素(修改堆内存中的内容)
  friends.push({ name: 'zz', home: '湖南' });

  // 可以修改对象属性(同样是修改堆内存)
  friends[0].name = 'HH'; 

  // 但不能重新赋值(更换栈中的地址牌)
  friends = ['新数组']; // 报错:Assignment to constant variable
</script>

这段代码完美展示了const对复杂类型的特性:数组的堆内存地址被锁定,但堆内存中的具体内容(数组元素、对象属性)可以自由修改。

3. 为什么会有这种差异?

根本原因在于内存管理效率:

  • 简单类型体积小(通常8字节以内),直接存栈中能快速访问;
  • 复杂类型体积大(可能包含成百上千个属性),存堆中可以灵活扩展空间,栈中只存地址能节省空间。

const通过"锁定栈内存"的方式,既保证了简单类型的常量特性,又允许复杂类型在合理范围内修改内容——这种设计完美平衡了"数据安全"和"开发灵活性"。


四、从一段代码看const的"兄弟"let:块级作用域的重要性

提到const就不能不提let,它们都是ES6块级作用域的"践行者"。看一个经典的循环示例(来自我的测试代码):

<script>
  // 使用let声明循环变量
  for (let i = 0; i < 10; i++) {
    setTimeout(function () {
      console.log(i); // 依次输出0-9
    }, 1000);
  }
</script>

如果把let换成var,结果会变成输出10次10——因为var没有块级作用域,循环中的i共享同一个全局变量。而let为每次循环创建独立的块级作用域,setTimeout中的回调能正确捕获当前循环的i值。

<script>
//闭包也一样解决问题
for(var i=0;i<10;i++){
        (function(i){
            setTimeout(function(){
                console.log(i)
            },1000)
        })(i)
    }
</script>

闭包能够解决这个问题的原因在于它能够捕获并保留外部函数的变量状态,即使外部函数已经执行完毕。具体来说:

  1. 变量作用域
    for 循环中使用 var 声明的变量 i 是函数作用域的,而不是块级作用域。因此,i 的值在循环结束后会变成 10

  2. 闭包的作用
    通过立即执行函数(IIFE)创建一个闭包,每次循环时都会创建一个新的函数作用域,并将当前的 i 值传递给这个作用域。这样,setTimeout 回调函数中捕获的 i 值就是闭包创建时的值,而不是循环结束后的值。

  3. 代码执行过程

    • 每次循环时,立即执行函数会捕获当前的 i 值。
    • setTimeout 回调函数在 1 秒后执行时,会使用闭包中捕获的 i 值,而不是全局的 i 值。

因此,闭包通过保留每次循环时的 i 值,解决了 var 变量作用域带来的问题,确保 setTimeout 回调函数能够正确输出预期的值。

这种特性对大型应用至关重要:在React组件、Vue的v-for指令中,块级作用域能避免变量污染,让代码更可控。


五、常见误区:const的"不可变"是绝对的吗?

新手最容易犯的错误,是认为const声明的对象"完全不可变"。实际上:

  • 对于数组,可以push/pop/splice,但不能重新赋值为[]
  • 对于对象,可以修改属性值(obj.key = newVal),但不能重新赋值为{}
  • 对于函数,可以修改原型方法,但不能重新赋值为function() {}

如果需要彻底禁止修改对象内容,可以使用ES5的Object.freeze(): 冻结对象,使其不可修改。最高级别不可变性,禁止对象本身及任意直接属性的修改(但不影响它引用的其他对象)

const obj = Object.freeze({ name: '张三' });
obj.name = '李四'; // 赋值无效(非严格模式下静默失败,严格模式报错)

但要注意,Object.freeze()只能冻结对象的第一层属性,嵌套对象仍可修改——这属于更高级的"深度冻结"范畴了。

ps:“深度冻结”:在这个对象调用Object.freeze(),然后遍历它引用的所有对象并在这些对象上调用Object.freeze()。一定要小心无意冻结其他共享对象


六、总结:const的"进化"背后是JS的成长

varconst,看似只是一个关键字的变化,背后却是JS从"玩具语言"到"企业级语言"的蜕变:

  • 开发者友好:块级作用域、常量声明让代码更易读、更安全;
  • 内存管理优化:通过栈/堆分离设计,平衡了性能与灵活性;
  • 生态扩展:ES6之后,TS、React、Vue等工具链的崛起,让JS能驾驭更复杂的业务场景。

下次再遇到const的"善变"行为,不妨打开浏览器的开发者工具,在Memory面板里观察内存地址的变化——你会更深刻地理解:所谓"不可变",不过是JS在内存世界里玩的一场"地址保卫战"。

b04be3ec3168ebbfa6f88c2088384e12.jpg