闭包是什么?大厂面试必考题

192 阅读5分钟

作用域与闭包

在JavaScript中,理解作用域和闭包是掌握这门语言的关键。闭包是一个高级概念,但一旦理解了它的本质,就能在编写更高效、更优雅的代码时发挥巨大作用。本文将深入探讨作用域和闭包的概念,并通过具体示例来说明它们的工作原理。

启示

对于许多JavaScript开发者来说,理解闭包往往是一个转折点。闭包不仅仅是语法糖,它是JavaScript中一种强大的特性,可以帮助你更好地管理和封装代码。然而,理解闭包需要一定的努力和实践。回忆几年前,我在大量使用JavaScript时完全不理解闭包是什么,总感觉这门语言有其隐蔽的一面。后来,通过大量阅读早期框架的源码,我逐渐理解了闭包的工作原理,特别是“模块模式”的概念。那一刻,我感到豁然开朗,原来我的代码中已经到处都是闭包了。

定义

闭包的定义是:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。下面通过一些代码来解释这个定义。

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

这段代码看起来和嵌套作用域中的示例代码很相似。基于词法作用域的查找规则,函数bar()可以访问外部作用域中的变量a。这是闭包吗?技术上来讲,也许是,但根据前面的定义,确切地说并不是。最准确的解释是词法作用域的查找规则,而这些规则只是闭包的一部分。

下面的代码更清楚地展示了闭包:

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

函数bar()的词法作用域可以访问foo()的内部作用域。然后我们将bar()函数本身作为一个值类型进行传递。在这个例子中,我们将bar()函数对象本身作为返回值。在foo()执行后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上是通过不同的标识符引用调用了内部的函数bar()

bar()显然可以被正常执行,但在定义时的词法作用域以外的地方执行。通常会期待foo()的整个内部作用域被销毁,因为垃圾回收器会释放不再使用的内存空间。然而,闭包的“神奇”之处在于它可以阻止这一点发生。内部作用域仍然存在,因此没有被回收。谁在使用这个内部作用域?原来是bar()本身在使用。bar()持有对foo()内部作用域的引用,这就是闭包。

理解

前面的代码片段有些死板,但闭包绝不仅仅是一个好玩的玩具。你已经写过的代码中一定到处都是闭包的身影。下面是一些常见的闭包应用场景:

  1. 定时器和事件监听器
function wait(message) {
    setTimeout(function timer() {
        console.log(message);
    }, 1000);
}
wait("Hello, closure!");

将一个内部函数(名为timer)传递给setTimeouttimer具有涵盖wait作用域的闭包,因此保有对变量message的引用。

  1. 模块模式
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

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

  1. 必须有外部的封闭函数,该函数必须至少被调用一次。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并访问或修改私有的状态。

循环和闭包

闭包在循环中也有广泛应用,但有时会出现意外的结果。例如:

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

这段代码的预期是分别输出数字1到5,每秒一次,但实际上会输出五次6。这是因为所有回调函数共享同一个i变量,而循环结束时i的值为6。

解决方法是为每个迭代创建一个新的作用域:

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

使用IIFE(立即执行函数表达式)为每个迭代创建一个新的作用域,确保每个回调函数访问的是正确的i值。

模块

模块模式是利用闭包的强大特性的典型例子。下面是一个简单的模块示例:

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

模块模式通过返回一个对象来暴露公共方法,同时保持内部数据的私有性。这样可以避免全局污染,提高代码的可维护性和安全性。

单例模式

如果只需要一个模块实例,可以使用单例模式:

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

结语

理解闭包不仅可以帮助你编写更高效的代码,还可以让你更好地管理代码的复杂性。闭包是JavaScript中一个强大的特性,通过实践和不断的探索,你将能够更加熟练地运用它。希望本文对你理解作用域和闭包有所帮助。这里还没有介绍完全,请等待下一篇文章。