如果是新手 JavaScript 开发人员,你可能已经听说过闭包这个关键字,但是大多数人可能还没有。或者,你是正在寻找新机会的开发人员,并且以前从未用过闭包,但是你知道这对于 JavaScript 开发人员职位的面试至关重要。
通常要了解闭包,仅阅读一篇文章是不够的。但在本文中,你将学习所有用到的概念来进行解释它,以及这些概念如何一清二楚地协同工作。
闭包存储一个函数中的当前定义以及作用域信息,将其传递给外层作用域。通常内层作用域无法从词法作用域环境中的外层作用域访问。
这个定义一开始可能看起来很吓人,但现在只关注关键字。首先让我从作用域关键字开始,它还将告诉你有关词法作用域的信息。
作用域
在编程中,作用域是一个基础概念,负责命名绑定。当在一个代码块中声明一个函数或者变量时,这些命名绑定从代码的其它部分可以访问或者不能访问。我们通常称之为作用域。
编程语言是在这个作用域概念的基础上创建的,以支持开发人员的安全性和持久性。
有两种常见的作用域方法,即词法(静态)作用域和动态作用域。
大多数已知的编程语言都在使用词法作用域,也称为静态作用域。这些编程语言是 C、C++、Java、JavaScript、Python 等。
在 Perl 和 Lisp 中,你可能更愿意用动态作用域。而且 Logo 作为一种编程语言也建立在动态作用域的基础上。
因此,下面我们首先从词法(静态)作用域开始。
词法(静态)作用域
在词法作用域环境中,命名绑定是在编译期确定的,也称早绑定。它为命名绑定带来了严格的可访问性规则。我们大致可以将这些规则描述为:
内层作用域可以访问其外层作用域
外层作用域不能访问其内层作用域
所以我们可以说,在词法作用域环境中,我们有从内层作用域到外层作用域的单向访问。对于开发者来说,这种作用域策略易于读懂,易于跟踪代码。
动态作用域
在动态作用域环境中,命名绑定是在运行期确定的,也称晚绑定。与词法作用域不同,动态作用域对于命名绑定没有可以访问性限制。
在这种作用域策略中,对于编程语言来说很容易实现,但是对于开发者来说,很难跟踪变量的值。因为对于代码不同部分中的每个命名绑定,对于相似的函数或者变量名都将存在竞争条件。
当然,这些定义不足以简单展示它们的含义。因此,下面我们来看一下对比中的作用域界定策略。
词法(静态)作用域与动态作用域
如下只是一个基础的 Javascript 代码段:
let a = 1;
const printParentScopeA = () => {
console.log(a);
}
const printChildScopeA = () => {
let a = 10;
console.log(a);
}
printChildScopeA();
printParentScopeA();
在这段代码中,我们总共有三种作用域定义:
全局作用域
函数 printParentScopeA 作用域
函数 printChildScopeA 作用域
调用 printChildScopeA() 函数时,它将打印在其作用域(即函数 printChildScopeA 的作用域)中定义的变量 a。因此它将立即打印 10。
调用 printParentScopeA() 函数时,它首先查找变量 a 的定义,而在其作用域(即函数printParentScopeA 的作用域)中找不到。然后在上层作用域中搜索 a 的定义,并在全局作用域中找到了。所以它打印出 1。
遵循的这种作用域逻辑是词法(静态)作用域。
假设它是一个动态作用域环境而不是词法作用域,那么输出又是什么呢?
在这种情况下,所有函数和变量定义都将存储在一个通用作用域内,所有相同名称的声明将指向相同的变量或函数,而最后一个具有同名的定义或声明会起最终作用。
因此在动态作用域环境中,我们的代码段会工作为:
调用 printChildScopeA() 函数时,首先,它将变量a的值更改为10(其定义存储在通用作用域内)。然后,它将打印定义在通用作用域中的变量a。因此它打印10。
调用 printParentScopeA() 函数时,它会在通用作用域内查找变量a的定义,然后找到其值10。因此它将再次打印10。
如您所见,这是不寻常的行为,对于大型代码库而言,很难跟踪。这就是为什么大多数编程语言都建立在词法(静态)作用域之上的原因。
现在,我们理解了词法作用域到底是什么,以及它如何定义作用域,如何在这些作用域定义中搜索命名绑定。
最后,我们再来谈谈 JavaScript 闭包。
闭包
正是由于闭包,我们才可以绑定一个词法作用域环境,并将其传递给外层作用域。如之前所读,通常在词法作用域中是不允许进入内层作用域环境的,但是闭包是允许我们这样做的一项至关重要的 JavaScript 功能。
那么,我们该怎么做呢?
我们可以通过将一个函数定义在另一个函数中,从而封装一个词法环境。在此定义过程中,我们可以把当前词法作用域内容存储在内层函数定义中。
那么下面我们来看一下一个简单的闭包例子。
闭包的例子
const powerOf = (x) => {
const power = (y) => {
return y**x;
};
return power;
}
const square = powerOf(2);
const cube = powerOf(3);
console.log(square(2)); // 打印 4
console.log(cube(2)); // 打印 8
在上面的代码示例中,我们可以看到,当 powerOf() 函数被带参数调用时,它是在返回 power() 函数而不是执行它,并且在该函数返回的过程中,参数 y 和变量 x 的当前值被保存了。
因此,当 powerOf(2) 被调用时,它是在返回:
((y) => {
return y**2;
});
而当 powerOf(3) 被调用时,它是在返回:
((y) => {
return y**3;
});
因此,正是由于 JavaScript 闭包,我们才得以将 power() 函数的当前定义和词法作用域存储在一起,并且我们从一个函数定义就得到了 square() 和 cube() 两个不同的函数定义。
JavaScript 闭包是一个很强大的功能,它甚至被用于实现一种称为模块模式的设计模式。正是由于这种实现,JavaScript 才在没有 public、private 关键字的情况下有了封装。
对于模块模式的实现,JavaScript 还用了另一个强大的功能,称为立即调用的函数表达式(IIFE)。
那么什么是 IIFE 呢?
立即调用的函数表达式(IIFE)
IIFE 是一种很基础的 JavaScript 功能,它让我们能在函数定义的过程中就能执行该函数。
如下是一个简单的 IIFE 例子:
(() => {
console.log('I am an IIFE');
})(); // 打印: I am an IIFE
在上例中,它立即打印 'I am an IIFE',而不需要等待单独的函数调用。
为实现模块模式,我们会在一个 IIFE 中定义我们函数,这个 IIFE 中可能就带有闭包。
模块模式实现
这里有基础的模块模式实现示例:
var myModule = ((() => {
var myPrivateVariable = 'private';
var myPublicVariable = 'public';
var myPrivateMethod = () => {
console.log(myPrivateVariable);
}
return {
myPublicVariable,
myPublicMethod: () => {
myPrivateMethod();
}
};
})());
console.log(myModule.myPublicVariable);
// 打印: public
myModule.myPublicMethod();
// 打印: private
console.log(myModule.myPrivateVariable);
// 打印: undefined
myModule.myPrivateMethod();
// 排除 TypeError: myModule.myPrivateMethod is not a function
正如我们从上例中所看到的,我们已经在一个 IIFE 中定义了公有和私有变量以及函数。
通过确定应返回哪些变量和函数,我们将其公开。如果不返回在模块定义中函数变量,那么它就变成了私有的。
如果试图访问私有变量,它会是 undefined。如果视图访问私有函数,它会抛出一个类型错误。
模块模式是 JavaScript 中一个很常见的模式,并且由于 IIFE 和闭包,它很容易实现。
总结
我希望现在已经清晰地理解了 JavaScript 闭包是什么以及其工作原理。同时,通过模块模式示例,我希望你看到了 JavaScript 中闭包和 IIFE 的常见用法。