通过 debugger图解JS 的闭包

吐槽君 分类:javascript

这是一个最好的时代,这也是一个最坏的时代。

本篇文章假定你对 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

1.png

在first 函数入栈时,我们发现局部变量有 dept=1,first:'白’,firstName:'白',局部变量以及函数参数都在,没有任何特殊的事情发生,OK,让我们进入下一步。

  • 第二次 debugger

2.png
second 函数进入了调用栈,如果你对作用域这一块内容熟悉的话,你应该不会惊讶,我们scope作用域中多了一个名为 first 的闭包,里面存储的正是 first 函数里面的变量对象(如果忘了这个名词,可以回头查看),因此通过这里我们可以知道,内部的 second 函数是可以访问到 first 函数里的属性的,让我们接着看第三个 debugger 会发生什么。

  • 第三次 debugger

3.png

third 函数进入了调用栈,并且跟第二次 debugger 如出一辙,作用域不仅将外部 second 函数的变量对象包含了起来,还包括了first 函数的变量对象,因此她可以访问并且修改 dept,并且成功置为 3。

学过作用域的我们都知道,函数内部访问一个变量时,在自身作用域没找到,就会从函数外部去找,直至找到全局作用域,那么问题来了,如果外部函数已经弹出执行栈,仍然可以访问吗?

这就是第四个 debugger 的作用所在了,为了检测 third函数可以在 first 和 second 已经出栈的情况下还能访问自身不存在的变量的情况,在代码中我们通过 y = first('白')('小')来获得第三个函数,让我们看看会发生什么。

  • 第四次 debugger

4.png

我们可以看到成功获取了 third 函数,并且有了一个惊人的发现,在 firstsecond 的执行环境被弹出的情况下,我们的 third 函数的 [[scopes]]属性仍然包含着他们的变量对象,回过头看,这不就是我们的作用域链吗?!

根据查阅资料显示:这里 y 的[[scopes]]里面存放的是运行期上下文的集合,因为我们并没有清除对 third 的引用,因此可以知道,我们的作用域链上仍然有着 firstsecond 函数的变量对象,这样就自然就能在 firstsecond 函数弹出调用栈之后还能访问到,因为函数弹出调用调用栈,只是销毁了内部的指向作用域链的指针 scope,而我们作用域链却没有因此去删除相应的变量对象,因为我们 third 函数仍然在引用。下面,让我们再次来读刚开始那段话,是否又有了新的感受呢?

简单来说,JS引擎进入代码后,会创建变量对象的一个的作用域链,以此保证对执行环境有权访问的所有变量和函数的有序访问。在进入函数后,会创建函数执行环境,并创建相应的变量对象,把我们的变量对象推入到作用域链的前端,作用域链的下一个结点保存的是外部环境的变量对象,作用域链的底端则是全局执行环境的变量对象。

作用域链和我们的执行调用栈是两个东西,在外部函数执行栈出栈后,因为内部函数仍然有引用,因此作用域链不会删除对应的变量对象,我们仍然可以通过作用域链来访问外部环境中的变量对象,这就是闭包现象。只有完全解除引用,作用域链才去维护这个状态,删除其变量对象。

这篇文章差不多就到这里了,你对闭包有了新的认识了吗?

回复

我来回复
  • 暂无回复内容