《你不知道的JavaScript》-- 闭包(笔记)

2022-04-07 15:46:15 浏览数 (1)

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

代码语言:javascript复制
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()。

在 foo() 执行后,通常引擎的垃圾回收器(用来释放不再使用的内存空间)会销毁 foo() 的整个内部作用域,而闭包阻止了销毁,因为 bar() 拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一直存活,以便 bar() 在之后任何时间进行引用,这个引用就叫作闭包。

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

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

代码语言:javascript复制
function foo(){
    var a = 2;
    function baz(){
        console.log(a);
    }
    bar(baz);
}
function bar(fn){
    fn();//闭包
}
foo();

传递函数也可以是间接的:

代码语言:javascript复制
var fn;
function foo(){
    var a = 2;
    function baz(){
        console.log(a);
    }
    fn = baz;//将baz分配给全局变量
}
function bar(){
    fn();//闭包
}
foo();
bar();

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

代码语言:javascript复制
function wait(message){
    setTimeout(function timer(){
        console.log(message);
    }, 1000);
}
wait('hello closure!');

wait(...)执行1000ms后,它的内部作用域并不会消失,timer函数依然保有 wait(...)作用域的闭包。

本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当作第一级的值类型并到处传递,就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax请求、跨窗口通信、Web Workers或者任何其他的异步(或同步)任务中,只要使用了回调函数,实际上就是在使用闭包。

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

代码语言:javascript复制
for(var i = 1; i <= 5; i  ){
    setTimeout(function timer(){
        console.log(i);
    }, i*1000)
}

延迟函数的回调会在循环结束时才执行,所以这段代码在运行时会以每秒一次的频率输出五次6。

这段代码的缺陷是我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个i的副本,但是根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,实际上只有一个i。

我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域,而且闭包作用域中需要有自己的变量,用来在每个迭代中存储i的值:

代码语言:javascript复制
for(var i = 1; i <= 5; i  ){
    (function(){
        var j = i;
         setTimeout(function timer(){
            console.log(j);
        }, j*1000)
    })()
}

代码语言:javascript复制
for(var i = 1; i <= 5; i  ){
    (function(i){
         setTimeout(function timer(){
            console.log(i);
        }, i*1000)
    })(i)
}

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

我们使用IIFE在每次迭代时都创建一个新的作用域,即每次迭代我们都需要一个块作用域,本质上这是将一个块转换成一个可以被关闭的作用域。

代码语言:javascript复制
for(let i = 1; i <= 5; i  ){
    setTimeout(function timer(){
        console.log(i);
    }, i*1000);
}

for循环头部的let声明指出变量在循环过程中不止被声明一次,每次迭代都会声明,随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

模块应用闭包:

代码语言:javascript复制
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();
foo.doAnother();

最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体。从模块中返回一个实际的对象并不是必须的,也可以直接返回一个内部函数。

模块模式需要具备两个必要条件:

1)必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例);

2)封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。

当只需要一个实例时,可以对这个模式进行简单的改进来实现单例模式:

代码语言:javascript复制
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();
foo.doAnother();

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

代码语言:javascript复制
function CoolModule(id){
    function identify(){
        console.log(id);
    }
    return {
        identify: identify
    }
}
var foo1 = CoolModule('foo 1');
var foo2 = CoolModule('foo 2');
foo1.identify();
foo2.identify();

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

代码语言:javascript复制
var foo = (function CoolModule(id){
    function change(){
        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.change();
foo.identify();

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

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

代码语言:javascript复制
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储存在一个根据名字来管理的模块列表中。

如何使用它来定义模块:

代码语言:javascript复制
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'));
foo.awesome();

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

ES6中为模块增加了一级语法支持,在通过模块系统进行加载时,ES6会将文件当作独立的模块来处理,每个模块都可以导入其他模块或特定的API成员,同样也可以导出自己的API成员。

基于函数的模块并不是一个能被静态识别的模式(编译器无法识别),它们的API语义只有在运行时才会被考虑进来,因此可以在运行时修改一个模块的API。

相比之下,ES6模块API是静态的(API不会在运行时改变)。由于编译器知道这一点,因此可以在编译期检查对导入模块的API成员的引用是否真实存在,如果API引用并不存在,编译器会在编译时就抛出“早期”错误,而不会等到运行期再动态解析(并且报错)。

ES6的模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块),浏览器或引擎有一个默认的“模块加载器”(可以重载)可以在导入模块时同步地加载模块文件。

代码语言:javascript复制
//bar.js
function hello(who){
    return 'Let me introduce:'   who;
}
export hello;

//foo.js
import hello from 'bar';
var hungry = 'hippo';
function awesome(){
    console.log(hello(hungry).toUpperCase());
}
export awesome;

//baz.js
module foo from 'foo';
module bar from 'bar';
console.log(bar.hello('rhino'));
foo.awesome();

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

模块文件中的内容会被当作好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样。

0 人点赞