【干货】带你轻松理解JavaScript中的闭包、调用栈和作用域链!!

前言

[干货]-今天我们就带大家来了解学习JavaScript中一座"大山" ----闭包 以及什么是调用栈,作用域链。

正文

我们首先从调用栈开始讲起!!

什么是调用栈?

在JavaScript中,调用栈(Call Stack)是一种数据结构,用于跟踪程序执行过程中的函数调用和返回。它是按照后进先出(LIFO)的原则进行操作的。

当一个函数被调用时,它会在调用栈中创建一个新的栈帧(Stack Frame)。栈帧包含了函数的参数、局部变量、返回地址等信息。当函数执行完成后,对应的栈帧将从调用栈中被移除。

调用栈对于程序的执行非常重要,因为它决定了函数调用的顺序和执行流程。如果一个函数调用了另一个函数,那么被调用的函数会在调用栈中创建一个新的栈帧,当这个函数返回时,栈帧将被移除。如果在一个函数返回后还需要访问其中的变量,那么就可能会出现问题,因为这些变量可能已经被从调用栈中移除了。

JavaScript中的调用栈大小通常是有限的,如果调用栈溢出(例如,由于无限递归或者过大的函数调用导致堆栈空间耗尽),那么程序可能会崩溃或者出现其他错误。

我们来通过案例分析调用栈:

js 复制代码
showName()
//函数声明会提升到当前作用域的最顶端,边编译边执行
console.log(myName)
//声明提升
var myName = '李杰'
function showName(){
    console.log('杰哥')
}
js 复制代码
输出:
杰哥
undefined

我们拿到这样一个案例分析,我们的js是如何对这段代码?

首先我们JS,会自动的生成一个栈体--调用栈

在我们的当前案例中,我们会生成一个全局作用域 -也叫全局执行上下文 !如果不了解全局作用域的小伙伴可以去:梦回JavaScript之作用域--小白篇 - 掘金 (juejin.cn)进行学习

然后,对全局作用域进行预编译,预编译可以参考学习:面试官:你知道为什么var存在声明提升嘛? 纯干货-JavaScript预编译机制!!!小白必看! - 掘金 (juejin.cn)

预编译的过程当中,我们的浏览器会内核会从第一行开始,逐步得将全局当中的变量声明和函数体放入到我们的全局执行上下文当中!

例如我们当前的案例:首先会找到变量声明:var myName于是,我们的v8引擎会记录一个myName:undefined存入到GO对象当中,在全局执行上下文的变量环境当中存入myName:undefined

在全局执行上下文当中有两个区块:变量环境词法环境

**变量环境:**一般存储var声明的变量和函数体

**词法环境:**一般存储let,const声明的变量

紧接着,我们的浏览器内核继续往下识别:于是会识别到一个函数体showName,于是便会将showName存入到GO对象当中,在全局执行上下文的变量环境中存入showName:function(){},到这里全局的预编译也结束了。

接下来,浏览器内核就会将全局执行上下文压入调用栈当中,于是就开始执行我们的代码了。

在执行的过程中:先会调用showName()输出杰哥之后再输出myName,因为此时的的myName仍然是undefined所以输出的是undefined,再往下执行,对myName进行赋值,于是myName:undefined->李杰,并将新的结果,存入到全局上下文当中。

通过这样一个案例,我们可以得出这样的一个结果:

上图就表示此时我们调用栈中的情况。

接下来,我们再看一个案例:

js 复制代码
function foo(){
    console.log('hello')
    foo()
}
foo()

while(1){
    console.log(123)
}

好了它的输出结果是什么?

js 复制代码
输出:
hello
....此处省略
hello
hello
hello
node:events:679
    function removeListener(type, listener) {
                           ^

RangeError: Maximum call stack size exceeded
    at WriteStream.removeListener (node:events:679:28)
    at Readable.removeListener (node:internal/streams/readable:926:47)
    at console.value (node:internal/console/constructor:313:16)
    at console.log (node:internal/console/constructor:380:26)
    at foo (C:\Users\www16\Desktop\培训\codespace\js\闭包\demo2\index.js:5:13)
    at foo (C:\Users\www16\Desktop\培训\codespace\js\闭包\demo2\index.js:6:5)
    at foo (C:\Users\www16\Desktop\培训\codespace\js\闭包\demo2\index.js:6:5)
    at foo (C:\Users\www16\Desktop\培训\codespace\js\闭包\demo2\index.js:6:5)
    at foo (C:\Users\www16\Desktop\培训\codespace\js\闭包\demo2\index.js:6:5)
    at foo (C:\Users\www16\Desktop\培训\codespace\js\闭包\demo2\index.js:6:5)

没错,它最后的输出结果会变成这样,在输出了很多个hello的时候,我们的编译器会报错这是为什么呢?

这是因为,我们的调用栈大小 其实是有限制的并不是说可以无限地区叠加。

无限递归或者过大的函数调用导致堆栈空间耗尽,导致栈溢出,随之而来的便是程序崩溃或者带来其他错误。

就相当于系统给你了大盒子装苹果,这个盒子是有限的,你如果一直往盒子里面装,装满了盒子,还在装,会导致苹果滚出来,丢失,更甚者会直接把盒子压坏。

调研栈也是这个道理。当然,一般我们不要进行无限递归或者无限循环调用函数是不会出现栈溢出的情况。

我们来分析一下此时调用栈的情况:

好了!我们调用栈就先学到这里啦,接下来我们来学习:

什么是作用域链?

在JavaScript中,作用域链(Scope Chain)是解决变量查找和函数调用的机制。当JavaScript代码试图访问一个变量或调用一个函数时,它需要沿着作用域链进行查找。

作用域链的概念与作用域密切相关。在JavaScript中,作用域定义了变量和函数的可见性和生命周期。作用域决定了变量的访问权限,以及变量在代码中的可见性。

作用域链的工作方式是,当JavaScript代码试图访问一个变量时,它首先在当前作用域中查找该变量。如果找不到,JavaScript会继续在上一个作用域中查找该变量,这个过程会持续进行,直到找到该变量或到达全局作用域。如果在全局作用域中仍然找不到该变量,JavaScript会返回undefined

类似地,当一个函数被调用时,JavaScript首先在当前作用域中查找该函数。如果找不到,JavaScript会在上一个作用域中继续查找,直到找到该函数或到达全局作用域。如果在全局作用域中仍然找不到该函数,JavaScript会抛出一个错误。

在JavaScript中,有两种类型的作用域:全局作用域和局部作用域。全局作用域中的变量和函数可以在当前作用域以及任何子作用域中被访问。局部作用域中的变量和函数只能在当前作用域中被访问。在函数内部定义的所有变量和函数都位于局部作用域中。

那么我们如何将作用域链与我们的执行上下文联系起来了呢?

首先我们看这样一个案例:

js 复制代码
function bar(){
    console.log(myName)
}
function foo(){
    var myName = '小红'
    bar()
}
var myName = '小明'
foo()

它的结果会输出什么?

是输出'小红'还是'小明',如果根据调用栈的思想,先进后出,逐层查找,会输出'小红'?

结果真的是这样嘛?

JS 复制代码
输出:
小明

其实不然,他的结果还是输出小明,为什么呢?

我们已经知道了:在我们的代码运行时,浏览器的内核会生成一个调用栈。

我们拿到这个代码进行分析:

  1. 首先,系统会生成调用栈,接着生成一个全局执行上下文
  2. 于是我们开始从全局中,获取全局的变量声明和函数声明。
  3. 在编译阶段:在我们的全局作用域当中声明了一个变量:var myName,在全局执行上下文中记录:myName = undefined
  4. 声明了两个函数体,在全局执行上下文中记录:bar:function,foo:function
  5. 将全局执行上下文推入调用栈中,接着开始执行全局。
  6. 执行到对myName进行赋值,执行foo()调用函数。
  7. 于是,浏览器内核又生成一个foo执行上下文于是对我们的foo函数体进行编译
  8. foo()函数体中声明了myName,然后在foo执行上下文中记录myName:undefined到这里,foo()函数编译就完成了,于是就再将它推入调用栈中!开始执行foo()函数体,对myName进行赋值,于是foo执行上下文中的myName:undefined->小红
  9. 接着调用bar()函数,于是我们的浏览器内核又生成bar执行上下文,于是开始对bar函数体进行编译。
  10. bar函数体中,只有一个输出语句开始执行!!

到这里了,我们思考bar()函数体中没有myName这个变量,那么它会去哪里查找这个变量呢?是去调用栈中的下一个foo()执行上下文找,还是回到全局执行上下文中查找呢?

这里,我们就要说一下作用域链

毫无疑问:bar()函数会回到全局中查找这个变量,这是因为,在bar()执行上下文被创建的时候,在bar()执行上下文的变量环境中会生成一个作用域链 ,我们就把它称为:outer

每次生成执行上下文的时候,其内部都会生成一个outer,它的作用是指示一个地址!指向的是其函数体的父容器!

也就是说,函数体定义在哪一个作用域当中,其outer就会指向那个作用域。

再看上述案例,bar()函数体定义在全局作用域中,虽然bar()函数在foo()函数中调用,最终,变量的查找也是顺着作用域链outer回到全局作用域中进行查找!

我们来看看图解:

接下来,我们来到我们的**"大山"**

什么是"闭包"?

在JavaScript中,闭包(Closure)是一个非常重要的概念,它是指一个函数可以记住并访问它被创建时的上下文中的变量,即使这个函数在其自身的上下文中被调用。闭包可以用于封装一个函数内部的变量,使得这些变量对外部是不可见的,从而保护数据的隐私和安全性。

闭包的特性可以归纳为以下几点:

  1. 闭包可以访问它被创建时的上下文中的变量。
  2. 闭包可以记住它被创建时的上下文,即使这个函数在其自身的上下文中被调用。
  3. 闭包可以访问并操作它被创建时所在的上下文中的变量。
  4. 闭包可以用于封装一个函数内部的变量,使得这些变量对外部是不可见的。

在JavaScript中,闭包可以通过以下方式实现:

  1. 在一个函数内部定义另一个函数,并返回这个内部函数。
  2. 返回的内部函数可以访问外部函数的变量对象和作用域链。
  3. 当外部函数执行完毕后,其变量对象和作用域链并不会被销毁,而是由闭包进行维护,以供内部函数后续访问。

在js中,根据词法作用域的规则内部函数总是可以访问其外部函数中声明的变量当内部函数被返回到外部函数之外时,即时外部函数执行结束了,但是内部函数引用了外部函数的变量,那么这些变量依旧会被保存在内存中,我们把这些变量的集合称为闭包

接下来,我们来学习这样一个案例:

js 复制代码
var arr = []
for(var i = 0; i <10; i++) {

    (function(j){
        var h =i
        arr[i] = function() {
            console.log(j);
        }
    })(i)
    }

for(var j = 0; j < arr.length; j++){
    arr[j]()
}

这个代码就js中闭包的一个经典体现

我们思考一下这个代码的输出结果是什么??

是输出十个10?还是?我们现在来揭晓答案!

js 复制代码
输出:
0
1
2
3
4
5
6
7
8
9

为什么是这样的输出结果呢???我们来画一个图展示:

我们怎么理解这张图片呢??大容器是调用栈(粗心没画)先把代码拿下来分析:

js 复制代码
var arr = []
for(var i = 0; i <10; i++) {

    (function(j){
        var h =i
        arr[i] = function() {
            console.log(j);
        }
    })(i)
    }

for(var j = 0; j < arr.length; j++){
    arr[j]()
}

在这个代码当中:

我们的全局作用域首先开始编译,向全局执行上下文存入:arr:undefined,于是开始执行

arr被赋值,于是开始更新全局执行上下文arr:undefined->[ ]

紧接着开始执行循环体部分:

我们就拿第一次循环来说:第一个循环体当中,有一个函数的执行,所以我们编译器会先编译,

生成一个function(j)执行上下文

在执行上下文中存入:h:undefinedarr[i]:function()j=undefined于是开始执行

赋值,形参和实参统一,给h赋值执行arr[i]()函数

此时function(j)执行上下文中存储的数据为:h:undefined->i j:undefined->i

**注意!!!**此时function(j)执行上下文执行完,被销毁!!调用栈中每一个执行上下文执行完都会被销毁!!

但是:function(j)执行上下文中的变量j无法确定是否被执行完,因为在arr[i]()对它进行调用,于是function(j)执行上下文被销毁的同时会丢下一个闭包 !!其中存储的就是变量j

再去执行arr[i](),浏览器内核会自动生成一个arr[i]()执行上下文,其中,它作用域链outer会指向function(j)留下来的闭包

于是每次执行console.log(j)的时候都会先再自身寻找这个变量,没找到这个变量,于是便随着作用域链outer找父级作用域中是否有这个变量!

所以说:每次循环都会有这样一个过程。并且arr[]是一个全局变量,所以输出结果会是

js 复制代码
输出:
0
1
2
3
4
5
6
7
8
9

好了,到这里了,我就为大家换一个说法帮助大家理解:我们把函数体比做一个个工作者,每次销毁就好比清洁工,每次当工作者完成工作的时候,清洁工就会过来问工作者:你的工作完成了嘛?如果工作者可以确切表明完成了的话,就会被清理。如果说有其他情况,就比如函数体中的变量没有在本身函数体中被执行,函数体无法辨别这个变量是否被执行完毕,那我们的清洁工就先不会将这些无法确定是否被执行完的变量清除,而是将他们放在一个闭包当中,以方便后来调用这些变量的函数进行调用!!

最后总结!!

js 复制代码
# 调用栈
用来管理函数调用关系的一种数据结构

当一个函数执行完毕后,它的执行上下文就会出栈!

# 作用域链
通过词法作用域来确定某作用域的 外层作用域,查找变量由内而外
的这种链状关系,叫做作用域链

# 闭包
在js中,根据词法作用域的规则内部函数总是可以访问其外部函数中声明的变量
当内部函数被返回到外部函数之外时,即时外部函数执行结束了,但是内部函数引用
了外部函数的变量,那么这些变量依旧会被保存在内存中,我们把这些变量的集合称为闭包

- 变量私有化

- 内存泄漏  导致栈空间越来越少

好啦!!我们今天的学习就到这里啦!如果大家有任何意见或者指正,欢迎大家在评论区留言!

点一个赞鼓励支持一下吧!🌹🌹🌹

相关推荐
程序猴老王2 分钟前
el-select 和el-tree二次封装
前端·vue.js·elementui
blzlh13 分钟前
手把手教你做网易云H5页面,进大厂后干的第一件事
前端·javascript·css
贩卖纯净水.40 分钟前
网站部署及CSS剩余模块
前端·css
Justinc.1 小时前
CSS3_BFC(十二)
前端·css·css3
刺客-Andy1 小时前
React第四节 组件的三大属性之state
前端·javascript·react.js
黄毛火烧雪下1 小时前
React 表单Form 中的 useWatch
前端·javascript·react.js
爱健身的小刘同学2 小时前
钉钉免登录接口
前端·javascript·钉钉
啵咿傲2 小时前
跨域相关的一些问题 ✅
前端
命运之光2 小时前
生日主题的烟花特效HTML,CSS,JS
前端·javascript·css
Cshaosun2 小时前
js版本之ES5特性简述【String、Function、JSON、其他】(二)
前端·javascript·es