为了账号安全,请及时绑定邮箱和手机立即绑定

JavaScript 执行前的“秘密会议”:深入解析预编译与作用域机制

标签:
JavaScript

在我们惊叹于 JavaScript 代码的流畅执行时,很少有人意识到,在 console.log 输出结果之前,引擎内部早已进行了一场精密的“筹备会议”。这场会议的核心,就是预编译(Hoisting)。要真正理解它,我们必须先窥探 V8 引擎的工作流程。

V8 引擎处理一段 JavaScript 代码,并非简单地从上到下逐行解释。它首先会经历一个编译阶段:将源代码分解为一个个基础的词法单元(Tokens),如 vara= 等;接着,通过语法分析将这些单元组织成一棵结构化的 AST(抽象语法树);最后,基于这棵树生成可执行的字节码。而预编译,正是发生在 AST 构建完成之后、代码正式执行之前的这个关键环节

一个反直觉的现象:为何 undefined 而非报错?

让我们从一个经典例子入手:

console.log(a); // 输出: undefined
var a = 1;

按照“从上到下”的朴素执行逻辑,console.log(a) 时变量 a 尚未声明,理应抛出 ReferenceError。然而,现实却输出了 undefined。这背后的原因,正是预编译在“暗中操作”。

在代码执行前,引擎已经完成了对当前作用域的扫描:

  1. 发现变量声明var a
  2. 提升(Hoist)声明:将变量 a声明提升到当前作用域(此处为全局作用域)的顶部。
  3. 初始化为 undefined:此时变量 a 已被创建,但尚未被赋值,因此其值为 undefined

所以,代码的实际执行顺序更像是这样:

var a; // 预编译阶段:声明被提升并初始化为 undefined
console.log(a); // 执行阶段:输出 undefined
a = 1; // 执行阶段:赋值

这种将变量和函数声明移动到其作用域顶部的行为,就是我们常说的声明提升。它的存在,是为了让 JavaScript 引擎能够在执行前就建立起一个完整的“内存蓝图”,明确知道在当前作用域内有哪些变量和函数可供使用。

预编译的两种形态:全局与函数

预编译并非千篇一律,它根据作用域的不同,分为全局预编译函数体内预编译两种。它们的触发时机和具体步骤略有差异。

1. 函数体内的预编译:一次调用,一次准备

函数的预编译是“懒惰”的,只有在函数被实际调用时才会触发。其过程围绕一个名为 AO(Activation Object,激活对象) 的执行上下文展开,步骤如下:

  • 步骤一:创建 AO 对象。这是一个空的容器,用于存放函数内部的所有标识符。
  • 步骤二:处理形参与变量声明。将函数的形参和所有 var 声明的变量名作为 AO 的属性,初始值设为 undefined。如果形参和变量同名,后者会覆盖前者(但在初始阶段,值仍由形参决定)。
  • 步骤三:实参与形参绑定。将传入的实参值赋给 AO 中对应的形参属性。
  • 步骤四:处理函数声明。将函数体内所有函数声明的函数名作为 AO 的属性,其值为整个函数体。注意:函数声明的提升优先级高于变量声明

实战演练

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

让我们跟随预编译的脚步,构建 fn 的 AO:

  1. 创建 AO: AO = {}
  2. 扫描形参和变量: 找到形参 a,变量 a, b, c。合并后 AO = { a: undefined, b: undefined, c: undefined }
  3. 绑定实参: 实参 1 赋给 aAO = { a: 1, b: undefined, c: undefined }
  4. 处理函数声明: 找到 function a() {}function c() {}。它们会覆盖 AO 中同名的属性。最终 AO = { a: function a(){}, b: undefined, c: function c(){} }

预编译完成后,代码开始执行:

  • 第一个 console.log(a) 输出函数 a
  • var a = 123a 重新赋值为 123
  • var b = function() {} 将匿名函数赋给 b
  • var c = aa 的当前值 123 赋给 c

最终的输出完美印证了预编译的威力。

2. 全局预编译:代码加载即启动

与函数不同,全局预编译在脚本加载时就立即执行。它围绕 GO(Global Object,全局对象) 进行,步骤更为简洁:

  • 步骤一:创建 GO 对象
  • 步骤二:处理全局变量声明。将所有 var 声明的全局变量名作为 GO 的属性,值为 undefined
  • 步骤三:处理全局函数声明。将所有全局函数声明的函数名作为 GO 的属性,值为函数体。

全局视角下的嵌套

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

全局预编译 (GO):

  1. 扫描全局变量: a, bGO = { a: undefined, b: undefined }
  2. 扫描全局函数: function a() {...}GO = { a: function a(){...}, b: undefined }

执行阶段:

  • var b = 2b 赋值为 2
  • a() 被调用,触发函数 a 的预编译,创建其 AO。

函数 a 的预编译 (AO):

  1. 扫描内部变量/形参: c, aAO = { c: undefined, a: undefined }
  2. 无实参,跳过绑定。
  3. 扫描内部函数: function c() {}AO = { c: function c(){}, a: undefined }

函数 a 的执行:

  • console.log(a):在 AO 中找到 a,其值为 undefined(因为 var a = b 还未执行)。
  • var a = b:在 AO 中找不到 b,于是向外层(GO)查找,得到 b = 2,将 a 赋值为 2
  • console.log(c):AO 中的 c 已被 var c = 3 覆盖,输出 3

最后,全局的 console.log(a) 输出的是 GO 中的函数 a

全局 (GO) 与局部 (AO) 的共生法则

GO 和 AO 并非孤立存在,它们共同构建了 JavaScript 的作用域链,遵循以下核心原则:

  1. 执行顺序先全局,后局部。整个程序启动时,先完成全局预编译(构建 GO)。只有当某个函数被调用时,才会为其创建 AO 并进行局部预编译。
  2. 作用域层级:GO 是最外层的根作用域,每个函数调用都会在其内部创建一个新的、嵌套的作用域(AO)。这种层级结构形成了变量查找的路径。
  3. 查找优先级就近原则。当代码中引用一个变量时,引擎首先在当前 AO 中查找。如果找不到,则沿着作用域链向上,在外层的 AO 或最终的 GO 中继续查找。反之则不成立——外层作用域无法直接访问内层作用域的变量。
  4. 命名空间隔离:同名的变量在不同作用域中互不干扰。内部的 a 和全局的 a 是两个独立的实体,各自存在于自己的 AO 或 GO 中。

理解预编译,就是理解 JavaScript 引擎如何在执行前为我们的代码搭建舞台。它揭示了那些看似“不合逻辑”的行为背后的严谨机制,是每一位前端开发者迈向精通之路的必经之门。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消