一篇文章彻底搞懂JS的执行机制

403 阅读12分钟

在上一篇文章中,我们学习了JS的作用域,其中,我们提到了一点:就是用关键字var声明的变量会存在声明提升;内层作用域可以访问外层作用域。那到底为什么用var声明的变量存在声明提升呢?当我们写下一段JS代码时,它到底是怎么被执行的呢?今天我们就来解答这个疑惑。

1. 执行上下文

我们先来通过一个例子来回顾一下var的声明提升,请看下面这段代码:

showName()
console.log(myname);

var myname = 'qiqi'
function showName() {
    console.log('函数showName被执行');
}

请问它的输出结果是什么?

通过上次的学习,我们知道,用var声明的变量会存在声明提升,所以这段代码在执行引擎的眼里应该是下面这样:

var myname
function showName() {
    console.log('函数showName被执行');
}
showName()
console.log(myname);
myname = 'qiqi'

诶,细心的朋友就会发现了,怎么整个函数体也被提升了呢?你不是说过var声明的变量才会提升吗?

其实这就是JS代码的编译过程,var声明的变量和函数声明会提升到整份代码的顶部,在JS执行引擎眼里,这份代码就长这样,当编译完成后,代码才开始执行。所以输出结果自然就是:

image.png

那到底为什么会出现这种情况呢?我们就要引出一个新的概念了,叫执行上下文

我们说过,一份代码在执行前,需要先进行编译,所以内存专门划了一块地方,给代码做准备工作,也就是编译。这块空间就是执行上下文。而在执行上下文中存在两个东西叫变量环境词法环境。在变量环境里放的就是你声明的一些变量。最后整理出一块可执行代码块,然后执行代码。这就是一份JS代码在执行前进行的操作。执行上下文如下图所示:

image.png

那代码的编译过程具体是什么样的呢?我们还是没有解决我们想知道的问题呀,为什么会存在函数声明提升和变量声明提升呢?在学习这个之前,我们得先来了解一下调用栈

2. 调用栈

我们先来看一个例子:

function test() {
    test()
}
test()

这份代码一眼看上去真奇怪,我们先定义了一个函数,这个函数的作用又是调用自己这个函数。我们直接来看一下它的输出结果:

image.png

运行结果直接显示爆栈了。

在这里存在的栈是什么?为什么输出结果显示爆栈了?

我们知道,栈是一种数据结构。而在JS中的栈里,它存在以下特性:

1. 栈是一种弱化后的数组

2. 先进后出

3. 只能使用push,pop或者unshift,shift方法的数组

所以在JS执行引擎中存在的这个栈是什么呢?

我们知道,在JS有很多函数,经常会出现一个函数调用另一个函数的情况。所以,在JS引擎中,存在着这么一个调用栈它是专门用来用来管理函数之间的调用关系的一种结构。

在了解了这一点之后。我们来通过一段代码更加深入的了解一下调用栈,请看:

var a = 2
function add(b, c) {
    return b + c
}
function addAll(b, c) {
    var d = 10
    var result = add(b, c)
    return a + result + d
}
console.log(addAll(3, 9));

请问它的输出结果是什么?

让我们用刚刚学过的调用栈执行上下文仔细的分析一下这段代码的执行过程。

首先,代码在执行前就存在一个调用栈,如图所示:

image.png 我们说过,一段代码是先编译在执行的,在编译的时候就会产生一个执行上下文。而对于这个全局的上下文,我们就叫它全局执行上下文,生成的全局执行上下文就会进入到调用栈中。

image.png 然后开始进行代码的编译,在全局执行上下文的左边变量环境中,首先找我们声明的量。

代码是从上往下执行的,首先我们声明了一个a,a的值应该为undefined,因为赋值语句是执行语句,在编译的时候我们只找我们声明的量。因为代码是先编译再执行的,编译和执行是严格分开的。然后我们定义了两个函数,用键值的方式进行表示,函数名做键,函数体做值,add = function(){},addAll = function(){},然后就没有声明的量了,剩下的都是赋值语句,所以我们把它写到变量环境中去。

image.png

这就是这份代码的全局编译过程,然后开始执行。代码从上往下执行,我们先给a赋值为了2,然后调用了addAll(3, 9),并要输出这个值。于是变成这样:

image.png 当我们调用了一个函数时,它也会生成一个执行上下文,并且进入这个调用栈中。

image.png 于是我们就开始对addAll中的代码进行编译,我们把addAll代码单独拿过来看:

function addAll(b, c) {
    var d = 10
    var result = add(b, c)
    return a + result + d
}

找我们声明的量,我们声明了d和result,所以d = undefined,result = undefined,然后还有两个参数b和c,就没了,写入到变量环境中去。

image.png

然后开始执行这段代码,先统一一下形参和实参的值,所以b = 3,c = 9,然后给d赋值为10,result赋值为了add(b,c)。

image.png 我们发现此时我们又调用了一个函数add(),所以它也会生成一个执行上下文并且进入调用栈

image.png

于是就开始对add中的代码进行编译,只有两个形参b和c。

image.png

然后开始执行add中的代码。先统一一下b和c的值,此时b为3,c为9,它要return b+c,所以它返回12,执行完成后,它就从调用栈中出栈,也就是销毁。

image.png 所以此时,result的值变为12,当时还有代码没执行完呢,它还要return a + result + d,我们发现result和d的值是有了,a为什么呢?此时它就会到它下一层的全局执行上下文中去找a的值,所以a的值为2,于是返回24。执行完毕后,addAll也从调用栈中出栈,也就是销毁。

image.png

于是代码接着运行console.log(addAll(3, 9))这句代码,addAll(3,9)给我们返回了24,所以此时打印输出的结果就是24。最后全局执行上下文也被销毁,调用栈又恢复如初。

image.png

这就是一份JS代码详细的执行过程。搞懂了这个,就能解决我们之前的疑惑了。

为什么会存在声明提升呢?因为当我们声明一个量时,它是属于编译过程,它应该先执行,提升到代码的顶部,而赋值语句属于执行阶段,所以等到所有编译过程都结束之后,才开始执行。

为什么内层作用域可以访问外层作用域呢?因为在调用栈中,当在一个函数自己的执行上下文中没有找到这个量时,他就会去下一级的全局执行上下文中去找。栈只能从上往下找,所以内层可以访问外层,而外层不能访问内层。

爆栈的问题我们也能解决了。因为test函数它一直重复调用自己,所以就一直生成执行上下文入栈,而调用栈的大小是有限的,所以就会爆栈。

所以对于调用栈,我们可以总结:

它是JS引擎用来管理函数之间的调用关系的一种结构

1. 编译总是发生在执行前一刻

2. 全局和函数体的编译会生成执行上下文存入调用栈

3. 当一个函数执行完毕后,它的执行上下文就会出栈,被销毁

3. JS代码的编译

在上一小节中,我们利用执行上下文调用栈仔细分析了一段代码的执行过程,让我们对代码的编译和执行有了深刻的认识,代码一定是先编译再执行的,但其实JS代码的编译过程中还有一些细节要提。比如下面这段代码:

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

请思考这段代码的运行结果是什么?

让我们一起来分析一下,首先,代码先进行编译,有一个全局执行上下文,我们定义了a = undefined,fn = function(){},编译过程是不是就结束了。如下图所示:

image.png

接下来就是执行代码,代码是从上往下执行的,我们先给a赋值为1,然后调用了这个函数,函数传了一个形参3进去,所以fn函数也生成了一个执行上下文:

image.png

然后fn中的代码开始编译,我们得先明白一个规则:编译时,先找形参声明和变量声明,再找函数声明。 所以我们开始找,先有一个形参a进去了,值为undefined,然后没形参了,这时我们发现,在函数里面。我们也定义了一个变量a,那这时这个变量a会不会也引入呢?答案是不会,它不会重复引入相同的量,如果引入了,假如这时我们要输出a,那它找哪个a呢?所以函数里面的a不会引入。然后我们还定义了一个b,所以b引入,值为undefined。没有形参声明变量声明了,此时在找函数声明之前,我们得得统一形参和实参的值,于是此时a的值变为3。请注意,这并不是执行语句,这还是在编译过程中,只是因为我们传了一个形参a为3进来,所以我们得统一值。如下图所示:

屏幕截图 2024-11-14 120939.png

然后才开始找函数声明,我们声明了一个函数a,我们说过,相同的量不会重复引入,所以还是用上面那个a。于是此时a = function(){},编译完后如下图所示:

屏幕截图 2024-11-14 120447.png

接下来才开始执行过程。先给a赋值为2,所以此时a为2,然后给b赋值为a,所以此时b也为2,然后打印输出a,所以输出结果应该为2。

image.png

对于代码的编译过程,我们已经彻底理清,我们来总结一下代码的编译过程:

1. 创建执行上下文对象

2. 找形参和变量声明,将形参和声明的变量名作为key,值为undefined

3. 统一形参和实参的值 (全局没有该步骤)

4. 找函数声明,将函数名作为key,值为函数体

4. 检验成果

通过上面的学习,我们已经搞明白了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);
}
fn(1)

现在看到这种代码还用害怕吗?我们已经搞懂了它的执行机制了,我们来一步步分析就行了。

首先有一个全局执行上下文,然后它入栈,然后开始编译,我们发现,在这个全局执行上下文中,只有一个函数声明,fn = function(){},编译结束。开始执行代码,调用这个函数,传了一个形参a值为1进去,于是函数生成了一个自己的执行上下文,我们就只画这个fn执行上下文了。

于是fn中的代码开始进行编译,先找形参声明变量声明

我们引入了一个形参a,所以此时a = undefined,再找变量声明,我们声明了一个a,因为不会重复引入,所以不引入函数中的a,又var b = function () {},请注意,这也是变量声明,它是函数表达式,它是执行语句,在执行阶段给b赋值为一个函数 ,因为他是用var定义的量,所以b = undefined,再找,声明了一个变量d,所以d = undefined。没有形参声明和变量声明了,在找函数声明之前,我们得先统一一下形参和实参的值。我们传了一个形参a值为1进来,所以此时a的值为1。如下图所示:

image.png

然后再找函数声明,定义了一个函数a,所以a = function(){},还定义了一个函数d,所以d = function(){},编译结束,如下图所示:

image.png

至此编译结束,开始执行。

首先输出a,a现在为一个函数,所以此时第一个输出为[Function: a]。然后给a赋值为123,所以a又变为123。然后又要输出a,所以第二个输出为123,然后又要输出a,所以第三个输出也为123,然后给b赋值为1个函数,所以此时b = function(){} ,然后要输出b,所以第四个输出为[Function: b],然后给d赋值为a,所以此时d的值也为123,然后要输出d,所以第五个输出为123。执行完毕,输出结果如下图所示:

image.png

当我们理清了JS的执行机制后,不管我们碰到多复杂的代码,都能清晰的分析出来它的编译过程。