js中的闭包

众所周知,闭包是js非常难的一个难点,但是我不认同,闭包其实很好理解,看完这篇文章,我相信你可以攻克这一难点。文章有点长,请大家多花点耐心。

为了讲闭包,我们还需要引入调用栈,以及作用域链这两个概念

调用栈、作用域链

我们先看一段代码

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

这个代码的意思就是我们调用foo的时候会打印一个hello,并且之后再进行调用自己,再打印hello,再调用自己,一直下去。似乎是陷入一个死循环,我们来看输出

js 复制代码
hello
hello
.....//此处省略
hello
RangeError: Maximum call stack size exceeded

这里居然还会报错,call stack 其实就是调用栈的意思,这段代码报错说的是调用栈爆栈了,空间不足!

调用栈其实就是用来存放执行上下文的一种数据结构 并且他有一个很重要的特点,大家一定要记住!:当一个执行上下文执行完毕之后,它的执行上下文就会出栈 栈的空间是有限的,如果一直不进行出栈,那么这个栈就会被挤爆掉,所以上面那个例子的输出结果我们可以理解了。

我们再来看一段代码

js 复制代码
var a = 2
function add(){
    var b = 10
    return a + b
}
add()

如何确定add执行上下文的外层环境呢,我们只需要看它在哪里声明即可,add函数声明在全局作用域中,所以它的外层作用域就是全局执行上下文(或全局作用域),所以它的outer指向了全局执行上下文。

每个执行上下文都会有个outer指针,outer指针的初始值为null,这个指针指向的是该执行上下文的外层上下文,v8在查找变量的过程中,顺着执行上下文中的outer指向查找一整根链,这种链状关系就叫作用域链

下面给出作图帮助理解

下面再给个例子帮助大家深化理解

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
}
addAll(3,6)

全局执行上下文:a: undefinedfunction: add()function: addAll()准备好之后入调用栈并且开始执行:a -> 2addAll() 于是开始编译addAll()

addAll执行上下文:d: undefinedresult: undefinedb ->3c->6 准备好后入调用栈并且开始执行:d -> 10result = add(3,6)

add执行上下文:b->3c->6 准备好后入调用栈并且开始执行,返回9

当9赋值给result的时候,我们的add执行上下文就已经执行完毕,当一个上下文执行完毕后会进行销毁,这就是一个解决栈满的机制。这也可以解释一直调用自己的函数的时候,它永远不会执行完毕,因此一直不销毁,再进行堆栈,所以会引发栈满,一般栈可以存放上百个上下文,不用去担心栈满。于是addAll开始执最后的return,a + result + b 里面只有a是找不到的,于是顺着addAll中的outer指针找,我们说了,指针的指向就是该函数的声明所在作用域,于是去全局找a,最后返回结果 2 + 9 + 10 = 21,于是addAll进行销毁,你想的答案是否对了呢?

最后给出一道面试题目,我们来看看这里会输出什么?

js 复制代码
function fn() {
    var arr = []
    for (var i =0;i < 5;i++){
        arr.push(function () {
            console.log(i);
            
        })
    }
    return arr
}

var funcs = fn()
for (var j= 0; j < 5; j++) {
    funcs[j]()  
    
} 

我先直接说运行结果为5个5了。

这是为什么?我们可以简短分析一下,就是i这个值存在于全局中,等到执行函数的时候,i已经为5了,5个函数都是打印i,所以是5个5。如果我想正常输出0到4呢?

第一种解决办法就是var换成let

let可以和{}结合形成一个块级作用域,因此会形成5个块级作用域,每个块级作用域都有一个i,因此5个函数声明在这里就是声明在对应的块级作用域中,也就是outer指向了对应的块级作用域,第一个函数找i时,去第一个块级作用域中找,里面的i是0,第二个函数找i时,去第二个块级作用域中找,里面的i是1,如此循环,就是0到4了。

第二种解决办法就是我们今天的重点闭包

闭包

我们通过下面这个例子帮助大家来认识下闭包

js 复制代码
function foo(){
    var myName = '小黑子'
    let test1 = 1
    let test2 = 2
    var innerBar = {
        getName: function(){
            console.log(test1);
            return myName
        },
        setName: function(newName){
            myName= newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName('大黑子')
bar.getName()

我们来一起逐步分析下

全局执行上下文:function: foo() bar: undefined 准备好后入栈开始执行该上下文,bar -> foo(),于是开始编译foo

foo执行上下文:myName: undefined, test1: undefined , test2: undefined innerBar: undefined,这里需要注意innerBarinnerBar是个对象,里面可以存放各种数据类型,后面我还会出一片对象的文章专门聊聊对象。该执行上下文准备好后入栈开始执行,里面的赋值我就不多赘述了,最终会返回一个innerBar这个对象。

返回对象给bar后,正常来讲这个执行上下文是已经进行了自我销毁。bar现在就成了innerBar这个对象,这个对象里面有两个key是函数,于是bar.setName就是调用这个函数,这个函数最终会返回一个myName,也就是大黑子,于是现在开始执行bar.getName,这个函数里面需要打印test1这个变量,和返回myName,这两个在函数体内都没有,于是查看outer,发现outer指向的是foo这个函数执行上下文,但问题来了,这个foo执行上下文在foo()赋值给bar时就已经进行了销毁,所以打印结果时报错才对。可是这道题的运行结果居然是

复制代码
1
大黑子

这是为什么呢,原来我们的js执行引擎在看到函数体内还有函数并且return的时候,在执行完外部函数的进行销毁的瞬间会丢出一个包,这个包就用来存放内部函数需要用到的变量,这里就是test1myName,里面的值分别为1和小黑子,丢出包也就是执行完foo,在此之后执行setName,这个时候setName指向的位置由原来的foo变为了foo丢下的闭包,于是寻找myName更改其值为大黑子,最后执行getName,输出为最终正确结果。``

整个过程非常绕,这里我还是给出一个贴图帮助大家理解

我们再来看一个更加直观的例子

js 复制代码
function a(){
    function b(){
        var bbb = 234
        console.log(aaa);
    }
    var aaa = 123
    return b
}
var c = a()
c()

这个题目其实已经很好理解了,全局中c需要被赋给a这个函数(a赋值完给c后销毁),而a这个函数返回了b这个函数,因此c其实就是接收了b这个函数,所以说执行c这个函数等同于执行b这个函数,而b原本指向了a这个函数,此时a销毁后留下了带有b函数所需参数的闭包,所以b函数指向了这个闭包,其实因此调用c会正常打印出aaa的值123。

好,现在我们收回到for循环那个题目,我们现在需要的是一个不改变var的情况下正常输出0到4这些数,既然要用闭包,我们肯定需要一个双层函数的结构,既然解决的是i没有赋值的问题,我们可以把i丢到闭包中,然后让console.log(i)指向这个i,就可以了。因此我们需要在第一个for循环中新增一个外层函数,让i赋值进去,我们就需要一个形参来接收。

js 复制代码
for(var i = 0; i < 5; i++){
    (function a(n){
        arr[i] = function(){
            console.log(n);
        }
    })(i)
}

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

初看可能有点难以理解,因为这里用到了自执行函数 。为了方便大家理解,我先解释下自执行函数。其实很好理解,我们声明一个foo函数是写成function foo(){},调用这个函数的时候foo()即可,这个foo就是一个函数体,因此我们完全可以把foo换成一个函数体,当然这里需要再加个(),于是这就是一个自执行函数。

这个for循环中的外层函数a()声明完后立即执行,并且还把i传了进去,至于里面的内层函数我们先不管,因为里面并没有return,等执行语句,里面只是把一个函数体赋值给了arr[0](i=0为例),这里需要注意,当a()函数执行完毕的时候(自执行函数),这个a的执行上下文会销毁掉,a的执行上下文里面只有i这个参数,并且i = 0,在销毁的时候它需要留一下里面的内层函数是否会用到自己的参数,发现内层函数确实需要,于是arr[0]里面的i就是指向了a[0]留下的闭包,如此循环,每个arr都有自己对应的a,并且a走后留下对应的闭包给到arr,因此最后执行10个arr内部函数的时候就是依次打印0-4了。

总结

如果上面的内容你都一一看完并且能够理解到位,那么恭喜你,你已经学会了闭包。我们可以通过上面的例子对闭包进行一个总结。

在js中,根据词法环境中的规则,内部函数总是可以访问到外部函数中的变量。当内部函数被返回到外部函数之外时,即使外部函数执行完毕被销毁时,内部函数还是可以引用到外部函数,并且此时的外部函数的执行上下文改成了一个闭包。

另外补充一点,一般闭包都是架构师用得多,这样做可以让变量私有化,当然我们使用闭包不能滥用,闭包还是存放在调用栈中,滥用还是会导致内存泄漏,所谓内存泄漏就是占用了栈空间

如果觉得本文对你有帮助的话,可以给个免费的赞吗?

相关推荐
彭铖洋3 小时前
VSCode会击败Cursor和Windsurf吗?
javascript·reactjs
HelloRevit5 小时前
Next.js 快速启动模板
开发语言·javascript·ecmascript
程序饲养员6 小时前
ReactRouter7.5: NavLink 和 Link 的区别是什么?
前端·javascript·react.js
拉不动的猪7 小时前
设计模式之------命令模式
前端·javascript·面试
uhakadotcom7 小时前
Bun vs Node.js:何时选择 Bun?
前端·javascript·面试
前端snow8 小时前
前端工程师看docker是什么?
前端·javascript·docker
鱼樱前端8 小时前
技术路线升级:从“纯前端”到“高价值前端”
前端·javascript
堕落年代8 小时前
Uniapp当中的async/await的作用
前端·javascript·uni-app
vjmap9 小时前
如何一键自动提取CAD图中的中心线(如墙体、道路、巷道中心线等)
前端·javascript·gis
小鱼儿20209 小时前
vue入门:函数式组件
前端·javascript·vue.js