2

前言

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

闭包的学术定义

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

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相同。

总结

闭包的技巧就介绍到这,更多的技巧还需要我们去开发中发现、领会并运用。下一章,我们来聊一聊js中最强大的属性之一——prototype。


24号来看你
32 声望3 粉丝

我是一名前端开发工程师,我喜欢看恐怖电影~!