此文为译文,原文为mdn关于闭包的解释,由于官方的介绍已经非常详细,建议有英语阅读能力的阅读官方文档。
本文后续会出相关闭包的使用场景
概念
闭包是捆绑在一起(封闭)的函数与对其周围状态(词法环境)的引用的组合。通俗的讲,闭包使您可以从内部函数访问外部函数的范围。在 JavaScript 中,每次创建函数时都会创建闭包。
词法作用域 Lexical scoping
function init() {
var name = 'Mozilla'; // name is a local variable created by init
function displayName() {
// displayName() is the inner function, a closure
console.log(name); // use variable declared in the parent function
}
displayName();
}
init();
init()
创建一个名为 name
的局部变量和一个名为 displayName()
的函数。 displayName()
函数为 init()
内部定义的内部函数,并且仅在 init()
函数体中可用。
请注意,displayName()
函数没有自己的局部变量。但由于内部函数可以访问外部函数的变量,因此 displayName()
可以访问父函数 init()
中声明的变量名。
这是一个词法作用域的例子,它描述了当函数嵌套时解析器如何解析变量名。
词法指的是词法作用域使用源代码中声明变量的位置来确定该变量可用的位置,嵌套函数可以访问在其外部范围内声明的变量。
在 ES6
之前,JavaScript 只有两种作用域:函数作用域和全局作用域。用 var
声明的变量要么是函数范围的,要么是全局范围的,这取决于它们是在函数内声明还是在函数外声明。这可能会困扰人,因为带有花括号的块不会创建范围:
if (Math.random() > 0.5) {
var x = 1;
} else {
var x = 2;
}
console.log(x);
对于使用块创建范围的其他语言(例如 C、Java)的人,上面的代码应该在console.log
行上抛出错误,因为我们在任何一个块中都超出了 x
的范围。但是,因为块不为 var
创建作用域,所以这里的 var
语句实际上创建了一个全局变量。下面还介绍了一个实际示例,说明了与闭包结合使用时如何导致实际错误。
if (Math.random() > 0.5) {
const x = 1;
} else {
const x = 2;
}
console.log(x); // ReferenceError: x is not defined
本质上,块最终在 ES6 中被视为作用域,但前提是您使用 let
或 const
声明变量。此外,ES6 引入了模块,它引入了另一种范围。闭包能够捕获所有这些范围内的变量,我们稍后会介绍。
闭包 Closure
先看一个例子
function makeFunc() {
const name = 'Mozilla';
function displayName() {
console.log(name);
}
return displayName;
}
const myFunc = makeFunc();
myFunc();
运行这段代码的效果与上面的 init()
函数示例完全相同。不同的是 displayName()
内部函数在执行之前从外部函数返回。
乍一看,这段代码仍然有效可能看起来不直观。在某些编程语言中,函数中的局部变量仅在该函数执行期间存在。一旦 makeFunc()
完成执行,您可能会认为 name
变量将不再可访问。然而,代码仍然按预期工作,因此这在 JavaScript 中显然不是这样。
原因是 JavaScript 中的函数形成闭包。闭包是函数和声明该函数的词法环境的组合。该环境由创建闭包时在范围内的任何局部变量组成。在这种情况下,myFunc
是对运行 makeFunc
时创建的函数 displayName
实例的引用。 displayName
的实例维护对其词法环境的引用,其中存在变量名。因此,当调用 myFunc
时,变量名仍然可用,并且“Mozilla”被传递给 console.log
。
再看一个例子加深理解
function makeAdder(x) {
return function (y) {
return x + y;
};
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);
console.log(add5(2)); // 7
console.log(add10(2)); // 12
在这个例子中,我们定义了一个函数 makeAdder(x)
,它接受一个参数 x
,并返回一个新函数。这个函数接受一个参数 y
,并返回 x
和 y
的和。
本质上,makeAdder
是一个函数工厂。它创建可以为其参数添加特定值的函数。
在上面的示例中,函数工厂创建了两个新函数——一个将 5
添加到其参数中,另一个将添加 10
。
add5
和 add10
都是闭包,它们共享相同的函数体定义,但存储不同的词法环境。在 add5
的词法环境中,x
为 5,而在 add10
的词法环境中,x
为 10。
闭包的作用
闭包很有用,因为它们允许您将数据(词法环境)与对该数据进行操作的函数相关联。这与面向对象编程有明显的相似之处,其中对象允许您将数据(对象的属性)与一个或多个方法相关联。
因此,您可以在通常使用只有一个方法的对象的任何地方使用闭包。
您可能想要执行此操作的情况在网络上特别常见。用前端 JavaScript 编写的大部分代码都是基于事件的。您定义一些行为,然后将其附加到由用户触发的事件(例如单击或按键)。代码作为回调(响应事件而执行的单个函数)附加。
例如,假设我们想在页面上添加按钮来调整文本大小。这样做的一种方法是指定正文元素的字体大小(以像素为单位),然后使用相对 em
单位设置页面上其他元素(例如标题)的大小:
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 12px;
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.2em;
}
这种交互式文本大小按钮可以更改 body 元素的 font-size 属性,并且由于相对单位,页面上的其他元素会接受调整。
function makeSizer(size) {
return function () {
document.body.style.fontSize = `${size}px`;
};
}
const size12 = makeSizer(12);
const size14 = makeSizer(14);
const size16 = makeSizer(16);
size12
, size14
, and size16
are now functions that resize the body text to 12
, 14
, and 16
pixels, respectively. You can attach them to buttons (in this case hyperlinks) as demonstrated in the following code example.
document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;
<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>
闭包模拟私有化方法
Java 等语言允许您将方法声明为私有,这意味着它们只能由同一类中的其他方法调用。
JavaScript,在ES6 class之前,没有声明私有方法的本地方式,但是可以使用闭包来模拟私有方法。私有方法不仅对限制对代码的访问有用。它们还提供了一种管理全局命名空间的强大方法。
以下代码说明了如何使用闭包来定义可以访问私有函数和变量的公共函数。请注意,这些闭包遵循模块设计模式。
const counter = (function () {
let privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment() {
changeBy(1);
},
decrement() {
changeBy(-1);
},
value() {
return privateCounter;
},
};
})();
console.log(counter.value()); // 0.
counter.increment();
counter.increment();
console.log(counter.value()); // 2.
counter.decrement();
console.log(counter.value()); // 1.
在前面的例子中,每个闭包都有自己的词法环境。但是,这里有一个由三个函数共享的词法环境:counter.increment、counter.decrement 和 counter.value。
共享词法环境是在匿名函数的主体中创建的,该函数在定义后立即执行(也称为 IIFE)。词法环境包含两个私有项:一个名为 privateCounter
的变量和一个名为 changeBy
的函数。您不能从匿名函数外部访问这些私有成员中的任何一个。相反,您可以使用从匿名包装器返回的三个公共函数来访问它们。
这三个公共函数是共享相同词法环境的闭包。由于 JavaScript 的词法作用域,它们每个都可以访问 privateCounter
变量和 changeBy
函数。
const makeCounter = function () {
let privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment() {
changeBy(1);
},
decrement() {
changeBy(-1);
},
value() {
return privateCounter;
},
};
};
const counter1 = makeCounter();
const counter2 = makeCounter();
console.log(counter1.value()); // 0.
counter1.increment();
counter1.increment();
console.log(counter1.value()); // 2.
counter1.decrement();
console.log(counter1.value()); // 1.
console.log(counter2.value()); // 0.
注意两个计数器如何保持彼此的独立性。
每个闭包通过自己的闭包引用不同版本的 privateCounter
变量。每次调用其中一个计数器时,其词法环境都会通过更改此变量的值而改变。一个闭包中变量值的更改不会影响另一个闭包中的值。
注意:以这种方式使用闭包提供了通常与面向对象编程相关的好处。特别是数据隐藏和封装。
闭包作用域链
每个闭包都有三个作用域:
- local
- 封闭范围(可以是块、函数或模块范围)
- global
一个常见的错误是没有意识到在外部函数本身是嵌套函数的情况下,对外部函数作用域的访问包括外部函数的封闭作用域——有效地创建了函数作用域链。为了演示,请考虑以下示例代码。
// global scope
const e = 10;
function sum(a) {
return function (b) {
return function (c) {
// outer functions scope
return function (d) {
// local scope
return a + b + c + d + e;
};
};
};
}
console.log(sum(1)(2)(3)(4)); // log 20
也可以不使用匿名函数来写:
// global scope
const e = 10;
function sum(a) {
return function sum2(b) {
return function sum3(c) {
// outer functions scope
return function sum4(d) {
// local scope
return a + b + c + d + e;
};
};
};
}
const sum2 = sum(1);
const sum3 = sum2(2);
const sum4 = sum3(3);
const result = sum4(4);
console.log(result); //log 20
在上面的例子中,有一系列的嵌套函数,它们都可以访问外部函数的作用域。在这种情况下,我们可以说闭包可以访问所有外部函数范围。
闭包也可以捕获块作用域和模块作用域中的变量。例如,以下代码在块作用域变量 y 上创建了一个闭包:
function outer() {
const x = 5;
if (Math.random() > 0.5) {
const y = 6;
return () => console.log(x, y);
}
}
outer()(); // logs 5 6
模块上的闭包可能更有趣。
// myModule.js
let x = 5;
export const getX = () => x;
export const setX = (val) => {
x = val;
}
在这里,模块导出了一对 getter
、setter
函数,它们关闭了模块范围的变量 x
。即使 x
不能从其他模块直接访问,也可以使用函数对其进行读写。
import { getX, setX } from "./myModule.js";
console.log(getX()); // 5
setX(6);
console.log(getX()); // 6
闭包也可以关闭导入的值,这被视为实时绑定,因为当原始值更改时,导入的值也会相应更改。
// myModule.js
export let x = 1;
export const setX = (val) => {
x = val;
}
// closureCreator.js
import { x } from "./myModule.js";
export const getX = () => x; // Close over an imported live binding
import { getX } from "./closureCreator.js";
import { setX } from "./myModule.js";
console.log(getX()); // 1
setX(2);
console.log(getX()); // 2
闭包的常见错误
在引入 let 关键字之前,在循环中创建闭包时会出现一个常见问题。为了演示,请考虑以下示例代码。
<p id="help">Helpful notes will appear here</p>
<p>E-mail: <input type="text" id="email" name="email" /></p>
<p>Name: <input type="text" id="name" name="name" /></p>
<p>Age: <input type="text" id="age" name="age" /></p>
function showHelp(help) {
document.getElementById('help').textContent = help;
}
function setupHelp() {
var helpText = [
{ id: 'email', help: 'Your e-mail address' },
{ id: 'name', help: 'Your full name' },
{ id: 'age', help: 'Your age (you must be over 16)' },
];
for (var i = 0; i < helpText.length; i++) {
// Culprit is the use of `var` on this line
var item = helpText[i];
document.getElementById(item.id).onfocus = function () {
showHelp(item.help);
};
}
}
setupHelp();
helpText
数组定义了三个有用的提示,每个提示都与文档中输入字段的 ID
相关联。循环遍历这些定义,将 onfocus
事件连接到每个显示相关帮助方法的事件。
如果您尝试使用此代码,您会发现它没有按预期工作:无论选中哪个文本框,都会显示有关年龄的消息。
原因是分配给 onfocus
的函数是闭包;它们由 setupHelp
函数范围内的函数定义和捕获的环境组成,循环创建了三个闭包。
但每个闭包都共享同一个词法环境,其中有一个变量(项)具有变化的值。
这是因为变量 item
是用 var
声明的,因此由于提升而具有函数范围。
item.help
的值是在执行 onfocus
回调时确定的。因为此时循环已经运行,所以 item 变量对象(由所有三个闭包共享)一直指向 helpText
列表中的最后一个条目。
在这种情况下,一种解决方案是使用更多的闭包,特别是使用前面描述的函数工厂:
function showHelp(help) {
document.getElementById('help').textContent = help;
}
function makeHelpCallback(help) {
return function () {
showHelp(help);
};
}
function setupHelp() {
var helpText = [
{ id: 'email', help: 'Your e-mail address' },
{ id: 'name', help: 'Your full name' },
{ id: 'age', help: 'Your age (you must be over 16)' },
];
for (var i = 0; i < helpText.length; i++) {
var item = helpText[i];
document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
}
}
setupHelp();
如果不想使用更多的闭包,可以使用 let
或 const
关键字:
function showHelp(help) {
document.getElementById('help').textContent = help;
}
function setupHelp() {
const helpText = [
{ id: 'email', help: 'Your e-mail address' },
{ id: 'name', help: 'Your full name' },
{ id: 'age', help: 'Your age (you must be over 16)' },
];
for (let i = 0; i < helpText.length; i++) {
const item = helpText[i];
document.getElementById(item.id).onfocus = () => {
showHelp(item.help);
};
}
}
setupHelp();
此示例使用 const
而不是 var
,因此每个闭包都绑定块范围的变量,这意味着不需要额外的闭包。
另一种替代方法是使用 forEach()
来遍历 helpText
数组并将侦听器附加到每个 <input>
,如下所示:
function showHelp(help) {
document.getElementById('help').textContent = help;
}
function setupHelp() {
var helpText = [
{ id: 'email', help: 'Your e-mail address' },
{ id: 'name', help: 'Your full name' },
{ id: 'age', help: 'Your age (you must be over 16)' },
];
helpText.forEach(function (text) {
document.getElementById(text.id).onfocus = function () {
showHelp(text.help);
};
});
}
setupHelp();
性能问题
如前所述,每个函数实例都管理自己的作用域和闭包。
如果特定任务不需要闭包,就没必要在函数里面创建函数;因为它会在处理速度和内存消耗方面对脚本性能产生负面影响。
例如,当创建一个新的对象/类时,方法通常应该与对象的原型相关联,而不是定义在对象构造函数中。原因是每当调用构造函数(创建对象)时,方法都会被重新分配
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
this.getName = function () {
return this.name;
};
this.getMessage = function () {
return this.message;
};
}
因为前面的代码没有利用在这个特定实例中使用闭包的好处,我们可以改写它以避免使用闭包,如下所示:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
}
MyObject.prototype.getName = function () {
return this.name;
};
MyObject.prototype.getMessage = function () {
return this.message;
};
继承的原型可以被所有对象共享,并且方法定义不必在每次创建对象时都出现。