阅读 51

JavaScript深入之闭包

引言

在 JavaScript 执行机制一中讲了变量环境、词法环境,一串 JavaScript 代码,首先会经历编译阶段,在编译阶段 var 声明的变量和函数声明的变量,会进行变量提升,存放在变量环境中,块级作用域中声明的变量,会存放在词法环境中。编译后会执行代码,在执行阶段,对编译阶段的代码进行赋值和输出。那么如果执行阶段中,有重复的变量,JavaScript 代码是如何执行的呢?

作用域链

每个执行上下文中,都包含一个外部的引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。 当执行一段 JavaScript 代码时,JavaScript 引擎首先会在当前执行上下文查找变量,如果没找到,会沿作用域链向上查找变量。那么这儿的作用域链是如何定义的呢?

先看一个例子

function fn1() {   console.log(name) // jack } function fn2() {   var name = 'eson'   fn1()   console.log(name) // eson } var name = 'jack' fn2() 复制代码

这段代码中的 fn1 函数和 fn2 函数中打印值是什么呢?结合前面所学,我们先来分析下代码,具体如下图所示

scope_chain.png

从图中可以看到,在执行 fn1 输出时,全局上下文和 fn2 函数中都声明了 name 值,那么输出的 name 是 eson 还是 jack 呢? 如果输出的值是 eson,那么查找变量的逻辑是通过调用的顺序执行的,但实际结果却是 Jack。是如何定位的呢?

在每个执行上下文的环境中,都包含一个外部引用,这个引用指向外部的执行上下文,我们称这个外部引用为 outer

当执行一段代码时,JavaScript 引擎首先在当前执行上下文中查找变量,如果找不到,会根据外部引用outer去对应的外部执行上下文查找变量,直到查找到最外层的全局执行上下文,从当前执行上下文到全局执行上下文,形成了一个链条,我们称为作用域链

此时再来分析下上面的例子,如下图所示:

outer.png

从图中可以看到,fn1 函数和 fn2 函数的 outer 都指向的是全局执行上下文,那么从 fn1 函数或 fn2 函数到全局上下文构成的链条,就是作用域链

例子中 fn2 函数调用了 fn1 函数,按正常照逻辑来讲,fn1 函数的 outer 应该指向的 fn2 的执行上下文,可是它却指向的是全局执行上下文,这是什么原因呢?要了解这个原因,需要先知道什么是词法作用域

词法作用域

词法作用域是由代码声明的位置决定的,所以词法作用域是静态作用域,通过它能预测代码在执行过程中如何查找标识。

lexical_scope_chain.png

由图可以看出,词法作用域是根据代码的位置决定的,其中 fn 函数中包含了 fn1 函数,fn1 函数包含了 fn2 函数,因为词法作用域是根据代码声明的位置所决定,那么作用域链的顺序是:fn2 函数作用域 -> fn1 函数作用域 -> fn 函数作用域 -> 全局作用域。

根据上面的分析,我们可以确定,词法作用域在代码编译的时候就确定了,和函数怎么调用没有直接的关系。

对于代码中存在块级作用域的情况,作用域链首先在词法环境中查找,没找到才会到变量环境中查找。详细在JavaScript 执行机制一中会有详细分析。

闭包

闭包对于初学者是一个难点,前面讲了变量环境、词法环境、作用域链,了解这些知识,对于理解闭包会比较容易,下面先看一个 ????:

var name = '李四' function foo() {   var name = '张三'   var age = 18   function showName() {     console.log(name)   }   return showName } var f = foo() f() 复制代码

从前面的知识,我们知道

  • 首先会创建一个全局执行上下文,入执行栈,变量环境存有变量 name 和 f,词法环境为 null;

  • 当遇到 f = foo()时,创建一个 foo 执行上下文,变量环境存有 name 和 age,词法环境为 null;

  • 当执行到 f()时,创建一个匿名的执行上下文,变量环境和词法环境为 null;

  • 执行 console.log(name),在当前执行上下文找变量 name,没找到;

  • 根据 outer,在 foo 执行上下文中找到变量 name 的值;

    在执行完 foo 函数的时候,按规则应该将 foo 函数销毁,但是我们在全局执行 f()中使用到了 name 变量,变量 name 的值并没有被销毁,那么像变量 name 就构成了 foo 闭包。

    在 JavaScript 中,根据词法作用域的规则,内部函数总能访问外部函数声明的变量,当通过调用外部函数返回内部函数时,即使外部函数执行完毕,内部函数引用外部函数的变量仍存在内存中,我们就把这些变量的集合称为闭包。比如 foo 函数中的 name 变量构成的集合,就是 foo 函数的闭包。

    那么闭包在内存中是如何存储的呢?为什么外部函数执行完成,销毁了,变量仍然存在内存中?

栈空间和堆空间

在 JavaScript 中,有 8 种数据类型:

基本数据类型:Undefined、Null、String、Boolean、Number、BigInt、Symbol; 引用数据类型:Object

对于基本数据类型,在内存中是存在栈空间中的,也就是前面说的执行栈中,而引用数据类型是存在堆空间中的。对于引用数据类型,栈空间只存有对应引用数据类型的堆中的地址。

明白了这个概念,再来看看闭包是如何存储的。

  • 当执行 foo 函数时,会预扫描内部函数是否有使用的 foo 函数中声明的变量;

  • 预扫描过程中,在内层函数 showName 中找到了外部函数 foo 中声明的变量 name,因此在 JavaScript 中判断这是一个闭包,此时会在堆中开辟一个空间创建 closure(foo)对象,将 name 值存到里面。而执行栈中的 foo 执行上下文中的 name 存的值是 closure(foo)的地址,因此当 foo 函数执行完成并销毁,name 值仍然存在堆的 closure(foo)的对象中,具体如下图所示:

heap.png

站在内存的角度分析了闭包中的变量是存在堆空间中,它并不会随着函数执行完毕而回收。根据这个规则,如果一个系统中大量使用了闭包,内存的占用会越来越大,系统使用越久就越慢,该怎么解决呢?

闭包是如何回收的

理解了闭包,接下来看看闭包是如何回收的。如果闭包使用不正确,会造成内存泄露,只有理解闭包是如何回收的,才能正确的使用闭包。

如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭。但如果这个变量之后不在使用,就会造成内存泄露。 如果引用闭包的函数是一个局部变量,等函数销毁后,在下一次执行垃圾回收时,判断闭包这块内容不再使用,那么 JavaScript 引擎的垃圾回收器就会回收这块内容。

所以使用闭包的原则是:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,占用内存又大,那么尽量让它作为局部变量使用。

总结

本文从作用域链、词法作用域分析了产生闭包的原因,同时也站在存储的角度分析了,为什么闭包造成内存泄露的原因。

  • 作用域链:每个执行上下文都有一个指向外部的 outer,这个指向外部的引用outer在编译时就决定了,也就是说,根据声明的位置就能确定外部引用 outer,这就是词法作用域(静态作用域),它能预测代码执行过程中如何查找标识。通过它就能确定作用域链。

  • 闭包:外部函数声明的变量,在内部函数中调用,当调用外部函数返回内部函数时,外部函数中声明的变量并不会随着外部函数执行完成而销毁,这些变量构成的集合就是外部函数的闭包。

  • 栈和堆空间:基本数据类型是存在栈空间中的,引用数据类型是存在堆空间中的。当形成闭包时,外部函数声明的变量存放在调用栈中,对于外部函数中的变量被内部函数所使用,在堆中就会创建一个 closure(函数名)的对象,用于存放闭包的变量集合,而外部函数中存的只是堆中的地址。所以说,当外部函数执行完成销毁时,JavaScript 引擎并不会回收堆中的地址。

  • 正确使用闭包:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,占用内存又大,那么尽量让它作为局部变量使用。


作者:chicABoo
链接:https://juejin.cn/post/7023259995219165214


文章分类
后端
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐