JS进阶 - 图解闭包

884 阅读9分钟

初衷

在实际开发中,通常是直接使用框架进行开发,框架确实提高了开发效率。在学习框架源码的过程中,越发感觉到 JavaScript 基础知识的重要性。所以打算重新梳理 JavaScript 中的知识点,并以笔记的形式发布出来。一方面分享给掘友们,另一方面由于是初次尝试书写技术型文章,若梳理内容不够完善,也欢迎掘友们指正。

目前已完成

当然,如果各位掘友认为书写的比较清晰,欢迎点赞、收藏加关注哦~


正文开始

初学 JavaScript 时,闭包是一个不太好理解的知识点。但在一些框架的源码中也充满了闭包,而且在《你不知道的 JavaScript(上卷)》中,作者将理解闭包,看作是某种意义上的重生。

《你不知道的 JavaScript(上卷)》5.1节

对于那些有一点 JavaScript 使用经验但从未真正理解闭包概念的人来说,理解闭包可以看作是某种意义上的重生。但是需要付出非常多的努力和牺牲才能理解这个概念。

真是如此吗?下面我们来剖析下闭包到底有什么神奇之处,争取一篇文章彻底搞懂闭包。

在剖析之前,需要有 3 个前置知识

  • 作用域
  • 垃圾回收
  • 函数是一等公民

作用域

在JavaScript中,有全局作用域、函数作用域以及E6加入的letconst块级作用域(以后再做具体介绍)。目前先以ES5来介绍作用域

// 示例
var message = "global";

function foo() {
  var message = "foo";

  console.log(message); // foo - 访问的是函数作用域中的message
}

foo();
console.log(message); // global - 访问的是全局作用域中的message

上述示例代码中,全局代码中的message访问的是全局作用域中声明的message,而在函数中访问到的是函数作用域中声明的message。原因是在执行全局代码时,JavaScript引擎会创建全局执行上下文(GEC,Global Execution Context);当执行函数调用时,JavaScript引擎会创建函数执行上下文(FEC,Functional Execution Context)。GEC和FEC中的变量对象(VO,Variable Object)Scope Chain是当前执行上下文保存的变量对象VO以及父级变量对象ParentVO, 在GEC中Scope Chain就是VO,也就是全局对象(GO,Global Object);在FECScope Chain就是活动对象(AO,Activation Obejct) 以及父级作用域的VO。全局声明的变量、函数保存在全局对象(GO)中,函数中声明的变量、函数,保存在函数对应的活动对象(AO) 。上述代码中的变量访问过程如下图所示

01_作用域-单个函数.png

关于JavaScript引擎创建GEC、FEC的执行过程,可以查看我的上篇文章 JavaScript-执行机制

垃圾回收

不管哪种编程语言,在代码执行过程中都需要分配内存。不同的是有些编程语言都要手动管理内存,有些编程语言可以自动管理内存。

不管以哪些方式来管理内存,内存管理有如下生命周期:

  1. 申请需要使用的内存
  2. 使用分配的内存
  3. 不需要使用时,对其进行释放

不同的编程语言对于第 1 步和第 3 步会有不同方式的实现

  • 手动管理内存:比如 C、C++、OC,都需要手动管理内存的申请和释放(malloc 和 free 函数)
  • 自动管理内存:比如 Java、JavaScript、Python、Swift、Dart 等,可以自动地帮我们管理内存

JavaScript 的内存管理

JavaScript 会在定义变量时分配内存,但针对不同的数据类型有所不同

  • 基本数据类型,会在执行时直接在栈空间中进行分配
  • 复杂数据类型,执行时会在堆内存中开辟一块空间,并将该空间的内存地址(指针)返回,存储在变量中

JavaScript 的垃圾回收

因为内存大小有限,所以当分配的内存不再需要使用时,需要对其进行释放,以便腾出更多内存空间。

在手动管理内存的编程语言中,需要通过一些方式开发者来释放不再需要的内存,比如 free 函数

  • 管理方式非常低效,而且影响编写逻辑代码的效率
  • 对开发者的要求高,如果忘记释放,可能会产生内存泄露

目前大部分编程语言都有自己的垃圾回收机制,对于那些不再使用的对象,都称之为垃圾,需要被回收,以释放更多的内存空间。比如 Java 的运行环境 JVM、JavaScript 的运行环境 JavaScript 引擎都有垃圾回收器(GC,Garbage Collection)。那么垃圾回收器是如何知道哪些对象不再使用呢?

常见的 GC 算法

引用计数
  1. 当一个对象有一个引用指向它时,那么这个对象的引用数就+1。当一个对象的引用数为 0 时,就说明这个对象可以被销毁
  2. 这个算法有一个很大的弊端就是会产生循环引用,如下图

01_GC-引用计数.png

标记清除
  1. 这个算法是设置一个根对象,垃圾回收器(GC)会定期从这个根对象开始,找到所有从根开始有引用到的对象。对那些没有引用到的对象,就认为是不可用的对象
  2. 这个算法很好的解决了循环引用的问题,如下图

02_GC-标记清除.png

JavaScript 采用比较广泛的是标记清除算法,当然类似于 V8 引擎为了进行更多的优化,在算法的实现细节上也会结合一些其他的算法。

函数是一等公民

在JavaScript中,函数使用非常灵活,可以作为一个函数的参数,也可以用为一个函数的返回值。

函数作为参数

function foo(fn) {
  fn(); // 调用传入的函数参数
}

function bar() {
  console.log("bar"); // bar
}

foo(bar); // 将函数bar作为参数传入foo函数中

函数作为返回值

function foo() {
  function bar() {
    console.log("bar");
  }

  return bar;
}

var result = foo(); // 执行foo函数,获取到返回值函数bar,并保存在result变量中
result(); // 通过result执行返回的函数

闭包

什么是闭包

MDN上的解释

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

个人理解
  1. 一个普通的函数,如果可以访问外层作用域的自由变量,那么这个函数就是一个闭包

  2. 广义的角度,因为函数可以访问到全局作用域的自由变量,所以JavaScript中的函数都是闭包

  3. 狭义的角度,如果函数访问了外层作用域的变量,那么就是闭包。如果没有访问,就不是闭包。

闭包的访问过程

根据定义,编写代码剖析下闭包的访问过程

function foo() {
  var name = "zhangsan";

  function bar() {
    var age = 28;

    console.log(name); // zhangsan
    console.log(age); // age
  }

  return bar;
}

var result = foo();
result();

上述示例代码,代码的执行过程如下:

  1. 解析:首先创建全局对象(GO),根据foo函数声明创建foo的函数对像(Funcion Object),函数对象中除了函数体内编写的代码,还保存着该函数的父级作用域
  2. 解析完成,会先创建全局执行上下文(GEC)压入到执行上下文栈(ECS),然后开始从上到下依次执行代码。
  3. 执行到调用foo()时,引擎会根据foo的函数对像(Funcion Object)创建对应的foo的活动对象(AO),然后创建foo的函数执行上下文(FEC)压入到执行上下文栈(ECS),然后开始执行函数内的代码。foo的函数执行上下文(FEC)中的变量对象(VO)指向foo的活动对象(AO)
  4. 在执行foo函数过程中,在函数内部又声明了bar函数,则会创建对应的bar的函数对像(Funcion Object)
  5. foo函数执行完成后,返回了内部的bar函数,将最终的返回值保存在变量result中。同时foo的函数执行上下文(FEC)会弹出执行上下文栈
  6. 执行到result()时,引擎根据bar的函数对像(Funcion Object)创建bar的活动对象(AO) , 然后创建**bar的函数执行上下文(FEC)压入到执行上下文栈(ECS)**开始执行代码。bar的函数执行上下文(FEC)中的变量对象(VO)指向bar的活动对象(AO)
  7. bar函数中访问了nameage两个变量,会通过**bar的函数执行上下文(FEC)中的变量对象(VO)**查找变量的值。
  8. bar函数内部声明了age变量,所以可以直接访问到age变量的值。但没有声明name变量,所以此时的变量对象VO(即bar的活动对象AO)中没有name变量,则会通过bar的函数执行上下文(FEC)中保存的Scoped Chain,查找父级作用域的变量对象VO(此时是foo的活动对象),在foo的活动对象中保存了foo函数声明的name变量的值。
  9. 正常情况下,foo函数在执行结束,foo的活动对象会被释放。但目前bar函数的作用域引用中指向了这个foo的活动对象,所以不会销毁。

图解访问过程

关于**全局对象(GO)函数对像(Funcion Object)**的创建过程,可以查看上篇文章 JavaScript-执行机制

  1. 我们先看一下执行到foo()时,引擎内部是怎样的呢?如下图所示

02_闭包-外层函数-执行开始.png

  1. 当调用result()时,此时又是怎样的?如下图所示

02_闭包-内层函数-执行开始.png

闭包的内存泄露

在上面的案例中,如果不再使用bar函数对象,那么该函数对象应该被销毁掉,并且其引用着的父级作用域AO也应该被销毁掉。但是目前因为在全局作用域下的result变量引用着bar函数对象(内存地址 0x00C),而bar函数对象的作用域引用着调用foo函数创建出来的活动对象(内存地址 0x00B)

那么如何解决这个问题呢?利用JavaScript引擎的垃圾回收机制,将全局变量result设置为null,在GC下次检测中,**活动对象(内存地址 0x00B)**从根节点出发,是不可达的,就是会销毁掉。

02_闭包-内层函数-销毁.png


下一篇,梳理JavaScript中的this

  • 如果认为本篇知识点梳理的尚可,欢迎点赞
  • 如果有需要补充、指正的地方,也欢迎评论留言哦~