人人都会AI编程

3.3 变量对象与活动对象:变量提升、函数提升的底层根源

更新时间:2026-07-11

如果你曾经在变量声明之前就使用它,得到了 undefined 而不是报错,你就已经遇见过变量提升(Hoisting)。这种看似“提前”的行为,本质上是执行上下文在创建阶段的准备工作。而这一切,绕不开两个核心概念——变量对象(Variable Object)活动对象(Activation Object)

3.3.1 执行上下文的两个阶段

当 JavaScript 引擎准备执行一段代码时,首先会创建一个执行上下文。执行上下文的生命周期分为两个阶段:

  1. 创建阶段:代码还没有真正执行,引擎会先做好所有的“准备工作”——建立作用域链、确定 this 指向,以及初始化变量对象
  2. 执行阶段:准备工作做完后,代码才逐行执行。

变量提升就发生在创建阶段。在这一阶段,引擎会扫描整个作用域中的声明,并将它们预先记录到变量对象中。等进入执行阶段时,变量对象上的名字已经存在,所以才出现“声明前可访问”的假象。

3.3.2 变量对象与活动对象到底是什么

  • 变量对象:是一个抽象概念,用于存储当前执行上下文中的所有变量和函数声明。它本身是引擎内部的一个数据结构,开发者无法直接访问。
  • 活动对象:当执行上下文是一个函数上下文时,变量对象就被称为活动对象。名称不同,实质基本一致,只是活动对象额外包含了函数的参数信息(arguments)。

简单来说:变量对象就是当前作用域内所有“名字”的集合。全局上下文的变量对象就是全局对象(浏览器中是 window),函数上下文的变量对象则是一个不可直接访问的内部对象。

3.3.3 创建阶段到底做了什么

在创建阶段,引擎按照以下顺序“填充”变量对象:

  1. 函数声明:扫描代码中的所有 function 声明,将函数名作为属性添加到变量对象中,并初始化为一个可用的函数引用。这就是函数提升——整个函数体被一同“提升”,在声明之前就可以调用。
  2. 变量声明(var:扫描所有 var 声明的变量,将变量名添加到变量对象中,并初始化为 undefined注意:只有声明被提升,赋值操作留在原地。
  3. 参数(仅函数上下文):在活动对象中,形参被建立并与实参绑定。没有传入实参的形参,值为 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 letconst 的“提升”与暂时性死区

letconst 也会提升吗?这个问题常引发争议。从严格意义上讲,它们同样会被提升,但表现与 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。所以在日常开发中,优先使用 constlet,可以让自己远离变量提升的大部分麻烦。

3.3.6 函数表达式与命名函数表达式的提升

只有函数声明会被整体提升,函数表达式不会被提升。函数表达式本质上是将函数赋值给一个变量(varletconst),因此受到变量声明的提升规则的约束。

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 中拿到的是同一个 ivar 无块级作用域)?
  • letconst 为何不能重复声明?为何需要先声明后使用?

这些知识会让你在调试变量作用域相关的 bug 时,不再靠“猜”,而是能从执行上下文的创建过程中推导出结果。当代码中因为“未定义”或“访问未初始化变量”报错时,请回想:引擎在创建阶段有没有把这个名字放进变量对象?它是怎么初始化的?执行阶段是否已经执行到了赋值语句? 这就是变量对象与活动对象提供的底层解释力。