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的填充顺序是严格且有讲究的:
-
形参初始化:
- 引擎首先会把
fn函数的所有形参(这里是a)作为AO的属性,并将其值初始化为undefined。 - 紧接着,将调用时传入的实参
1赋值给形参a。此时,AO的初步形态是:AO = { a: 1 }。
- 引擎首先会把
-
函数声明提升与覆盖:
- 接下来,引擎会“扫描”函数体内部所有的函数声明(注意,是
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() {} }。
- 接下来,引擎会“扫描”函数体内部所有的函数声明(注意,是
-
变量声明提升(仅声明):
- 最后,引擎会扫描所有的
var变量声明(这里是var a、var b、var 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() {} }。
- 这是一个赋值操作。将AO中
-
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。
-
形参初始化:
- 形参
a和b被添加到AO,并初始化为undefined。 - 实参
1赋值给a。由于只传入了一个实参,b保持undefined。 - AO初步:
AO = { a: 1, b: undefined }。
- 形参
-
函数声明提升与覆盖:
- 扫描函数体,找到
function b() {}。 - 由于AO中已经存在名为
b的属性(形参b),函数声明function b() {}会覆盖掉形参b的undefined值。此时,b的值变成了这个函数本身。 - AO演变为:
AO = { a: 1, b: function b() {} }。
- 扫描函数体,找到
-
变量声明提升:
- 扫描
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 函数执行上下文。
全局执行上下文的预编译:
global = 100:这是一个赋值操作,如果global不存在,它会成为全局对象的属性。在全局预编译阶段,global变量被创建并赋值为100。function fn() {}:函数fn被提升到全局作用域。var global:全局的var global声明被提升,但由于global已经存在(被global = 100创建),所以不会重新初始化为undefined。
fn 函数执行上下文的预编译:
当 fn() 被调用时,为 fn 创建新的AO。
- 变量声明提升:
var global被提升到fn函数作用域的顶部,并初始化为undefined。- 关键点:这个局部的
global变量,在fn函数内部形成了一个“结界”,它会遮蔽外部(全局)的同名global变量。这意味着在fn函数内部,你访问的global都是这个局部的global,而不是全局的那个100。 - AO状态:
AO = { global: undefined }。
- 关键点:这个局部的
执行阶段:局部变量的“主场优势”
预编译完成后,fn 函数内部的代码开始执行:
-
console.log(global);(第8行):- 查找
global的值。根据AO,此时global是undefined。 - 输出:
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预编译的几条“江湖规矩”:
- 创建执行上下文:每次函数执行都会创建一个新的执行上下文,其中包含一个活动对象(AO)。
- 形参优先:形参会作为AO的属性,并接收实参的值。
- 函数声明“霸道”提升:
function xxx() {}形式的函数声明会被完整地提升到作用域顶部,如果与形参或变量同名,它会覆盖掉形参或变量的初始值。 - 变量声明“温柔”提升:
var xxx形式的变量声明也会提升,但只会提升声明,并初始化为undefined。如果与形参或函数声明同名,它不会覆盖已有的值。 - 赋值操作在执行阶段:所有的赋值操作(包括函数表达式的赋值)都在代码执行到那一行时才进行。
- 局部遮蔽全局:函数内部的局部变量(即使与全局变量同名)会优先被访问,形成“遮蔽效应”。
理解这些“江湖规矩”,你就能在JavaScript的世界里游刃有余,不再被那些看似“魔幻”的输出所困扰。下次再遇到类似问题,不妨在脑海中模拟一遍预编译过程,相信你会豁然开朗!
希望这篇“罗生门”能帮助你更深入地理解JavaScript的预编译机制。如果你有任何疑问或想挑战更复杂的代码,欢迎在评论区留言!