JavaScript变量声明:var、let、const的区别与最佳实践
1. 从一段“诡异”的代码说起:为什么我们需要let和const
如果你写过一段时间的 JavaScript,尤其是经历过 ES5 及之前的时代,那你一定对var这个关键字又爱又恨。爱的是它简单,声明变量全靠它;恨的是它带来的种种“灵异”现象,常常让代码行为超出你的预期。我们先来看一段经典的、让无数新手掉坑的代码:
for (var i = 0; i < 5; i++) { setTimeout(function() { console.log(i); }, 100); }请问,这段代码会输出什么?如果你的直觉是0, 1, 2, 3, 4,那么恭喜你,你掉进了var的第一个大坑。实际输出是五个5。为什么?因为var声明的i是函数作用域(或者叫全局作用域),而不是块级作用域。整个循环结束后,i的值变成了5,而setTimeout中的回调函数在循环结束后才执行,它们访问的是同一个、已经变成5的i。
这就是 ES6(ES2015)引入let和const最直接的动机之一:提供块级作用域的变量声明,让变量的生命周期和可见性限制在{}花括号之内,从而写出更可预测、更安全的代码。今天,我们就来彻底掰扯清楚var、let、const这三兄弟的特点、区别,以及在实际项目中到底该怎么选。这不仅仅是语法差异,更是编程思维和代码质量的一次升级。
2.var的“遗产”:函数作用域与变量提升
要理解let和const带来的革新,必须先摸透var的老底子。它的行为特性是历史遗留问题,但在维护老代码或理解某些“奇怪”现象时至关重要。
2.1 核心特点:函数作用域与“变量提升”
var声明的变量,其作用域被限制在声明它的函数内部。如果不在任何函数内声明,那就是全局变量。注意,是函数,不是代码块(如if、for、while的{})。这就是开头那个例子问题的根源:for循环的{}关不住var i。
更“神奇”的是变量提升。JavaScript 引擎在执行代码前会有一个“编译”阶段,在这个阶段,它会找到所有var声明,并把它们“提升”到当前作用域(函数或全局)的顶部。但提升的仅仅是声明,赋值操作会留在原地。
console.log(myVar); // 输出:undefined,而不是报错 var myVar = 10; console.log(myVar); // 输出:10上面的代码在引擎眼里相当于:
var myVar; // 声明被提升到作用域顶部 console.log(myVar); // 此时 myVar 已声明但未赋值,值为 undefined myVar = 10; // 赋值留在原地 console.log(myVar); // 10这种机制常常导致一个误区:你以为变量在声明前不存在,实际上它存在,只是值为undefined。这容易引发难以察觉的 Bug,比如在赋值前就使用了变量,得到的是undefined而非预期的报错。
2.2 可以重复声明:一个潜在的混乱之源
var允许你在同一作用域内多次声明同一个变量,后面的声明不会覆盖前面的,但如果伴有初始化,则会覆盖值。
var x = 1; var x = 2; // 允许,不会报错 console.log(x); // 输出:2这个特性在大型项目或多人协作中是个噩梦。你可能会无意中在某个深层函数里重新声明了一个全局变量,导致其他依赖该变量的模块行为异常,而且这种错误静默发生,极难排查。
2.3 在全局作用域声明会成为全局对象的属性
在浏览器环境中,全局作用域下用var声明的变量,会自动成为window对象的属性。
var globalVar = 'I am global'; console.log(window.globalVar); // 输出:'I am global'这可能会造成全局命名空间污染,与其他脚本库的变量发生冲突。
注意:虽然
var有这么多“坑”,但你必须了解它,因为海量的遗留代码库还在使用它。在维护老项目时,盲目地将所有var改为let/const可能会引入意想不到的副作用,需要结合具体代码逻辑谨慎处理。
3.let的革新:块级作用域与暂时性死区
let是 ES6 引入的用于声明变量的关键字,旨在解决var的主要缺陷。它的设计更加严谨和直观。
3.1 核心特点:块级作用域
let声明的变量,其作用域被限制在声明它的代码块内。这个“块”指的是任何用{}包裹起来的语句,比如if、for、while、switch,或者单独的一个{}。
用let重写开头的例子,问题迎刃而解:
for (let i = 0; i < 5; i++) { setTimeout(function() { console.log(i); // 输出:0, 1, 2, 3, 4 }, 100); }这里,每次循环迭代都会创建一个新的块级作用域,每个i都是这个新作用域内的独立变量。setTimeout回调捕获的是各自迭代时的i值,因此输出正确。
3.2 暂时性死区:更严格的声明前访问限制
let也有“提升”,但机制与var完全不同,这引入了“暂时性死区”的概念。从代码块开始到let声明语句执行之前的这个区域,称为该变量的暂时性死区。在 TDZ 内访问该变量会直接抛出一个ReferenceError。
console.log(myLet); // ReferenceError: Cannot access 'myLet' before initialization let myLet = 10;这比var的undefined行为要好得多,因为它将潜在的错误从运行时逻辑错误(得到undefined)提前到了语法/解析阶段(直接报错),更有利于早期发现和修复问题。
3.3 禁止重复声明
在同一作用域内,let不允许重复声明同一个变量。
let y = 1; let y = 2; // SyntaxError: Identifier 'y' has already been declared这强制了代码的清晰性,避免了因重复声明导致的意外覆盖。
3.4 全局声明不会成为全局对象的属性
在全局作用域下用let声明的变量,不会自动成为window(或global)对象的属性。
let letGlobal = 'I am let global'; console.log(window.letGlobal); // 输出:undefined (在浏览器中) console.log(letGlobal); // 输出:'I am let global'这有助于减少全局命名空间的污染。
4.const的坚守:常量与不可变绑定
const也是 ES6 引入的,用于声明常量。很多人误以为const代表“值不可变”,其实更准确的说法是“绑定不可变”。
4.1 核心特点:声明时必须初始化,且绑定不可变
const声明的常量必须在声明的同时进行初始化,且之后不能再被重新赋值。
const PI = 3.14159; PI = 3.14; // TypeError: Assignment to constant variable. const MY_CONST; // SyntaxError: Missing initializer in const declaration这里的“绑定不可变”,指的是变量名与那个初始值之间的“引用”关系被锁定了。对于基本类型(数字、字符串、布尔、null、undefined、Symbol、BigInt),值本身就存在变量里,所以不能重新赋值就等同于值不可变。
4.2 对于对象和数组:绑定不变,内容可变
这是const最容易让人困惑的地方。当const声明一个对象或数组时,不可变的是这个变量指向的内存地址(引用),而不是这个地址里存储的内容。
const person = { name: 'Alice' }; person.name = 'Bob'; // 这是允许的!修改对象属性 console.log(person); // { name: 'Bob' } person = { name: 'Charlie' }; // TypeError: Assignment to constant variable. 禁止重新赋值,即更换引用 const arr = [1, 2, 3]; arr.push(4); // 允许!修改数组内容 console.log(arr); // [1, 2, 3, 4] arr = [5, 6]; // TypeError: Assignment to constant variable.所以,const保证的是“你指着这个房子(对象)的手不能换指别的房子”,但并没有限制你进入这个房子去重新装修(修改属性)。如果你需要对象也完全不可变,需要使用Object.freeze()等更深层的冻结方法。
4.3 同样具备块级作用域和暂时性死区
const与let在作用域、暂时性死区、禁止重复声明、全局属性等方面行为完全一致。它们是一对亲兄弟,只是const多了一个“必须初始化且不能重新赋值”的约束。
5. 三者的横向对比与选型策略
了解了各自特点后,我们通过一个表格进行系统性的对比,这能帮你快速决策。
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域或全局作用域 | 块级作用域 | 块级作用域 |
| 变量提升 | 有,提升声明,初始值为undefined | 有(在TDZ中),访问会报错 | 有(在TDZ中),访问会报错 |
| 暂时性死区 | 无 | 有 | 有 |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 声明时初始化 | 可选 | 可选 | 必须 |
| 重新赋值 | 允许 | 允许 | 不允许 |
全局声明成为window属性 | 是 | 否 | 否 |
5.1 现代 JavaScript 开发中的选型黄金法则
在实际项目中,如何选择?我个人的经验是遵循一条简单的法则,它能覆盖 99% 的场景:
默认使用const,只在你知道这个变量需要被重新赋值时,才使用let,基本不用var。
这条法则背后的逻辑是:
const优先:大部分变量在声明后其引用是不需要改变的。使用const可以明确表达这个意图,让代码更可读,也防止了意外的重新赋值。即使它指向一个对象,你依然可以修改其内部属性,这通常是我们期望的行为。let用于变量:当你明确知道这个值需要改变时,比如循环计数器、状态标志、累积计算的结果等,就使用let。它提供了块级作用域的安全性,又保留了可变的灵活性。var的弃用:在新的项目和模块中,没有理由再使用var。它的特性(函数作用域、提升、重复声明)更像是语言的缺陷而非特性。只在维护无法改动的遗留代码时才需要与之打交道。
5.2 实战中的细节与“坑”
循环中的const:你可能会惊讶,for...of或for...in循环中可以使用const。
const arr = ['a', 'b', 'c']; for (const item of arr) { console.log(item); // 输出 'a', 'b', 'c' } // 这里每次迭代都会创建一个新的块级作用域和一个新的常量 `item`,绑定当次迭代的值。这是完全合法的。但是,传统的for (let i=0; i<10; i++)循环不能将let换成const,因为i++试图对i进行重新赋值,会报错。
let在for循环头部的特殊行为:标准规定,for循环(let i = ...)的括号部分会创建一个父级作用域,而循环体{}则在每次迭代中创建新的子作用域。这使得在循环体内创建的函数(如setTimeout回调)能捕获到当次迭代的i值。这是语言特意为let设计的行为,用以解决历史问题。
const与不可变数据:如果你正在使用 Redux 或推崇函数式编程,你会追求状态的不可变性。这时,const是你的好朋友,但它只保证了引用的不变。要真正实现深不可变,你需要配合使用扩展运算符、Object.assign、Array.prototype.map等返回新对象/数组的方法,或者使用 Immutable.js 这样的库。
6. 从语法到思维:编写更健壮的代码
理解var、let、const的区别,不仅仅是记住几条语法规则,更是培养一种更严谨的编程思维。
作用域意识:块级作用域让你能更精细地控制变量的生命周期。你可以放心地在if、for内部声明临时变量,而不用担心它们泄露到外部,污染作用域。这使得代码的模块化更好,不同逻辑块之间的耦合度更低。
声明即意图:看到const,你就知道这个标识符指向的东西大体上稳定不变;看到let,你就预期它的值可能会随着逻辑流改变。这种代码自解释性大大提升了可读性和可维护性。
错误前置:暂时性死区的设计,将“使用未初始化的变量”这类错误从潜在的运行时逻辑错误(undefined导致的奇怪现象)转变为明确的、在开发阶段就能捕获的引用错误,极大地提升了调试效率。
在我自己的项目实践中,强制使用 ESLint 规则prefer-const和no-var是非常有帮助的。它们能自动检查代码,提示你将可以改为const的let进行转换,并禁止使用var。这相当于让工具来强制执行前面提到的黄金法则,帮助团队形成一致的、更优的编码风格。
从var到let/const的转变,是 JavaScript 语言走向成熟和严谨的重要一步。拥抱块级作用域,善用const声明意图,你的代码会减少许多难以追踪的幽灵 Bug,变得更加清晰和强壮。下次写let或const时,不妨多想一秒:这个值,真的需要改变吗?