JavaScript 专家级编程(二)
三、闭包
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 引入了两种新的变量类型,“??”和“??”,这两种类型都允许开发人员使用块级范围。这是一个巨大的改进,因为它消除了变量提升如何应用的一些模糊性,并使 JavaScript 更容易理解。考虑下面的例子,它展示了块作用域在 Ruby 中是如何工作的:
10.times do |x|
foo = 'bar'
end
# => undefined local variable or method foo' 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
http://howtonode.org/what-is-this
2
http://en.wikipedia.org/wiki/Free_variable
3
http://en.wikipedia.org/wiki/Factory_method_pattern
4
https://github.com/jquery/jquery/blob/master/src/core.js#L685
5
http://msdn.microsoft.com/en-us/magazine/ff696765.aspx
6
http://groups.google.com/group/comp.lang.javascript/msg/9f58bd11bd67d937
7
http://www.addyosmani.com/resources/essentialjsdesignpatterns/book/#singletonpatternjavascript
四、行话和俚语
Abstract
冰况有这么多术语的原因之一是观测冰况的水手经常被困在冰中,除了看冰况之外无事可做。
冰况有这么多术语的原因之一是,观察冰况的水手经常被困在冰中,除了看冰况之外无事可做——亚历克·威尔金森,《冰气球:安德烈和北极探险的英雄时代》
几个月前,我偶然看到加里·伯恩哈特的一个演讲,题目就叫“水”Wat 是互联网上的一种口语,用来描述对某个主题的困惑或有趣的怀疑,这里指的是 JavaScript。伯恩哈特的演讲采用了问答的形式。首先,他展示了一行看似合理的 JavaScript 代码,然后让观众给出结果。有一次,他问观众{}+[]会出什么。大多数观众认为结果会是某种错误,因为把文字对象和数组加在一起是没有意义的。结果反而是“0”。观众困惑地呻吟和大笑。演示就这样继续下去,问问题,然后给出似乎错得离谱的结果。
令许多 JavaScript 捍卫者懊恼的是,这个演示像病毒一样传播开来,主要是因为它有趣而轻松,给了 JavaScript 社区一个自嘲的工具。最终,就连 JavaScript 的创始人布伦丹·艾希(Brendan Eich)也加入了这场争论,在最近的一次演讲中,他半心半意地解释了他的语言在伯恩哈特的演讲中做的一些看似愚蠢的事情。
最初,我以为这一章会花在解释 JavaScript 中 Wat 的例子上。然而,随着我对 Bernhardt 演讲中使用的各种例子的深入研究,我开始意识到这些不一致之处并不是语言的缺陷,而是语言内部的秘密握手,一种编程术语。在那一点上,我对这一章的方向改变了,现在的目标是定义术语,因为它与编程有关。我将给出 JavaScript 中行话的例子,如何拥抱它或避免它,取决于你自己的风格。
行话.原型=新俚语( )
在准确定义行话之前,你必须先了解什么构成了俚语。俚语是在一种文化的正常和标准词汇之外使用的词语或表达方式。俚语传递意义的能力取决于接受者对词语或表达中高度语境化的指称进行解读的能力。在努力编纂俚语的过程中,Bethany K. Dumas 和 Jonathan Lighter (Duman & Lighter,1978 年)建议俚语的例子必须满足以下至少两个标准:
- 它降低了“正式或严肃的演讲或写作的尊严”,即使是暂时的。换句话说,在这些情况下,这可能被认为是“对语域的明显滥用”
- 它的使用意味着用户熟悉所指的任何事物,或者熟悉它并使用该术语的一群人。
- "在与社会地位较高或责任较大的人的日常交谈中,这是一个禁忌词。"
- 它取代了“一个众所周知的传统同义词。”这样做主要是为了避免由常规短语或进一步阐述引起的不适。
从这些规则中可以看出,行话只符合第二个标准。然而,即使这样,也足以开始看到可能被称为编程术语的模糊轮廓。
什么是程序化行话?
编程行话是通过使用高度特定的、通常是技术性的语言规则来压缩代码。像其他形式的行话一样,编程形式用于在社区成员之间有效地引用复杂的想法。它可以成为成员间引用复杂概念的一种速记。然而,因为行话是如此高度语境化,它经常充当社区之间的社会分割线或语言边界守卫。这可能就是为什么外行人觉得行话难以理解的原因。了解了这一点,您就可以开始确定定义编程术语的标准了:
- 它缩短了语言的机制。
- 社区中的普通成员很容易混淆或误解它。
- 它破坏了服务中的视觉清晰度和其他目标(例如,更小的代码或更快的执行)。
- 它是一种社区内部分层的手段。
行话名声不好,因为它经常被那些对术语的意思只有一点点概念的人使用。在这种情况下,行话就成了谈话中的噪音,是让说话者看起来更聪明的语言填充物。在编程俚语的情况下,它可能表现为误用或误用编程概念,希望显得聪明。当然,术语的误用会让演讲者看起来像个骗子和白痴。理查德·米切尔总结了这种情绪,他写道:
His jargon hides the hole in his heart for him, but not for us. He used scientific language instead of technology. -Richard Mitchell, "Words Can't Express"
在 JavaScript 中,有三种语言成分特别适合创造行话:强制、逻辑运算符和按位操作(俗称位扭曲)。)现在您已经有了识别编程术语的基础,您将在本章的剩余部分探索和理解它在 JavaScript 中是如何发生的具体例子。
Note
行话通常以贬义的方式使用,描述使用技术术语使说话者看起来聪明或专业。然而,正确使用的行话可以是一个概念的简洁指针,一个有经验的听众不需要解释。在这一章中,行话仅仅意味着高度上下文相关的代码,对于外行人来说通常是难以理解的,但不一定本质上是不好的。
强迫
与大多数其他语言一样,在 JavaScript 中,强制是将一种类型的对象或实体强制转换成另一种类型的行为。这不要与类型转换混淆,类型转换是类型之间的显式转换。在 JavaScript 中,显式类型转换如下所示:
// => "1"
var a = (1).toString();
console.log(a);
但是,也可以通过以下方式将数字隐式转换为字符串:
// => "1"
var a = 1 + "";
console.log(a);
多年来困扰我的许多最神秘的代码示例都在某种程度上涉及了强制。我的大部分困惑是由于 JavaScript 如何处理特定的多态性。如果你回想一下核心概念一章,你会记得这种形式的多态使用执行的上下文来帮助塑造结果。具体来说,JavaScript 使用重载来改变操作符的行为,这取决于它们是如何被调用的。
例如,二元运算符可用于求和或连接,但它也在这个过程中强制值。关于强制的大部分困惑在于知道它是如何或何时发生的。在 JavaScript 中,强制总是将复杂的对象简化为一种基本形式,或者在两种基本类型之间进行转换。不能将数字强制转换为数组,但可以将数组强制转换为数字。以下示例有助于解释 JavaScript 强制值的各种方式。
方法
JavaScript 使用二元运算符将两个值连接在一起。然而,为了实现这一点,JavaScript 首先将零悄悄地强制转换成一个字符串。当 JavaScript 试图将对象转换成字符串时,它首先调用toString()方法。如果toString()没有返回一个原始表示,它就遵从valueOf()函数。如果valueOf()函数也不能产生原始值,JavaScript 抛出一个TypeError异常:
// => '0'
var s = ''+0;
console.log(s);
要编号
一元运算符的工作是将后面的操作数转换成数字。像连接过程一样,它也涉及到将对象强制转换成原始形式,这次是一个数字。这相当于写1*'10'。正如在字符串转换过程中一样,JavaScript 依赖于toString()或valueOf()的结果。然而,顺序是相反的:JavaScript 首先调用valueOf(),然后调用toString()。这里有一个简单的例子:
// => 10
console.log(+'10');
上下文感知强制
许多内置核心对象可以被强制,因此支持一元和二元运算。被强制的对象定制valueOf()和toString()的返回值,使其在上下文中有意义。以内置的Date对象为例。当将对象转换为原始数字时,它返回自 epoch 以来的毫秒数,这对于执行计算非常有用:
// => 1373558473636
console.log(+new Date());
但是,epoch 的字符串表示形式没有那么有用,因此当日期转换为字符串时,对象返回当前日期和时间的文本表示形式:
// => Thu Jul 11 2013 11:01:13 GMT-0500 (CDT)
console.log(new Date() + '');
胁迫抓到你了
了解类型转换的操作顺序应该使您能够为自己的对象创建有意义的转换值。这样,当您的对象被强制时,就像内置的Date对象一样,它可以返回一个上下文感知的结果。然而,正如您将在下面的代码中看到的那样,这实际上比乍看起来更难做到:
var Money = function (val, sym) {
this.currencySymbol = sym;
this.cents = val;
};
var dollar = new Money(100, '$');
// Not helpful
// => NaN
console.log(+dollar);
// Not helpful
// => Total: [object Object]
console.log("Total: " + dollar);
Money.prototype.toString = function () {
return this.currencySymbol + (this.cents / 100).toFixed(2);
};
Money.prototype.valueOf = function () {
return this.cents;
};
// Helpful!
// => 100
console.log(+dollar);
// Wait what?! I wanted $1.00
// => 100
console.log(dollar + '');
// Now I am totally confused!
// => $1.00
console.log([dollar] + '');
转换发生的顺序似乎与您在Date示例中学到的不一致。要得到答案,您需要看看 JavaScript 在将这个对象强制转换成String时采取的步骤。这里,操作符过载再次成为问题。您可能会认为,因为您正在连接一个字符串,JavaScript 将使用toString()而不是valueOf(),就像它对Date对象所做的那样。下面是规范中关于类型转换的描述:
The abstract operation ToPrimitive accepts an input parameter and an optional parameter PreferredType. The abstract ToPrimitive operation converts its input parameters to non-object types. If an object can be converted into several basic types, it can use the optional prompt PreferredType to support that type.
在这种情况下,对象的转换遵循以下顺序:
Returns the default value of the object. You can retrieve the default value of the object by calling the [[DefaultValue]] internal method of the object and passing the optional prompt PreferredType. For all local ECMAScript objects in 8.12.8, this specification defines the behavior of [[DefaultValue]] internal methods.
所以看起来你需要明白DefaultValue是如何在对象中导出的。深入研究规范,您会发现 JavaScript 有两种确定DefaultValue的方法:一种是字符串,另一种是数字。它根据提供给DefaultValue方法的hint参数做出这个决定。如果没有提供hint,JavaScript 默认为一个Number。下面是一个假想版本的ToPrimitive()方法的样子:
var ToPrimitive;
ToPrimitive = function (obj) {
var funct, functions, val, _i, _len;
functions = ["valueOf", "toString"];
if (typeof obj === "object") {
if (obj instanceof Date) {
functions = ["toString", "valueOf"];
}
for (_i = 0, _len = functions.length; _i < _len; _i++) {
funct = functions[_i];
if (typeof obj[funct] === "function") {
val = obj[funct]();
if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
return val;
}
}
}
throw new Error("DefaultValue is ambigious.");
}
return obj;
};
// => 1 (as string)
console.log(ToPrimitive([1]));
// => Thu Jul 11 2013 15:55:11 GMT-0500 (CDT)
console.log(ToPrimitive(new Date()));
现在您明白了为什么对象的串联不能使用自定义的toString()方法:因为没有为内部的DefaultValue函数指定提示,JavaScript 就认为您需要一个数字。这导致了对valueOf()的调用。您现在需要做的就是找出如何将提示设置为一个字符串,就像内置的Date对象一样。不幸的是,没有办法为自定义对象指定提示!在DefaultValue方法描述的底部,你会发现这个警告:
When calling the internal method of O [[DefaultValue]] without prompt, it behaves as if the prompt is a number, unless O is a date object (see 15.9.6), in which case it behaves as if the prompt is a string. The above [[DefaultValue]] specification for native objects can only return the original value. If the host object implements its own [[DefaultValue]] internal method, it must ensure that its [[DefaultValue]] internal method can only return the original value.
您现在已经发现了 JavaScript 中的一个无法回避的限制(至少不是以优雅的方式)。由于没有内置的方式来指定对DefaultValue函数的提示,对象不能像Date对象那样偏好toString()。然而,并没有失去一切;如果您参考前面的例子,您会发现您最终找到了让dollar对象以您想要的方式连接的方法。奇怪的是,如果您首先将对象包装在一个数组中,它会工作。只有这样,JavaScript 才会使用toString()方法正确地强制值,但是为什么呢?这里有一个提示:
// => object
console.log(typeof [1].valueOf());
// => string
console.log(typeof [1].toString())
你想明白了吗?记住ToPrimitive的规则说函数必须返回一个原始值。然而,数组的valueOf()方法返回一个对象,这导致ToPrimitive函数继续运行并调用toString()。对toString()的后续调用确实返回了所需的原始值。在内部,数组的toString()函数必须遍历其集合中的所有元素,并对每个元素调用toString()。这个理论很容易检验;您可以简单地将一个对象推入一个不能被强制转换为字符串的数组中:
var noConversions = [{
toString: undefined
}];
// => Uncaught TypeError: Cannot convert object to primitive value
console.log(noConversions + '');
不出所料,尝试的强制会引发错误。
通过强制进行混合类型比较
到目前为止,我一直在谈论强制,因为它适用于求和或连接的类型转换。但是,在执行相等测试之前,equals 运算符也会将操作数强制转换为原始值。考虑下面的例子:
// => true
console.log([1] == 1);
// => true
console.log([1] == "1");
// => true
console.log([{
toString: function () {
return 1;
}
}] == "1");
// => false
console.log([1] === 1);
// => false
console.log([1] === "1");
// => false
console.log([{
toString: function () {
return 1;
}
}] === "1");
令人担忧的是,通过强制,一个对象本质上可以等于一个原始值,但至少现在您知道这种情况何时发生。此外,您可以看到为什么在 JavaScript 最佳实践中如此大力提倡使用严格等于运算符来比较值。
复杂胁迫
既然你已经掌握了强制的基础知识,让我们来试试一个高级的例子(我说的高级,是指让人麻木的迟钝)。想想这块宝石:
// => '10'
++[[]][+[]]+[+[]]
理解正在发生的事情的最好方法是首先打开内部包装,然后向外工作。首先,从左到右看内部数组:
// => [Array[0]]
[[]]
// An array which contains a single value, a coerced zero thanks to the unary operation.
// => [0]
[+[]]
// A second array also containing a coerced zero.
// => [0]
[+[]]
接下来,思考二元运算符两边的两个操作数。从左边开始:
// => 1
++[[]]['0']
这个声明有点棘手。实际上,内部数组在索引“0”处被访问并被返回。在返回点,左边的一元运算符将它递增,这也将它变成一个数字。然后将这两个值合并。由于左操作数是一个数字,而右操作数是一个数组,所以组合将通过串联进行,而不是求和。因此,最终的序列如下所示:
// => '10'
1 + ['0']
现在您已经理解了为什么这是行话——因为它通过对内部强制机制的深入理解来执行任务。让我们继续讨论逻辑运算符的话题,以理解它们在编程术语中的作用。
逻辑运算符
逻辑运算符用于返回布尔值,但在某些情况下,它们可用于缩短语句中的控制流。这种短路通常会缩短代码,但会牺牲表达能力。这样,逻辑运算符非常适合创建编程术语。下一节将逐步介绍各种逻辑运算符,解释如何使用它们来产生术语。
逻辑与(&&)
逻辑 OR 和逻辑 and 都用于链接返回布尔值的比较。在逻辑 AND 的情况下,所有条件求值必须为真;否则,返回 false。
通过比较或隐式回退的赋值
了解了&&的行为,就有可能在一条语句中同时利用链接和返回值:
var car = {
hasWheels: function () {
return true;
},
engineRunning: function () {
return true;
},
wheelsTurning: function () {
return true;
}
};
if (car.inMotion = car.hasWheels() && car.engineRunning() && car.wheelsTurning()) {
console.log('vrrrrooooommmm');
}
虽然上面的代码在技术上是正确的,但在条件表达式中使用赋值语句并不是一种好的做法,因为人们经常将赋值语句误解为相等比较,这可能会导致混淆。
逻辑或(||)
与逻辑 AND 运算符非常相似,逻辑 or 运算符可以用作控制流机制,它从左到右比较操作数,寻找第一个真值。与 AND 运算符不同,or 运算符只需要一个操作数为真就能成功。
默认值
使用逻辑“或”的一种常见方式是将默认值赋给在方法签名中被认为是可选的变量。OR 操作符测试左操作数,寻找一个undefined将会寻找一个可以被强制为布尔值的值。一旦找到,该值就被赋给变量。
var Car = function(){
var args = Array.prototype.slice.call(arguments);
this.name = args[0] || 'tesla'
this.mpg = args[1] || 100
this.mph = args[2] || 80
// => Volt
console.log(this.name);
// => 90
console.log(this.mpg);
// => 80
console.log(this.mph);
}
new Car('Volt',90);
逻辑 NOT(!)
逻辑 NOT 运算符需要一个右操作数,即布尔值或可以强制为一个布尔值。只有当操作数为假时,它才返回真。
速记布尔型
正如您在强制一节中看到的,隐式类型转换很难通过阅读代码来理解。我在 JavaScript 中看到的最普遍的约定之一是使用逻辑 NOT 作为布尔值的快捷方式。考虑 NOT 运算符可以强制然后表达布尔值的以下方式:
// number is coerced to a Boolean false
// NOT inverts it to true
// => true
console.log(!0);
// number is coerced to a Boolean true
// NOT inverts it to false
// => false
console.log(!1);
// number is coerced to a Boolean true
// NOT inverts it to false
// => false
console.log(!-1);
// string is coerced to a Boolean truthy *something*
// NOT inverts it to false
// => false
console.log(!'0');
// string is coerced to a Boolean truthy *something*
// NOT inverts it to false
// => false
console.log(!'1');
// this is coerced to a Boolean falsey *nothing*
// NOT inverts it to true
// => true
console.log(!undefined);
// this is coerced to a Boolean truthy *something*
// NOT inverts it to true
// => false
console.log(!this);
// unary operator coerces empty array into zero
// zero is coerced into Boolean false
// NOT inverts it to true
// => true
console.log(!+[]);
// inner NOT coerces the empty array to false
// false is not a valid array index so undefined is returned
// undefined is coerced into Boolean false
// NOT inverts it to true
// => true
console.log(![][![]]);
双音符
正如您在上一个示例中看到的,逻辑 NOT 运算符可以将多种实体转换为变量,包括未定义的变量。知道了这一点,你就可以把缺少一个变量当作事实上的假变量。在下面的例子中,您可以看到双 NOTs 的使用如何允许代码以相同的方式处理未定义的和显式的 false 布尔值。然而,这个代码非常不透明;它在视觉空间中节省的东西,在概念的清晰性上失去了。
var user = {
isAdmin: function () {
return !!this.admin;
}
};
// undefined this.admin is coerced to false
// then inverted to true
// then inverted again to false
// => false
console.log(user.isAdmin());
user.admin = true;
// this.admin is true without coercion
// inverted to false
// inverted back to true
// => true
console.log(user.isAdmin());
user.admin = false;
// => false
console.log(user.isAdmin());
立即调用函数表达式
使用逻辑 NOT 运算符,可以编写更简洁的立即调用函数表达式。在这种情况下,逻辑 NOT 运算符告诉解析器不要将函数视为函数声明,而是提供新执行上下文的表达式:
// Uncaught SyntaxError: Unexpected token (
function(){console.log('foo');}();
// => foo
!function(){console.log('foo');}();
既然您已经对这一部分进行了逻辑总结,那么您可以过渡到 JavaScript 的一些真正的后路,更好的说法是位操作。
钻头旋转
顾名思义,位运算是在比特级别处理数据的过程。一般来说,这对于要求快速执行和/或具有有限资源的算法是有用的。具体来说,这些操作必须只需要对数据进行原始转换,才能从这种操作中获益。位运算是许多低级任务的标准,包括通过套接字通信、压缩或加密信息,或者处理位图图形。使用位操作来实现基于角色的访问控制(RBAC)系统也很常见,因为它们的访问权限可以只用一个位字段来描述,但在数据库中仍然是一个单一的数字。
按位运算符有四种不同的风格:分别是NOT、AND、OR和XOR。除了逻辑操作符之外,JavaScript 还有左右移位操作符。如你所料,正确解释这些操作符的方法和原因是相当复杂的,还必须包括理解比特移位一般是如何工作的。因此,它超出了本章的范围。相反,您将继续关注行话表达式,但现在重点放在按位运算的使用上。接下来的例子和解释有点无聊的行话。
按位与(&)
按位OR函数在每个位位置返回 1,其中两个操作数在指定位置都为 1。
将十六进制转换为 RGB
有时,将十六进制数转换成 RGB 值很有用;例如,在 CSS 类的服务中:
// my favorite hex color
var color = 0xC0FFEE;
// Red
// => 192
console.log((color>>16) & 0xFF);
// Green
// => 255
console.log((color>>8) & 0xFF);
// Blue
// => 238
console.log(color & 0xFF);
您可以进一步扩展这个函数,创建一个渐变工厂 1 来返回颜色渐变:当给定一个开始和结束颜色以及若干停止点时。
var GradientFactory = (function () {
var _beginColor = {
red: 0,
green: 0,
blue: 0
};
var _endColor = {
red: 255,
green: 255,
blue: 255
};
var _colorStops = 24;
var _colors = [];
var _colorKeys = ['red', 'green', 'blue'];
var _rgbToHex = function (r, g, b) {
return '#' + _byteToHex(r) + _byteToHex(g) + _byteToHex(b);
};
var _byteToHex = function (n) {
var hexVals = "0123456789ABCDEF";
return String(hexVals.substr((n >> 4) & 0x0F, 1)) + hexVals.substr(n & 0x0F, 1);
};
var _parseColor = function (color) {
if ((color).toString() === "[object Object]") {
return color;
} else {
color = (color.charAt(0) == "#") ? color.substring(1, 7) : color;
return {
red: parseInt((color).substring(0, 2), 16),
green: parseInt((color).substring(2, 4), 16),
blue: parseInt((color).substring(4, 6), 16)
};
}
};
var _generate = function (opts) {
var _colors = [];
var options = opts || {};
var diff = {
red: 0,
green: 0,
blue: 0
};
var len = _colorKeys.length;
var pOffset = 0;
if (typeof (options.from) !== 'undefined') {
_beginColor = _parseColor(options.from);
}
if (typeof (options.to) !== 'undefined') {
_endColor = _parseColor(options.to);
}
if (typeof (options.stops) !== 'undefined') {
_colorStops = options.stops;
}
_colorStops = Math.max(1, _colorStops - 1);
for (var x = 0; x < _colorStops; x++) {
pOffset = parseFloat(x, 10) / _colorStops;
for (var y = 0; y < len; y++) {
diff[_colorKeys[y]] = _endColor[_colorKeys[y]] - _beginColor[_colorKeys[y]];
diff[_colorKeys[y]] = (diff[_colorKeys[y]] * pOffset) + _beginColor[_colorKeys[y]];
}
_colors.push(_rgbToHex(diff.red, diff.green, diff.blue));
}
_colors.push(_rgbToHex(_endColor.red, _endColor.green, _endColor.blue));
return _colors;
};
return {
generate: _generate
};
}).call(this);
// From hex to hex
// => ["#000000", "#262626", "#4C4C4C", "#727272", "#999999"]
console.log(GradientFactory.generate({
from: '#000000',
to: '#999999',
stops: 5
}));
// From color object to hex
// => ["#C0FFEE", "#CFFFF2", "#DFFFF6", "#EFFFFA", "#FFFFFF"]
console.log(GradientFactory.generate({
from: {
red: 192,
green: 255,
blue: 238
},
to: {
red: 255,
green: 255,
blue: 255
},
stops: 5
}));
按位或(|)
按位OR函数在每个位位置返回 1,其中两个操作数中的任何一个在指定位置为 1。
截断数字
正如您在上一节中了解到的,这个函数对一对位执行逐位OR运算。它也可以用来向下舍入数字。
// => 30
var x = (30.9 | 0);
console.log(x);
按位异或(^)
下面的例子利用了这样一个事实:在 2 位模式的特定位不匹配的地方,按位XOR运算符返回 1。
确定符号相等
这个表达式是确定两个操作数是否有相反符号的简单方法。它之所以有效,是因为 JavaScript 使用二的补码来表示负数,这使得XOR成为可能。
var signsMatch = function (x, y) {
return !((x ^ y) < 0);
};
// => false
console.log(signsMatch(10, -10));
// => true
console.log(signsMatch(0, 0));
// => true
console.log(signsMatch(0, -0));
// => true
console.log(signsMatch(-10, -10));
// => true
console.log(signsMatch(1, 1e0));
// => false
console.log(signsMatch(-1, 1e0));
切换位
偶尔,您会看到用于切换位的XOR操作符,这有助于切换对象的状态。这里有一个例子:
var light = {
on: 1,
toggle: function () {
return this.on ^= 1;
}
};
// => 0
console.log(light.toggle());
// => 1
console.log(light.toggle());
// => 0
console.log(light.toggle());
位元 not(;)
按位NOT函数实质上是交换一个数字的符号,然后从中减去 1。在幕后,JavaScript 将操作数转换为二进制表示,然后通过将所有位从 1 交换到 0 来计算新的数字,反之亦然。这个新数叫做原数的一补数。最后,一的补码被转换回十进制数。了解了NOT的行为,您就可以用一些聪明的方法来利用它。
逐位算术
偶尔,您会看到开发人员使用按位NOT对变量执行算术运算。这里有一个例子:
// => 9
∼-10
// => 11
-∼10
// => 18
2*∼-10
将字符串解析为数字
按位NOT操作符返回操作数的取反值,字符串作为这个过程的一部分被强制。因此,提供一个 double NOT会将数字返回到其原始符号。
var num = "100.7"
// => true
console.log(parseInt(num,10) === ∼∼num);
按位移位(<>,>>>)
比特移位是使用按位运算符,通过将整数的二进制表示在比特域中向左或向右移动任意数量的比特位置来操作整数。移位的过程导致一个新数的形成。在与硬件设备交互时,移位是很常见的,因为它们通常缺乏浮点数的支持。位偏移在图像处理中也非常有用,例如,当位偏移用于处理颜色配置文件之间的转换,或处理像素域的位图操作时。
比特移位在 JavaScript 中不太常用,但是对于对一个数字执行简单的算术移位或者作为一个更大函数的一部分仍然非常有用,您将在接下来的 signum 函数示例中看到。
希格诺函数
signum(也称为 sign)函数的目的是确定一个数是小于、等于还是大于零;因此可以返回-1、0或1作为结果。
var sign = function(x) {
return (x >> 31) | ((-x) >>> 31);
};
// => -1
console.log(sign(-100));
// => 0
console.log(sign(0));
// => 1
console.log(sign(100));
虽然您使用了移位来计算数字的符号,但是您也可以使用两个普通的旧三进制表达式组合在一起:
// => 1
console.log(100 ? 100 < 0 ? -1 : 1 : 0);
现在你已经知道这个函数是有效的,让我们来看看为什么。首先,考虑右移位运算符。该运算符的作用是将操作数移位指定的位数,在本例中为 31 位。因为使用的是位域的结尾,正数总是返回0,负数总是返回-1。这里有几个例子:
// => -1
console.log(-200 >> 31);
// => -1
console.log(-100 >> 31);
// => 0
console.log(0 >> 31);
// => 0
console.log(100 >> 31);
// => 0
console.log(200 >> 31);
接下来,使用补零右移操作符>>>将 31 位向右移动,并从左侧移动任何需要的零。同样,您可以在下面的代码中看到这种情况:
// => 1
console.log(-200 >>> 31);
// => 1
console.log(-100 >>> 31);
// => 0
console.log(0 >>> 31);
// => 0
console.log(100 >>> 31);
// => 0
console.log(200 >>> 31);
最后,为了获得返回值,您使用了按位OR操作符。然而,除非您反转OR操作数右边的数字符号,否则您不会得到预期的结果。简化的函数如下所示:
// => -1
console.log(-200 >> 31 | 200 >>> 31);
// => -1
console.log(-100 >> 31 | 100 >>> 31);
// => 0
console.log(0 >> 31 | 0 >>> 31);
// => 1
console.log(100 >> 31 | -100 >>> 31);
// => 1
console.log(200 >> 31 | -200 >>> 31);
不透明代码
用任何语言都有可能写出晦涩或混乱的代码。有整个社区致力于这些追求。例如,黑帽编码者使用难以阅读的代码作为对抗白帽的一层防御。其他人从编写神秘代码中找到乐趣。有一种叫做编程高尔夫的消遣方式,玩家试图用最少的字符数(击球数)返回一个函数(球洞)的预期结果。以下是纯粹为了游戏而故意使用模糊语法的例子。这些例子中有许多可能被认为是 JavaScript 中真正的 WAT 例子。许多例子都是从网站wtfjs.com得到的灵感。
偷偷摸摸的评估
顾名思义,这个函数为执行代码提供了一个访问 eval 的后门。一些网站试图给用户提供一个经过净化的 JavaScript 子集来使用。如这段代码所示,用动态语言(如 JavaScript)很难做到这一点。这个脚本通过访问String.sub方法的constructor函数来工作。JavaScript constructor方法接受一个字符串,然后就地进行计算。
// => foo
""["sub"]"constructor"")()
你所有的基地
比较不同碱基的数目时要小心。例如,在这里你比较一个八进制数和一个使用科学记数法的十进制数。除非你仔细阅读,否则你可能会对结果感到困惑。
// comparing against octals
// => false
1 + 064 == 65
// => false
064 > 60
// comparing against scientific notation
// => false
3000000000 > 4e9
变量的 Unicode
JavaScript 允许将 Unicode 用作属性描述符和变量名,这可能会导致一些非常不可读的代码。请考虑以下几点:
var \u1000 = {\u1001: function () {
return 'Unicode';
}
};
// => 'Unicode'
console.log(\u1000.\u1001());
确实是水
尽管 Unicode 示例可能有点难以理解,但它无法与后面的内容相比。这段代码以某种方式产生了单词'secret'这一事实看起来几乎是不可思议的。这段代码是由一个名为 jsfuck、 2 的程序生成的,从它的名字来看,这个程序的灵感来自于同样令人不快却恰如其分的标题 Brainfuck 语言。这是真正的代码,即使是最有经验的开发人员也会说哇!?
// => 'secret'
console.log((![]+[])[+[[!+[]+!+[]+!+[]]]]+(!![]+[])[+[[!+[]+!+[]+!+[]]]]+([][(![]+[])[+[[+[]]]]+([][[]]+[])[+[[!+[]+!+[]+!+[]+!+[]+!+[]]]]+(![]+[])[+[[!+[]+!+[]]]]+(!![]+[])[+[[+[]]]]+(!![]+[])[+[[!+[]+!+[]+!+[]]]]+(!![]+[])[+[[+!+[]]]]]+[])[+[[!+[]+!+[]+!+[]]]]+(!![]+[])[+[[+!+[]]]]+(!![]+[])[+[[!+[]+!+[]+!+[]]]]+(!![]+[])[+[[+[]]]])
为了理解这段代码最终是如何产生字符串'secret'的,您需要简单地弄清楚它进行转换的机制。实际上,这比它第一次出现要容易。为了简洁起见,让我们只弄清楚如何再现隐藏单词中的字母 s。在代码中,s 可以在这个表达式中找到:(![]+[])[+[[!+[]+!+[]+!+[]]]]。了解了强制和一元运算之后,您就可以开始一步步地执行这段代码了。首先看看内部数组:
// => [3]
[!+[]+!+[]+!+[]]
您知道一元运算符将空数组转换为数字;在这种情况下,一个零。接下来你会看到逻辑NOT操作符,你知道它给出了操作数相反的布尔值。在这种情况下,操作数被强制转换为false,NOT操作符忠实地将其转换为true。这就剩下方程式true + true + true。接下来,二元运算符将true值相加,这首先需要将它们强制转换为数字。这意味着true + true + true现在是1 + 1 + 1。将它们相加得到3。下面的代码证明了刚才的步骤:
// => true
+[[!+[]+!+[]+!+[]]] == [3]
要继续,您需要了解括号内发生了什么。同样,一旦你把它分解开来,这是很容易弄清楚的。首先考虑这个:
// => true
!+[]
好了,很清楚了;你之前看到了同样的序列。然而,在这个版本中,布尔值false与空数组连接在一起。这意味着布尔值false变成了字符串"false"。本质上,我们的代码已经被简化为一个字符串,就像一个数组一样被访问,以获得第四个项目,也就是您要寻找的字母 s。成功!
// => 's'
("false")[3]
// => true
"s" == (![]+[])[+[[!+[]+!+[]+!+[]]]]
我鼓励你看看 jsfuck 4 项目的源代码,因为有一些有趣的瓶子里的船式扭曲,用来获得完全编码任何东西所需的所有字符。有些编码相当史诗。这里有一个例子:
// => true
'(' == ([][(![]+[])[+[[+[]]]]+([][[]]+[])[+[[!+[]+!+[]+!+[]+!+[]+!+[]]]]+(![]+[])[+[[!+[]+!+[]]]]+(!![]+[])[+[[+[]]]]+(!![]+[])[+[[!+[]+!+[]+!+[]]]]+(!![]+[])[+[[+!+[]]]]]+[])[+[[+!+[]]]+[[!+[]+!+[]+!+[]+!+[]+!+[]]]]
用 209 个字符来编码一个右括号。确实是水!
摘要
您刚刚花了整整一章的时间学习强制、位运算和逻辑运算符。现在,您更好地理解了为什么在使用 JavaScript 的这些特性时,实际结果和预期结果之间经常会出现脱节。擅长利用这些细微差别的程序员通常可以将非常复杂的行为打包到几个字符中。正如我在介绍中提到的,这种高度上下文相关的代码我称之为编程术语,但其他人贬损地称之为 WAT 风格的编程。在尝试使用或阅读 JavaScript 行话时,需要记住以下几点。
- 编程行话是通过使用高度具体且通常是技术性的语言规则对代码进行压缩。
- 行话不好也不坏;这取决于演讲者和听众是否理解表达的上下文和参考点。
- 强制是将一种类型的对象或实体强制为另一种类型的行为。
- 在 JavaScript 中,强制要么是为了将复杂对象简化为基本形式,要么是为了在两种基本类型之间进行转换。
- 逻辑运算符用于返回布尔值,但在某些情况下,它们可用于缩短语句中的控制流。
- 只能对整数执行位运算。
- 位运算对于需要快速执行和/或运算资源有限的算法非常有用。
- 位运算通常可以用来代替其他与数学相关的函数——例如,用
∼∼'10'代替parseInt('10',10).
附加参考
http://rocha.la/JavaScript-bitwise-operators-in-practicehttp://sla.ckers.org/forum/read.php?24,32930http://javascriptissexy.com/12-simple-yet-powerful-javascript-tips/http://codegolf.stackexchange.com/questions/2682/tips-for-golfing-in-javascripthttp://stackoverflow.com/questions/2350718/are-there-any-short-tricks-in-javascript-1-8-that-i-can-use-to-reduce-my-golfhttp://www.benlesh.com/2012/05/javascript-fun-part-6-code-golf.html?m=1http://wtfjs.com/http://jscoercion.qfox.nl/
Footnotes 1
http://markdaggett.com/blog/2012/03/23/generate-beautiful-gradients-using-javascript/
2
3
http://en.wikipedia.org/wiki/Brainfuck
4
https://github.com/aemkei/jsfuck
五、异步生活
Abstract
那些武断地认为互联网将走向何方的人在过去几年里花了很多时间谈论响应式网络的兴起。与网页设计相关的响应能力取决于开发人员设计网站的能力,该网站能够智能地适应无数用来访问其内容的设备。理想情况下,一个响应式网站不仅仅适合给定的屏幕大小;它还改变了网站的功能、视觉流程和美学,以适应平台或设备的功能。
那些武断地认为互联网将走向何方的人在过去几年里花了很多时间谈论响应式网络的兴起。与网页设计相关的响应能力取决于开发人员设计网站的能力,该网站能够智能地适应无数用来访问其内容的设备。理想情况下,一个响应式网站不仅仅适合给定的屏幕大小;它还改变了网站的功能、视觉流程和美学,以适应平台或设备的功能。
与 JavaScript 相关的响应性就是编写代码,最大限度地减少用户对界面被锁定或冻结的感觉。让界面有响应感可能很难做到,因为现代 web 应用越来越需要访问外部应用编程接口(API)或不能立即返回结果的长时间运行的流程。大多数语言允许开发人员使用线程或并发操作将这些扩展流程推到所谓的后台。
然而,JavaScript 是单线程的,这意味着开发人员必须更聪明地处理长时间运行的流程。本章解释了通过 JavaScript 或浏览器帮助正确规划和编写响应代码的各种机制。
理解 JavaScript 中的并发性
在研究这个主题时,我意识到许多人将并发执行与运行异步代码的能力混为一谈。虽然异步执行通常用于实现并发的外观,但这两者并不相同。在讨论使用 JavaScript 编写并发和异步代码的具体技术方法和限制之前,先讨论一下并发的基本定义会有所帮助。
并发
在编程环境中,并发是指两个或多个计算过程在共享资源的同时同时执行的能力。这些进程(有时称为线程)可以共享一个处理器,也可以分布在网络上,稍后由执行管理器进行同步。并行进程之间的通信通常是显式的,要么通过消息传递进行,要么通过共享变量进行。一般来说,并发进程应该只用于不确定的问题,这意味着状态的排序并不重要。代码的并发执行提供了许多优点和一些缺点,我在下面的小节中对此进行了概述。
并发的优势
- 增加一次可以运行的程序数量。
- 允许具有独立于资源的顺序步骤的程序被无序地处理。如果中间步骤的持续时间未知,这尤其有用。
- 当长时间运行的任务完成时,应用不会变得无响应。
- 具有执行先决条件的任务可以稍后排队,直到满足这些依赖关系。
并发的缺点
- 两个将对方列为先决条件的进程可以无限期地等待对方。这有时被称为死锁。
- 当进程的结果依赖于由于并行执行而无法保证的特定序列或时序时,可能会出现争用情况。
- 并发操作的管理和同步比顺序执行更复杂。
- 并发程序通常需要多倍的资源。多个进程可以并行执行。并且需要将它们编组和同步在一起的开销。
- 当并发操作由于未能正确同步而破坏了彼此的状态时,数据完整性可能会丢失。
JavaScript 中并发的硬道理
只有单线程,JavaScript 不可能有真正的并发。这一现实并不是过去必须支持功能不足的浏览器的遗留问题。Brendan Eich 指出,Java 在 1995 年给 Netscape 增加了线程,但是用他的话说“我绝不会在 JS 中放共享可变状态抢先线程。”他觉得线索对观众来说是错误的。然而,为这个决定辩护,我认为 JavaScript 的流行部分是因为缺乏经验的程序员可以融入这种语言。如果每个 JavaScript 新手一开始就必须担心死锁和竞争条件,那么这种语言的采用将会慢得多。单线程意味着死锁是不可能的,除非在顺序进程无法结束的情况下。如果程序有循环依赖,就会发生这种情况。
只有一个线程有明显的缺点。也就是说,当计算机上的单个内核达到极限时,程序可能会达到任意的处理阈值(即使有其他内核可用)。此外,当在浏览器中运行时,脚本必须定期服从浏览器的用户界面(UI)进程,以保持网页的响应性。一个闲置时间过长的脚本很可能会被浏览器误解为失控脚本,此时用户会看到如图 5-1 所示的弹出窗口。
图 5-1。
Unresponsive script pop-up
随着时间的推移,JavaScript 社区和语言已经发展到最大化单线程。例如,尽管 JavaScript 没有真正的并发性,但通过战略性地使用诸如setInterval()、setTimeout()或异步版本的XMLHttpRequest()等函数,可以模拟其效果。当这些技术不能满足需要时,可以部署后台工作人员(我将在本章后面介绍)。为了更好地理解如何构建程序以最大化类似并发的行为,您必须理解 JavaScript 中事件循环的工作方式。
理解 JavaScript 事件循环
现在您已经大致了解了并发性,您可以评估 JavaScript 运行程序的方法,即不断寻找要处理的传入事件消息。JavaScript 的单线程意味着每个运行时进程只有一个事件循环。JavaScript 的事件循环深受两个概念的影响,即运行到完成和非阻塞输入/输出(I/O)。
运行至完成
JavaScript 的事件循环被设计成一个从运行到完成的环境。实际上,这意味着一旦 JavaScript 开始执行一个任务,它就不能被中断,直到它完成。没有运行到完成,您就不能确定对象的状态,因为它可能在正常的事件循环周期之外被访问。Mozilla 这样描述运行到完成的目标:
Fully process each message before processing any other messages. When reasoning programs, this provides some good properties, including the fact that whenever a function runs, it can't be preempted, and it will run completely before any other code runs (and the data of the function operation can be modified). For example, this is different from C, where if a function runs in one thread, it can stop at any time to run other code in another thread. 2
蓄意制造的事件
在 JavaScript 中,正在运行的程序为事件循环创建要处理的消息。这些消息由事件发生时触发的侦听器创建。乍一看,这似乎不起眼,但它暗示了 JavaScript 事件循环的一个强大特性。JavaScript 使用监听器来监控事件,这意味着输入可以同时从许多地方到达。监听器允许事件并行展开。Mozilla 是这样解释事件化设计的:
A very interesting feature of the event loop model is that unlike many other languages, JavaScript never blocks. Processing I/O is usually performed by events and callbacks, so when the application waits for the return of IndexedDB query or XHR request, it can still process other things, such as user input. Legacy exceptions exist, such as alarms or synchronous XHR, but it is considered a good practice to avoid them. Please note that the exception of exception does exist (but it is usually an implementation error, not something else). 3
非阻塞 I/O 是 JavaScript 中的一种机制,它允许在等待另一个操作的结果完成时对传入的消息进行排序。基于事件的消息传递还允许 JavaScript 捕获同时发生的动作,但要确保它们被事件循环按顺序处理。这种能力是 JavaScript 中模拟并发的方式,它允许通过巧妙使用回调、闭包或承诺来减轻执行缓慢的操作的影响。
在事件循环内部
在事件循环中,传入的消息被提取到一个帧堆栈中,并按照特定的顺序进行处理。当一个帧被添加到堆栈中时,该帧所需的任何对象和变量都被添加到共享内存堆中,或者从共享内存堆中检索。任何当前没有被执行的代码都被添加到队列中以备后用。一旦整个堆栈完成,不需要的变量将从堆中删除,队列中的下一条消息将被提取到堆栈中。事件循环生命周期如图 5-2 所示。
图 5-2。
Diagram of the JavaScript event loop
许多
堆是内存中一个顺序不可知的容器。堆是 JavaScript 存储当前正在使用的变量和对象的地方,或者是垃圾收集过程尚未收获的地方。
基本框架
帧是事件循环周期中需要执行的连续工作单元。框架包含一个执行上下文,它将堆中的函数对象和变量链接在一起。
堆
事件循环堆栈包含消息执行所需的所有顺序步骤(帧)。事件循环从上到下处理帧。基于帧的依赖链将帧添加到堆栈中。具有从属关系的框架会将它们的从属框架添加到顶部。该过程确保在被偶发代码引用之前满足依赖关系。考虑以下示例:
var sum = function (a, b) {
return a + b;
}, addOne = function (num) {
return sum(1, num);
};
// => 11
addOne(10);
在addOne()消息从队列移动到堆栈的时候,它成为了基础帧。我称这个frame0\. Frame0包含了对addOne()函数的引用和num参数的值(目前是10))。因为addOne()依赖于sum()函数,所以创建了一个新的帧(frame1),它包含对sum()函数的引用以及传入参数"a"和"b"的值。在本例中,frame1没有其他需要满足的依赖关系,所以现在可以从frame1开始向下展开堆栈。一旦事件循环处理了一个帧,它就会从堆栈顶部弹出。这种情况一直持续到堆栈为空,此时从队列中检索一个新项。
长队
队列是等待处理的消息列表。每条消息引用一个 JavaScript 函数。当堆栈为空时,队列中最早的消息将作为下一个基础帧添加到堆栈中。
回收
JavaScript 事件循环的设计迫使代码按顺序执行。了解这一点意味着编写同步代码将为开发人员提供大量的清晰度,因为他们可以按照代码运行的方式编写代码。由于使用了同步结构,下面的源代码的意图非常清楚。该流反映了事件循环处理它时将会发生的情况:
var person = {};
var bank = {
funds: 0,
receiveDepositFrom: function(person) {
this.funds += person.funds;
person.funds = 0;
}
};
// => undefined
console.log(person.funds);
person.funds = (function work() {
return 100;
})();
// => 100
console.log(person.funds);
bank.receiveDepositFrom(person);
// => 0
console.log(person.funds);
用 JavaScript 编写同步代码有一些明确的优势:
- 代码更容易理解,因为程序可以按顺序阅读。
- 同步函数返回值并在词法上下文中抛出异常,使它们更容易调试。
然而,大多数复杂程度不同的 JavaScript 程序不应该仅仅由一系列连续的步骤组成。这样做会导致性能和代码质量方面的问题。让我们分别来看看这两个问题。
感知性能
许多程序依赖于不立即返回值的函数。想象一下,如果前一个例子中的work()函数花了一些时间来完成,而不是立即返回:
person.funds = (function work() {
// Simulate a long running task.
var end = Date.now() + 4000;
while (Date.now() < end){
//noop
}
return 100;
})();
代码继续按预期执行,但是用户体验会下降,因为在work()函数返回值之前,程序看起来是冻结的。执行中的同步延迟不是您可能面临的唯一问题。准则期望员工在尝试存钱之前先有钱。有可能work()函数改为轮询一个远程服务,它不会像前面的例子那样直到完成才阻塞。在这种情况下,代码会中断,因为person.funds在它被访问时会是undefined:
var person = {};
var bank = {
funds: 0,
receiveDepositFrom: function(person) {
// Now NaN because person.funds is undefined.
this.funds += person.funds;
person.funds = 0;
}
};
// => undefined
console.log(person.funds);
(function work(person) {
// Assumes you have jQuery installed
$.ajax({
url: " http://some.webservice.com/work.json ",
context: document.body
}).done(function() {
person.funds = 100;
});
})(person);
// => undefined
console.log(person.funds);
bank.receiveDepositFrom(person);
// => 0
console.log(person.funds);
一旦 AJAX 请求完成,您可以发送一个函数回调到之前的上下文,而不是将person对象作为参数传递给work()函数。回调是控制数据流最流行的模式之一。JavaScript 中的回调是将一个函数对象作为参数传递给另一个函数的行为,该函数将用于返回值。实际上,回调允许您将当前词法上下文从代码的同步执行中分离出来。回调是延续传递风格的一种形式,您将在下一节中了解到。
连续传球风格
延续传递风格(CPS)是函数式编程范例中流行的一个概念,其中程序的状态是通过使用延续来控制的。就你的目的而言,延续将是你的回调。延续对于异步编程非常流行,因为程序可以等待数据,然后通过提供的延续来推进状态。JavaScript 可以支持延续,因为函数是语言中的一等公民。使用 continuations(回调),您可以推迟存款操作,直到 AJAX 方法返回:
var person = {};
var bank = {
funds: 0,
receiveDepositFrom: function(person) {
this.funds += person.funds;
person.funds = 0;
}
};
// => undefined
console.log(person.funds);
(function work(callback) {
$.ajax({
url: " http://some.webservice.com/work.json ",
context: document.body
}).done(function() {
callback(100);
});
})(function(amount) {
person.funds = amount;
// => 100
console.log(person.funds);
bank.receiveDepositFrom(person);
// => 0
console.log(person.funds);
});
这种编码风格应该看起来很熟悉,因为 CPS 被许多最流行的库和运行时大量使用,以至于它们几乎是不可避免的。尽管代码的执行仍然反映了自顶向下的布局,但它的表达能力明显下降了。读者现在需要在代码体内来回思考,以理解执行流程。然而,现在代码的响应性更好了,您可以努力提高方法的质量。
回调地狱
同步设计使代码基础扁平化,这可以提高清晰度,但随着时间的推移,它会降低您组织和重用代码的能力。CPS 可以解决这个问题,但它不是万能的。如果不加以检查,延续会变成算法套娃,无限地嵌套在另一个套娃里。这里有一个假设的例子:
login('user','password', function(result) {
if (result.ok) {
getProfile(function(result) {
if (result.ok) {
updateProfile(result.user, function(result) {
if (result.ok) {
callback(user);
}
});
}
});
}
}, callback);
虽然这段代码是响应性的,但由于异步结构,它几乎不可读。这种编码风格有时被称为末日金字塔或回调地狱 4 ,因为代码向右延伸的速度比向下移动的速度快。回调地狱之所以被恰当地命名,是因为它执行以下操作:
- 使得代码更难阅读和维护
- 降低了代码的模块化程度,也更难划分成关注点
- 使得错误传播和异常处理更加困难
- 缺乏正式的 API,所以回调可能返回,也可能不返回,它们产生的结果可能是一个大杂烩。
当然,导致回调地狱的设计决策并不是 JavaScript 独有的问题。在许多方面,开发人员不断使用新的语言和技术重新发明过去的反模式。在 20 世纪 70 年代和 80 年代,许多程序因过度使用goto语句而遭受损失。goto语句“执行控制到另一行代码的单向转移。” 5 就像回调一样,使用goto跳转到代码中的其他地方破坏了源代码的线性。生成的代码通常需要程序员在思想上展开堆栈来理解当前的上下文。对goto最广泛引用的批评是 Edsger Dijkstra 写的:
My second comment is that our intelligence is more inclined to master static relationships, while our ability to visualize the process of evolution over time is relatively poor. For this reason, we should (as wise programmers, aware of our limitations) try our best to shorten the conceptual gap between static programs and dynamic processes, and make the correspondence between programs (expanded in text space) and processes (expanded in time) as simple as possible. Ed W. Dijestra (Dijkstra, 1968).
回调或 CPS 本身并没有什么问题。然而,当过度使用时,CPS 增加了程序员在编写功能的原始意图和最终执行它的上下文之间的认知不一致。这是因为 CPS 的目标绝不是将控制权交还给调用者。相反,延续使用回调作为有状态的烫手山芋,总是试图将它传递给其他人。
想象一下,如果一个延续的优先级被颠倒了。该流程没有强调传递当前上下文的能力,而是立即返回一个表示延迟的未来状态的令牌。这形成了一种计算 I.O.U,它提供了与 CPS 相同的异步执行,同时保持了高度的声明性。我描述的是 Promise 模式,您将在下一节中对其进行测试。
承诺:从未来回来
promise 是一个令牌对象,它表示尚未返回的函数的未来值或异常。Promises 提供了一种清晰易读的方法,将异步执行转换成可视化的顺序控制流。任何阻塞事件循环的流程都是 promise 模式的候选者。考虑这个程序,首先使用 CPS 编写,然后使用 promise 重写:
// CPS style
var user;
login('user', 'password', function(result) {
if (result.ok) {
user = result.user;
}
});
// Promise style and assumes login returns a promise object.
var promise = login('user', 'password');
promise.then(function(result) {
if (result.ok) {
user = result.user;
}
});
正如您在前面的代码中看到的,承诺并不像 CPS 那样将执行状态作为函数参数传递。相反,未来上下文的占位符会立即返回到当前词法范围。这允许代码像 CPS 一样保持非阻塞,但具有缩短静态程序和动态过程之间的概念差距的优势,正如 Dijkstra 恳求我们做的那样。
虽然我将抽象地提到承诺,但实际上有几种实现。在本章中,当我说 promise 时,我在技术上指的是 Promise A+, 6 ,这非常适合 JavaScript。根据规范,承诺对象由以下部分组成:
- Promise 是一个带有
then方法的对象或函数,其行为符合这个规范。 - 表是一个定义了一个
then方法的对象或函数。 - Value 是任何合法的 JavaScript 值(包括
undefined、一个 thenable 或一个承诺)。 - 异常是使用
throw语句抛出的值。 - Reason 是一个值,表示拒绝承诺的原因。
信守承诺
在现实生活中,承诺对象提供了定义事件预期的契约。因为 promise 对象代替实际值被立即返回,所以它们提供了比 CPS 更好的组合。具体来说,承诺可以连锁或连接在一起,并在各种环境下执行。然而,创建这些链需要一些样板代码才能工作。令人欣慰的是,其他人已经将这些比特抽象成了库。下面的例子利用 Kristopher Kowal 的 Q 7 库来演示承诺链和连接。
Note
在开始之前,你需要通过 npm: npm install q安装 Q。
连锁和延迟执行
承诺优于 CPS 的一个主要用例是当一个程序有一系列需要以特定顺序运行的异步功能时。在下面的例子中,您将看到一个连续的承诺链是如何以特定的顺序执行的。在解析过程中,计算出的数字被传递到链中的下一个环节。比较和对比该序列的能力也作为一系列嵌套回调来实现:
Q = require('q');
// Simulates a long running process
var sleep = function(ms) {
return function(callback) {
setTimeout(callback, ms);
};
};
// Using Continuation Passing Style.
var squareCPS = function(num, callback){
sleep(1000).call(this, function(){
callback(num * num);
});
};
// => 100000000
squareCPS(10, function(num){
squareCPS(num, function(num){
squareCPS(num, function(num){
console.log(num);
});
});
});
// Using Promises.
var square = function(num) {
var later = Q.defer();
sleep(1000).call(this, function() {
later.resolve(num * num);
});
return later.promise;
};
// => 100000000
square(10)
.then(square)
.then(square)
.then(function(total){
console.log(total);
});
并行连接
如果您有一系列不确定的函数,您可以使用Q并行执行您的函数,如下例所示:
Q.allSettled([
square(10),
square(20),
square(30)
]).then(function(results){
results.forEach(function (result) {
// => 100
// => 400
// => 900
console.log(result.value);
});
});
这一部分是对承诺对象的简要介绍,所以如果你在承诺中看到了承诺,那么有一些重要的话题值得深入探讨。如果你很好奇,我鼓励你去看看 Q 上的文档,因为它提供了比我在这里介绍的更完整的承诺介绍。
生成器和协程程序
虽然本章是关于异步代码和并发性的,但这两个主题的软肋实际上是关于控制执行流的。作为一个思维实验,现在试着在你的头脑中提取 JavaScript 的基本成分。你会允许什么特征蒸发到以太中,如果去除,什么会彻底打破语言?我打赌你不会碰那些控制执行流程的组件。在其他组件中,JavaScript 中的控制流机制为您的应用提供了以下功能:
- 满足前提条件时执行语句
- 语句之间的条件分支
- 有条件地从一个语句继续到另一个语句
- 将执行流转移出一个上下文,然后在预定的位置继续执行。
这一部分是关于列表中的最后一个要点。语言——包括 Python、Lua 和 small talk——通过使用协程和生成器来处理结构化的非局部控制流 8 。协程和生成器允许使用预定的入口和出口点来暂停和恢复代码的执行。ECMAScript 6 准备将这两个概念引入到语言中。本节探讨它们是如何工作的,并演示如何使用它们。
发电机
生成器是在维护集合自身内部状态的同时对集合进行迭代的函数。生成器可以有自己的状态,并暂时将它们的执行让给另一个进程,这一事实意味着它们对于如下各种任务非常有用:
- 共享多任务处理
- 元素的顺序处理
- 将有一定等待时间的多个流程排序作为其设计的一部分
- 简单的状态机
在 JavaScript 中,任何包含yield操作符的函数都被认为是生成器。下面是一个简单的例子,演示了生成器如何维护自己的内部状态:
var sequence, sq;
sq = function* (initialValue) {
var current, num, step;
num = initialValue || 2;
step = 0;
while (true) {
current = num * step++;
yield current
}
};
sequence = sq(20);
// => 0
console.log(sequence.next().value);
// => 20
console.log(sequence.next().value);
// => 40
console.log(sequence.next().value);
// => 60
console.log(sequence.next().value);
Note
根据 ECMAScript 规范, 9 协程/生成器通过在函数关键字:Function*(){...}后加一个星号来定义。在 V8 中,您可以使用--harmony-generators标志:$ node --harmony-generators foo.js来启用生成器。在撰写本文时,只有 node 0.11。+支持和声发生器。
前面的生成器不断迭代,直到达到 JavaScript 所能支持的最大值(1.7976931348623157e+308)。)生成器也可以定义可能值的范围。当所有可能性都用尽时,会出现一个StopIteration异常:
var a, alphabet, sequence;
alphabet = function*() {
var charCode = 65;
while (charCode < 91) {
yield String.fromCharCode(charCode++);
}
throw new Error("StopIteration")
};
sequence = alphabet();
a = 0;
while (a < 27) {
try {
// => a..z
console.log(sequence.next().value);
} catch (e) {
// => [Error: StopIteration]
console.log(e);
}
a++;
}
不得不担心捕捉可选的越界错误会使您的代码变得脆弱。事实证明,发生器有一个内置的布尔done值,可以通过检查来确定是否到达了序列的末尾。知道了这一点,你可以这样重写前面的例子:
var letter, alphabet, sequence;
function* alphabet() {
var charCode = 65;
while (charCode < 91) {
yield String.fromCharCode(charCode++);
}
};
sequence = alphabet(),
letter = sequence.next();
while (!letter.done) {
// => A..Z
console.log(letter.value);
letter = sequence.next();
}
惯例协程
协程有时被称为协同调度线程,因为它们允许在单个进程上共享执行。在 JavaScript 中,协程是用于流控制的生成器。像生成器一样,协程是可以通过使用yield操作符挂起和恢复它们的执行上下文的对象。与生成器不同,协程可以控制让步后返回哪个执行上下文;这种能力使它们非常适合控制程序的流程。Wikipedia 更进一步地描述了这一点:“由于生成器主要用于简化迭代器的编写,所以生成器中的 yield 语句并不指定要跳转到的协程,而是将值传递回父例程。” 10
在许多语言中,协同程序是在生成器之外明确定义的。在 JavaScript 中,协同例程是作为一种模式实现的,而不是作为语言的一个独特特性。这是可能的,因为 JavaScript 本身支持延续,正如您在回调部分所了解的。下面是一些如何用 JavaScript 实现协同程序的例子。最基本的协程是二进制切换,可以这样写:
var toggle = (function*(){
while(true){
yield true
yield false
}
})();
for(var x = 0; x < 5; x++){
// => true, false, true, false, true
console.log(toggle.next().value)
}
这个例子使用多个yield语句作为控制流机制,在真和假状态之间振荡。注意,这个协程形成了一个非常基本的状态机,它处理两个位置(on,off ),而不需要显式定义一个布尔变量。您可以使用这个协程反复切换 UI 元素。Harold Cooper 指出,这个“变量只能被避免,因为协程给语言增加了一种全新形式的状态,即协程当前被挂起的状态。” 11 虽然这个例子很有指导性,但它的用处有限。让我们看一个更复杂的用例。
可延续生成元
Tim Caswell 最近发布了一个有用的库,名为 Gen-run 12 ,根据 Caswell 的说法,它“消耗可延续的产出生成器,并将其自己的延续传递给可延续的,以便当它们解决时,生成器主体将恢复并返回一个值或抛出一个错误。”通俗地说,Gen-run 在让步和恢复行为周围注入控制流规则,以同时处理异步和同步功能。整个库足够小,可以在这里内联显示:
function run(generator, callback) {
// Pass in resume for no-wrap function calls
var iterator = generator(resume);
var data = null, yielded = false;
var next = callback ? nextSafe : nextPlain;
next();
check();
function nextSafe(item) {
var n;
try {
n = iterator.next(item);
if (!n.done) {
if (typeof n.value === "function") n.value(resume());
yielded = true;
return;
}
}
catch (err) {
return callback(err);
}
return callback(null, n.value);
}
function nextPlain(item) {
var cont = iterator.next(item).value;
// Pass in resume to continuables if one was yielded.
if (typeof cont === "function") cont(resume());
yielded = true;
}
function resume() {
var done = false;
return function () {
if (done) return;
done = true;
data = arguments;
check();
};
}
function check() {
while (data && yielded) {
var err = data[0];
var item = data[1];
data = null;
yielded = false;
if (err) return iterator.throw(err);
next(item);
yielded = true;
}
}
}
为了理解这个库是如何工作的,考虑这个简单的例子,它对一系列对sleep函数的调用进行排序:
function sleep(ms) {
return function (callback) {
setTimeout(callback, ms);
};
}
// => Prints "Started", "Almost Done", and "Done" on indvidual lines.
run(function* () {
console.log("Started");
yield sleep(1000);
console.log("Almost Done")
yield sleep(1000);
console.log("Done!");
});
如果不使用 Gen-run,就不会有控制流机制,因此控制台语句会立即显示在屏幕上。但是,因为生成器将它们的执行上下文让给了传入的 sleep 函数,所以您可以暂停,然后以同步方式恢复执行。
Gen-run 的设计得到了增强,因为生成器本身可以将自己的 yield 上下文委托给其他生成器。这是使用yield*语法完成的。考虑这个例子,其中run包装器委托给sub生成器:
function* sub(n) {
while (n) {
console.log(n--);
yield sleep(10);
}
}
// => Prints "Start", "[10..1]","End" on individual lines.
run(function* () {
console.log("Start");
yield* sub(10);
console.log("End");
});
从前面的例子中可以看出,Gen-run 最擅长的是控制任意数量的函数的执行,其中执行顺序至关重要。
网络工作者
Web workers 是 JavaScript 进程,可以在所谓的浏览器后台运行。实际上,每个新工人都有自己的全局上下文,这允许他们执行长时间运行的流程,而不必让步来更新用户界面。值得注意的是,workers 是 HTML 规范的一部分, 13 不是 ECMAScript 的一部分。从 JavaScript 的角度来看,web workers 没有什么特别之处,除了它们可以由浏览器按需创建并由主浏览器上下文控制。本节详细探讨了 web workers,以及当需要大量计算时,如何使用它们来最小化对用户体验的影响。
Note
不要将 worker 的全局上下文与仅仅是一个操作系统线程相混淆。全球环境实际上是一个更加资源密集型的过程。
并发
将 web workers 视为 JavaScript 中实现并发的一种方式可能很有诱惑力,但是真正的并发的一部分是共享执行上下文的能力。尽管工作人员确实可以访问父浏览器上下文的一些属性,但他们的访问是作为消息传递 API 来处理的,这与共享资源不是一回事。这个 API 确保后台工作人员在沙箱中运行,不会破坏主窗口文档环境的状态。Mozilla 自己的文档详细阐述了对线程安全的需求:14
Worker interface produces real OS-level threads, and concurrency may produce interesting effects in code if not careful. However, in the case of web workers, careful control of communication points with other threads means that it is actually difficult to cause concurrency problems. Without access to non-thread-safe components or DOM, you must serialize objects to transfer specific data to and from threads. So you have to work very hard to cause problems in your code.
知道什么时候当工头
就像在现实生活中一样,知道何时雇佣某人是你作为经理所能做出的最大决定之一。在正确的时间招聘可能意味着成功和失败的区别;不幸的是,反过来也是如此。下面是在程序中使用 web workers 之前要考虑的利弊列表。对于不需要来自 UI 层的频繁消息的问题,Web workers 是一个极好的选择;例如,物理模拟、长轮询网络操作、图像处理或密集数据解析。然而,它们并不是万灵药,如果用错了数量或者用错了问题,它们实际上会损害应用的性能。在某些情况下,工作人员甚至可以使浏览器崩溃,因为他们返回到主脚本的消息没有被限制。 十五
优势
- 它们允许长时间运行或计算密集型任务从 UI 事件循环中分离出来,这使得程序感觉响应更快。
- 工作人员可以按需启动,因此可以根据需要增加或减少后台资源。
不足之处
- Workers 启动时是资源密集型的,每个实例的内存占用都很高。
- 它们独立于主 UI 线程工作,因此它们不能访问 DOM 的许多部分或全局变量。
- 并不是所有的运行时环境都支持所有类型的 web workers,所以开发人员在测试跨平台时必须小心。
- 缩小脚本时必须格外小心,防止它破坏对 workers 的引用。
雇佣工人
了解网络工作者的最好方式是看他们的实际行动。事实证明,规范中实际上描述了两种形式的工作人员:专用型和共享型。这些工人几乎是相同的;它们只有几个方面不同:
- 创建专用工作线程后,它只能访问创建它的父线程。然而,共享工作者可以有多个关注点。
- 专用工作器也只在它们的父工作器持续的时间内持续,而共享工作器必须被显式终止。
基础
所有工人都是通过使用Worker构造函数创建的:
worker = new Worker("worker.js");
一旦创建好,就可以通过一个简单的消息传递 API 来管理工人。通过使用两种方法的基本握手来促进消息传递:postMessage()和onmessage()。一个简单的乒乓示例只需要两个文件,如下所示:
// ping.html
<!DOCTYPE HTML>
<html>
<body>
<script type="text/javascript" charset="utf-8">
addEventListener("DOMContentLoaded", (function() {
worker = new Worker("pong.js");
worker.onmessage = function(e) {
console.log(e.data);
};
console.log('ping');
worker.postMessage();
}), false);
</script>
</body>
</html>
// pong.js
onmessage = function(event) {
postMessage('pong');
};
一旦这个程序运行,您将看到"ping"和"pong"被写到开发人员控制台。因为这个简单的例子是一个专用的工作器,所以只要 web 浏览器一关闭,它就会自动结束。
敬业的工人
专用 web workers 是后台进程,只对调用它们的脚本可用。由于它们的隔离性质,它们没有共享 web workers 复杂,因此拥有最广泛的浏览器支持。同样,web 工作者的目标是让开发人员能够将计算密集型或长时间运行的流程推到后台。如果没有 web workers,这些进程通常会阻塞事件循环,使程序感觉冻结了。
下面的代码示例演示了 web 工作人员如何加速众所周知的资源密集型图像处理程序。这个示例使用一个独立的 worker 创建非常详细的画布动画,该 worker 接受画布像素的集合,然后修改它们并将其返回给父脚本。整个过程使用requestAnimationFrame function来完成,它允许更新仅在主机平台准备好接收新帧时发生。这种方法无缝地扩展了动画的流畅性,因为只有当计算机有可用资源时,才会调用工作者。动画的一个静止帧如图 5-3 所示。
图 5-3。
Still frame of the canvas animation
// index.html
<html>
<head>
<title>index</title>
</head>
<body>
<script type="text/javascript" charset="utf-8">
addEventListener("DOMContentLoaded", (function() {
var canvas, ctx, imageData, requestAnimationFrame, worker;
// get the correct animationFrame handler
requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
window.requestAnimationFrame = requestAnimationFrame;
// add a canvas element and create a rendering context
canvas = document.createElement("canvas");
document.getElementsByTagName("body")[0].appendChild(canvas);
canvas.height = canvas.width = 400;
ctx = canvas.getContext("2d");
imageData = ctx.createImageData(canvas.width, canvas.height);
// create a new web worker instance
worker = new Worker("worker.js");
worker.onmessage = function(e) {
ctx.putImageData(e.data.pixels, 0, 0);
// once the canvas is ready for another frame request it from the worker
window.requestAnimationFrame(function() {
worker.postMessage({
pixels: ctx.getImageData(0, 0, canvas.width, canvas.height),
seed: e.data.seed
});
});
};
// seed the worker process.
worker.postMessage({
pixels: ctx.getImageData(0, 0, canvas.width, canvas.height),
seed: +new Date()
});
}), false);
</script>
</body>
</html>
// worker.js
setPixel = function() {
var index;
index = (x + y * imageData.width) * 4;
imageData.data[index + 0] = r;
imageData.data[index + 1] = g;
imageData.data[index + 2] = b;
imageData.data[index + 3] = 255;
};
onmessage = function(event) {
var b, d, g, height, imageData, pos, r, seed, t, width, x, x2, xoff, y, y2, yoff;
pos = 0;
imageData = event.data.pixels;
seed = event.data.seed;
width = imageData.width;
height = imageData.height;
xoff = width / 2;
yoff = height / 2;
y = 0;
while (y < height) {
x = 0;
while (x < width) {
x2 = x - xoff;
y2 = y - yoff;
d = Math.sqrt(x2 * x2 + y2 * y2);
t = Math.sin(d / 6.0 * (+new Date() - seed) / 5000);
r = t * 200 + y;
g = t * 200 - y;
b = t * 255 - x / height;
imageData.data[pos++] = Math.max(0, Math.min(255, r));
imageData.data[pos++] = Math.max(0, Math.min(255, g));
imageData.data[pos++] = Math.max(0, Math.min(255, b));
imageData.data[pos++] = 255;
x++;
}
y++;
}
postMessage({
pixels: imageData,
seed: seed
});
};
Note
工作者文件所在的 URI 不能违反浏览器的同源策略。 16
共享工人
与范围仅限于父文档的专用工作器不同,共享工作器可以跨许多浏览器上下文共享。通过调用构造函数时分配的唯一端口传递消息来处理通信。下面是一个简单的公共/私人聊天应用,演示了共享工作者是如何工作的。
Note
浏览器对共享工作者的支持不如专用工作者。 17
// chat.html
<!DOCTYPE HTML>
<html>
<head>
<title>Chat Room</title>
<script>
var configure, name, sendMessage, update, updateChannel, updatePrivateChannel, updatePublicChannel, worker;
configure = function(event) {
var name;
name = event.data.envelope.from;
return document.getElementById("guest_name").textContent += " " + name;
};
updatePublicChannel = function(event) {
return updateChannel(document.getElementById("public_channel"), event);
};
updatePrivateChannel = function(event) {
return updateChannel(document.getElementById("private_channel"), event);
};
updateChannel = function(channel, event) {
var div, from, m, message, n;
from = event.data.envelope.from;
message = event.data.envelope.body;
div = document.createElement("div");
n = document.createElement("button");
n.textContent = from;
n.onclick = function() {
return worker.port.postMessage({
action: "msg",
envelope: {
from: name,
to: from,
body: document.getElementById("message").value
}
});
};
div.appendChild(n);
m = document.createElement("span");
m.textContent = message;
div.appendChild(m);
return channel.appendChild(div);
};
update = function(event) {
switch (event.data.action) {
case "cfg":
return configure(event);
case "txt":
return updatePublicChannel(event);
case "msg":
return updatePrivateChannel(event);
}
};
sendMessage = function(message) {
return worker.port.postMessage({
action: "txt",
envelope: {
from: name,
body: message
}
});
};
worker = new SharedWorker("chat_worker.js", "core");
name = void 0;
worker.port.addEventListener("message", update, false);
worker.port.start();
</script>
</head>
<body>
<h2>Public Chat</h2>
<h1>Welcome <span id="guest_name"></span></h1>
<h4>public</h4>
<div id="public_channel"></div>
<h4>private</h4>
<div id="private_channel"></div>
<form onsubmit="sendMessage(message.value);message.value = ''; return false;">
<p>
<input id='message' type="text" name="message" size="50">
<button>Post</button>
</p>
</form>
</body>
</html>
// chat_worker.js
/*
Simplified example from:
http://www.whatwg.org/specs/web-apps/current-work/multipage/workers.html
*/
var getMessage, getNextName, nextName, onconnect, viewers;
getNextName = function() {
nextName++;
return "Guest" + nextName;
};
getMessage = function(event) {
var channel, from, to, viewer, _results;
switch (event.data.action) {
case "txt":
_results = [];
for (viewer in viewers) {
_results.push(viewers[viewer].port.postMessage({
action: "txt",
envelope: {
from: event.target.session.name,
body: event.data.envelope.body
}
}));
}
return _results;
break;
case "msg":
from = event.target.session;
to = viewers[event.data.envelope.to];
if (to) {
channel = new MessageChannel();
from.port.postMessage({
action: "msg",
envelope: {
to: to.name,
from: from.name,
body: "private message sent to: " + event.data.envelope.to
}
}, [channel.port1]);
return to.port.postMessage({
action: "msg",
envelope: {
to: from.name,
from: to.name,
body: "private message: " + event.data.envelope.body
}
}, [channel.port2]);
}
}
};
nextName = 0;
viewers = {};
onconnect = function(event) {
var name;
name = getNextName();
event.ports[0].session = {
port: event.ports[0],
name: name
};
viewers[name] = event.ports[0].session;
event.ports[0].postMessage({
action: "cfg",
envelope: {
from: name,
body: "connected"
}
});
return event.ports[0].onmessage = getMessage;
};
分包商
主文档上下文并不是可以产生工人的唯一元素。Web 工作者可以将复杂的处理任务委派给他们自己的一组下属,这些下属被称为子工作者。就像 web workers 一样,子 worker 不能违反浏览器的同源策略,尽管子 worker 的源基于实例化 worker 的位置,而不是主文档。不幸的是,对子工作者的支持非常少,所以我不会给出一个示例用例。我包含这个主题主要是为了完整性。
建筑师鲍勃
作为部署过程的一部分,现代工作流通常将单个脚本缩小并连接到单个主文件中。这样做会破坏对上一个示例中的 worker 源文件的引用,因为 worker 文件不存在于生产环境中。解决这个问题的一种方法是编写工作代码,使它与应用的其余部分一起内联执行。使用 Blob API,这个过程几乎没有痛苦。 18 我们来看一个例子:
var blobTheBuilder, winUrl, worker;
winUrl = window.URL || window.webkitURL;
blobTheBuilder = new Blob(["self.onmessage=function(e){postMessage(Math.round(Math.sqrt(e.data)))}"]);
worker = new Worker(winUrl.createObjectURL(blobTheBuilder));
worker.onmessage = function (e) {
return console.log(e.data);
};
// Find the closest square root of a number
// => 6
worker.postMessage(42);
摘要
以下部分将本章中的概念归纳为一系列要点:
- 编程中的并发性是指两个或多个计算过程在共享资源的同时执行的能力。
- 并发进程应该只用于不确定的问题,这意味着状态的排序并不重要。
- JavaScript 是一种单线程语言,这意味着并发性通常是用其他方法伪造的。
- JavaScript 的事件循环被设计成非阻塞的 I/O 操作。
- JavaScript 中的回调是将函数对象作为参数传递给另一个函数的行为,该函数将在返回值上使用。
- promise 是一个令牌对象,它表示尚未返回的函数的未来值或异常。
- 协程和生成器允许使用预定的入口和出口点来暂停和恢复代码的执行。
- Web workers 是 JavaScript 进程,可以在所谓的浏览器后台运行。
额外资源
以下是关于本章讨论的各种主题的有用帖子和文章的列表。
回收
http://docs.nodejitsu.com/articles/getting-started/control-flow/what-are-callbackshttp://matt.might.net/articles/by-example-continuation-passing-style/
发电机
http://devsmash.com/blog/whats-the-big-deal-with-generatorshttp://jlongster.com/A-Study-on-Solving-Callbacks-with-JavaScript-Generators
协同程序
http://syzygy.st/javascript-coroutines/http://www.dabeaz.com/coroutines/Coroutines.pdfhttp://calculist.org/blog/2011/12/14/why-coroutines-wont-work-on-the-web/
承诺
网络工作者
http://www.html5rocks.com/en/tutorials/workers/basics/https://developer.mozilla.org/en-US/docs/Web/Guide/Performance/Using_web_workers
Footnotes 1
Brenda neich . com/2007/02/threads-suck/
2
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/EventLoop
3
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/EventLoop
4
5
http://en.wikipedia.org/wiki/Goto
6
http://promises-aplus.github.io/promises-spec/
7
https://github.com/kriskowal/q
8
http://en.wikipedia.org/wiki/Control_flow#Structured_non-local_control_flow
9
http://wiki.ecmascript.org/doku.php?id=harmony:generators
10
http://en.wikipedia.org/wiki/Coroutine#Comparison_with_generators
11
http://syzygy.st/javascript-coroutines/
12
https://github.com/creationix/gen-run
13
http://www.whatwg.org/specs/web-apps/current-work/multipage/workers.html#workers
14
https://developer.mozilla.org/en-US/docs/Web/Guide/Performance/Using_web_workers
15
http://blog.sethladd.com/2011/09/box2d-and-web-workers-for-javascript.html
16
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Same_origin_policy_for_JavaScript
17
http://caniuse.com/#feat=sharedworkers
18