引言
在这篇文章里,我将会从浅到深从变量提升的实现到实现原理来向大家解释,究竟是什么使变量提升这种概念存在。本文将基于原理通过比较简单的方式进行表述,让大家理解预编译和调用栈,执行上下文栈的概念。
编译过程
JavaScript代码的执行是由JavaScript引擎负责的,每个现代浏览器都包含了自己的JavaScript引擎。最知名的JavaScript引擎包括V8(Chrome)、SpiderMonkey(Firefox)、JavaScriptCore(Safari)等。
在理解JavaScript的底层编译原理之前,让我们先来了解JavaScript代码的编译过程。JavaScript是一种解释性语言,而不是编译性语言。这意味着代码不是一次性编译成二进制文件,而是在运行时逐行解释和执行。
编译过程主要分为以下三个阶段:
a. 词法分析(Lexical Analysis)
在这一阶段,JavaScript引擎将源代码分解成词法单元,例如标识符、关键字、运算符等。这个过程生成了一个令牌流(token stream),用于表示代码的结构和含义。
b. 语法分析(Syntax Parsing)
在这个阶段,引擎将词法分析生成的令牌流转化为抽象语法树(Abstract Syntax Tree,AST)。AST是一种树状结构,用于表示代码的层次结构和逻辑。
c. 代码生成(Code Generation)
最后,引擎将AST转化为可执行的机器代码,以便在计算机上运行。这个机器代码通常不是传统意义上的二进制代码,而是中间代码,由JavaScript引擎执行。
当然这些比较抽象专业,不太理解也是正常的我们直接开始实例分析:
实例讲解
js
var a = 1
function foo(a){
console.log(a)
var a = 2
console.log(a)
}
foo(3)
当我们拿到这样的一段代码,你认为控制台会输出什么?答案是 3 和 2这就是变量提升的问题,我们的代码实际上变成了这样:
js
var a = 1
function foo(a){
var a
console.log(a)
a = 2
console.log(a)
}
foo(3)
这就是所谓的变量提升,将var a提升到了前面,而我们可以通过分析它的预编译情况来明白这样的一个提升到底是怎么来的,而预编译情况我们可以通过一个四部曲来进行分析:
预编译发生在函数执行之前(四部曲)
- 创建A0对象 (Action Object)
- 找形参和变量声明,将变量声明和形参作为A0的属性名,值为undefined
- 将实参值和形参值统一
- 在函数体内找函数声明,将函数名作为AO对象的属性名,值赋予函数体
我们来尝试一下:
js
//首先我们创造了一个AO对象
AO{
//我们开始寻找声明
//此时只记录声明了a //第三步统一值 //第四步其中并没有函数声明所以结束
a: undefined a: undefined 3
}
然后我们就对foo函数进行了一次预编译,此时我们再来继续一步步地执行foo
函数,我们遇到了第一个console.log(a)
,那么此时,他找到了AO
发现a的值为3
,于是输出3,再往后,我们遇到了 var a = 2
那么此时,a的值变为了2,我们又遇到了第二个console.log(a)
,于是输出2。这样你就得到了答案3 2。
那我们再来升华一下:
js
function fn(a){
console.log(a)// ?
var a = 123
console.log(a)// ?
function a(){}
console.log(a)// ?
var b = function(){}
console.log(b)// ?
function d(){}
var d = a
console.log(d)// ?
}
看蒙了吗?不要着急,用我们的四部曲来慢慢尝试一遍,如果你的答案是这样:
js
[Function: a]
123
123
[Function: b]
123
恭喜你已经迈出了一大步!当然如果没想出来我们再来预编译一遍:
js
AO:{
//寻找声明 //统一值 //找到函数声明
a: undefined 1 function a(){},
b: undefined,
d: undefined function d(){},
}
有了这个你再一步步去执行函数体,你就得到了我们的答案。到这里如果你明白了,那么恭喜!我们可以开始去全局预编译了。
预编译发生在全局(三部曲)
- 创建G0对象 (Global Object)
- 找变量声明,将变量声明作为G0的属性名,值为undefined
- 在全局找函数声明,将函数名作为G0对象的属性名,值赋予函数体
如果你仔细观察你会发现,好像就只是少了个第二步 找形参和变量声明,将变量声明和形参作为A0的属性名,值为undefined,没错,因为全局是没有形参给它引入的嘛,那么就很简单了,整体是差不多一样的嘛,来试一试:
js
var global = 100
function fn{
console.log(gobal)
}
预编译是这样的:
js
GO:{
global:undefined
fn: function fn() {}
}
现在你已经了解了它们两兄弟,但是正常情况下它们都是一起出行的,那我们来看看它们结合起来应该是怎样理解的吧。
调用栈
每当 JavaScript 解释器要执行我们编写的函数或脚本时,它都会创建一个新的上下文。每个脚本/代码都以一个称为全局执行上下文的执行上下文开始。每次我们调用一个函数时,都会创建一个新的执行上下文并将其放在执行堆栈的顶部。当您调用调用另一个嵌套函数的嵌套函数时,也会放在执行堆栈的顶部。让我来解释一下:
也就是说,我们来刚才的两兄弟如果放在一起:
js
var global = 100
function fn(){
console.log(global)
var a = 123
console.log(a)
}
我们预编译后就会有两个:
js
GO{
global:undefined
fn: function () {}
}
AO{
a: undefined
}
我们运行一下:
js
100
123
你会发现AO
中是没有global
的,说明它在找预编译的时候还找到了"外面",它在GO
中找到了global
,也就是从里到外寻找,而为什么会从里到外呢?这就是调用栈。接下来我们看一张图片:
你要问:诶?怎么变成执行上下文了?没错,前面我们只是便于理解,而真正的调用栈
就是像这张图片一样的一个类似高楼的结构。我们接下来一步步分析一下调用栈和执行上下文
。
如果你想要更加细致的了解一下执行上下文理解 JavaScript 中的执行上下文和执行栈
入栈
我们还是用之前那个小例子来举例,当我们开始执行这个js程序,我们当然是从头开始一步步向下执行对吧,那么我们首先拥有了一个调用栈
,将全局进行了一次预编译,然后把全局执行上下文
,也就是我们之前说的GO
压入栈底:
然后我们要在fn函数中输出,那我们又对fn函数
进行一次预编译,得到了fn的执行上下文
,也就是我们之前说的AO
压入栈底:
寻找
如果你们看了之前那个文章,那么我们就知道,此时,this
指向了我们的fn执行上下文
,也就是说我们此时fn函数
中的console.log(global)
先在fn执行上下文中寻找global
,然后我们发现并没有找到global
,那么此时我们的this
向下移动,指向了全局执行上下文
。然后你就发现了,之所以从里到外寻找,正是因为一次次入栈,然后我们从所在位置向下寻找,就是从里到外寻找,那么你就得到了这样的顺序:
具体的指向为自上而下,从fn执行上下文
再到指向全局执行上下文
,我们的例子中只有变量环境
,并没有如let
这样的词法环境
,但是在单一的一个执行上下文里中顺序是从词法环境
再到变量环境
,我们在此就不在过多赘述。嵌套函数则同理,一层一层往上累加执行上下文。
总结
JavaScript的预编译和上下文栈是关键的底层概念,有助于理解JavaScript代码的执行流程和作用域管理。
预编译包括变量提升和函数声明提前,确保代码在运行时正确执行。
上下文栈用于管理执行上下文,跟踪变量和函数的作用域,确保它们在正确的上下文中访问。
深入理解JavaScript的内部机制是成为一位卓越的JavaScript开发者的关键步骤。 JavaScript的底层原理不仅仅是理论知识,它们直接影响代码的质量和性能。 持续学习和实践将使你更加精通JavaScript,从而能够构建高质量的Web应用程序。
希望大家能够通过不断的学习继续提升自己,加油~~
如果你想了解更多这类文章,点赞关注作者更新更多后续~
更多推荐→当你被面试问到:你还记得原型和原型链吗?