深入理解js中的调用栈与作用域链

1,075 阅读6分钟

作用域链和调用栈是 JavaScript 中非常重要的概念,它们负责管理函数的作用域和调用关系。今天小编带大家来深入理解一下他们的原理。

调用栈:

在JavaScript中,调用栈是一个非常重要的概念,它用于管理函数调用的执行上下文。调用栈是一个后进先出的数据结构,用于存储函数调用时创建的函数执行上下文。每当一个函数被调用时,JavaScript引擎会创建一个新的执行上下文并将其压入调用栈。这个执行上下文包含了函数执行所需的所有信息,比如函数的局部变量、参数、this的值等。当函数执行完毕后,其执行上下文会从调用栈中弹出,控制流会返回到前一个(父)执行上下文。

调用栈其实就是用来存放执行上下文的一种数据结构并且他有一个很重要的特点,大家一定要记住!:当一个执行上下文执行完毕之后,他的执行上下文就会出栈。 让我们通过下面这段代码案例来说明调用栈的原理:

var a = 2
function add(){
    var b = 10
    return a + b
}
add()

这个代码会输出什么,很明显,我们可以很快得出答案。结果就是12。我们现在带大家深入底层逻辑聊聊这个代码的运行过程。
代码执行之前要预编译(有不懂预编译的小伙伴可以去看下这篇文章 [《 面试官灵魂拷问——预编译你了解吗》])【juejin.cn/post/736212…
这段代码我们会先创建一个GO对象预编译,这个对象中的执行过程如下

GO{
	a : undefined
	function :add()
}

GO对象是全局执行上下文的一部分,你可以理解成就是全局执行上下文。当全局执行上下文准备好之后,它就会入栈,并且每个栈内都会有个outer指针,outer指针的初始值为null,这个指针指向的是该执行上下文的外层上下文, v8引擎编译全局代码的时候会创建一个全局执行上下文对象,而栈就是用来存储一个又一个执行上下文对象的,此时全局执行上下文入栈了:

Snipaste_2024-04-30_15-09-43.png

在全局执行上下文中有一个变量环境和词法环境,简单来说,变量环境中放的是你用var声明的变量,词法环境中放的是你用let和const声明的变量,预编译的时候就会把这些变量放进去:

Snipaste_2024-04-30_15-09-54.png

现在开始执行全局执行上下文:

GO{
	a : undefined -> 2
	add()
	outer: null
}

全局预编译完成后,就开始执行代码,此时a得到值2:调用栈如下

Snipaste_2024-04-30_15-10-10.png

当执行到add()的时候,我们的预编译就会开始编译add函数,于是创建一个AO对象:

AO{
	b: undefined
	outer: null
}

之后执行函数add的调用,执行之前又要先进行它的编译,此时编译器又会创建一个add函数的执行上下文对象,将其压入栈:

Snipaste_2024-04-30_15-10-19.png

当AO对象编译好后,或者说add函数执行上下文准备好后,add函数执行上下文会进行入栈,于是开始执行add函数执行上下文

AO{
	b: undefined -> 10
	return a + 10
	outer: null -> 全局执行上下文
}

编译后执行,b的值变为10

Snipaste_2024-04-30_15-10-28.png

最后要返回a+b的值,所以就要去查找a和b,查找规则是先从本身的词法环境里找,找不到再去本身的变量环境里找,我们在本身的变量环境里找到了b = 10,但是没找到a,就去下一个执行上下文里找(此例中为全局执行上下文),同样先从词法环境里找,找不到再去变量环境里找,得到a = 2,然后就可以返回a+b的值即12,查找方向如图:

Snipaste_2024-04-30_15-10-39.png

图中红色的框框其实就是调用栈 ,我们用它来管理函数的调用关系。这张图是我当时给大家聊预编译时谈到调用栈时所理解的,但其实它并不完全对。为什么这么说呢?
这里我给大家说下原因,顺带把作用域链解释清楚。outer为什么指向了全局执行上下文,outer这个东西其实就是构成了作用域链。

作用域链:

通过词法环境中的outer指针来确定某作用域的外层作用域,查找变量由内到外的这种链状关系。 也就是说当代码在一个作用域中无法找到某个变量时,JavaScript 引擎会沿着作用域链向上查找,直至全局作用域。这种链状的查找关系就是作用域链。 但并不是在调用栈中从上到下查找,而是看当前执行上下文变量环境中的out指向来定,而outer指向的规则是:我的词法作用域在哪里,out就指向哪里。
词法作用域:在函数定义时所在的作用域,而不是函数执行时所在的作用域。

如何确定add执行上下文的外层作用域呢,我们只需要看它在哪里声明定义的即可,add函数声明在全局作用域中,所以它的外层作用域就是全局执行上下文(或全局作用域),所以它的outer指向了全局执行上下文。因此我们AO中没有a,查找就朝着outer指向的位置全局中查找,这才找到了a的值,所以最终return 12。所以我说刚刚那个图其实是不完全对的,或者理解还没更加深入,它的指针并不是从上到下,而是看outer的指向,outer的指向又取决于你变量的声明位置。

Snipaste_2024-04-30_15-10-28.png 所以应该要这样理解。

让我们通过一个简单的代码来理解一下作用域链的原理:

function bar (){
    console.log(myName);
}
function foo(){
    var myName = 'Tom'
    bar()
}
var myName = 'jerry'
foo()

大家认为这道题会输出谁的名字呢?Tom?还是jerry?

我来给大家分析一下:首先会创建全局执行上下文 GO对象:

myName:undefined
bar:function bar(){}
foo:function foo(){}

准备好后开始执行:myName:undefined——>'jerry'和foo()开始编译
foo执行上下文:myName: undefined准备好后入栈开始执行:myName -> 'Tom'、bar(),于是开始编译bar()
bar执行上下文:无变量,直接执行,打印myName,这里找不到myName于是开始求助词法环境中的outer指针,刚刚咱们说了,指针指向就是自己函数声明定义时所在的作用域,bar函数声明在全局执行上下文中,所以打印jerry,所以执行foo函数就是执行bar函数,最终输出为jerry。
如果你的答案是Tom你肯定看的是foo的作用域,其实不然,要看out指针指向的作用域。用图来给大家理解一下

134662D0-2DB6-465A-B904-158DE1259A3F.png

好啦,今天的分享就到这里结束啦,看完这篇之后相信大家对调用栈,作用域链会有一个更深的理解吧。最后如果你觉得本文对你有帮助的话,可以给个免费的赞赞嘛。感谢感谢!