通过 debugger图解JS 的闭包
这是一个最好的时代,这也是一个最坏的时代。
本篇文章假定你对 JS
已经有一定的基础,如果对执行环境以及作用域链有一定的了解或者怕冗长的概念者,请直接跳到第三步实践环节。
1.写在前面
在 JavaScript
的领域,关于对闭包的解读实在太多了,各大社区随便一搜就是一划不见底的海量技术贴,但是其中充斥了大量的没自己独到见解的随手 copy 的概念堆砌文,虽然它们其中也不乏四两拨千斤让人豁然开朗的深度技术好贴,但是都缺乏让人实际的图文场景并且能够长期使用记住的效果,因此本篇文章是我在 <<js高程设计>>闭包一节以及修言的《从编译原理的角度理解作用域》作为理论基础,然后通过实践写出来的一篇文章,如有错误指出,还望指正。
2.尽量凝练的前置知识
说到闭包,我们又不得不说到JS
的作用域链,说到作用域链,又不得不说到JS
的执行环境,因此说闭包,其实是对几个重要的JS
核心概念做解释和梳理,但是为了避免在用代码解释闭包之前做大量的术语解释,我只简要列以下几点前置只是铺垫,如有自己不熟悉的地方,自行补全。
别走,我发誓只有这一个概念要看。
- 执行环境(也叫执行上下文)
JavaScript
代码执行的时候会进入不同的执行环境,执行环境定义了变量或者函数能够访问的其他数据,这些执行环境会形成一个执行环境栈,下面就是它的组成:
执行环境 | |
---|---|
variable object(变量对象) | 函数声明,局部变量,函数参数,他们的集合就叫变量对象 |
[[scope]]属性 | 指向作用域链,作用域链是一个由变量对象组成的带头结点的单向链表,其主要作用就是进行变量查找,而 scopde 属性是指向该链表的头结点,稍后我们将在实践中看到这一个属性。 |
this 指针 | 指向一个环境对象,而不是一个执行环境 |
以上就是执行环境的组成。
简单来说,
JS
引擎进入代码后,会创建变量对象的一个的作用域链,以此保证对执行环境有权访问的所有变量和函数的有序访问。在进入函数后,会创建函数执行环境,并创建相应的变量对象,把我们的变量对象推入到作用域链的前端,作用域链的下一个结点保存的是外部环境的变量对象,作用域链的底端则是全局执行环境的变量对象。
作用域链和我们的执行调用栈是两个东西,在外部函数执行栈出栈后,因为内部函数仍然有引用,因此作用域链不会删除对应的变量对象,我们仍然可以通过作用域链来访问外部环境中的变量对象,这就是闭包现象。只有完全解除引用,作用域链才去维护这个状态,删除其变量对象。
以上两句话非常重要,现在你可能还不能完全消化它,那么下面我们就进入实战环节吧。
3.通过 debugger图解JS 的闭包
一句废话也不多说,先直接上代码:
function first(first) {
const firstName = first
const dept = 1
debugger // 第一个 debugger
return function second(second) {
const secondName = firstName + '/' + second
dept = 2
debugger // 第二个 debugger
return function third(third) {
const thirdName = secondName + '/' + third
dept = 3
debugger // 第三个 debugger
}
}
}
const x = first('白')('小')('唯')
const y = first('白')('小')
debugger // 第四个 debugger
first
函数内部return 回来一个second
函数,second
函数里面又return 回来一个名为 thrid
函数,并且我们分别在合适的时机debugger 了一下,下面让我们在浏览器中跑一下,看看会出现什么情况。
- 第一次 debugger
在first 函数入栈时,我们发现局部变量有 dept=1,first:'白’,firstName:'白'
,局部变量以及函数参数都在,没有任何特殊的事情发生,OK,让我们进入下一步。
- 第二次 debugger
second
函数进入了调用栈,如果你对作用域这一块内容熟悉的话,你应该不会惊讶,我们scope
作用域中多了一个名为 first
的闭包,里面存储的正是 first
函数里面的变量对象(如果忘了这个名词,可以回头查看),因此通过这里我们可以知道,内部的 second
函数是可以访问到 first
函数里的属性的,让我们接着看第三个 debugger 会发生什么。
- 第三次 debugger
third 函数进入了调用栈,并且跟第二次 debugger 如出一辙,作用域不仅将外部 second 函数的变量对象包含了起来,还包括了first 函数的变量对象,因此她可以访问并且修改 dept,并且成功置为 3。
学过作用域的我们都知道,函数内部访问一个变量时,在自身作用域没找到,就会从函数外部去找,直至找到全局作用域,那么问题来了,如果外部函数已经弹出执行栈,仍然可以访问吗?
这就是第四个 debugger 的作用所在了,为了检测 third函数可以在 first 和 second 已经出栈的情况下还能访问自身不存在的变量的情况,在代码中我们通过 y = first('白')('小')
来获得第三个函数,让我们看看会发生什么。
- 第四次 debugger
我们可以看到成功获取了 third
函数,并且有了一个惊人的发现,在 first
和 second
的执行环境被弹出的情况下,我们的 third
函数的 [[scopes]]
属性仍然包含着他们的变量对象,回过头看,这不就是我们的作用域链吗?!
根据查阅资料显示:这里 y 的[[scopes]]
里面存放的是运行期上下文的集合,因为我们并没有清除对 third 的引用,因此可以知道,我们的作用域链上仍然有着 first
和 second
函数的变量对象,这样就自然就能在 first
和 second
函数弹出调用栈之后还能访问到,因为函数弹出调用调用栈,只是销毁了内部的指向作用域链的指针 scope,而我们作用域链却没有因此去删除相应的变量对象,因为我们 third
函数仍然在引用。下面,让我们再次来读刚开始那段话,是否又有了新的感受呢?
简单来说,
JS
引擎进入代码后,会创建变量对象的一个的作用域链,以此保证对执行环境有权访问的所有变量和函数的有序访问。在进入函数后,会创建函数执行环境,并创建相应的变量对象,把我们的变量对象推入到作用域链的前端,作用域链的下一个结点保存的是外部环境的变量对象,作用域链的底端则是全局执行环境的变量对象。
作用域链和我们的执行调用栈是两个东西,在外部函数执行栈出栈后,因为内部函数仍然有引用,因此作用域链不会删除对应的变量对象,我们仍然可以通过作用域链来访问外部环境中的变量对象,这就是闭包现象。只有完全解除引用,作用域链才去维护这个状态,删除其变量对象。
这篇文章差不多就到这里了,你对闭包有了新的认识了吗?