深入理解js闭包原理

4,200 阅读11分钟

对于前端新人来说,闭包一直是个难点,无论在面试还是开发过程中,经常弄不懂其中的原理。

今天花了大概一天的时候,主要基于《js高程》、MDN和阮一峰老师的一篇博文以及一些网络资料对闭包的内部原理做了深入梳理。

参考资料:

我们先看定义:

闭包是指 有权访问另一个函数作用域中的变量的函数 。——《js高程》

Closures are functions that have access to variables from another function’s scope.

闭包是函数和声明该函数的词法环境的组合。——《MDN》

A closure is the combination of a function and the lexical environment within which that function was declared.

是不是有点懵?...

  • 有权?——怎样算有权,有没有依据
  • 函数作用域?——貌似理解,不就是函数内部的局部作用域,那怎样可以访问到这个作用域?
  • 怎么确定函数作用域?——貌似知道js是静态作用域,什么意思,什么叫静态作用域能理解吗?
  • 词法环境?执行环境?

因此,可以看出来,不理解这些原理去理解闭包都是耍流氓。

ok,要了解哪些概念呢?先告诉大家,需要先深入了解:(变量)作用域词法环境/执行环境变量对象/活动对象作用域链。这些概念之间的关系如下图所示(也是本文的总纲):

理解了这些概念,才能深入理解闭包的概念。以下开始介绍:

变量作用域

我们先从js特殊的变量作用域入手,变量作用域有两种解析方式:

  • 静态作用域:又称为词法作用域。在编译阶段就能确定变量的引用。和程序定义的位置有关,和代码执行的顺序无关。一般采用嵌套作用域的规则解析。
  • 动态作用域:由程序运行时刻决定,和程序代码执行的顺序有关。动态作用域一般用动态栈来管理,代码在执行过程中会将变量依次压栈,在查找变量相应变量时会从栈顶找到最近的变量。

对于js:

  • 使用的是静态作用域。
  • 没有块级作用域(主要有两种:全局作用域和函数作用域)
  • 使用词法环境管理静态作用域

词法环境

在《js红宝书》里Ch4.2里叫做“执行环境”,那“执行环境”是如何管理静态作用域的呢?

我这里理解“执行环境”和“词法环境”是一个意思,不知道正确与否。

执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象。环境中定义的所有变量和函数都保存在这个对象中(虽然代码中无法访问,但在处理数据时解析器会使用到)

具体说来,怎么描述每个环境呢?

  • 环境记录:用来记录环境里面定义的形参、函数声明、变量等
  • 对外部词法环境的引用(outer)

对应于全局执行环境和函数执行环境:

  • 全局执行环境:最外围的一个执行环境。根据ECMAScript实现所在的宿主环境不同,表示执行环境的对象也不一样。在web浏览器中,全局执行环境被认为是window对象,因此所有全局变量和函数都是作为window对象的属性和方法创建的。
  • 函数执行环境:每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中,而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。ECMAScript程序中的执行流正由这个方便的机制控制着。
需要提出一点的是,也是我个人比较容易混淆的点,即使是静态作用域,就是在进入到某个环境前,会将代码进行解析,根据代码编写的位置来确定作用域并提前声明环境中会用到的变量和函数。

所以这个过程其实也是在函数执行的过程的,并不是代码编写的时候就确定了(虽然确定,但是也得程序执行了才会有的呀),虽然想法很傻,但是的确困惑了我很久,哈哈。

某个执行环境中的所有代码执行完毕后,该环节被销毁,保存在其中的所有变量和函数定义也随之销毁,全局执行环境直到应用程序退出——例如关闭网页/浏览器时才会被销毁

变量对象和作用域链

是不是还是觉得有点虚、抽象,执行环境在代码里如何落地呢?——变量对象和作用域链对象。

上面我们提到了描述一个执行环境,有两部分内容:环境记录和对外部词法环境的引用。

变量对象

环境记录用变量对象来描述。在函数执行环境中用“活动对象”来代替,不过不需要理解“变量对象”和“活动对象”本质上有什么区别,其实作用都差不多。

变量对象/活动对象包含着当前执行环境的环境记录,例如变量定义、函数声明、形参等,函数的“变量对象”会包含一个特殊的arguments对象。

全局环境的变量对象始终存在,函数环境的变量对象只有函数执行的过程中存在(一般,闭包下因为保存对其的引用会存在至闭包被解除引用时才会随着闭包环境的销毁而销毁)

作用域链

除了环境记录外,还需要保存对外部词法环境的引用。这里使用一个指向变量对象的指针列表——作用域链来记录。

作用域链本质上是一个列表对象,线性、有次序的保存着变量对象的引用。

作用域链的作用是保证对执行环境有权访问的所有变量和函数的有序访问。

作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其“活动对象”作为变量对象(活动对象最开始包含一个对象,即arguments)。作用域链的下一个变量对象来自包含(外部)环境,层层延续到全局执行环境,全局执行环境的变量对象始终都是作用域链中的最后一个对象。

标识符解析是沿着作用域链一级一级地搜索标识符的过程,始终从作用域链的前端开始,然后逐级往后回溯,直到找到为止,如果找不到,通常会报错。

另外,在js中, 内部环境可以通过作用域链访问所有的外部环境,但外部环境不能访问内部环境中的任何变量和函数。因此,作用域链的线性和有序也体现在此,每个环境都可以向上搜索作用域链以查找变量和函数名,但不能往下。

执行环境、变量对象和作用域链的关系及内部原理

我们理解执行环境和变量对象、作用域链之间的大致关系,但是内部的实现原理是怎样的呢?

首先,变量对象和作用域链我们能理解:作用域链是一个有次序保存相应执行环境的变量对象的引用的列表。

而变量对象和执行环境也是一对一的,一个执行环境对应着一个变量对象(活动对象)。

那作用域链和执行环境呢?

理解这个很重要,因为我们知道执行环境是通过栈来管理的,在某一段代码执行完成后,顶部的环境就被出栈了,那既然外部环境执行完后都出栈了,但为什么闭包里又会提到说引用了该环境的作用域呢?且看下面的原理。

其实,在调用函数时,会为函数创建一个执行环境压入栈顶,同时会创建一个预先包含全局变量对象的作用域链,这个作用域链被保存在内部的 [[Scope]]属性 中。再执行时,会通过 复制函数的[[Scope]]属性中的对象构建起执行环境的作用域链,此后,又有一个活动对象被创建并被推入这个执行环境作用域链的顶端 。无论什么时候在函数中访问一个变量时,都会从作用域链中搜索具有相应名字的变量。

我们来看一段代码来说明这三者之间的关系:

function compare(value1, value2){
    if(value1 < value2){
        return -1;
    }else if(value1 > value2){
        return 1;
    }else{
        return 0;
    }
}
var res = compare(5, 10);

以上代码先定义了fn函数,然后又在全局作用域链中调用了它。当调用compare函数时,会创建一个包含arguments、value1、value2的活动对象。

如下图所示,执行环境、作用域链、变量对象(活动对象)的关系:

闭包

一般来讲,当函数执行完毕后,局部活动对象就会被销毁,内存中仅保存全局作用域(全局执行环境的变量对象)。但是闭包的情况有所不同:

在另一个函数内部定义的函数会将包含函数(即外部函数)的活动对象添加到它的作用域链中。

如下面代码所示:

function createComparisonFunction(propertyName) {
    return function(object1, object2){
        var value1 = object1[propertyName];
        var value2 = object2[propertyName];
        if (value1 < value2){
            return -1;
        } else if (value1 > value2){
            return 1;
        } else {
            return 0;
        }
    };
}
// 创建函数
var compareNames = createComparisonFuncion('name');
// 调用函数
var result = compareNames({name: 'zs'},{name: 'ls'});
// 解除对匿名函数的引用(以便释放内存)
compareNames = null;

在 createComparsionFunction 函数内部定义的匿名函数的作用域链中,实际上将会包含外部函数 createComparsionFunction 的活动对象。在匿名函数从 createComparsionFunction 中被返回后,它的作用域链被初始化为包含 createComparsionFunction 函数的活动对象和全局变量对象。

这样,匿名函数就可以访问在 createComparsionFunction 中定义的所有变量。更为重要的是,createComparsionFunction 函数在执行完毕后,其活动对象也不会被销毁,因为匿名函数的作用域链仍然在引用这个活动对象。换句话说,当 createComparsionFunction 函数返回后,其执行环境的作用域链会被销毁,但它的活动对象仍然会留在内存中;直至匿名函数被销毁后, createComparsionFunction 函数的活动对象才会被销毁。

创建的比较函数被保存在变量compareNames中。而通过将compareNames设置为等于null解除该函数的引用,就等于通知垃圾回收例程将其清除。随着匿名函数的作用域链被销毁(当初引用的包含外部函数的活动对象的引用被解除了),其他作用域(除了全局作用域)也都可以安全的销毁了。

下图展示了调用 compareNames 函数的过程中产生的作用域链之间的关系:

小结

看完上面粗糙的梳理,再来看这两个不同的定义:

闭包是指有权访问另一个函数作用域中的变量的函数。——《js高程》

Closures are functions that have access to variables from another function’s scope.

闭包是函数和声明该函数的词法环境的组合。——《MDN》

A closure is the combination of a function and the lexical environment within which that function was declared.

是不是感觉理解的透彻多了。

对于闭包,不管定义的差别是怎样的,大家理解了其中的内部原理,无论给出怎样的定义,都能理解了。

所以,其实闭包在js代码里随处可见,你理解了吗?

另外,今天在看阮一峰那篇《学习Javascript闭包(Closure)》,下面有个人评论了一句层次很高的话: “类是有行为的数据,闭包是有数据的行为。——迷途小书童” 很帅,有木有?!

(本篇完,本文大部分内容由《js高程第三版》、MDN以及网络资料整理后,经本人理解后梳理而成,还请大家多多指教)