javascript学习-闭包

Posted 赵弘添

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了javascript学习-闭包相关的知识,希望对你有一定的参考价值。

1.什么是闭包

大多数书本中对闭包的定义是:“闭包是指有权访问另一个函数作用域中的变量的函数。”。这个概念过于抽象了,对初学者而言没啥帮助。好在《javascript忍者秘籍》5.1中给了一个例子来进一步的解释了什么是闭包:

复制代码
            var outerValue= \'ninja\';
            
            var later;
            
            function outerFunction() {
                var innerValue = "samurai";
                
                function innerFunction(paramValue) {
                    assert(outerValue == "ninja", "I can see the outerValue.");
                    assert(innerValue == "samurai", "I can see the innerValue.");
                    assert(paramValue == "wakizashi", "I can see the paramValue.");
                    assert(tooLater == "ronin", "Inner can see the tooLater.");
                }
                
                later = innerFunction;
            }
            
            assert(tooLater, "Outer can\'t see the tooLater.");
            
            var tooLater = "ronin";
            
            outerFunction();
            
            later("wakizashi");
复制代码

测试结果是:

看,这个later指向的就是一个闭包,它实际指向了一个外部函数outerFunction中的一个内部函数innerFunction。当outerFunction函数被调用通过全局变量later将innerFunction函数从outerFunction函数这个封闭的监狱里放出来后,innerFunction函数就一下子变得超级厉害了,成为了闭包,一旦调用闭包,它既能看见全局的outerValue,监狱里的innerValue,自己随身携带的paramValue,还能看见以前根本不认识的tooLater。

当然了,我认为这里例子并不完整,为了形式的完整性,我给它加强一下:

复制代码
            asserts();

            test("函数闭包", function() {
                var before_outerFunction = "before_outerFunction";

                function outerFunction(outerParam) {
                    var before_innerFunction = "before_innerFunction";

                    function innerFunction(innerParam) {
                        return {
                            before_outerFunction : before_outerFunction,
                            after_outerFunction : after_outerFunction,
                            before_innerFunction : before_innerFunction,
                            after_innerFunction : after_innerFunction,
                            outerParam : outerParam,
                            innerParam : innerParam,
                            before_callClosure : before_callClosure,
                            after_callClosure : after_callClosure,
                        };
                    }

                    var after_innerFunction = "after_innerFunction";
                    
                    return innerFunction;
                }

                var after_outerFunction = "after_outerFunction";

                var closure = outerFunction("outerParam");

                var before_callClosure = "before_callClosure";

                var ret = closure("innerParam");
                
                assert(ret.before_outerFunction, "before_outerFunction");
                assert(ret.after_outerFunction, "after_outerFunction");
                assert(ret.before_innerFunction, "before_innerFunction");
                assert(ret.after_innerFunction, "after_innerFunction");
                assert(ret.outerParam, "outerParam");
                assert(ret.innerParam, "innerParam");
                assert(ret.before_callClosure, "before_callClosure");
                assert(ret.after_callClosure, "after_callClosure");

                var after_callClosure = "after_callClosure";
            });
复制代码

测试结果是:

结论就是,当闭包被调用的那一刻,它马上就立地成佛了,既能看到眼前看到的,也能看到曾今看到的,前世今生,形形色色,全都历历在目啊。只有尚未发生的after_callClosure,那个实在是看不到。

难怪不止一本书中提到,只有理解了闭包才能真正的理解Javascript,这玩意就是一个反直觉的异类啊。

2.函数作用域链

如果只是认识一下什么是闭包,那上面的一段就够了,但是这远远称不上理解闭包。《Javascript忍者秘籍》在这里及其不负责任的开始大讲特讲怎么使用闭包:

  • 用闭包实现私有变量
  • 在回调函数中使用闭包
  • 在定时器中使用闭包
  • 用闭包实现函数的bind
  • 用闭包实现函数的curry化
  • 用闭包实现函数结果的缓存
  • 用闭包实现函数的包装

这个忍者师傅估计当时是喝多了,简单露了两手后就扔给我们一堆的掌法、步法、剑法、刀法。唯独把最关键的内功心法给忘了。好了,不指望它了,还是自己接着找师傅吧,有钱就是任性,请了一堆的师傅在桌上摆着,所以才这么有底气。于是我看到了《Javascript高级程序设计》这位牛逼的不行的师傅。高手就是高手,一上来就告诉我,要理解闭包,先翻到第四章看看什么是作用域链。立马翻过去看啊,4.2关于执行环境及作用域,只有短短的两页,大体意思是:通过执行环境、作用域链、活动对象,我们实现了变量的一层层查找。得了,我智商不够,继续换老师,于是我又找到了《高性能Javascript》,第二章中的一小段,题目是“作用域链和标识符解析”,同样是短短的两页,字字珠玑,图文并茂,立马有种醍醐灌顶的感觉。

每一个Javascript函数都表示为一个对象,更确切的说,是Function对象的一个实例。

当编译器看到下面的全局函数:

                function add(num1, num2) {
                    var sum = num1 + num2;
                    return sum;
                }

它通过类似下面的代码来创建函数对象:

var add = new Function("num1", "num2", "var sum = num1 + num2;\\nreturn sum;");

Function函数中要自动完成作用域链的构造:

  1. 创建add函数对象的作用域链对象,并把引用保存在add函数对象的[[Scope]]属性中
  2. 把作用域链对象的第一项指向全局作用域对象

听起来是蛮复杂,用下面的图形象化一下:

当add被调用时,例如通过下面的代码:

var total = add(5, 10);

这次轮到引擎来干活了,它要完成下面的工作:

  1. 执行此函数会创建一个称为执行环境(execution context)的内部对象。一个执行环境定义了一个函数执行时的环境。函数每次执行时都对应的执行环境都是临时的,所以多次调用同一个函数就会导致创建多个执行环境。当函数执行完毕,执行环境就被销毁。
  2. 每个执行环境都有自己的作用域链,用于解析标识符。当执行环境被创建时,它的作用域链初始化为当前运行函数的[[Scope]]属性中的对象。这些值按照它们出现在函数中的顺序,被复制到执行环境的作用域链中。
  3. 这个过程一旦完成,一个被称为“活动对象”(activation object)的新对象就为执行环境创建好了。活动对象作为函数运行时的变量对象,包含了此函数的所有局部变量,命名参数,参数集合以及this。
  4. 然后活动对象被推入到执行环境作用域链的最前端。
  5. 函数执行过程中每次解析标识符,就在执行环境的作用域链中从前往后的查找
  6. 函数执行完毕,执行环境被销毁,活动对象也随之被销毁。

再用书上的图形象化一下:

3.闭包与函数作用域链

仍然是《高性能Javascript》,第二章其中的一小段,题目是“闭包、作用域和内存”。给出的示例代码如下:

复制代码
                function saveDocument(id) {                    
                }

                function assignEvents() {
                    var id = "xdi9592";
                    document.getElementById("save-btn").onclick = function(event) {
                        saveDocument(id);
                    }
                }
复制代码

assignEvents()函数给一个DOM元素设置事件处理函数。这个事件处理函数就是一个闭包,它在assignEvents()执行时创建,并且能访问所属作用域的id变量。为了让这个闭包访问id,必须创建一个特定的作用域链。

当assignEvents()函数执行时,一个包含了变量id以及其他数据的活动对象被创建。它成为执行环境作用域链中的第一个对象,而全局对象紧随其后。当闭包被创建时,它的[[Scope]]属性被初始化为这些对象。

由于闭包的[[Scope]]属性包含了与执行环境作用域链相同的的对象的引用,因此会产生副作用。通常来说,函数的活动对象会随着执行环境一同销毁。但引入闭包时,由于引用仍然存在于闭包的[[Scope]]属性中,因此激活对象无法被销毁。

当闭包被执行时,会创建一个执行环境,它的作用域链与属性[[Scope]]中所引用的两个相同的作用域链对象一起被初始化,然后一个活动对象为闭包自身所创建。

 

《Javascript高级程序设计》也有一个类似的例子,示例代码如下:

复制代码
                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 = createComparisonFunction("name");
                //调用函数
                var result = compareNames({ name: "Nicholas" }, { name: "Greg" });
                //解除对匿名函数的引用(以便释放内存)
                compareNames = null;
复制代码

函数compareNames被调用时的执行环境如下图:

这张图其实很容易引起误解,很多人以为这张图是函数compareNames被调用时的一个快照,其实不是,当createComparisonFunction函数执行时,createComparisonFunction函数的执行环境是没错的。但是它返回的那个匿名函数是一个闭包,因此该匿名函数的作用域链会复制createComparisonFunction函数的执行环境的作用域链,然后createComparisonFunction函数结束,createComparisonFunction函数的执行环境被销毁,但是createComparisonFunction函数的活动对象因为被闭包引用了,所以无法销毁。

当compareNames执行时,它依然遵循着普通函数的执行流程:

  1. 创建函数的执行环境;
  2. 创建函数的执行环境的作用域链,并复制函数的作用域链;
  3. 创建函数的活动对象,并加入到函数的执行环境的作用域链的第一条

4.全局对象是什么

这个问题提的似乎很没有水平啊,学习Javascript的初学者哪个不知道全局对象的重要性呢。但是如果换一个角度来看,如果把所有Javascript代码认为是写在一个最外层的超级函数里的。那么当这个超级函数执行时,它应该也会继续函数调用的三板斧:

  1. 创建超级函数的执行环境;
  2. 创建超级函数的执行环境的作用域链,并复制超级函数的作用域链,此时为空;
  3. 创建超级函数的活动对象,并加入到超级函数的执行环境的作用域链的第一条

根据函数作用域链的检索机制和全局对象的用法,我们似乎可以得到一个推论:全局对象其实就是超级函数的活动对象。

再进一步,我们定义的所有全局函数其实都是闭包,因为它们都把超级函数的活动对象,也就是全局函数复制到了自己的函数作用域链中。

再次回到我们开头对闭包的测试用例,如果我们不是直接返回内部函数,而是直接在外部函数里调用内部函数呢?

复制代码
            test("函数闭包", function() {
                var before_outerFunction = "before_outerFunction";

                function outerFunction(outerParam) {
                    var before_innerFunction = "before_innerFunction";

                    function innerFunction(innerParam) {
                        return {
                            before_outerFunction : before_outerFunction,
                            after_outerFunction : after_outerFunction,
                            before_innerFunction : before_innerFunction,
                            after_innerFunction : after_innerFunction,
                            outerParam : outerParam,
                            innerParam : innerParam,
                            before_callClosure : before_callClosure,
                            after_callClosure : after_callClosure,
                        };
                    }

                    var after_innerFunction = "after_innerFunction";

                    var ret = innerFunction("xxx");
                    assert(ret.before_outerFunction, "before_outerFunction");
                    assert(ret.after_outerFunction, "after_outerFunction");
                    assert(ret.before_innerFunction, "before_innerFunction");
                    assert(ret.after_innerFunction, "after_innerFunction");
                    assert(ret.outerParam, "outerParam");
                    assert(ret.innerParam, "innerParam");
                    assert(ret.before_callClosure, "before_callClosure");
                    assert(ret.after_callClosure, "after_callClosure");
                    
                    return innerFunction;
                }

                var after_outerFunction = "after_outerFunction";

                var closure = outerFunction("outerParam");
                //log(closure);

                var before_callClosure = "before_callClosure";

                var ret = closure("innerParam");

                assert(ret.before_outerFunction, "before_outerFunction");
                assert(ret.after_outerFunction, "after_outerFunction");
                assert(ret.before_innerFunction, "before_innerFunction");
                assert(ret.after_innerFunction, "after_innerFunction");
                assert(ret.outerParam, "outerParam");
                assert(ret.innerParam, "innerParam");
                assert(ret.before_callClosure, "before_callClosure");
                assert(ret.after_callClosure, "after_callClosure");

                var after_callClosure = "after_callClosure";
            });
复制代码

测试结果是:

一切和预想的一样,所谓的闭包并不是return是才发生的,而是在内部函数被编译器创建函数对象的那一刻就决定的。为函数的作用域链复制当前函数执行环境的作用域链。用下图形象化一下:

最关键的是这套函数定义、函数调用机制是可以无限嵌套下去的,而且函数定义和函数调用的时机也是分离的。每个函数执行时都有自己的活动对象负责管理自己的作用域,执行环境作用域链的职责不过是把这些嵌套的函数的各自的活动对象串联起来。函数的作用域链的职责不过是相当于一个中间变量,负责保存上一级函数执行环境的作用域链。

所以内部函数直接在外部函数内调用的话也能访问到内部函数的相关变量,不是因为内部函数调用时真的可以看见外部函数,而是因为内部函数的执行环境的作用域链中已经复制了外部函数的执行环境的作用域链。函数的执行环境的作用域链是自完备的,函数调用时只会在自己的函数的执行环境的作用域链中查找,其实它根本就不知道外部函数或者全局函数什么的。

如果内部函数不return出去的话,一切都会随着外部函数调用完成,外部函数的执行环境对象被销毁,导致外部函数的执行环境作用域链的被销毁,导致外部函数活动对象被销毁,与此同时内部函数对象作为局部变量也会被销毁。这一系列的销毁过程将内部函数的作用域链复制了外部函数的执行环境的作用域链的“罪证”被掩盖得天衣无缝。临时的外部函数的活动对象也绝对不会跑到笼子外面去。

但是一旦内部函数被return出去的话,内部函数的作用域链复制了外部函数的执行环境的作用域链的“罪证”被暴露了,本该被销毁的外部函数的活动对象也意外地活了下来,并随时等待着随着内部函数被调用而继续呼风唤雨。其实不仅仅是外部函数的活动对象,外部函数的执行环境的作用域链上的所有活动对象都意外的活了下来,如果我们构造一个二层以上的函数嵌套,不断地进行函数的定义和函数的调用,最后返回一个最内层的函数,你就会发现一组本该死去的活动对象都意外的活了下来。

闭包执行后直接设置为null可以保证那些意外活下来的活动对象被清除吗?按道理应该是这样的,不过很不确定,这取决于引擎的垃圾回收机制怎么玩的,如果按照引用计数的垃圾收集方式,这个推论应该是真的,但是目前大多数引擎采用的却是标记清除的垃圾收集方式,这就很难保证这个推论是真的了。或者更简单的,随着闭包引用变量的自动清除,也能让那些活动对象寿终正寝,也是说得过去的。这或许也正好解释了看到的Javascript代码中很少有对闭包主动设置为null的。

在Javascript的世界里,其设计思想果然还是一如既往的单纯质朴。

如何管理函数?Javascript回答说用函数对象。

如何管理函数的作用域?Javascript回答说用活动对象。

如果函数调用有嵌套呢?Javascript回答说用作用域链,把活动对象串起来。

如果一个外部函数返回了一个内部函数,导致外部函数的活动对象泄露了怎么办?Javascript回答说那就叫做闭包吧。

 5.后记

本文的一切功劳属于那些经典书籍的作者们,我不生产知识,我只是知识的搬运工。本文的一切错误属于我个人,谁让我是初学者呢,有时候搬错了也是难免的,那就在不断地错误不断地改正中不断地成长吧。

以上是关于javascript学习-闭包的主要内容,如果未能解决你的问题,请参考以下文章

Javascript学习笔记:闭包题解

傻瓜学习JavaScript闭包(译)

通过示例学习JavaScript闭包

《JavaScript权威指南》学习——js闭包

Javascript中的闭包

Javascript中的闭包(转载)