揭开神秘面纱!JS 代码执行前竟暗藏玄机

前言:那些你看不懂的 JS "反常" 现象

你有没有遇到过这些 JS 场景:

  • 先调用函数,再写函数定义,代码居然正常运行不报错?
  • 先输出变量,再用var声明变量,结果不是报错,而是输出undefined
  • 函数里的同名形参、变量、函数声明,输出结果总是和你想的不一样?

这些看似 "不讲道理" 的现象,背后深藏着 JS 最核心的底层机制 ------预编译

它是 JS 引擎执行代码前的底层预处理机制,是变量提升、作用域运行的核心根基。吃透预编译,方能洞悉 JS 执行底层逻辑,摆脱代码玄学,夯实前端核心功底。

一、先搞懂:V8 引擎是怎么执行 JS 的?

回顾链接:juejin.cn/post/764262...

我们写的 JS 代码,最终是交给浏览器的 V8 引擎执行的。它的工作流程分为三步,而预编译,就藏在执行前的准备环节里:

  • 词法分析(分词) :把你写的代码字符串,拆成一个个有意义的 "小单元"(术语叫 Token),比如varfunction、变量名、运算符这些,相当于把句子拆成一个个词语。

  • 语法分析(解析) :把上一步拆好的 Token,按照 JS 的语法规则,组成一棵AST 抽象语法树,同时检查你的代码有没有语法错误(比如少写了括号、关键字写错)。

  • 预编译(代码生成前的预处理) :这就是我们今天的主角!在执行任何一行代码之前,引擎会先做一遍 "预编译" 的准备工作,提前处理所有的变量和函数声明,生成可执行的代码。预编译发生在语法分析之后,代码执行之前,这也是所有 "变量提升" 现象的根源。

二、核心概念:预编译到底是什么?

一句话总结:预编译,就是 JS 引擎在执行代码前,提前处理所有声明的过程

JS 并不是 "执行一行,编译一行",而是会先看一遍代码,把所有的变量声明、函数声明都提前处理好,放到对应的 "容器" 里,然后开始执行代码。

这个 "容器",在函数体内叫AO对象 ,在全局里叫GO对象

三、函数里的预编译:一步步拆解(AO 对象)

当函数被调用时,会创建它自己的执行上下文,同时生成对应的 AO 对象,预编译会按固定的 4 步处理,我们用一个例子配合讲解,你一眼就能看懂。

预编译 4 步走

  1. 创建AO对象:函数执行时,首先创建一个执行上下文 AO:{},它就是函数自己的 "作用域容器"。
js 复制代码
AO = {}
  1. 处理形参和变量声明 :找形参和变量声明,将形参和变量名作为属性名,添加到 AO 中,值为 undefined

注意:这里只处理声明,不处理赋值

  1. 形参和实参统一:把调用函数时传入的实参值,赋值给 AO 里对应的形参属性。

  2. 处理函数声明 :在函数体内找函数声明(形如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 avar bvar 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() {},依次处理:

  1. 处理 function a() {}:覆盖 AO 里的a,值变为函数体
  2. 处理 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 对象。让我们逐行拆解:

  1. console.log(a);:此时预编译刚结束,AO 里的afunction a() {},所以输出结果为 function a() {}

  2. var a = 123;:这是赋值操作var声明已在预编译处理过,执行阶段只做赋值),会修改 AO 里的a的值:

js 复制代码
AO = { 
   a: 123, // 从function a() {}更新为123 
   b: undefined, 
   c: function c() {} 
   }
  1. console.log(a);:AO 里的a现在是123,所以输出结果为 123

  2. function a() {}:划重点:函数声明在预编译阶段已经处理完毕,执行阶段会直接跳过这行代码 ,不会对 AO 产生任何影响!很多人误以为这里会重新声明a,其实完全不会。

  3. var b = function() {}:这是赋值操作var b的声明已在预编译处理),修改 AO 里的b的值:

js 复制代码
AO = { 
   a: 123, 
   b: function() {}, // 从undefined更新为function(){}函数 
   c: function c() {} 
}
  1. console.log(b);:AO 里的b现在是function(){} 函数,所以输出结果为 function() {}

  2. function c() {}:和第 4 行一样,函数声明已在预编译处理,执行阶段直接跳过,不影响 AO。

  3. 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 步走

  1. 创建 GO 对象:创建全局执行上下文 GO:{}
  2. 处理全局变量声明:找全局变量声明,将全局变量名作为属性名,添加到 GO 中,值为 undefined
  3. 处理全局函数声明:找全局函数声明,将函数名作为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 代码的执行顺序是固定的三步,我们先把大框架说清楚:

  1. 第一步:全局预编译 (代码执行前,先处理所有全局声明,生成GO对象)
  2. 第二步:执行全局代码(从上到下执行,遇到函数调用时,暂停全局代码,进入函数执行)
  3. 第三步:函数预编译 + 执行 (调用函数时,先做函数的预编译,生成AO对象,再执行函数内部代码)
  4. 第四步:回到全局代码,继续执行剩下的内容

诸位大佬,请让我们就按这个顺序,一步步来拆解这个例子吧。

第一步:全局预编译(生成GO对象)

预编译发生在任何代码执行之前 ,引擎会先找一下所有全局的var声明和function声明,生成全局对象GO

你的全局代码里,有哪些声明?

js 复制代码
var a; // 全局变量声明 
var b = 2; // 全局变量声明(带赋值,但预编译只处理声明) 
function a() {} // 全局函数声明

全局预编译 3 步走

  1. 创建空的GO对象
js 复制代码
GO = {}
  1. 处理var声明,值初始化为undefined JS引擎找到var avar b,把它们加到GO里,值先设为undefined
js 复制代码
GO = { 
  a: undefined, 
  b: undefined 
}
  1. 处理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
// }

第二步:执行全局代码(遇到函数调用前)

预编译完成后,引擎开始从上到下执行全局代码:

  1. var a;:声明已经在预编译处理过了,这里没有赋值,所以GO中的a还是function a() {},不产生变化。
  2. var b = 2;:赋值操作!把GO中的bundefined改成2,现在GO中的b = 2
  3. function a() {}:函数声明已经在预编译处理过了,执行阶段直接跳过,不产生任何影响。
  4. a();:调用函数a,暂停全局代码,进入函数a的执行流程。

第三步:函数a的预编译 + 执行(生成AO对象)

调用函数时,JS引擎会先为函数创建执行上下文 ,生成专属的AO对象(函数的 "局部仓库"),再执行函数内部代码。

1. 函数预编译(生成AO对象)

按之前讲的 4 步走:

  1. 创建空的AO对象
js 复制代码
AO = {}
  1. 处理形参和变量声明,值设为undefined : 函数a没有形参,所以只处理var声明的变量:var cvar a,加到AO里:
js 复制代码
AO = { c: undefined, a: undefined }
  1. 形参实参相统一:没有形参,直接跳过。

  2. 处理函数声明,覆盖同名属性值 :找到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的变化)

预编译完成后,开始执行函数里的每一行代码:

  1. console.log(a);:找AO中的a,此时是undefined,所以输出的是undefined

  2. var c = 3;:赋值操作!把AO中的cfunction c() {}改成3,现在AO中的c = 3

  3. var a = b;:赋值操作!这里的b要怎么找?

    • 先看AO里有没有b?没有!
    • 去全局GO里找,GO中的b = 2,所以把AO中的aundefined改成2,现在AO中的a = 2
  4. function c() {}:函数声明已经在预编译处理过了,执行阶段直接跳过。

  5. console.log(c);:找AO中的c,此时是3,所以输出的是3

函数执行结束后,AO对象会被销毁(里面的ac都是局部变量,不会影响全局的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 总会在后台悄悄做好前期准备工作,稳稳保障每一行代码都能按照预期正常执行。

相关推荐
许彰午12 小时前
32 个 Vue 组件的设计取舍
前端·javascript·vue.js
Asize12 小时前
JavaScript 对象通关指南:从字面量到原型链,一篇文章踩遍所有坑
前端·javascript
幸运小圣13 小时前
前端三种输入数据来源生成 worksheet(工作表)新手适用详细篇【SheetJS】
开发语言·前端·javascript
Hilaku13 小时前
如何实现 0 毫秒无感页面跳转?聊聊被低估的 Speculation Rules API
前端·javascript·程序员
Larcher15 小时前
数组去重算法:理论与实践深度解析
javascript·算法·代码规范
XinZong15 小时前
一起来聊聊?OpenClaw 的 Skill 是提效的技能工具,还是又一个吃灰的 App 柜?
javascript
卷帘依旧15 小时前
Transpiler和Polyfill分别是什么作用
javascript
之歆15 小时前
Day17_JavaScript高级核心垃圾回收执行上下文闭包完全指南(上)
开发语言·javascript·ecmascript
狗都不学爬虫_16 小时前
JS逆向 - QY信息公示登录(加速乐+阉割版5S+瑞树+鸡眼4)
javascript·爬虫·python