JavaScript专题[7]-闭包原理详解

131 阅读16分钟

前言

闭包是 JavaScript 的核心技术之一,在面试以及实际应用当中,我们都离不开它们,甚至可以说它们是衡量js工程师实力的一个重要指标。下面我们就罗列闭包的几个常见问题,从回答问题的角度来理解和定义你们心中的闭包。

问题如下:

  1. 什么是闭包?
  2. 闭包的原理可不可以说一下?

初探闭包

讲到闭包,我回想起一位大佬(怀泽)跟我说过的话:闭包就是「盗梦空间」

《盗梦空间》是美国的一部著名电影,讲述了一帮“盗梦者”进入别人的梦境,窃取商业机密的故事。其中梦是有很多层的,每进入一层梦境,都会记住上一层梦境中发生的事情。这层梦境中的事情做完了死掉后,就可以回到上一层梦境中,直至回到现实。

闭包与之相似,梦境中的现实就是代码执行的全局环境,每一层梦境就是函数作用域,一层套一层的梦境就是函数嵌套函数,每一层梦境都可以访问上一层中的记忆,就是可访问变量,第一层的人可以进入第二层人的梦境,就是外层函数访问内层函数。整个盗梦的过程就形成了一个闭包

以下面的代码为例,这个梦境这样的:

let x = 1
function dream1 () {
	let y = 2
  return function dream2 () {
    let z = 3
    return function dream3 () {
      return x + y + z
    }
	}
}
dream1()()() // 6

然而闭包真的就这么简单吗?NO!

其实对闭包的理解,我们还是要通过代码和一些专业术语来解释,毕竟专业的理论知识解释逻辑,才是正途!

我们来看一下闭包在书中的解释:

《你不知道的JavaScript》:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行

《JavaScript高级程序设计》:闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

《JavaScript权威指南》:函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域中,这种特性在计算机科学文献中被称为‘闭包’。从技术的角度讲,所有的JavaScript函数都是闭包:它们都是对象,它们都关联到作用域链。

可见,它们各有各自的定义,但要说明的意思大同小异。在这之前我对它是知其然而不知其所以然,最后用了两天的时间从词法作用域到作用域链的概念再到闭包的形成做了一次总的梳理,发现做人好清晰了。

概念梳理

不知道大家还记不记得我之前写过的关于 作用域执行上下文和this 的文章。

执行上下文和this 一文中,我们知道代码的执行过程分为预编译阶段和执行阶段,预编译阶段由编译器将 JS 编译成可执行的代码,同时会进行声明提升;执行上下文是在执行阶段创建的。

那么编译器是如果将js编译成可执行的代码的呢?

编译阶段

var a = 1为例,这里简单介绍一下代码的编译过程:

  1. 分词/词法分析:编译器把字符串分解成有意义的代码块var, a, =, 2,这些代码块被称为词法单元。
  2. 语法分析:这个过程会把词法单元流转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为是 抽象语法树(Abstract Syntax Tree)
  3. 代码生成:编译器会询问当前作用域是否已经存在变量a,没有创建一个新的,然后将1赋值给它,最后某种方法将AST转换成引擎识别的代码。

运行阶段

编译过后会进入代码的运行阶段,运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。在引擎查找的过程中会进行一次叫做 LHS和RHS的查找。LHS和RHS的含义是“赋值操作的左侧和右侧”。换句话说,当变量出现在变量的左侧时进行LHS查找,出现在右侧的时候进行RHS查找。其中RHS查找就是简单的查找某个变量的值,而LHS查找则是试图找到变量的容器本身,从而对其赋值。可以理解为:赋值操作的目标是谁(LHS),以及谁是赋值操作的源头(RHS)

词法作用域

编译的第一阶段分词/词法分析也叫词法化,就是对编写的代码进行检查。其中分词是无状态的,词法分析是有状态的。如果是有状态的解析还会赋予单词语义。像这种在我们编写代码时,书写变量和作用域的位置决定的作用域叫做词法作用域,当词法分析器处理代码时候会保持作用域不变(大部分情况下)。

function foo(){
    console.log(a)
}

function bar(){
  var a = 3
	foo()
}
var a = 1
bar() // 1

JavaScript的作用域大部门是词法作用域(除了this),在bar函数中调用foo函数之后会去执行foo,而在foo函数中要出输出a,这个时候就要执行上面所说的,去执行一个RHS查找,因为在foo作用域中没有找到a,所以就去上级作用域去查找,于是就找到了a的值是1。

我们知道,代码运行是要有执行环境的,那么什么是执行环境呢?

执行环境

在web浏览器中,浏览器全局环境即window是最外层的执行环境,而每个函数也都有自己的执行环境,当调用一个函数的时候,函数会被推入到一个环境栈(ECStack)中,当他以及依赖成员都执行完毕之后,栈就将其环境弹出。位于环境栈中最外层是 window , 它只有在关闭浏览器时才会从栈中销毁。

环境栈也有人称做它为函数调用栈,位于环境栈中最外层是 window , 它只有在关闭浏览器时才会从栈中销毁。而每个函数都有自己的执行环境。

那到底什么是环境栈呢?这样从内存管理说起了。

内存管理

内存管理是计算机科学中的概念,是指对内存生命周期的管理。内存是由可读写单元组成,表示一片可操作空间。管理指的是人为的去操作一片空间的申请、使用和释放。内存的管理流程就是开发者主动申请空间、使用空间、释放空间的过程。示例如下:

var foo = 'bar' // 分配内存
console.log(foo) // 读写内存
foo = null // 释放内存

内存空间分为栈空间和堆空间。

  • 栈空间:由操作系统自动分配释放,存放函数的参数值、局部变量的值等,其操作方式类似于数据结构的栈。
  • 堆空间:一般由开发者分配释放,关于这部分空间要考虑垃圾回收的问题。

关于堆栈空间的更多概念,可以查看 值传递与引用传递 一文的详细介绍。

上文所说的环境栈就内存空间中的栈空间,也就是是JS引擎的一个储存栈

变量对象与活动对象

每个执行环境都有一个表示变量的对象,叫变量对象,这个对象里储存着在当前环境中所有的变量和函数。变量对象对于执行环境而言很重要,它在函数执行之前被创建。在没有执行当前环境之前,变量对象中的属性都不能访问。

进入执行阶段之后,变量对象转变为了活动对象,里面的属性都能被访问了,然后开始进行执行阶段的操作。所以活动对象实际就是变量对象在真正执行时的另一种形式。活动对象在最开始时,只包含一个变量,即argumens对象。

 function fun (a){
    var n = 12
    function toStr(a){
        return String(a)
    }
 }

以上代码中,在 fun 函数的环境中,压入环境栈之前,有三个变量对象,首先是 arguments,变量n 与 函数 toStr 。执行阶段,压入环境栈之后,他们都属于fun的活动对象。

作用域链

当代码在一个环境中执行时,会创建变量对象的一个作用域链。用数据格式表达作用域链的结构如下:

作用于链的前端,始终都是当前执行的代码所在环境的变量对象。全局执行环境的变量对象也始终都是链的最后一个对象。

[
	{ 0: 当前环境的变量对象 },
	{ 1: 外层变量对象 },
	{ 2: 全局变量对象 }
]

再来看下面这个简单的例子,我们可以先思考一下,每个执行环境下的变量对象都是什么? 这两个函数它们的变量对象分别都是什么?

function foo () {
  var a = 12
  fun(a)
  function fun (a) {
    var b = 8
    console.log(a + b)
  }
}  
    
foo() // 20

我们以fun为例,当我们调用它时,会创建一个包含 arguments,a,b的活动对象,对于函数而言,在执行的最开始阶段它的活动对象里只包含一个变量,即arguments (当执行流进入,再创建其他的活动对象)。

在活动对象中,它依然表示当前参数集合。对于函数的活动对象,我们可以想象成两部分,一个是固定的arguments对象,另一部分是函数中的局部变量。而在此例中,a和b都被算入是局部变量中,即便a已经包含在了arguments中,但他还是属于活动对象。

有没有发现在环境栈中,所有的执行环境都可以组成相对应的作用域链。我们可以在环境栈中非常直观的拼接成一个相对作用域链。

下面我们大致说下这段代码的执行流程:

  1. 在创建foo的时候,作用域链已经预先包含了一个全局变量对象,并保存在内部属性[[ Scope ]]当中,此时作用域链中只有全局变量对象。
  2. 执行foo函数,创建执行环境与活动对象后{ arguments,a,fun,[[scope]] },取出函数的内部属性 [[scope]] 构建当前环境的作用域链(追加一个它自己的活动对象)。
  3. 执行过程中遇到了fun,从而继续对fun使用上一步的操作。
  4. fun执行结束,移出环境栈。foo因此也执行完毕,继续移出。
  5. 引擎监听到 foo 没有被任何变量所引用,开始实施垃圾回收,清空占用内存。

作用域链其实就是引用了当前执行环境的变量对象的指针列表,它只是引用,但不是包含。因为它的形状像链条,它的执行过程也非常符合,所以我们都称之为作用域链

讲到这里,我们不得不将垃圾回收搬出来说说了。

垃圾回收

垃圾回收指的就是 JS 引擎识别垃圾之后,将垃圾占据的空间进行回收。什么是垃圾?垃圾指的是可以通过引用、或者当前上下文的作用域链等方式能回查找到的的对象,即可达对象

JS的内存管理是自动执行的,创建、回收对象这些工作都是不用我们去操作的,垃圾回收的标准就是这对象是否可达,或者说是否是一个可达对象。

所以,JS 引擎识别可达对象之后,将垃圾占据的空间进行回收,就是垃圾回收

JavaScript拥有自动的垃圾回收机制,关于垃圾回收机制,有一个重要的行为,那就是,当一个值,在内存中失去引用时,垃圾回收机制会根据特殊的算法找到它,并将其回收,释放内存。

举个栗子:

function objGroup(obj1, obj2) {
  obj1.next = obj2
  obj2.prev = obj1

  return {
    o1: obj1,
    o2: obj2
  }
}

let obj = objGroup({ name: 'obj1' }, { name: 'obj2' })
console.log(obj)

上面的代码中,obj.o1,obj.o1.next,obj.o2.prev都可以在全局作用域中被访问,所以都是可达对象,而如果删除了obj.o1和obj.o2.prev,那么obj1的对象空间就找不到了,就会变成垃圾,js引擎就会进行垃圾回收。

再探闭包

终于梳理完了,现在我们将上面的概念串起来解释闭包。

一段代码丢给了浏览器,编译阶段编译器先进行词法分析,创建词法作用域变量对象 Variable Object(简称VO),同时会进行变量/函数提升。然后将代码转换成词法单元,生成抽象语法树(AST),最后解析AST后生成浏览器识别的代码,然后这段代码就丢给了执行环境执行。

执行阶段,解释执行全局代码、调用函数等都会创建并进入一个新的执行环境,而这个执行环境被称之为执行上下文。执行上下文包含了变量对象,作用域链及this指向等属性。其中变量对象转换成了活动对象Active Object(简称AO),然后当前执行环境的变量对象和所有外层已经完成激活的活动对象组成了作用域链,作用域链的查找规则遵循LHS规则,这个规则规定了如果未在当前作用域中找到变量,则继续向上查找直到全局作用域。执行上下文会被压入执行环境栈,即入栈。函数执行之后,上下文即被销毁,即出栈,销毁的过程中就会触发垃圾回收

因为作用域链有由内而外的,所以全局作用域是无法访问函数内部作用域变量的。但是,如果外部函数中返回了一个函数,这个函数提供一个了执行上下文,在这个上下文中引用其它上下文的变量对象时,垃圾回收监听这个引用(可达对象)还在,就不会进项回收。当你在当前执行环境中访问它时,它还是在内存当中的。

这就是闭包的基本原理。

下面分析一个计数器的例子:

总结上面的概念:

function add() {
    var count = 0
    function demo() {
        count ++
        console.log(count)
    }
    return demo;
}
var counter = add()
counter()
counter()

以上代码的执行过程如下:

  1. 进入全局代码,创建全局执行上下文,全局执行上下文压入执环境栈

  2. 全局上下文初始化(函数变量声明,函数提升,变量提升等)

  3. 执行add函数,创建 add 执行上下文,add 执行上下文压入执行环境栈

  4. add 执行上下文初始化,创建变量对象,作用域链,绑定this

  5. add 函数执行完毕,add 执行上下文从执行栈中弹出

  6. 执行函数demo,创建demo执行上下文,demo 执行上下文压入执行环境栈

  7. demo进行上下文初始化

  8. demo函数执行完毕,demo函数从执行栈中弹出

我们借助chrome的调试工具亲眼见证闭包的产生:

V8引擎执行流程:

第一步:创建全局执行上下文,并将其压入ECStack中

//第一步:创建全局执行上下文,并将其压入ECStack中
ECStack = [
  // 全局执行上下文
  EC(G) = {
   // 全局变量对象
   VO(G) {
    ... // 包含全局对象原有的属性
    add = function add(){...}
    add[[scope]] = VO(G)  // 词法作用域:创建函数的时候就确定了
   }
 }
]

第二步:执行函数 add()

ECStack = [
 // add 执行上下文
  EC(add) = {
  // 链表初始化为:AO(add)->VO(G)
  [scope]: VO(G)
  scopeChain:<AO(add), add[[scope]]> // 作用域链
  // 创建函数add的活动对象
  AO(add):{
   arguments:[],
   count:0,
   demo: function demo(){...},
   demo[[scope]] = AO(add),
   this: window
  }
 }
 // 全局执行上下文
 EC(G) = {
   // 全局变量对象
   VO(G) {
    ... // 包含全局对象原有的属性
    add = function add(){...}
    add[[scope]] = VO(G)  // 词法作用域:创建函数的时候就确定了
   }
 }
]

总结

1.什么是闭包?

闭包是由函数以及创建该函数的词法环境组合而成,这个环境包括了这个闭包创建时所能访问的所有局部变量。当一个函数能够记住并访问到其所在的词法作用域及作用域链,特别强调是在其定义的作用域外进行的访问,此时该函数和其上层执行上下文共同构成闭包。

2. 闭包的原理可不可以说一下?

结合我们上面讲过的,它的根源起始于词法阶段,在这个阶段中形成了词法作用域。最终根据调用环境产生的环境栈来形成了一个由变量对象组成的作用域链,当一个环境没有被js正常垃圾回收时,我们依然可以通过引用来访问它原始的作用域链。

面试题

循环闭包

利用闭包,修改下面的代码,让循环输出的结果依次为1, 2, 3, 4, 5

for (var i=1; i<=5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, 1000 )
}

答案:

// 闭包
for (var i = 1; i <= 5; i++) {
  log(i) // 1 2 3 4 5
}
function log(i) {
  setTimeout(function timer() {
    console.log(i)
  }, 1000)
}
// 立即执行函数
for (var i = 1; i <= 5; i++) {
  (function (i) {
    setTimeout(function timer() {
      console.log(i)
    }, 1000)
  })(i)
}
// 块级作用域
for (let i=1; i<=5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, 1000 )
}

参考文献

深入贯彻闭包思想,全面理解JS闭包形成过程

彻底搞懂JS中的作用域和作用域链(闭包的形成)

一文理清由闭包引发内存泄漏和垃圾回收机制

javascript之闭包五(闭包的定义)

作用域和闭包

最后

读一篇文章或者看几页书,也不过是几分钟的事情。但是要理解的话需要个人消化的过程,从输入 到 理解 到消化 再到输出,这是一个非常合理的知识体系。我想不仅仅对于闭包,它对任何知识来说都是一样的重要,当某些知识融入到我们身体时,需要把他输出出去,告诉别人。这不仅仅是“奉献”精神,也是自我提高的过程。

文中如有错误,欢迎在评论区指正,如果这篇文章帮助到了你,欢迎点赞和关注。

想阅读更多优质文章、可我的微信公众号【阳姐讲前端】,每天推送高质量文章,我们一起交流成长。