问大家一个问题,你们喜欢什么动物,下面这行代码的输出结果是什么:
JavaScript
console.log(a);
var a = 1;
如果你只学过 C 语言,那你第一反应肯定是:这代码报错了啊,怎么能运行呢?
诶,这就要引出我们这篇文章要讲的一个 JS 不同于 C 语言的机制:预编译。
对于 C 语言这样的底层语言,它是直接编译成机器码,从上往下给机器看的,没有声明就是不能读。但是 JS 不一样,引擎在真正执行代码前,会进行一次“两遍扫描”。它会先把所有出现过的变量记录下来,这样在输出的时候,它不会认为这是错的,因为它能看到有 a 这个变量,不过它并不知道 a 的值是多少。
接下来,我们就完整地看一遍“预编译”到底是怎么回事。
预编译的核心:两张“内存表”
预编译主要发生在两个时机:全局代码执行前,以及函数调用前。为了管理变量和函数,JS 引擎会在内存中创建两个隐式的对象:
- GO (Global Object,全局对象) :程序一开始就创建。
- AO (Activation Object,活跃对象) :每次调用一个函数时,在执行内部代码前创建。
我们可以把预编译的过程,抽象为一个极度严格的 4 步内存建表算法(以函数 AO 为例):
- 创建对象:在内存中创建空的 AO 活跃对象。
- 提升形参与变量声明:扫描所有的形参和
var声明,把名字作为属性存进 AO,并统一初始化为undefined。 - 实参与形参统一:把外部实际传进来的值,赋给 AO 中对应的形参。
- 提升函数声明:扫描代码中
function开头的函数声明,直接将函数体存入内存。🚨注意:如果名字和前面的变量冲突了,函数声明会无条件覆盖前面的所有值。
(注:全局的 GO 创建过程类似,只是没有形参那两步)
那么简单了解了预编译的机制后,开篇那两行代码的答案也很明显了:undefined。因为在预编译阶段,var a 被提升并赋予了 undefined,但赋值动作(= 1)被留在了原地,等到真正执行第二行时才会发生。
高阶测试:四步算法的终极推演
为了彻底检验你是否看懂了预编译,我们来看下面这段经典的“照妖镜”代码。请注意,它的视觉欺骗性极强,融合了传参、变量覆盖和函数表达式:
JavaScript
function test(a) {
console.log(a);
var a = 123;
console.log(a);
function a() {}
console.log(a);
var b = function() {};
console.log(b);
}
test(1);
这题怎么解?千万不要顺着代码一行行往下读。我们严格套用上面说的 “AO 创建 4 步法” -> “代码执行” 的状态机来一步步推演:
第一阶段:预编译阶段(函数被调用,内部代码未执行)
此时引擎开始扫描 test 函数内部的声明,并构建 AO 内存表:
- 创建 AO:
AO = {} - 找形参和 var 声明:发现形参
a,变量a(忽略,已存在),变量b。 当前内存:AO = { a: undefined, b: undefined } - 实参与形参统一:外部调用了
test(1),把实参1赋给形参a。 当前内存:AO = { a: 1, b: undefined } - 找函数声明:发现
function a() {}。根据铁律,函数声明优先级最高,无条件覆盖前面的值。 (注意:var b = function(){}是函数表达式,不参与这一步的特权提升)。 最终预编译结束,内存状态锁定为:AO = { a: [Function: a], b: undefined }
第二阶段:执行阶段(代码逐行运行)
预编译结束,代码开始从上到下一行行真正执行了。执行期铁律:只看赋值,纯声明直接跳过。
console.log(a);👉 去 AO 查表,此时的a是被函数声明覆盖后的值。输出:[Function: a]var a = 123;👉 这是赋值动作!数字123强势写入内存,把函数覆盖掉。此时AO.a = 123。console.log(a);👉 查表,a变成数字了。输出:123function a() {}👉 纯函数声明,在预编译阶段已经被抽走了。执行引擎直接无视并跳过这一行。console.log(a);👉 查表,a依然是刚才的数字。输出:123var b = function() {};👉 发生赋值动作!此时把一个函数对象赋值给了b。此时AO.b = [Function: b]。console.log(b);👉 查表,b已经被赋值了。输出:[Function: b]
总结
在 C 语言中,代码的物理排版位置决定了它的可见性。但在 JavaScript 中,物理排版常常是一场视觉骗局(传进来的实参 1 还没捂热就被后面的声明覆盖了)。