小白的JS学习之路(二)——“预编译”
前言
大家好,我是一名正在学习JavaScript的初学者。在学习过程中,最让我困惑的就是“为什么变量可以先使用再声明?”(即变量提升)以及“函数为什么可以先调用再定义?”。
直到我深入了解了V8引擎的预编译过程,这些谜团才终于解开。本文将以初学者的视角,结合V8引擎的工作流程,深入浅出地谈谈我对“预编译”的理解,希望能帮助到和我一样迷茫的小伙伴。
💡 阅读本文你将收获:彻底搞懂变量提升和函数提升的底层原理,掌握AO(执行期上下文)和GO(全局对象)的工作机制。
一、V8引擎是怎么工作的?
在谈“预编译”之前,我们得先简单了解一下V8引擎是怎么执行JavaScript代码的。V8引擎是Chrome浏览器的核心,它负责将我们写的JS代码转换成计算机能懂的机器码。
它的工作过程大致分为三步:
- 分词/词法分析(Tokenizing/Lexing):将代码字符串分解成一个个词法单元(tokens)。
- 例如
var a = 1;会被分解为:var、a、=、1、;。
- 例如
- 解析/语法分析(Parsing):将词法单元流转换成一个抽象语法树(AST, Abstract Syntax Tree)。这个树描述了代码的语法结构。
- 代码生成:将AST转换成可执行的机器指令。
关键点来了:在第三步“代码生成”之后、正式执行代码之前,V8引擎会进行一次**“预编译”**。这就是为什么我们可以“先使用,后声明”。
二、什么是“预编译”?
预编译发生在代码执行前的那一刻。它的任务是:确定变量的定义和函数的声明,并提前分配给对应的作用域。
V8引擎会产生两种主要的执行上下文(Execution Context):
- 全局执行上下文(Global Object, 简称 GO):对应全局作用域。
- 函数执行上下文(Activation Object, 简称 AO):对应函数作用域。
预编译的过程,本质上就是创建和填充 GO 和 AO 的过程。
三、全局预编译(GO 的创建过程)
当我们打开一个JS文件或HTML页面时,V8引擎会首先创建一个全局执行上下文 GO。它的创建遵循以下三步:
- 创建 GO 对象:
GO = { ... } - 找变量声明:将变量名作为 GO 的属性名,值为
undefined。 - 找函数声明:将函数名作为 GO 的属性名,值为函数体。
💡 示例脚本 1:最简单的预编译
console.log(a);
var a = 1;
V8预编译过程分析(全局预编译):
- 创建 GO 对象:
GO = { ... } - 找变量声明:找到
var a,将a作为 GO 的属性,值为undefined。- 此时
GO = { a: undefined, ... }
- 此时
- 找函数声明:这段代码没有函数声明,跳过。
正式执行代码:
- 执行
console.log(a):在 GO 中找到了a,值为undefined,所以输出undefined(而不是报错!)。 - 执行
a = 1:在 GO 中找到a,将其值从undefined修改为1。
结论: 这就是“变量提升”的真相!其实变量并没有“移动”,只是V8在预编译阶段提前知道了它的存在,并给了它一个初始值 undefined。
四、函数预编译(AO 的创建过程)
当我们调用一个函数时,V8引擎会为这个函数创建一个函数执行上下文 AO。相比全局预编译,它多了处理参数的步骤。
函数预编译四步法(重点!):
- 创建 AO 对象:
AO = { ... }(这个对象就是函数的作用域)。 - 找形参和变量声明:将形参和变量名作为 AO 的属性名,值为
undefined。 - 将形参和实参统一:将传入的实参值赋给 AO 中对应的形参属性。
- 找函数声明:将函数名作为 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。
- 创建 AO 对象:
AO = { ... } - 找形参和变量声明:
- 形参:
a - 变量声明:
var a、var b、var c - 将他们添加到 AO,值为
undefined。 - 此时
AO = { a: undefined, b: undefined, c: undefined } - 注意:如果形参和变量同名(如
a),不会产生冲突,它们指向同一个属性。
- 形参:
- 形参实参统一:实参
1赋给形参a。- 此时
AO = { a: 1, b: undefined, c: undefined }
- 此时
- 找函数声明:
function a() {}:函数名a作为属性名,函数体作为值。这会覆盖掉之前a的值(1)!function c() {}:函数名c作为属性名,函数体作为值。- 此时
AO = { a: function a() {}, b: undefined, c: function c() {} }
正式执行代码(逐行分析):
console.log(a);- 查找
a:在 AO 中找到了a,当前值是function a() {}。 - 输出:
ƒ a() {}
- 查找
var a = 123;- 执行赋值:将 AO 中的
a修改为123。 - 此时
AO = { a: 123, b: undefined, c: function c() {} }
- 执行赋值:将 AO 中的
console.log(a);- 查找
a:在 AO 中找到了a,当前值是123。 - 输出:
123
- 查找
function a() {}和function c() {}- 这两行是函数声明,在预编译阶段已经处理过了,执行阶段会直接跳过!
var b = function() {}- 执行赋值:将 AO 中的
b修改为这个函数表达式。 - 此时
AO = { a: 123, b: ƒ () {}, c: function c() {} }
- 执行赋值:将 AO 中的
console.log(b);- 输出:
ƒ () {}
- 输出:
var c = a;- 执行赋值:将 AO 中的
a(123) 赋给c。 - 此时
AO = { a: 123, b: ƒ () {}, c: 123 }
- 执行赋值:将 AO 中的
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。
- 创建 AO 对象:
AO = { ... } - 找形参和变量声明:
- 形参:
a、b - 变量声明:
var c - 添加到 AO:
AO = { a: undefined, b: undefined, c: undefined }
- 形参:
- 形参实参统一:只传了一个
1,所以只有a被赋值。AO = { a: 1, b: undefined, c: undefined }- 规则:只传了一个值就只补从左往右第一个,另外一个是空的(
undefined)。
- 找函数声明:
function b() {}:函数名b作为属性名,函数体作为值。覆盖掉之前b的值(undefined)!AO = { a: 1, b: function b() {}, c: undefined }
正式执行代码(逐行分析):
console.log(b);- 查找
b:在 AO 中找到了b,当前值是function b() {}。 - 输出:
ƒ b() {}
- 查找
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 }
- 注意:这里没有
var c;- 变量声明,预编译已处理,执行阶段跳过。
a = 3;- 修改 AO 中的
a为3。
- 修改 AO 中的
b = 2;- 修改 AO 中的
b为2。 - 此时
AO = { a: 3, b: 2, c: 0 }
- 修改 AO 中的
console.log(b);- 输出:
2
- 输出:
function b() {}- 函数声明,预编译已处理,执行阶段跳过。
console.log(b);- 输出:
2
- 输出:
六、总结与误区澄清
通过这三个脚本的详细分析,我们可以总结出以下规律:
1. 预编译总结
- 全局预编译:创建 GO,找变量、找函数。
- 函数预编译:创建 AO,找形参和变量、统一参数、找函数。
- 执行顺序:先预编译,再逐行执行。
2. 常见误区澄清
- 误区:“变量提升是把代码移动到顶部。”
- 正解:是V8在预编译阶段提前知道了变量存在,并在内存中分配了空间(值为
undefined)。
- 正解:是V8在预编译阶段提前知道了变量存在,并在内存中分配了空间(值为
- 误区:“函数声明和函数表达式提升是一样的。”
- 正解:函数声明会整体提升(包括函数体),而函数表达式只提升变量名(值为
undefined,直到执行到赋值那一行才会有值)。
- 正解:函数声明会整体提升(包括函数体),而函数表达式只提升变量名(值为
互动环节
如果你觉得这篇文章对你有帮助,请:
-
👍 点赞支持一下
-
💬 评论分享你的疑问或心得(比如你有没有遇到过更坑的提升问题?)
-
🔗 分享给更多的小伙伴
参考资料
- JavaScript高级程序设计(第4版)
- 你不知道的JavaScript(上卷)
- 饥人谷前端视频教程(肖恩(Sean)老师)
本文为原创内容,基于个人学习笔记整理。如有错误,欢迎指正!