作用域闭包

2017/11/25 posted in  JavaScript

闭包,JavaScript这门语言中近乎神话的一个概念。如果你了解词法作用域的改变,那闭包这个概念几乎是不言自明的。JavaScript中闭包无处不在,你只需要能够给识别并拥抱它。

闭包是基于词法作用域书写代码时所产生的自然结果。当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时就产生了闭包。

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    bar();
}

foo();

上面这段代码,从技术上来讲,也许是闭包。但根据前面的定义,确切的说并不是。这里更准确的解释是bar()对a的引用,是运用的词法作用域的查找规则,而这些规则只是闭包的一部分。

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}

var baz = foo();
baz(); //2

上面这段代码中,我们将bar()函数本身当做一个值类型进行传递,而bar()函数的词法作用域能够访问foo()的内部作用域。

foo()执行后,得到的返回值会赋值给变量baz,并调用baz(),实际上只是通过不同的标识符引用调用了内部函数bar()。

在这个例子中,bar()在自己定义的词法作用域之外被执行,这就是闭包的效果。

在函数执行之后,正常情况下,内部的整个作用域都会被销毁。但在这个例子中不会被销毁,因为bar()本身在使用,它拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在之后任何时间进行引用。

循环和闭包

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

上面这段代码,我们的预期是分别输出1~5,每秒一次,每次一个。但实际情况是每秒输出一个6。

这个6,是因为循环终止条件是i <= 5,条件首次成立时 i 值是 6。延迟函数的回调函数会在循环结束之后才会运行。

我们试图假设循环中的每个迭代在运行时都会给自己创建一个 i 的副本。但根据作用域工作原理,尽管循环中的五个函数是在各个迭代中分别定义的,但它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i 。因此我们需要更多的闭包作用域。

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

但上面的代码还是不行,为什么?因为作用域是空的,仅仅是将它们封闭起来是不够的。它需要一点实质内容才行。

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

这样就可以了。再改进一下。

for(var i = 1;i <= 5;i++){
    (function(j){
        setTimeout(function timer(){
            console.log(j);
        },1000);
    })(i);  
}
块作用域

上面我们用IIFE在每次迭代时都创建一个新的作用域,其实就是每次迭代我们都需要一个块作用域。

for(var i = 1;i <= 5;i++){
    let j = i; //闭包的块作用域
    setTimeout(function timer(){
        console.log(j);
    },1000);
}

本质上这是将一个块转换成一个被关闭的作用域。

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

在for循环头部用let声明 i 会有一个特殊行为,每次迭代都会声明一次。随后的每次迭代都会使用上一次迭代结束时的值来初始化这个变量。

模块

function CoolModule(){
    var something = 'cool';
    var another = [1,2,3];
    
    function doSomething(){
        console.log(something);
    }

    function doAnother(){
        console.log(another.join('!'));
    }

    return {
        doSomething: doSomething,
        another: another
    }
}

var foo = CoolModule();
foo.doSomething(); //cool
foo.another(); //1!2!3!

上面这段代码的模式,在JavaScript中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露。

通过调用CoolModule() 来创建一个模块实例。如果不执行,内部作用域和闭包都无法被创建。

CoolModule() 返回一个用对象字面量语法来表示的对象,没有返回内部数据变量的引用,这是为了保持内部数据变量的隐藏和私有的状态。可以将这个对象类型的返回值看做是模块的公共API。

从模块中返回一个对象并不是必须的,也可以直接返回一个内部函数。jQuery就是最好的例子。

模块模式必备两个必要条件:
1. 必须有外部的封闭函数,该函数必须至少被调用一次。
2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用所返回的,只有数据属性而没有闭包函数的对象不是真正的模块。

var foo = (function CoolModule(id){
    function change(){
        publicAPI.identity = identity2;
    }

    function identity1(){
        console.log(id);
    }

    function identity2(){
        console.log(id.toUpperCase());
    }

    var publicAPI = {
        change: change,
        identity: identity1
    }

    return publicAPI;
})("foo module");

foo.identity();  //foo module
foo.change();
foo.identity();  //FOO MODULE

通过在模块实例的内部保留对公共API对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改它们的值。

现代的模块机制

var MyModules = (function(){
    var modules = {};

    function define(name, deps, impl){
        for(var i = 0;i < deps.length;i++){
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply(impl,deps);
    }

    function get(name) {
        return modules[name];
    }

    return {
        define: define,
        get: get
    }
})();

MyModules.define('bar',[],function(){
    function hello(who){
        return 'Let me introduce: ' + who;
    }

    return {
        hello: hello
    }
});

MyModules.define('foo',['bar'],function(bar){
    var hungry = 'hippo';
    
    function awesome(){
        console.log(bar.hello(hunpry).toUpperCase());
    }

    return {
        awesome: awesome
    }
});

var bar = MyModules.get('bar');
var foo = MyModules.get('foo');
console.log(bar.hello('hippo')); //Let me introduce: hippo
foo.awesome(); //LET ME INTRODUCE: HIPPO

这段代码最核心的就是 modules[name] = impl.apply(impl, deps) 为模块定义引入包装函数,并将返回值,也就是模块的API,存储在一个根据名字来管理的模块列表中。换句话说,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。

未来的模块机制

ES6中为模块增加了一级语法支持,一个文件为一个模块。与基于函数的模块相比,ES6模块API更加稳定。

//bar.js

function hello(who){
    return 'Let me introduce: ' + who;
}

export hello;
//foo.js

//仅从‘bar’模块导入hello()
import hello from 'bar';

var hungry = 'hippo';

function awesome(){
    console.log(hello(hungry).toUpperCase());
}

export awesome;
//导入完成的‘foo’和‘bar’模块
import bar from 'bar';
import foo from 'foo';

console.log(bar.hello('rhino'));
foo.awesome();

import 可以将一个模块中的一个或多个API导入到当前作用域中,并分别绑定在一个变量上。module会将整个模块的API导入并绑定到一个变量上。export会将当前模块的一个标识符(变量、函数)导出为公共API。

模块文件中的内容会被当做好像包含在作用域闭包中一样来处理。