前言

闭包是基于词法作用域在书写代码时所产生的自然结果,不需要刻意的去创建闭包,闭包的创建和使用在代码中随处可见,我们需要的是怎么去识别和根据自己的需要去使用它。

什么是闭包

先看一段代码:

function foo() {
    var a = 2;

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

foo();

当我们运行foo函数的时候,foo函数里面会运行bar函数,打印时通过作用域链往上RHS查询,在foo函数作用域中查询到了a变量,并得到它的值。

我们根据之前学习的作用域知识,很快的明白了为什么bar函数在运行时可以获取到a,以及如何获取到a。这个就是词法作用域的查找规则,而这些规则是闭包非常重要的一部分原理。

下面我们再小小改动一下代码:

function foo() {
    var a = 2;

    function bar() {
        console.log(a);
    }
    
    return bar;
}

var baz = foo();

baz(); // 2 —— 朋友,这就是闭包的效果。

当foo函数在执行后,其整个内部作用域都会被销毁(垃圾回收);但是由于我们导出了一个bar函数,bar函数的作用域链中包含了foo函数作用域,由于bar函数没有被运行,而是被return抛出,这就导致引擎需要保持foo函数作用域一直存活,以便给bar函数使用。

我们在之后再运行baz变量,实际上就是bar函数的引用,运行时可以得到a变量的值。

我们更换几种用法:

function foo() {
    var a = 2;

    function baz() {
        console.log(a); // 2
    }
    bar(baz);
}

function bar(fn) {
    fn(); // 妈妈快看呀,这就是闭包!
}

foo();
var fn;

function foo() {
    var a = 2;

    function baz() {
        console.log(a);
    }
    fn = baz; // 将 baz 分配给全局变量
}

function bar() {
    fn(); // 妈妈快看呀,这就是闭包!
}

foo();
bar(); // 2

无论通过什么样的方式将内部函数传递到所在词法作用域以外,它都会持有在定义时的作用域引用,无论在何处调用这个内部函数,都会形成闭包。

我们再看几个经典例子:

定时器

function wait(message) {
    setTimeout(function timer() {
        console.log(message);
    }, 1000);
}

wait("Hello, closure!");

setTimeout接收一个函数参数,这个timer函数内部打印message;在1秒后运行;此时timer函数持有wait的作用域引用,所以在延迟运行时能够输出正确的值,这也是闭包。

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

IIFE

var a = 2;

(function IIFE() {
    console.log(a);
})();

虽然这段代码可以正常工作,但严格来讲它并不是闭包。为什么?因为函数(示例代码中的 IIFE)并不是在它本身的词法作用域以外执行的。它在定义时所在的作用域中执行(而外部作用域,也就是全局作用域也持有 a)。a 是通过普通的词法作用域查找而非闭包被发现的。

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

循环和闭包

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

这段代码中,根据已习得的知识,你觉得它会输出什么。

事实上所有的打印只会在延迟后输出结果:6

分析:

首先使用了var声明,那么相当于在for同级作用域声明了一个变量i

函数timer形成了一个闭包,但是我们需要知道,我们持有的是作用域引用,for循环每次都没有生成新的独立作用域,所以timer拿到是和for循环同级作用域,那么在延迟触发前,for循环已经结束,i++最后得到结果6被赋值给i

定时器触发时,从作用域中找到i;他的值就是6。

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

那么这个代码真正需要解决的问题是什么?

他需要在每个for循环时都需要一个独立的作用域来存储i变量。

解法1:

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

通过自运行函数生成一个函数作用域,然后里面存储了1份i的值。

解法2:

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

这种就优化了一下,通过函数的参数来实现。

解法3:

es6更新了一个let什么,它可以用来劫持块作用域,并且在这个块作用域中声明一个变量。

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

let每次使用时会使得本次循环的作用域被劫持,从而在该作用域创建了一个j变量能够被timer获取到。

但是let不仅仅是如此,他在for循环的头部声明时,会有特殊行为,这也导致我们可以这么写:

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

我们这时再来看一下块作用域中提到的let代码:

{
    let j;
    for (j = 0; j < 10; j++) {
        let i = j; // 每个迭代重新绑定!
        console.log(i);
    }
}

是不是有点明白了。

模块

模块的实现,其实也是利用了闭包的能力,对外抛出的api(属性或者函数)持有内部的私有访问权限,从而可以实现优雅的封装和使用。

function foo() {
    var something = "cool";
    var another = [1, 2, 3];

    function doSomething() {
        console.log(something);
    }

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

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

接下来我们将需要被使用的api抛出

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

这个模式在 JavaScript 中被称为模块。最常见的实现模块模式的方法通常被称为模块暴露。

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

对于一个真正的模块他需要满足2点:

  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

现代的模块机制

这里就只展示代码了,大概10分钟就能看明白了。

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,
    };
};

定义模块:

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

未来的模块机制

这个大家应该都已经用烂了,就不过多介绍了,放点示例代码。

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

小结

闭包就好像从 JavaScript 中分离出来的一个充满神秘色彩的未开化世界,只有最勇敢的人才能够到达那里。但实际上它只是一个标准,显然就是关于如何在函数作为值按需传递的词法环境中书写代码的。

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

如果没能认出闭包,也不了解它的工作原理,在使用它的过程中就很容易犯错,比如在环中。但同时闭包也是一个非常强大的工具,可以用多种形式来实现模块等模式。

模块有两个主要特征:(1)为创建内部作用域而调用了一个包装函数;(2)包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包。

现在我们会发现代码中到处都有闭包存在,并且我们能够识别闭包然后用它来做一些有的事!

分类: 你不知道的JavaScript 标签: 模块作用域闭包

评论

暂无评论数据

暂无评论数据

目录