晴明的博客园 GitHub      CodePen      CodeWars     

[js] 闭包与模块

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

一个例子

下面用一些代码来解释这个定义。

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

这段代码基于词法作用域的查找规则,函数bar() 可以访问外部作用域中的变量a。
这是闭包吗?
技术上来讲,也许是。但根据前面的定义,确切地说并不是。我认为最准确地用来解释bar() 对a 的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分。
函数bar() 具有一个涵盖foo() 作用域的闭包(事实上,涵盖了它能访问的所有作用域,比如全局作用域)。也可以认为bar() 被封闭在了foo() 的作用域中。因为bar() 嵌套在foo() 内部。

闭包例子

  • 1
function foo() {
    var a = 2;
    function bar() {
        console.log( a );
    }
    return bar;
}
var baz = foo();
baz(); // 2 —— 这就是闭包的效果。

函数bar() 的词法作用域能够访问foo() 的内部作用域。然后我们将bar() 函数本身当作一个值类型进行传递。

在这个例子中,我们将bar 所引用的函数对象本身当作返回值。在foo() 执行后,其返回值(也就是内部的bar() 函数)赋值给变量baz 并调用baz(),实际上只是通过不同的标识符引用调用了内部的函数bar()。bar() 显然可以被正常执行。

但是在这个例子中,它在自己定义的词法作用域以外的地方执行。

在foo() 执行后,通常会期待foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。
由于看上去foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的“神奇”之处正是可以阻止这件事情的发生。
事实上内部作用域依然存在,因此没有被回收。
谁在使用这个内部作用域?原来是bar() 本身在使用。
拜bar() 所声明的位置所赐,它拥有涵盖foo() 内部作用域的闭包,使得该作用域能够一直存活,以供bar() 在之后任何时间进行引用。

bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

因此,在几微秒之后变量baz 被实际调用(调用内部函数bar),不出意料它可以访问定义时的词法作用域,因此它也可以如预期般访问变量a。

这个函数在定义时的词法作用域以外的地方被调用。
闭包使得函数可以继续访问定义时的词法作用域。
  • 2

当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到闭包。

function foo() {
    var a = 2;
    function baz() {
        console.log( a ); // 2
    }
    bar( baz );
}
function bar(fn) {
    fn(); // 妈妈快看呀,这就是闭包!
}

把内部函数baz 传递给bar,当调用这个内部函数时(现在叫作fn),它涵盖的foo() 内部作用域的闭包就可以观察到了,因为它能够访问a。

  • 3

传递函数当然也可以是间接的。

var fn;
function foo() {
    var a = 2;
    function baz() {
        console.log( a );
    }
    fn = baz; // 将baz 分配给全局变量
}
function bar() {
    fn(); // 妈妈快看呀,这就是闭包!
}
foo();
bar(); // 2

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包.

  • 4
function wait(message) {
    setTimeout( function timer() {
        console.log( message );
    }, 1000 );
}
wait( "Hello, closure!" );

将一个内部函数(名为timer)传递给setTimeout(..)。
timer 具有涵盖wait(..) 作用域的闭包,因此还保有对变量message 的引用。
wait(..) 执行1000 毫秒后,它的内部作用域并不会消失,timer 函数依然保有wait(..)作用域的闭包。

深入到引擎的内部原理中,内置的工具函数setTimeout(..) 持有对一个参数的引用,这个参数也许叫作fn 或者func,或者其他类似的名字。
引擎会调用这个函数,在例子中就是内部的timer 函数,而词法作用域在这个过程中保持完整。
这就是闭包。

  • 5

使用jq

function setupBot(name, selector) {
    $( selector ).click( function activator() {
        console.log( "Activating: " + name );
    } );
}
setupBot( "Closure Bot 1", "#bot_1" );
setupBot( "Closure Bot 2", "#bot_2" );

本质上无论何时何地,如果将函数(访问它们各自的词法作用域)当作第一级的值类型并到处传递,就会看到闭包在这些函数中的应用。
只要使用了回调函数,实际上就是在使用闭包!

  • 6

IIFE

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

虽然这段代码可以正常工作,但严格来讲它并不是闭包。

因为函数(示例代码中的IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而外部作用域,也就是全局作用域也持有a)。
a 是通过普通的词法作用域查找而非闭包被发现的。

尽管技术上来讲,闭包是发生在定义时的,但并不非常明显。

尽管IIFE 本身并不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建可以被封闭起来的闭包的工具。
因此IIFE 的确同闭包息息相关,即使本身并不会真的使用闭包。

循环和闭包

  • 1
for (var i=1; i<=5; i++) {
    setTimeout( function timer() {
        console.log( i );
    }, i*1000 );//这段代码在运行时会以每秒一次的频率输出五次6。
}

当循环内部包含函数定义时,代码格式检查器经常发出警告。

延迟函数的回调会在循环结束时才执行。
事实上,当定时器运行时即使每个迭代中执行的是setTimeout(.., 0),所有的回调函数依然是在循环结束后才会被执行,因此会每次输出一个6 出来。

代码中到底有什么缺陷导致它的行为同语义所暗示的不一致呢?
缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i 的副本。
但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,
但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。
这样说的话,当然所有函数共享一个i 的引用。循环结构让我们误以为背后还有更复杂的
机制在起作用,但实际上没有。
如果将延迟函数的回调重复定义五次,完全不使用循环,那它同这段代码是完全等价的。

  • 2

使用IIFE实现闭包

如果作用域是空的,那么仅仅将它们进行封闭是不够的。

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

可行的写法1

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

在迭代内使用IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

重返块作用域

使用IIFE 在每次迭代时都创建一个新的作用域。
换句话说,每次迭代我们都需要一个块作用域。
let 声明,可以用来劫持块作用域,并且在这个块作用域中声明一个变量。
本质上这是将一个块转换成一个可以被关闭的作用域。

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

模块

这里并没有明显的闭包,只有两个私有数据变量something和another,以及doSomething() 和doAnother() 两个内部函数,它们的词法作用域(而这就是闭包)也就是foo() 的内部作用域。

function foo() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join( " ! " ) );
    }
}

最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。

function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join( " ! " ) );
    }
    return {
        doSomething: doSomething,
        doAnother: doAnother
    };
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

CoolModule() 只是一个函数,必须要通过调用它来创建一个模块实例。
如果不执行外部函数,内部作用域和闭包都无法被创建。
其次,CoolModule() 返回一个用对象字面量语法{ key: value, ... } 来表示的对象。
这个返回的对象中含有对内部函数而不是内部数据变量的引用。
保持内部数据变量是隐藏且私有的状态。
可以将这个对象类型的返回值看作本质上是模块的公共API。
这个对象类型的返回值最终被赋值给外部的变量foo,然后就可以通过它来访问API 中的属性方法,比如foo.doSomething()。

从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。jQuery 就是一个很好的例子。jQuery 和$ 标识符就是jQuery 模块的公共API,但它们本身都是函数(由于函数也是对象,它们本身也可以拥有属性)。

doSomething() 和doAnother() 函数具有涵盖模块实例内部作用域的闭包( 通过调用CoolModule() 实现)。
当通过返回一个含有属性引用的对象的方式来将函数传递到词法作用域外部时,我们已经创造了可以观察和实践闭包的条件。
如果要更简单的描述,模块模式需要具备两个必要条件。

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

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

上一个示例代码中有一个叫作CoolModule() 的独立的模块创建器,可以被调用任意多次,每次调用都会创建一个新的模块实例。
当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式:

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];
    function doSomething() {
        console.log( something );
    }
    function doAnother() {
        console.log( another.join( " ! " ) );
    }
    return {
        doSomething: doSomething,
        doAnother: doAnother
    };
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

将模块函数转换成了IIFE,立即调用这个函数并将返回值直接赋值给单例的模块实例标识符foo。

模块也是普通的函数,因此可以接受参数:

function CoolModule(id) {
    function identify() {
        console.log( id );
    }
    return {
        identify: identify
    };
}
var foo1 = CoolModule( "foo 1" );
var foo2 = CoolModule( "foo 2" );
foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"

模块模式另一个简单但强大的变化用法是,命名将要作为公共API 返回的对象:

var foo = (function CoolModule(id) {
    function change() {
        // 修改公共API
        publicAPI.identify = identify2;
    }
    function identify1() {
        console.log( id );
    }
    function identify2() {
        console.log( id.toUpperCase() );
    }
    var publicAPI = {
        change: change,
        identify: identify1
    };
    return publicAPI;
})( "foo module" );
foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

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

现代的模块机制

大多数模块依赖加载器/ 管理器本质上都是将这种模块定义封装进一个友好的API。

var MyModules = (function Manager() {
    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
    };
})();

这段代码的核心是modules[name] = impl.apply(impl, deps)。
为了模块的定义引入了包装函数(可以传入任何依赖),并且将返回值,也就是模块的API,储存在一个根据名字来管理的模块列表中。

如何使用MyModules来定义模块:

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( hungry ).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

"foo" 和"bar" 模块都是通过一个返回公共API 的函数来定义的。
"foo" 甚至接受"bar" 的示例作为依赖参数,并能相应地使用它。

特点:为函数定义引入包装函数,并保证它的返回值和模块的API 保持一致。
换句话说,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。

ES6的模块机制

//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;

//baz.js
// 导入完整的"foo" 和"bar" 模块
module foo from "foo";
module bar from "bar";
console.log(bar.hello( "rhino" )); // Let me introduce: rhino
foo.awesome(); // LET ME INTRODUCE: HIPPO
posted @ 2017-05-17 22:08  晴明桑  阅读(304)  评论(0编辑  收藏  举报