本文是「JavaScript 运行现场」系列的第 2 篇。
下面使用几段可直接运行的简化代码说明问题,重点放在声明创建、初始化时机和函数声明的处理差异。
先看两段只差一个关键字的代码。
js
console.log(a)
var a = 1
运行结果是:
text
undefined
把 var 换成 let:
js
console.log(b)
let b = 1
运行结果变成:
text
ReferenceError: Cannot access 'b' before initialization
两段代码都在声明之前读取变量,结果却完全不同。把它简单归结为"var 会提升,let 不会提升"并不准确,因为执行到第一行时,引擎已经知道当前作用域中存在 b;如果它完全不知道 b,报错信息应该更接近 b is not defined。
这个差异要从代码正式执行前的准备过程看起。上一篇提到,JavaScript 执行全局代码或调用函数时,会先创建对应的执行上下文。变量声明会在这个阶段被登记,但不同声明的初始化时间并不相同。
var 在代码执行前已经被初始化为 undefined
先只看第一段:
js
console.log(a)
var a = 1
为了观察顺序,可以把引擎的处理过程简化为两个阶段。
准备阶段发现了 var a,于是创建变量绑定,并给它一个初始值 undefined。代码随后从上到下执行,第一行读取到的就是这个初始值;执行到 var a = 1 时,右侧的 1 才被赋给 a。
text
准备阶段:创建 a,并初始化为 undefined
执行第一行:console.log(a)
输出 undefined
执行第二行:a = 1
因此,下面这种改写有助于理解运行结果:
js
var a
console.log(a)
a = 1
这只是行为上的等价说明,JavaScript 引擎不会真的把源代码移动到文件顶部。所谓"变量提升",描述的是声明在代码执行前已经进入当前环境,而不是文本发生了重新排序。
把声明和赋值拆开后,另一个容易忽略的问题也会清楚一些:
js
console.log(a)
var a = getValue()
在执行 console.log(a) 时,getValue() 还没有运行。准备阶段处理的是变量声明,函数调用和赋值表达式仍然要等执行走到这一行。
同一个作用域里重复声明 var 也不会创建多个独立变量:
js
var count = 1
var count = 2
console.log(count)
这里只有一个 count 绑定,第二次赋值把值改成了 2。这类宽松行为在旧代码中很常见,也让重复命名更难在早期暴露。
let 和 const 已经存在,但暂时不能读取
再看第二段:
js
console.log(b)
let b = 1
准备阶段同样会发现 b,并在当前块级作用域中创建对应绑定。区别在于,这个绑定此时还没有完成初始化。
从进入作用域开始,到执行 let b = 1 之前,这段范围通常称为暂时性死区,英文是 Temporal Dead Zone,常写作 TDZ。
js
{
// b 的暂时性死区开始
console.log(b)
let b = 1
// 执行声明后,b 才可以读取
}
第一行读取 b 时,引擎查找到了这个绑定,所以它不会继续向外层作用域寻找同名变量。由于绑定还没有初始化,读取操作直接抛出 ReferenceError。
下面这段代码更能看出变量已经进入当前作用域:
js
const value = 'outer'
{
console.log(value)//ReferenceError
let value = 'inner'
}
如果块内的 value 完全没有创建,console.log 应该读取外层的 'outer'。实际执行时,块内声明会遮蔽外层变量,而读取又发生在初始化之前,所以代码报错。
const 在声明前的行为与 let 相同:
js
console.log(token)
const token = 'abc'
它也处于暂时性死区。const 额外要求声明时必须完成赋值,后续不能再次给这个变量绑定新值。
js
let page
page = 2
const size // SyntaxError
暂时性死区还有一个容易误判的边界。typeof 对完全未声明的变量通常不会报错:
js
console.log(typeof missingName)
// "undefined"
但变量处于暂时性死区时,typeof 仍然会抛出异常:
js
console.log(typeof status)
let status = 'ready'
这里的 status 已经属于当前作用域,只是尚未初始化。typeof 不能绕过这个状态。
把 var、let 和 const 放在同一张图里,会更容易看清它们在准备阶段和执行阶段的差异。

函数声明为什么能在定义前调用
变量声明之外,函数也会出现类似的提前可用现象。
js
printMessage()
function printMessage() {
console.log('hello')
}
这段代码可以正常输出 hello。准备阶段处理函数声明时,当前作用域中的 printMessage 已经被初始化为对应的函数对象,所以第一行调用能够直接找到函数。
函数表达式的结果不同:
js
run()
var run = function () {
console.log('running')
}
这里会出现:
text
TypeError: run is not a function
准备阶段处理的是 var run,因此 run 已经存在并且值为 undefined。执行第一行时,相当于调用:
js
undefined()
变量能够被找到,所以报错类型不是 ReferenceError;它当前保存的值不能调用,于是得到 TypeError。
如果改成 let:
js
run()
let run = function () {
console.log('running')
}
第一行会在暂时性死区中读取 run,因此得到 ReferenceError。函数体写法相同,前面的声明方式改变后,准备阶段的处理也随之改变。
箭头函数同样属于函数表达式:
js
load()
const load = () => {
console.log('loaded')
}
load 受 const 的初始化规则约束,在声明执行前无法调用。
这几段代码可以归成三种情况:
- 函数声明在准备阶段创建并完成初始化;
var声明的函数表达式先得到undefined;let和const声明的函数表达式在初始化前不能读取。
项目中把函数声明改写成箭头函数时,如果原代码依赖"定义前调用",行为就会发生变化。下面这种调整表面上只改了语法:
js
init()
function init() {
// 初始化逻辑
}
改成:
js
init()
const init = () => {
// 初始化逻辑
}
第二版在执行第一行时会报错。重构时需要一起调整调用位置,不能只确认函数体是否相同。
用一段代码验证三个阶段
可以把前面的差异放进同一个函数中运行:
js
function inspectDeclarations() {
console.log(varValue)
try {
console.log(letValue)
} catch (error) {
console.log(error.name)
}
declaredFunction()
try {
expressionFunction()
} catch (error) {
console.log(error.name)
}
var varValue = 1
let letValue = 2
function declaredFunction() {
console.log('function declaration')
}
var expressionFunction = function () {
console.log('function expression')
}
}
inspectDeclarations()
输出顺序是:
text
undefined
ReferenceError
function declaration
TypeError
varValue 在准备阶段已经初始化为 undefined;letValue 已经创建,但读取时仍处于暂时性死区;declaredFunction 已经保存了函数对象;expressionFunction 此时只有 var 提供的初始值 undefined。
调试类似问题时,可以先确认当前标识符来自哪一种声明,再看读取发生在声明执行之前还是之后。只记"都会提升"或者"只有 var 提升",很难解释错误类型为什么不同。
这一篇没有继续展开全局 var 与 window 的关系、ES Module 顶层作用域以及块级函数声明的兼容行为。这些规则会受到脚本类型、严格模式和运行环境影响,混在当前问题里会削弱主线。
下一篇会继续沿着变量查找过程,分析内部函数读取同名变量时为什么优先使用定义位置的作用域,以及词法作用域怎样形成作用域链。