三、闭包

Abstract

“无论你走到哪里,都有你。”

“无论你去哪里,你都在那里。”——牛仔万岁

本章的目的是用简单的英语解释闭包是如何工作的,并给出几个引人注目的例子,说明闭包的使用确实提高了代码的质量。同时,您还将探索 ECMAScript 6 中的任何改进是否意味着闭包不需要成为 JavaScript 的瑞士军刀。

和很多人一样,我是一个自学成才的程序员。十多年前,我也是一名在洛杉矶工作的创意总监。我受雇于一家大公司,继承了一个由非常聪明、技术天才的程序员组成的团队。我觉得我需要学习足够多的代码来智能地和他们说话。我不想提出一个不可能的特性,但更重要的是,我想了解我们正在构建的媒体中固有的承诺和问题。然而,更普遍的是,我只是一个非常好奇的人,喜欢学习,一旦我开始使用 JavaScript,编程的世界就开始对我开放了。多年后的今天,我坐在这里写关于语言内部的文章,希望将这条线索传递给你。

由于我的计算机科学教育是临时性的,所以我想更好地理解 JavaScript(以及一般的编程)中的许多核心概念。我的假设是,有其他人和我一样多年来一直在使用和滥用 JavaScript。出于这个原因,我决定写闭包,这是 JavaScript 中一个经常使用但又容易被误解的概念。闭包很重要,原因有很多:

  • 它们既是一种特性,也是一种理念,一旦理解,JavaScript 中的许多其他概念(例如,数据绑定、异步编程和承诺对象)就会变得更加容易。
  • 它们是语言中最强大的组件之一,而许多其他所谓的真正语言并不支持它们。
  • 正确使用时,它们为开发人员提供了一种机制,使他们的代码更具表现力、更紧凑和可重用。

尽管闭包提供了所有潜在的好处,但它们有一种不可思议的特性,让人很难理解。让我们从一个定义开始:

  • 闭包是将所有自由变量和函数绑定到一个封闭表达式中的行为,该表达式在创建它们的词法范围之外持续存在。

尽管这是一个简洁的定义,但对于门外汉来说,它是相当难以理解的;让我们深入了解一下。

瞄准镜上的直接涂料

在真正理解闭包之前,您必须后退一步,看看 JavaScript 中的作用域是如何工作的。JavaScript 的作者有时会提到词法范围,或者当前和/或执行范围。

词法范围仅仅意味着语句在代码体中的位置很重要。语句的位置会影响访问方式,进而影响访问内容。在 ES 6 发布之前,JavaScript 只能通过函数调用来创建新的作用域。 1 这个事实经常让习惯于块级作用域的开发人员感到困惑,这是许多其他语言的标准。下面的示例演示了词法范围:

// Free Variable

var iAmFree = 'Free to be me!';

function canHazAccess(notFree){

var notSoFree = "i am bound to this scope";

// => "Free to be me!"

console.log(iAmFree);

}

// => ReferenceError: notSoFree is not defined

console.log(notSoFree)

canHazAccess();

如您所见,函数声明canHazAccess()可以引用iAmFree变量,因为该变量属于封闭范围。iAmFree变量是 JavaScript 中所谓的自由变量的一个例子。 2 自由变量是函数体可以访问的任何非局部变量。要成为自由变量,它必须在函数体之外定义,并且不能作为函数参数传递。

相反,从封闭范围之外引用notSoFree会产生错误,因为在定义该变量时,它在新的词法范围内。(记住,在 ES 6 之前,函数调用创建了一个新的作用域。)

函数级作用域就像单向镜子;它们让函数体内的元素监视外部作用域中的变量,同时保持隐藏。正如您将看到的,闭包缩短了这种关系,并提供了一种机制,通过这种机制,外部作用域可以访问内部作用域。

这种理解

scope 的一个经常让开发人员(甚至是经验丰富的开发人员)感到困惑的特性是使用关键字this,因为它与词法范围有关。在 JavaScript 中,this关键字总是指脚本执行范围的所有者。误解this的工作方式会导致各种奇怪的错误,开发人员认为他们正在访问一个特定的作用域,但实际上是在使用另一个。这可能是这样发生的:

var Car, tesla;

Car = function() {

this.start = function() {

console.log("car started");

};

this.turnKey = function() {

var carKey = document.getElementById('car_key');

carKey.onclick = function(event) {

this.start();

};

};

return this;

};

tesla = new Car();

// Once a user clicks the #carKey element they will see "Uncaught TypeError: Object has no method 'start'"

tesla.turnKey();

写这篇文章的开发人员正朝着正确的方向前进,但最终这种理解迫使他们偏离了轨道。他们正确地将点击事件绑定到了car_key DOM 元素。然而,他们假设在 car 类中嵌套 click 绑定会给 DOM 元素一个对汽车的this上下文的引用。这种方法很直观,看起来也很合法,尤其是基于我们对自由变量和词法范围的了解。不幸的是,它无可救药地坏掉了;因为正如我们前面所学的,每次调用一个函数都会创建一个新的作用域。一旦onclick事件被触发this现在指的是 DOM 元素而不是汽车类。

开发人员有时会通过将它赋给一个局部自由变量(例如,that, _this, self, me)来避免这种范围混乱。下面是之前重写的方法,使用局部自由变量代替 this 变量:

var Car, tesla;

Car = function() {

this.start = function() {

console.log("car started");

};

this.turnKey = function() {

var that = this;

var carKey = document.getElementById('carKey');

carKey.onclick = function(event) {

that.start();

};

};

return this;

};

tesla = new Car();

// Once a user click's the #carKey element they will see "car started"

tesla.turnKey();

因为that是一个自由变量,所以触发 onclick 事件时不会重新定义。相反,它仍然是指向前一个this上下文的指针。从技术上讲,将this强制转换为局部变量解决了这个问题,我将抑制住称之为反模式的冲动(目前如此)。这些年来,我已经数千次使用这种技术。然而,这总感觉像是一个黑客,幸运的是,闭包可以帮助我们以一种更优雅的方式封送作用域。

让有块范围

ES 6 引入了两种新的变量类型,“T0”和“T1”,这两种类型都允许开发人员使用块级范围。这是一个巨大的改进,因为它消除了变量提升如何应用的一些模糊性,并使 JavaScript 更容易理解。考虑下面的例子,它展示了块作用域在 Ruby 中是如何工作的:

10.times do |x|

foo = 'bar'

end

# => undefined local variable or methodfoo' for main:Object (NameError)`

puts foo

在下面的例子中,Ruby 解释器在试图引用 loop 语句外的局部变量foo时发生了爆炸,因为 Ruby 使用了块级作用域。然而,在 JavaScript 中,变量被愉快地返回到循环块之外:

for (var x = 0; x < 10; x++){

var foo = "bar";

}

// => 'bar'

console.log(foo);

JavaScript 的函数级局部变量作用域意味着在幕后解释器实际上将变量提升到块之外。实际得到的解释看起来更像这样:

var x, foo;

for (x = 0; x < 10; x++) {

foo = "bar";

}

// => 'bar'

console.log(foo);

随着let声明的引入,JavaScript 现在可以使用真正的块级范围。这里有一个例子:

for (var x = 0; x < 10; x++) {

let foo = "bar";

// => bar

console.log(foo);

}

// => ReferenceError: foo is not defined

console.log(foo);

这些新声明的引入不仅使理解块作用域的程序员对 JavaScript 更加清楚,而且有助于编译器提高运行时性能。

现在您已经理解了 JavaScript 中的作用域是如何工作的,您可以继续探索闭包。

我的第一次结案陈词

在其最基本的形式中,闭包只是一个返回内部函数的外部函数。这样做可以创建一种机制,根据需要返回封闭的范围。下面是一个简单的闭包:

function outer(name) {

var hello = "hi"

inner;

return inner = function() {

return hello + " " + name;

};

}

// Create and use the closure

var name = outer("mark")();

// => 'hi mark'

console.log(name);

正如您在上一章中了解到的,JavaScript 引入了一种新的函数样式:所谓的胖箭头。让我们用粗箭头重写前面的例子:

var outer (name) => {

var hello = "hi"

inner;

inner => hello + " " + name;

}

var name = outer("mark")();

// => 'hi mark'

console.log(name);

在这两个例子中,可以看到局部变量hello可以用在内部函数的 return 语句中。在执行点,hello是一个属于封闭范围的自由变量。不过,这个例子几乎没有意义,所以让我们来看一个稍微复杂一点的闭包:

var car;

function carFactory(kind) {

var wheelCount, start;

wheelCount = 4;

start = function() {

console.log('started with ' + wheelCount + ' wheels.');

};

// Closure created here.

return (function() {

return {

make: kind

wheels: wheelCount

startEngine: start

};

}());

}

car = carFactory('Tesla');

// => Tesla

console.log(car.make);

// => started with 4 wheels.

car.startEngine();

为什么使用闭包?

现在您已经对闭包有了基本的定义,让我们看看一些用例,看看它们在哪些地方可以优雅地解决 JavaScript 中的常见问题。

对象工厂

前面的闭包实现了通常所说的工厂模式。为了与工厂模式保持一致,工厂的内部可能相当复杂,但是由于某种程度上的封闭性,它们被抽象掉了。这突出了闭包的最佳特性之一:隐藏状态的能力。JavaScript 没有私有或受保护上下文的概念,但是使用闭包给了我们一个很好的方法来模拟某种程度的隐私。

创建绑定代理

如前所述,让我们重温一下前面的Car类。通过将外部函数的this引用分配给一个that自由变量,作用域问题得到了解决。代替这种方法,我们将通过使用闭包来解决它。首先,创建一个名为proxy的可重用闭包函数,它接受一个函数和一个上下文,并返回一个应用了所提供的上下文的新函数。然后用代理包装onclick函数,并传入this,它引用了Car类的当前实例。巧合的是,这是 jQuery 在自己的代理函数中所做工作的简化版本: 4

var Car, proxy, tesla;

Car = function() {

this.start = function() {

return console.log("car started");

};

this.turnKey = function() {

var carKey;

carKey = document.getElementById("carKey");

carKey.onclick = proxy(function(event) {

this.start();

}, this);

};

return this;

};

// Use a closure to bind the outer scope's reference to this into the newly created inner scope.

proxy = function(callback, self) {

return function() {

return callback.apply(self, arguments);

};

};

tesla = new Car();

// Once a user click's the #carKey element they will see "car started"

tesla.turnKey();

Note

ES 5 引入了一个bind函数,作为您的绑定代理。前面的例子只是用来详细探索绑定代理是如何工作的。但是,在生产代码中,您应该遵从本机 Function.prototype.bind 接口。

上下文感知的 DOM 操作

这个例子直接来自 Juriy Zaytsev 的优秀文章“JavaScript 闭包的用例”他的示例代码演示了如何使用闭包来确保 DOM 元素具有惟一的 ID。更重要的是,您可以使用闭包来以封装的方式维护程序的内部状态。

var getUniqueId = (function() {

var id = 0;

return function(element) {

if (!element.id) {

element.id = 'generated-uid-' + id++;

}

return element.id;

};

})();

var elementWithId = document.createElement('p');

elementWithId.id = 'foo-bar';

var elementWithoutId = document.createElement('p');

// => 'foo-bar'

getUniqueId(elementWithId);

// => 'generated-id-0'

getUniqueId(elementWithoutId);

单一模块模式

模块用于封装和组织相关的代码。使用模块可以让你的代码库更干净,更容易测试和重用。模块模式通常被认为是理查德·孔福尔德、 6 的功劳,尽管有许多人,最著名的是道格拉斯·克洛克福特,负责推广它。单例模块是一种限制对象存在多个实例的风格。当您希望几个对象共享一个资源时,这非常有用。单例模块的一个更深入的例子可以在这里找到, 7 但是现在,考虑下面的例子:

// Create a closure

var SecretStore = (function() {

var data, secret, newSecret;

// Emulation of a private variables and functions

data = 'secret';

secret = function() {

return data;

}

newSecret = function(newValue) {

data = newValue;

return secret();

}

// Return an object literal which is the only way to access the private data.

return {

getSecret: secret

setSecret: newSecret

};

})();

var secret = SecretStore;

// => "secret"

console.log(secret.getSecret());

// => "foo"

console.log(secret.setSecret("foo"));

// => "foo"

console.log(secret.getSecret());

var secret2 = SecretStore;

// => "foo"

console.log(secret2.getSecret());

摘要

在这一章中,你学习了 JavaScript 闭包的黑暗艺术。闭包是 JavaScript 中最容易被误解的概念之一,因为它们涉及到语言中许多不太为人所知的细节,包括自由变量、词法范围和函数级范围。

闭包是强大的,因为它们允许自由变量在其词法范围之外持久化。然而,它们经常很容易被错误地创建,并可能导致对this操作符如何工作的误解。随着 ES 6 中块级范围的引入,这种不确定性至少在短期内可能会增加。

Footnotes 1

T2http://howtonode.org/what-is-this

2

T2http://en.wikipedia.org/wiki/Free_variable

3

T2http://en.wikipedia.org/wiki/Factory_method_pattern

4

T2https://github.com/jquery/jquery/blob/master/src/core.js#L685

5

T2http://msdn.microsoft.com/en-us/magazine/ff696765.aspx

6

T2http://groups.google.com/group/comp.lang.javascript/msg/9f58bd11bd67d937

7

T2http://www.addyosmani.com/resources/essentialjsdesignpatterns/book/#singletonpatternjavascript