前言:那些你看不懂的 JS "反常" 现象
你有没有遇到过这些 JS 场景:
- 先调用函数,再写函数定义,代码居然正常运行不报错?
- 先输出变量,再用
var声明变量,结果不是报错,而是输出undefined? - 函数里的同名形参、变量、函数声明,输出结果总是和你想的不一样?
这些看似 "不讲道理" 的现象,背后深藏着 JS 最核心的底层机制 ------预编译。
它是 JS 引擎执行代码前的底层预处理机制,是变量提升、作用域运行的核心根基。吃透预编译,方能洞悉 JS 执行底层逻辑,摆脱代码玄学,夯实前端核心功底。
一、先搞懂:V8 引擎是怎么执行 JS 的?
回顾链接:juejin.cn/post/764262...
我们写的 JS 代码,最终是交给浏览器的 V8 引擎执行的。它的工作流程分为三步,而预编译,就藏在执行前的准备环节里:
-
词法分析(分词) :把你写的代码字符串,拆成一个个有意义的 "小单元"(术语叫 Token),比如
var、function、变量名、运算符这些,相当于把句子拆成一个个词语。 -
语法分析(解析) :把上一步拆好的 Token,按照 JS 的语法规则,组成一棵AST 抽象语法树,同时检查你的代码有没有语法错误(比如少写了括号、关键字写错)。
-
预编译(代码生成前的预处理) :这就是我们今天的主角!在执行任何一行代码之前,引擎会先做一遍 "预编译" 的准备工作,提前处理所有的变量和函数声明,生成可执行的代码。预编译发生在语法分析之后,代码执行之前,这也是所有 "变量提升" 现象的根源。
二、核心概念:预编译到底是什么?
一句话总结:预编译,就是 JS 引擎在执行代码前,提前处理所有声明的过程。
JS 并不是 "执行一行,编译一行",而是会先看一遍代码,把所有的变量声明、函数声明都提前处理好,放到对应的 "容器" 里,然后开始执行代码。
这个 "容器",在函数体内叫AO对象 ,在全局里叫GO对象 。
三、函数里的预编译:一步步拆解(AO 对象)
当函数被调用时,会创建它自己的执行上下文,同时生成对应的 AO 对象,预编译会按固定的 4 步处理,我们用一个例子配合讲解,你一眼就能看懂。
预编译 4 步走
- 创建AO对象:函数执行时,首先创建一个执行上下文 AO:{},它就是函数自己的 "作用域容器"。
js
AO = {}
- 处理形参和变量声明 :找形参和变量声明,将形参和变量名作为属性名,添加到 AO 中,值为
undefined。
注意:这里只处理声明,不处理赋值
-
形参和实参统一:把调用函数时传入的实参值,赋值给 AO 里对应的形参属性。
-
处理函数声明 :在函数体内找函数声明(形如
function 函数名() {}这种形式),将函数名作为AO 中的属性名,值就是整个函数体。
注意 :这一步会覆盖之前同名的
undefined值,这就是函数提升优先级比变量提升高的原因!
举个例子,你可以一眼看懂
我们用这段代码来拆解预编译过程:
js
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)
预编译阶段:AO 对象的诞生与初始化
步骤1:创建空的AO 对象
函数被调用时,第一步就是创建一个空的 AO 对象:
js
AO = {}
步骤2:处理形参和变量声明,初始化值为undefined
JS引擎会找出函数内的所有形参 和所有变量声明 ,把它们的名字作为 AO 的属性名,值先设为undefined。
注意:这一步只处理声明,不处理赋值,也不处理函数声明(函数声明在第 4 步处理)。
- 形参:
a→ 在AO 添加a: undefined - 变量声明:
var a、var b、var c→ 分别添加到 AO(重复声明不影响,值还是undefined)
这一步结束后,AO 就长这样:
js
AO = {
a: undefined,
b: undefined,
c: undefined
}
步骤3:形参和实参统一,用实参更新形参的值
函数调用时传入的实参 1,会赋值给对应的形参 a,更新 AO 里的a的值:
js
AO = {
a: 1, // 从undefined更新为实参的值1
b: undefined,
c: undefined
}
步骤4:处理函数声明,覆盖同名属性值
JS引擎会找出函数内的所有函数声明 (形如function 函数名() {} 形式),把函数名作为 AO 的属性名,值设为函数体本身。
注意:这一步会覆盖之前同名的形参 / 变量值,这就是 "函数提升优先级高于变量提升" 的本质!
例子里的函数声明有两个:function a() {} 和 function c() {},依次处理:
- 处理
function a() {}:覆盖 AO 里的a,值变为函数体 - 处理
function c() {}:覆盖 AO 里的c,值变为函数体
这一步结束后,预编译完成,最终的 AO 是这样的:
js
AO = {
a: function a() {}, // 被函数声明所覆盖,不再是1或undefined
b: undefined, // 函数表达式`var b = function() {}`不是函数声明,只有变量声明,值仍为undefined
c: function c() {} // 被函数声明所覆盖,不再是undefined
}
执行阶段:逐行代码如何修改 AO?
预编译完成后,JS引擎开始从上到下执行代码,所有变量 / 函数的读写都直接操作 AO 对象。让我们逐行拆解:
-
console.log(a);:此时预编译刚结束,AO 里的a是function a() {},所以输出结果为function a() {}。 -
var a = 123;:这是赋值操作 (var声明已在预编译处理过,执行阶段只做赋值),会修改 AO 里的a的值:
js
AO = {
a: 123, // 从function a() {}更新为123
b: undefined,
c: function c() {}
}
-
console.log(a);:AO 里的a现在是123,所以输出结果为123。 -
function a() {}:划重点:函数声明在预编译阶段已经处理完毕,执行阶段会直接跳过这行代码 ,不会对 AO 产生任何影响!很多人误以为这里会重新声明a,其实完全不会。 -
var b = function() {}:这是赋值操作 (var b的声明已在预编译处理),修改 AO 里的b的值:
js
AO = {
a: 123,
b: function() {}, // 从undefined更新为function(){}函数
c: function c() {}
}
-
console.log(b);:AO 里的b现在是function(){}函数,所以输出结果为function() {}。 -
function c() {}:和第 4 行一样,函数声明已在预编译处理,执行阶段直接跳过,不影响 AO。 -
var c = a:这是赋值操作,把 AO 里a的值(当前是123)赋值给c,修改 AO 里的c的值:
AO = { a: 123,
b: function() {}, c: 123 // 从function c() {}更新为123 }
lua
9.`console.log(c);`:AO 里的`c`现在是`123`,所以输出结果为 `123`。
**该执行阶段结束**
最终例子所输出的结果:
```js
function fn(a) {
console.log(a); // function a() {}
var a = 123
console.log(a); // 123
function a() {}
var b = function() {}
console.log(b); // function() {}
function c() {}
var c = a
console.log(c); // 123
}
fn(1)
这么一对照,是不是瞬间就把函数预编译的底层逻辑吃透了?且全局预编译和它的套路几乎一模一样,甚至还更简单、更好理解了呢!
四、全局的预编译:规则和例子(GO 对象)
全局的预编译和函数里的逻辑几乎一样,只是把 AO 换成了 GO(全局对象),而且没有形参的步骤。
全局预编译 3 步走
- 创建 GO 对象:创建全局执行上下文 GO:{}
- 处理全局变量声明:找全局变量声明,将全局变量名作为属性名,添加到 GO 中,值为 undefined
- 处理全局函数声明:找全局函数声明,将函数名作为GO 的属性名,函数体作为属性值
再举个例子:
js
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); // ?
}
想要知道该例子所输出的结果是什么,首先要搞懂:整个流程的执行顺序
JS 代码的执行顺序是固定的三步,我们先把大框架说清楚:
- 第一步:全局预编译 (代码执行前,先处理所有全局声明,生成
GO对象) - 第二步:执行全局代码(从上到下执行,遇到函数调用时,暂停全局代码,进入函数执行)
- 第三步:函数预编译 + 执行 (调用函数时,先做函数的预编译,生成
AO对象,再执行函数内部代码) - 第四步:回到全局代码,继续执行剩下的内容
诸位大佬,请让我们就按这个顺序,一步步来拆解这个例子吧。
第一步:全局预编译(生成GO对象)
预编译发生在任何代码执行之前 ,引擎会先找一下所有全局的var声明和function声明,生成全局对象GO。
你的全局代码里,有哪些声明?
js
var a; // 全局变量声明
var b = 2; // 全局变量声明(带赋值,但预编译只处理声明)
function a() {} // 全局函数声明
全局预编译 3 步走
- 创建空的
GO对象
js
GO = {}
- 处理
var声明,值初始化为undefinedJS引擎找到var a和var b,把它们加到GO里,值先设为undefined:
js
GO = {
a: undefined,
b: undefined
}
- 处理
function声明,覆盖同名变量 JS引擎找到到function a() {},会把函数名a加到GO里,值就是整个函数体。注意:这里的a和之前的var a同名,函数声明会覆盖变量声明的undefined值!
js
GO = {
a: function a() { ... }, // 被函数声明覆盖了
b: undefined
}
这一步结束,你的注释里的GO就成型了:
js
// GO = {
// a: undefined → function a() {xx},
// b: undefined → 2 // 这个'→2'是后面执行阶段的赋值,预编译时还是undefined
// }
第二步:执行全局代码(遇到函数调用前)
预编译完成后,引擎开始从上到下执行全局代码:
var a;:声明已经在预编译处理过了,这里没有赋值,所以GO中的a还是function a() {},不产生变化。var b = 2;:赋值操作!把GO中的b从undefined改成2,现在GO中的b = 2。function a() {}:函数声明已经在预编译处理过了,执行阶段直接跳过,不产生任何影响。a();:调用函数a,暂停全局代码,进入函数a的执行流程。
第三步:函数a的预编译 + 执行(生成AO对象)
调用函数时,JS引擎会先为函数创建执行上下文 ,生成专属的AO对象(函数的 "局部仓库"),再执行函数内部代码。
1. 函数预编译(生成AO对象)
按之前讲的 4 步走:
- 创建空的
AO对象
js
AO = {}
- 处理形参和变量声明,值设为
undefined: 函数a没有形参,所以只处理var声明的变量:var c和var a,加到AO里:
js
AO = { c: undefined, a: undefined }
-
形参实参相统一:没有形参,直接跳过。
-
处理函数声明,覆盖同名属性值 :找到
function c() {},把AO.c的值从undefined改成函数体:
js
AO = {
c: function c() {}, // 被函数声明覆盖
a: undefined
}
这一步就结束了,你的注释里的AO就成型了:
js
// AO = {
// c: undefined → function c() {} → 3,
// a: undefined → 2,
// }
2. 执行函数内部代码(逐行看AO的变化)
预编译完成后,开始执行函数里的每一行代码:
-
console.log(a);:找AO中的a,此时是undefined,所以输出的是undefined。 -
var c = 3;:赋值操作!把AO中的c从function c() {}改成3,现在AO中的c = 3。 -
var a = b;:赋值操作!这里的b要怎么找?- 先看
AO里有没有b?没有! - 去全局
GO里找,GO中的b = 2,所以把AO中的a从undefined改成2,现在AO中的a = 2。
- 先看
-
function c() {}:函数声明已经在预编译处理过了,执行阶段直接跳过。 -
console.log(c);:找AO中的c,此时是3,所以输出的是3。
函数执行结束后,AO对象会被销毁(里面的a和c都是局部变量,不会影响全局的GO)。
第四步:回到全局代码,执行最后一行
函数执行完,回到全局代码,执行最后一行:
js
console.log(a);
这里的a找的是全局GO中的a,它的值还是预编译阶段生成的function a() {}(函数里的a是局部变量,不影响全局的a),所以输出结果是function a() {xx}。
即最终例子所输出的结果:
js
// GO = {
// a: undefined function a() {xx},
// b: undefined 2
// }
var a
var b = 2
function a () {
console.log(a); // undefined
var c = 3
var a = b
function c() {}
console.log(c); // 3
}
// AO = {
// c: undefined function c() {} 3,
// a: undefined 2,
// }
a()
console.log(a); // function a() {xx}
如此一来,相信你已经吃透了预编译机制啦
五、预编译的结果:变量提升 vs 函数提升
我们常说的 "变量提升" 和 "函数提升",本质上就是预编译的结果:
| 类型 | 规则 | 特点 |
|---|---|---|
| 变量提升 | 用var声明的变量,会被提升到作用域顶部,值初始为undefined,赋值留在原地 |
只提升声明,不提升赋值 |
| 函数提升 | 用function声明的函数,会被整个提升到作用域顶部,值就是函数体 |
优先级比变量提升高,会覆盖同名变量的undefined值 |
注意:只有
var声明的变量和function声明的函数会被提升,let/const声明的变量会被 "提升" 但进入暂时性死区,不会初始化为undefined,所以提前访问会报错;函数表达式(比如var a = function() {})本质是变量提升,不会有函数提升的效果。
六、总结:
-
预编译统一在代码运行前执行,分为全局和函数两种场景。
-
函数预编译分四步,创建 AO ,找形参变量,统一实参、找函数声明。函数声明优先级高于变量。
-
全局预编译三步走,创建 GO ,找变量、找函数声明。
结语
看完这些内容,相信就能帮大家揭开 JavaScript 预编译的神秘面纱啦。弄懂这些知识,不光能避开日常写代码时不少常见问题,还能让咱们写出的代码逻辑更清晰、运行更高效。
要记得,JavaScript 总会在后台悄悄做好前期准备工作,稳稳保障每一行代码都能按照预期正常执行。