[js学习笔记] 闭包

什么是闭包

MDN中对闭包的描述是

闭包是由函数以及创建该函数的词法环境组合而成。
这个环境包含了这个闭包创建时所能访问的所有局部变量。

而es2017文档对词法环境的描述是这样的

A Lexical Environment consists of an Environment Record and a possibly null reference to an outer Lexical Environment.

environment record(环境记录)记录相应环境中的形参,函数声明,变量声明等。外部的词法环境的引用可以为null,比如全局词法环境。

所以,其实我们可以这样理解
闭包 = 代码块 + 创建该代码块的上下文中数据。

如果以此理解闭包的话,其实下面就是一个闭包

1
2
3
4
5
var cache={}
function step(){
console.log(cache);
}
step();

其实它可以看做一个全局环境创建的匿名函数。而《JavaScript权威指南》中也说到:从技术的角度讲,所有的JavaScript函数都是闭包。

但这算是理论意义上的闭包,而我们日常所熟知的是实践意义上的闭包。
汤姆大叔翻译的文章中指出

ECMAScript中,闭包指的是:

  1. 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  2. 从实践角度:以下函数才算是闭包:
    • 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    • 在代码中引用了自由变量

理论上要实现闭包,对于要实现将局部变量在上下文销毁后仍然保存下来,基于栈的实现显然是不适用的(因为与基于栈的结构相矛盾)。因此在这种情况下,上层作用域的闭包数据是通过 动态分配内存的方式来实现的(基于“堆”的实现),配合使用垃圾回收器(garbage collector简称GC)和 引用计数(reference counting)。这种实现方式比基于栈的实现性能要低,然而,任何一种实现总是可以优化的: 可以分析函数是否使用了自由变量,函数式参数或者函数式值,然后根据情况来决定 —— 是将数据存放在堆栈中还是堆中。

同一个父上下文中创建的闭包是共用一个[[Scope]]

而从上文提到EMACSscript所有函数理论上都是闭包。而创建函数的父级上下文的数据是保存在函数的内部属性 [[Scope]]中的,同一个父上下文中创建的闭包是共用一个[[Scope]]属性,就是所有的内部函数都 共享同一个父作用域
所以从这角度可以解释下面这个经典问题了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var test = function() {
var ret = [];
for(var i = 0; i < 5; i++) {
ret[i] = function() {
return i;
}
}
return ret;
};
var test0 = test()[0]();
console.log(test0); // 输出:5
var test1 = test()[1]();
console.log(test1); //输出:5

上面的函数中ret[i]保存的都是函数,并引用外部的词法环境的i值,形成多个闭包。可是所有的ret[i]()都是共享同一个父作用域,也即是 i值都是一样的 。当调用 ret[i]() 的时候, test() 已经执行完毕,i值等于5,所以最后ret数组中函数的返回值都是5,而不是我们所希望的0、1、2、3、4。
要想得到我们想要的结果,我们可以做以下修改:

1
2
3
4
5
6
7
8
9
10
11
var test = function() {
var ret = [];
for(var i = 0; i < 5; i++) {
ret[i] = (function test2(x) {
return x;
})(i)
}
return ret;
};

我们创建了一个test2函数,并赋值给ret[i]。此时test2函数也有自己的[[scope]]。我们为每一个test2函数绑定了一个i,并以形参x的形式传入函数,则每一个test2函数共有同一个[[scope]](即ret[],变量i等)同时,也分别有了自己的变量x,而函数内部返回的是x为非i。
当然我们也可以尝试自己构建Execution Context和Activation Object来分析

应用场景

闭包,其实可以理解为在一定场景中通过额外设置一个独立的函数作用域,并能在其内部可以访问函数外面的变量,无论执行该函数时其外部的作用域是否已经销毁。
其实闭包其实可以适用于很多种情景,下面是一些常用的情景,可能比较局限,以后想到再回来补充~

  1. 封装变量
    情况允许的时候,可以尽量使用闭包把全局变量包裹起来变成局部变量,提高访问数据的效率
  2. 捕获外部变量
    在外部环境被销毁的时候,还能保存引用的外部变量为自己的作用域中,但不使用的时候最好将其设为null,解除引用。
  3. 模拟私有方法
    上面提到外部不能直接访问函数内部定义的函数和变量,而我们也可以通过闭包将其暴露出去。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    var obj=function(){
    var priv=0;
    function cal(i){
    priv=+i;
    console.log(priv);
    }
    return {
    add:function(){
    cal(1);
    },
    min:function(){
    cal(-1);
    },
    val:function(){
    console.log(priv);
    }
    }
    }
    //obj.add() //报错,obj()才是一个函数对象
    obj().add(); //1
    obj().add(); //1
    obj().val(); //0
    let count1=obj(); // 把obj()的返回值赋值给count1变量,obj()的返回值也为对象
    let count2=obj();
    count1.add();//1
    count1.val();//1
    count2.val();//0

方法内变量的生存周期取决于方法实例是否存在活动引用,如没有就销毁活动对象。
前三条执行语句的作用域都是独立的,因为函数中的变量没有存在活动引用。
第三四条语句相当于实例化两个对象,存在活动引用,所以在后三条语句执行后,同对象的共享同一个词法作用域,而不同对象间词法作用域不同。

闭包的性能优化

随意使用过多闭包可能会影响性能,因为闭包在处理速度和内存消耗方面对脚本性能具有负面影响。像上文中闭包的实现方式,每次调用构造函数的时候,方法都需要重新赋值一遍。在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造器中。所以我们其实可以使用原型链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
obj.prototype.add=function(){}
obj.prototyoe.min=function(){}
```
### 闭包与内存泄漏
我们总是听到说闭包会造成内存泄漏,要尽量减少闭包的使用。那是什么导致的内存泄漏呢。
我们知道,有时我们可以用闭包把原是全局变量包裹起来变成局部变量,从而可以继续使用这些变量。但是把变量包裹在闭包中和放在全局作用于中的对内存的影响是相同。所以这种情况不算的是内存泄漏。但当我们需要回收这些内存的时候,把变量设为null即可。
我们通常所说闭包会导致内存泄漏,是使用闭包的时候容易形成循环使用,如果闭包的作用域中保存着一些dom节点,这时候就有可能造成内存泄漏。详情我们可以看下面这段代码
```javascript
function test(){
var element=document.getElementId('div1')
element.onclick=function(){
alert(element.id)
}
}

补充——对相同作用域内创建的闭包共享同一个VO的理解

看这里看这里js学习笔记-闭包与共享作用域

最后

上文是我对闭包的一些知识点的整理和较为浅显的理解,可能有些地方有失偏颇,希望指正~

参考链接

JavaScript深入之闭包
深入理解JavaScript系列(16):闭包(Closures)
JavaScript之作用域与闭包详解
举例详细说明javascript作用域、闭包原理以及性能问题
JavaScript 循环添加事件时闭包的影响有哪些解法?