JavaScript忍者秘籍——闭包

概要:本篇博客主要介绍了JavaScript的闭包

1.闭包的工作原理

  简单地说,闭包就是一个函数在创建时允许该自身函数访问并操作该自身函数之外的变量时所创建的作用域。

例如:

var outerValue = 'ninja';
var later;
function outerFunction(){
    var innerValue = 'samurai';
    function innerFunction(){
        debugger
        console.assert(outerValue,"I can see the ninja.");
        console.assert(innerValue,"I can see the samurai.");
    };
    later = innerFunction;
};
outerFunction();
later();

  第一个断言肯定会通过,因为outerValue是在全局作用域内的。在外部函数中声明innerFunction()的时候,不仅是声明了函数,还创建了一个闭包,该闭包不仅包含函数声明,还包含了函数声明的那一时刻点上该作用域中的所有变量。最终,当innerFunction()执行的时候,当时声明的作用域已经消失了,通过闭包,该函数还是能够访问到原始作用域的。

再看一个例子:

var outerValue = 'ninja';
var later;
function outerFunction(){
    var innerValue = 'samurai';
    function innerFunction(paramValue){
        console.assert(outerValue,"Inner can see the ninja.");
        console.assert(innerValue,"Inner can see the samurai.");
        console.assert(paramValue,"Inner can see the wakizashi.");
        console.assert(tooLate,"Inner can see the ronin.");
    };
    later = innerFunction;
};
console.assert(!tooLate,"outer can't see the ronin");
var tooLate = 'ronin';

outerFunction();
later('wakizashi');

测试结果说明了三个关于闭包的更有趣的概念:

  ● 内部函数的参数是包含在闭包中的。

  ● 作用域之外的所有变量,即便是函数声明之后的那些声明,也都包含在闭包中。

  ● 相同的作用域内,尚未声明的变量不能进行提前引用。

  注意:每个通过闭包进行信息访问的函数都有一个"锁链",如果我们愿意,可以在它上面附加任何信息。使用闭包时,闭包里的信息会一直保存在内存中,直到这些信息确保不再被使用。

2.使用闭包

- 私有变量

  闭包的一种常见用法是封装一些信息作为"私有变量"。

function Ninja(){
    var feints = 0;
    this.getFeints = function(){
        return feints;
    };
    this.feint = function(){
        feints++;
    };
};
var ninja = new Ninja();
ninja.feint();
 
console.assert(ninja.getFeints()==1,
    "We're able to access the internal feint count.");
console.assert(ninja.feints===undefined,
    "And the private data is inaccessible to us.");

  在上述代码中,我们创建了一个函数构造器,在函数上使用new关键字时,就会创建一个新对象实例,该函数会被调用,并将新对象作为它的上下文,函数会作为该对象的构造器。所以函数内的this就是新实例化的对象。在构造器内,我们定义了一个变量feints用于保存状态。JavaScript的作用域规则显示了它的可访问性只能在构造器内部。要让外部的代码可以访问到该内部变量,我们定义了一个存取方法getFeints(),该方法只能对内部变量进行读取,但不能写入。

  接下来,创建实现方法feint(),以便在一个受控制的方法内控制变量的值。

  建立了构造器之后,使用new操作符进行调用,然后再调用feint()方法。

- 回调与计时器

  另一个使用闭包最常见的情形就是在处理回调或使用计时器的时候。在这两种情况下,函数都是在后期未指定的时间进行异步调用,在这种函数内部,我们经常需要访问外部数据。闭包可以作为一种访问这些数据的很直观的方式,特别是在避免创建全局变量来存储信息时。例如:

<div id = 'testSubject'></div>
<button type = 'button' id = 'testButton'>Go!</button>
<script>
jQuery('#testButton').click(function(){
    var elem$ = jQuery('#testSubject');
    elem$.html("Loading...");
    jQuery.ajax({
        url:'test.html',
        success: function(html){
            console.assert(elem$,"We can see elem$,via the closure for this callback.");
        elem$.html(html);
        }
    })
})    
</script>

在计时器间隔回调中使用闭包:

<div ></div>
<script>
function animateIt(elementId){
    var elem = document.getElementById(elementId);
    var tick = 0;
    var timer = setInterval(function(){
        if(tick < 100){
            elem.style.left = elem.style.top = tick + 'px';
            tick++;
        }else {
            clearInterval(timer);
            console.assert(tick == 100,
                "Tick accessed via a closure.");
            console.assert(elem,
                "Element also accessed via a closure.");
            console.assert(timer,
                "Timer reference also obtained via a closure.");
        }        
    },10);
};
animateIt('box'); </script>

  以上代码的重要作用是:使用一个独立的匿名函数完成特定元素的动画效果。通过闭包,该函数使用三个变量控制动画的过程。

3.绑定函数上下文

  给函数绑定一个特定的上下文:

<button >Click Me!</button>
<script>
var button = {
    clicked: false ,
    click: function(){
        this.clicked = true ;
        console.assert(button.clicked , "The button has been clicked" ) ;
    };
};
var elem = document.getElementById("test");
elem.addEventListener('click',button.click,false);
</script>

  在本例中有一个button按钮,我们想知道它是否曾经被单击过。为了保持状态信息,我们创建一个名为button的支持对象用于保存按钮的单击状态。在该对象中我们还定义一个方法,该方法将作为一个事件处理程序,在按钮被单击的时候进行触发。该方法将会作为按钮的click事件处理程序,设置clicked属性为true,然后测试按钮的状态是否正确记录在支持对象上。但是上述代码是错误的,因为浏览器的事件处理系统认为函数调用的上下文是事件的目标元素,所以我们将click状态设置在错误的对象上了。修改如下:

function bind(context,name){
    return function(){
        return context[name].apply(context,arguments);
    };
};
var button = {
    clicked: false,
    click: function(){
        this.clicked = true;
        console.assert(button.clicked,"The button has been clicked.");
        console.log(this);
    };
};
var elem = document.getElementById("test");
elem.addEventListener("click",bind(button,"click"),false);

  使用了添加的bind()方法,该方法用于创建并返回一个匿名函数,该函数使用apply()调用了原始函数,以便我们可以强制将上下文设置成我们想要的任何对象。本例中,传递给bind()的第一个参数就是要设置的上下文对象。

4.函数重载

- 使用闭包实现缓存记忆

Function.prototype.memoized = function(key){
    this._values = this._values || {};
    return this._values[key] !== undefined ? this._values[key] : this._values[key] = this.apply(this, arguments); 
};
Function.prototype.memoize = function(){
    var fn = this;
    return function(){
        return fn.memoized.apply(fn, arguments);
    };
};
var isPrime = (function(num){
    var prime = num != 1;
    for(var i = 2; i < num; i++){
        if(num % i == 0){
            prime = false;
            break;
        };
    };
    return prime;
}).memoize();
console.assert(isPrime(17),"17 is prime");

  本例中,首先创建了memoized()方法,然后又添加另外一个新方法memoized()。该方法返回了一个包装了原始函数并且调用了memoized()方法的新函数,这样,它返回的始终是原始函数的缓存记忆版本。这使调用者无需再调用memoized()。

- 函数包装

  函数包装是一种封装函数逻辑的技巧,用于在单个步骤内重载创建新函数或继承函数。最有价值的场景是,在重载一些已经存在的函数时,同时保持原始函数在被包装后仍然能够有效使用。

例如:

function wrap(object,method,wrapper){
    var fn = object[method];

    return object[method] = function(){
        return wrapper.apply(this,[fn.bind(this)].concat(
            Array.prototype.slice.call(arguments)));
    };
}

if (Prototype.Browser.Opera) {
    wrap(Element.Methods,"readAttribute",function(original,elem,attr){
        return attr == "title" ? elem.title : original(elem,attr);
    });
}

  以上代码首先将原有方法保存在变量fn中,稍后我们会在后面会通过匿名函数的闭包来访问它。然后,我们使用一个新的匿名函数来重载该方法。新函数执行了之前传进来的包装器函数wrapper,并传递一个重新构造过的参数列表。在构建这个参数列表时,我们希望第一个参数是我们要重载的原有函数,所以创建了一个数组,其中包含原始函数的引用并将原始参数也追加到该数组中。

5.即时函数

(function(){......})();

  这个简单的构造函数被称之为即时函数,它创建了一个函数实例,第二个圆括号对表示执行该函数,在语句结束之后立即销毁该函数没有任何引用。

- 临时作用域和私有变量

  利用即时函数我们可以创建一个临时的作用域用于存储数据状态。例如:

  创建一个独立的作用域

document.addEventListener("click", ( function(){
    var numClicks = 0;
    return function(){
        alert( ++numClicks );
    };
})(), false);

  创建一个即时函数,并返回了一个值作为事件处理程序的一个函数。该即时函数的返回值被传递到了addEventListener()方法。但是,我们创建的内部函数依然可以通过闭包获取numClicks变量的值。

  通过参数限制作用域内的名称

  我们也可以像其他普通函数一样,在即时调用的时候向即时函数传递参数,通过形参名称来引用这些参数。示例如下:

(function(what){alert(what);})("Hi there!");

  另一个使用即时函数的更加实际的例子,在页面上同时使用Prototype库和jQuery库时,使用$引用Prototype的时候必须使用jQuery变量来引用jQuery。通过即时函数,我们可以将$重新分配回jQuery。

<img src="../images/ninja-with-pole.png">
<script>
    $ = function(){ alert('not jQuery!'); };
    (function($){
        $('img').on('click',function(event){
            $(event.target).addClass('clickedOn');
        })
    })(jQuery);
</script>

  首先将$定义为其他内容,而不是jQuery,以便其他代码占用$。但是,由于想在代码片段中使用$来引用jQuery,所以定义一个接收单个参数的即时函数。在函数体中,参数$将优先于全局变量$。通过向即使函数传入jQuery参数,函数内部的$就变成了jQuery。

  这种技术常用于在开发插件的时候,由于假设将$引用jQuery是不安全的,所以这些开发人员在开发插件的时候,都将插件代码放在即时函数内部,从而使用$安全引用jQuery。

  使用简洁名称让代码保持可读性

  在代码中我们会频繁的引用一个对象,如果引用长且复杂会让代码变得难以阅读,一个便捷的方法就是把这个引用赋值给一个短小的变量名,像下面这样:

var short = Some.long.reference.to.something;

  但是,在我们使用简洁名称short来代替Some.long.reference.to.something的时候,我们在当前作用域内却引用了一个不必要的新名称。因此可以使用即时函数将短名称引用到一个有限的作用域内。如:

(function(v){
    Object.extend(v,{
        href: v._getAttr,
        src: v._getAttr,
        type: v._getAttr,
        action: v._getAttrNode,
        disabled: v._flag,
        checked: v._flag,
        readonly: v._flag,
        multiple: v._flag,
        onload: v._getEv,
        onunload: v._getEv,
        onclick: v._getEv,
        ...
    });
})(Element.attributeTranslations.read.values);

  在代码中,将Element.attributeTranslations.read.values作为即时函数的第一个参数传递进去,也就意味着参数v就是这个长名称数据结构的引用,并存在于即时函数的作用域内。这种在作用域内创建临时变量的技巧,对没有延迟调用的循环遍历来说尤其有用。

- 循环

  即时函数另一个有用的地方就是可以利用循环和闭包解决一些棘手的问题。如:

<div>DIV 0</div>
<div>DIV 1</div>
<script>
    var divs = document.getElementsByTagName("div");
    for (var i = 0; i < divs.length; i++) {
        divs[i].addEventListener("click",function(){
            alert("divs #" +i+ "was clicked.");
        },false);
    }
</script>

  我们的目的是单击每个<div>元素。以上代码中遇到了一个使用闭包和循环时常见的问题,也就是说函数绑定之后,闭包抓取的变量被更新了。这意味着,每一个绑定的函数处理程序都会一直显示i最后的值。处理方法如下:

<div>DIV 0</div>
<div>DIV 1</div>
<script>
    var divs = document.getElementsByTagName("div");
    for (var i = 0; i < divs.length; i++) (function(n){
        divs[n].addEventListener("click",function(){
            alert("div #" +n+ "was clicked.");
        },false);
    })(i);
</script>

  通过在for循环内加入即时函数,我们可以将正确的值传递给即时函数,进而让处理程序也得到正确的值。这意味着,在for循环每次迭代的作用域中,i变量都会重新定义,从而给click处理程序的闭包传入我们期望的值。