JavaScript 的闭包用于什么场景
本文翻译自 MDN ( Mozilla Developer Network ):
- 原文地址:MDN
- 译文地址:shixinzhang 的博客
读完本文你将了解到:
词法作用域
考虑如下代码:
function init() {
var name = 'Mozilla'; // name 是 init 函数创建的局部变量
function displayName() { // displayName() 是函数内部方法,一个闭包
alert(name); // 它使用了父函数声明的变量
}
displayName();
}
init();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
init()
函数创建了本地变量 name
和函数 displayName()
。
displayName()
是定义在 init()
内部的内部函数,因此只能在 init()
函数内被访问。 displayName()
没有内部变量,但是由于内部函数可以访问外部函数的变量, displayName()
可以访问 init()
中的变量 name
。
运行上述代码,我们可以看到 name
的值成功地被打印出来。
这是“词法作用域”(其描述了 JS 解析器如何处理嵌套函数中的变量)的一个例子。
词法作用域是指一个变量在源码中声明的位置作为它的作用域。同时嵌套的函数可以访问到其外层作用域中声明的变量。
闭包
现在看一下下面的代码:
function makeFunc() {
var name = 'Mozilla';
function displayName() {
alert(name);
}
return displayName;
}
var myFunc = makeFunc();
myFunc();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
运行上面的代码会和第一个例子有同样的结果。不同的是 - 同时很有趣的是- 内部函数 displayName()
在执行前先被外部函数作为返回值返回了。
乍一看,这个代码虽然能执行却并不直观。在一些编程语言中,函数内的局部变量只在函数执行期间存活。一旦 makeFunc()
函数执行完毕,你可能觉得 name
变量就不能存在了。然而,从代码的运行结果来看,JavaScript 跟我们前面说到的“一些编程语言”关于变量明显有不同之处。
上面代码的“不同之处”就在于,makeFunc()
返回了一个闭包。
闭包由函数和它的词法环境组成。这个环境指的是函数创建时,它可以访问的所有变量。在上面的例子中,myFunc
引用了一个闭包,这个闭包由 displayName()
函数和闭包创建时存在的 “Mozilla” 字符串组成。由于 displayName()
持有了 name
的引用,myFunc
持有了 displayName()
的引用,因此 myFunc
调用时,name
还是处于可以访问的状态。
下面是一个更有趣的例子:
function makeAdder(x) {
return function(y) {
return x + y;
};
}
var add5 = makeAdder(5);
var add10 = makeAdder(10);
console.log(add5(2)); // 7
console.log(add10(2)); // 12
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
上面的例子中,makeAdder()
接受一个参数 x,然后返回一个函数,它的参数是 y,返回值是 x+y。
本质上,makeAdder()
是一个函数工厂 — 为它传入一个参数就可以创建一个参数与其他值求和的函数。
上面的例子中我们使用函数工厂创建了两个函数,一个将会给参数加 5,另一个加 10。
add5
和 add10
都是闭包。他们使用相同的函数定义,但词法环境不同。在 add5 中,x 是 5;add10 中 x 是 10。
闭包实战场景之回调
闭包有用之处在于它可以将一些数据和操作它的函数关联起来。这和面向对象编程明显相似。在面对象编程中,我们可以将某些数据(对象的属性)与一个或者多个方法相关联。
因此,当你想只用一个方法操作一个对象时,可以使用闭包。
在 web 编程时,你使用闭包的场景可能会很多。大部分前端 JavaScript 代码都是“事件驱动”的:我们定义行为,然后把它关联到某个用户事件上(点击或者按键)。我们的代码通常会作为一个回调(事件触发时调用的函数)绑定到事件上。
比如说,我们想要为一个页面添加几个用于调整字体大小的按钮。一种方法是以像素为单位指定 body 元素的 font-size,然后通过相对的 em 单位设置页面中其它元素(例如页眉)的字号。
这里是 CSS 代码:
body {
font-family: Helvetica, Arial, sans-serif;
font-size: 12px;
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.2em;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
我们修改字体尺寸的按钮可以修改 body 元素的 font-size 属性,而由于我们使用相对单位,页面中的其它元素也会相应地调整。
HTML 代码:
<p>Some paragraph text</p>
<h1>some heading 1 text</h1>
<h2>some heading 2 text</h2>
<a href="#" id="size-12">12</a>
<a href="#" id="size-14">14</a>
<a href="#" id="size-16">16</a>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 1
- 2
- 3
- 4
- 5
- 6
- 7
JavaScript 代码:
function makeSizer(size) {
return function() {
document.body.style.fontSize = size + 'px';
};
}
var size12 = makeSizer(12);
var size14 = makeSizer(14);
var size16 = makeSizer(16);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
size12, size14, 和 size16 现在可以分别调整 body 的字体到 12, 14, 16 像素。我们接下来可以把它们绑定到按钮上:
document.getElementById('size-12').onclick = size12;
document.getElementById('size-14').onclick = size14;
document.getElementById('size-16').onclick = size16;
- 1
- 2
- 3
- 1
- 2
- 3
现在分别点击几个按钮,整个页面的字体都会调整。
用闭包模拟私有方法
一些编程语言,比如 Java,可以创建私有方法(只能被同一个类中的其他方法调用的方法)。
JavaScript 不支持这种方法,但是我们可以使用闭包模拟实现。私有方法不仅可以限制代码的访问权限,还提供了管理全局命名空间的强大能力,避免非核心的方法弄乱了代码的公共接口。
下面的代码说明了如何使用闭包定义能访问私有函数和私有变量的公有函数。这种方式也叫做模块模式:
var counter = (function() {
var privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
};
})();
console.log(counter.value()); // logs 0
counter.increment();
counter.increment();
console.log(counter.value()); // logs 2
counter.decrement();
console.log(counter.value()); // logs 1
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
之前的例子中,每个闭包都有其独自的词法环境。但是这个例子中,三个方法 counter.value()
, counter.increment()
和 counter.decrement()
共享一个词法环境。
这个共享的环境创建于一个匿名函数体内,该函数一经定义就立刻执行。环境中包含两个私有项:名为 privateCounter 的变量和名为 changeBy 的函数。 它俩都无法在匿名函数外部直接访问。必须通过匿名包装器返回的对象的三个公共函数访问。
多亏了 JavaScript 的词法作用域,这三个函数可以访问 privateCounter 和 changeBy(),使得它们三个闭包共享一个环境。
你可能注意到,上述代码中我们在匿名函数中创建了 privateCounter,然后立即执行了这个函数,为 privateCounter 赋了值,然后将结果返回给 counter。
我们也可以将这个函数保存到另一个变量中,以便创建多个计数器。
var makeCounter = function() {
var privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return {
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
};
var counter1 = makeCounter();
var counter2 = makeCounter();
alert(counter1.value()); /* Alerts 0 */
counter1.increment();
counter1.increment();
alert(counter1.value()); /* Alerts 2 */
counter1.decrement();
alert(counter1.value()); /* Alerts 1 */
alert(counter2.value()); /* Alerts 0 */
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
现在两个计数器 counter1, counter2 持有不同的词法环境,它俩有各自的 privateCounter 与值。调用其中一个计数器,不会影响另一个的值。
这样使用闭包可以提供很多面向对象编程里的好处,比如数据隐藏和封装。
常见的错误:在循环中创建闭包
在 ECMAScrpit 2015 以前,还没有 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>
- 1
- 2
- 3
- 4
- 1
- 2
- 3
- 4
function showHelp(help) {
document.getElementById('help').innerHTML = help;
}
function setupHelp() {
var helpText = [
{'id': 'email', 'help': 'Your e-mail address'},
{'id': 'name', 'help': 'Your full name'},
{'id': 'age', 'help': 'Your age'}
];
for (var i = 0; i < helpText.length; i++) {
var item = helpText[i]; //var 声明的变量,它的作用域在函数体内,而不是块内
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}
setupHelp();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
上述代码中,helpText
是三个 id 与提示信息关联对象的数组。在循环中,我们遍历了 helpText 数组,为数组中的 id 对应的组件添加了聚焦事件的响应。
如果你运行上面的代码,就会发现,不论你选择哪个输入框,最终显示的提示信息都是 “Your age”。
原因就是:我们赋值给 onfocus
事件的是三个闭包。这三个闭包由函数和 setUpHelp()
函数内的环境组成。
循环中创建了三个闭包,但是它们都使用了相同的词法环境 item,item 有一个值会变的变量 item.help。
当 onfocus
的回调执行时,item.help 的值才确定。那时循环已经结束,三个闭包共享的 item 对象已经指向了 helpText 列表中的最后一项。
这种问题的解决方法之一就是使用更多的闭包,比如使用之前提到的函数工厂:
function showHelp(help) {
document.getElementById('help').innerHTML = 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();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
这样运行结果就正确了。不像前面的例子,三个回调共享一个词法环境,上面的代码中,使用 makeHelpCallback()
函数为每一个回调创建了一个新的词法环境。在这些环境中,help 指向 helpText 数组中正确对应的字符串。
使用匿名函数解决这个问题的另外一种写法是这样的:
function showHelp(help) {
document.getElementById('help').innerHTML = 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++) {
(function() {
var item = helpText[i];
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
})(); // 立即调用绑定函数,使用正确的值绑定到事件上;而不是使用循环结束的值
}
}
setupHelp();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
如果你不想使用更多的闭包,也可以使用 ES2015 中介绍的块级作用域 let
关键字:
function showHelp(help) {
document.getElementById('help').innerHTML = 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++) {
let item = helpText[i]; //限制作用域只在当前块内
document.getElementById(item.id).onfocus = function() {
showHelp(item.help);
}
}
}
setupHelp();
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
上面的代码使用 let 而不是 var 修饰了变量 item,因此每个闭包绑定的是当前块内的变量。不需要额外的闭包。
注意性能
在不是必需的情况下,在其它函数中创建函数是不明智的。因为闭包对脚本性能具有负面影响,包括处理速度和内存消耗。
比如,在创建新的对象或者类时,方法通常应该关联到对象的原型,而不是定义到对象的构造器中。因为这将导致每次构造器被调用,方法都会被重新赋值一次(也就是说,创建每一个对象时都会重新为方法赋值)。
举个例子:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
this.getName = function() {
return this.name;
};
this.getMessage = function() {
return this.message;
};
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
上面的代码没有利用闭包的优点,我们可以把它改写成这样:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
}
MyObject.prototype = {
getName: function() {
return this.name;
},
getMessage: function() {
return this.message;
}
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
然而一般来说,不建议重定义原型。
下面的代码将属性添加到已有的原型上:
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;
};
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
但是,我们还可以将上面的代码简写成这样:
function MyObject(name, message) {
this.name = name.toString();
this.message = message.toString();
}
(function() {
this.getName = function() {
return this.name;
};
this.getMessage = function() {
return this.message;
};
}).call(MyObject.prototype);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
在前面的三个示例中,继承的原型可以为所有对象共享,且不必在每一次创建对象时重新定义方法。