一看就懂的 var、let、const 三者区别
var、let、const 都可以声明变量,但它们在作用域、初始化时机、重复声明、全局对象属性和重新赋值方面不同。原文写于 ES2015 早期,下面保留原来的学习顺序,并把“变量提升”和“暂时死区”的说法改成更准确的现代表述。
一、var 的函数作用域与提升
var 的作用域通常是最内层的函数体;普通的 if、for、while 或独立代码块不会为 var 创建块级作用域。如果代码不在函数中,它会属于当前脚本或模块;Node.js 的 CommonJS 文件还会被模块函数包装,所以文件顶层的 var 不等于浏览器页面的 window 属性。
function person(status) {
if (status) {
var value = '蛙人'
} else {
console.log(value) // undefined
}
console.log(value) // undefined
}
person(false)
可以把上面的声明部分近似理解为被处理到了函数作用域的前面,但只有声明被提前处理,初始化赋值不会提前执行:
function person(status) {
var value
if (status) {
value = '蛙人'
} else {
console.log(value) // undefined
}
console.log(value) // undefined
}
这就是通常所说的 var 提升(hoisting)。它不是引擎真的把源代码文本移动了,而是声明实例化和赋值执行发生在不同阶段。
function readValue() {
console.log(value) // undefined
var value = '蛙人'
console.log(value) // 蛙人
}
readValue()
var 允许同一作用域内重复声明;这也是旧代码中容易出现隐蔽覆盖的原因:
var count = 1
var count = 2
console.log(count) // 2
除非维护旧代码或需要兼容早期脚本,否则现代代码通常优先使用 const 和 let。
二、let 是块级声明
let 的作用域是最近的代码块、switch、循环、函数或模块作用域。它不能在同一作用域中被另一个 let、const、class、var 或 import 重复声明。
function person(status) {
if (status) {
let value = '蛙人'
console.log(value) // 蛙人
} else {
// console.log(value) // ReferenceError:这里没有名为 value 的可访问绑定
}
// console.log(value) // ReferenceError:value 只存在于 if 代码块
}
person(true)
下面两个声明属于不同的代码块,因此互不冲突:
let value = '外层'
if (true) {
let value = '内层'
console.log(value) // 内层
}
console.log(value) // 外层
同一作用域中重复声明会在解析阶段报错:
let value = '蛙人'
// let value = '另一个值' // SyntaxError: Identifier 'value' has already been declared
但父子代码块可以分别声明同名变量:
var oldValue = '外层'
let newValue = '外层'
if (true) {
var oldValue = '被覆盖'
let newValue = '内层'
console.log(oldValue, newValue) // 被覆盖 内层
}
console.log(oldValue, newValue) // 被覆盖 外层
注意:var 和 let 如果最终属于同一个作用域,也会冲突;不能利用块的写法掩盖这一点:
let name = 'A'
// var name = 'B' // SyntaxError
var age = 1
{
// let age = 2 // 这是子块,可以声明自己的 age
}
三、const 是不可重新赋值的绑定
const 与 let 一样是块级声明,也有暂时死区,不能重复声明;区别是 const 必须在声明时初始化,而且之后不能把变量绑定到另一个值:
const value = '蛙人'
// value = '另一个值' // TypeError: Assignment to constant variable.
// const age // SyntaxError: Missing initializer in const declaration
const 锁定的是绑定(binding),不是对象内部的所有状态。对象和数组仍然可以被修改:
const person = {
name: '蛙人',
age: 23,
}
person.age = 18 // 正常:修改对象内部属性
person.gender = 'unknown' // 正常:添加属性
// person = {} // TypeError:不能重新赋值
const list = []
list.push('JavaScript') // 正常
// list = ['另一个数组'] // TypeError:不能重新赋值
如果需要限制对象自身属性被修改,应根据需求使用 Object.freeze()、不可变数据结构或复制策略;const 本身不提供深冻结:
const config = Object.freeze({
port: 3000,
})
// config.port = 4000 // 严格模式下抛出 TypeError;非严格模式下可能静默失败
Object.freeze() 默认也是浅冻结,嵌套对象仍需单独处理。
四、暂时死区(TDZ)
let、const 和 class 的绑定从作用域开始到声明初始化完成之前处于暂时死区(Temporal Dead Zone,TDZ)。它们不是“完全没有提升”:声明在作用域建立时就已被登记,但在执行到声明前不能读取。
console.log(typeof varValue) // 'undefined'
var varValue = 1
// console.log(typeof letValue) // ReferenceError:Cannot access 'letValue' before initialization
let letValue = 2
typeof 只有在操作未声明标识符时通常能避免 ReferenceError;如果标识符已经有 let/const/class 绑定但仍在 TDZ 中,typeof 也会抛错:
console.log(typeof neverDeclared) // 'undefined'
{
// console.log(typeof declaredLater) // ReferenceError
let declaredLater = 1
console.log(typeof declaredLater) // 'number'
}
函数闭包的创建位置和调用位置也要注意 TDZ:
{
const read = () => value
// read() // ReferenceError:调用仍处在 value 的 TDZ 中
let value = 3
console.log(read()) // 3:此时已经离开 TDZ
}
原文中“引擎把变量放到 TDZ,执行完声明后移出”的说法是便于理解的模型;规范层面更准确的说法是:词法绑定已经存在,但在初始化前读取会触发 ReferenceError。
五、全局脚本、模块与 Node.js 的差异
在浏览器的经典脚本中,顶层 var 通常会成为全局对象(一般是 window,更通用地写作 globalThis)的属性;顶层 let 和 const 不会成为全局对象属性:
<script>
var scriptVar = 'var'
let scriptLet = 'let'
const scriptConst = 'const'
console.log(globalThis.scriptVar) // 'var'
console.log(globalThis.scriptLet) // undefined
console.log(globalThis.scriptConst) // undefined
</script>
这段行为只针对浏览器经典脚本。<script type="module"> 的顶层声明属于模块作用域;Node.js 的 CommonJS 文件和原生 ECMAScript 模块也有各自的模块作用域,不能把它们直接等同于 window。
使用 globalThis.someName = value 才是在明确地写全局对象属性,但应用代码通常应避免无必要的全局状态。
六、循环中的 var 与 let
let 在 for 循环中可以为每次迭代创建独立的绑定,因此异步回调常能得到预期的迭代值;var 通常只有一个函数作用域绑定:
for (var i = 0; i < 3; i += 1) {
setTimeout(() => console.log('var:', i), 0)
}
// var: 3
// var: 3
// var: 3
for (let j = 0; j < 3; j += 1) {
setTimeout(() => console.log('let:', j), 0)
}
// let: 0
// let: 1
// let: 2
这不是因为定时器“记住了当时的值”,而是因为闭包捕获了不同的词法绑定。若必须使用 var,也可以通过立即调用函数或显式传参创建新的作用域,但现代代码使用 let 更清晰。
七、如何选择
| 场景 | 推荐 |
|---|---|
| 变量后续不会重新绑定 | const |
| 变量需要重新赋值,如循环计数器 | let |
| 需要维护的旧脚本、依赖函数作用域的遗留代码 | var,并明确其提升与作用域 |
| 需要修改对象内部状态 | 可以用 const,但要理解它不冻结对象 |
| 需要跨模块共享状态 | 优先使用模块导出、明确的状态容器或依赖注入,避免隐式全局变量 |
const 并不要求变量所指向的值是 primitive;let 也不等于对象一定可变。它们控制的是变量绑定,数据是否可变由对象自身、冻结策略和程序设计决定。
小结
var主要是函数作用域,声明会提升,初始化不会提升,可以重复声明;let是块级作用域,可以重新赋值,但不能同作用域重复声明;const是块级作用域,必须初始化,不能重新赋值;let、const、class在初始化前处于 TDZ,连typeof也不能绕过;const只保证绑定不变,不保证对象或数组内部不可变;- 浏览器经典脚本中的顶层
var与全局对象属性有关,模块、CommonJS 和浏览器模块不要混淆; - 新代码通常优先
const,需要重新绑定时使用let,尽量避免新增var。