小白的JS学习之路(二)——“预编译”

6 阅读7分钟

小白的JS学习之路(二)——“预编译”

前言

大家好,我是一名正在学习JavaScript的初学者。在学习过程中,最让我困惑的就是“为什么变量可以先使用再声明?”(即变量提升)以及“函数为什么可以先调用再定义?”。

直到我深入了解了V8引擎的预编译过程,这些谜团才终于解开。本文将以初学者的视角,结合V8引擎的工作流程,深入浅出地谈谈我对“预编译”的理解,希望能帮助到和我一样迷茫的小伙伴。

💡 阅读本文你将收获:彻底搞懂变量提升和函数提升的底层原理,掌握AO(执行期上下文)和GO(全局对象)的工作机制。


一、V8引擎是怎么工作的?

在谈“预编译”之前,我们得先简单了解一下V8引擎是怎么执行JavaScript代码的。V8引擎是Chrome浏览器的核心,它负责将我们写的JS代码转换成计算机能懂的机器码。

它的工作过程大致分为三步:

  1. 分词/词法分析(Tokenizing/Lexing):将代码字符串分解成一个个词法单元(tokens)
    • 例如 var a = 1; 会被分解为:vara=1;
  2. 解析/语法分析(Parsing):将词法单元流转换成一个抽象语法树(AST, Abstract Syntax Tree)。这个树描述了代码的语法结构。
  3. 代码生成:将AST转换成可执行的机器指令。

关键点来了:在第三步“代码生成”之后、正式执行代码之前,V8引擎会进行一次**“预编译”**。这就是为什么我们可以“先使用,后声明”。


二、什么是“预编译”?

预编译发生在代码执行前的那一刻。它的任务是:确定变量的定义和函数的声明,并提前分配给对应的作用域。

V8引擎会产生两种主要的执行上下文(Execution Context)

  • 全局执行上下文(Global Object, 简称 GO):对应全局作用域。
  • 函数执行上下文(Activation Object, 简称 AO):对应函数作用域。

预编译的过程,本质上就是创建和填充 GO 和 AO 的过程


三、全局预编译(GO 的创建过程)

当我们打开一个JS文件或HTML页面时,V8引擎会首先创建一个全局执行上下文 GO。它的创建遵循以下三步:

  1. 创建 GO 对象GO = { ... }
  2. 找变量声明:将变量名作为 GO 的属性名,值为 undefined
  3. 找函数声明:将函数名作为 GO 的属性名,值为函数体

💡 示例脚本 1:最简单的预编译

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

V8预编译过程分析(全局预编译):

  1. 创建 GO 对象GO = { ... }
  2. 找变量声明:找到 var a,将 a 作为 GO 的属性,值为 undefined
    • 此时 GO = { a: undefined, ... }
  3. 找函数声明:这段代码没有函数声明,跳过。

正式执行代码:

  1. 执行 console.log(a):在 GO 中找到了 a,值为 undefined,所以输出 undefined(而不是报错!)。
  2. 执行 a = 1:在 GO 中找到 a,将其值从 undefined 修改为 1

结论: 这就是“变量提升”的真相!其实变量并没有“移动”,只是V8在预编译阶段提前知道了它的存在,并给了它一个初始值 undefined


四、函数预编译(AO 的创建过程)

当我们调用一个函数时,V8引擎会为这个函数创建一个函数执行上下文 AO。相比全局预编译,它多了处理参数的步骤。

函数预编译四步法(重点!):

  1. 创建 AO 对象AO = { ... }(这个对象就是函数的作用域)。
  2. 找形参和变量声明:将形参和变量名作为 AO 的属性名,值为 undefined
  3. 将形参和实参统一:将传入的实参值赋给 AO 中对应的形参属性。
  4. 找函数声明:将函数名作为 AO 的属性名,值为函数体

💡 示例脚本 2:复杂的函数预编译

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);

V8预编译过程分析(函数预编译):

假设调用 fn(1),实参是 1

  1. 创建 AO 对象AO = { ... }
  2. 找形参和变量声明
    • 形参:a
    • 变量声明:var avar bvar c
    • 将他们添加到 AO,值为 undefined
    • 此时 AO = { a: undefined, b: undefined, c: undefined }
    • 注意:如果形参和变量同名(如 a),不会产生冲突,它们指向同一个属性。
  3. 形参实参统一:实参 1 赋给形参 a
    • 此时 AO = { a: 1, b: undefined, c: undefined }
  4. 找函数声明
    • function a() {}:函数名 a 作为属性名,函数体作为值。这会覆盖掉之前 a 的值(1)
    • function c() {}:函数名 c 作为属性名,函数体作为值。
    • 此时 AO = { a: function a() {}, b: undefined, c: function c() {} }

正式执行代码(逐行分析):

  1. console.log(a);
    • 查找 a:在 AO 中找到了 a,当前值是 function a() {}
    • 输出:ƒ a() {}
  2. var a = 123;
    • 执行赋值:将 AO 中的 a 修改为 123
    • 此时 AO = { a: 123, b: undefined, c: function c() {} }
  3. console.log(a);
    • 查找 a:在 AO 中找到了 a,当前值是 123
    • 输出:123
  4. function a() {}function c() {}
    • 这两行是函数声明,在预编译阶段已经处理过了,执行阶段会直接跳过
  5. var b = function() {}
    • 执行赋值:将 AO 中的 b 修改为这个函数表达式。
    • 此时 AO = { a: 123, b: ƒ () {}, c: function c() {} }
  6. console.log(b);
    • 输出:ƒ () {}
  7. var c = a;
    • 执行赋值:将 AO 中的 a (123) 赋给 c
    • 此时 AO = { a: 123, b: ƒ () {}, c: 123 }
  8. console.log(c);
    • 输出:123

五、进阶示例:参数未传递的情况

💡 示例脚本 3:形参多于实参

function foo(a, b) {
  console.log(b);   // 输出?
  c = 0;           // 注意:这里没有 var,c 会变成全局变量!
  var c;
  a = 3;
  b = 2;
  console.log(b);   // 输出?
  function b() {}   // 函数声明
  console.log(b);   // 输出?
}

foo(1);

V8预编译过程分析(函数预编译):

调用 foo(1),只传了一个实参 1

  1. 创建 AO 对象AO = { ... }
  2. 找形参和变量声明
    • 形参:ab
    • 变量声明:var c
    • 添加到 AO:AO = { a: undefined, b: undefined, c: undefined }
  3. 形参实参统一:只传了一个 1,所以只有 a 被赋值。
    • AO = { a: 1, b: undefined, c: undefined }
    • 规则:只传了一个值就只补从左往右第一个,另外一个是空的(undefined)。
  4. 找函数声明
    • function b() {}:函数名 b 作为属性名,函数体作为值。覆盖掉之前 b 的值(undefined)
    • AO = { a: 1, b: function b() {}, c: undefined }

正式执行代码(逐行分析):

  1. console.log(b);
    • 查找 b:在 AO 中找到了 b,当前值是 function b() {}
    • 输出:ƒ b() {}
  2. c = 0;
    • 注意:这里没有 var 关键字!V8会在当前作用域(AO)找 c,发现 AO 中有 var c,所以 c 被赋值为 0
    • 如果 AO 中也没有 c,V8 会去全局 GO 中找,如果还没有,就会在 GO 中创建一个 c(意外创建了全局变量!)。
    • 此时 AO = { a: 1, b: function b() {}, c: 0 }
  3. var c;
    • 变量声明,预编译已处理,执行阶段跳过。
  4. a = 3;
    • 修改 AO 中的 a3
  5. b = 2;
    • 修改 AO 中的 b2
    • 此时 AO = { a: 3, b: 2, c: 0 }
  6. console.log(b);
    • 输出:2
  7. function b() {}
    • 函数声明,预编译已处理,执行阶段跳过。
  8. console.log(b);
    • 输出:2

六、总结与误区澄清

通过这三个脚本的详细分析,我们可以总结出以下规律:

1. 预编译总结

  • 全局预编译:创建 GO,找变量、找函数。
  • 函数预编译:创建 AO,找形参和变量、统一参数、找函数。
  • 执行顺序:先预编译,再逐行执行。

2. 常见误区澄清

  • 误区:“变量提升是把代码移动到顶部。”
    • 正解:是V8在预编译阶段提前知道了变量存在,并在内存中分配了空间(值为 undefined)。
  • 误区:“函数声明和函数表达式提升是一样的。”
    • 正解:函数声明会整体提升(包括函数体),而函数表达式只提升变量名(值为 undefined,直到执行到赋值那一行才会有值)。

互动环节

如果你觉得这篇文章对你有帮助,请:

  • 👍 点赞支持一下

  • 💬 评论分享你的疑问或心得(比如你有没有遇到过更坑的提升问题?)

  • 🔗 分享给更多的小伙伴


参考资料

  1. JavaScript高级程序设计(第4版)
  2. 你不知道的JavaScript(上卷)
  3. 饥人谷前端视频教程(肖恩(Sean)老师)

本文为原创内容,基于个人学习笔记整理。如有错误,欢迎指正!