从变量提升到 V8 预编译,彻底搞懂 JS 执行机制

1 阅读5分钟

引言

  写 JS 谁没被变量提升坑过?明明变量写在后面,前面却打印出 undefined;函数定义在末尾,开头就能直接调用。很多人背了 “声明会提升” 的口诀,却以为是代码被偷偷搬到了顶部 —— 大错特错!这根本不是什么玄学bug,而是V8引擎在代码执行前悄悄搞的预编译小动作。

1.变量提升现象

  那什么是变量提升呢?接下来用一段代码告诉你:

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

输出结果为:

image.png

  你看,我们明明先打印 a、后定义 a,输出的却不是预期的报错,而是undefined。这就是前端入门必踩的“变量声明提升”坑。它的本质是:V8 引擎在代码执行前,会先偷偷把变量 a 的声明提到当前作用域的最前面,赋值操作却原封不动留在原地。所以这段代码的实际执行顺序,相当于引擎帮你偷偷改成了这样:
image.png

2.什么是V8引擎的预编译?

  • 预编译发生在代码执行之前,但并不是“一次性编译完所有代码”,函数的预编译是在“函数被调用时”才触发的,全局预编译在代码加载时触发。
  • 预编译只处理“声明”(变量声明var、函数声明function),不处理“赋值操作”。这也是为什么变量提升后,值是undefined的原因。
  • let、const没有变量提升,严格来说是“暂时性死区”,本质是V8引擎对let、const的预编译处理和var不同,后续会在误区中补充。

3.预编译的两个场景 GO 全局/AO 函数

 3.1 AO函数

  函数的预编译,只有在函数被调用时才会触发,核心是创建AO对象(执行上下文对象),用一段代码解释:

function foo(a, b) {
  console.log(b);
  c = 0
  var c
  a = 3
  b = 2
  console.log(b);
  function b() {}
  console.log(b);
}
foo(1)

步骤如下:

  1. 创建一个执行上下文对象 AO:{},初始值为空对象。
  2. 去函数体内找形参和变量声明,将形参和变量名作为属性名,添加到 AO 中,值为undfined。
  • 形参:a、b → AO添加属性a: undefined,b: undefined
  • 变量声明:var c :undefined
  • 此时AO:{a: undefined, b: undefined,c:undfined}
  1. 将形参和实参统一。
  • 函数调用时实参是1,对应形参a → a = 1
  • 实参只有一个,形参b没有对应实参,仍为undefined
  • 此时AO:{a: 1, b: undefined,c:undfined}
  1. 在函数体内找到函数声明,将函数名作为AO 中的属性名,函数体作为属性值。
  • 函数声明:function b() {} → AO添加属性b: function b() {}
  • 此时AO:{a: 1, b: function b() {}, c:undfined }

  最后输出结果为:

image.png

  如果还是觉得抽象,我给你打个最通俗的比方:我们把 V8 引擎比作一家公司的大老板,他脾气很倔,只认最终的执行指令,拿到手就直接干,绝不帮你整理乱七八糟的材料。而预编译,就是老板身边那个能力超强的全能女秘书。

  当你把一整段 JS 代码(也就是一堆待处理的工作任务)扔给公司时,绝不会直接堆到老板桌上。秘书会第一时间把所有任务全部过一遍,雷打不动做两件核心工作:

  1. 先把所有立项申请单独挑出来:也就是所有的var变量声明和function函数声明,提前拿去给老板签字审批。老板签完字,就代表这个项目(变量 / 函数)在公司系统里正式存在了,只是还没分配资源开始干活(默认值为undefined)。
  2. 再把剩下的具体执行任务按原顺序整理好:也就是赋值、运算、函数调用、打印这些真正干活的代码,等所有立项全部审批完成后,再按顺序交给老板执行。

 3.2 GO全局

  全局预编译,在代码加载完成后、执行之前触发,核心是创建GO对象(全局执行上下文对象),同样结合案例:

function a () {}
console.log(a);
var a = 1
var b = 2

步骤如下

  1. 创建全局执行上下文 对象GO:{}
  2. 去全局体内找变量声明,将变量名作为属性名,添加到 GO 中,值为undfined。
  • 变量声明:var a,b → GO添加属性a: undefined,b: undefined;
  • 此时GO:{a: undefined,a: undefined}
  1. 将函数声明作为属性名,函数体作为属性值,添加到 GO 中。

-函数声明:function a() {}、 → GO属性中a重新被赋值: function a(){} -此时GO:{a: function a(){}, b: undefined}

最后输出:

image.png

4.总结

  到这里,我们就彻底搞懂了 JS 变量提升和预编译的底层逻辑。所有看似反直觉的变量提升现象,本质上都不是代码被物理移动到了作用域顶部,而是 V8 引擎在代码执行前,通过预编译阶段提前完成了所有声明的内存初始化。

  • 预编译的触发时机:全局预编译在代码加载完成后触发,函数预编译只在函数被调用的瞬间触发,函数执行完毕后对应的 AO 对象会立即销毁。
  • 预编译的核心工作:只处理var变量声明和function函数声明,完全不处理赋值、运算、打印等执行逻辑,这也是为什么变量提升后默认值是undefined的根本原因。
  • 两个核心对象的执行规则:
  1. 全局预编译(GO):创建全局执行上下文 → 收集所有var变量声明并赋值为undefined → 收集所有函数声明并赋值为函数体
  2. 函数预编译(AO):创建函数执行上下文 → 收集形参和var变量声明并赋值为undefined → 形参实参统一 → 收集内部函数声明并赋值为函数体
  • 黄金优先级规则:函数声明提升优先级 > 变量声明提升优先级,同名情况下函数声明会先占据内存,变量声明不会覆盖已有的函数声明,只有后续的赋值操作才会覆盖。