引言
从ES5的var到ES6引入的let和const,这些变化不仅仅是语法上的改进,更是JavaScript向企业级开发语言迈进的重要标志。本文将深入探讨这三种声明方式的底层原理和区别。
一、JavaScript代码执行机制
1.1 代码执行过程
JavaScript代码的执行过程可以分为以下几个阶段:
-
代码加载阶段
- 代码从硬盘读入内存
- V8引擎(Chrome浏览器的心脏)负责解析和执行代码
-
编译阶段
- 创建代码执行环境
- 进行语法检测
- 准备变量查找规则
- 建立作用域链
-
执行阶段
- 按照代码顺序执行
- 进行变量赋值
- 执行函数调用
1.2 作用域和作用域链
-
作用域:变量查找的规则
- 全局作用域
- 函数作用域
- 块级作用域
-
作用域链:变量查找的路径
- 从当前作用域开始查找
- 逐级向上查找父作用域
- 直到全局作用域
- 如果都没找到则抛出ReferenceError
二、ES5时代的var
2.1 var的特点
var是ES5中唯一的变量声明方式,它具有以下特点:
-
变量提升(Hoisting)
变量提升是JavaScript中的一个重要概念,它指的是JavaScript引擎在执行代码之前,会将变量和函数的声明提升到当前作用域的最顶部。这个过程发生在代码编译阶段,而不是执行阶段。
-
变量声明会被提升到当前作用域的最顶部
// 实际代码 console.log(a); // undefined var a = 1; // JavaScript引擎实际执行的代码 var a; // 声明被提升 console.log(a); // undefined a = 1; // 赋值保持在原位置 -
只有声明会被提升,赋值不会被提升
// 实际代码 console.log(a); // undefined var a = 1; console.log(a); // 1 // 等价于 var a; // 声明提升 console.log(a); // undefined a = 1; // 赋值不提升 console.log(a); // 1 -
函数声明会被整体提升
// 实际代码 showName(); // "函数执行了" console.log(myName); // undefined var myName = 'Trae' function showName() { console.log('函数执行了'); } -
变量提升的优先级
// 函数声明优先于变量声明 console.log(foo); // [Function: foo] var foo = '变量声明'; function foo() { console.log('函数声明'); } -
变量提升的作用域
function test() { console.log(a); // undefined var a = 1; } test(); console.log(a); // ReferenceError: a is not defined // 变量提升只在当前作用域内有效 -
变量提升的注意事项
- 使用var声明的变量会被提升
- 使用let和const声明的变量不会被提升,会形成暂时性死区(TDZ)
- 函数声明会被整体提升
- 函数表达式不会被提升
// 函数表达式不会被提升 sayHi(); // TypeError: sayHi is not a function var sayHi = function() { console.log("Hi"); };
-
-
函数作用域
- 在函数内部声明的变量,外部无法访问
- 没有块级作用域的概念
function test() { var a = 1; if(true) { var b = 2; } console.log(b); // 2,if块中的变量在函数内可访问 } -
可重复声明
- 同一作用域内可以重复声明同名变量
- 后面的声明会覆盖前面的声明
var a = 1; var a = 2; console.log(a); // 2 -
全局变量挂载到window对象
- 在全局作用域使用var声明的变量会成为window对象的属性
- 可能导致全局命名空间污染
var globalVar = 1; console.log(window.globalVar); // 1
2.2 var的局限性
var的局限性主要体现在以下几个方面:
// 变量提升带来的问题
console.log(a); // undefined
var a = 1;
// 作用域问题
for(var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i); // 输出3次3
}, 0);
}
// 重复声明问题
var x = 1;
var x = 2; // 允许重复声明,容易造成代码维护困难
三、ES6的let和const
3.1 let的特性
let是ES6引入的新的变量声明方式,它解决了var的许多问题:
-
块级作用域
- 变量只在声明它的代码块内有效
- 解决了for循环中的变量泄露问题
{ let blockVar = 1; } console.log(blockVar); // ReferenceError // for循环示例 for(let i = 0; i < 3; i++) { setTimeout(() => { console.log(i); // 输出0,1,2 }, 0); } -
暂时性死区(TDZ)
- 在变量声明之前访问变量会抛出ReferenceError
- 从代码块开始到变量声明之间的区域称为暂时性死区
// ReferenceError: Cannot access 'a' before initialization console.log(a); let a = 1; -
不可重复声明
- 同一作用域内不能重复声明同名变量
- 有助于代码维护和调试
let x = 1; let x = 2; // SyntaxError -
不会挂载到window对象
- 全局作用域下声明的变量不会成为window对象的属性
- 避免了全局命名空间污染
let globalLet = 1; console.log(window.globalLet); // undefined
3.2 const的特性
const是ES6引入的常量声明方式,用于声明不可变的变量:
-
声明时必须初始化
- 必须在声明时赋值
- 不能先声明后赋值
const a; // SyntaxError const b = 1; // 正确 -
简单数据类型:值不可改变
- 数值、字符串、布尔值等基本类型
- 尝试修改会抛出TypeError
const num = 1; num = 2; // TypeError -
复杂数据类型:引用地址不可改变,但对象内容可以修改
- 对象、数组等引用类型
- 可以修改对象的属性或数组的元素
const obj = { name: 'test' }; obj.name = 'new name'; // 允许 obj = {}; // TypeError
四、内存分配机制
4.1 内存栈(Stack)
内存栈是JavaScript中用于存储基本类型数据和引用地址的内存区域:
-
连续的内存空间
- 按照先进后出的原则管理内存
- 内存分配和释放由系统自动管理
- 适合存储固定大小的数据
-
访问速度快
- 直接访问内存地址
- 不需要额外的内存管理开销
- 适合频繁访问的数据
-
存储简单数据类型和引用地址
- 存储基本类型值(Number、String、Boolean等)
- 存储引用类型的指针
- 空间有限,不适合存储大量数据
-
空间较小
- 通常只有几MB到几十MB
- 超出限制会导致栈溢出
- 不适合存储大型数据结构
4.2 内存堆(Heap)
内存堆是JavaScript中用于存储复杂数据类型的内存区域:
-
不连续的内存空间
- 动态分配和释放
- 可以存储任意大小的数据
- 内存碎片化问题
-
访问速度相对较慢
- 需要通过引用地址访问
- 需要垃圾回收机制
- 内存分配和释放需要额外开销
-
存储复杂数据类型
- 对象、数组等引用类型
- 函数、闭包等
- 可以存储大量数据
-
空间较大
- 可以动态扩展
- 受系统可用内存限制
- 适合存储大型数据结构
五、变量赋值机制
5.1 简单数据类型
简单数据类型的赋值是值传递,在内存栈中进行:
const a = 1;
// 内存栈中存储值1
// 值不可改变
let b = a;
b = 2;
console.log(a); // 1,a的值不会改变
5.2 复杂数据类型
复杂数据类型的赋值是引用传递,涉及内存栈和内存堆:
const obj = { name: 'test' };
// 内存栈中存储引用地址
// 内存堆中存储对象内容
// 引用地址不可改变,但对象内容可以修改
const arr = [1, 2, 3];
arr.push(4); // 允许修改数组内容
arr = []; // TypeError,不能改变引用地址
5.3 ps:作用域链
《你不知道的JavaScript》(上卷)对作用域链的比喻
六、实际应用建议
-
优先使用
const,除非确定变量需要重新赋值- 提高代码可维护性
- 减少bug出现的可能性
- 符合函数式编程思想
-
需要重新赋值的变量使用
let- 循环计数器
- 需要多次修改的变量
- 临时变量
-
避免使用
var,防止变量污染和提升带来的问题- 使用ESLint等工具强制使用let和const
- 在旧代码中逐步替换var
- 注意兼容性问题
-
在循环中使用
let声明计数器- 避免闭包陷阱
- 每次循环创建新的作用域
- 解决异步问题
-
使用
const声明对象时,注意对象内容仍然可以修改- 使用Object.freeze()冻结对象
- 使用不可变数据结构
- 注意深拷贝和浅拷贝的区别
七、面试常见要点总结
-
变量提升和暂时性死区的区别
- var存在变量提升
- let和const存在暂时性死区
- 函数声明和变量声明的提升规则
-
作用域的区别(函数作用域 vs 块级作用域)
- var只有函数作用域
- let和const有块级作用域
- 作用域链的形成过程
-
内存分配机制(栈和堆)
- 栈和堆的特点
- 内存管理方式
- 垃圾回收机制
-
变量赋值机制(值传递 vs 引用传递)
- 基本类型的值传递
- 引用类型的引用传递
- 深拷贝和浅拷贝
-
const对简单类型和复杂类型的处理差异
- 基本类型的不可变性
- 引用类型的可变性
- 如何实现真正的不可变对象