【转】javascript 闭包

转载自:https://segmentfault.com/a/1190000007814785

前言

闭包这个概念几乎成了JavaScript面试者必问的话题之一,可以毫不客气地说对闭包的理解和运用体现了一名js工程师的功底。那么闭包到底是什么,它又能带来什么特别的作用?网上有很多文章和资料都讲述了这个东西,但是大多解释得比较含糊,涉及闭包底层过程却一笔带过,对于初学者的理解十分不友好。在这里,我想讲述清楚闭包的来龙去脉,加深大家对此理解,如有讲述不合理的地方,欢迎指出并交流。

例子

假设我们有这样一个需求,判断某个对象是否为指定类型,比如判断是否为函数:

function isFunction(obj){
    return (typeof obj === 'function');
}

如果业务功能只需要这一种类型判断,这么写当然没有问题,但是如果业务逻辑还需要有是否为字符串类型、是否为数组类型等判断时该怎么办?使用switch来对传参进行判断?

function isType(obj,type) {
    switch (type) {
        case 'string': 
            return (typeof obj === 'string')
        case 'array':
            return (typeof obj === 'array')
         case 'function':
            return (typeof obj === 'function')
        default:
            break;
    }
}

这样写似乎也还不错,但是如果用闭包特性来写,整体的代码就会优雅很多:

function isType(type){
    return function(obj){
        return Object.prototype.toString.call(obj) == '[object '+ type + ']'
    }
}

//定义一个判断是否为函数类型的函数
var isFunction = isType('Function'); 
var isString = isType('String');

//测试
var name = 'Tom';    
isString(name)//true

先把Object.prototype.toString与typeof的问题放一边,这种书写方式是否比上一个switch的方式更为清楚且易扩展?(观众老爷:清楚个毛啊,明明更复杂了好吧!)稍安勿躁,下面我就解释:
1、Object.prototype.toString与typeof都可以对变量进行类型判断,不同之处在于后者对引用类型的变量判断都会返回'object',因此很难确定返回的值是不是函数。 而前者更为严谨,在任何值上调用Object.toStrng()会返回一个[object NativeConstructorName]格式的字符串。
2、再来说说这里的闭包特性,isType函数的作用是返回一个用于定制类型判断的匿名函数。当我们调用isType('String')时,得到的是一个这样的函数:

var isString = isType('String');
//等价于
var isString = function (obj) {
    return Object.prototype.toString.call(obj) == '[object String]';
}

这种模式是不是有点似曾相识?是否有点像工厂模式?确实挺像的,只不过工厂模式是用来定制对象的,而这个是用来定制函数的。事实上这是一个闭包在js里的经典技巧,它有一个很装逼的名字函数柯里化。

为什么会这样?

之所以能实现这种效果,是因为闭包的特性使得返回的匿名函数的作用域链一直保存着对type变量的引用。
什么意思呢,这里我想从另一个方面来解释,假设js不存在闭包这个特性,那上面的代码执行效果又会变成什么样?
按照一般的理解来说,在调用并执行完isType('String')方法后,isType函数内部变量都应该被回收清除,变量type会被清空;也就是说当我再调用isString(obj)时,它得到的应该是一个type变量为undefined的函数:

var isString = function (obj) {
    return Object.prototype.toString.call(obj) == '[object undefined]';
}

undefined?什么鬼?为什么不是返回指定type='String'的函数?事实上,return function(){} 形式返回的并不是一个函数,而是一个函数的引用。什么是引用,简单来说就是一个指向这个函数在内存中的地址。也就是说这个返回来的匿名函数并没有“定型”成真正的

var isString = function (obj) {
    return Object.prototype.toString.call(obj) == '[object String]';
}

它实际上还是这个函数:

var isString = function (obj) {
    return Object.prototype.toString.call(obj) == '[object '+ type +']';
}

既然如此,为什么我们可以成功的得到我们想要的函数?就是由于闭包特性导致isType()在执行完后,垃圾回收器并没有清空内部变量type。没有清空的原因是,内部函数(返回的匿名函数)的作用域链仍然保有对 外部函数(isType)的变量type的引用。JavaScript的垃圾回收器对于这种 保有引用的变量是不会清除的。关于什么是作用域链以及作用域链和垃圾回收之间的具体关系,才是真正涉及闭包来龙去脉的真正原因,但是我要放到下一段讲。这里我要再举一个例子,以验证我前面所说的。

再来一个例子

  function f1(){
    var n=999;
    nAdd=function(){n+=1}
    function f2(){
      alert(n);
    }
    return f2;
  }
  var result=f1();
  result(); // 999
  nAdd();
  result(); // 1000

这里盗用阮大侠的例子,相信很多朋友都会看过他这篇关于对闭包概念解释的文章。这里算是做一个补充吧。
nAdd=function(){n+=1},js语法的书籍都讲过,不以var 声明的变量都会被默认创建并提至全局变量中。虽然不推荐这种做法,容易造成全局变量污染和难以调试等问题,但是写个小代码测试就没什么问题了。
f2被返回,并将f2的引用赋值给了result。由于f2函数的作用域链保有对n的引用,所以在执行完f1()之后,n并没有被回收清除。 这时再调用nAdd(),因为nAdd函数的作用域链也对n保有引用,所以在执行n+1的操作后,所有引用这个n的地方都会+1。

作用域链和执行环境

对于非计算机科班出身的朋友看到这两个名词,心中会不会有一丝不安?其实他们并不难懂。当某个函数第一次被调用时,会创建一个执行环境及相应的作用域链,并把作用域链赋值给一个特殊的内部属性,scope。然后使用this.arguments和其他命名参数的值来初始化函数的活动对象。在作用域链中,外部函数的活动对象始终处于第二位,外部函数的外部函数的活动对象处于第三位,......直至作为作用域链终点的全局执行环境。

function isType(type){
    return function(obj){
        return Object.prototype.toString.call(obj) == '[object '+ type + ']'
    }
}

var isString = isType('String');

var name = 'Tom';    
isString(name)//true

当我第一次调用isString(name)时,执行环境会去创建一个包含this、arguments和obj的活动对象。而外部函数的变量对象(this和type)在isString()执行环境的作用域链中则处于第二位。全局的变量对象window则在isString()的作用域链中排第三位。作用域链上的变量对象的排列顺序也就决定了执行时变量查找的顺序。这也解释了,为什么当外部有多个相同变量名的变量时,解析器会取离它最近的那一个外部变量。

这里也说明了一个常用的开发技巧————缓存。在函数内部,缓存一个变量可以减少执行器查找变量的次数,提升执行性能,因为它总是位于这个执行环境的作用域链上的第一位活动对象中。

当调用isType('String')之后,内部函数执行环境的作用域链就有了包含type变量的活动对象,垃圾回收的机制之一就是 判断一个对象是否存在被引用,如果是则不清除。而此时内部函数被isString变量引用,所以在执行完isString(name)后,内部变量type依然存在。


闭包的滥用会导致一些副作用,比如内存溢出、调试困难等。所以要慎用,清除闭包的方法就是消除引用。在该例中,令isString = null 即可清除引用。

 

上一章讲解了闭包的底层实现细节,我想大家对闭包的概念应该也有了个大概印象,但是真要用简短的几句话来说清楚,这还真不是件容易的事。这里我们就来总结提炼下闭包的概念,以应付那些非专人士的心血来潮。

闭包的学术定义

先来参考下各大权威对闭包的学术定义:

wiki百科

闭包,又称词法闭包或函数闭包,是引用了自由变量的函数。这个被引用的自由变量将和这个函数一同存在,即使已经离开了创造它的环境也不例外。所以,有另一种说法认为闭包是由函数和与其相关的引用环境组合而成的实体。闭包在运行时可以有多个实例,不同的引用环境和相同的函数组合可以产生不同的实例。

其实这个定义就一句话:“闭包是引用了自由变量的函数”,后面的都是这句话的解释。如果你对上一章中的内部函数作用域链有引用type变量的例子还有印象的话,那么在这里你会感觉好像是这么一会一回事。虽然我们不知道自由变量的明确定义,但我们能感觉到type的值就是这个自由变量。那究竟什么是自由变量?在一个作用域中使用某个变量,而不声明该变量,那么对这个作用域来说,该变量就是一个自由变量。

JavaScript 权威指南

函数对象可以通过作用域链相互关联起来,函数体内部的变量都可以保存在函数作用域内,这种特性在计算机科学文献中称为闭包。

这句话有一个关键词:“变量保存”,这确实是js闭包的一大特性。内部函数通过对自由变量的引用,再将自己的引用返回出去(内部函数),达到内部函数保存变量的效果。

JavaScript 高级程序设计

闭包是指有权访问另一个函数作用域中的变量的函数。

这里没有指明另一个函数就是嵌套函数的内部函数。事实上,在js中,只有内部函数有权访问外部函数作用域的变量。(这是由作用域链的查找机制决定的)

让我再结合上节的例子来看下:

function isType(type){
    return function(obj){    //返回一个匿名函数引用
        return Object.prototype.toString.call(obj) == '[object '+ type + ']';    //匿名函数内部保有对自由变量的type的引用
    }
}


var isFunction = isType('Function'); //匿名函数的引用数 1
var isString = isType('String');    //匿名函数的引用数 2

//测试
var name = 'Tom';
isString(name)//true

我对闭包的理解

如果 一个内部函数保有对外部作用域变量的引用 并且 这个内部函数也被引用 时,那么无论在什么执行环境下,这个被引用的变量将和这个函数一同存在。那个这个函数就是闭包。

js 闭包技巧

闭包引用带来的问题

下面我来看一道关于闭包的经典面试题,1秒后打印所有的迭代次数。通常我们可能会写出下面这样的代码:

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

timeCount();    //5 5 5 5 5

事实上这个例子,并不是关于闭包的技巧,相反它是由闭包特性带来的问题。理解这个问题有助于我们理解闭包。首先我们来看导致这个问题的原因:
1.setTimeout为异步任务;
2.回调函数中的i只有一个引用;

异步任务意味着它并不会马上执行,而是被推到一个异步任务队列中等待执行,直到js线程任务执行完后才会去执行这个队列中的任务。(类似的异步任务还有dom的交互事件绑定)
也就是说,当每次执行循环体的setTimeout方法时,js执行器并没有马上执行而是将其推入异步任务队列中。当5次循环执行完后,js线程再去执行异步队列中的任务(此时的i就是5了)。
解决的方法也很简单,那就是不使用i的引用,直接使用i的副本。那怎么使用i的副本?
《JavaScript高级程序设计》中提到,所有函数的参数都是按值传递的,什么意思?比如有一个函数 function add(num){},当我调用这个函数时 add(i), 在add函数内部变量i 不再是外部函数i的引用,而是一个独立存在的 与i的值相等的变量。这也就达到了复制i的作用。
(function(){})()是匿名函数的自执行写法。

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

timeCount();    //1 2 3 4 5

有闭包的bug一般都比较隐匿,这会增加调试的难度。这也就是为什么很多老手都不推荐大量使用闭包的原因之一,还有一个就是不释放变量的内存空间。

模拟私有成员

在JavaScript中是没有私有成员的概念,不能使用private关键字声明,所有的属性都是公有的。所以人们在JavaScript编程通常用两种方法来规定私有成员:
1.私有成员以下划线的方式命名;
2.利用闭包来模拟私有成员;
第一种方法是最简单的,而且效果还可以的方法,它的实现完全靠程序员的自觉性,很难保证不会有人刻意去使用私有成员。第二种方法虽然有点难理解,但它确实有效地实现了私有成员的保护。虽然js没有私有成员的概念,但是函数有私有变量的概念,函数外部不能访问私有变量。所以我们可以利用闭包的特性,创建访问私有变量的公有方法(特权方法)。

function Person(value) {
    var name = value;
    this.setName = function(newName) {
        name = newName;
    };
    this.getName= function() {
        return name;
    };
}

var tom = new Person('Tom');
console.log(tom.getName()); // Tom

利用闭包,我们可以通过特权方法来获取和修改私有变量,从而达到约束和规范代码的作用,这在大型应用开发中尤为重要。但是这种写法还需要改进,我们希望实例能够共享实例方法,而不是通过复制来得到这些方法的使用:

(function() {
    var name;
    Person = function(value){ //不声明变量person,使其可以在全局被访问
        name = value;
    };    
    Person.prototype = {
        setName: function (newName) {
            name = newName;
        },
        getName: function() {
            return name;
        }
    }
})()

var tom = new Person('Tom');
console.log(tom.getName()); // Tom

创建一个匿名自执行函数,是为了得到一个静态私有作用域,在这个静态作用域中创建的name变量,这样既可以保证它的数据安全,也能被实例方法所访问。

函数的柯里化

柯里化是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。---来自wiki百科
这里有一点要注意,单一参数并不是指一个参数,而是最初函数的参数(外部函数),可以是多个。
它的理论依据是,如果你固定某些参数,你将得到接受余下参数的一个函数。这很容易理解,如果我们有一个二元一次方程,z = x + y;我固定x的值为3,这个方程就变成了一元一次方程,z = 3 + y;
柯里化的过程是清楚了,但是他的目的是什么呢,我们为什么要固定一个参数返回一个新函数?为什么不直接定义一个新函数呢?
如果直接定义一个新函数,原来的参数变成函数内部固定的私有变量,这样一来虽然特定的功能完成了,但是代码的通用性却降低了。基于这个应用场景创造的新函数,换了一个相似的应用场景(只是参数的改变)却不得不重新定义一个新函数,造成了代码的重复。
通用性的增强必然导致适用性的降低,柯里化就是这么一个过程,将原本接受多个参数的函数(因为多个参数,自然适应的业务场景就多,通用性也就强),转为接受少个参数的新函数(参数少,应用的场景也就更明确,适用性也就强)。 这么一来,通过柯里化,开发者便可掌握代码的通用性和适用性之间的平衡。

这个理解起来可能有点吃力,毕竟柯里化是属于函数式编程里的重要技巧,一般像我们这种习惯面向对象开发的人确实会比较难以领会它的精髓。

单例模式

单例模式的定义是产生一个类的唯一实例,很多js的开发者认为,类似Java那种单例模式的创造方式在JavaScript中没有必要。因为在js中,不需要实例化也可创建对象,只要直接全局作用域创建一个字面量对象,以便整个系统访问。单例模式在js中的应用场景确实也不算多,主要应用在框架层,而大多数js的开发者是从事应用层的开发,所以接触不多。比如一个遮罩层的创建,为确保一次只有一个遮罩层,使用单例模式是最好的选择。

var singleton = function( fn ){
    var result;
    
    return function(){
        return result || ( result = fn .apply( this, arguments ) );
    }
}

var createMask = singleton(
    function(){
        return document.body.appendChild( document.createElement('div') );
    }
)

函数绑定

这一个技巧放在最后讲,是因为ES5规定了对原生函数绑定方法的实现——Function.prototype.bind。使用闭包来绑定this变量的hack技术已经退出历史舞台,但是老版的IE浏览器依然在使用这种技术来实现函数的绑定。先来看一个场景

var tip = {
    name: 'jack',
    say: function() {
        alert(this.name)
    }
}

btn.onclick = tip.say();    // 输出 '',因为window对象存在name属性,是一个空字符串

在注册事件中的事件处理程序没有绑定执行环境,所以当触发事件处理程序时,this指向正在执行的环境对象,在这里是全局对象window。最常见的解决方法就是绑定他的执行环境对象


btn.onclick = tip.say().bind(tip);    // jack

还有一种方法,就是利用apply+闭包来达到绑定效果,apply将事件处理程序与正确的环境对象绑定,再将绑定后的函数返回赋值给事件处理程序。它常用作不支持原生bind方法的兼容性处理。

if (!Function.prototype.bind) {
  Function.prototype.bind = function (oThis) {
    if (typeof this !== "function") {
      // closest thing possible to the ECMAScript 5
      // internal IsCallable function
      throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable");
    }

    var aArgs = Array.prototype.slice.call(arguments, 1), 
        fToBind = this, 
        fNOP = function () {},
        fBound = function () {
          return fToBind.apply(this instanceof fNOP
                                 ? this
                                 : oThis || this,
                               aArgs.concat(Array.prototype.slice.call(arguments)));
        };

    fNOP.prototype = this.prototype;
    fBound.prototype = new fNOP();

    return fBound;
  };
}

调用方式与原生bind相同。

posted @ 2017-02-20 16:56  艾玛电动臀  阅读(793)  评论(0编辑  收藏  举报