【c转JavaScript】什么是预编译

0 阅读4分钟

问大家一个问题,你们喜欢什么动物,下面这行代码的输出结果是什么:

JavaScript

console.log(a);
var a = 1;

如果你只学过 C 语言,那你第一反应肯定是:这代码报错了啊,怎么能运行呢?

诶,这就要引出我们这篇文章要讲的一个 JS 不同于 C 语言的机制:预编译

对于 C 语言这样的底层语言,它是直接编译成机器码,从上往下给机器看的,没有声明就是不能读。但是 JS 不一样,引擎在真正执行代码前,会进行一次“两遍扫描”。它会先把所有出现过的变量记录下来,这样在输出的时候,它不会认为这是错的,因为它能看到有 a 这个变量,不过它并不知道 a 的值是多少。

接下来,我们就完整地看一遍“预编译”到底是怎么回事。

预编译的核心:两张“内存表”

预编译主要发生在两个时机:全局代码执行前,以及函数调用前。为了管理变量和函数,JS 引擎会在内存中创建两个隐式的对象:

  • GO (Global Object,全局对象) :程序一开始就创建。
  • AO (Activation Object,活跃对象) :每次调用一个函数时,在执行内部代码前创建。

我们可以把预编译的过程,抽象为一个极度严格的 4 步内存建表算法(以函数 AO 为例):

  1. 创建对象:在内存中创建空的 AO 活跃对象。
  2. 提升形参与变量声明:扫描所有的形参和 var 声明,把名字作为属性存进 AO,并统一初始化为 undefined
  3. 实参与形参统一:把外部实际传进来的值,赋给 AO 中对应的形参。
  4. 提升函数声明:扫描代码中 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 内存表:

  1. 创建 AOAO = {}
  2. 找形参和 var 声明:发现形参 a,变量 a(忽略,已存在),变量 b当前内存:AO = { a: undefined, b: undefined }
  3. 实参与形参统一:外部调用了 test(1),把实参 1 赋给形参 a当前内存:AO = { a: 1, b: undefined }
  4. 找函数声明:发现 function a() {}。根据铁律,函数声明优先级最高,无条件覆盖前面的值。 (注意:var b = function(){} 是函数表达式,不参与这一步的特权提升)。 最终预编译结束,内存状态锁定为:AO = { a: [Function: a], b: undefined }

第二阶段:执行阶段(代码逐行运行)

预编译结束,代码开始从上到下一行行真正执行了。执行期铁律:只看赋值,纯声明直接跳过。

  1. console.log(a); 👉 去 AO 查表,此时的 a 是被函数声明覆盖后的值。输出:[Function: a]
  2. var a = 123; 👉 这是赋值动作!数字 123 强势写入内存,把函数覆盖掉。此时 AO.a = 123
  3. console.log(a); 👉 查表,a 变成数字了。输出:123
  4. function a() {} 👉 纯函数声明,在预编译阶段已经被抽走了。执行引擎直接无视并跳过这一行。
  5. console.log(a); 👉 查表,a 依然是刚才的数字。输出:123
  6. var b = function() {}; 👉 发生赋值动作!此时把一个函数对象赋值给了 b。此时 AO.b = [Function: b]
  7. console.log(b); 👉 查表,b 已经被赋值了。输出:[Function: b]

总结

在 C 语言中,代码的物理排版位置决定了它的可见性。但在 JavaScript 中,物理排版常常是一场视觉骗局(传进来的实参 1 还没捂热就被后面的声明覆盖了)。