在我们惊叹于 JavaScript 代码的流畅执行时,很少有人意识到,在 console.log 输出结果之前,引擎内部早已进行了一场精密的“筹备会议”。这场会议的核心,就是预编译(Hoisting)。要真正理解它,我们必须先窥探 V8 引擎的工作流程。
V8 引擎处理一段 JavaScript 代码,并非简单地从上到下逐行解释。它首先会经历一个编译阶段:将源代码分解为一个个基础的词法单元(Tokens),如 var、a、= 等;接着,通过语法分析将这些单元组织成一棵结构化的 AST(抽象语法树);最后,基于这棵树生成可执行的字节码。而预编译,正是发生在 AST 构建完成之后、代码正式执行之前的这个关键环节。
一个反直觉的现象:为何 undefined 而非报错?
让我们从一个经典例子入手:
console.log(a); // 输出: undefined
var a = 1;
按照“从上到下”的朴素执行逻辑,console.log(a) 时变量 a 尚未声明,理应抛出 ReferenceError。然而,现实却输出了 undefined。这背后的原因,正是预编译在“暗中操作”。
在代码执行前,引擎已经完成了对当前作用域的扫描:
- 发现变量声明:
var a。 - 提升(Hoist)声明:将变量
a的声明提升到当前作用域(此处为全局作用域)的顶部。 - 初始化为
undefined:此时变量a已被创建,但尚未被赋值,因此其值为undefined。
所以,代码的实际执行顺序更像是这样:
var a; // 预编译阶段:声明被提升并初始化为 undefined
console.log(a); // 执行阶段:输出 undefined
a = 1; // 执行阶段:赋值
这种将变量和函数声明移动到其作用域顶部的行为,就是我们常说的声明提升。它的存在,是为了让 JavaScript 引擎能够在执行前就建立起一个完整的“内存蓝图”,明确知道在当前作用域内有哪些变量和函数可供使用。
预编译的两种形态:全局与函数
预编译并非千篇一律,它根据作用域的不同,分为全局预编译和函数体内预编译两种。它们的触发时机和具体步骤略有差异。
1. 函数体内的预编译:一次调用,一次准备
函数的预编译是“懒惰”的,只有在函数被实际调用时才会触发。其过程围绕一个名为 AO(Activation Object,激活对象) 的执行上下文展开,步骤如下:
- 步骤一:创建 AO 对象。这是一个空的容器,用于存放函数内部的所有标识符。
- 步骤二:处理形参与变量声明。将函数的形参和所有
var声明的变量名作为 AO 的属性,初始值设为undefined。如果形参和变量同名,后者会覆盖前者(但在初始阶段,值仍由形参决定)。 - 步骤三:实参与形参绑定。将传入的实参值赋给 AO 中对应的形参属性。
- 步骤四:处理函数声明。将函数体内所有函数声明的函数名作为 AO 的属性,其值为整个函数体。注意:函数声明的提升优先级高于变量声明。
实战演练:
function fn(a) {
console.log(a);
var a = 123;
console.log(a);
function a() {}
var b = function() {};
console.log(b);
function c() {}
var c = a;
console.log(c);
}
fn(1);
让我们跟随预编译的脚步,构建 fn 的 AO:
- 创建 AO:
AO = {} - 扫描形参和变量: 找到形参
a,变量a,b,c。合并后AO = { a: undefined, b: undefined, c: undefined }。 - 绑定实参: 实参
1赋给a,AO = { a: 1, b: undefined, c: undefined }。 - 处理函数声明: 找到
function a() {}和function c() {}。它们会覆盖 AO 中同名的属性。最终AO = { a: function a(){}, b: undefined, c: function c(){} }。
预编译完成后,代码开始执行:
- 第一个
console.log(a)输出函数a。 var a = 123将a重新赋值为123。var b = function() {}将匿名函数赋给b。var c = a将a的当前值123赋给c。
最终的输出完美印证了预编译的威力。
2. 全局预编译:代码加载即启动
与函数不同,全局预编译在脚本加载时就立即执行。它围绕 GO(Global Object,全局对象) 进行,步骤更为简洁:
- 步骤一:创建 GO 对象。
- 步骤二:处理全局变量声明。将所有
var声明的全局变量名作为 GO 的属性,值为undefined。 - 步骤三:处理全局函数声明。将所有全局函数声明的函数名作为 GO 的属性,值为函数体。
全局视角下的嵌套:
var a;
var b = 2;
function a() {
console.log(a);
var c = 3;
var a = b;
function c() {}
console.log(c);
}
a();
console.log(a);
全局预编译 (GO):
- 扫描全局变量:
a,b→GO = { a: undefined, b: undefined }。 - 扫描全局函数:
function a() {...}→GO = { a: function a(){...}, b: undefined }。
执行阶段:
var b = 2将b赋值为2。a()被调用,触发函数a的预编译,创建其 AO。
函数 a 的预编译 (AO):
- 扫描内部变量/形参:
c,a→AO = { c: undefined, a: undefined }。 - 无实参,跳过绑定。
- 扫描内部函数:
function c() {}→AO = { c: function c(){}, a: undefined }。
函数 a 的执行:
console.log(a):在 AO 中找到a,其值为undefined(因为var a = b还未执行)。var a = b:在 AO 中找不到b,于是向外层(GO)查找,得到b = 2,将a赋值为2。console.log(c):AO 中的c已被var c = 3覆盖,输出3。
最后,全局的 console.log(a) 输出的是 GO 中的函数 a。
全局 (GO) 与局部 (AO) 的共生法则
GO 和 AO 并非孤立存在,它们共同构建了 JavaScript 的作用域链,遵循以下核心原则:
- 执行顺序:先全局,后局部。整个程序启动时,先完成全局预编译(构建 GO)。只有当某个函数被调用时,才会为其创建 AO 并进行局部预编译。
- 作用域层级:GO 是最外层的根作用域,每个函数调用都会在其内部创建一个新的、嵌套的作用域(AO)。这种层级结构形成了变量查找的路径。
- 查找优先级:就近原则。当代码中引用一个变量时,引擎首先在当前 AO 中查找。如果找不到,则沿着作用域链向上,在外层的 AO 或最终的 GO 中继续查找。反之则不成立——外层作用域无法直接访问内层作用域的变量。
- 命名空间隔离:同名的变量在不同作用域中互不干扰。内部的
a和全局的a是两个独立的实体,各自存在于自己的 AO 或 GO 中。
理解预编译,就是理解 JavaScript 引擎如何在执行前为我们的代码搭建舞台。它揭示了那些看似“不合逻辑”的行为背后的严谨机制,是每一位前端开发者迈向精通之路的必经之门。