Skip to content

JavaScript 中的变量提升

规范层面的定义

ECMAScript 规范中不存在名为"hoisting"的术语。变量提升是对以下规范行为的现象级描述:

  1. var 声明:在进入执行上下文(Execution Context)的 DeclarationInstantiation 阶段被提前注册并初始化为 undefined
  2. 函数声明:在词法环境(LexicalEnvironment)创建时被整体初始化——标识符绑定 + 函数体一次性完成
  3. let / const 声明:在词法环境创建时被注册,但未被初始化——在 LexicalBindingInitializeBinding 执行前不可访问

关键差异不在"是否被提升",而在规范是否允许在初始化前访问letconst 也被提升了(引擎在进入作用域时已知它们的存在),但规范通过 LetVariableConstVariable 的绑定状态强制阻止了初始化前的读写。


V8 解析管线的实现路径

变量提升的行为在 V8 的两个阶段被确定:

阶段一:解析(Parser → AST)

text
Source Code → Scanner → Token Stream → Parser → AST → Scope Info
  1. Scanner:将源码字符流转为 token 序列。var a 产生 VAR + IDENTIFIER(a) token
  2. Parser:自上而下语法分析。Paser 根据语言标准构建 AST 节点:
    • var a = 10VariableDeclaration 节点,包含 VariableProxy(a) + Literal(10)
    • function fn() {}FunctionLiteral 节点,包含函数名绑定 + 函数体作用域
  3. Scope Analysis:Parser 在遍历 AST 时同步构建作用域链。每个 VariableDeclaration 被注册到所属作用域的 declarations_ 列表中

关键数据结构是 Scope::variables_ZoneHashMap),键为变量名,值为 Variable 对象。Variable::initialization_ 标志位决定变量是否已完成初始化——这就是 TDZ 的底层实现。

阶段二:执行(Ignition Interpreter)

AST 被编译为字节码后,由 Ignition 解释器逐条执行:

  1. 进入作用域 → 创建上下文

    • var → 分配 slot 在 VariableEnvironment 中,值初始化为 undefined
    • let → 分配 slot 在 LexicalEnvironment 中,标记为 kTheHole(TDZ 哨兵值)
    • function fn → 分配 slot 并立即可调用
  2. 字节码层面的差异(简化表示):

    • var a = 10LdaSmi [10] + Star r0(store 到寄存器)
    • let b = 20LdaSmi [20] + StaCurrentContextSlot [2](先写入 context slot 再标记为已初始化)

    let/constStaCurrentContextSlot 指令内部会检查目标 slot 是否为 kTheHole,若是且来自赋值,则将其替换为实际值,同时清除 TDZ 标记。读取时若目标 slot 仍为 kTheHole,抛出 ReferenceError

kTheHole 的设计

V8 使用特殊的内部哨兵值 kTheHole(全称 HoleNaN,实际为特定模式的 NaN 值)标记未初始化的词法绑定:

text
Context slot → [kTheHole] → 被解释器拦截 → 抛 ReferenceError
Context slot → [actual_value] → 正常读写

它不是 undefinedundefined 是合法的 JS 值,用其作为"未初始化"标记会无法区分 let a(未初始化)和 let a = undefined(显式赋值)。kTheHole 在 V8 内部是唯一的哨兵值,JS 代码无法显式产生它——typeof kTheHole 的输出在引擎内部被特殊处理,对外表现为 undefined


作用域链与标识符解析

JS 的作用域是词法的(静态的),标识符解析遵循 Scope Chain 向上查找。

LexicalEnvironment vs VariableEnvironment

Execution Context 包含两个环境组件:

组件管理内容不可变
LexicalEnvironmentletconstclass、函数声明(部分情况)N(withcatch 可修改)
VariableEnvironmentvarY(创建后不可变)

ES6 引入块级作用域(Block Scope)后,引擎需要区分块级绑定和函数级绑定。实现方式:

  • 进入函数 → 创建 VariableEnvironment(存 var)+ 外层 LexicalEnvironment(存 let/const
  • 进入 {} 块 → 新建内部 LexicalEnvironment,其 outer 指向上层环境
  • 退出块 → 内部 LexicalEnvironment 被丢弃,GC 回收

标识符解析(Identifier Resolution)

text
1. 在当前 LexicalEnvironment 查找
2. 未找到 → 在 VariableEnvironment 查找
3. 未找到 → 沿 outer 链向上
4. 到达 GlobalEnvironment → 在全局对象属性中查找
5. 仍未找到 → ReferenceError (非严格模式下 var 赋值会隐式创建全局变量)

注意第 5 步:非严格模式下 x = 1(无声明)会在全局对象上创建属性,这并非提升行为,而是作用域链解析失败后的 fallback 机制。


函数提升与变量提升的优先级

函数声明与 var 变量声明在同一个作用域中发生冲突时,函数声明优先:

js
console.log(foo) // [Function: foo]

var foo = 'bar'

function foo() {
  console.log('baz')
}

V8 的处理逻辑:DeclarationInstantiation 阶段,var 声明在词法环境中被注册时,会先检查同名绑定是否已存在——若已存在(函数声明已占用该 slot),var 声明被静默忽略。赋值 foo = 'bar' 发生在执行阶段,此时 slot 的值被覆盖为字符串。

执行顺序:

text
阶段 1(DeclarationInstantiation):
  function foo → 分配 slot + 写入函数体
  var foo → 检查 slot 已存在 → 跳过

阶段 2(Execution):
  foo = 'bar' → 覆盖 slot 值为 'bar'

若顺序颠倒(var 在前,function 在后),行为不变——规范规定函数声明在 DeclarationInstantiation 中的处理优先于 var 声明,与源码书写顺序无关。


常见处理方式

方案一:统一使用 const / let

js
// ❌ 避免
var count = 0

// ✅
let count = 0        // 需要重新赋值时使用 let
const MAX = 100      // 不变值使用 const

let/const 被提升但受 TDZ 保护——声明前访问抛出 ReferenceError,将问题从"静默产生 undefined"变为"明确报错",大幅降低调试成本。

适用:所有 ES6+ 环境,应作为默认做法。

方案二:变量声明前置(兼容 var 代码风格)

js
function process(data) {
  // 所有 var 声明集中在函数顶部
  var result, i, len, item

  result = []
  for (i = 0, len = data.length; i < len; i++) {
    item = data[i]
    result.push(item * 2)
  }
  return result
}

手动模拟提升行为,声明与使用分离以减少困惑。非严格模式下可配合 linter 规则 vars-on-top 强制该风格。

适用:遗留 var 代码库,无法批量迁移到 let/const 的情况。

方案三:使用 IIFE 创建独立作用域(异步循环中的 var 问题)

js
// ❌ var + 异步 → 输出 3 3 3
for (var x = 0; x < 3; x++) {
  setTimeout(() => console.log(x), 0)
}

// ✅ var + IIFE → 输出 0 1 2
for (var x = 0; x < 3; x++) {
  (function (xCopy) {
    setTimeout(() => console.log(xCopy), 0)
  })(x)
}

每次循环迭代调用 IIFE 创建独立的函数作用域,xCopy 参数捕获当前循环值。代价是每次迭代创建一个函数对象 + 一次函数调用,对循环次数较大的场景(>10k 次迭代)会有可测量的内存和 CPU 开销。

适用:ES5 遗留代码中无法使用 let 的场景。

方案四:setTimeout 参数传值

js
for (var x = 0; x < 3; x++) {
  setTimeout((val) => console.log(val), 0, x)
}

setTimeout 的第 3+ 参数在定时器到期时作为回调实参传入。循环中每次 setTimeout 调用时 x 的当前值被复制为参数快照。优势是代码简洁,劣势是仅适用于 setTimeout/setInterval——对其他异步 API(Promise、事件监听、fetch)无效。

适用:setTimeout/setInterval 中的异步循环。

方案五:使用 bind 绑定参数

js
for (var x = 0; x < 3; x++) {
  setTimeout(console.log.bind(console, x), 0)
}

Function.prototype.bind 创建新函数,其前几个参数被预置。与 IIFE 相比,bind 创建的内置绑定函数内部优化比 IIFE 更好(V8 对 Bind 有专门优化路径),但其 this 绑定逻辑在 console.log 场景下需显式传入 console 作为 thisArg。

适用:回调函数签名固定、需预置参数的异步场景。

方案六:使用数组 forEach(架构层消除循环变量)

js
// ❌ var + for 循环
for (var x = 0; x < 3; x++) { /* ... */ }

// ✅ forEach → 每次迭代 callback 参数即独立绑定
[0, 1, 2].forEach((x) => {
  setTimeout(() => console.log(x), 0)
})

Array.prototype.forEach 的 callback 参数 x 是每次迭代独立传入的函数参数,天然具有独立绑定。无需 let 或 IIFE。注意 forEach 无法 break/continue(抛出异常除外),且链式调用的可读性在复杂逻辑中下降。

适用:数组遍历场景,不需要 break/continue

异步循环方案对比

方案输出结果内存开销ES5 兼容适用 API
let 声明0 1 2每次迭代一个 block scopeN所有异步 API
IIFE0 1 2每次迭代一个闭包Y所有异步 API
setTimeout 传参0 1 2参数值复制Y仅 setTimeout/setInterval
bind0 1 2bind 函数对象Y所有异步 API
forEach0 1 2callback 参数Y数组遍历

选择路径

text
ES6+ 环境?
  ├── 是 → let/const(方案一),异步循环直接用 let
  └── 否 → 异步场景?
              ├── 是 → setTimeout? → 传参(方案四)
              │       其他异步?    → IIFE(方案三)/ bind(方案五)
              └── 否 → var 声明前置(方案二)+ ESLint vars-on-top

核心原则let/const 从语言层面解决了 var 的作用域缺陷,适合作为默认选择。IIFE、传参、bind 属于 ES5 语境下的补救手段,也可以帮助理解 let 在每次迭代中创建独立绑定的机制。


生产环境中的调试

症状

  • 变量在声明前访问值为 undefined 而非报错(var 特征)
  • 在声明前访问 let/const 变量抛出 ReferenceError: Cannot access 'x' before initialization
  • 异步回调中变量值与预期不符(循环中 var + 异步的经典 bug)

Chrome DevTools 排查

  1. Sources → Call Stack:断点停在问题行,查看 Scope 面板中的 Local / Closure / Global 变量。未初始化的 let/const 显示为 <value unavailable>
  2. Sources → Scope:DevTools 区分 Block({} 块内 let/const)和 Local(函数内 var),确认变量属于哪个作用域
  3. Console 直接访问:在断点处 Console 中输入变量名——若处于 TDZ 区间,直接报 ReferenceError

典型线上 case

SSR 渲染的页面:Node.js 服务端渲染模板时,for (var i = 0; ...) 循环中使用异步数据查询,回调执行时 i 已变成 length,导致渲染结果偏移一行、且非稳定复现(取决于异步完成时序)。

修复var ilet i。一行改动消除整类 bug。


TDZ 的精确区间

js
// TDZ 开始 → 进入作用域(let x 被注册但未初始化)
// 在 let x 声明行之前访问 x → ReferenceError

let x = 1  // ← TDZ 结束(InitializeBinding 完成)

实际工程中容易踩坑的模式:

js
// ❌ 类属性初始化器中的 TDZ
class A {
  a = this.b  // undefined,不是 TDZ(b 已提升但在 a 之后初始化)
  b = 2
}

// ❌ 函数默认参数中的 TDZ
function fn(a = b, b = 1) {} // ReferenceError
fn(undefined, 1)

参数默认值按顺序求值,后定义的参数 b 在被 a 的默认值引用时仍处于 TDZ。


typeof 对 TDZ 的行为

typeof 是唯一可以在 TDZ 区间操作变量而不抛异常……除非变量未被声明:

js
typeof undeclaredVar  // 'undefined'(未声明的变量不抛错)
typeof x              // ReferenceError(x 已声明但未初始化,在 TDZ 内)
let x

差异的根因:typeof 的安全守卫覆盖的是作用域链解析失败的场景(变量完全不存在于作用域链),而非 TDZ 保护——TDZ 中的变量已存在于作用域链中,typeof 对该绑定进行读操作,触发 kTheHole 检查,ReferenceError 照常抛出。这是规范特例,不应依赖。