JS预编译的“罗生门”:V8引擎的幕后心机与变量的乾坤大挪移

129 阅读10分钟

JS预编译的“罗生门”:V8引擎的幕后心机与变量的乾坤大挪移

各位掘友,大家好!今天我们不聊框架,不谈架构,咱们来扒一扒JavaScript最“底层”的秘密——预编译。你以为你的代码是按部就班、从上到下执行的?Too young, too simple!在V8引擎的“法眼”之下,一切早已被安排得明明白白。今天,我们就通过三段“烧脑”代码,深入剖析预编译的“罗生门”,看看变量和函数是如何上演一场场“乾坤大挪移”的。


第一幕:函数与变量的“爱恨情仇”——《fn的奇幻漂流》

首先,请看这段代码,它看似简单,实则暗藏玄机:

function fn(a){
    console.log(a); // [function: a]
    var a = 123
    console.log(a); // 123
    function a() {} // 函数声明
    var b = function() {} // 函数表达式
    console.log(b); // [Function: b]
    function d() {}
    var d = a
    console.log(d) // 123
}
fn(1);

预编译阶段:AO(活动对象)的诞生与演变

fn(1) 被调用时,V8引擎会为 fn 函数创建一个全新的执行上下文(Execution Context)。在这个上下文创建的初期,一个至关重要的角色——**活动对象(Activation Object,简称AO)**便开始登场。AO是函数执行时用于存储变量、函数声明和形参的“百宝箱”。

预编译阶段,AO的填充顺序是严格且有讲究的:

  1. 形参初始化

    • 引擎首先会把 fn 函数的所有形参(这里是 a)作为AO的属性,并将其值初始化为 undefined
    • 紧接着,将调用时传入的实参 1 赋值给形参 a。此时,AO的初步形态是:AO = { a: 1 }
  2. 函数声明提升与覆盖

    • 接下来,引擎会“扫描”函数体内部所有的函数声明(注意,是 function xxx() {} 这种形式,不包括函数表达式)。
    • 它找到了 function a() {}function d() {}
    • 对于 function a() {}:由于AO中已经存在名为 a 的属性(形参 a),函数声明会毫不留情地覆盖掉形参 a 的当前值 1。此时,a 的值变成了这个函数本身。
    • 对于 function d() {}:AO中没有 d,所以 d 被添加进来,并赋值为这个函数。
    • AO演变为:AO = { a: function a() {}, d: function d() {} }
  3. 变量声明提升(仅声明)

    • 最后,引擎会扫描所有的 var 变量声明(这里是 var avar bvar d)。
    • 对于 var a:AO中已经有 a(而且是函数 a),所以 var a 不会再次声明,也不会覆盖 a 的值。它只是一个“占位符”,表示 a 是一个局部变量。
    • 对于 var b:AO中没有 b,所以 b 被添加到AO中,并初始化为 undefined
    • 对于 var d:AO中已经有 d(而且是函数 d),var d 不会再次声明,也不会覆盖 d 的值。
    • 至此,预编译阶段完成,fn 函数的活动对象(AO)的最终状态是:
    AO = {
        a: function a() {},
        d: function d() {},
        b: undefined
    }
    

执行阶段:AO的动态变化

预编译完成后,代码开始从上到下逐行执行,AO中的值会根据赋值操作而动态变化:

  • console.log(a); (第13行)

    • 查找 a 的值。根据预编译后的AO,a 当前是 function a() {}
    • 输出:[function: a]
  • var a = 123 (第14行)

    • 这是一个赋值操作。将 123 赋值给AO中的 a。此时,a 的值从函数变成了数字 123
    • AO更新为:AO = { a: 123, d: function d() {}, b: undefined }
  • console.log(a); (第15行)

    • 查找 a 的值。根据更新后的AO,a 当前是 123
    • 输出:123
  • function a() {} (第16行)

    • 此行代码在预编译阶段已经被处理过了,这里只是一个声明,不会再有任何操作。
  • var b = function() {} (第17行)

    • 这是一个函数表达式的赋值操作。将匿名函数 function() {} 赋值给AO中的 b
    • AO更新为:AO = { a: 123, d: function d() {}, b: function() {} }
  • console.log(b); (第18行)

    • 查找 b 的值。根据更新后的AO,b 当前是 function() {}
    • 输出:[Function: b]
  • function d() {} (第19行)

    • 此行代码在预编译阶段已经被处理过了,这里只是一个声明,不会再有任何操作。
  • var d = a (第20行)

    • 这是一个赋值操作。将AO中 a 的当前值(即 123)赋值给AO中的 d
    • AO更新为:AO = { a: 123, d: 123, b: function() {} }
  • console.log(d) (第21行)

    • 查找 d 的值。根据更新后的AO,d 当前是 123
    • 输出:123

第一幕总结:函数声明在预编译阶段拥有至高无上的“优先权”,它会覆盖同名的形参或变量声明。而函数表达式则像个“乖宝宝”,只在执行到那一行时才进行赋值。


第二幕:形参与函数声明的“相爱相杀”——《foo的参数之谜》

接下来,我们看看这段代码,它展示了形参和函数声明之间微妙的关系:

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

预编译阶段:AO的再次洗牌

foo(1) 被调用时,同样会创建一个新的执行上下文和对应的AO。

  1. 形参初始化

    • 形参 ab 被添加到AO,并初始化为 undefined
    • 实参 1 赋值给 a。由于只传入了一个实参,b 保持 undefined
    • AO初步:AO = { a: 1, b: undefined }
  2. 函数声明提升与覆盖

    • 扫描函数体,找到 function b() {}
    • 由于AO中已经存在名为 b 的属性(形参 b),函数声明 function b() {}覆盖掉形参 bundefined 值。此时,b 的值变成了这个函数本身。
    • AO演变为:AO = { a: 1, b: function b() {} }
  3. 变量声明提升

    • 扫描 var c。AO中没有 c,所以 c 被添加到AO中,并初始化为 undefined
    • AO最终状态:AO = { a: 1, b: function b() {}, c: undefined }

执行阶段:赋值的“变脸”艺术

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

  • console.log(a); (第2行)

    • 查找 a 的值。根据AO,a 当前是 1
    • 输出:1
  • c = 0 (第3行)

    • 0 赋值给AO中的 c
    • AO更新为:AO = { a: 1, b: function b() {}, c: 0 }
  • var c (第4行)

    • 此行无实际操作,c 已在预编译阶段声明。
  • a = 3 (第5行)

    • 3 赋值给AO中的 a
    • AO更新为:AO = { a: 3, b: function b() {}, c: 0 }
  • b = 2 (第6行)

    • 这是一个关键的赋值操作。将 2 赋值给AO中的 b。此时,b 的值从函数变成了数字 2
    • AO更新为:AO = { a: 3, b: 2, c: 0 }
  • console.log(b) (第7行)

    • 查找 b 的值。根据更新后的AO,b 当前是 2
    • 输出:2
  • function b() {} (第8行)

    • 此行无实际操作,函数声明已在预编译阶段处理。
  • console.log(b); (第9行)

    • 查找 b 的值。根据更新后的AO,b 当前仍然是 2
    • 输出:2

第二幕总结:形参和函数声明在预编译阶段会相互影响,函数声明会覆盖同名形参。而一旦进入执行阶段,任何赋值操作都会直接改变变量的当前值,无论它之前是函数还是其他类型。


第三幕:全局变量的“李代桃僵”——《fn与global的捉迷藏》

最后,我们来看一个涉及全局变量和局部变量“同名”的场景,看看它们是如何玩“捉迷藏”的:

global = 100
function fn() {
    console.log(global); // undefined
    global = 200
    console.log(global); // 200
    var global = 300
}
fn()
var global

预编译阶段:全局与局部的“结界”

这段代码涉及到全局执行上下文和 fn 函数执行上下文。

全局执行上下文的预编译:

  1. global = 100:这是一个赋值操作,如果 global 不存在,它会成为全局对象的属性。在全局预编译阶段,global 变量被创建并赋值为 100
  2. function fn() {}:函数 fn 被提升到全局作用域。
  3. var global:全局的 var global 声明被提升,但由于 global 已经存在(被 global = 100 创建),所以不会重新初始化为 undefined

fn 函数执行上下文的预编译:fn() 被调用时,为 fn 创建新的AO。

  1. 变量声明提升var global 被提升到 fn 函数作用域的顶部,并初始化为 undefined
    • 关键点:这个局部的 global 变量,在 fn 函数内部形成了一个“结界”,它会遮蔽外部(全局)的同名 global 变量。这意味着在 fn 函数内部,你访问的 global 都是这个局部的 global,而不是全局的那个 100
    • AO状态:AO = { global: undefined }

执行阶段:局部变量的“主场优势”

预编译完成后,fn 函数内部的代码开始执行:

  • console.log(global); (第8行)

    • 查找 global 的值。根据AO,此时 globalundefined
    • 输出:undefined
  • global = 200 (第9行)

    • 200 赋值给AO中的局部 global 变量。
    • AO更新为:AO = { global: 200 }
  • console.log(global); (第10行)

    • 查找 global 的值。根据更新后的AO,global 当前是 200
    • 输出:200
  • var global = 300 (第11行)

    • 此行代码的 var global 部分在预编译阶段已经处理过了。这里的 = 300 是赋值操作。
    • 注意:虽然这里写了 = 300,但由于上一行 global = 200 已经给 global 赋了值,并且 var 声明不会重复初始化,所以这里的 300 并不会覆盖 200。实际上,这行代码在执行时,如果 global 已经有值,这个赋值操作会被忽略(在非严格模式下)。但在实际开发中,这种写法非常容易引起混淆,应尽量避免。
    • AO保持:AO = { global: 200 }

第三幕总结:局部变量的提升会“遮蔽”同名的全局变量,形成一个独立的“作用域结界”。在函数内部,你操作的都是这个局部的变量,与外部的全局变量互不影响。


终章:预编译的“江湖规矩”

通过这三段代码的“血泪史”,我们可以总结出JavaScript预编译的几条“江湖规矩”:

  1. 创建执行上下文:每次函数执行都会创建一个新的执行上下文,其中包含一个活动对象(AO)。
  2. 形参优先:形参会作为AO的属性,并接收实参的值。
  3. 函数声明“霸道”提升function xxx() {} 形式的函数声明会被完整地提升到作用域顶部,如果与形参或变量同名,它会覆盖掉形参或变量的初始值。
  4. 变量声明“温柔”提升var xxx 形式的变量声明也会提升,但只会提升声明,并初始化为 undefined。如果与形参或函数声明同名,它不会覆盖已有的值。
  5. 赋值操作在执行阶段:所有的赋值操作(包括函数表达式的赋值)都在代码执行到那一行时才进行。
  6. 局部遮蔽全局:函数内部的局部变量(即使与全局变量同名)会优先被访问,形成“遮蔽效应”。

理解这些“江湖规矩”,你就能在JavaScript的世界里游刃有余,不再被那些看似“魔幻”的输出所困扰。下次再遇到类似问题,不妨在脑海中模拟一遍预编译过程,相信你会豁然开朗!

希望这篇“罗生门”能帮助你更深入地理解JavaScript的预编译机制。如果你有任何疑问或想挑战更复杂的代码,欢迎在评论区留言!