如果你曾经在变量声明之前就使用它,得到了 undefined 而不是报错,你就已经遇见过变量提升(Hoisting)。这种看似“提前”的行为,本质上是执行上下文在创建阶段的准备工作。而这一切,绕不开两个核心概念——变量对象(Variable Object) 和 活动对象(Activation Object)。
3.3.1 执行上下文的两个阶段
当 JavaScript 引擎准备执行一段代码时,首先会创建一个执行上下文。执行上下文的生命周期分为两个阶段:
- 创建阶段:代码还没有真正执行,引擎会先做好所有的“准备工作”——建立作用域链、确定
this指向,以及初始化变量对象。 - 执行阶段:准备工作做完后,代码才逐行执行。
变量提升就发生在创建阶段。在这一阶段,引擎会扫描整个作用域中的声明,并将它们预先记录到变量对象中。等进入执行阶段时,变量对象上的名字已经存在,所以才出现“声明前可访问”的假象。
3.3.2 变量对象与活动对象到底是什么
- 变量对象:是一个抽象概念,用于存储当前执行上下文中的所有变量和函数声明。它本身是引擎内部的一个数据结构,开发者无法直接访问。
- 活动对象:当执行上下文是一个函数上下文时,变量对象就被称为活动对象。名称不同,实质基本一致,只是活动对象额外包含了函数的参数信息(arguments)。
简单来说:变量对象就是当前作用域内所有“名字”的集合。全局上下文的变量对象就是全局对象(浏览器中是 window),函数上下文的变量对象则是一个不可直接访问的内部对象。
3.3.3 创建阶段到底做了什么
在创建阶段,引擎按照以下顺序“填充”变量对象:
- 函数声明:扫描代码中的所有
function声明,将函数名作为属性添加到变量对象中,并初始化为一个可用的函数引用。这就是函数提升——整个函数体被一同“提升”,在声明之前就可以调用。 - 变量声明(
var):扫描所有var声明的变量,将变量名添加到变量对象中,并初始化为undefined。注意:只有声明被提升,赋值操作留在原地。 - 参数(仅函数上下文):在活动对象中,形参被建立并与实参绑定。没有传入实参的形参,值为
undefined。
console.log(age); // undefined(提升但未赋值)
var age = 25;
console.log(age); // 25
greet(); // "Hello"(函数整体提升)
function greet() {
console.log("Hello");
}
实际发生的事情等价于:
// 创建阶段
var age; // 声明提升,初始化为 undefined
function greet() {} // 函数整体提升
// 执行阶段
console.log(age); // undefined
age = 25; // 赋值留在原地
console.log(age); // 25
greet(); // "Hello"
3.3.4 函数提升 vs 变量提升:谁优先
当同一个名字同时被变量声明和函数声明占用时,函数声明优先级更高。因为函数声明在变量对象中的添加顺序先于变量声明,且函数会被初始化为具体值,而 var 变量初始化为 undefined。如果两者同名,函数引用会覆盖 undefined,并且即使变量声明在函数之后,函数依然会被保留。但之后的赋值操作会根据执行顺序改变变量的值。
console.log(typeof value); // "function"
var value = 10;
function value() {}
console.log(typeof value); // "number"
分析:创建阶段先添加 function value,再添加 var value(但此时 value 已存在,不会覆盖函数)。执行阶段,value = 10 把函数替换成了数字,所以最后输出 "number"。
最佳实践:不要依赖这种隐式规则,避免在同一作用域中声明同名的函数和变量。
3.3.5 let 和 const 的“提升”与暂时性死区
let 和 const 也会提升吗?这个问题常引发争议。从严格意义上讲,它们同样会被提升,但表现与 var 完全不同。
ES6 引入了词法环境(Lexical Environment)的概念来管理作用域,let/const 声明就存储在词法环境中。在上下文的创建阶段,这些声明会被记录,但不会被初始化为任何值(连 undefined 都没有)。从创建阶段结束到实际声明语句被执行之前,这段区间被称为暂时性死区(Temporal Dead Zone, TDZ)。任何访问 TDZ 中的变量的操作都会抛出 ReferenceError。
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;
即使 typeof 操作也无法穿透 TDZ:
console.log(typeof y); // ReferenceError(而非 "undefined")
let y;
这实际上是更安全的设计:强迫开发者在声明之后再使用变量,减少因提升导致的隐蔽 bug。所以在日常开发中,优先使用 const 和 let,可以让自己远离变量提升的大部分麻烦。
3.3.6 函数表达式与命名函数表达式的提升
只有函数声明会被整体提升,函数表达式不会被提升。函数表达式本质上是将函数赋值给一个变量(var、let 或 const),因此受到变量声明的提升规则的约束。
sayHi(); // TypeError: sayHi is not a function
var sayHi = function() {
console.log("Hi");
};
创建阶段,sayHi 被提升并赋值为 undefined,执行阶段调用 undefined() 报错。同理,let/const 声明的函数表达式同样会进入 TDZ。
命名函数表达式则是一个例外:它的函数名在表达式外部不可用,只在其自身作用域内有效,不会污染外部变量对象。
var fn = function inner() {
console.log(typeof inner); // "function"
};
console.log(typeof inner); // "undefined"
3.3.7 为什么需要理解变量对象/活动对象
掌握这一底层机制,能让你彻底搞懂以下常见问题:
- 为什么在声明前使用
var变量得到的是undefined,而不是报错? - 为什么函数可以在定义前调用?
- 为什么循环内的
setTimeout中拿到的是同一个i(var无块级作用域)? let和const为何不能重复声明?为何需要先声明后使用?
这些知识会让你在调试变量作用域相关的 bug 时,不再靠“猜”,而是能从执行上下文的创建过程中推导出结果。当代码中因为“未定义”或“访问未初始化变量”报错时,请回想:引擎在创建阶段有没有把这个名字放进变量对象?它是怎么初始化的?执行阶段是否已经执行到了赋值语句? 这就是变量对象与活动对象提供的底层解释力。