1. 从“变量”说起:为什么我们需要三种声明方式?
如果你刚开始学JavaScript,看到var、let、const这三个家伙,是不是有点懵?心里可能在想:“不都是用来声明变量的吗?搞这么复杂干嘛?” 我刚开始接触的时候也是这么想的,觉得var就够用了。但后来在项目里踩过几次坑,比如一个循环里的变量莫名其妙影响了外面的值,或者某个函数里的变量被意外覆盖,我才明白ES6引入let和const,真的是来“救火”的。
简单来说,你可以把var看作是JavaScript早期的“老办法”,它有一些设计上的“历史包袱”,比如作用域规则比较“宽松”,容易导致一些意想不到的bug。而let和const是ES6带来的“新规矩”,它们用更严格、更清晰的方式来管理变量,让你的代码更健壮、更容易维护。
想象一下你管理一个工具箱。var就像是你把所有工具都扔在一个大箱子里,虽然拿起来方便,但找起来乱,容易拿错。而let和const则是给工具分门别类,放进不同的小格子(块级作用域),并且const还会把一些重要的工具(常量)锁起来,防止你误操作。这样是不是清晰多了?
所以,理解这三者的区别,尤其是块级作用域和变量提升这两个核心概念,绝对不是死记硬背面试题,而是为了写出更安全、更可靠的代码。接下来,我们就用大量实际的代码例子,把它们掰开揉碎了讲清楚。
2. 作用域之争:全局、函数与块级作用域
作用域,说白了就是一个变量“能活多久、能在哪儿被访问到”的范围。JavaScript的作用域主要有三种:全局作用域、函数作用域,以及ES6新增的块级作用域。var、let、const在这三种作用域下的表现截然不同,这也是它们最根本的区别。
2.1 var的“老地盘”:函数作用域
var声明的变量,其作用域要么是全局的(在函数外声明),要么是函数级的(在函数内声明)。它没有块级作用域的概念。这里的“块”,指的就是用花括号{}包裹起来的一段代码,比如if语句、for循环、或者单纯的一个{}。
我们来看个例子,这是我早期常犯的错误:
function testVar() {
if (true) {
var insideVar = '我在if块里面';
}
console.log(insideVar); // 输出:'我在if块里面'
}
testVar();
看到了吗?insideVar明明是在if的{}里声明的,但在if块外面依然可以访问到。这是因为对于var来说,if的{}不构成一个独立的作用域,insideVar的作用域是整个testVar函数。这就好比你在卧室(if块)里放了个玩具,结果客厅(函数内其他位置)也能随便玩,这显然容易造成混乱。
2.2 let和const的“新规矩”:块级作用域
let和const则严格遵守块级作用域的规则。变量只在声明它的那个{}内部有效,出了这个块就访问不到了。
我们把上面的例子用let重写一下:
function testLet() {
if (true) {
let insideLet = '我在if块里面';
console.log(insideLet); // 输出:'我在if块里面'
}
console.log(insideLet); // 报错:ReferenceError: insideLet is not defined
}
testLet();
这次,在if块外访问insideLet,直接报错了。这就对了!玩具现在只能放在卧室里玩,不能拿到客厅,规矩清晰,避免了误用。
const在作用域规则上和let完全一致,也是块级作用域。它们俩的出现,就是为了解决var作用域“泄露”的问题,让变量的生命周期更加可控。
2.3 一个经典的“坑”:for循环中的变量泄露
这个区别在for循环中体现得淋漓尽致,也是面试必考题。我们直接看代码:
// 使用 var - 经典的“坑”
console.log('使用 var 的循环:');
for (var i = 0; i < 3; i++) {
setTimeout(function() {
console.log(i); // 注意:这里打印的是循环结束后的 i
}, 100);
}
// 输出结果会是:3, 3, 3
// 循环结束后,全局(或函数)作用域里只有一个 i,其值为 3
// 使用 let - 符合直觉的结果
console.log('使用 let 的循环:');
for (let j = 0; j < 3; j++) {
setTimeout(function() {
console.log(j); // 这里打印的是每次循环时 j 的值
}, 100);
}
// 输出结果会是:0, 1, 2
// 每次循环,let都会创建一个新的、独立的块级作用域,j的值被“锁”在那个作用域里
为什么会有这种差异?因为var声明的i作用域是整个函数(或全局),循环结束后i变成了3。而三个setTimeout回调函数共享了这个唯一的、值为3的i。而let声明的j,每次循环迭代都会创建一个新的块级作用域,每个回调函数捕获的都是自己那个作用域里的j值(0, 1, 2)。这个特性对于处理异步操作(如事件监听、定时器)至关重要,能帮你省去很多用闭包来保存状态的麻烦。
3. 变量提升(Hoisting):悬在空中的声明
“变量提升”是JavaScript引擎在编译阶段做的一件“怪事”:它会将变量和函数的声明,提升到当前作用域的顶部。但是,var、let、const在提升时的表现又有微妙而关键的不同。
3.1 var的变量提升:先上车,后补票
对于var,提升是“完全体”的:声明被提升,并且初始化为undefined。
console.log(myVar); // 输出:undefined,而不是报错!
var myVar = 5;
console.log(myVar); // 输出:5
在JavaScript引擎眼里,上面这段代码相当于:
var myVar; // 声明被提升到顶部,并初始化为 undefined
console.log(myVar); // 所以这里打印 undefined
myVar = 5; // 赋值操作留在原地
console.log(myVar); // 打印 5
这允许你在声明前使用变量(虽然值是undefined),有时会让代码看起来有点反直觉,也是bug的温床。
3.2 let和const的变量提升与“暂时性死区”
let和const也有提升!这一点很多人会误解。但它们提升后的行为与var不同:声明会被提升,但不会被初始化。
从代码块(块级作用域)的起点,到变量声明语句被执行之前的这个区域,被称为 “暂时性死区”。在死区内访问该变量会抛出一个ReferenceError。
// 暂时性死区 (Temporal Dead Zone - TDZ) 示例
console.log(myLet); // 报错:ReferenceError: Cannot access 'myLet' before initialization
let myLet = 10;
// 对比 var
console.log(myVar); // 输出:undefined
var myVar = 10;
你可以这样理解:var在提升时,引擎给你预留了一个位置(变量),并且放了一个默认值undefined进去。而let/const在提升时,引擎只是知道有这么一个变量将要存在(声明被登记),但严格禁止你在它被正式“初始化”(即执行到声明语句)之前去访问它,访问就会触发错误。
“暂时性死区”的存在,是为了让程序更严谨。 它强制要求你先声明、后使用,避免了因意外访问到undefined而导致的隐蔽bug。这也是为什么现在普遍建议用let和const替代var的原因之一。
4. 重新声明与重新赋值:谁更“专一”?
声明变量后,我们可能想改它的值(重新赋值),或者在同一个作用域里再声明一次同名变量(重新声明)。这三兄弟在这方面的规矩也大不相同。
4.1 var:非常“随和”,允许重复声明
var允许你在同一个作用域内多次声明同一个变量,后面的声明会覆盖前面的,但之前的值(如果已赋值)可能会被保留或覆盖,这取决于你是否在声明时赋值。这很容易导致变量被意外覆盖。
var mood = '开心';
var mood = '有点郁闷'; // 完全合法,不会报错
console.log(mood); // 输出:'有点郁闷'
// 更迷惑的情况
var count = 1;
if (true) {
var count = 2; // 这实际上是在修改全局(或函数)作用域里的那个count!
console.log(count); // 2
}
console.log(count); // 2, 函数内的count被意外修改了
4.2 let:一次声明,终身有效(在同作用域内)
let不允许在同一个作用域内重复声明同一个变量。这能有效防止你无意中覆盖掉已有的变量。
let name = '小明';
let name = '小红'; // 报错:SyntaxError: Identifier 'name' has already been declared
// 但在不同的块级作用域里,是允许的
let age = 18;
if (true) {
let age = 25; // 这是新的块级作用域,合法
console.log(age); // 25
}
console.log(age); // 18,外层的age没有被影响
但是,let声明的变量,其值是可以被重新赋值的。
let score = 90;
score = 95; // 完全合法
console.log(score); // 95
4.3 const:最“专一”的常量声明
const是“constant”(常量)的缩写。它结合了let的块级作用域特性,并增加了两条更严格的限制:
- 声明时必须初始化(必须赋值)。
- 声明后,其绑定(对于基本类型就是值,对于对象是内存地址)不可改变。
const PI; // 报错:SyntaxError: Missing initializer in const declaration
const PI = 3.14159; // 正确
PI = 3.14; // 报错:TypeError: Assignment to constant variable.
这里有一个非常重要的细节:const保证的是“绑定”不变,而不是“值”完全不可变。
- 对于基本数据类型(如
Number,String,Boolean,null,undefined,Symbol),值就保存在变量绑定的内存地址里,所以值也不能变。 - 对于引用数据类型(如
Object,Array,Function),变量绑定的是指向该对象的一个内存地址(引用)。const保证的是这个引用地址不变,但对象内部的内容是可以修改的。
// const 与对象
const person = { name: 'Alice', age: 20 };
person.age = 21; // 合法!修改对象内部的属性
console.log(person); // { name: 'Alice', age: 21 }
person = { name: 'Bob' }; // 报错!试图改变 person 的引用(内存地址)
// const 与数组
const colors = ['red', 'green'];
colors.push('blue'); // 合法!修改数组内容
console.log(colors); // ['red', 'green', 'blue']
colors = ['yellow']; // 报错!试图改变 colors 的引用
所以,当你用const声明一个对象或数组时,你是在说:“这个变量将永远指向这个特定的对象/数组”,但并没有说这个对象/数组的内容是冻结的。如果你需要完全冻结一个对象,使其不可变,需要使用Object.freeze()方法。
5. 全局作用域下的特殊行为:window对象的属性
在浏览器环境中,全局作用域有一个特殊的对象window。使用var在全局作用域下声明的变量,会自动成为window对象的一个属性。而let和const声明的全局变量则不会。
var globalVar = '我是var';
let globalLet = '我是let';
const globalConst = '我是const';
console.log(window.globalVar); // 输出:'我是var'
console.log(window.globalLet); // 输出:undefined
console.log(window.globalConst); // 输出:undefined
console.log(globalVar); // '我是var' (依然可以访问)
console.log(globalLet); // '我是let'
console.log(globalConst); // '我是const'
这个特性意味着,如果你用var声明了一个全局变量name,它会覆盖window对象自带的name属性(通常用于设置窗口名称),这可能会引发一些难以察觉的冲突。而let和const则避免了这种污染全局window对象的风险。
6. 实战指南:如何选择 var、let、const?
讲了这么多区别,在实际编码中到底该怎么选呢?根据我这些年的经验,我总结了一个简单的“三步决策法”:
- 默认使用
const。只要这个变量的值在初始化后不打算被重新赋值,就用const。这能最大程度地保证数据的不可变性,让代码意图更清晰,减少意外修改的风险。大部分变量其实都属于这一类,比如配置项、函数引用、导入的模块、DOM元素引用等。 - 当变量需要被重新赋值时,使用
let。比如循环计数器、状态标志、需要累积计算的变量等。 - 基本不再使用
var。除非你正在维护一个非常古老的、不支持ES6的代码库,或者有极其特殊的兼容性要求,否则在新项目中应该彻底摒弃var。let和const提供的块级作用域和更严格的规则,能帮你避免绝大多数由var引起的作用域污染和变量提升问题。
举个例子,写一个简单的购物车计算函数:
// 好的实践
function calculateCartTotal(items) {
const TAX_RATE = 0.08; // 税率是常量,用 const
let subtotal = 0; // 小计需要累加,用 let
for (let item of items) { // 循环迭代变量,用 let
subtotal += item.price * item.quantity;
}
const tax = subtotal * TAX_RATE; // 计算出的税额也不需要再改,用 const
const total = subtotal + tax; // 总计也是常量
return { subtotal, tax, total };
}
// 避免这样写
function oldStyleCalculate(items) {
var TAX_RATE = 0.08; // 可能被意外修改
var subtotal = 0;
for (var i = 0; i < items.length; i++) { // i 可能泄露或引起闭包问题
subtotal += items[i].price * items[i].quantity;
}
// ... 其他代码可能意外访问到 i
}
遵循这个原则,你的代码会立刻变得更有条理,也更安全。刚开始可能会不习惯,但坚持下来,你会发现代码的bug率真的会下降。我自己在团队中推行这个规范后,那些因变量作用域混淆而导致的诡异bug几乎绝迹了。记住,好的工具要用对方式,let和const就是JavaScript送给我们的更趁手、更安全的工具。

5134

被折叠的 条评论
为什么被折叠?



