JavaScript-专家级编程-全-
JavaScript 专家级编程(全)
一、对象和原型
Abstract
熟能生巧。只有熟能生巧。
——文斯·隆巴迪
熟能生巧。只有熟能生巧。——文斯·隆巴迪
在一本面向专家的书中包含三章关于 JavaScript 核心概念的内容似乎有些奇怪。毕竟,这些主题是这门语言最基本的组成部分。我的观点是:就像一个没有读写能力的人可以说一种语言一样,开发人员也可以使用 JavaScript 的基本特性,却幸福地意识不到它们的复杂性。
这些章节的目的是揭示语言中一些更模糊的部分。这些概念可能是你一直想要学习的,甚至是你已经理解的。想象一下,你正在进入大脑的地下室,那里储存着 JavaScript。像用手电筒一样用这篇文章来检查你知识基础的裂缝。这一章和下一章旨在填补任何可能被揭露的漏洞。不要认为这是不必要的复习,而是对您对 JavaScript 理解的结构性评估。
我将从这门语言的目标的高层次概述开始。但是在您知道之前,您将会趴在地上,在 JavaScript 鲜为人知的概念中爬行。我将详细描述与对象和原型相关的重要思想。然后,在接下来的章节中,你会看到函数和闭包,它们是 JavaScript 的构建块。
鸟瞰 JavaScript
我们所说的 JavaScript 实际上是 ECMAScript 语言规范的一种实现。要使 JavaScript 被认为是 ECMAScript 的有效版本,它必须提供支持规范中定义的语法和语义的机制。JavaScript 作为一个实现必须为程序员提供使用各种类型、属性、值、函数和保留字的启示,这些组成了 ECMAScript。
一旦一个版本的 JavaScript 符合 ECMAScript,语言设计者就可以自由地用他们认为合适的额外特性和方法来修饰他们的版本。ECMAScript 规范明确允许这种繁荣,正如您在这里看到的:
The consistent implementation of ECMAScript allows providing other types, values, objects, properties and functions than those described in this specification. In particular, the consistent implementation of ECMAScript allows the objects described in this specification to be provided with attributes not described in this specification and their values. The consistent implementation of ECMAScript allows the support of program and regular expression syntax not described in this specification.
这些额外的特性可以与核心元素并行存在,并且仍然被认为是有效的实现,这一事实表明了 ECMAScript 标准体的进步性。ECMAScript 的松散性既是优点也是缺点。尽管添加新功能的灵活性鼓励了语言设计者的创新,但这可能会让开发人员在试图编写聪明的 polyfill1来支持各种实现和运行时环境之间的差异时处于不利地位。
ECMAScript 规范会随着时间的推移而发生变化,原因有很多(这里无法一一列举)。不过,这些变化主要是试图用新方法来解决老问题,或者支持更大的计算生态系统中的进步。不断变化的规范代表了一种将语言中的进化过程形式化的尝试。因此,尽管我在谈论“核心概念”,好像它们是不可变的,但实际上它们不是。本章探讨的概念是基础的,也是重要的,但是我给读者的建议是保持警觉。
设计脚本
顾名思义,ECMAScript 是一种脚本语言,用于以编程方式与宿主环境进行交互。主机系统,无论是浏览器、服务器还是硬件,都公开了供 JavaScript 操纵的控制点。大多数主机环境只允许 JavaScript 触发已经在用户控制下的系统方面(尽管是手动的)。例如,当浏览器用户使用鼠标或手指点击网页上的链接时,JavaScript 可以通过编程触发相同的事件:
document.getElementById('search').click();
传统上,ECMAScript 几乎完全是作为浏览器中的 web 脚本工具。开发者用它来增强用户浏览网页的体验。今天,由于 V8 或 TraceMonkey 等独立引擎,ECMAScript 在服务器上和在浏览器上一样得心应手。
ECMAScript 标准团体预见到了开发人员传统上使用 JavaScript 的方式和最近增长的地方之间日益增长的差异。在最新规范中定义“web 脚本”时,它明智地提供了两个示例,展示了 ECMAScript 目前流行的各种环境:
Web browser provides ECMAScript host environment for client computing, for example, including objects representing windows, menus, pop-ups, dialog boxes, text areas, anchors, frames, history, cookies and input/output. In addition, the host environment provides a means to attach script code to events such as focus change, page and image loading, unloading, error and suspension, selection, form submission and mouse action. The script code appears in HTML, and the displayed page is a combination of user interface elements and fixed and calculated text and images. Script code is a response to user interaction and does not need the main program. Web server provides different host environments for server-side computing, including objects representing requests, clients and files; And sharing data. By using both browser-side and server-side scripts at the same time, the calculation can be distributed between client and server, and a customized user interface can be provided for Web-based applications. Each Web browser and server that supports ECMAScript provides its own host environment, thus perfecting the ECMAScript execution environment.
Note
在撰写本文时,ECMAScript 6 的最新版本(名为“Harmony”)即将到来,尽管尚未正式发布,但许多提议的更改已经得到了运行时引擎和浏览器的支持。这一章详尽地介绍了该语言的核心,其中也包括了在 Harmony 中引入的一些新特性。当我解释一个可能支持有限的提议特性时,我会特别注意提醒读者。
对象概述
JavaScript 是一种面向对象编程(OOP)语言,由 Brendan Eich 创建,他在为 Netscape 工作时经过几周的开发后发布了这种语言。JavaScript 虽然名字里有“Java”,但是和 Java 语言关系不大。在 InfoWorld 的一次采访中,Eich 解释了导致这种语言被改名为“JavaScript”的事件的转变:据我所知,JavaScript 开始是 Mocha,然后变成 LiveScript,然后在 Netscape 和 Sun 合作时变成 JavaScript。但实际上和 Java 没什么关系或者关系不大,对吧?艾希:没错。从 1995 年 5 月到 12 月的六个月里,先是摩卡,然后是 LiveScript。然后在 12 月初,网景和 Sun 签订了一份许可协议,它变成了 JavaScript。我们的想法是让它成为 Java 和编译语言的补充脚本语言。2
即使随便比较一下这两种语言,也会发现明显的差异。与 Java 不同,JavaScript 不被编译,不强制严格的类型,也没有正式的基于类的继承机制。相反,JavaScript 在主机环境(例如,web 浏览器)的上下文中执行,支持变量的动态类型化,并且通过原型链而不是类来实现继承。因此,我们也许应该把两个名字之间的相似之处记在账上,作为对营销协同作用的渴望,而不是试图在两种语言之间建立有意义的联系。
尽管 Java 和 JavaScript 有所不同,但它们都是 OOP 家族的成员。面向对象意味着对象通过相互通信来控制程序的运行。OOP 语言是一些流行的编程范例,其中包括函数式、命令式和声明式。
Note
仅仅因为 JavaScript 被认为是一种面向对象的语言,并不意味着它局限于这种范式。比如现在流行的库下划线. js 3 就是用函数式编程风格编写的。
客体化
作为一门 OOP 语言意味着什么?对于有经验的程序员来说,这似乎是一个不必要的问题,但是回答这个问题的行为给了你评估 JavaScript 的 OOP 方法所需要的空间。本书的大部分内容都是关于对象及其相互关系的设计和思考,但重要的是要记住,对象只是用于建模程序的许多可能隐喻中的一种。
隐喻是诱人的,而且往往模糊不清,因为它们揭示了;它们的启示可能会让你清晰地构思出一个问题的解决方案,同时不必要地使另一个问题复杂化。当你回答 OOP 意味着什么时,反思你自己的理解和假设。你可能会发现你对这个概念有偏见。
JavaScript 中的对象只不过是属性的容器。我曾听程序员把它们描述为“属性包”,这给人一种赏心悦目的感觉。每个对象可以有零个或多个属性,这些属性可以保存一个原始值或引用一个复杂对象的指针。JavaScript 可以用三种方式创建对象:使用文字符号、new()
操作符或create()
函数。以最简单的形式,这三种方法可以表达如下:
var foo = {}
,
bar = new Object()
,
baz = Object.create(null);
这两种方法的区别在于对象的初始化方式,我们将在后面详细介绍。现在,我将描述通过给对象分配自定义属性来修饰它们的方法。
道具员
许多开发人员认为对象的属性只是一个可以被赋予名称和值的容器。然而实际上,JavaScript 为开发人员提供了一系列强大的属性描述符,这些描述符进一步塑造了属性的行为。现在让我们对它们进行迭代:
可配置的
当该属性设置为 true 时,受影响的属性可以从父对象中删除,并且属性的描述符可以在以后修改。当设置为 false 时,属性的描述符将被密封,以防进一步修改。这里有一个简单的例子:
var car = {};
// A car can have any number of doors
Object.defineProperty(car, 'doors', {
configurable: true
,
value: 4
});
// A car must have only four wheels
Object.defineProperty(car, 'wheels', {
configurable: false
,
value: 4
});
delete car.doors;
// => "undefined"
console.log(car.doors);
delete car.wheels;
// => "4"
console.log(car.wheels);
Object.defineProperty(car, 'doors', {
configurable: true
,
value: 5
});
// => "5"
console.log(car.doors);
// => Uncaught TypeError: Cannot redefine property: wheels
Object.defineProperty(car, 'wheels', {
configurable: true
,
value: 4
});
正如你在前面的例子中看到的,wheels
变得固定,而doors
保持可延展性。程序员可能希望撤销属性的configurable
属性,作为一种防御性编程的形式,以防止对象被修改,就像该语言的内置对象一样。
可列举的
如果使用代码迭代对象的属性,则会出现可枚举属性。当设置为 false 时,不能迭代这些属性。这里有一个例子:
var car = {};
Object.defineProperty(car, 'doors', {
writable: true
,
configurable: true
,
enumerable: true
,
value: 4
});
Object.defineProperty(car, 'wheels', {
writable: true
,
configurable: true
,
enumerable: true
,
value: 4
});
Object.defineProperty(car, 'secretTrackingDeviceEnabled', {
enumerable: false
,
value: true
});
// => doors
// => wheels
for (var x in car) {
console.log(x);
}
// => ["doors", "wheels"]
console.log(Object.keys(car));
// => ["doors", "wheels", "secretTrackingDeviceEnabled"]
console.log(Object.getOwnPropertyNames(car));
// => false
console.log(car.propertyIsEnumerable('secretTrackingDeviceEnabled'));
// => true
console.log(car.secretTrackingDeviceEnabled);
从前面的例子中可以看出,即使一个属性是不可枚举的,也不意味着该属性完全隐藏了。可枚举属性可用于阻止程序员使用该属性,但不应用作保护对象属性免受检查的方法。
可写的
为 true 时,可以更改与属性关联的值;否则,该值保持不变。
var car = {};
Object.defineProperty(car, 'wheels', {
value: 4
,
writable: false
});
// => 4
console.log(car.wheels);
car.wheels = 5;
// => 4
console.log(car.wheels);
检查物体
在上一节中,您学习了如何在您创建的对象上定义自己的属性。就像在生活中一样,知道如何读和写是很有帮助的,所以在这一节中,您将学习如何在 JavaScript 中挖掘对象的底层。下面是在检查对象时值得了解的函数和属性列表。
Object.getOwnPropertyDescriptor
在上一节中,您看到了设置属性的各种方法。Object.getOwnPropertyDescriptor
为您详细描述对象的任何属性的设置:
var o = {foo : 'bar'};
// Object {value: "bar", writable: true, enumerable: true, configurable: true}
Object.getOwnPropertyDescriptor(o,'foo');
Object.getOwnPropertyNames
此方法返回对象的所有属性名,甚至是无法枚举的属性名:
var box = Object.create({}, {
openLid: {
value: function () {
return "nothing";
}
,
enumerable: true
}
,
openSecretCompartment: {
value: function () {
return 'treasure';
}
,
enumerable: false
}
});
// => ["openLid", "openSecretCompartment"]
console.log(Object.getOwnPropertyNames(box).sort());
object.getprototypeof
此方法用于返回特定对象的原型。代替这个方法,可以使用__proto__
方法,这是许多解释器实现的一种访问对象原型的方法。然而,__proto__
总是被认为是一种黑客,JavaScript 社区主要将其作为权宜之计。然而,值得注意的是,即使Object.getPrototypeOf
允许您访问对象的原型,设置对象实例的原型的唯一方法是使用__proto__
属性。
var a = {};
// => true
console.log(Object.getPrototypeOf(a) === Object.prototype && Object.prototype === a.__proto__);
object . has owned 属性
JavaScript 的原型链允许你迭代一个对象的实例,并返回所有可枚举的属性。它包括不存在于对象上但存在于原型链中某处的属性。hasOwnProperty
方法允许您识别对象实例上是否存在相关的属性:
var foo = {
foo: 'foo'
};
var bar = Object.create(foo, {
bar: {
enumerable: true
,
value: 'bar'
}
});
// => bar
// => foo
for (var x in bar) {
console.log(x);
}
var myProps = Object.getOwnPropertyNames(bar).map(function (i) {
return bar.hasOwnProperty(i) ? i : undefined;
});
// => ['bar']
console.log(myProps);
对象.键
此方法仅返回对象的可枚举属性列表:
var box = Object.create({}, {
openLid: {
value: function () {
return "nothing";
}
,
enumerable: true
}
,
openSecretCompartment: {
value: function () {
return 'treasure';
}
,
enumerable: false
}
});
// => ["openLid"]
console.log(Object.keys(box));
Object.isFrozen
如果被检查的对象无法扩展且其属性无法修改,则此方法返回 true 或 false:
var bombPop = {
wrapping: 'plastic'
,
flavors: ['Cherry', 'Lime', 'Blue Raspberry']
};
// => false
console.log(Object.isFrozen(bombPop));
delete bombPop.wrapping;
// undefined;
console.log(bombPop.wrapping);
// prevent further modifications
Object.freeze(bombPop);
delete bombPop.flavors;
// => ["Cherry", "Lime", "Blue Raspberry"]
console.log(bombPop.flavors);
// => true
console.log(Object.isFrozen(bombPop));
Object.isPrototypeOf
此方法检查给定对象的原型链中的每个链接是否存在另一个对象:
// => true
Object.prototype.isPrototypeOf([]);
// => true
Function.prototype.isPrototypeOf(()=>{});
// => true
Function.prototype.isPrototypeOf(function(){});
// => true
Object.prototype.isPrototypeOf(()=>{});
Note
在撰写本文时,所谓的胖箭头语法仅在 Firefox 22 (SpiderMonkey 22)等浏览器中受支持。在不受支持的浏览器中运行箭头函数会产生语法错误。
Object.isExtensible
默认情况下,JavaScript 中的新对象是可扩展的,这意味着可以添加新的属性。但是,可以标记一个对象以防止它在将来被扩展。在某些环境中,在不可扩展的对象上设置属性会引发错误。在试图修改对象之前,您可以使用Object.isExtensible
来检查它:
var car = {
doors: 4
};
// => true
console.log(Object.isExtensible(car) === true);
Object.preventExtensions(car);
// => false
console.log(Object.isExtensible(car) === true);
Object.isSealed
该函数返回 true 或 false,具体取决于对象是否无法扩展以及其所有属性是否不可配置:
var ziplockBag = {};
// => false
console.log(Object.isSealed(ziplockBag) === true);
// => true
console.log(Object.isExtensible(ziplockBag));
Object.seal(ziplockBag);
// => true
console.log(Object.isSealed(ziplockBag) === true);
// => false
console.log(Object.isExtensible(ziplockBag));
对象. valueOf
如果你曾经试图检查一个对象,却看到它吐出“[object Object]
”,那你就见识过这个功能的厉害了。Object.valueOf
用来形容一个物体具有一个原始值。所有对象都接收这个函数,但它本质上是一个存根,意味着以后会被自定义函数覆盖。创建你自己的valueOf
函数提供了一种给你的自定义对象一个额外的描述细节层的方法:
var Car = function (name) {
this.name = name;
}
var tesla = Object.create(Car.prototype, {
name: {
value: 'tesla'
}
});
// => [Object object]
console.log(tesla.valueOf());
Car.prototype.valueOf = function () {
return this.name;
}
// => tesla
console.log(tesla.valueOf());
Object.is (ECMAScript 6)
测试两个值的相等性一直是一些 JavaScript 开发人员的痛处,因为 JavaScript 实际上支持两种形式的相等性比较。为了检查抽象的相等性,JavaScript 使用双重相等语法==
。当检查严格相等时,JavaScript 使用三重相等语法===
。两者之间的主要区别在于,默认情况下,抽象等式运算符会强制一些值来进行比较。Object.is
方法确定两个提供的参数是否具有相同的值,而不需要强制。以下是一些如何使用Object.is
方法的例子:
// True because both strings use the same characters and length.
Object.is('true', 'true')
// False because type case counts as a difference.
Object.is('True', 'true')
// True because function is coerced to true using the logical not operator.
Object.is(!function(){}(), true)
// True because the built-in Math object has no prototype.
Object.is(undefined, Math.prototype);
不要将此行为与严格相等比较运算符混淆,后者仅在两者共享同一类型而不是同一值时返回 true。下面的例子可以很容易地表示出来:
// => false
console.log(NaN === 0/0);
// => true
Object.is(NaN,0/0);
修改对象
除了能够探索现有对象的结构,能够修改(或防止修改)也是必不可少的。这一部分解释了各种可以让物体按照你的意愿弯曲的机制。
对象.冻结
冻结对象可以防止它被再次更改。冻结对象不能接受新特性、删除现有特性或更改其值:
var bombPop = {
wrapping: 'plastic'
,
flavors: ['Cherry', 'Lime', 'Blue Raspberry']
};
// => false
console.log(Object.isFrozen(bombPop));
delete bombPop.wrapping;
// undefined;
console.log(bombPop.wrapping);
// prevent further modifications
Object.freeze(bombPop);
delete bombPop.flavors;
// => ["Cherry", "Lime", "Blue Raspberry"]
console.log(bombPop.flavors);
// => true
console.log(Object.isFrozen(bombPop));
object . define 属性
该功能允许定义新属性或修改现有属性:
var car = {};
Object.defineProperties(car, {
'wheels': {
writable: true
,
configurable: true
,
enumerable: true
,
value: 4
}
,
'doors': {
writable: true
,
configurable: true
,
enumerable: true
,
value: 4
}
});
// => 4
console.log(car.doors);
// => 4
console.log(car.wheels);
对象.定义属性
此功能允许将单个属性添加到对象或修改现有属性:
var car = {};
Object.defineProperty(car, 'doors', {
writable: true
,
configurable: true
,
enumerable: true
,
value: 4
});
Object.preventExtensions
此功能防止新属性被添加到现有对象中。但是,不要将这种方法与冻结对象相混淆。虽然一个对象不能被扩展,但它可以被缩减,这意味着属性是可移除的。
var car = {
doors: 4
};
// => true
console.log(Object.isExtensible(car) === true);
Object.preventExtensions(car);
// => false
console.log(Object.isExtensible(car) === true);
// => 4
console.log(car.doors);
delete car.doors;
// => undefined
console.log(car.doors);
car.tires = 4;
// => undefined
console.log(car.tires);
对象.原型
设置对象的原型会将该对象与其现有的原型链分离,并将其附加到指定的新对象的末尾。这对于用另一个对象及其链中的对象的属性和方法来填充对象很有用。
var Dog = function () {};
Dog.prototype.speak = function () {
return "woof";
};
var Cat = function () {};
Cat.prototype.speak = function () {
return "meow";
};
var Tabby = function () {};
Tabby.prototype = new Cat();
var tabbyCat = new Tabby();
// => 'meow'
console.log(tabbyCat.speak());
// => undefined
console.log(tabbyCat.prototype);
// Setting the prototype of an object instance will not affect the instantiated properties
tabbyCat.prototype = new Dog();
// => Dog { speak: function }
console.log(tabbyCat.prototype);
// => 'meow'
console.log(tabbyCat.speak());
对象.密封
密封对象使其不可变,这意味着不能添加新属性,并且现有属性被标记为不可配置。这与冻结对象以防止对象被进一步修改不同,如下例所示:
var envelope = {
letter: 'To whom it may concern'
};
// => false
Object.isSealed(envelope);
Object.seal(envelope);
envelope.letter = "Oh Hai";
envelope.stamped = true;
// => Oh Hai
console.log(envelope.letter);
// => undefined
console.log(envelope.stamped);
调用对象
有时,一个对象借用另一个对象的函数是很有用的,这意味着借用对象只是执行借出的函数,就像它是自己的函数一样。就像你可能向朋友借一件毛衣一样。你暂时用这件毛衣来取暖,但用完之后就把它还回去。JavaScript 中的毛衣借用分别通过call()
和apply()
函数完成。它们的行为非常相似,除了call()
函数接受一个参数列表,而apply()
函数期望一个参数数组。这些方法对于向对象注入临时功能非常有用,比如使用核心对象的内置函数或者链接对构造函数的调用。
函数调用和函数应用
var friend = {
warmth: 0
,
useSweater: function (level) {
this.warmth = level;
}
};
var me = {
warmth: 0
,
isWarm: function () {
return this.warmth === 100;
}
};
// => false
console.log(me.isWarm());
try {
me.useSweater(100);
} catch (e) {
// => Object #<Object> has no method 'useSweater'
console.log(e.message);
}
friend.useSweater.call(me, 100);
// => true
console.log(me.isWarm());
me.warmth = 0;
// => false
console.log(me.isWarm());
friend.useSweater.apply(me, [100]);
// => true
console.log(me.isWarm());
创建对象
JavaScript 将几乎所有东西都视为一个对象,因此该语言的几乎每个元素都可以被创建、分配属性并链接到原型链。唯一的例外是语言的饿鬼null
和undefined
。在 JavaScript 中创建对象时,它们不是由整块布制成的。在这一节中,我将解释创建对象的三种方法,以及为什么需要不止一种方法。
Note
我曾经错误地认为数字不是对象,因为我不能使用点语法对它们调用方法——例如,1.toString()。事实证明,大多数解释者认为周期是整数和分数之间的分界线。如果使用分组括号(1)调用方法。toString()或双周期 1..toString(),成功了!
对象文字
文字语法将代码中的其他对象描述为一系列用逗号分隔的属性,这些属性用花括号括起来。与new Object()
和Object.create()
语法不同,字面语法没有被显式调用,因为字面符号实际上是在特定上下文中使用Object.create
方法的一种语法快捷方式。这里有一个例子:
var foo = {
bar: 'baz'
};
var foo2 = Object.create(Object.prototype, {
bar: {
writable: true
,
configurable: true
,
value: 'baz'
}
});
// => baz
console.log(foo.bar);
// => baz
console.log(foo2.bar);
字面语法清晰、富于表现力且简洁。你可以描述和创建你的对象,一步到位。这种特性使文字符号语法成为简单的一次性对象的最佳选择,这些对象用于处理事件、编组对象之间的状态变化,或者在保持代码可视化分组的同时划分功能。字面语法和new Object() form
的另一个细微区别是,字面语法的构造函数不能被重定义。然而,本机Object
构造函数属于全局名称空间,如果修改,可能会导致难以跟踪的意外行为。隐式调用字面语法的事实为代码提供了一点防御性编程。
var foo = new Object();
var bar = {};
// => object
console.log(typeof(foo))
// => object
console.log(typeof(bar))
window.Object = function(){ arguments.callee.call() };
// => Uncaught RangeError: Maximum call stack size exceeded
var foo = new Object();
字面语法并不适用于所有用例;例如,无法创建原型不是内置对象的对象。此外,因为文本语法是隐式调用的,所以没有显式的构造函数,这意味着对象文本不是好的对象工厂。
Note
对象文字不是 JSON。很多人把 Object literal 语法和 JSON 混为一谈,即使它们看起来很相似,但它们并不相同。JSON 只是一种数据描述语言,所以不能包含函数。此外,许多 JSON 解析器希望使用双引号来定义属性,而字面语法并不要求这样做。
新对象()
当我谈到new Object()
时,我真正讨论的是new
运算符。该运算符按需创建对象的实例。它接受一个构造函数和一系列在初始化过程中使用的可选参数。创建时,新创建的对象继承自构造函数的原型。
var Animal, cat, dog;
Animal = function (inLove) {
this.lovesHumans = inLove || false;
};
cat = new Animal();
dog = new Animal(true);
// => false
console.log(cat.lovesHumans);
// => true
console.log(dog.lovesHumans);
操作符是 JavaScript 试图模仿 Java 的残余结构。许多人对new
操作符感到困惑,因为它给 JavaScript 强加了一个伪经典词汇表,而 JavaScript 没有正式的基于类的继承方法。为了更好地理解new
在幕后做什么,让我们以前面的例子为例,剖析一下new
在为我们做什么。希望这能消除其语义带来的任何潜在的歧义。
1.JavaScript 创建一个新对象
这相当于创建一个对象文字{}
。
2.JavaScript 将新创建的对象的构造函数链接到Animal
函数
/*
* function (inLove) {
* this.lovesHumans = inLove || false;
* }
*/
console.log(cat.constructor);
3.JavaScript 将对象的原型链接到 Animal.prototype
在构造过程中,新创建的对象获得对以前构造函数的属性的引用。它们是一个浅层拷贝,如果以后修改,实际发生的是对构造函数属性的引用现在被一个局部引用所掩盖。
var Animal, cat, dog;
Animal = function (inLove) {
this.lovesHumans = inLove || false;
};
cat = new Animal();
dog = new Animal(true);
// capture the errors so our script will continue to execute.
try {
// => Uncaught TypeError: Object [object Object] has no method 'jump'
console.log(cat.jump());
} catch (e) {}
/*
* We can change the base object and have the changes reflected downward even
* to objects who have already been instantiated.
*/
Animal.prototype.jump = function () {
return "how high?!";
};
// => how high?!
console.log(cat.jump());
// => how high?!
console.log(dog.jump());
/*
* Changes to the local property do not propagate up the prototype chain.
* Instead, the reference to the prototype's property is blocked by the new local
* property of the same name.
*/
cat.jump = function () {
return "no";
}
// => no
console.log(cat.jump());
// => how high?!
console.log(dog.jump());
4.JavaScript 将任何提供的参数分配给新创建的对象
new
操作符在新创建的对象上封送任意数量的属性的初始化。它们作为参数传递给构造函数。
var Animal, dog;
Animal = function (inLove) {
this.lovesHumans = inLove || false;
};
//
new is essentially doing this:
// dog = {}
// dog.lovesHumans = true;
dog = new Animal(true);
如果你认为new
是一个乐于助人的工作精灵,它会按照食谱为你制作物品,那就没问题了。但是,如果你假设new
的行为和它在 Java 等其他语言中的行为一样,那你就不好过了。
对象.创建
在 ECMAScript 5 中引入Object.create
之前,创建原型继承的唯一方法是使用new
操作符。然而,出于所有的意图和目的,应该使用Object.create()
和字面符号来代替new Object()
。Object.create()
为开发者提供了与new
相同的好处,但是方法签名与语言的其他部分更加一致。Object.create
的优势不仅仅是语义上的改进,Object.create
实际上更加强大,主要是在它如何支持继承方面。Object.create
接受两个参数:一个用作原型的对象和一个可选的 property 对象,该对象包含用来配置新创建的对象的值。
var Car = {
drive: function (miles) {
return this.odometer += miles;
}
};
var tesla = Object.create(Car, {
'odometer': {
value: 0
,
enumerable: true
}
});
// => 10
console.log(tesla.drive(10));
本节研究了使用 JavaScript 创建、访问和修改对象的各种方式。一路上,我暗示了原型概念是如何工作的。下一节将解释 JavaScript 如何在 OOP 中实现常见的策略,比如继承,以及开发人员在尝试使用它时遇到的一些常见问题。
编程原型
OOP 语言的目的是创建虚拟对象,这些虚拟对象能够相互通信以完成任务。通常,这意味着用代码对实体的表示进行建模,然后让软件使用它来实现开发人员的目标。虽然前面的定义听起来很简单,但现实是,在编排对象之间的数据和状态交换时,经常会出现不可避免的混乱。当将复杂的现实世界问题领域转化为一系列相互依赖的对象时,尤其如此。一般来说,OOP 语言通过应用更高阶的概念,包括抽象、封装、继承和多态,减轻了将实体翻译成代码时固有的组织复杂性。在大多数 OOP 语言中,这些技术是使用类来应用的。
C++、JAVA 和 Ruby 等语言中的类是对象的描述,而不是对象本身。同样,你不会因为吃了一份冰激凌而大脑冻结,你也不能用一个类来执行工作。类是有意抽象的,因为它们必须定义它们所创建的潜在对象的所有特征、功能和启示。基于类的语言的支持者说,它们在结构和状态之间提供了清晰的描述。相反的论点是,类迫使一个不必要的僵化的本体对对象进行分类。
在 JavaScript 中,没有类定义这种东西。对象通过原型链接从其他对象继承它们的功能(如果需要)。这些原型链接可以反过来形成彼此之间的依赖链,从而通过组合实现复杂的行为。本节详细解释了原型概念的复杂性,以及如何在 JavaScript 中最大化其有效性。
为了充分解释使用原型编程的好处,您首先需要理解抽象、封装、继承和多态性的目标,因为它们适用于 JavaScript。作为对这四个概念的解释的一部分,我将使用编程示例来帮助清楚地描述 JavaScript 的原型和许多其他程序员可能更熟悉的基于类的方法之间的区别。
抽象
编程中的抽象是将现实世界的对象或过程在精神上转换成计算模拟的虚拟构造。抽象为程序员提供了一种机制,开始将复杂的主题分解成更小的离散部分。在大多数 OOP 语言中,这个过程被称为解耦。从类或原型的角度考虑问题是抽象的,因为它们给出了一个方便的隐喻来组织我们的程序,同时隐藏了与机器对话的实际低级代码。关于抽象的一个常见误解是,它们只是为了隐藏信息、将内容解耦到模块中,或者定义对象之间的清晰接口。尽管这些是抽象的战略目标,但是实现这些目标的策略会因语言而异。在 JavaScript 中,所有抽象的根源都是原型的使用,原型是处理封装、继承和多态的实际机制。
包装
软件设计中的封装有三个目标:隐藏实现、促进模块化和保护对象的内部状态。设计良好的对象隐藏了不需要的或特权信息,不让公众看到。封装通过定义一个公共接口来做到这一点,该接口为程序员提供了关于如何使用对象的足够信息,同时隐藏了它如何工作的细节。通过封装进行信息隐藏还允许业务逻辑的实现随着时间的推移而改变,而不会影响向用户公开的公共接口。这就像用户学习驾驶一辆汽车:一旦他们明白如何使用方向盘和踏板,发动机有多少个阀门就无关紧要了。
如果我们扩展前面的例子,我敢打赌,你可以在汽车之间交换引擎,而不需要司机重新学习如何驾驶汽车。他们可能会注意到汽车性能的不同,但界面保持不变。这个观察暗示了封装的下一个好处,那就是它促进了代码设计的模块化。
真正的封装还提供了第三个好处,即防止私有逻辑被其他对象访问或修改。这样,封装就像类周围的保护屏障,确保对象的内部工作不受干扰。
在基于类的语言中隐藏实现的一种流行方法是使用私有和公共函数。函数的私有性质是由语言强制实施的限制,这使得某些代码对类实例可用,但对外部对象不可访问。下面是一个 Java 示例:
public class Car{
private String name;
private int wheelCount;
public String getName(){
return name;
}
public void setName(String newName){
name = newName;
}
public String getWheelCount(){
return wheelCount;
}
public void setWheelCount( String wheels){
wheelCount = wheels;
}
}
正如你在前面的例子中看到的,直接访问name
或wheelCount
变量是不可能的,因为 Java 允许它们被声明为私有的。若要访问它们,必须改用类的公共方法。通常,这些代理方法被称为 getters 和 setters。通过这种方式,仍然可以使用这些变量,尽管是通过一个受控的接口。
JavaScript 基于原型的方法的一个结果是,它防止对象将属性指定为私有,这使得封装更加困难(但不是不可能!).
var Car = function(){
var name = 'Tesla';
var wheelCount = '4';
this.getName = function(){
return name;
}
this.getWheelCount = function() {
return wheelCount;
}
this.setName = function(newName) {
name = newName;
}
this.setWheelCount = function(newCount) {
wheelCount = newCount;
}
}
var myCar = new Car();
// #=> undefined
console.log(myCar.name);
myCar.name = "Corvette";
// #=> 'Corvette'
console.log(myCar.name);
// #=> 'Tesla'
console.log(myCar.getName());
// #=> 'Corvette'
myCar.setName('Corvette');
console.log(myCar.getName());
在这个脚本中,您可以看到在函数体内定义了两个局部变量。由于 JavaScript 的函数级作用域的工作方式,这两个变量是隐式私有的。为了向外部公开它们的值,您可以创建自己的 getter 和 setter 方法。关键的一点是,通过使用局部变量而不是对象属性,它们的值不会被外部访问。
这种方法提供了良好的封装,因为它通过信息隐藏促进了模块化,并保护对象的内部状态免受不必要的全局访问。
多态性
多态描述了一个对象在特定环境下像另一个对象一样工作的能力。在 OOP 语言中有许多类型的多态性,但是“特别多态性” 4 在 JavaScript 中尤其普遍和有用。这一节探讨了特殊多态性是如何工作的。
特定多态性
即席多态为对象提供了使用调用上下文来决定结果的能力。上下文可能包括调用对象或提供给方法的参数类型。临时多态性有时被称为函数重载或操作符重载,因为这些技术是实现这种形式的多态性的常用方法。
函数重载
在 C++等静态类型语言中,函数重载允许开发人员定义多个同名函数,只要它们的方法签名互不相同。函数之间的区别是通过要求不同数量的自变量或不同类型的自变量来实现的。一旦实现,就由编译器根据所提供的参数的数量和类型来选择正确的函数。
JavaScript 函数不执行类型检查,可以接收任意数量的参数。这种灵活性意味着函数重载开箱即用,无需声明同一函数的多种风格。
运算符重载
许多语言都支持运算符重载,开发者可以通过重载重新定义运算符的工作方式。JavaScript 不支持这种级别的重载,但是允许操作者根据使用它们的上下文来改变它们的行为。根据使用情况,考虑“+
”操作符的行为。
// summation
// => 2
console.log(1+1);
// concatenation
// => "foo bar"
console.log("foo " + "bar");
// accumulation
// => 2
var num = 1;
console.log(num++);
遗产
继承定义了对象之间的语义层次,允许孩子创建父类的专门化、一般化或变体。5
遗产的定义字面意思是将权利、财产和义务传给另一方(通常是在死后)6。在基于类的语言中,继承被描述为在对象之间形成“is-a”7关系(类Dog
是Mammal
的子类,而Animal
是Mammal
的超类)。
事实上,孩子可以从他们的父母那里继承规范,这使得许多开发人员相信,继承也为程序员提供了在他们的系统中重用代码的渠道。直觉上这是有意义的;想象一个所有对象都互相共享属性的集合。通过将这些公共特性提取到一个基类中,每个孩子都将自动受益于这些特性,而不必在内部重新定义这些特性。
然而,通过继承来重用代码是非常困难的,因为在大多数语言中,一个孩子只能从一个父母那里继承。这种限制会导致类继承它不需要的代码,或者需要重写父类的功能。Angus Croll 简洁地描述了使用继承进行代码重用的问题,他写道:
Using inheritance as a tool for code reuse is a bit like ordering a happy package because you want plastic toys. Of course, the circle is a shape, and the dog is a mammal-but once we go beyond those textbook examples, most of our hierarchical systems will become arbitrary and fragile-this is established to manipulate behavior, even if we pretend to represent reality. In order to reuse a few, successive generations are carrying more and more unexpected or irrelevant behaviors. 8
改变类的继承品质的需要混淆了父类和子类之间的关系。此外,通过省略或覆盖父元素的某些方面,子元素还会破坏封装,并通过紧密耦合 9 来提升脆弱的代码。
JavaScript 中的继承也绝不完美。JavaScript 使用差分继承 10 ,其中所有的对象都是从一个通用的基类而不是某个父类派生出来的。每个被创建的对象都有一个对创建它的对象的引用,这是该对象的原型。基于类的继承根据相似性定义对象之间的关系,而差异继承使用原型和后代之间的微小差异作为分界线。
原型的力量
包括 JavaScript 在内的基于原型的语言允许一个对象通过原型链接引用另一个对象,从而增加了对象的复杂性。JavaScript 使用原型链作为对象之间的动态委托机制,其中引用属性的尝试沿着原型链向上传播,直到到达最后一个链接。实际上,原型为开发人员提供了组织和重用代码的灵活工具。本节探讨如何访问和扩充对象的原型链。
理解原型
在 JavaScript 中,可以通过三种方式访问原型:
Foo.prototype
定义使用new
运算符实例化的对象的原型;例如,new Foo()
。Object.getPrototypeOf(foo)
返回给定对象的原型引用。Foo.__proto__
是指向对象构造器自己的原型对象的属性。此属性引用是非标准的,但较旧的引擎可能依赖于它。因此,__proto__
现在已经被编入最新版本的 ECMAScript (ES6)中。我提到这个属性只是为了完整性。如果你需要引用一个对象的原型,你应该选择标准化的Object.getPrototypeOf()
而不是这个__proto__
。
下面的代码演示了读取 prototype 对象的各种方法:
var Car = function (wheelCount) {
this.odometer = 0;
this.wheels = wheelCount || 4;
};
Car.prototype.drive = function (miles) {
this.odometer += miles;
return this.odometer;
};
var tesla = new Car();
// => true
console.log(Object.getPrototypeOf(tesla) === Car.prototype);
// => true
console.log(tesla.__proto__ === Car.prototype);
拥有一个原型对象似乎有些危险,因为如果一个对象无意中修改了原型的一个属性会发生什么呢?事实证明,JavaScript 可以防止这类事情的发生;任何试图设置原型属性的行为都会在对象实例上产生一个新的属性,从而阻碍对原型链中某个位置的属性的访问。继续以汽车为例,您可以看到这一幕:
var tesla = new Car();
// => 4
console.log(tesla.wheels);
var isetta = new Car(3);
// =>3
console.log(isetta.wheels);
isetta.drive = function (miles) {
this.odometer -= miles;
return this.odometer;
};
// => -10
console.log(isetta.drive(10));
// => 10
console.log(tesla.drive(10));
// Changes made to the prototype are propagated throughout the chain.
Car.prototype.drive = function (miles) {
this.odometer += miles * 2;
return this.odometer;
};
// However it cannot propagate changes to properties defined inside the constructor.
Car.prototype.odometer = 0;
// => -20 no change because the local function obscures the prototype's new version
console.log(isetta.drive(10));
// => 30
console.log(tesla.drive(10));
这种方法有几个优点:
- 通过链接对象访问的原型的属性仅仅是一个浅层引用,这增加了一层对意外更改的防御。
- 浅属性引用节省内存,因为给定的属性或函数只有一个实例。
- 添加到原型对象的属性会立即向下传播到属性链中较低的对象。
car 构造函数的最后一个例子试图在运行时重置odometer
的值,希望它能重置所有实例的值。它失败了,因为odometer
属性是在构造函数中定义的。然而,如果您已经在原型上以与驱动方法相同的方式定义了odometer
,只要对象实例没有定义自己的里程表本地副本,更改就会生效,这发生在drive()
功能期间。
var Car = function (wheelCount) {
this.wheels = wheelCount || 4;
};
Car.prototype.odometer = 0;
Car.prototype.drive = function (miles) {
this.odometer += miles;
return this.odometer;
};
var tesla = new Car();
// assign the odometer a new default value.
Car.prototype.odometer = 200;
// => 210
console.log(tesla.drive(10));
// assign it yet again.
Car.prototype.odometer = 2000;
// This change fails because the drive function set a local variable for odometer as it runs.
// => 220
console.log(tesla.drive(10));
按惯例分类
JavaScript 没有正式的基于类的结构。即使最后一节证明了这一事实,我也不会责怪一些读者在内心深处有一丝疑虑。也许这种怀疑是因为 JavaScript 领域充斥着对类或基于类的术语的引用。让事情更加混乱的是,该语言有一个保留的class
关键字,它什么也不做!道格拉斯·克洛克福特认为 JavaScript 是伪经典的,因为他认为对象是由构造函数产生的,这是“不必要的间接层次”(Crockford,2008)。每当人们谈论 JavaScript 中的类时,他们谈论的是作为风格惯例的类,而不是语言的特性。
进行这种区分是很重要的,因为那些熟悉其他语言的类的人会带来某些心理产物和对它们如何工作的期望。这些先入为主的想法可能会让期望 JavaScript 有同样行为的开发人员迷失方向。接下来是对从类的角度思考的 JavaScript 开发人员的讨论,关于他们如何使用设计模式实现类行为。这种模式是内置语言特性和编码约定的混合。在基于类的面向对象语言中,一般来说,状态由实例承载,方法由类承载,继承只是结构和行为。在 ECMAScript 中,状态和方法由对象携带,而结构、行为和状态都是继承的。11
构造器
直觉上,似乎构造函数的目标是构造一个对象。在 JavaScript 中,构造函数只不过是函数,当用一个new
操作符调用时,它返回一个实例对象。在 JavaScript 中,任何使用new()
操作符调用的函数都是构造函数。构造函数的目的是用合理的默认值初始化新创建的对象。根据经验,只定义从构造函数派生的所有实例所需的属性和函数。
var Car = function(){
// Instance Property
this.running = false;
// Instance Method
this.start = function(){
return this.running = true;
}
}
var tesla = new Car();
// => false
console.log(tesla.running);
// => true
console.log(tesla.start());
没有 new 运算符,并非所有内置函数都可以调用。这通常是因为内置对象没有可返回的合理默认值。调用Date()
函数会返回一个表示当前日期和时间的字符串,而调用Math()
函数会返回一个错误。
// => "Wed May 15 2013 15:42:24 GMT-0400 (EDT)"
Date()
// => TypeError: object is not a function
Math();
如果可能,最好从构造函数返回类似的结果,不管它是否在 new 运算符的上下文中被调用。大卫·赫尔曼在他的“让你的构造函数成为新的不可知论者”(Herman,2013)一节中详细讨论了这个话题。然而,JavaScript 的许多内置对象并不遵守这一约定。
// Zero is returned as specified by the built-in Number object's constructor.
// => 0
var num = Number();
// A new instance of the number object is returned.
// => Number {}
var num = new Number();
Note
JavaScript 没有正式的类,但是它遵循其他语言中使用的命名约定,在这些语言中使用大写名称(例如,“Foo”表示类对象)。
实例属性
实例属性是描述对象实例质量的任何可公开访问的变量。实例属性是那些可能因对象而异的值。在前面的示例中,this.running
是一个实例属性。实例属性可以在构造函数内部定义,也可以作为原型对象的一部分单独定义。
var Car = function(wheelCount){
this.wheels = wheelCount || 4
}
Car.prototype.odometer = 0;
var tesla = new Car();
// => 4
console.log(tesla.wheels);
// => 0
console.log(tesla.odometer);
实例方法
实例方法提供对对象实例有用的功能。实例方法还可以访问实例属性。实例方法可以用两种方式定义:它可以通过引用this
关键字来扩展实例,或者直接将属性设置为原型链。
var Car = function(){
// Instance Property
this.running = false;
// Instance Method
this.start = function(){
return this.running = true;
}
}
Car.prototype.stop = function() {
return this.running = false;
}
var tesla = new Car();
// => false
console.log(tesla.running);
// => true
console.log(tesla.start());
// => false
console.log(tesla.stop());
类别属性
类属性是属于类对象本身的变量。它们对于永远不会改变的属性很有用,比如常量。核心Math
对象有一个类属性PI
,其默认值为3.141592653589793
。在 JavaScript 中,类属性可以直接在构造函数上设置。
var Cake = function () {};
Cake.isLie = true;
类方法
类方法,有时被称为静态方法,是只对类本身可用的函数。类方法可以访问类属性,但不能访问对象实例的属性。类方法通常是对提供的参数执行计算并返回结果的实用函数。例如,考虑核心Math
对象的各种类方法。类方法的定义方式与类属性相同。如果您想给内置的String
对象添加一个反向的类方法,您可以简单地这样写:
String.reverse = function (s) {
return s.split("").reverse().join("");
};
// => secret message
console.log(String.reverse("egassem terces"));
Note
即使允许,你也不会真的想要像这样扩展一个核心 JavaScript 对象。这至少被认为是不礼貌的,但可能会给你或他人的代码带来错误。此规则的一个例外是,当一个对象通过使用 polyfill 进行扩展,以填充其他代码所期望的缺失功能时。
摘要
对象是 JavaScript 的构造块,为了确保您的构造尽可能地坚固,请考虑以下关键概念:
- 对象是包含零个或多个属性的包。
- 对象属性要么是基本类型,要么是复杂类型。对象可以保存自己的基本类型副本,但只能指向复杂类型。因此,JavaScript 属性被认为是通过引用传递或通过值传递。
- 对象属性可以有标志,这些标志在被修改时会改变对象的行为和功能。
- 可以通过以下三种方式之一创建对象:
- 使用字面语法'
{}
' - 将 new 运算符与构造函数'
new Foo()
'结合使用 - 使用内置的
Object.create()
功能。
- 使用字面语法'
- JavaScript 是一种基于原型的语言,其中对象通过原型链的链接相互关联。
- 当检查一个对象的属性时,它会查询原型链的每一步,直到它被返回或被确定为未定义。
- 当在存在于原型链中某处的对象上设置属性时,原型属性不会改变;相反,在本地对象上定义了一个新的属性,阻止对远程原型属性的访问。
- JavaScript 没有正式的类机制;所有类代码的使用都是约定,而不是语言的属性。
- JavaScript 使用差分继承意味着内存占用通常比使用抽象类要小得多。
- JavaScript 是一种面向对象的语言,但这并不妨碍您用许多其他编程范式编写 JavaScript。
Footnotes 1
http://remysharp.com/2010/10/08/what-is-a-polyfill/
2
http://www.infoworld.com/d/developer-world/javascript-creator-ponders-past-future-704
3
4
http://en.wikipedia.org/wiki/Ad_hoc_polymorphism
5
6
http://en.wikipedia.org/wiki/Inheritance
7
http://en.wikipedia.org/wiki/Is-a
8
http://javascriptweblog.wordpress.com/2010/12/22/delegation-vs-inheritance-in-javascript/
9
[en.wikipedia.org/wiki/Coupling_(computer_programming)
](http://en.wikipedia.org/wiki/Coupling_(computer_programming)
10
https://developer.mozilla.org/en/docs/Differential_inheritance_in_JavaScript
11
ECMA 262 版工作草案
二、函数
Abstract
正如你在前一章所学的,JavaScript 中几乎所有的东西都是对象,包括函数。然而,函数不仅仅是容纳属性的袋子;它们是用这种语言完成工作的方式。通常,只有当开发人员写的东西在他们面前爆炸时,他们才会意识到函数的细节。我在这一章的目标是向你展示 JavaScript 函数的复杂性,这将有望使你不必从代码库中取出语法碎片。
正如你在前一章所学的,JavaScript 中几乎所有的东西都是对象,包括函数。然而,函数不仅仅是容纳属性的袋子;它们是用这种语言完成工作的方式。通常,只有当开发人员写的东西在他们面前爆炸时,他们才会意识到函数的细节。我在这一章的目标是向你展示 JavaScript 函数的复杂性,这将有望使你不必从代码库中取出语法碎片。
在我开始之前,有一点需要注意:JavaScript 的好坏取决于它的解释器。尽管这里讨论的概念在语言规范中已经很好地涵盖了,但这并不意味着所有的主机环境都将以相同的方式工作。换句话说,你的里程可能会有所不同。本节将讨论 JavaScript 函数的常见误解以及它们引入的无声错误。但是,没有详细介绍调试函数。幸运的是,JavaScript 社区中的其他人已经记录了函数中的错误,特别是在 Juriy Zaytsev 的优秀文章“命名函数表达式去神秘化”中。 1
JavaScript 中的块
在你理解 JavaScript 中的函数之前,你必须理解块。JavaScript 块只不过是组合在一起的语句。块以左花括号“{”开始,以右花括号“}”结束。简单地说,块允许括号内的语句一起执行。块构成了 JavaScript 中最基本的控制结构。以下是 JavaScript 中块如何工作的几个例子:
// Immediately invoked function expression
;!function () {
var triumph = false
,
cake = false
,
satisfaction = 0
,
isLie
,
note;
// Block used as part of a function expression
var isLie = function (val) {
return val === false;
}
// Block used as part of a conditional statement
if (isLie(cake)) {
triumph = true;
makeNote('huge success');
satisfaction += 10;
}
// Block used as part of a function declaration
function makeNote(message) {
note = message;
}
}();
正如您之前看到的,函数本质上是开发人员可以按需调用的命名块。这很容易证明:
// The inline conditional block statement is executed only once per cycle.
if (isLie(cake)) {
...
}
function makeNote(message) {
...
}
// The function declaration is executed as many times as it is called.
makeNote("Moderate Success");
makeNote("Huge Success");
函数参数
控制流语句(if、for、while 等)等函数。)可以通过向函数体传递参数来初始化。在 JavaScript 中,变量或者是复杂类型(例如,对象、数组),或者是基本类型(例如,字符串、整数)。当复杂对象作为参数提供时,它通过引用传递给函数体。JavaScript 不是发送变量的副本,而是发送一个指针指向它在内存堆中的位置。相反,当把一个基本类型传递给一个函数时,JavaScript 通过值传递。这种差异可能会导致微妙的错误,因为从概念上讲,函数通常被视为一个黑盒,并假设它们只能通过返回变量来影响封闭范围。通过引用传递,参数对象被修改,即使它可能没有被函数返回。这里演示了按引用传递和按值传递:
var object = {
'foo': 'bar'
}
,
num = 1;
// Passed by reference
;!function(obj) {
obj.foo = 'baz';
}(object);
// => Object {foo: "baz"}
console.log(object);
// Passed by value;
;!function(num) {
num = 2;
}(num);
// => 1
console.log(num);
获胜的论点
对于设计不需要预先确定数量的参数作为方法签名的函数来说,arguments
对象是一个有用的工具。arguments
对象背后的思想是,它就像一个通配符,允许您像数组一样通过迭代这个特殊的对象来访问任意数量的参数。这里有一个例子:
var sum = function () {
var len = arguments.length
,
total = 0;
for (var x = 0; x < len; x++) {
total += arguments[x];
}
return total;
};
// => 6
console.log(sum(1, 2, 3));
然而,arguments 对象最令人沮丧的一个方面是,它有足够多的类似数组的行为来绊倒开发人员。如果重写函数以使用更多数组方法,脚本将失败:
var sum = function () {
var total = 0;
while (arguments.length > 0) {
total += arguments.pop();
}
return total;
};
// Uncaught TypeError: Object #<Object> has no method 'pop'
sum(1, 2, 3);
幸运的是,ESCMAScript 6 改进了函数接受参数的方式,使得原始的arguments
对象几乎不再有用。让我们来看几个支持参数的新特性。
defaultParameters (ECMAScript 6)
许多语言允许您为方法签名中的参数选择默认值。最后,在 ECMAScript 6 (ES 6)中,JavaScript 将是这些语言中的一种。
var join = function (foo = 'foo', baz = (foo === 'foo') ? join(foo + "!") : 'baz') {
return foo + ":" + baz;
}
// => hi:there
console.log(join("hi", "there"));
// Use the default parameter when not supplied
// => hi:baz
console.log(join("hi"));
// Use the default parameter when undefined is supplied
// => foo:there
console.log(join(undefined, "there"));
// Use an expression which has access to the current set of arguments
// => foo:foo!:baz
console.log(join('foo'));
其他(ECMAScript 6)
有时,设计接受任意数量参数的函数是有用的,甚至是必要的。然而,由于argument
对象的不稳定性,这可能很棘手。
var dispatcher = {
join: function (before, after) {
return before + ':' + after
}
,
sum: function () {
var args = Array.prototype.slice.call(arguments);
return args.reduce(function (previousValue, currentValue, index, array) {
return previousValue + currentValue;
});
}
};
var proxy = {
relay: function (method) {
var args;
args = Array.prototype.splice.call(arguments, 1);
return dispatcher[method].apply(dispatcher, args);
}
};
// => bar:baz
console.log(proxy.relay('join', 'bar', 'baz'));
// => 28
console.log(proxy.relay('sum', 1, 2, 3, 4, 5, 6, 7));
在前面的例子中,我们的代理对象需要一个参数,这个参数是 dispatcher 上调用的方法。它不知道它所调用的函数还需要多少其他参数。如您所知,参数对象不是数组,因此没有有用的方法,如splice
、map
或reduce
。为了将剩余的任意数量的参数发送给调度程序,您必须用一个数组来处理它们。
rest
参数消除了函数间令人讨厌的秘密握手。下面是使用rest
参数重写的先前方法:
var dispatcher = {
join: function (before, after) {
return before + ':' + after
}
,
sum: function (...rest) {
return rest.reduce(function (previousValue, currentValue, index, array) {
return previousValue + currentValue;
});
}
};
var proxy = {
relay: function (method, ...goodies) {
return dispatcher[method].apply(dispatcher, goodies);
}
};
// => bar:baz
console.log(proxy.relay('join', 'bar', 'baz'));
// => 28
console.log(proxy.relay('sum', 1, 2, 3, 4, 5, 6, 7));
函数类型
现在您已经对块和参数有了更好的理解,让我们更深入地研究函数声明和函数表达式,这是 JavaScript 中使用的两种类型的函数。对于普通读者来说,这两者非常相似:
// Function Declaration
function isLie(cake){
return cake === true;
}
// Function Expression
var isLie = function(cake){
return cake === true;
}
两者之间唯一真正的区别是何时被评估。解释器可以在解析函数声明时访问它。另一方面,函数表达式是赋值表达式的一部分,它阻止 JavaScript 在程序完成赋值之前对其求值。这种差异可能看起来很小,但意义是巨大的;考虑下面的例子:
// => Hi, I'm a function declaration!
declaration();
function declaration() {
console.log("Hi, I'm a function declaration!");
}
// => Uncaught TypeError: undefined is not a function
expression();
var expression = function () {
console.log("Hi, I'm a function expression!");
}
正如您在前面的例子中看到的,函数表达式在被调用时抛出了一个异常,但是函数声明执行得很好。这个异常触及了声明函数和表达式函数之间差异的核心。JavaScript 知道声明函数,可以在程序执行前解析它。因此,如果程序在定义函数之前就调用它,这并不重要,因为 JavaScript 已经在幕后将函数提升到了当前范围的顶部。在将函数表达式赋值给变量之前,不会对其求值;因此,它在被调用时仍然是未定义的。这就是为什么好的代码风格是在当前作用域的顶部定义所有变量。如果您当时这样做了,您的脚本将在视觉上与 JavaScript 在解析时所做的相匹配。
要带走的概念是,在解析期间,JavaScript 将所有函数声明移动到当前作用域的顶部。这就是为什么声明性函数出现在脚本体的什么地方并不重要。要进一步探究声明和表达式之间的区别,请考虑以下几点:
function sayHi() {
console.log("hi");
}
var hi = function sayHi() {
console.log("hello");
}
// => "hello"
hi();
// => 'hi'
sayHi();
如果您不经意地阅读这段代码,您可能会认为声明函数会被破坏,因为它的函数表达式有一个相同的名称。但是,因为第二个函数是赋值表达式的一部分,所以它有自己的作用域,JavaScript 将它们视为独立的实体。让事情更加混乱的是,看看这个例子:
var sayHo
// => function
console.log(typeof (sayHey))
// => undefined
console.log(typeof (sayHo))
if (true) {
function sayHey() {
console.log("hey");
}
sayHo = function sayHo() {
console.log("ho");
}
} else {
function sayHey() {
console.log("no");
}
sayHo = function sayHo() {
console.log("no");
}
}
// => no
sayHey();
// => ho
sayHo();
在前面的例子中,您看到了如果一个函数是表达式,而另一个是声明,那么同名的函数会被认为是不同的。在这个例子中,我试图根据程序如何执行来有条件地定义函数。阅读该脚本的控制流,您会期望 sayHey 返回“Hey ”,因为条件语句评估为 true。相反,它返回“否”,意味着第二个版本的sayHey
函数击败了第一个。更令人困惑的是 sayHo 函数的行为方式正好相反!同样,差异归结于解析时间和运行时间。
您已经了解到,当 JavaScript 解析脚本时,它会收集所有的函数声明并将它们提升到当前作用域的顶部。当这种情况发生时,它将第一个版本的 sayHey 与第二个版本的 say hey 撞在一起,因为它们存在于相同的作用域中。这解释了为什么它返回“否”。您还知道,在赋值过程完成之前,解析器会忽略函数表达式。赋值发生在运行时,也就是计算条件语句的时候。这解释了为什么 sayHo 函数可以有条件地定义。这里要记住的关键是函数声明不能有条件地定义。如果需要条件定义,请使用函数表达式。此外,函数声明不应该在控制流语句中进行,因为解释器处理它的方式不同。
函数范围
与许多其他语言的作用域是块不同,JavaScript 的作用域是函数。在 Ruby(1.9 . x 版)中,可以这样写:
x = 20
10.times do |x|
# => 0..9
puts x
end
# => 20
puts x
这表明每个块都有自己的作用域。相反,您可以用 JavaScript 编写类似的代码:
var x = 20;
// Functions have their own scope
;!function() {
var x = "foo";
// => "foo"
console.log(x);
}();
// => 20
console.log(x);
for (x = 0; x < 10; x++) {
// => 0..9
console.log(x);
}
// => 10
console.log(x);
在 JavaScript 中,x
在 for 循环中是可用的,因为作为控制语句,它属于封闭范围。这对于许多习惯于阻塞级别范围的开发人员来说并不直观。JavaScript 至少部分通过使用闭包来处理块级范围的需求,我将在后面讨论。
箭头倾向(ECMAScript 6)
从 ES 5 开始,JavaScript 只支持函数级范围。这意味着this
总是引用函数体内的作用域。对于习惯于阻塞级别范围的开发人员来说,函数级别范围的这种质量一直是一个尴尬的事实。许多开发人员通过使用自由变量或使用绑定函数来规避这种行为。
// Option 1: Use a local free variable to bypass the need to reference this.
var VendingMachine = function () {
this.stock = ["Sgt. Pepper", "Choke", "Spite"];
var that = this;
return {
dispense: function () {
if (that.stock.length > 0) {
return that.stock.pop();
}
}
};
};
var popMachine = new VendingMachine();
// => 'Spite'
console.log(popMachine.dispense());
// Option 2: Use a bound function to reference this.
var VendingMachine = function () {
this.stock = ["Sgt. Pepper", "Choke", "Spite"];
var dispense = function () {
if (this.stock.length > 0) {
return this.stock.pop();
}
};
return {
dispense: dispense.bind(this)
};
};
var popMachine = new VendingMachine();
// => 'Spite'
console.log(popMachine.dispense());
幸运的是,ES 6 的主要新特性之一是通过使用所谓的胖箭头来消除词汇 this 的歧义。粗箭头是使用“=>
”而不是“function(){}``”来编写函数的一种新的更短的方法,使用过 CoffeeScript 的人会对它很熟悉。与任何变化一样,一些开发人员哀叹他们认为函数工作方式不必要的复杂。然而,当用于正确的问题时,粗箭头确实有其优势。下面是如何使用粗箭头重写
VendingMachine`函数:
// Option 3: Use a fat arrow to supply the lexical this.
var VendingMachine = function () {
this.stock = ["Sgt. Pepper", "Choke", "Spite"];
return {
dispense: () => {
if (this.stock.length > 0) {
return this.stock.pop();
}
}
};
};
var popMachine = new VendingMachine();
// => 'Spite'
console.log(popMachine.dispense());
除了语法更短之外,粗箭头还使得阅读代码更清晰,因为this
参数与代码的其余部分是可视化链接的。粗箭头还使开发人员能够编写更短、更简洁(但可读)的代码(例如,map 和 reduce 需要迭代器函数,当使用旧的函数范式编写时,这些函数看起来不必要的复杂)。现在,使用粗箭头,您可以在一行中编写简单的函数,这允许省略隐含的需求,如 return 语句。这是CoffeeScripters
熟悉的另一个约定,因为 CoffeeScript 函数中的最后一个语句总是返回值。
// function classic
var sum = [1, 2, 3, 4, 5].reduce(function (last, curr) {
return last + curr;
});
// => 15
console.log(sum);
// now with 100% more fat arrow.
var sum = [1, 2, 3, 4, 5].reduce((last, curr) => last + curr);
// => 15
console.log(sum);
函数符
JavaScript 中的函数是将整个语言结合在一起的粘合剂,掌握函数对征服整个语言大有帮助。记住这一点,您现在可以研究 JavaScript 中函数的几种高级用法,它们不仅可以提高代码的质量,还可以提高阅读的清晰度。
表达式闭包
表达式闭包是编写简单函数的捷径。如果表达式闭包看起来很熟悉,那是因为它们非常类似于 lambda 表达式在其他语言(如 Lisp)中的工作方式。
// => 10
[1, 2, 3, 4].reduceRight(function(curr, val) curr + val);
使用 ES 6 中新的胖箭头语法,可以节省更多的字符。
// => 10
[1,2,3,4].reduceRight((curr, val) => curr + val);
Note
目前,表达式闭包在大多数浏览器中支持有限。基于 Mozilla 的浏览器是唯一完全实现这种语法的浏览器。
立即调用的函数表达式
立即调用的函数表达式(IIFE)是一种你会看到各种库和框架重复使用的模式。最基本的形式是,它可以用几种方式来写:
;(function(){
...
})();
;!function(){
...
}();
;-function(){
...
}();
;+function(){
...
}();
;∼function(){
...
}();
// Not Recommended
;void function(){
...
}();
// Not Recommended
;delete function(){
...
}();
生命的美妙之处在于,它使用一元表达式来强制函数声明,这通常需要显式地调用到可以自我执行的函数表达式中。在内部,JavaScript 在函数声明上运行一元运算。该操作的结果是函数表达式,它会被后面的括号()
立即调用。除了优雅的代码之外,IIFE 还提供了以下内容:
- 它提供了防止命名冲突的闭包。
- 它提供了优雅的块范围。
- 它防止了全局名称空间的污染。
- 它促使开发人员从模块化代码的角度进行思考。
Note
值得一提的另一点是在语句前使用分号。添加它提供了一点防御编程,防止其他可能没有尾随分号的畸形模块。如果这只是一个函数声明,那么它将被吸收到前面的模块中,当多个脚本作为部署过程的一部分连接在一起时,这种情况经常发生。强烈建议您遵循这一约定,以保护自己免受生产中的神秘 bug。
递归函数
递归函数就是能够调用自身的函数。你可以把它们想象成受控循环。这种自我执行的能力被证明是一种优秀的工具,可以使代码更加简洁,同时降低复杂性。然而,递归函数并非没有潜在的危险;如果使用不当,递归函数会变成吞噬资源的内存黑洞,直到脚本失败。让我们来探索使用递归函数的正确方法,同时避免无限递归。
考虑以下示例:
var tree = {
name: 'Users'
,
children: [{
name: 'heavysixer'
,
children: [{
name: 'Applications'
,
children: []
}, {
name: 'Downloads'
,
children: []
}, {
name: 'Library'
,
children: [{
name: 'Accounts'
,
children: []
}, {
name: 'Arduino'
,
children: []
}]
}]
}, {
name: 'root'
,
children: []
}]
};
var walker = function walk(branch, newDepth) {
var depth = newDepth || 0;
var len = branch.children.length;
console.log(depth + ':' + branch.name);
while (len > 0) {
len--;
walker(branch.children[len], depth + 1);
}
};
/*
=> 0:Users
=> 1:root
=> 1:heavysixer
=> 2:Library
=> 3:Arduino
=> 3:Accounts
=> 2:Downloads
=> 2:Applications
*/
walker(tree);
在这个例子中,walker 函数接受一个表示目录树的 JSON 对象;迭代每个节点;并分别输出所有目录名及其深度的列表。您可以编写一系列嵌套的for
循环,并得到相同的输出。然而,要做到这一点,你必须首先计算树的绝对深度,以了解所需的循环次数。当然,这个过程是完全错误的,因为它使代码变得非常脆弱。使用递归函数,您可以以灵活的方式实现相同的效果,因为您可以测试子节点的存在,然后再递归调用该函数,这次提供当前分支作为根节点。
您可能想知道是否可以通过在 arguments 对象中使用callee
引用来进一步简化递归函数:
// reference the callee object from the arguments object
arguments.callee(branch.children[len], depth + 1);
不幸的是,使用arguments.callee
在严格模式下不起作用;它抛出一个错误:"Uncaught TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them."
Note
有关递归的更多信息,请参阅函数一章的函数部分。
高阶函数
当人们将 JavaScript 描述为具有“一流的函数”时,这意味着 JavaScript 允许将函数作为参数提供给其他函数。一级函数是函数式编程范式的标志,JavaScript 也间接支持它。函数式编程将函数作为一个抽象单元来使用和执行。这与面向对象编程(OOP)不同,面向对象编程使用对象作为抽象的手段来操作和存储数据的变化状态。
一级函数的一个很大的特点是,它们允许使用 JavaScript 创建高阶函数,即接受函数作为参数或返回函数作为返回值的函数。高阶函数有许多优点,但主要用途之一是将常见功能抽象到一个地方。所以,JavaScript 中很多高阶函数的使用都是为了所谓的效用函数也就不足为奇了。例如,Jeremy Ashkenas 的项目 underscore.js 称自己是“一个 JavaScript 的实用函数库,它提供了许多您在 Prototype.js(或 Ruby)中期望的函数式编程支持,但没有扩展任何内置的 JavaScript 对象。 2
毫不奇怪,下划线. js 很好地利用了高阶函数。我在这里包含了两个这样的函数:
// The cornerstone, an
eachimplementation, aka
forEach.
// Handles objects with the built-in
forEach, arrays, and raw objects.
// Delegates to **ECMAScript 5**'s native
forEach if available.
var each = _.each = _.forEach = function(obj, iterator, context) {
if (obj == null) return;
if (nativeForEach && obj.forEach === nativeForEach) {
obj.forEach(iterator, context);
} else if (obj.length === +obj.length) {
for (var i = 0, l = obj.length; i < l; i++) {
if (iterator.call(context, obj[i], i, obj) === breaker) return;
}
} else {
for (var key in obj) {
if (_.has(obj, key)) {
if (iterator.call(context, obj[key], key, obj) === breaker) return;
}
}
}
};
// Return the results of applying the iterator to each element.
// Delegates to **ECMAScript 5**'s native
map if available.
_.map = _.collect = function(obj, iterator, context) {
var results = [];
if (obj == null) return results;
if (nativeMap && obj.map === nativeMap) return obj.map(iterator, context);
each(obj, function(value, index, list) {
results.push(iterator.call(context, value, index, list));
});
return results;
};
您可以使用_.map() like this:
// => [2,3,6]
var doubled = _.map([1, 2, 3], function(num){ return num * this.multiplier; }, {multiplier : 2});
当您打开_.map()
高阶函数时,您会看到几个使其如此强大的特性:
- 因为数据、迭代器和上下文是作为参数传递给 map 命令的,所以使用该函数变得非常有表现力。这是因为意图的透明性是由参数提供的。
- 该函数变得与实现无关,允许它在方法的本机实现不可用时充当多填充,否则遵从内置版本。
- 事实上,您可以将迭代器和执行上下文作为参数传入,这意味着 map 函数保持了令人满意的通用性。这使得该方法可以在各种各样的环境中使用,从而减少了代码重复发生的机会。
调试函数
在我结束这个主题之前,让我们简单地讨论一下调试函数。在 JavaScript 命名中,函数表达式是完全可选的。那么为什么要这么做呢?答案是帮助调试过程。命名函数表达式可以在新定义的范围内访问其名称,但不能在封闭范围内访问。没有名字,他们的匿名性质会让他们在调试时感觉有点像机器中的幽灵。
var namedFunction = function named() {
// => function
console.log(typeof(named));
}
namedFunction();
// => undefined
console.log(typeof(named));
无名函数表达式在堆栈跟踪中显示为“(anonymous function)
”或类似的东西。当试图展开一个调用堆栈可能长达数英里的异常时,命名函数表达式会使您更加清晰:
/*
* It is much harder to debug anonymous function expressions
* Uncaught boom
* - (anonymous function)
* - window.onload
*/
;!function(){
throw("boom");
}();
/*
* Naming your function expressions give you a place to start looking when debugging.
* Uncaught boom
* - goBoom
* - window.onload
*/
;!function goBoom() {
throw("boom")
}();
摘要
在 JavaScript 中使用函数时,需要记住几个关键概念:
- 除了少数例外(如
let
操作符),JavaScript 具有函数级作用域,这与其他许多主要作用域在块级的语言不同。 - 函数有两种形式:函数声明和函数表达式。函数声明在运行时被提升,这允许你从本地块中的任何地方调用它们;如果在定义函数表达式之前调用它们,函数表达式会引发错误。
- 参数对象就像一个数组,足以让你陷入困境。
- ES 6 增加了一种将默认参数指定为函数签名一部分的方法。
- ES 6 引入了
rest
操作符,这为您提供了一种处理函数中任意数量参数的简单方法。 - 粗箭头函数可以作为在函数体内指定
this
值的简洁方式。 - 有许多很棒的概念范例,比如 IIFEs,可以用来使您的函数更强大、更易于管理。
- 尽可能使用命名函数,因为它们使堆栈跟踪更具可读性。
Footnotes 1
2
三、闭包
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-practice
http://sla.ckers.org/forum/read.php?24,32930
http://javascriptissexy.com/12-simple-yet-powerful-javascript-tips/
http://codegolf.stackexchange.com/questions/2682/tips-for-golfing-in-javascript
http://stackoverflow.com/questions/2350718/are-there-any-short-tricks-in-javascript-1-8-that-i-can-use-to-reduce-my-golf
http://www.benlesh.com/2012/05/javascript-fun-part-6-code-golf.html?m=1
http://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-callbacks
http://matt.might.net/articles/by-example-continuation-passing-style/
发电机
http://devsmash.com/blog/whats-the-big-deal-with-generators
http://jlongster.com/A-Study-on-Solving-Callbacks-with-JavaScript-Generators
协同程序
http://syzygy.st/javascript-coroutines/
http://www.dabeaz.com/coroutines/Coroutines.pdf
http://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
https://developer.mozilla.org/en-US/docs/Web/API/Blob
六、JavaScript IRL
Abstract
"头脑是模拟自己的模拟物."
-埃罗尔·奥扎恩
头脑是一种模拟自己的模拟物—埃罗尔·奥赞
兴奋起来;这一章是关于机器人、JavaScript 和其他的。不过说真的,机器人和 JavaScript 应该足够了。在这一章中,我将快速调查物理计算领域,以及用 JavaScript 编写的机器人如何融入其中。本章的大部分内容涵盖了使用监听 JavaScript 的机器与周围世界进行交互的方式。
硬件爱好者的日记
在我成长的过程中,我的弟弟马特得到了所有的二手技术。每当我们家升级一件消费电子产品时,旧版本就会被马特拿走,他几乎会立刻抱着它蜷缩在房间的地毯上,用双腿缠住猎物。然后,就像一只患有强迫症的秃鹰,他会开始用他的儿童螺丝刀有条不紊地拆开塑料尸体。最终,机器会停止运转,小的塑料齿轮、电线和电路板会散落出来。有时他会试着把它重新组装起来,或者把零件留着以后用。马特对一种完全不同的机器学习感兴趣。
硬件黑客并不是一个他已经超越的阶段。他变成了一个男孩巫师,用一把直角牧田螺旋枪代替了魔杖。15 岁时,他在一家名为 Dashboard Stereo 的公司找到了一份安装汽车音响系统的工作。他纠缠着老板达雷尔,直到他让步给了马特一份工作。马特是至少十年来最年轻的技术人员,但他已经比他们中的大多数人都要好。他是有史以来最年轻的移动电子认证专业(MECP)安装人员,年仅 15 岁。他在音响店工作到大学毕业,然后去了弗吉尼亚理工大学。毫不奇怪,马特成为了一名电气工程师,现在是麻省理工学院研究人员的一员。
消防水管
作为一名高中生,我被北卡罗莱纳艺术学校录取,这意味着我住在离家大约两个小时的地方。今年年初,马特把他的电动低音炮借给我,让我带回宿舍。不幸的是,我一到学校就意识到我把扬声器的插头忘在家里了。我去 Radio Shack 买了一个通用电源适配器,它看起来像一个手里剑,但有各种大小的插头,而不是金属点。我回到学校,把手里剑插在扬声器的后面,站在那里,手里拿着线的另一端看着墙壁。
从小到大,我一直梦想着像马特一样打开硬件,但在这里,我甚至被插上东西吓住了。我意识到我不知道我在做什么,所以我打电话给我爸爸。事实证明,这是个坏主意。我的父亲是另一个天才的机器语者;有时,当我哥哥和他没有任何共同语言时,他们至少会说瓦特和欧姆这两种共同语言。我解释了我不知道该用哪个插头的问题,这是他告诉我的:
Your problem is that you don't know the correct voltage or current to power the speakers. Imagine that your loudspeaker is running on water, so you hang it on the fire hose. Water flowing through the hose is the voltage, and the speed of water flow is the amperage.
我知道他觉得他已经用一个一年级学生就能理解的方式解释了。但我站在那里,把公用电话听筒贴在耳朵上,试图想象他刚才说的话。不过,我还不如打电话给中国,因为他在消防水管那里把我搞糊涂了。我回到我的宿舍,再次拿起线的一端。我想,会有多糟呢?
我在可用选项中选择了一个电源设置,并将电源线插入墙上的插座。我立刻听到扬声器里传来微弱的嗡嗡声,然后是一声巨响,然后就什么也没有了。我很快意识到我已经销毁了我哥哥的扬声器,我对我们家族拥有的与硬件交流的能力抱有的任何幻想都烟消云散了。灰色的烟雾慢慢地从扬声器的舷窗飘出。当然,马特发现后非常生气,可能是因为我没有问他,也可能是因为他不明白怎么会有人对电学基础知识如此无知。从那以后,我不敢再尝试了——也就是说,直到成年后我发现了 Arduino。
其他人的硬件
我在加州大学洛杉矶分校参加了一个演讲,我去听凯西·雷耶斯讲述他的项目“处理”Processing 是他和 Ben Fry 创建的一个程序,使艺术家能够用代码进行素描。Reas 最近加入了加州大学洛杉矶分校的设计媒体艺术系,我很有兴趣见到他本人,因为他在麻省理工学院上学时,我就一直关注着他的工作。作为那天晚上演讲的一部分,另一个人谈到了一种新的努力,那就是创造一种可以安装在单板上的廉价微控制器。该委员会和项目的名称是 Arduino,它试图让外行人也能接触到硬件,就像处理程序一样。目标是通过生产开源硬件来解放物理计算设备。
我作为一个感兴趣的观察者随意地关注着这个项目,并最终在一段时间后购买了自己的主板。当冲浪板到达时,它被放在我办公室的一个架子上,就像一个我没玩过的运动的奖杯一样放在那里。我不时地看着 Arduino,在电话会议期间把它从架子上拿下来,用手指触摸 PCB 的边缘,用拇指按压跳线引脚,就好像它们是一个迷你钉床一样。对我来说,这块小小的金属和硅胶包含了硬件的希望和危险。我非常想通过自己设计的硬件与这个世界互动,但我所能想象的只有从我哥哥的扬声器中飘出的灰色烟雾。
让我们开始身体接触吧
软件编程的一个很大的优点是很难把计算机的硬件搞砸。当然,你可能会不经意地通过写rm -rf /
而不是rm -rf ./
来擦除你的硬盘(我在这里是凭经验说的),但是硬盘仍然工作正常。软件更加宽容,允许更多的尝试和错误。硬件可以像火的审判(字面意思)。不小心放错线有可能会烧坏您的 Arduino 或电脑(或两者)。然而,硬件通过压电蜂鸣器播放的不可否认的警笛声在召唤我。在我的控制下,一个闪烁的 LED 灯的潜力比它应有的更有价值。
第一部分的大部分内容都是关于我个人在硬件方面的挫折和恐惧。我把这一节作为鼓励那些坐在硬件池边、戴着 JavaScript floaty 翅膀的人的一种方式。如果我能在这里学会游泳,你也能。进来吧;水(我是说电压)没问题。
物理计算
我知道我承诺过机器人,我会实现的,但是你在这一章学到的大部分东西都被归入物理计算的范畴。这个名字本身听起来非常模糊,几乎毫无意义。从定义开始会帮助你找到方向。维基百科是这样定义物理计算的:
Physical computing, in the broadest sense, refers to the construction of interactive physical systems by using software and hardware that can sense and respond to the simulated world. Although this definition is broad enough to cover intelligent car traffic control systems or factory automation processes, it is not often used to describe them. Broadly speaking, physical computing is a creative framework for understanding the relationship between human beings and the digital world. In practical use, this term usually describes handicraft art, design or DIY hobby projects, which use sensors and microcontrollers to convert analog inputs into software systems and/or control electromechanical devices such as motors, servo systems, lighting or other hardware. 1
在流行文化中,物理计算通常与工程局外人或新媒体艺术家联系在一起,他们不受专业工程的引力影响。许多最有趣的物理计算例子都采用了两种方法中的一种:
- 他们以一种意想不到的方式将计算机交织成一个现有的模拟物理过程。
- 他们将虚拟世界的规则、比喻或人工制品映射到物理空间。
尽管维基百科的定义暗示了物理计算的 DIY 性质,但这并不是说该领域没有消费级、大众市场电子产品的位置。微软 Kinect 是这种设备的一个完美例子。
Kinect 这个名字巧妙地影射了它的目的,那就是利用它的摄像头来读取玩家的身体手势,作为控制游戏的手段。这个名字是动力学(运动)和连接(数据传输)的融合,这是物理计算的两个关键方面。Kinect 是一个非常精致和非常复杂的物理计算设备的例子。
你可能会问自己这样一个问题:硅胶中区分物理计算设备和非物理计算设备的线是什么?考虑一下 Kinect 和数码摄像机之间的区别。两者之间的重要区别不是技术复杂性的不对称,而是 Kinect 使用视频捕捉作为更大的反馈处理循环的一部分,涉及玩家、游戏系统和潜在的远程服务器。或者,摄像机仅仅不加区别地存储所有可用的输入,并等待进一步的指令。
物理计算的意义不在于制造更多的东西。目标是在现实世界和虚拟世界之间建立新的通道,允许用户阅读、混合和转播他们周围的世界。
物联网
物理计算设备通常被称为“物联网”的一部分凯文·阿什顿(Kevin Ashton)创造了这个术语,他用这个术语来理论化一个世界,在这个世界中,所有设备将通过无所不在的网络和低功耗、廉价和混杂的传感器持续连接在一起。他感兴趣的是这些设备如何跟踪和分类自己,或者在用户需要时大声说话。他的重点是新兴的射频识别(RFID)标签领域,这是一种由外部电源供电时可以读写的小电路。RFID 标签现在在日常生活中无处不在。从信用卡到家庭宠物的颈背,它们被嵌入到一切事物中。阿什顿在 2009 年 7 月版的 RFID 杂志中解释了他的概念:
Today, computers-and the Internet-depend almost entirely on human beings to obtain information. About 50 gigabytes (one gigabyte is equal to 1024 megabytes) of data on the Internet are almost all captured and created by humans for the first time by typing, pressing the recording button, taking digital photos or scanning bar codes. Traditional diagram of Internet. . . Save the largest and most important routers among all people. The problem is that people's time, attention and accuracy are limited-all these mean that they are not very good at capturing data about things in the real world. This is a big event. We are material, so is our environment. . . You can't eat a little, burn them to keep warm or put them in your fuel tank. Ideas and information are important, but things are more important. However, today's information technology relies so much on data generated by people that our computers know more about ideas than things. If we have computers that know everything-using the data they collected without any help from us-we will be able to track and calculate everything, and greatly reduce waste, loss and cost. We will know when things need to be replaced, repaired or recalled, whether they are fresh or in the best condition. The Internet of Things has the potential to change the world, just like the Internet. Maybe even more so. 2 —Kevin Ashton
今天,这个术语已经被其他几个领域所采用。根据你的对话对象,物联网现在同时描述:
- 阿什顿定义中的库存和履行系统
- 像 Kinect 这样的物理计算设备
- 增强现实设备,将虚拟对象叠加到特定的真实空间中,只能通过虚拟舷窗(即智能手机)进行查看
- 虚拟物体以图案的形式存在,使用 3D 打印机等快速成型工具生产。
因为这是关于 JavaScript 机器人的一章,所以我将使用第二个定义。
为什么是 JavaScript
如前所述,物理计算不仅仅是硬件的事情。它实际上是物理和虚拟之间精心编排的 I/O 循环之舞。您选择的语言决定了舞蹈在用户看来有多轻松。事实证明,JavaScript 让这两个伙伴几乎毫不费力地拥抱在一起,但原因可能出乎你的意料。JavaScript 具有适合物理计算的技术和语义特征,并且不断增加的库使得硬件变得不那么困难。本节解释了为什么 JavaScript 是物理计算的最佳选择。
建造桥梁
NodeBots 的创始人克里斯·威廉姆斯(我将在后面讨论)已经思考了很多关于 JavaScript 如何增强机器人技术的问题。在从事一个使传感器能够通过各种无线频谱进行通信的项目时,他对其他图书馆使用的方法感到不满。他觉得这种方法虽然在技术上很熟练,但在语义上却很笨拙。在他看来,这些图书馆在他们期望的世界行为方式和实际运作方式之间遭遇了脱节。一段时间后,Williams 审阅了 Nikolai Onken 和 rn Zaefferer 关于“机器人 JavaScript”的演示提案。他们的提议宣称 JavaScript 可以用来控制现实世界中的设备。这激发了他的想象力,他制定了一个最小但富有表现力的语法:
$("livingroom").bind("motion", function() {
$(this).find("lights").brightness("75%").dimAfter("120s");
});
这个简单代码片段的美妙之处在于,用 Williams 的话来说:“将现实世界的对象和动作建模为可链接的、事件化的流程几乎是自然而然的事情。”这个被提议的语法激发了 Williams 编写 node-serialport,他把它看作是“硬件的网关”
反应式编程范例
Williams 的起居室灯光示例暗示了现实世界的一个基本特性,即它是在不同持续时间内执行的异步操作的集合。在他的模型中,起居室对象将事件监听器绑定到发生在其中的任何运动。一旦被触发,绑定的函数调用一个方法来打开灯。这些灯依次有自己的反应任务链要完成,首先打开,然后在给定的时间框架后变暗。
在前面的代码片段中提到的这种事件观察者模式在很多 JavaScript 库中非常常见,比如 jQuery。这种熟悉是 Williams 认为 JavaScript 是控制硬件的好选择的原因之一,因为即使没有硬件经验的开发者也可以利用他们的知识来构建交互式网页。
该代码片段还暗示,需要编写该框架来处理一个事件同时来自许多输入的世界。反应式系统的目标是响应被监视对象的状态变化,并将这些变化传播到任何其他相关对象。反应式系统的经典例子是一个电子表格,其中“c”列中的总和取决于“a”和“b”列的相加。通常,这种计算只会发生一次。如果“a”或“b”的值发生变化,“c”将不再正确。除非“c”被告知这一变化,否则它永远不会更新,从而永远不同步。然而,在反应式系统中,“c”会观察“a”和“b”的变化。一旦检测到变化,它将再次对“a”和“b”求和。重新计算其值的过程反过来会触发依赖于事件流中较高“c”的对象也做出反应。
给机器人编程时,机器人可能会使用各种不同的传感器同时跟踪许多不同的环境变量。然而,这些传感器可能在不同的时间间隔返回结果。因此,在硬件必须做出响应之前,反应式系统将有助于对输入进行聚合、重新处理和潜在的无效处理。与机器人相关的反应式编程系统的目标是处理现实世界的异步性质,并将其重新表述为硬件可以执行的一系列顺序步骤。在下一节中,您将开始使用 NodeBots 软件栈构建您的反应式系统。
节点机器人:快速、廉价、伺服控制
节点机器人是使用不可见的 JavaScript 系绳控制的机器人。这个系绳由一个节点服务器和一组库组成,这些库抽象了与硬件通信的大部分繁重工作。您将构建的节点机器人利用 Arduino 板来控制输出外设。但是,在您开始构建您的机器人之前,您必须首先了解所有这些技术是如何协同工作的。考虑图 6-1 所示的下图。它解释了您最终将构建的 Nodebot 的结构。
图 6-1。
Anatomy of a Nodebot
幸运的是,你将在这一章花大部分时间编写代码。这是因为当你在你的机器人图上移动到更低的位置时,创造奇迹所需的代码变得更加机器专用,表达性更差,而且表面上看起来写起来也没什么意思。但是,为了让您知道自己有多好,并确保您完全理解堆栈的各个部分如何协同工作,您将从膝盖以下开始向上编写代码。该过程将是重复编写使 LED 闪烁所需的代码,这在硬件上相当于“Hello world”。
您将首先使用本机 IDE 直接为 Arduino 编写,然后过渡到编写与节点串行端口握手的固件。最后,你会去 Firmata 然后是 Johnny-Five。
取代
我前面提到过,机器将使用 JavaScript 连接到主机。这与典型的方法有很大不同,典型的方法是编辑源文件,然后将其编译成字节码,这样就可以直接存储在 Arduino 的芯片上。只有这样,程序才能运行。这个开发周期被称为编辑-编译-运行-调试(ECRD),大多数 Arduino 机器人就是这样构建的。相比之下,节点机器人将机器人的大脑保存在主机上,并使用读取-评估-打印-循环(REPL)环境。这种方法有一些优点和缺点,我将在这里一一列举。
优势
- 由于主机和硬件之间的实时交互,鼓励实验。
- 降低了调试的复杂性,因为代码仍然可以在主机上访问,并且不会被编译成不同的形式,这可能会导致不一致。
- 在硬件的低级控制和高级业务逻辑之间提供了一个清晰的关注点分离。
- 由于主机提供的额外资源,程序可能会更复杂。
不足之处
- 需要持久的系绳,这可能会限制机器人的自主性。
- 增加了机器人运行所需的依赖性。
- 可能导致主机和机器人之间的响应延迟,这是由于通过系链发送消息需要时间。
何必呢?
多年来,当我在电话里缠着我的哥哥马特,向他咨询我最新的不切实际的硬件想法时,他通常会以同样的方式回答:“你为什么要这么做?”他的话总是戳破我的精神泡沫,我会沮丧地回到现实,因为真正的工程师会认为我的想法是愚蠢的。有几次他试图回答我的问题,我很快就迷失在概念和细节中,因为我没有参照系来理解。我知道他不是有意伤害我的感情,也许在他看来,他是在为我节省时间和精力,让我去追求他认为是幼稚的方法。当我开始对 JavaScript 机器人感到兴奋时,我问自己这个问题:真正的机器人工程师会对节点机器人嗤之以鼻,认为它们没有价值吗?为了找到答案,我问了一位真正的机器人工程师。
Raquel Vélez 是一名机械工程师,曾在加州理工学院接受培训,此后在机器人领域工作了近十年。她在 NodeBots 社区也非常活跃。因为 Vélez 是专业和业余机器人社区的内部人士,我觉得她可以回答“为什么这么麻烦”这个问题。当我向她提出这个问题时,她是这样说的:
Indeed, the node robot is still in its infancy; We won't run driverless cars with node soon. But the point is not that node will replace C++/Python-instead, by opening the robot community to JavaScript community, we are making robots that didn't exist before available to people all over the world. This influx, in turn, increases the diversity of people trying to solve difficult problems, thus promoting the development of all technologies (networks, robots).
她继续以这种方式比较和对比这两个社区:
Basically, you can't get it in the "traditional" robot industry with the ability of real open source and super-fast turnaround time. When I work in academia/industry, you must have a lot of money, experience and time to complete any important work. With NodeBots, you don't need any of these things-you can just start.
由于她提到的所有原因,甚至在 Vélez 给专家竖起大拇指之前,我就接受了这个想法,还因为我不需要征求任何人的许可就可以开始。如果你能凑齐不到 100 美元的零件和工具,基本上没有进入的障碍,我将在下面介绍。
先决条件
本章有各种外部和特定于系统的先决条件,需要满足这些条件才能逐步完成。在尝试复制 Nodebot 示例之前,请确保您已经花了必要的时间来确认您的环境满足以下先决条件。
一般
在安装任何东西之前,确保您的系统能够编译 Node 的任何和所有本机模块。在撰写本文时,需要 Python 2.x 使用 3.x 版本会导致失败,因为node-serialport
依赖于node-gyp
,而后者需要 Python 2.x
Windows 操作系统
必须安装 Visual Studio 2010+(速成版就可以)。如果您将使用 Arduino,请确保您安装了必要的驱动程序。 4
Mac OS X
你必须确保你已经安装了 xCode 命令行工具 5 (最低限度)。
Linux 操作系统
最有可能的是,除了一般的先决条件之外,您的系统不存在任何特殊的依赖关系。
购物单
在你建立你的机器人军队之前,你必须有一套基本的零件和少量的工具。以下是复制本章示例所需的最低购物清单。如果你认为 JavaScript 机器人可能会吸引你一段时间的兴趣,你可能会考虑购买一个预捆绑的工具包。这些包包括您需要的零件和一些其他好的组件。很多时候,这些工具以探索者、发明家或入门者的名字出售;并且可以通过各种本地和在线电子产品零售商获得。
- 一个 Arduino Uno R3 板卡 6 个
- 10 英尺。USB 2.0 认证的 480Mbps 型公到 B 型公电缆
- 几个基本的红色 5 毫米发光二极管
- 一包试验板跳线
- 微型伺服电机
- 安全眼镜
Arduino IDE
在本节中,您将使用本机 IDE 创建一个 Arduino 闪烁。您将编写一个简单的脚本,然后必须上传到 Arduino 板上。只有在使用 IDE 时才需要这两步过程;一旦您将一个节点串行端口添加到堆栈中,您就可以创建到 Arduino 板的持久连接。
设置
你首先需要下载 Arduino IDE 7 并成功安装。安装完成后,您需要将 LED 的较长引脚(正极)放在引脚插槽 13 中,较短引脚(负极)放在接地插槽中。您使用引脚 13 的原因是因为它已经内置了一个电阻。一旦安装完毕,你的电路板看起来应该如图 6-2 所示。
图 6-2。
Board layout for the blink example
烟气试验
要执行此测试,您需要按照一系列步骤让您的 LED 闪烁。
步骤 1:连接电路板
将 USB 线连接到 Arduino 和电脑。您应该会看到板上有一个小 LED 灯,并且一直亮着。此 LED 表示电源正在流向主板。
Note
在 Windows 机器上,硬件向导可能会提示您安装 Arduino 的驱动程序。您需要解压缩FTDI USB Drivers.zip
,它可以在您随 IDE 下载的 Arduino 发行版的drivers
文件夹中找到。从(高级)菜单选项中将向导指向这些驱动程序。
步骤 2:选择正确的电路板
确保您在 ide 中选择了正确的板。这可以通过从工具➤板子菜单中选择板来完成,如图 6-3 所示。
图 6-3。
Arduino IDE board selection menu Note
本章假设您正在使用 Arduino Uno。如果您使用的是另一种类型的主板,前面的截图不会 100%准确。
步骤 3:编写固件
Arduino IDE 使用了速写本的比喻,其中每一页都是可以加载到 Arduino 中的草图。草图以文件扩展名.ino
保存。以下是您将上传到 Arduino 的草图。幸运的是,你不需要转录它,因为这个代码可以在 ide 的examples
文件夹中找到(见图 6-4 )。
图 6-4。
Arduino IDE example selection menu
/*
Blink
Turns on an LED on for one second, then off for one second, repeatedly.
This example code is in the public domain.
*/
// Pin 13 has an LED connected on most Arduino boards.
// give it a name:
int led = 13;
// the setup routine runs once when you press reset:
void setup() {
// initialize the digital pin as an output.
pinMode(led, OUTPUT);
}
// the loop routine runs over and over again forever:
void loop() {
digitalWrite(led, HIGH); // turn the LED on (HIGH is the voltage level)
delay(1000); // wait for a second
digitalWrite(led, LOW); // turn the LED off by making the voltage LOW
delay(1000); // wait for a second
}
这段代码应该是不言自明的;它只是初始化电路板,然后开始重复循环。在每次循环中,代码都会发出一个调用,将高值或低值写入 Arduino 的引脚 13。这段代码很难理解的一个方面是常量OUTPUT
、HIGH
和LOW
实际上做了什么。
步骤 4:编译并上传固件
选择眨眼教程后,将出现一个新的草图窗口。这个新窗口的顶部有几个图标。找到复选标记图标并按下它。此操作告诉 IDE 验证代码并将其编译成适合上传到 Arduino 板的格式。如果一切正常,您应该会在界面底部看到编译完成的消息。
单击顶部菜单中的右箭头图标,这将把代码上传到 Arduino。您会看到底部附近出现一个进度指示器,它会随着代码传输到电路板而更新。一旦你看到完成上传,你应该会看到你的 Arduino 有节奏地为你闪烁一个 LED。
第五步:拔掉 Arduino
成功完成测试后,从计算机上拔下 USB 电缆,这将切断 Arduino 的电源。
节点串行端口
节点串口是 NodeBot 层蛋糕的基础。本章涉及的所有其他库都会以某种方式依赖于这个库。但是,在使用节点串行端口与 Arduino 通信之前,您需要创建自定义的.ino
固件,它允许节点代码和 Arduino 之间的握手。
烟气试验
步骤 1:连接电路板
使用 USB 电缆将您的主板重新连接到计算机。您应该会看到板载 LED 变亮,表示主板已通电。
Note
如果您跳过了前面的 Arduino 示例,请参考该部分以确保您已经安装了所有必需的驱动程序。
步骤 2:选择正确的电路板
确保您在 ide 中选择了正确的电路板,就像您在前面的 Arduino IDE 示例中所做的那样。
步骤 3:编写固件
在 Arduino IDE 中打开一个新的草图文件,并转录以下代码:
int bytesRead = 0;
boolean isPinSet;
byte stored[2];
void setup()
{
Serial.begin(57600);
}
void loop()
{
while (Serial.available()) {
int data = Serial.read();
stored[bytesRead] = data;
bytesRead++;
if (bytesRead == 2) {
if (isPinSet == false) {
isPinSet = true;
pinMode(stored[0], OUTPUT);
} else {
digitalWrite(stored[0], stored[1]);
}
bytesRead = 0;
}
}
}
步骤 4:编译并上传固件
将前面的代码转录到草图文件中后,单击复选标记图标验证并编译源代码。如果您已经正确输入了所有内容,您应该会在界面底部看到消息“Done compiling”。接下来,单击右箭头将编译后的代码上传到 Arduino。当它传输代码时,您应该会看到一个进度指示器出现。一旦一切完成,您应该会在界面底部看到“上传完成”的消息。
步骤 5:安装节点串行端口
如果您的计算机上已经安装了节点和 npm,您可以像这样安装节点串行端口:
npm install serialport
第六步:编写程序
从您喜欢的文本编辑器中创建新文件,并键入以下代码。一旦转录,保存它为serial-blinky.js
到你安装节点串口的同一个文件夹。
var serial = require("serialport")
,
raddress = /usb|acm|com/i
,
pin = 13;
serial.list(function(err, result) {
var read = new Buffer(0)
,
address, port, bite;
if (result.length) {
address = result.filter(function(val) {
// Match only address that Arduino cares about
// ttyUSB#, cu.usbmodem#, COM#
if (raddress.test(val.comName)) {
return val;
}
}).map(function(val) {
return val.comName;
})[0];
port = new serial.SerialPort(address, {
baudrate: 57600
,
buffersize: 1
});
port.on("open", function() {
var bite;
function loop() {
port.write([pin, (bite ^= 0x01)]);
}
setInterval(loop, 500);
});
} else {
console.log("No valid port found");
}
});
现在使用以下命令从命令行运行您的代码:
node serial-blinky.js
如果一切正常,LED 应该开始为您闪烁。
Caution
如果出现“找不到模块‘串行端口’”错误,您需要将此草图保存在包含节点串行端口库的‘node _ modules’文件夹旁边。
第七步:拔掉 Arduino
成功完成该测试后,从计算机上拔下 USB 电缆。这样做应该会切断 Arduino 的电源。
近得危险
这种方法实际上比只为 Arduino 编写更麻烦,因为它需要两个紧密耦合的文件才能工作。如果您对 JavaScript 文件进行了实质性的修改,那么您需要在。ino 文件。这是因为节点串行端口是一个低级的库,只是为了通过串行端口进行通信,仅此而已;一分不少。值得庆幸的是,当您继续使用 Firmata 时,您将在抽象上更上一层楼。
格式(formata)
Firmata 是用于 Arduino 和主机之间通信的通用协议。在本例中,您将使用两种形式的 Firmata。第一个是固件。您将直接加载到 Arduino 上。第二个 Firmata 是与固件握手的节点库。在本节中,您将重新创建 blinking 示例,但是这次使用 Firmata 作为桥梁。
烟气试验
步骤 1:连接电路板
使用 USB 电缆将您的主板重新连接到计算机。您应该会看到板载 LED 变亮,表示该板已通电。
Note
如果您跳过了前面的 Arduino 示例,请参考该部分以确保您已经安装了所有必需的驱动程序。
步骤 2:选择正确的电路板
确保在 ide 中选择了正确的电路板,就像在 Arduino IDE 示例中一样。
步骤 3:找到串行端口
节点串行端口需要知道 Arduino 连接到哪个端口。要找到端口的路径,查看工具➤串口子菜单下,如图 6-5 所示。Arduino 将连接到带有复选标记的端口。记下这份参考资料,以便以后使用。
图 6-5。
Arduino IDE serial port selection menu
步骤 4:安装固件
要设置 REPL 开发环境,您必须在 Arduino 上安装 StandardFirmata 固件。幸运的是,这些代码与 IDE 捆绑在一起。只需选择文件➤范例➤ Firmata ➤标准 Firmata,如图 6-6 所示。这将打开一个新的草图窗口,所需的代码已经存在。现在点击右箭头,将编译好的代码上传到板上。
图 6-6。
Arduino IDE Firmata selection menu
上传完成后,您的 REPL 环境就可以使用了。此时,可以关闭原生 Arduino IDE 在本章的其余部分,您将不再需要它。
步骤 5:安装 Firmata 库
现在,您已经将标准 Firmata 固件加载到您的主板上,您需要安装 Firmata 节点库,它能够理解如何与之通信。从安装 node-serialport 的同一目录中,键入以下内容:
npm install firmata
第六步:编写程序
如果 Firmata 安装正确,你就可以重写你的闪烁程序了。在文本编辑器中,转录以下代码,并将其作为'firmata-blinky.js
'保存在您用来存储之前示例的同一文件夹中:
/**
* Sample script to blink LED 13
*/
console.log('blink start ...');
var pin = 13;
var firmata = require('firmata');
var board = new firmata.Board('/dev/cu.usbmodem1411', function(err) {
var bite;
board.pinMode(pin, board.MODES.OUTPUT);
function loop() {
board.digitalWrite([pin, (bite ^= 0x01)]);
}
setInterval(loop, 500);
});
现在,使用以下命令从命令行运行您的代码:
node firmata-blinky.js
如果一切正常,您应该会看到 LED 开始为您闪烁。
第七步:拔掉 Arduino
成功完成该测试后,从计算机上拔下 USB 电缆,这将切断 Arduino 的电源。
真实的 REPL
现在您已经安装了 Firmata 固件,并与主机上的 Firmata 库进行了通信,您已经有了一个真正的 REPL 开发环境设置。这意味着(与您的节点串行端口版本不同),您不必在每次更改主机上的源代码时都更新固件。不幸的是,尽管 Firmata 很棒,但您必须编写的 JavaScript 代码仍然非常特定于领域。就像在 Arduino IDE 示例中一样,在您的代码正确运行之前,您需要理解几个不明确的常量和模式。要编写更加与硬件无关的代码,您需要在堆栈上再往上爬一层。前进到强尼五号!
强尼五号
里克·沃尔德伦对机器人很认真,以至于他造了自己的机器人来向妻子求婚。她不是工程师,也没有自己的机器人使者,而是用她最好的机器人声音告诉了沃尔德伦这个好消息。就我个人而言,我认为 Waldron 是 JavaScript 社区中一个崩溃的人物——一个愉快地利用自己的智力来取乐而不是牟利的人,但却认真地致力于推动社区和语言向前发展。
Waldron 创建了 Johnny-Five,这是一个开源的 Arduino 编程框架,位于 Firmata 和 Node 串行端口堆栈之上。Johnny-Five 有一个清晰的表达 API,感觉就像大多数开发人员习惯在其他环境中编写的 JavaScript。这是最接近柏拉图式的理想,是克里斯·威廉姆斯在他的假想起居室例子中提出的。我问了沃尔德伦关于 Johnny-Five 的事情,以及为什么他和其他人一样,认为 JavaScript REPL 环境是机器人编程的理想环境。他是这样回应的:
All hardware is implicitly synchronized; It exists in the real world. If you let something move, it takes real time to move. This means that any program interacting with hardware must know these time constraints and be able to provide an effective control mechanism. Traditionally, this is realized by multithreading and interrupt-based programming model. I believe that the single-threaded, round-based execution model can provide the same level of efficient control. Consider a simple sensor connected to Arduino; Traditionally, we call some functions repeatedly to read and process the values of analog sensors, and conditionally execute other parts of the program according to the changes of the values. When using Johnny-Five framework to write the same program in JavaScript, the programming model becomes an observer in the form of event bus. If the value of the sensor changes, the listener will be notified. When programming output, the idea is the same, but it has greater influence. Suppose we want our program to move a servo mechanism back and forth from 0 degrees to 180 degrees; Using our servo data manual, we calculate that the whole journey of 180 degrees takes 1000 milliseconds. If you write in Arduino C, you need a delay (1000) after the first move, which will block the whole one-second execution process. If this is in a loop, then each loop has a pause time of 1 second. If the program must also read sensors for certain conditions, these sensors will also be blocked for 1000 milliseconds. In JavaScript on Node.js, using Johnny-Five, tasks that need "delay" or "loop" will not prevent execution. Instead, they are scheduled tasks that will be called in a later execution round, allowing the rest of the program to continue normally. Round-based execution model is actually not a part of JavaScript language; This is an example of an embedded environment, such as browser, or in this case, the round-based execution of Node.js. Node.js is implemented in the form of libuv, which provides an asynchronous, event-based execution environment. This model is an implicit simulation of the explicit loop () in Arduino C.
Waldron 的方法非常符合本章前面提到的反应式编程范例的精神。状态变化在整个框架中传播的方式意味着您可以编写更少的代码来有效地为真实世界建模。在下一节中,您将重新创建闪烁的 LED。然后,您将通过创建一个更高级的示例来探索 Johnny-Five 的 REPL 环境。
烟气试验
步骤 1:连接电路板
使用 USB 电缆将您的主板重新连接到计算机。您应该会看到板载 LED 变亮,表示该板已通电。
第二步:安装强尼五号
此步骤假设您已经将 StandardFirmata 固件刷新到 Arduino 上。如果您尚未完成此步骤,请参考本章前面的 Firmata 部分。在安装 node-serialport 和 Firmata 的同一目录中,键入以下内容:
npm install johnny-five
第三步:写一个程序
假设 Johnny-Five 安装正确,您就可以重写闪烁的 led 示例了。在文本编辑器中,转录以下代码,并将其作为'johnny-blinky.js
'保存在您用来存储之前示例的同一文件夹中:
var five = require("johnny-five")
,
board = new five.Board();
board.on("ready", function() {
(new five.Led(13)).strobe();
});
现在,使用以下命令从命令行运行您的代码:
node johnny-blinky.js
如果一切正常,LED 应该开始为您闪烁。
第四步:拔掉 Arduino
成功完成测试后,从计算机上拔下 USB 电缆,这将切断 Arduino 的电源。
摆弄强尼五号
只要看看 Johnny-Five 闪烁 LED 所需的行数,就应该很清楚,这个框架确实使 Arduino 的编写变得更加容易。然而,你才刚刚开始!在下一个示例中,您将使用 REPL 控制台实时控制微型服务器电机。通过这个过程,你将更多地了解 Johnny-Five 如何在内部对硬件建模,以及如何利用这些知识来改进你自己的程序。
第一步:准备板子
在这个例子中,你将使用 Johnny-Five 控制一个微型伺服系统。如果你有你的马达,将数据线插入第 10 号插脚,将电源线插入第 5 号插脚。最后,将接地线插入其中一个可用的接地引脚(参见图 6-7 )。
图 6-7。
Wiring diagram for servo example Note
此图显示了直接连接到 Arduino 引脚插槽的电线。然而,在现实中你可能需要使用跳线来连接你的伺服到 Arduino。
第二步:连接电路板
使用 USB 电缆将您的主板重新连接到计算机。您应该会看到板载 LED 变亮,表示主板已通电。
第三步:写一个程序
现在,您将编写一个简单的程序来与您的伺服系统进行交互。在文本编辑器中,将以下代码转录到一个文件中。将文件另存为“servo.js
”,保存在示例中使用的同一目录下。
var five = require("johnny-five")
,
board = new five.Board();
board.on("ready", function() {
var servo = new five.Servo(10);
this.repl.inject({
servo: servo
});
servo.center();
servo.on("move", function(err, degrees) {
console.log("move", degrees);
});
});
现在,使用以下命令从命令行运行您的代码:
node servo.js
如果一切正常,您应该在终端窗口中看到伺服中心和以下输出:
1374513199929 Board Connecting...
1374513199933 Serial Found possible serial port /dev/cu.usbmodem1411
1374513199934 Board -> Serialport connected /dev/cu.usbmodem1411
1374513203157 Board <- Serialport ready /dev/cu.usbmodem1411
1374513203158 Repl Initialized
>>
在 REPL 控制台中,键入以下内容:
this.servo.move(90)
应该发生两件事:您应该看到伺服旋转了 90 度,并看到 Johnny-Five 呈现给控制台的硬件状态的表示(您将在下一节中详细探讨)。
第四步:拔掉 Arduino
成功完成测试后,从计算机上拔下 USB 电缆,这将切断 Arduino 的电源。
五号还活着
当您向 Johnny-Five 的 REPL 实例发出命令时,它会返回一个表示当前环境状态的 JavaScript 对象。在您的伺服示例中,在您发出移动命令后,Johnny-Five 返回了一个看起来有点像这样的对象:
{
board: {
ready: true
,
firmata: {...}
,
register: [ [Circular] ]
,
id: '98880E34-5D9E-49A9-8BA0-89496E54F765'
,
debug: true
,
pins: { '0': [Object], '1': [Object], '2': [Object], '3': [Object], '4': [Object], '5': [Object], '6': [Object], '7': [Object], '8': [Object], '9': [Object], '10': [Object], '11': [Object], '12': [Object], '13': [Object], '14': [Object], '15': [Object], '16': [Object],'17': [Object], '18': [Object], '19': [Object] }
,
repl: {
context: [Object]
,
ready: false
,
_events: {}
}
,
_events: { ready: [Function] }
,
port: '/dev/cu.usbmodem1411'
}
,
firmata: {...}
,
_maxListeners: 10
,
MODES: {
INPUT: 0
,
OUTPUT: 1
,
ANALOG: 2
,
PWM: 3
,
SERVO: 4
}
,
I2C_MODES: {
WRITE: 0
,
READ: 1
,
CONTINUOUS_READ: 2
,
STOP_READING: 3
}
,
STEPPER: {
TYPE: [Object]
,
RUNSTATE: [Object]
,
DIRECTION: [Object]
}
,
HIGH: 1
,
LOW: 0
,
pins: [ [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object], [Object]]
,
analogPins: [14, 15, 16, 17, 18, 19]
,
version: { major: 2, minor: 3 }
,
firmware: { version: [Object], name: 'StandardFirmata.ino'}
,
currentBuffer: []
,
versionReceived: true
,
sp: {
domain: null
,
_events: [Object]
,
_maxListeners: 10
,
options: [Object]
,
path: '/dev/cu.usbmodem1411'
,
fd: 11
,
readStream: [Object]
}
}
,
id: '946C9829-5DB0-4EA2-8283-6249CC8E25F6'
,
pin: 10
,
mode: 4
,
range: [0, 180]
,
type: 'standard'
,
specs: {
speed: 0.17
}
,
history: [{ timestamp: 1374513576399, degrees: 90 }]
,
interval: null
,
isMoving: true
,
_events: {
move: [Function]
}
}
在这个对象中,您不仅可以看到 Arduino 硬件在引脚和端口方面的表现,还可以看到所描述的 Firmata 以及您所连接的伺服系统的功能。除了棋盘的当前状态,还有一个历史数组,其中包含一段时间内的变化列表。当然,当您试图调试一段时间内多个输入和输出之间的复杂交互时,这是非常宝贵的。
我不能过分夸大在飞行中摆弄约翰尼五 REPL 环境的能力是多么神奇。正如 Raquel Vélez 早些时候指出的那样,她对 NodeBots 的部分兴奋之处在于,你可以快速构建原型。使用 REPL 环境,你可以在控制台上交互式地测试硬件直觉,在你把东西提炼成精确组合的程序之前,先粗略地画出草图。
福克斯机器人
虽然你确实得到了一个在你控制下旋转的伺服系统,但我很难称之为机器人。实际上,你可以花一整本书来解释和构建节点机器人。因此,我将这一章的范围限制在解释足以给你必要的背景来自己探索它们。以下是使用这种方法对机器人编程的几个关键概念:
- 在对硬件进行编程时,有可能发生火灾或其他现实世界中的灾难,但这并不意味着它会发生。
- 当你以一种意想不到的方式将计算机交织到现有的模拟物理过程中时,有趣的事情就发生了。
- 当你将虚拟世界的特性映射到物理空间时,有趣的事情就会发生。
- 网络感知对象可以被视为物联网的一部分。
- 反应式编程范式通过观察聚合对象之间的数据流来处理状态变化。
- 反应式编程特别适合于将异步世界转换成一系列可链接的事件流程。
- 许多传统的硬件开发使用编辑-编译-运行-调试(ECRD)过程,该过程允许硬件在独立的环境中运行,但是开发和调试可能会很慢。
- 节点机器人使用读取-评估-打印-循环(REPL)环境,这允许更快的开发和实时编码。然而,这种方法要求硬件被持久地束缚。
JS 社区的一部分人对 JavaScript 机器人的兴趣显而易见。以至于 NodeBot 开发人员已经产生了他们自己的网站、聚会和会议;甚至创造了一个国际节点机器人日,让书呆子们聚在一起互相焊接。如果你像我一样被这种潜力所吸引,我鼓励你去寻找其他有相似兴趣的人,然后开始建设!
额外资源
本章中的 Arduino 图表是使用 Fritzing 制作的,Fritzing 是一种开源硬件计划,旨在支持设计师、艺术家、研究人员和爱好者使用交互式电子设备进行创造性工作。它有一套很棒的工具和教程来帮助硬件新手规划并最终制作出他们自己的设计。点击这里了解更多:fritzing.org/
。
Footnotes 1
http://en.wikipedia.org/wiki/Physical_computing
2
http://en.wikipedia.org/wiki/Internet_of_Things
3
http://voodootikigod.com/nodebots-the-rise-of-js-robotics/
4
http://arduino.cc/en/Main/Software
5
https://developer.apple.com/xcode/
6
http://arduino.cc/en/Main/arduinoBoardUno
7
http://arduino.cc/en/Main/Software
七、风格
Abstract
风格是不断浮出水面的主题的实质。
-维克多雨果
It is the essence that the style subject is constantly called to the surface. -victor hugo
我的目标是让你成为更好的 JavaScript 程序员。为此,我将在这一章教你关于风格的知识。我不是在谈论时尚,因为我认为大多数程序员都通不过这个测试——除非动漫展上有时装。我会解释风格的重要性,它是如何形成的,它是如何传播的,以及当你需要杀死它时,要寻找什么迹象。具体来说,我将研究应用于编写 JavaScript 的风格。一旦有了评估好技术的背景,我将向您介绍编程风格的元素,这些元素在我作为一名专业软件开发人员的这些年里很好地服务了我。
什么是风格?
风格经常被用来衡量质量。当某人被描述为有风格或时髦时,它几乎普遍被认为是一种补充。如果有人的风格受到质疑,通常是与另一个人的风格相比较。“我的风格是最好的,所以我向你挑战,”这位 20 世纪 70 年代的武术明星尖叫道。
时尚是一种新的方法,一种独特的视角,或者对一个主题的洞察力。一种风格的应用可以变得如此突出,以至于它扩展了活动本身,例如,通过说一所房子是以弗兰克·劳埃德·赖特风格建造的。绘画中的个人风格几乎可以在一夜之间变成一场艺术运动。风格像谣言一样传播。它是最初的迷因,一种永远改变你看待主题方式的思维病毒。风格是传播新思想的管道。
风格如何影响程序员?我有好消息给那些倾向于算法的人。不管一种风格看起来有多个人化,要让它存在下去,它必须是可重复的。风格必须被编成一系列的步骤或规则的组合,可以被遵循,然后被其他人认可。如果风格是质量的衡量标准,同时又是可重复的,那么它是可以被教授的。问问斯特伦克和怀特就知道了。
小威廉·斯特伦克在康奈尔大学任教授时写了《风格的要素》。他从 7 条语言使用规则和 11 条写作原则开始。他的学生埃尔文·布鲁克斯·怀特在近 40 年后修订了这本书,并增加了另外 4 条规则。这本书的目的是给有抱负的作家和语法学家一个评估他们自己作品的框架。
根据怀特的说法,斯特伦克决定写“小书”是出于对那些因阅读这位作家糟糕的写作而痛苦的人的同情:“威尔觉得读者大部分时间都处于严重的困境中,在沼泽中挣扎,任何试图写英语的人都有责任迅速耗尽交换,让读者站在干燥的地面上,或者至少扔一根绳子。”
多年来,这本书一直很受那些学习高效写作的人的欢迎,现在它被亲切地简称为“斯特伦克和怀特”这并不是说这本书受到了普遍的喜爱或追随。《纽约时报》援引多萝西·帕克的话说:“如果你有任何渴望成为作家的年轻朋友,你能帮他们的第二大忙就是送他们一本《文体要素》第一,当然是在他们高兴的时候,现在就开枪。"
许多人认为这些规定太过严格和固执己见。怀特说,斯特伦克认为“犹豫不决比犯错更糟糕。”斯特伦克的主张是,时尚不仅是激情,也是划定界限的能力,允许一个想法蓬勃发展,同时迫使另一个想法消亡。风格就像正弦波,吸引一些人,排斥另一些人。
什么是程序化风格?
如前所述,Stunk 和 White 写这本书不仅是为了授权和培训作者,也是为了让读者免于陷入他们心目中的文本陷阱。同样,好的编程风格服务于两种受众:开发人员和处理人员。代码应该在语法和技术上写得很好。下面的部分描述了我认为在编程风格的应用中必不可少的品质。
一致性
通过对代码库重复应用规则,可以确保一致性。一致性减少了源代码中的噪音,使编码者的意图更加清晰。如果一个开发人员试图拼凑如何阅读你的代码,你阻止了他理解它做什么。一致性与代码的外观有关,例如,命名约定、空格的使用和方法签名。它还涉及如何编写代码(例如,确保所有函数在所有上下文中返回可预测的结果)。
表示
代码本质上是一种符号语言,其中隐含着可变性和抽象性。因此,开发人员必须找到一种方法使代码对读者有意义和相关。相关性可以通过精确命名变量和函数来实现。当审查一个类、方法或变量时,读者应该通过阅读代码来理解代码的角色和职责。如果一个函数只能通过看作者留下的评论来理解,那么这应该是代码缺乏表现力的线索。
简洁
努力做到刚刚好。好的编程,就像好的写作一样,是关于目的的清晰,而不仅仅是紧凑。它应该是降低一个功能的复杂性,而不是它的有用性。
抑制
风格永远不应该压倒主题。在这一点上,风格成为主题,然后它是一个肤浅的技巧,一盘被过多的华丽破坏了的菜。我想起了我在大学里看到的一套极简主义的国际象棋。每一块都是白色或黑色的立方体,大小都一样。这些碎片只是重量不同;越重要的部分越重。这套国际象棋既美观又难下。在编程中,聪明是致命的。程序员必须克制自己,不要在语言中使用让代码潮人高兴的内部笑话,但要让源代码难以理解和维护。
JavaScript 风格指南
风格指南仅仅是指南。它们是为了给你指明正确的方向,但它们充其量只是一个不可改变的事实。编码理论是不断变化的,重要的是不要把自己锁在这些规则应用的教条方法中。正如我的克莱德·福勒教授在我的画室绘画课上告诉我的那样,“你必须用手思考。”他的意思是,你必须通过行动来思考,同时保持与工作保持临界距离的能力。
这份风格指南是通过编译、回顾和考虑我多年来在自己的工作中所做的选择,以及评估 JavaScript 社区中我所钦佩的个人和开发团队的编码实践而创建的。这种风格指南应该被视为输入和影响的融合,而不是单个人的创造性输出。你可以在本章末尾找到一个附加资源列表,其中包含我在撰写本文时考虑的其他指南和文档。本指南分为两个部分:“视觉清晰度规则”和“计算效率规则”
视觉清晰度规则
- 写得清晰而有表现力:在考虑代码视觉清晰度的好准则时,记住这条规则很重要。当命名变量和函数,或者组织代码时,记住你是为人类而写,而不是为编译器。
- 遵循现有的惯例:如果你在一个团队中工作,或者被雇佣来写代码,你不是在为自己写作。因此,你应该让你的风格与现有的生态系统共存,但不牺牲质量。
- 只用一种语言编写:在可能的情况下,不要使用 JavaScript 作为其他语言的载体。这意味着抵制编写内嵌 HTML 或 CSS 的冲动。清晰的代码加强了关注点的分离。
- 强制统一列宽:在源代码中争取一致的行长度。长线条使眼睛疲劳,降低理解能力。长的行也会导致不必要的水平滚动。行业标准是每行 80 个字符。
文档格式
理解一个程序的源代码通常需要读者在心里编译代码。这个过程需要读者的持续关注,任何分心都会将读者从他们的心流中驱逐出去。格式不正确或不一致的信号源会成为信号源信号的视觉噪声。本节提供了一些约定和指南,允许格式支持源,而不是降低它的重量。
命名规格
JavaScript 是一种由括号、数字和字母组成的简洁语言。让你的代码对人类有表达能力的唯一方法之一是给你的变量、函数和属性起一个有意义的名字。在选择名称时,它应该描述该对象的角色和职责。使用模糊或生硬的名字,如doStuff
或item1
,就像告诉读者去弄清楚,而他们通常不会。
选择具有有意义的、表达性的和描述性的名字的变量和函数。为读者而写,而不是为编译器。
// Bad
var a = 1
,
aa = function(aaa) {
return '' + aaa;
};
// Good
var count = 1
,
toString = function(num) {
return '' + num;
};
常数
在运行时引擎支持的地方,应该使用关键字const
来声明常量。当const
关键字不可用时,常量应该属于一个名称空间或对象。这样做有助于组织元素并防止命名冲突。在这两种情况下,常量都应该用大写字母书写,空格用下划线代替。
// Bad
MEANING_OF_LIFE = 43;
// Good
const MEANING_OF_LIFE = 43;
// Good
com.humansized.MEANING_OF_LIFE = 42;
// Good
Math.PI
其他命名约定
命名约定应该赋予变量或对象额外的含义。这样做是为了暗示它们的功能和语义目的。例如,JavaScript 中没有正式的类,但是类是组织代码的一种常见模式。因此,应该成为类的函数应该通过使用命名约定将自己与普通函数区分开来,即使运行时进程将对它们一视同仁。这种命名惯例有时被称为匈牙利符号。
变量应为 CamelCase:
myVariableName
类应该是 PascalCase:
MyAwesomeClass
函数应该是 CamelCase:
isLie(cake)
名称空间应该是 CamelCase,并使用句点作为分隔符:
com.site.namespace
匈牙利符号不是必需的,但可以用来表达对象是通过库或框架构造的,或者依赖于库或框架。
// jQuery infused variable
var $listItem = $("li:first");
// Angular.js uses the dollar sign to refer to angular-dependent variables
$scope, $watch, $filter
常量和变量
变量和常量定义总是在作用域的顶部,因为当运行时引擎处理代码时,变量会被提升到顶部。因此,在顶部声明变量更符合解析源代码时的情况:
// Bad
function iterate() {
var limit = 10;
for (var x = 0; x < limit; x++) {
console.log(x);
}
}
// Good
function iterate() {
var limit = 10
,
x = 0;
for (x = 0; x < limit; x++) {
console.log(x);
}
}
通过始终使用var
、let
或const
声明变量来避免污染全局名称空间:
// Bad
foo = 'bar';
// Good
var foo = 'bar';
let foo = 'bar';
const foo = 'bar';
使用单个var
声明声明多个变量,但是用换行符分隔每个变量。这减少了不需要的字符,同时保持了源代码的可读性:
// Bad
var foo = "foo";
var note = makeNote('Huge Success');
// Good
var foo = "foo"
,
note = makeNote('Huge Success');
最后声明未赋值的变量。这使得读取器知道它们是需要的,但是延迟了初始化:
var foo = "foo"
,
baz;
不要在条件语句中分配变量,因为这通常会掩盖错误:
// Bad because it is easily misread as an equality test.
if (foo = bar) {...}
不要用变量名混淆函数参数,因为这会使代码更难调试:
// Bad
function addByOne(num) {
var num = num + 1;
return num;
}
// Good
function addByOne(num) {
var newNum = num + 1;
return newNum;
}
空白行
一个空行应该总是在注释的开始之前,因为它允许注释与它所引用的代码在视觉上分组:
var foo = "foo";
// Too compressed
function bar(){
// Hard to know here things are.
return true;
}
// Cleanly delineated
function baz() {
// Now return true
return true;
}
应该使用空行来分隔逻辑上相关的代码,因为读者可以在视觉块中处理相关的代码:
// Bad
var = wheels;
wheels.clean()
car.apply(wheels);
truck.drive();
// Good
var = wheels;
wheels.clean()
car.apply(wheels);
truck.drive();
逗号
删除对象声明中的尾部逗号,因为它们会破坏一些运行时环境:
// Bad
var foo = {
bar: 'baz'
,
foo: 'bar'
,
};
// Good
var foo = {
bar: 'baz'
,
foo: 'bar'
};
不要使用逗号开头的格式。有些人认为逗号优先格式提供了更好的可读性,因为它强调了逗号,因此提供了集合中元素的可视分隔:
var fruits = [ 'grapes'
, 'apples'
, 'oranges'
, 'crackers'
, 'cheese'
, 'espresso'
];
然而,大多数 JavaScript 开发世界使用逗号作为最后的格式,正如 Brendan Eich 指出的, 2 这两种风格不能很好地混合,当两种风格结合时很容易漏掉一个错误的逗号:
var fruits = [ 'grapes'
,
, 'apples'
, 'oranges'
, 'crackers'
, 'cheese'
, 'espresso'
];
分号
JavaScript 确定分号在某些上下文中是可选的,但在其他上下文中是必需的。让事情更加混乱的是,ECMAScript 规范对如何自动插入分号做出了规定:
Some ECMAScript statements (empty statements, variable statements, expression statements, do-while statements, continue statements, break statements, return statements and throw statements) must end with semicolons. Such semicolons may always appear explicitly in the source text. However, for convenience, in some cases, such semicolons can be omitted from the source text. The way to describe these situations is that in these cases, semicolons are automatically inserted into the source code tag stream.
您不应该添加无意义的分号,但它们应该用于清楚地描述逻辑语句的结尾,即使它们是自动插入的候选项。
空白
应该删除行尾和空行中的空白。开发人员不应该混合使用空格和制表符,在函数声明中,每个逗号后面都应该有空格。所有这些规则都有助于确保在各种可用的开发环境中以一致的视觉方式呈现源代码:
// Bad
findUser(foo,bar,baz)
makeSoup( );
var foo = { };
var arr = [ ];
// Good
findUser(foo, bar, baz)
空白不应出现在空函数或对象文字中,因为这会降低可读性:
makeSoup();
var foo = {};
var arr = [];
支架和大括号
只有在编译器需要或者有助于从外部源描述内部内容时,才使用括号和大括号。括号应该出现在需要它们的行上:
// Bad
if (hidden)
{
...
}
// Good
if (hidden) {
}
可读性应该胜过简洁。让自动代码压缩器来操心如何让代码变得更小:
// Bad
if (condition) goTo(10);
// Good
if (condition) {
goTo(10);
}
与括号和大括号一起使用的空格
在前面和括号之间添加空格以提高可读性:
// Less Readable
if(foo,bar,baz){}
// More readable
if (foo, bar, baz) {
}
前面的规则有几个例外:
// No whitespace needed when there is a single argument
if (foo) ...
// No whitespace when a parenthesis is used to form a closure
;(function () {...})
// No whitespace when brackets are used as a function argument
function sortThis([2, 3, 4, 1])
用线串
为了一致性,应该使用单引号来构造字符串,同时也是为了帮助区分对象文字和 JSON,后者需要双引号。
// Bad
var foo = "Bar";
// Good
var foo = 'Bar';
应该重新考虑长度超过预定字符行限制的字符串。并且,如果需要,应该将它们连接起来。
功能
方法签名必须一致。如果函数在一个上下文中返回一个变量,它应该在所有上下文中返回一个变量:
// Bad
var findFoo(isFoo){
if ( isFoo === 'Bar' ) {
return true;
}
}
// Good
var findFoo(isFoo) {
if ( isFoo === 'Bar' ) {
return true;
}
return false;
}
虽然不是必需的,但从函数中提前返回可以使意图更加清晰:
// Good
var findFoo = function(isFoo) {
if ( isFoo === 'Bar' ) {
return true;
}
return false;
}
评论
注释永远不应该跟在陈述后面:
var findFoo = function(isFoo); // Do not do this
应该谨慎使用注释;过度使用注释会让作者觉得他们的代码缺乏表现力。评论应该始终作为一个完整的思想来写。多行注释应该总是使用 Multiline 语法,因为它使您能够将使用单行语法编写的注释视为单独的项,而不是前一行的延续。
// Some really
// bad multiline comment
/**
* A well-formed multiline comment
* so there...
*/
计算效率的规则
计算效率和视觉清晰度同样重要。请记住下面的例子:
- 为连接而写:现代应用经常将 JavaScript 源代码转换成一个流线型的文件用于生产。您应该对您的脚本进行防御性编程,以防止操作上下文和范围损坏中的切换。
- 保持代码浏览器不可知:通过将特定于浏览器的代码抽象到接口中,保持业务逻辑不受这些代码的影响。随着浏览器的流行和过时,这将使你的代码保持一个干净的升级路径。
- 抵制
eval()
的使用:它经常是恶意代码执行的注入点。 - 抵制使用
with()
:它会使代码的含义难以理解。3 - 保持原型的原始性:永远不要修改 Array.prototype 这样的内置函数的原型,因为它会悄悄地破坏其他人的代码,而这些代码需要标准的行为。
相等比较和条件评估
用=代替并使用!==而不是!=因为 JavaScript 的动态性意味着在测试等式时有时会过于宽松。
当只是测试“真实性”时,您可以强制这些值:
if (foo) {...}
if (!foo) {...}
测试空性时:
if (!arr.length) { ... }
在检验真理时,你必须明确:
// Bad because all of these will be coerced into true
var zero = 0
,
empty = ""
,
knull = null
,
notANumber = NaN
,
notDefined;
if (!zero || !empty || !knull || !notANumber || !notDefined ) ...
// Bad
var truth = "foo"
,
alsoTrue = 1
if (truth && alsoTrue) ...
// Good
if (foo === true) ...
常量和变量
删除变量时,将其设置为 null,而不是调用#delete
或将其设置为 undefined:
// Bad because undefined means the variable is useful but as yet has no value
this.unwanted = undefined;
/**
* Bad because calling delete is much slower than reassigning a value.
* Use delete if you want to remove the attribute from an objects list of keys.
*/
delete this.unwanted;
// Good
this.unwanted = null;
函数表达式
函数表达式是链接到变量的函数对象。因此,它们可以用比函数声明更多的方式来编写:
// Anonymous Function
var anon = function () {
return true;
}
// Named Function
var named = function named() {
return true;
};
// Immediately-invoked function, hides its contents from the executing scope.
(function main() {
return true;
})();
函数表达式是在解析时定义的。因此,不要将他们的名字挂在范围的顶部。然而,函数表达式比函数声明更受欢迎,因为在旧的浏览器中存在某些错误。
// Bad - Runtime Error
iGoBoom();
var iGoBoom = function () {
alert('boom');
}
// Good
iGoBoom();
function iGoBoom() {
alert('boom');
}
不要在 block 语句中使用函数声明;它们不是 ECMAScript 的一部分。请改用函数表达式:
// Bad
if (ball.is(round)) {
function bounce(){
// Statements Continue
}
return bounce()
}
// Good
if (ball.is(round)) {
var bounce = function () {
// Statements Continue
}
}
在提高清晰度的地方打破连锁方法:
// Bad
jQuery.map([1,3,2,5,0], function(a) { return a + a; }).sort(function(a, b) { return a - b;});
// Good
jQuery.map([1,3,2,5,0], function(a) { return a + a; })
.sort(function(a, b) { return a - b;});
不要在函数中使用相同的名称来隐藏本机参数对象:
// Bad
var foo = function(arguments) {
alert(arguments.join(' '));
}
// Good
var foo = function(args) {
alert(args.join(' '));
}
目标
对象文字符号应该比new Object() when creating an empty object because the object literals scope does not need to be first resolved and therefore performs better. Additionally, the object literal syntax is less verbose:
更受青睐
// Ok
var person = new Object();
person.firstName = "John";
person.lastName = "Doe";
// Better
var person = {
firstName: "John"
,
lastName: "Doe"
}
不要将保留字覆盖为键,因为这样做会模糊对这些属性的访问,这可能会产生意想不到的后果:
// Bad
var person = { class : "Person" };
// Good
var person = { klass : "Person" };
数组
为了清晰和简洁,使用字面语法创建一个new Array()
。
// Verbose
var arr = new Array();
// Succinct
var arr = [];
关注点分离
只写由程序负责的代码。让您的代码远离视图层和模板代码:
var view = {
title: "Joe"
,
calc: function () {
return 2 + 4;
}
}, output;
// Bad
output = '<div><h5>' + title + '</h5><p>' + calc() + '</div>';
// Good
var output = Mustache.compilePartial('my-template', view);
将 JavaScript 排除在 HTML 之外:
// Bad
<button onclick="doSomething()" id="something-btn">Click Here</button>
// Good
var element = document.getElementById("something-btn");
element.addEventListener("click", doSomething, false);
Note
JavaScript 中有很多模板库,比如 mustache.js、 4 可以帮助你从 JavaScript 中提取 HTML。
运营背景和范围
在可能的情况下,将您的代码包装在立即调用的函数表达式(IIFE)中。它使您的代码免受他人的污染,并使其更容易抽象成可重用的模块。
// Good
;(function( window, document, undefined) {
// My Awesome Library
...
})(this, document);
为持续时间不可知的代码执行而设计,这可以防止您的代码累积可能不再相关的请求。
// Bad because this might take longer than 100 milliseconds to complete.
setInterval(function () {
findFoo();
}, 100);
// Good this will only be called again once findFoo has completed.
;(function main() {
findFoo();
setTimeout(main, 100);
})();
为了防止破坏,声明操作上下文的社区代码(例如,use strict)
)应该包装在模块的生命中,或者在需要时包装在函数中:
// Bad
var bar = findLooseyGoosey();
"use strict";
var foo = findStrictly();
// Good
var bar = findLooseyGoosey();
;(function () {
"use strict";
var foo = findStrictly();
})();
var findStrictly = function() {
"use strict";
}
强迫
使用显式转换而不是隐式强制,因为它使代码库更具声明性:
var num = '1';
// Bad implicit coercion
num = +num;
// Good expressive conversion
num = Number(num);
强制风格
正如我前面所讨论的,要使风格存在,它必须被编成一系列可以重复的规则。在团队环境中编写代码的最大挑战之一是在开发人员之间保持统一的风格。幸运的是,对于个人和团队来说,有几种方法可以确保遵循风格。
美容师
美化程序是通过使用一系列格式约定将样式统一应用于源代码来处理代码的程序。通常,美化程序被连接到一个工作流程中,当保存一个被关注的文件时,它们会自动运行。美化器也用于从源文件中解包或移除混淆(巧合的是,代码打包有时也称为丑化)。两个流行的美化器是 JS 美化器和 CodePainter,其灵感来源于微软 Word 的 format painter。许多美化器允许您使用配置对象或命令行参数手动指定格式规则。
我们来快速看一下 JS 美化界面和选项。首先,你必须从 NPM 下载安装 JS 美化。在这个例子中,提供了一个-g
标志,它全局安装 JS 美化:
npm -g install js-beautify
安装后,您可以直接从命令行进行美化,如下所示:
js-beautify jquery.min.js
下面是 JS 美化支持的命令行和美化器选项的列表。
CLI 选项:
-f, --file Input file(s) (Pass '-' for stdin). Can also be passed directly.
-r, --replace Write output in-place, replacing input
-o, --outfile Write output to file (default stdout)
--config Path to config file
--type [js|css|html] ["js"]
-q, --quiet Suppress logging to stdout
-v, --version Show the version
-h, --help Show this help
更漂亮的选择:
-s, --indent-size Indentation size [4]
-c, --indent-char Indentation character [" "]
-l, --indent-level Initial indentation level [0]
-t, --indent-with-tabs Indent with tabs, overrides -s and -c
-p, --preserve-newlines Preserve existing line-breaks (--no-preserve-newlines disables)
-m, --max-preserve-newlines Maximum number of line-breaks to be preserved in one chunk [10]
-j, --jslint-happy Enable jslint-stricter mode
-b, --brace-style [collapse|expand|end-expand] ["collapse"]
-B, --break-chained-methods Break chained method calls across subsequent lines
-k, --keep-array-indentation Preserve array indentation
-x, --unescape-strings Decode printable characters encoded in xNN notation
-w, --wrap-line-length Wrap lines at next opportunity after N characters [0]
--good-stuff Warm the cockles of Crockford's heart
通过 IDE 实施
许多流行的集成开发环境(ide)提供了多种方法来调整和配置它们的功能,以支持个人的格式需求。通过宏和格式化引擎,这些编辑器使开发人员能够自动处理关于使用空白、行尾或制表符等的决策。理论上,这些工具应该给开发人员一种方法来处理样式指南中一些容易实现的格式化成果。然而,如前所述,有许多因素,如团队偏好、语言要求和个人选择,都会影响风格的定义。这些可变性使得任何开发人员都不太可能保持理智,不得不手动配置他们的 ide 来支持每个新项目的样式需求。
为了解决对灵活的项目级配置系统的需求,开发人员已经开始采用允许他们指定样式规则作为项目配置设置的一部分的工具。最流行的项目之一是编辑器配置项目。项目的维护者是这样描述目标的:
EditorConfig helps developers define and maintain a consistent coding style between different editors and ide. The EditorConfig project consists of a file format for defining coding styles and a set of text editor plug-ins, which enable the editor to read the file format and follow the defined styles. EditorConfig files are easy to read, and they work well with the version control system.
一旦安装到支持的 IDE 中,EditorConfig 插件就会扫描一个名为.editorconfig
的隐藏配置文件,然后相应地调整格式设置。
在下一节中,我将描述 EditorConfig 可以控制的一些属性,以及开发人员如何实施编码风格的基线。考虑下面的例子,其中.editorconfig
config 放在 JavaScript 应用的根目录中:
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# Top most config file
root = true
# Base style guide to apply to all files unless overridden by lower rules.
[*]
# Define end of line options
# Available options are "lf", "cr", or "crlf"
end_of_line = lf
# Define character set options
# "latin1", "utf-8", "utf-8-bom", "utf-16be" or "utf-16le"
# Note: Use of "utf-8-bom" is discouraged.
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
# Commonly user-defined settings
indent_style = space
indent_size = 2
# Indentation override for all JS under lib directory
[lib/**.js]
indent_size = 4
# Markdown file configurations
[*.md]
trim_trailing_whitespace = false
正如您在前面的配置文件中看到的,EditorConfig 文件为开发人员提供了一个易于使用的工具来执行某些高级格式化任务。不幸的是,这个工具从来没有打算强制执行前面定义的一些语义最佳实践。为了统一实施 JavaScript 风格,需要一个专门为此工作设计的工具。输入 JSHint。
通过 JSHint 实施样式
JSHint 最初是由 Anton Kovalyov 编写的,它是实施代码风格的另一个很好的选择。JSHint 最初是道格拉斯·克洛克福特 JSLint 项目的一个分支。这两个程序的工作方式基本相同:一行一行地遍历源文件,并列出潜在问题或偏离可接受风格的地方。
许多人认为 JSLint 过于固执己见,尽管 JSLint 的目标是检测 JavaScript 程序中的潜在错误和疏忽,但它也迫使开发人员以任意的形式编写 JavaScript,这并不一定是对他们现有方法的改进。JSLint 的源代码暗示了这种紧张关系:
"WARNING: JSLint will hurt your feelings."
Kovalyov 松开了 JSLint 的螺钉,并试图将关于风格的观点与静态代码分析的需要分开。通过这样做,JSHint 成为了原版的一个更友好、更温和的版本。JSHint 网站在描述其目标时提到了这一点:
Our goal is to help JavaScript developers write complex programs without worrying about spelling mistakes and language traps. We believe that static code analysis programs-and other code quality tools-are important and beneficial to the JavaScript community, so we should not alienate their users.
如前所述,JSHint 的目标之一是提供一种配置 linter 的方法,使得它只强制执行团队或个人寻求推广的编码约定。通常,JSHint 的选项分为四个主要类别:遗留、可执行、可放宽和环境选项。每个类别都包含许多不同的选项——事实上,太多了,无法在此一一列举。为了说明这一点,我将给出每个类别的几个典型例子,但是如果您感兴趣,我鼓励您详细阅读文档。
- 可执行选项:顾名思义,这些额外的选项可以由 jsHint 执行。这里有几个例子:
camelcase (true | false) // This option allows you to enforce camelCase style for all variable names.
undef (true | false) // Prevents you from defining variables which are initially undefined. Often times when this happens it is because a variable was declared but never used at all.
- 宽松的选择:一些对一个人来说是最佳实践的规则对另一个人来说却很烦人。JSHint 知道这一点,并提供了一组选项来减少默认情况下由 linter 触发警告的情况。例如:
evil (true | false) // It is almost universally agreed that the use of eval is a bad idea because it exposes a conduit for a third-party to inject malicious code and have the host application execute it.
debug (true | false) // This option allows you to suppress warnings about any use of the debugger statement in the code.
- 环境选项:这一类别中的选项定义由其他库(如 jQuery 或 Nodejs)公开的任何全局变量。
jquery (true | false) // whether or not to expose the global $ or jQuery variables.
Caution
在你继续之前给你一个警告。JSHint 等静态代码分析工具只验证代码的语法结构。这对于捕捉小错误或风格不一致是一个巨大的素材,否则可能会从日常开发的裂缝中溜走。然而,这些工具不能告诉你所写的代码是否真的达到了预期的目的。为此,开发人员需要在各种不同的环境下测试代码,以确保它能按预期执行。
摘要
在这一章中,你学到了风格是一个过程的独特方法。对于风格的存在,它必须被编纂成一系列可重复的步骤。与 JavaScript 相关的风格旨在使代码更具表现力,更易于阅读和理解,并以最小化可能引入 bug 的潜在缺陷的方式编写。
程序员在交易时应该记住几条规则。格式和命名约定要一致。通过使用描述变量和函数用途的名称来表达。通过编写包含关注点分离的模块化代码来保持简洁,其中函数和变量只有一个任务。保持克制,拥抱 JavaScript 的简洁,但不要以牺牲可读性为代价。
你可以在这里找到更多关于美化者的信息:
你可以在这里找到更多风格指南:
- “写作原则一致,惯用 JavaScript:
https://github.com/rwldrn/idiomatic.js/
- 谷歌 JavaScript 风格指南:
http://google-styleguide.googlecode.com/svn/trunk/javascriptguide.xml
- Airbnb JavaScript 风格指南:
https://github.com/airbnb/javascript
- jQuery 风格指南:
http://contribute.jquery.org/style-guide/js/
Footnotes 1
http://www.nytimes.com/2009/04/22/books/22elem.html?_r=0
2
https://mail.mozilla.org/pipermail/es-discuss/2011-September/016802.html
3
http://yuiblog.com/blog/2006/04/11/with-statement-considered-harmful/
4
https://github.com/ja/nl/mustache.js/
八、工作流程
Abstract
一场反常的暴风雪让我明白了如何通过改进我的工作流程来使 JavaScript 应用开发更快、更有趣、表面上更有利可图。本章的目标是教其他人如何做同样的事情。
一场反常的暴风雪让我明白了如何通过改进我的工作流程来使 JavaScript 应用开发更快、更有趣、表面上更有利可图。本章的目标是教其他人如何做同样的事情。
不要铲雪
不要把行动和成就混为一谈。—约翰·伍登
我们在堪萨斯州遇到了一场巨大的暴风雪,人们亲切地称之为“奥兹国的暴风雪”像许多有学龄儿童的人一样,我们的房子是分开的。我们的孩子期待着放学后的一天,在外面嬉戏,回到温暖的可可杯,舒适地坐在炉火旁。我和妻子担心这场暴风雪会把我们淹没在工作的雪崩中。
像所有善良的堪萨斯人一样,在暴风雨的那一天,我尽职尽责地准备好与大自然母亲战斗。我给自己穿了好几次衣服,让我的四肢裹上层层温暖。然后我摇摇摆摆地走进车库,从挂着塑料红色雪铲的挂板上取下它。我想象自己是一个维京人,从石制壁炉上方打开他沾满鲜血的战斧。我打开车库门,走向车道上洁白的原始景观。
铲了几分钟后,“诚实劳动”的新鲜感已经消失了。取而代之的是麻木地意识到,我将不得不做一上午挖掘车道的苦差事。像许多其他非专业铲雪人员一样,我做的是这项工作,而不是支付我的账单,即设计和开发软件。然后,我开始计算在我已经浪费的计费时间里,我本可以购买多少台吹雪机,深感沮丧。
在这一点上,我对我的情况以及它与软件开发的关系有了一个顿悟。我遇到的是工作流程问题。我从事的是一项暂时重要但从长远来看毫无价值的任务。上午的大部分时间,我都在把车道上的雪沿着我的院子边缘堆成令人印象深刻的白色小山。这个过程花了我几个小时,但很快太阳就会抹去这一艰苦工作的所有证据。
我开始想,在我的日常开发过程中,有哪些任务是像铲雪一样的。这些任务似乎是必不可少且不可避免的,但最终可以通过更好的工具或更清晰的视角来更快地完成。
什么是工作流
当用 JavaScript 或任何语言开发一个项目时,每个项目都会经历不同的阶段。经理们发现给这些阶段命名很有用(例如,“计划”、“开发”、“测试”和“部署”)。然后,他们将日常任务分成一个阶段或另一个阶段。当他们这样做时,他们采用了工作流,简单地说就是定义和应用规则的过程,以控制任务、数据和附属资料如何以及何时从一个人传递到另一个人。
工作流通常与团队遵循的更大的开发方法紧密结合。例如,敏捷团队可能会遵循强调紧密迭代和较小开发阶段的工作流。而瀑布团队可能会强制执行一个工作流规则,确保在没有完整的规范之前不能构建任何东西。工作流的目标是最大化生产力和最小化复杂性。
然而,这种愿望说起来容易做起来难。通常,工作流的正确实现是一种平衡行为,只有通过定义足够精确的规则才能完全遵循,而不会限制正在开发的过程或产品中的创新或改进。当一个工作流减慢了开发速度的时候,就是它需要被重新评估的时候。
明智的 JavaScript 开发工作流程
尽管我之前说过工作流通常是由团队或者开发方法决定的,但是开发人员也有自己的工作流。本节描述了 JavaScript 开发的合理的个人工作流程,它分为六个阶段:工具选择、引导、开发、测试、构建和支持。图 8-1 显示了该工作流程。
图 8-1。
A diagram that visualizes the workflow process
工具选择
在你建造任何东西之前,你必须选择合适的工具。在这一阶段,开发人员通过对编码环境做出重要选择,并确定所需的任何外部资源(例如,库或应用框架),为应用奠定基础。工具的选择对应用有持久的影响,甚至在选择工具的人离开之后。您不仅是在为自己选择开发栈,也是在为后来的其他开发人员选择开发栈。本节致力于了解如何选择正确的工具,从哪里获得它,以及如何使它保持最新。
在我年轻的时候,我受训成为一名艺术家。我们画画时,我的绘画教授克莱德·福勒给学生们讲课。我们把画架随意地放在房间中央的模型或静物周围。克莱德会慢慢地绕着画室的外环转,停下来向某个学生提供反馈;但通常只是自言自语。有一天,当我正沉迷于正确地渲染袋子褶皱中形成的阴影时,他对全班同学说,对艺术知之甚少的人会说这样的话:“我不懂艺术,但我知道我喜欢什么。”事实上,他断言他们真的在说,“我不懂艺术,但我喜欢我所知道的。”直到后来,我才真正理解这个概念。当选择正确的工具时,你喜欢你所知道的东西的心态是不稳定的。如果你正处于压力之下,要尽可能快地表现出进步,那就更是如此。在这个阶段,目标应该是选择适合项目的工具,而不是适合开发人员的工具。
我知道开发人员很容易选择他们最熟悉或最擅长的工具。这具有直观的意义,因为如果你精通一种工具,那么你在使用它时会更有效率。虽然这可能是真的,但它也让开发人员成为被称为“工具法则”的思维定势的牺牲品这意味着你总是选择你最喜欢的工具,即使它对于手头的任务来说是错误的选择。
亚伯拉罕·马斯洛完美地总结了这一点,他说:“我认为,如果你唯一的工具是一把锤子,那么把一切都当成钉子是很诱人的。”换一种说法,你可能是用锤子的专家,但没人愿意住在只用锤子盖的房子里。
订购工具
直到最近,如果您想将外部 JavaScript 添加到您的应用中,您要么复制代码并粘贴到您现有的脚本文件中,要么下载源代码的副本并使用 Script 标记将其包含到您的页面中。在我讨论这些工具的实际集成之前,让我快速地转移话题,首先讨论如何对它们进行排序。
选好工具后,你需要知道去哪里买。作为一个思维实验,想象一下你在现实生活中可能会如何购买一把锤子。最有可能的是,你会选择一家商店购买。选择商店时,你要考虑商店本身的几个因素——可能是价格、便利性和退货政策。现在想象一下,我们把这三个方面重新投射到软件工具上。
价格
价格是将这个工具集成到您的项目代码库中所花费的成本(时间、精力、理智)。当评估一个软件工具的价格时,你想知道它的编写、支持和测试有多好。将价格视为使用该工具所承担风险的一个因素。目标是以最小的代价获得最大的价值。考虑一下使用 jQuery 的代价,它是世界上最流行的 JavaScript 库之一。它拥有庞大的用户基础(支持),由该领域的专家编写(代码质量)。最后,开发人员已经将它集成到几乎所有可以运行 JavaScript 的平台中(经过测试)。因此,jQuery 很可能比你自己花一个周末写的库价格更低。
便利
对我来说,在网上买锤子比开车去当地的大商店要方便得多。除非我马上需要它;然后当地商店轻而易举地赢了。假设我想要的软件工具是 jQuery。去它的网站下载我自己的副本并把它直接放在我的应用的源文件夹中似乎很方便。这样做相当于去附近的五金店买一把锤子。但是,如果我后来想更改 jQuery 的版本,会发生什么情况呢?我需要回到网站重新下载吗?我如何首先知道是否有新版本?我需要一次又一次地去它的网站等待新的发布吗?突然,这似乎不太方便。在软件世界中,这个任务通常由包管理器来处理,我将在后面详细解释。
退货政策
拿到锤子后讨厌了怎么办?如果我买它的商店不接受退货,我已经有效地把这个锤子的价格加到了我买的下一个锤子上,因为我不能退货。发展中也是如此。如果将一个工具集成到您的代码库中需要花费大量的精力,那么以后提取它也很可能需要同样多(或者更多)的精力。对于一个软件工具来说,有一个好的返回策略,意味着从你的代码库中提取它是很容易的。这就是包管理器的用武之地。
JavaScript 包管理器
包管理器是管理软件工具集合的应用,它实现自动安装、配置、更新和从各种平台上删除它们的方法。包管理器解决了工作流开发中的三个主要问题:依赖性管理、升级路径保护和软件工具管理。包管理并不是什么新鲜事。许多编程语言,如 Perl 或 Ruby,多年来一直享有完善的包管理器。
直到最近,人们还很少意识到 JavaScript 需要类似的解决方案。许多人认为脚本语言不需要额外的开销,处理这种情况的最好方法就是复制并粘贴到一个工作应用中。随着 JavaScript 的流行和使用的增长,包管理的需求和选择也在增长。这里只是 JavaScript 包管理的流行选择的一个例子:NPM、鲍尔、安德和组件!
为了说明为什么在 JavaScript 开发工作流中集成包管理器是值得努力的,我将一次一个地探索它们解决的隐含问题。我将以鲍尔为例。
人民的凉亭
我选择 Bower 作为包管理器来演示,不仅因为它提供了完美的双关语,还因为 Bower 专注于前端。对于许多 JavaScript 开发人员来说,前端是他们花费大部分时间的地方。Bower 支持许多不同的包类型,它甚至拥有一个强大的 API,开发人员可以与之交互并对其进行编程。让我们来看看如何让 Bower 运行起来。讽刺的是,Bower 是通过另一个包管理器(npm)分发的。因此,首先您需要安装 npm。npm 准备就绪后,Bower 可以安装在一条线上:
npm install -g bower
要安装最新的最棒的 jQuery,您可以简单地这样写:
bower install jquery
这个命令使 Bower 从远程服务器克隆适当的存储库。Bower 在开发人员系统的本地缓存中维护自己的组件目录。在本地缓存后,Bower 会制作一份软件副本,并将其放在与运行安装命令的路径相关的 bower_components/目录中。
您可以轻松地查看特定版本的 jQuery:
bower install jquery#1.6.0
如果您检查签出的代码,您会发现一个 jquery 目录。在这个文件中,您只会发现两个文件:component.json 和 jquery.js。
鲍尔小心
在继续之前,必须说明的是,Bower 不对上传到其存储库的包进行任何验证或确认。很容易认为这些工具集合是经过审查的,或者是以某种方式正式提供的。他们不是。你不会因为某样东西适合你的嘴而吃它。你也不应该仅仅因为 Bower 有可用的包就安装它。
依赖性管理
到目前为止,我一直用锤子来比喻软件工具。诚然,这种心理形象有点做作,但也有误导性。锤子的伟大之处在于,在你理解了它的工作原理之后,每次你使用它时,它的表现都是一样的。你不必担心你选择的螺丝刀会影响锤子的功能。不幸的是,发展中的情况并非如此。大多数软件工具依赖于一系列其他程序。这种分层是编程的本质所固有的,意味着这些工具之间形成了依赖链。这些链条经常缠绕在你的电脑内部。如果另一个程序修改了共享链中的一个链接,它会对你的系统造成严重破坏。而且作为一个额外的奖励,它经常默默地做到这一点。
包管理器试图通过使用配置文件来保护这些链,它们就像一个配方一样遵循这些配置文件。配置中的每个属性都告诉软件包管理器如何安装软件,以及它所依赖的程序(如果有的话)。在包管理器中有一个惯例,将一个配方放在源代码树的根中。Bower 将这些配置文件称为 bower.json 文件。下面是一个 jQuery 的 bower.json 文件的例子:
{
"name": "jquery"
,
"version": "1.8.0"
,
"main": "./jquery.js"
,
"ignore": []
,
"dependencies": {}
,
"devDependencies": {}
,
"gitHead": "a81132c96b530736a14a48aad3916b676d102368"
,
"_id": "jquery@1.8.0"
,
"repository": {
"type": "git"
,
"url": "git://github.com/components/jquery.git"
}
}
这个对象的结构非常简单:
- 名称:这是必需的。当然,这也是你项目的名字。
- 版本:这是一个语义版本号。
- main:这是可以在其中找到软件的端点的字符串或数组。
- 忽略:一些应用定期生成文件作为开发人员的辅助,例如记录文件的异常或创建隐藏的资源文件夹。通常,这些文件只对工具的创建者重要,安装软件包的开发人员可以忽略它们。该指令允许您指定要忽略的各种路径。
- 依赖性:这是 Bower 开始为您做繁重工作的地方。这个指令是一个 JavaScript 散列,它定义了其他工具及其细节的列表,比如软件在生产中运行所需的版本号。Bower 要么在本地缓存中查找兼容版本,要么从远程位置下载。
- devDependencies:一些工具指定只在开发期间需要的依赖。许多编写良好的工具还附带了验证功能的单元测试。创建者可以将测试框架添加到依赖列表中,然后 Bower 会在开发过程中包含它,在为产品编译代码时忽略它。
- gitHead:随着项目的发展,新的代码会取代旧的代码。Git 为每个提交分配一个唯一的散列,这允许 Bower 引用软件项目生命周期中的一个精确时刻。通过这种方式,Bower 可以检查特定版本的 jQuery 或任何其他项目。
- _id:用于引用特定组件的内部 id。
- 存储库:描述用于存储软件工具的源代码控制的位置和类型的散列。
保护升级路径
许多软件包管理器,比如 Homebrew,在系统范围内安装软件包。这通常意味着您不能同时安装一个以上版本的工具。
如前所述,鲍尔采取了不同的方法。Bower 试图只管理前端所需的软件,并且一次只管理一个应用。通过划分每个应用的依赖关系,开发人员不必担心指定最新版本的 jQuery 会如何影响以前使用旧版本的应用。早些时候,Bower 检查了 jQuery 的一个旧版本。如果您后来决定签出 jQueryUI,您可以这样做:
bower install jquery-ui
在检查 bower_components 目录时,您会看到 bower 添加了一个新文件夹:jquery-ui。等待;还有呢!如果您重新调查 jquery 文件夹,请注意 Bower 自动将它更新为较新的版本,因为在 jquery-ui 的 bower.json 文件中,它列出了 jQuery 的一个特定依赖项:
"dependencies": {
"jquery": ">= 1.8"
}
,
如您所见,它需要新版本的 jQuery。任何高于 1.8 的版本都可以工作。Bower 签出了 jQuery 的最新版本,并替换了旧版本。
相对于自己动手的方法,包管理器提供的最后一个好处是,它们提供了一种方法来轻松处理关于查找、集成和删除工具的常见任务。您已经看到了安装工具是多么容易。卸载一个也很容易:
bower uninstall jquery
conflict jquery-ui depends on jquery
bower warn To proceed, run uninstall with the --force flag
请参见已卸载...哦,等等,实际上这个命令失败了,因为 jQuery-ui 依赖于 jQuery。如果现在卸载 jQuery,将不再满足 jQuery-ui 的一个依赖项。如果您仍然希望看到世界毁灭,您可以通过在命令末尾提供- force 标志来强制卸载。
除了 Bower 能够节省您的时间之外,它还具有一些节省时间的功能,可以让您更容易地找到并安装软件。Bower 提供了一个强大的界面来搜索和查找与您的兴趣相关的包。例如,如果您想查看有哪些 jQuery 或相关插件可用,您可以首先像这样搜索 Bower:
bower search jque
Search results:
- jquery git://github.com/components/jquery.git
- jquery-ui git://github.com/components/jqueryui
jquery.cookie git://github.com/carhartl/jquery-cookie.git
... results continue...
如果您想查看您已经在本地安装了哪些包,您可以让 Bower 为您列出它们:
bower ls
/Users/heavysixer/Desktop/bower
■??]
ε□□□□□□□□□□□
ε──??″
请注意,Bower 不仅列出了您已经安装的包,还列出了每个包的依赖项。
之前您试图卸载 jQuery,但是被 Bower 的依赖管理器阻止了。如果这个命令成功了,Bower 仍然有一个隐藏的包的本地缓存,您可以使用它在以后重新安装。如果您想清除本地缓存,您可以这样做:
bower cache-clean
Bower 致力于解决整个包管理问题的一小部分:前端开发的组件控制。Bower 的开发人员明白,尽管这是一个有待解决的开放性问题,但他们的部分成功取决于 Bower 与构建堆栈中的其他流程集成的能力。
如今,许多应用都经历了一个分层部署过程,在这个过程中,代码被发送到一个编程传送带上,进行净化、缩小、模糊、打包和部署。为了让开发人员采用 Bower,它必须找到一种方法与其他外部流程共存。Bower 的解决方案是公开一个简单的高级 API,允许程序员编写脚本。难道你不知道吗,它是用 JavaScript 写的!下面是它如何工作的一个例子:
var bower = require('bower');
bower.commands
.search('jquery', {})
.on('packages', function(packages) {
/*
packages is a list of packages returned by searching for 'jquery' */
});
在这个片段中,您可以看到一些外部脚本需要 Bower。一旦定义完毕,Bower 实例就会被发出一个命令,要求它搜索任何名称中带有“jquery”的可用包。Bower 的 API 被设计成可选地发出事件来响应发出的命令。调用脚本可以为这些发出的事件注册一个侦听器。
在玩具脚本中,您正在监听“packages”事件,当调用该事件时,它允许您遍历 Bower 返回的 jQuery 包列表。Bower 发出的其他一些常见事件是数据、结束、结果和错误。
在这一节中,您学习了如何选择工具,从哪里获得工具,以及如何将管理这些工具的繁重工作交给像 Bower 这样的包管理人员。
在下一节中,您将探索如何使用工具来生成代码,这将帮助您进行开发。开始自举吧!
拔靴带
在道格拉斯·恩格尔巴特(Douglas Engelbart)通过他的 Bootstrap Alliance 推广“Bootstrap”概念之前,这个术语描述的是独自完成一项通常需要多人完成的任务的不合理尝试。想象一下靠自己努力奋斗的荒谬。随着时间的推移,这个词开始反映企业家不屈不挠的内在精神,他们利用有限的资源快速创业。自力更生的努力就像建造一架通往天堂的梯子,它主要是用胶带粘在一起的。
与开发相关的自举涉及到程序员试图用代码生成器、插件、框架和现有代码快速启动代码库。在这个阶段不编写自定义代码。相反,开发人员利用任何现成的东西来解决他们项目的一般特性。
自举不仅仅是通过将一套部件组合在一起来解决一般性的问题,还包括使用代码来编写代码。像 Ruby on Rails 这样的框架将代码生成的概念融入到了它们的 DNA 中。它们有接受自定义参数的生成器,允许开发人员快速创建定制的代码块。由于其剪切粘贴文化,JavaScript 很难理解这个概念。相反,JavaScript 中的自举通常包括将大量的库转储到一个文件夹中,并将它们连接到 HTML 页面中。在过去的两年里,JavaScript 社区对生成器充满了兴趣。
这种对生成器的支持在 Yeoman 中表现得最为明显,这是一个由 Google 开发人员编写的固执己见的工作流工具。和 Rails 一样,Yeoman 强调代码编写代码的概念。历史上,自耕农是王室的一种文书随从。顾名思义,Yeoman 项目试图将一些管理开发工作流的单调工作从开发人员身上剥离出来。
正如我用 Bower 解释了关于工具选择的突出问题一样,我将同样用 Yeoman 解释与 JavaScript 开发工作流相关的引导和任务自动化。本演示将带您完成安装和配置 Yeoman 的过程,并搭建一个基本的 AngularJS 应用。
使用约曼
在让约曼开始工作之前,你必须安装它。npm 已经就绪,您只需在控制台中键入以下命令:
npm install -g yo grunt-cli bower
Note
如果您使用的是 Yeoman 之前的版本,您可能需要清除 npm 缓存才能使用该命令。以前,您不能使用-g 标志来指定全局安装。您可以强制 npm 清除缓存并像这样更新 Yeoman:NPM 缓存清除&& npm 更新-g yo
这段代码安装了 Yeoman、grunt 命令行界面(CLI)和 Bower 包管理器(如果您正在学习,应该已经安装了)。从 install 命令就可以看出,Yeoman 通过将一系列相关技术结合在一起来帮助开发人员。美国约曼公司利用这些项目为四个主要目标服务,这些目标将在下面的章节中讨论。
脚手架
Yeoman 让开发者有机会使用各种预定义的模板作为构建的基础。这些模板中有许多是基于知名项目构建的,比如 HTML5 样板、Twitter Bootstrap 或 AngularJS。
现成的 Yeoman 官方支持几个生成器:webapp、angular、backbone、bbb、ember、chromeapp、chrome-extension、bootstrap、mocha 和 karma。我把它作为一个练习留给读者去探索每一个。
约曼提供了一种直接从 npm 下载和安装新发电机的机制。在搭建 AngularJS 应用之前,必须确保安装了 AngularJS 生成器:
npm install generator-angular
安装后,您可以使用以下代码搭建 AngularJS 应用:
yo angular
这段代码调用 AngularJS 生成器。一旦运行,程序通过一系列是或否的问题提示开发人员,同时试图确定更多关于项目基本需求的信息。为了简单起见,我对所有这些问题的回答都是否定的。完成后,Yeoman 会生成类似如下的输出:
create app/styles/bootstrap.css
create app/index.html
create component.json
create package.json
create Gruntfile.js
invoke angular:common:/Users/heavysixer/Desktop/yeomanapp/node_modules/generator-angular/app/index.js
create .bowerrc
create .editorconfig
create .gitattributes
create .jshintrc
create app/.buildignore
create app/.htaccess
create app/404.html
create app/favicon.ico
create app/robots.txt
create app/styles/main.css
create app/views/main.html
create test/runner.html
create .gitignore
invoke angular:main:/Users/heavysixer/Desktop/yeomanapp/node_modules/generator-angular/app/index.js
create app/scripts/app.js
invoke angular:controller:/Users/heavysixer/Desktop/yeomanapp/node_modules/generator-angular/app/index.js
create app/scripts/controllers/main.js
create test/spec/controllers/main.js
这是一个很好的开始!Yeoman 创建了一个合理的应用结构,并连接了所有 AngularJS 依赖项。就像电视上的那个人说的,“但是等等;还有呢!”
虽然这个生成器创建了一个完整的 AngularJS 应用,但也有一些更小的生成器可以用来创建 AngularJS 框架的各个方面。以下是一些例子:
yo angular:controller myController
yo angular:directive myDirective
yo angular:filter myFilter
包装管理
如果您需要向项目中添加一个新的依赖项,使用 Yeoman 很容易,因为它集成了 Bower。让我们将 Angular-UI 项目添加到应用中。此代码添加了一组有用的 AngularJS 过滤器和指令:
bower install angular-ui
同样,如果一切正常,您应该会在终端窗口看到如下输出:
bower cloning git://github.com/angular-ui/angular-ui.git
bower caching git://github.com/angular-ui/angular-ui.git
bower fetching angular-ui
bower checking out angular-ui#v0.4.0
bower copying /Users/heavysixer/.bower/cache/angular-ui/bd4cf7fc7bfe2a2118a7705a22201834
bower installing angular-ui#0.4.0
在 Bower 为自己的目的缓存 angular-ui 源代码之后,它会在 Yeoman 创建的 app 目录中的 bower_components 文件夹中放置一个副本。Bower 将这种浅层复制作为依赖性管理过程的一部分。
内置服务器
Yeoman AngularJS 生成器的目的是快速搭建一个基本的 AngularJS 应用,开发人员可以开始修改它以满足自己的需求。像许多现代 JavaScript 框架一样,AngularJS 是数据驱动的。这通常意味着连接到一个服务器来拉回资源以显示给用户。不幸的是,浏览器中的安全限制阻止您将本地文件拖到浏览器中,然后发出远程 AJAX 请求。幸运的是,Yeoman 内置了一个很棒的服务器。
从项目的根文件夹中,您可以通过在控制台中键入以下命令来快速启动 AngularJS 应用:
grunt server
Note
如果您没有安装 Ruby 或 Compass gem,您可能会在尝试运行服务器时收到一条警告消息。您可以安装 Ruby 和 Compass gem,或者使用-- force
环境标志:grunt server --force
强制服务器在没有它们的情况下启动。
您应该看到 Grunt 在您的计算机上启动一个作为进程运行的服务器,然后打开您的默认浏览器,其中已经加载了您的应用。无需首先将网站部署到网络上就能快速启动服务器的能力可以节省大量时间,但这还不是最酷的部分。Yeoman 服务器实际上是一个 LiveReload 服务器。这意味着除了提供站点的本地文件,服务器还会监视它们的变化。当它发现你改变了一个文件,它会自动重新加载浏览器。
虽然这看起来是一个微不足道的增加,但是想象一下你浪费了多少时间,让你的 IDE 移动到你的浏览器并点击重新加载。从长远来看,杀死这种重复的任务真的会增加。既然我们新开发的应用已经在浏览器中启动并运行,是时候进入工作流程的下一个阶段了:开发。
发展
在开发阶段,程序员编写代码,测试产品断言,并追踪 bug。这些任务中有许多涉及大量重复的体力劳动。这样,开发人员就成了瓶颈,因为他们一次只能完成一项任务。正如您在引导阶段所看到的,任务自动化在提高开发过程中的生产速度和质量方面起着重要的作用。
它在两个方面提高了速度:它通常可以比开发人员更快地完成这些任务,并且许多任务可以并行运行,这改善了曾经是一系列连续步骤的情况。
引导部分主要关注编写代码的代码。在开发阶段,您探索通过捕捉简单错误或实施最佳实践来提高开发人员编写的代码质量的工具。
一箱咖啡脚本
CoffeeScript 是编译成 JavaScript 的精品语言。CoffeeScript 深受 Ruby 的影响,并从它那里借用了很多简洁的语法。与 Ruby 不同,CoffeeScript 中的代码缩进很重要。这是因为 CoffeeScript 在编译过程中使用缩进来帮助定义代码的词法范围。
CoffeeScript 有时会被严肃的 JavaScript 程序员认为是不必要的抽象,只是用另一种要学习的微语言来搅浑开发的水。在他们看来,JavaScript 作为一种语言已经很容易编写和阅读了。因此,让另一种语言为他们做这件事没有任何好处。让我在下一节阐述我支持 CoffeeScript 的理由。
Note
以下示例假设您已经安装了 CoffeeScript。关于安装 CoffeeScript 的更多信息可以在这里找到: http://coffeescript.org/
少写
如果说写代码是最花时间的过程,那么少写代码,得到同样的结果是好事,对吧?CoffeeScript 有一个非常简洁的语法,允许您编写如下内容:
square = (x) -> x * x
根据 CoffeeScript 编译器的设置,它会将前面一行编译成如下所示:
(function() {
var square;
square = function(x) {
return x * x;
};
}).call(this)
本节的目的是解释为什么应该使用 CoffeeScript,而不是如何编写它。现在,我要说的是,在 CoffeeScript 中,单箭头定义了一个函数,圆括号定义了该函数可以接受的参数。重要的一点是,CoffeeScript 可以将一条非常简洁的语句外推到任何 JavaScript 开发人员都应该能够阅读的 JavaScript 源文件中。
固执己见的翻译
正如您在前面的例子中看到的,CoffeeScript 编译器不仅仅是创建了我们函数的一对一翻译。CoffeeScript 试图遵循 JavaScript 中常见的最佳实践,并在可能的情况下,为您实施这些实践。在培训团队中的新开发人员时,我经常让他们先从 CoffeeScript 开始,然后再转到 JavaScript。通过这种方式,我可以谈论 CoffeeScript 修改代码背后的原因。CoffeeScript 对这个简单的方法做了几个重要的修改,值得一次讨论一个。
第一个修改是 CoffeeScript 编译器将函数包装到一个立即调用的函数表达式(IIFE)中。通过将函数封装到一个闭包中,CoffeeScript 保护代码不被其他脚本意外覆盖。
IIFE 还为与其他源文件的连接准备代码。文件的连接和缩小是自动构建系统中的常见任务。通过将所有文件合并成一个文件,浏览器必须发出更少的请求,这加快了站点的呈现速度。不幸的是,有时连接会破坏代码。出现这种情况有多种原因,但一个常见的错误是一个或多个文件在开头或结尾没有换行符。这可能导致代码一起运行,从而产生错误。
CoffeeScript 对我们代码的下一个增强更加微妙,因为它是关于如何编写代码的意见。在原始函数中,您使用了函数表达式而不是函数声明。CoffeeScript 使得编写函数声明变得几乎不可能,但是它这样做有一个很好的理由。
早期版本的 Internet Explorer(版本 8 和更低版本)有一个作用域问题,即命名函数可以同时被视为声明和表达式。
CoffeeScript 通过几乎完全使用函数表达式来回避整个问题。事实上,CoffeeScript 允许函数声明的唯一地方是在定义类的时候。
除了执行函数表达式,CoffeeScript 还将变量定义为局部变量,并将其声明移到了函数块的顶部。通过这样做,CoffeeScript 可以保护您避免任何不可预见的变量提升问题,这些问题可能是在定义函数之前调用函数而引起的。
最后但同样重要的是,CoffeeScript 从函数中返回值,即使您没有明确请求它。就像在 Ruby 中一样,CoffeeScript 假设函数体中的最后一个元素应该是返回值。因为 CoffeeScript 实施了这个约定,所以所有的函数签名都受益于基本级别的一致性。
快速失败
乍一看,这似乎有悖常理,但 CoffeeScript 无法编译成 JavaScript 实际上是一件好事。顾名思义,JavaScript 是一种脚本语言,浏览器不需要编译就可以执行。
CoffeeScript 只有在语法正确的情况下才会编译成 JavaScript。现在,这并不意味着代码会如你所愿,但至少在浏览器执行它时,它将是有效的 JavaScript。
统一团队代码
大多数专业开发人员作为团队的一部分以小组的形式编写代码。通常,团队有一个风格指南,他们遵循该指南来保持代码的可读性和统一性。这些约定可以涵盖从如何命名变量到代码缩进多少个空格的范围。使用 CoffeeScript 不会解决所有这些问题,但它至少会保证编译后的 JavaScript 具有某种程度的一致性。
我推广 CoffeeScript 的一个原因是它遵循了一系列旨在提高代码质量的最佳实践。CoffeeScript 通过控制源代码的最终形式(就像它在生命中包装我们的代码时一样)或通过限制您可以首先编写的代码类型(就像他们使编写函数声明变得困难时一样)来实施它的最佳实践集。
CoffeeScript 通常被认为是一种爱它或恨它的技术。不幸的是,许多人甚至在尝试之前就下定了决心。有时候,当我哄骗一个固执的开发者时,我觉得我在和我的孩子说话,“你不一定要喜欢它,但是你一定要试一试。”
不管你个人对 CoffeeScript 的感觉如何,这种语言的流行是不可否认的,支持它的工具生态系统也在不断发展。默认情况下,Yeoman 会自动观察 CoffeeScript 文件并为您编译它们,现在许多生成器最近也增加了对 CoffeeScript 的支持。例如,如果您想使用 CoffeeScript 而不是普通的 JavaScript 生成 AngularJS 项目,您可以提供可选的“- coffee”参数。完整的命令如下所示:
yo angular --coffee
棉绒收集器
CoffeeScript 对代码风格的自动执行为源代码提供了一层事实上的代码分析。就好像有人越过你的肩膀看着你说,“你不会真的想这么写的;我来帮你修吧。”这种做法疏远了一些人。对那些开发人员来说幸运的是,有其他工具可以提供静态代码分析,而无需为您编写代码。
JSHint 最初是由 Anton Kovalyov 编写的,是代码分析任务的另一个很好的选择。JSHint 最初是道格拉斯·克洛克福特 JSLint 项目的一个分支。这两个程序的工作方式基本相同:一行一行地遍历源文件,并列出潜在问题或偏离可接受风格的地方。
许多人觉得 JSLint 太固执己见了。尽管 JSLint 的目标是检测 JavaScript 程序中的潜在错误和疏忽,但它也迫使开发人员以任意形式编写 JavaScript,这并不一定是对他们现有方法的改进。JSLint 的源代码暗示了这种紧张:
"WARNING: JSLint will hurt your feelings."
Kovalyov 松开了 JSLint 的螺钉,并试图将关于风格的观点与静态代码分析的需要分开。通过这样做,JSHint 成为了原版的一个更友好、更温和的版本。JSHint 的网站在描述目标时提到了这一点:
Our goal is to help JavaScript developers write complex programs without worrying about spelling mistakes and language traps. I believe that static code analysis programs-and other code quality tools-are important and beneficial to the JavaScript community, so we should not alienate their users.
JSHint 的目标之一是提供一种配置 linter 的方法,以便它只强制执行团队或个人寻求推广的编码约定。JSHint 的选项分为三个主要类别:可实施的、可放松的和环境选项。
每个类别包含许多不同的选项;事实上,不胜枚举。相反,我将为每个类别提供几个典型的例子来说明这一点,但是我鼓励那些感兴趣的人详细阅读文档。
可执行期权
顾名思义,这些额外的选项可以由 JSHint 强制执行。这里有两个例子:
camelcase (true | false) //
:此选项允许您对所有变量名强制使用 camelCase 样式。undef (true | false) //
:防止您定义最初未定义的变量。这种情况经常发生,一个变量被声明了,但是从来没有被使用过
可放宽的选项
对一个人来说是最佳实践的规则对另一个人来说却很烦人。JSHint 知道这一点,并提供了一组选项来减少默认情况下由 linter 触发警告的情况。这里有两个例子:
- 几乎所有人都认为使用 eval 是个坏主意,因为它暴露了第三方注入恶意代码并让宿主应用执行代码的渠道。
debug (true | false) //
:该选项允许您禁止在代码中使用调试器语句的警告。
环境选项
此类别中的选项定义了由其他库(如 jQuery 或 Nodejs)公开的任何全局变量。这里有一个例子:
jquery (true | false) //
:是公开全局$还是 jQuery 变量。
幸运的是,作为自动化构建过程的一部分,Yeoman 被自动配置为 lint 任何 JavaScript,这一点我将在后面介绍。您可以通过编辑 JSHint 资源文件来修改默认的 JSHint 设置,该文件位于应用目录的根目录中。该文件被命名为. jshintrc。
在继续之前有一个警告:静态代码分析工具,比如 JSHint,只验证代码的语法结构。这对于捕捉小错误或风格不一致是一个巨大的素材,否则可能会从日常开发的裂缝中溜走。然而,这些工具无法告诉您所编写的代码是否真的达到了预期的目的。为此,开发人员需要在各种不同的环境下测试代码,以确保代码按预期执行。
测试
测试可能意味着任何事情,从断言应用执行了它被设计的任务,到它在各种平台(如桌面、电话或平板电脑)上看起来是否正确。编写有效的测试并知道首先要测试什么可以改进工作流程的这个阶段。同样,自动化是关键,不仅在为开发人员运行测试时如此,在跨多个平台分布测试用例时也是如此。
我还应该提到,测试并不总是遵循开发过程。包含测试驱动开发(TDD)或行为驱动开发(BDD)的方法遵循测试优先的范例。这些开发人员从编写描述需要编写的功能的测试开始。然后运行测试以确保它们失败。一旦编写了一个测试,编码才开始。
许多方法还指出,开发人员应该只编写足够通过测试的代码。希望代码库可以更精简,因为不需要的功能不会被添加。我把开发之后的测试放在这一章中,主要是因为我相信这是大多数人认为的流程一起流动的顺序。实际上,开发和测试可以是紧密耦合的阶段,它们一起振荡。
在这一节中,我将介绍几个与测试相关的工具,并演示如何将它们有效地集成到您的工作流程中。
如何测试
我敢打赌,除了 JavaScript 之外,大多数使用其他语言的专业开发人员都会将测试作为他们正常工作流程的一部分。JavaScript 在这方面仍然落后的原因与典型的 JavaScript 开发人员的素质关系不大,更多的是因为 JavaScript 可以在如此多的不同平台和上下文中运行。在大多数语言中,编写测试是最难的部分,但是在 JavaScript 中是运行测试。幸运的是,许多聪明的开发人员一直在努力工作,慢慢解决这个问题。以自动化的方式可靠地运行 JavaScript 测试有几个可行的选择。
JavaScript 测试人员通常分为两个阵营:使用独立引擎(如 V8 或 Rhino)进行测试的,以及在浏览器中运行的。我将演示两个测试程序:Karma 和 PhantomJS。
因果报应
Karma 是一个最初由 AngularJS 团队在编写 AngularJS 时并行开发的测试运行程序。它是测试框架不可知的,这意味着您可以使用任何您最熟悉的 JavaScript 测试库。它有一个内置的文件监视器,开发人员可以对其进行配置,以便在监视器看到源文件发生变化时自动运行相关的测试。
Karma 被设计成在实际的设备和浏览器上运行测试,这意味着测试得到了代码在目标设备/平台上将如何执行的真实表示。Karma 构建时考虑到了更大的工作流程,并为持续集成工具(如 Jenkins、Travis 和 Teamcity)提供了各种入口点。
Karma 唯一的依赖项是 Node.js,您应该已经安装了它。Karma 团队建议您通过 npm 在全球范围内安装项目,可以这样做:
$ npm install -g karma
# Start Karma
$ karma start
运行 start 命令应该会打开一个浏览器,在活动选项卡中运行 Karma。您应该会在控制台中看到类似这样的内容:
INFO [karma]: Karma server started at
http://localhost:8080/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 26.0 (Mac)]: Connected on socket id TPVQXqXCvrM2XhRwABfC
到目前为止,业力并没有那么有帮助;它只是闲置在打开的浏览器中,因为没有测试要运行——或者有吗?如果查看 Yeoman 生成的目录结构,应该会看到一个 main.js 文件。它位于/test/spec/controllers/目录中。现在您有一个测试要运行,您只需要配置 Karma 来运行它,这需要一点点配置。
作为引导过程的一部分,Yeoman 已经为您生成了一个配置文件。如果您查看根目录,您应该看到一个名为 karma.conf.js 的文件。幸运的是,开发人员对该文件进行了很好的注释,并且选项非常容易理解。
默认情况下,Karma 被设置为以集成模式运行,但是如果您在 Karma 配置文件中手动将 singleRun 更改为 true,您可以指示 Karma 按需运行测试:
// Karma configuration
// base path, that will be used to resolve files and exclude
basePath = '';
// list of files / patterns to load in the browser
files = [
JASMINE
,
JASMINE_ADAPTER
,
'app/components/angular/angular.js'
,
'app/components/angular-mocks/angular-mocks.js'
,
'app/scripts/*.js'
,
'app/scripts/**/*.js'
,
'test/mock/**/*.js'
,
'test/spec/**/*.js'
];
// list of files to exclude
exclude = [];
// test results reporter to use
// possible values: dots || progress || growl
reporters = ['progress'];
// web server port
port = 8080;
// cli runner port
runnerPort = 9100;
// enable / disable colors in the output (reporters and logs)
colors = true;
// level of logging
// possible values: LOG_DISABLE || LOG_ERROR || LOG_WARN || LOG_INFO || LOG_DEBUG
logLevel = LOG_INFO;
// enable / disable watching file and executing tests whenever any file changes
autoWatch = false;
// Start these browsers, currently available:
// - Chrome
// - ChromeCanary
// - Firefox
// - Opera
// - Safari (only Mac)
// - PhantomJS
// - IE (only Windows)
browsers = ['Chrome'];
// If browser does not capture in given timeout [ms], kill it
captureTimeout = 5000;
// Continuous Integration mode
// if true, it capture browsers, run tests and exit
singleRun = false;
保存更改后,当您重新运行 Karma start 命令时,您应该会看到不同的结果:
karma start
浏览器应该出现一瞬间,然后又消失了。检查控制台,您应该看到相关的位应该在底部,看起来应该有点像这样:
INFO [karma]: Karma server started at``http://localhost:8080
INFO [launcher]: Starting browser Chrome
INFO [Chrome 26.0 (Mac)]: Connected on socket id UpRyiPnI-M9x4d35NiqQ
Chrome 26.0 (Mac): Executed 1 of 1 SUCCESS (0.084 secs / 0.013 secs)
如你所见,你启动了 Karma,它又启动了 Chrome,Chrome 最终为你运行了 Jasmine 测试。你明白我说的依赖管理是什么意思吗?在控制台输出的最后,您可以看到您的单个测试在几分之一秒内运行。
拥有最多的鬼魂
PhantomJS 是您将调查的下一个测试跑步者。不像 Karma,它是一个顽固的测试者,PhantomJS 试图在一个不可见的接口中复制整个 web 栈(DOM 遍历、CSS 选择、JSON 解析、Canvas 和 SVG 处理)。这有时被称为无头浏览器。PhantomJS 通过在上面放置一个强大的 JavaScript API 来增强普通浏览器的特性。
开发人员可以使用 API 来完成各种有用的任务,例如以编程方式捕获屏幕截图、监控网站性能或模拟用户与网站的交互。PhantomJS API 还允许开发人员使用熟悉的库(如 jQuery)来编写 API 脚本,这使得启动和运行速度更快。
在被单下,PhantomJS 只是 Webkit。这意味着,当编写测试时,程序员必须意识到,结果可能不会真实地反映代码在其他浏览器(例如,Internet Explorer)上的行为。与 Karma 不同,它只是一个测试运行者,PhantomJS 认为测试运行只是它擅长的许多用例之一。
测试运行基础设施不像 Karma 中那样容易访问。幸运的是,PhantomJS 拥有活跃的用户群,并且已经编写了几个附加项目来让 phantom 轻松运行测试。PhantomJS 生态系统中有几个测试项目值得一提,包括 casperJS、Poltergeist 和 GhostDriver。
不幸的是,让它们运行起来超出了本章的范围。相反,让我们专注于将幻像整合到因果报应中。当 Karma 之前运行测试时,浏览器弹出一瞬间运行测试,然后自动关闭。
通过切换到 PhantomJS,您可以完全避免这种情况,因为测试将在一个不可见的无头浏览器中运行。幸运的是,这种集成很容易实现。您只需要重新打开 karma.conf.js 文件,并将 browsers 数组中的单个条目改为 PhantomJS。
一旦保存并关闭文件,您应该再次触发 Karma start 命令。这一次,不会出现浏览器窗口,您应该会在控制台输出中看到略有不同的结果:
INFO [karma]: Karma server started at``http://localhost:8080
INFO [launcher]: Starting browser PhantomJS
INFO [PhantomJS 1.7 (Mac)]: Connected on socket id 2WUOvjjU9KSbb442Kkt9
PhantomJS 1.7 (Mac): Executed 1 of 1 SUCCESS (0.034 secs / 0.007 secs)
Karma 这次使用了幻想曲,作为奖励,用了将近一半的时间来运行测试!既然您已经了解了如何作为 JavaScript 开发工作流的一部分可靠地运行测试,那么让我们花一点时间来探索能够并且应该编写的测试类型。
测试什么
要正确测试应用,您必须从各种不同的方面着手。您希望单独测试代码,然后在集成到最终部署的环境中时再测试一次。同时,您还可以关注另一个测试流,看看代码执行得有多好。
通常,测试属于四个测试类别之一:单元、集成、性能和兼容性。我将在本节的剩余部分介绍每一类工具。
单元测试
单元测试测试单个代码单元,例如,一个较大类的特定函数。单元测试使您能够孤立地进行测试,以确保您的函数在最基本的级别上完成了预期的任务。有几个优秀的 JavaScript 测试框架:Mocha、QUnit 和 Jasmine,仅举三个例子。下面是在每个框架中编写的相同测试:
/* Written in Mocha */
var assert = require("assert")
describe('truth test', function(){
it('should know that true is equal to true', function(){
assert.equal(true, true);
})
})
/* Written in QUnit */
test( "truth test", function() {
ok( true === true, "is true!" );
});
/* Written In Jasmine */
describe("truth test", function() {
it("should know true is equal to true", function() {
expect(true).toBe(true);
});
});
集成测试
集成测试有时被称为端到端测试,因为它们一起测试一组较小的功能,以确保较大的任务按计划进行。集成测试主要用于执行一个场景,该场景代表了软件可能如何使用的潜在用例。这些测试通常需要访问额外的资源,比如外部 API 或浏览器 cookies。碰到这些外部元素会导致测试变慢,所以它们经常被模拟出来,用一个代表预期结果的虚拟对象来代替。
接下来是 AngularJS 应用主控制器的源代码。这段代码后面是一个 Jasmine 测试,也是 Yeoman 自动创建的。巧合的是,这个测试是您在检查各种测试跑步者时重复运行的相同测试。
'use strict';
/* app/scripts/controllers/main.js */
angular.module('DesktopApp')
.controller('MainCtrl', function ($scope) {
$scope.awesomeThings = [
'HTML5 Boilerplate'
,
'AngularJS'
,
'Karma'
];
});
/* /test/spec/controllers/main.js */
'use strict';
describe('Controller: MainCtrl', function () {
// load the controller's module
beforeEach(module('DesktopApp'));
var MainCtrl
,
scope;
// Initialize the controller and a mock scope
beforeEach(inject(function ($controller, $rootScope) {
scope = $rootScope.$new();
MainCtrl = $controller('MainCtrl', {
$scope: scope
});
}));
it('should attach a list of awesomeThings to the scope', function () {
expect(scope.awesomeThings.length).toBe(3);
});
});
请注意,这个测试中的大部分代码实际上是在模拟应用实际运行时的状态。这就是我所说的模拟大环境的方方面面。
一旦创建了主控制器的实例,测试就验证了包含三个元素的数组被绑定到$scope 变量的预期。测试框架将此计入通过的测试,并最终向测试运行人员报告这些结果。
性能测试
性能测试确保代码尽可能高效地工作。如前所述,PhantomJS 可用于自动化网站的网络监控。典型的用例是使用 onResourceRequested 和 onResourceReceived 属性来测量请求和响应周期的持续时间。然而,这对于程序员来说没有 devOps 中的人有用。
当我想到开发人员级别的性能测试时,它通常包括像在单元测试中一样隔离单个功能,并测量各种不同浏览器的性能。这种测试不需要每次迭代都运行一次,因为一旦你建立了结果,它就不会改变(除非你改变你的函数)。出于这个原因,我通常只使用 jsPerf 网站,它获取一个代码片段,在各种不同的浏览器中运行它,并向您返回一个报告。
兼容性测试
JavaScript 应用被部署到平台和主机应用的多样化生态系统中。兼容性测试是对开发一次并在任何地方部署的不合理愿望的考验。通过兼容性测试,开发人员可以看到相同的代码如何在各种不同的设备、浏览器等上执行。这些测试主要关注各种平台之间的差异,这通常意味着应用如何可视化地呈现,以及平台提供或限制哪些启示。因此,这些测试通常依赖于可视化的报告,而不是简单的通过-失败统计数据,这些统计数据会显示在控制台窗口中。
在一个屋檐下收集(更不用说购买和维护)不断增长的设备和浏览器列表,并对它们进行单独测试,将是最不具生产力的事情。幸运的是,有几种技术已经出现来满足这种需求。然而不幸的是,你可能需要带上你的信用卡。下面是一些提供兼容性测试的产品的简要介绍。
浏览器堆栈
根据其网站,该公司提供“所有桌面和移动浏览器的即时访问”它的付费服务让开发者可以访问各种虚拟机,从那里他们可以测试他们正在开发的产品。Browserstack 还提供屏幕截图服务,开发者可以提供一个 URL,Browserstack 反过来在许多不同的浏览器上创建结果页面的屏幕截图。
邦伊普
该工具可用于自动化多浏览器设备测试。Bunyip 可用于在您自己的设备农场上收集浏览器,但它也提供了与 Browserstack 等其他工具的集成。
Adobe Inspect
Inspect 是一项免费增值服务,允许您同步各种设备。使用 Inspect,作为开发人员,您可以更改代码,保存结果,然后观察所有连接的设备和浏览器的更新。就像 Browserstack 一样,Adobe Inspect 提供截图服务,还提供远程检查工具,可用于在远程设备上动态更改 HTML、CSS 和 JavaScript。
您可能想知道为什么我没有提到 PhantomJS,特别是因为它是免费和开源的。诚然,PhantomJS 确实提供了截图功能,而且因为它可以通过编程来捕捉它们,它们甚至可以被串成一个视频。然而,PhantomJS 只是 Webkit,因此不是真正的兼容性测试工具。
建筑物
一旦开发人员完成了一个特性并准备好与世界共享,他们就将代码部署到生产中。运输代码的艺术可以是一整本书的主题,并且已经超出了本书和一般 JavaScript 的范围。相反,本节将关注创建本地构建,这意味着将源代码准备成适合上传到 Web 或包含到更大的部署流中的形式。
如您所见,大部分 JavaScript 工作流都是以一种让程序员尽可能容易开发的形式编写代码。这可能意味着使用本地包管理器来编组依赖项或高级语言,如 CoffeeScript 作为 JavaScript 的代理。通常,其他工具如 HAML 被用来代替 HTML,SASS 被用来代替 CSS。这些工具的存在是为了让开发更有趣、更高效、更少出错。
然而,这些技术有一个巨大的缺点:没有浏览器能理解它们。因此,构建阶段的大部分时间致力于将人类易于阅读的代码转换成机器能够理解的源代码。在典型的构建过程中有几个共同的步骤:编译、分析、连接、优化、测试和通知。像往常一样,我将在接下来的部分中详细解释每个步骤。
汇编
JavaScript 只有在以 CoffeeScript 等其他形式启动时才会被编译。构建器进程通常遍历 CoffeeScript 文件,并将它们发送给编译器。然后,结果通常会保存到临时的构建目录中。
分析
如前所述,静态代码分析在确保交付的代码满足或超过预定的质量阈值,并且符合大型团队定义的风格惯例方面起着重要的作用。这种分析通常由 JSHint 之类的工具来执行,我在前面已经介绍过了。这一阶段的失败可以暂停构建,或者只是在通知阶段通过向控制台或日志文件写入报告来警告开发人员。
除了代码的静态分析,这个过程还可以使用伊斯坦布尔等工具,这是一个 JavaScript 的测试覆盖工具。伊斯坦布尔可以报告任何在测试期间没有被调用的代码区域。
串联
感觉到的应用的缓慢很大程度上是由于下载应用所依赖的所有相关源文件所需的请求数量。通过将整个源代码连接成一个文件,网站的性能将会提高。
通常,框架代码和库会跳过这一步,因为它们中的许多已经托管在其他地方的内容分发网络(cdn)上。Web 浏览器允许跨多个域的并行下载,这意味着利用 CDN 至少有两个好处。它可以通过并行浏览器请求加速初始下载,并减少剩余连接代码的文件大小。
最佳化
一旦原始 JavaScript 被编译成单个文件,构建器进程就会尽可能地减小文件大小。通常,这意味着使用诸如 UglifyJS 或 Google 的 closure 编译器之类的程序。这些压缩机中的一些比其他的更具侵略性。例如,闭包编译器试图在转换过程中使源代码“更好”。这可能意味着重写代码的某些方面,或者删除它认为没有使用的代码。
测试
所有这些对源代码的压缩、优化和美化可能会无意中破坏某些东西。因此,在发布代码之前,最好最后一次测试代码。大多数构建过程被设计为在测试失败时停止,从而降低了用错误版本覆盖生产中的代码的风险。
通知
有几个受众对构建过程的结果感兴趣。第一类是开发人员,第二类是等待将编译好的代码循环到更大的部署周期中的任何外部流程。对于感兴趣的人来说,通知可能意味着创建一个描述构建结果的报告,这可能简单到是失败还是通过。
该报告还可以概述关于代码质量和测试覆盖率的发现。一旦代码变得干净,它就可以被提交回源代码库,此时任何提交后挂钩都可以被触发。任何持续集成工具,如 Travis 或 Cruise Control 监听这些触发器,现在都知道一个新的构建已经准备好了。
继续学习 Yeoman,您现在将了解它是如何处理构建过程的。约曼实际上将这项任务委托给了其他人——同样,选择的工具是 Grunt。在引导过程中,Yeoman 为 Grunt 创建了一个配置文件,名为 Gruntfile.js,这并不奇怪。您已经尝试了其中的两个:grunt 服务器和 grunt 测试。然而,默认任务是构建过程。您可以通过在控制台中键入以下命令来开始构建过程:
grunt
当各个任务被单独调用时,您的控制台开始滚动,在过程结束时,您应该看到消息“完成,没有错误”在控制台里。现在,在应用目录中应该有一个名为 Dist 的新文件夹。该文件夹包含运行 AngularJS 应用所需的所有新编译的 JavaScript 文件。
恭喜你!您几乎已经到达了开发工作流的末尾。剩下的最后一点是如何在代码离开巢穴时支持它。
支持
一个开发人员生活中的悲哀事实是,在某个时候,软件将被发布到野外,在源代码内部的某个地方有一个非故意的故障。本章研究了将这些错误的检查和保护集成到开发工作流中的各种方法。
然而,有时这些技术是不够的,因此支持部署的代码必须是工作流的一部分。在这个阶段,开发人员使用工具和技术来尽可能快地跟踪和消除任何错误。
支持分两个阶段:当异常发生时得到通知和按需重新创建 bug,这样就可以隔离出有问题的来源。首先,我将讨论一个用于触发异常通知的工具,然后我将简要介绍如何将生产中的 bug 映射到开发源代码。
JavaScript 中的错误报告
许多现代应用框架都内置了异常通知。通常,当发生错误时,异常被代码块捕获,以便堆栈跟踪和环境变量可以被打包成报告,该报告通常被邮寄给开发人员。从这个报告中,开发人员有更好的机会来拼凑出哪里出错了。有完整的产品,如 errorCeption,专门为您解析、绘制和报告这些内容。错误报告器的基础很容易组合在一起。本质上,您只想将侦听器绑定到窗口对象的 onerror 事件。
以下是一个过于简化的示例,只是为了让您了解大致情况:
window.onerror = function(msg, url, lineNum) {
$.ajax({
url: "
http://someserver.com/exception-notifier
"
,
type: "get"
,
data: {
message: msg
,
url: url
lineNumber: lineNum
}
,
success: function(data) {
alert(“Error Reported”);
}
});
}
解开毛衣
不幸的是,这种方法并不完全安全。还记得构建过程修改 JavaScript 源代码的时候吗?所有这些压缩、混淆和连接会使调试产品代码变得像穿毛衣上的松线一样困难。过不了多久,你就只剩下一堆纱线,其他什么都没有了。这是因为压缩程序通常会缩短变量名,并从源代码中删除换行符。因此,通告程序返回的变量、方法名和行号将与未压缩的 JavaScript 不匹配。可以想象,这使得开发人员更难将错误的原因追溯到原始代码。幸运的是,近年来开发者,更重要的是浏览器,已经开始接受一个叫做源地图的概念。
源映射是编译后的文件和未压缩的 JavaScript 源之间的映射。这个映射是在编译时通过向编译器提供特殊指令而生成的。一旦编译器创建了映射,它就可以被支持浏览器的开发工具自动解析。
现在,对生成源地图的支持仍然不稳定,但是主要的编译器,包括 Google 的 Closure 编译器,都可以生成它们。另外很重要的一点是,源代码地图并不是 JavaScript 的专利。它们旨在成为任何可以缩小的文件类型的标准;所以 CSS 也支持源码图。
摘要
本章详细剖析了构建 JavaScript 应用的现代开发工作流程。有几个关键点希望你带走。
你应该尽量减少铲雪,这意味着做一些目前可能是必不可少的工作,但对项目的长期进展没有任何好处。
明智地选择您的技术组合;你经常不仅为你自己,也为你之后的每一个人做决定。选择适合工作的工具;不仅仅是你最擅长的那个。
拥抱自动化;如果你发现自己一天要手动完成一个过程好几次,那就想办法将其机械化。寻找在代码质量和编程风格方面实施社区标准的工具。这些工具不仅能帮助你发现小错误,还能为所有团队成员之间的一致性提供基线。
编写测试并持续运行它们。它们不仅证明了你的软件是可行的,而且给了你和你的团队信心去做未来的改变,而不用担心它会悄悄地破坏现有的特性。为人类而写,让构建过程去操心如何让它更小更高效。
当代码上线时,开发人员的工作流程不会停止;总会有不被考虑的边缘情况或平台。因此,当这些错误发生时,建立支持流程是很重要的。
九、代码质量
Abstract
品质不是一种行为,而是一种习惯。
—亚里士多德
品质不是一种行为,而是一种习惯。—亚里士多德
写出高质量的 JavaScript 意味着什么?质量可以被测量吗,或者它是一种主观的观点,类似于柏拉图式的关于美和艺术的理想?程序员倾向于在对质量的主观和客观理解之间摇摆不定。他们提升了软件工艺等概念,软件工艺是编写软件的手工方法。软件工匠被描述为拥有高超的技能,并且已经将他们的工作浓缩到基本的组成部分。匠人的电气化体现就是所谓的摇滚明星程序员。一个人的定义是基于一个概念,即一个人可以是如此独特的天才艺术家,工作产品在某种程度上大于他们的部分之和。然而,许多编程都围绕着通过过程化和可重复的过程来度量、重构和改进代码。这意味着质量可以被提取为一系列独立的和可测量的步骤。
如果质量是可以衡量的,那么 JavaScript 开发人员有什么机制可以确保他们产生优秀的代码?本章深入探讨了编写高质量 JavaScript 的概念,首先定义了与编程相关的质量,然后提供了一个评估和改进代码的框架。
定义代码质量
像许多吸引来自不同背景的个人的复杂学科一样,程序质量的定义经常跨越艺术和科学之间的界限。编程行为通常是创造性解决问题和运用工程师的严谨来提炼解决方案的融合。编程是通过编码的可重复步骤进行的客观观察和来自个人经验和洞察力的主观评估之间的一种张力。事实上,质量这个词支持这两种观点。芭芭拉·w·塔奇曼是这样解释品质的两面性的:“品质”这个词当然有两个意思:第一,某物的本质或本质特征,如“他的声音具有命令的品质”;第二,优秀的条件意味着好的质量,区别于差的质量(塔奇曼,1980)。
塔奇曼继续将质量描述为“自我滋养”,这是一个非常令人回味的形象。质量也被描述为一种追求,这表明它不是一个目的地,而是一个旅程。这可能是因为定义不固定;它属于时代精神。要证明这一点,你只要看看艺术史就知道了,艺术史一直在排斥或拥抱不同的艺术表现形式。在一生的时间里,法国印象派画家从被艺术机构嗤之以鼻到数年后达到了艺术界的顶峰。他们的画没有改变——只是品质的定义变了。
在这一章中,我认为评价 JavaScript 源代码需要主观和客观两种立场。事实上,我相信你甚至不能把一个和另一个完全分开。然而,在我提出这个问题之前,我需要正确地展示这两种形式。
主观质量
主观质量通常描述受启发的或必要的代码,或者 Truchman 所说的“天生的优秀”在他关于产品质量的文章中,大卫·加文定义了一种质量形式,他称之为超越。他将卓越品质定义为
.... absolute and universally accepted, uncompromising standards and signs of high achievement. However, supporters of this view claim that quality cannot be defined accurately; On the contrary, it is a simple and unanalyzable attribute, and we can only know it through experience. This definition borrows a lot from Plato's discourse on beauty. In the seminar, he thought that beauty was one of "Platonic forms", so it was an undefined term. Like other terms considered by philosophers as "logical primitive", beauty (and perhaps quality) can only be understood after a person comes into contact with a series of objects showing its characteristics. (Gavin, 1984)
这个定义清楚地阐明了这样一个观点,即主观质量依赖于个人经验或有技能的个人的指导,以认可和促进他们领域内的优秀。它断言,主观质量在其本质层面上是普遍真实的,与其说是创造的,不如说是发现的。
客观质量
客观质量断言,如果天才是可以衡量的,它是可以量化和重复的。蛋糕的质量并不取决于面包师天生的优秀,而是精确选择和测量配料以及精确遵循食谱的结果。客观质量在一个反馈循环中产生、应用和提炼关于主题的经验近似值。这种形式的质量有助于算法、测试套件和软件工具。在本章的剩余部分,我将介绍一种通过客观质量来改进代码的方法。
质量是如何衡量的?
您正在为质量寻找一个可用的定义,但是首先您需要考虑它与编程相关的各个方面。这些方面通常被表示为软件度量:
Software metrics are measurements of some attributes of a piece of software or its specifications. Because quantitative measurement is essential in all sciences, computer science practitioners and theorists have been trying to introduce similar methods into software development. The goal is to obtain objective, repeatable and quantifiable metrics, which may have many valuable applications in schedule and budget planning, cost estimation, quality assurance testing, software debugging, software performance optimization and optimal personnel task assignment.
为了通过度量来框定代码质量,我包含了六个度量标准:
- 美学:这个标准衡量代码的视觉内聚性,但是也包括格式、命名约定和文档结构的一致性和思考性。美学被测量来回答这些问题:
- 代码的可读性如何?
- 页面上的各个部分是如何组织的?
- 它在编程风格方面使用了最佳实践吗?
- 完整性:完整性度量代码是否“适合目的” 1 一个程序要被认为是完整的,必须满足或超过指定问题的要求。完整性还可以衡量特定实现符合行业标准或理想的程度。这项措施试图回答的关于完整性的问题是:
- 代码解决了它想要解决的问题吗?
- 给定一个期望的输入,代码会产生期望的输出吗?
- 它满足所有定义的用例吗?
- 安全吗?
- 它能很好地处理边缘情况吗?
- 是否经过充分测试?
- 性能:性能根据已知的基准来测量实现,以确定它有多成功。这些指标可能会考虑程序大小、系统资源效率、加载时间或每行代码的错误等属性。使用绩效评估,您可以回答以下问题:
- 这种方法的效率如何?
- 它能承受多大的负荷?
- 这种代码的容量限制是什么?
- 努力:这个度量测量产生和支持代码的开发成本。努力可以根据所用的时间、金钱或资源来分类。衡量努力有助于回答这些问题:
- 代码可维护吗?
- 它容易部署吗?
- 有记录吗?
- 写作花了多少钱?
- 持久性:为了具有持久性,程序在产品中的生命周期被测量。耐久性也可以被认为是对可靠性的一种衡量,这是衡量寿命的另一种方式。耐久性可以通过测量来回答这些问题:
- 它的性能可靠吗?
- 在必须重启、升级和/或更换之前,它可以运行多长时间?
- 是否可扩展?
- 接受度:接受度衡量其他程序员如何评估和评价代码。跟踪接收允许您回答这些问题:
- 代码有多难懂?
- 设计决策有多周密?
- 该方法是否利用了已有的最佳实践?
- 用起来过瘾吗?
为什么要度量代码质量?
“我不能对代码质量收费。”当我问一个朋友对这个问题的看法时,他直接引用了我的一个朋友的话。他的意思是代码质量主要对程序员有利,对客户来说是无形的税收。我能理解他的观点;我有不止一次的经历,当我抱怨测试方法的时候,一个潜在的客户的眼睛会相应地转回来。我朋友接着说:“客户花钱买的是一个结果,不是一个过程。当我购买西南航空的机票时,我支付的是到达目的地的费用,而不是乘坐飞机。”这种说法听起来有些天真,但是我将在这一节中论证测量代码质量不会让你失去竞争优势;这是你的竞争优势。
管理顾问汤姆·彼得斯曾经说过,“能衡量的就能完成。”在这种情况下,衡量意味着向前看,以便预测变化。通常,测试和质量度量只是在出现问题后进行的事后分析。当在开发过程中持续应用时,度量代码质量可以让您理解项目的健康状况。它也可以暗示未来负面事件的可能性。考虑以下方式,代码质量不仅可以提高您的代码,还可以提高项目的底线:
- 技术债务是一个隐喻,它描述了随着时间的推移,坏代码从您的项目中窃取的时间、金钱和资源等不断增加的成本。有许多质量度量,包括代码复杂性分析,可以识别软件中代码不足的区域。
- 有几个度量标准(比如 Halstead metrics,我将在后面介绍)可以表明维护您的代码库所需的未来工作量。了解这一点可以帮助你准确地为这些改进做预算。
- 许多代码质量度量试图理解代码中的路径。然后,这些措施可以识别缺陷存在的可能性,以及它们可能隐藏的位置。这些工具在评估另一个团队的代码时特别有价值,因为它们可以像地雷探测器一样,通过算法扫描未知的函数领域。
- 虽然发现新错误的能力很重要,但是知道何时停止编写测试是安全的技能也很重要。许多著名的开发人员已经证明测试不是免费的,所以知道什么时候停止可以省钱。许多代码质量工具可以使用简单的试探法告诉您何时达到了适当的测试覆盖率水平。
- 接受代码质量度量是预防性维护的一种形式。我听过有人不屑一顾地谈论代码质量,说这就像刷牙一样。在某种程度上,他们是对的,质量的本质是后期添加要困难得多,就像刷牙不会去除现有的蛀牙一样。
既然有了代码质量的基线,您就有了一个有效的定义,并且您不仅理解了如何度量质量,还理解了为什么您应该这样做。在下一节中,我将解释您在追求质量的过程中可以使用的各种工具和技术。
在 JavaScript 中测量代码质量
客观质量分析在程序上搅动代码,以便计算精华能够上升到顶端。这项任务是通过使用编程工具来完成的,这些工具在各种环境中评估代码,使用度量标准来得出最终的质量分数。本节解释静态代码分析,这是一种非常适合评估 JavaScript 质量的方法。
静态代码分析
静态代码分析是在不运行代码的情况下分析代码的过程。静态分析很像文本编辑器中的拼写检查器。拼写检查器扫描文档以查找文本正文中的错误和歧义,而无需理解书写的含义。类似地,代码的静态分析分析源代码的功能正确性,而不必知道它做什么。尽管 JavaScript 是一种非常动态的语言,但它非常适合静态分析,因为它没有被编译成另一种形式。本节将评估 JavaScript 中的两种静态分析方法,包括语法验证器和复杂性分析工具。
语法验证
在 JavaScript 中,语法验证有两种方法。第一种是使用诸如 JSLint 3 或 JSHint, 4 之类的 linter,它不仅检查你的代码的功能正确性,而且当你的程序没有遵循它们的最佳实践时,偶尔还会提供一点严厉的爱。考虑以下质量可疑的代码:
// foo.js
onmessage = function(event) {
"use strict"
event = event
if(event){
return {"success" : postMessage('pong'), "success" : "ok"}
}
};
我使用的是 JSHint,您可以通过这种方式将它作为 npm 模块安装(根据您的用户帐户权限,您的系统可能需要使用sudo
):
npm install jshint -g
安装完成后,您可以从终端的命令行对源文件运行 linter:
jshint foo.js
JSHint 将报告这些警告:
foo.js: line 7, col 3, Missing semicolon.
foo.js: line 8, col 16, Missing semicolon.
foo.js: line 10, col 56, Duplicate key 'success'.
foo.js: line 10, col 63, Missing semicolon.
请注意,linter 只通知您缺少分号和重复的键。就个人而言,我认为event = event
的无意义赋值也值得一提,但从技术上讲,这段代码没有任何问题。这种模糊性说明了 linter 的观点驱动方法,即它不仅验证语法,还验证您的方法。
对于那些对识别代码中所谓的不良气味不太感兴趣的人,可以使用一个简单的独立 ECMAScript 解析器,比如 Esprima, 5 ,它只会剔除无效代码。可以从 npm 模块安装 Esprima,如下所示:
npm install -g esprima
与 JSHint 类似,它可以从终端的命令行验证代码:
esvalidate foo.js
完成后,Esprima 应该会在终端窗口中输出如下内容:
foo.js:6: Duplicate data property in object literal not allowed in strict mode
Linters 和 parsers 是建立代码质量基线的优秀工具。这些工具中的许多可以并且应该集成到更大的开发工作流中。它们是提高我前面提到的美学、努力和接收质量的关键因素。然而,在大多数情况下,简单的语法卫生不足以确保代码质量。下一节将探索有助于减少代码库中复杂性蔓延的工具。
复杂性
Antoine de Saint-Exupery 可能是在谈论代码质量,他说,“完美是可以实现的,不是当没有什么可以添加的时候,而是当没有什么可以删除的时候。”高质量的代码不仅在形式上是正确的,而且在概念上是清晰的,并且在向读者说明所需的问题是如何解决的能力上是富有表现力的。不幸的是,有许多原因可以解释为什么简洁的代码会退化成操作数和操作符的混乱。团队可能会改变,功能可能会增加或减少,涉众的目标可能会改变;所有这些事件都发生在程序员们顶着压力继续发货的时候。
任何从事编程工作过一段时间的人都知道“代码是我们的敌人” 6 一个简单的发展事实是,代码越多,质量越下降。代码是句法脂肪团;添加比移除容易。所谓的代码膨胀会导致一个复杂的程序,因为程序员需要阅读、理解和维护更多的源代码。聪明的程序员通过利用设计模式和应用框架来对抗复杂性。他们采用的开发方法试图通过一致的编程方法来降低复杂性。
除了这些方法之外,复杂性也可以通过编程使用质量度量标准来测量,这些质量度量标准被调整以发现困难的代码。本节探讨了识别、隔离并最终从程序中删除复杂 JavaScript 的方法。
通过代码度量测量复杂性
对于运行时引擎来说,JavaScript 并不复杂。它可能漏洞百出,效率低下,或者不完整,但是与编程相关的复杂性是一个纯粹的人类难题。因此,代码复杂度是程序员为了完全理解一个代码单元而必须忍受的脑力劳动的度量。
多年来,程序员已经开发了度量复杂性的方法。这些度量确定了源代码中的缺点,这些缺点通常会导致复杂的代码。其中一些指标是经验观察的结果,其他的是关于程序员如何思考代码的算法解释。其他程序员已经将这些度量利用到工具中,这些工具可以定期扫描程序,以帮助开发人员了解他们的代码哪里需要重构或额外的测试。
本节展示了这些复杂性度量的精选,它们非常适合 JavaScript,以及一组可以自动化质量控制的工具。
过多的评论
复杂代码的一个明显结果是,对于读者来说,源代码不再是自文档化的。注释通常被用来使未来的程序员能够翻译以前的开发人员的方法。出于这个原因,注释可能是一个引人注目的复杂性度量,因为它们表明还有工作要做或者可以进行改进。
代码行
就像过度注释度量一样,计算代码行数具有直观的意义。随着功能的扩展,开发人员误解实现细节的可能性也在增加。代码行数可以用多种方法来度量,包括代码行数(LOC),源代码行数(SLOC),或者无注释的源代码行数(NCSL)。
评估 LOC 指标时,请确保您在正确的详细程度上进行分析。例如,将一个函数重构为三个函数可能会增加 LOC 度量,但实际上会降低源代码的整体复杂性。出于这个原因,开发人员有时称 LOC 为一个天真的度量。在评估 JavaScript 时,我发现 LOC 度量在函数级最有效,因为长函数通常是不必要的复杂性的标志。
耦合
如果一个对象需要另一个对象的实现的显式知识才能工作,那么依赖对象就被认为是与另一个对象紧密耦合的。应尽可能避免这种耦合,因为它会使整个源变得脆弱。此外,这意味着信息隐藏正在失败,实现逻辑正在泄漏到更大的代码库中。
当静态分析 JavaScript 的紧密耦合时,可以计算用于访问对象链中属性的点数。在可能的情况下,你应该将通话链控制在三点或更少。这里有一个例子:
// too tighly coupled
var word = library.shelves[0].books[0].pages[0].words[10];
// loosely coupled
var shelf = library.getShelfAt(0);
var book = shelf.getBookAt(0);
var page = book.getPageAt(0);
var word = page.getWordAt(10);
每个函数的变量
带有太多局部变量的 JavaScript 函数可能表明该函数可以改进,要么通过关注点分离,要么通过将变量分组到一个公共对象中。考虑以下示例:
var race = function () {
var totalLaps = 10;
var currentLap = 0;
var driver1 = "Bob";
var driver2 = "Bill";
var car1 = {
driver: driver1
,
fuel: 100
,
maxMph: 100
,
miles: 0
,
tires: 4
};
var car2 = {
driver: driver2
,
fuel: 100
,
maxMph: 100
,
miles: 0
,
tires: 4
};
var cars = [car1, car2];
while (currentLap < totalLaps) {
currentLap++;
cars.forEach(function (car) {
car.miles += Math.floor(Math.random() * car.maxMph) + 1;
});
}
if (car1.miles > car2.miles) {
console.log(car1.driver + " wins!");
} else {
console.log(car2.driver + " wins!");
}
}
// => (Bob or Bill) wins!
race();
race
函数处理的不仅仅是模拟比赛,所以函数体充满了局部变量。通过改进关注点的分离,您可以将变量的数量从七个减少到两个,如下所示:
var addCar = function (driver) {
return {
driver: driver
,
fuel: 100
,
maxMph: 100
,
miles: 0
,
tires: 4
};
};
var race = function (cars) {
var totalLaps = 10;
var currentLap = 0;
while (currentLap < totalLaps) {
currentLap++;
cars.forEach(function (car) {
car.miles += Math.floor(Math.random() * car.maxMph) + 1;
});
}
cars.sort(function (a, b) {
return a.miles > b.miles ? -1 : 1;
});
console.log(cars[0].driver + " wins!");
};
// => (Bob or Bill) wins!
race([addCar('Bob'), addCar('Bill')]);
每个函数的参数
对于使函数过于复杂的参数数量,并没有硬性规定。然而,向函数中传递一系列参数可能表明函数的目的是混乱的。在某些情况下,你可以通过逻辑地组织相关的论点来减少读者必须记住的论点的数量。这可以通过将它们分组到一个对象中来实现,您可以将该对象提供给函数:
var detectCollision = function (x1, x2, y1, y2, xx1, xx2, yy1, yy2) {
// more code
}
// Restructure the function to accept logically organized objects.
// rect1 == { x1:0, x2:0, y1:0, y2:0 }
var detectCollision = function (rect1, rect2) {
// more code
}
嵌套深度
深度嵌套的代码比浅层代码更复杂,也更难测试。函数中的嵌套深度有多种测量方法。例如,这些函数中的每一个都有四个嵌套深度:
// Nesting depth of three
var isRGBA = function (color) {
if (color != 'red') {
if (color != 'blue') {
if (color != 'green') {
if(color != 'alpha'){
return false;
}
}
}
}
return true;
};
// Nesting depth of three
var isRGBA = function (color) {
if (color != 'red' && color != 'blue' && color != 'green' && color != 'alpha') {
return false;
}
return true;
};
isRGBA
的第二个实现与第一个版本具有相同的嵌套深度,这似乎是不正确的;毕竟只有一个 if 语句。然而,逻辑操作符(&&
)的使用是用来嵌套条件逻辑的,所以读者必须在心里解开它们。应该重新考虑总嵌套深度为 4 或更多的函数。
圈复杂度
圈复杂度有一个听起来非常复杂的名字。每次大声说出来都觉得自己更聪明。自己试试;你会明白我的意思。幸运的是,这项措施背后的概念比其名称更容易理解。圈复杂度是由 Thomas McCabe (McCabe,1976)发明的,作为发现函数内部复杂度的一种方法。他断言,函数的复杂性与函数体内发生的控制流决策的数量成正比。
这种方法以两种方式之一得出复杂性分数:
- 它可以计算一个函数中的所有决策点,然后加 1。
- 它可以把函数看成一个控制流图 7 (G)从顶点总数(n)和连通平方分量总数(p)中减去边数(e);例如:
v(G) = e - n + 2p
基本示例
为了更好地理解这一措施,让我们看看它的行动。在下面的例子中,我编写了一个假想的页面路由器,它可以从一些重构中受益。为了更加清晰,我在函数的每个决策点都增加了复杂度分数。此外,分数从 1 开始,而不是在末尾加 1。
var route;
// score = 1
route = function() {
// score = 2
if (request && request.controller) {
switch (true) {
// score = 3
case request.controller === "home":
// score = 4
if (request.action) {
// score = 5
if (request.action === "search") {
return goTo("/#home/search");
// score = 6
} else if (request.action === "tour") {
return goTo("/#home/tour");
} else {
return goTo("/#home/index");
}
}
break;
// score = 7
case request.controller === "users":
// score = 8
if (request.action && request.action === "show") {
return goTo("/#users/show" + request.id);
} else {
return goTo("/#users/index");
}
}
} else {
return goTo("/#error/404");
}
};
这个函数的复杂度为 8,McCabe 认为这是非常复杂的。理想情况下,McCabe 认为这个函数的得分应该在 4 分以下。8 分说明这个功能做的太多了。圈分数能告诉你的不仅仅是一个函数需要修剪的事实;McCabe 建议为每个圈点编写一个测试。这样做将确保覆盖所有可能的决策路径。因为分数越低越好,所以任何分数为 10 或更高的函数都会增加函数中出现 bug 的可能性。
限制
圈度量的一个盲点是,它只关注控制流,把它作为函数中复杂性的唯一来源。任何花了很少时间阅读代码的程序员都知道复杂性不仅仅来自控制流。例如,这两个表达式将获得相同的圈分数,尽管其中一个显然更难理解:
// Cyclomatic score: 2
if(true){
console.log('true');
}
// Cyclomatic score: 2
if([+[]]+[] == +false){
console.log('surprise also true!');
}
此外,通过使用 McCabe 的推理,一个单一的整体程序,不管多长,总会被认为没有一个只有一个 if 语句的程序复杂。对于开发者来说,这与现实不符。这并不是说这个指标没有价值;它像代码矿坑中的金丝雀一样工作得很好,对函数中可能潜伏的潜在问题起到了早期警告的作用。值得考虑另一个度量,它不仅仅使用控制流来度量复杂性。为此,您需要发现 NPATH。
n 路径复杂性
Brian Nejmeh 创建了 NPATH 复杂度度量来分析函数或单元级别的代码质量。Nejmeh 认为软件质量的最大收益是在单元级别上实现的,因为它们可以从源代码的其余部分中分离出来,因此提供了一种客观度量复杂性的有效方法。根据 Nejmeh:
NPATH, the non-circular execution path of the function, is an objective measure of software complexity related to the ease with which the software can be comprehensively tested. (Nejmeh,1988)
计算非循环执行路径是一种隐晦的说法,这种方法是对一个函数可以执行的所有不同方式的总结。NPATH 使用这个路径计数来导出函数的最终复杂度分数。这类似于 McCabe 的圈复杂度测量的工作方式。两者的区别在于圈复杂度计算控制流决策,而 NPATH 计算所有可能的路径。奈梅赫认为 NPATH 的非循环计数是对麦凯布方法的改进。具体来说,Nejmeh 认为 McCabe 的度量标准未能衡量一个函数的全部复杂性,原因如下:
- 与指数函数相比,圈复杂度不能正确地解释通过线性函数的不同数量的非循环路径。
- 它以同样的方式对待所有的控制流机制。然而,Nejmeh 认为有些结构天生就难以理解和正确使用。
- McCabe 的方法没有考虑函数中嵌套的级别。例如,三个连续的 if 语句与三个嵌套的 if 语句得分相同。然而,Nejmeh 认为程序员理解后者会更困难,因此应该被认为更复杂。
基本示例
为了更好地理解 NPATH 度量如何对 JavaScript 函数进行评分,考虑下面的例子。如前所述,NPATH 对各种控制流机制进行不同的评分。为了帮助读者,我在每个控制流语句上方添加了评分说明作为注释。
var equalize;
equalize = function(a, b) {
// NP[(if)] = NP[(if-range)] + NP[(else-range)] + NP[(expr)]
// 1 + 1 + 0
// NPATH Score = 2
if (a < b) {
// NP[while] = NP[(while-range)] + NP[(expr)] + 1
// 1 + 0 s+ 1
// NPATH Score = 2
while (a <= b) {
a++;
console.log("a: " + a + " b: " + b);
}
} else {
// NP[while] = NP[(while-range)] + NP[(expr)] + 1
// 1 + 0 + 1
// NPATH Score = 2
while (b <= a) {
b++;
console.log("a: " + a + " b: " + b);
}
}
console.log("now everyone is equal");
};
// Total NPATH Score: 2 * 2 * 2 = 8
equalize(10, 9);
Note
所有的 NPATH 表达式计算 NP[(expr)]都得到 0 分。NPATH 通过计算逻辑运算符(&&
、||)
)的数量来确定表达式得分。这是因为这些操作符对可能的控制流路径的数量有复杂的分支影响。
限制
正如我前面所讨论的,量化复杂性有益于程序员,而不是运行时引擎。因此,这些度量标准是基于创造者个人对复杂性的定义的基础水平。就 NPATH 而言,Nejmeh 认为一些控制流语句天生就比其他语句更容易理解。例如,在一对连续的 if 语句上使用带有两个case
标签的 switch 语句,您将得到较低的 NPATH 分数。尽管这对 if 语句可能需要更多的代码行,但我不认为它们在本质上更难理解。这就是为什么不要盲目地应用复杂性度量,而是花时间去理解他们的世界观是至关重要的。对于复杂性的另一个固执的观点,让我们考虑霍尔斯特德度量。
霍尔斯特德度量
在 70 年代后期,计算机程序被写成单个文件,随着时间的推移,由于它们的整体结构,变得难以维护和增强。为了提高这些程序的质量,Maurice Halstead 开发了一系列定量的方法来确定程序来源的复杂性(Halstead,1977)。众所周知,霍尔斯特德度量是“最早的软件度量之一,[而且]它们是复杂性的一个强有力的指示器。” 8
Halstead 回避了这样一个普遍的观点,即衡量质量和复杂性只能由熟悉程序目标和语言的领域专家来完成。相反,霍尔斯特德的论点是“软件应该反映算法在不同语言中的实现或表达,但独立于它们在特定平台上的执行。因此,这些指标是从代码中静态计算出来的。” 9
自从引入 Halstead 以来,近 40 年来,开发人员已经将 Halstead 的度量标准应用到许多不同的语言中,包括 JavaScript。尽管这些衡量标准及其关于人类认知的潜在假设并非没有批评者,但单独考虑每个衡量标准以及它们如何得出 JavaScript 代码的分数仍然是有益的。通过理解这些度量标准是如何工作的,您可以扩展自己评估代码的思维框架,并且至少可以更好地理解如何以及为什么使用这些度量标准对一个 JavaScript 单元进行评分。
输入
霍尔斯特德的度量标准将函数对运算符和操作数的使用作为其各种度量的输入。然而,在收集这些输入之前,您必须考虑 Halstead 在 JavaScript 中的操作数和运算符是什么意思。
JavaScript 中的操作数是语句的一部分,包含要执行的对象或表达式。相比之下,JavaScript 有许多形式的操作符 10 对操作数执行操作。下面是一个基本的例子:
var x = 5 + 4;
为了清楚地看到操作符和操作数的细节,可以使用 Esprima JavaScript 解析器 11 将语句提取到语法树中:
// Syntax tree of: var x = 5 + 4;
{
"type": "Program"
,
"body": [
{
"type": "VariableDeclaration"
,
"declarations": [
{
"type": "VariableDeclarator"
,
"id": {
"type": "Identifier"
,
"name": "x"
}
,
"init": {
"type": "BinaryExpression"
,
"operator": "+"
,
"left": {
"type": "Literal"
,
"value": 5
,
"raw": "5"
}
,
"right": {
"type": "Literal"
,
"value": 4
,
"raw": "4"
}
}
}
]
,
"kind": "var"
}
]
}
使用这个语法树,您可以计算唯一操作数(3)和运算符(2)。出于本章的目的,我使用这个简单的陈述作为霍尔斯特德指标中使用的计算的基础。现在有了 JavaScript 中操作数和操作符的工作定义,您可以通过以下方式获得 Halstead 指标的输入:
- n1 =唯一运算符的数量
- n2 =唯一操作数的数量
- N1 =总运营商数量
- N2 =总操作数的数量
使用语法树中的运算符和操作数计数,可以得到以下值:
n1 = 2
n2 = 3
N1 = 2
N2 = 3
有了这些输入的值,您现在可以将它们输入到各种指标中来计算分数。使 Halstead 度量如此灵活的一个事实是,它们的定量性质意味着它们可以很好地应用于整个源文件或单个函数。事实上,在同一个程序上以不同的分辨率运行霍尔斯特德度量标准会给你带来有趣的结果。不过,出于本节的目的,我将解释这些指标,就好像您将在函数级别应用它们一样。
程序长度(N)
程序长度通过将操作数和运算符的总数加在一起(N1 + N2)
来计算。较大的数字表示将函数分解成较小的组件可能会有好处。你可以用这种方式表达节目长度:
var N = N1 + N2;
词汇量(n)
词汇表的大小是通过将唯一的操作符和操作数相加得到的(n1 + n2)
。正如程序长度度量一样,较高的数字表示该函数可能做得太多。你可以用下面的表达式来表示词汇量:
var n = n1 + n2;
程序体积(V)
如果你的大脑是一个玻璃罐,一个程序的体积描述了它占据了容器的多少。它描述了为了完全理解函数,读者必须在心里分析的代码量。程序量考虑在一个函数中对操作数执行的操作的总数。因此,无论是否缩小,函数都将得到相同的分数。这不能说是其他复杂性度量标准,包括源代码行(SLOC)作为他们计算的一部分。节目量是通过将节目长度(N)乘以词汇量(N)的以 2 为底的对数来计算的。你可以用 JavaScript 这样写:
// => 11.60964047443681
var V = N * (Math.log(n) / Math.log(2));
体积是这一衡量标准的一个令人回味的名称,因为它可以有多种含义。之前,我谈到体积是一种取代其他精神资源的质量,但你也可以把它看作是一种信噪比度量。就像在现实世界中一样,当音量设置在一定范围内时,信息传输效果最佳。想象你正在听收音机;当音量旋钮调得太低时,你必须使劲才能听到。但是,将旋钮转到 11 会使输出声音过大,影响理解。
程序级别(L)
程序级别定义了一种方法的相对复杂性。它使用潜在量(V1)除以实际量(V)来得出感知的能力水平。一个函数的潜在体积是这样定义的,就好像它是以最理想的实现形式写的一样。程序级别可以表示如下:
var L = (V1 * V);
因此,实现越接近 1,该方法就越可取。
Note
每种语言的潜在音量不同。高级语言比低级语言得分高得多,因为高级语言从程序源中抽象出复杂性。
难度级别(D)
难度衡量读者误解源代码的可能性。难度级别的计算方法是将唯一运算符的一半乘以操作数的总数,再除以唯一操作数的数量。在 JavaScript 中,应该这样写:
var D = (n1 / 2) * (N2 / n2);
如果你考虑到当一个程序的容量增加时,理解它的难度也增加,这就可以直观地理解了。当操作数和操作符被重用时,它们增加了跨许多控制流路径引入错误的可能性。
规划工作(E)
这种方法估计了一个有能力的程序员在理解、使用和改进一个基于容量和难度分数的函数时可能付出的努力。因此,编程工作可以表示如下:
var E = V * D;
毫不奇怪,与卷和难度一样,需要较低的努力分数。
实施时间(T)
这种方法估计了一个合格的程序员实现一个给定功能所需要的时间。霍尔斯特德通过将努力(E)除以一个斯特劳德数得出这个度量。 12 斯特劳德数是一个人每秒可以做出的基本(二元)决策的数量。因为斯特劳德数不是从程序源中导出的,所以可以通过比较预期结果和实际结果来随时间进行校准。实施的时间可以这样表示:
var T = E / 18;
Note
有效的斯特劳德数范围从 5 到 25,其中 25 是一个人在每单位测量中可以做出的简单决策的最大数量。霍尔斯特德认为数字 18 可以很好地替代一个合格程序员的表现。
错误数量(B)
这个度量估计了给定程序中已经存在的软件缺陷的数量。正如您所料,错误的数量与其复杂性(数量)密切相关,但可以通过程序员自己的技能水平(E1)来减轻。霍尔斯特德在他自己的研究中发现,在 3000 到 3200 的范围内可以找到足够的 E1 值。可以使用下面的表达式来估计错误:
var B = V/E1;
限制
虽然霍尔斯特德指标可以提供信息,但一些人质疑它们的可靠性和表面有用性。一些人,如卢·马尔科,批评了评分系统的模糊性和如何应用的不确定性。Marco 指出,霍尔斯特德并没有在这个问题上提供明确的方向:
Halsted pointed out that the lower the program level, the more complicated the program is. Unfortunately, he didn't go further. Is the program of level 100 complicated? How about class 05? All you can do is compare the versions of the same program and compare their program levels. Recall that the McCabe metric gives the upper limit of complexity of 10. The calculation of halsted metric of bubbling sort shows that bubbling sort is very complicated in implementation. The problem is that the calculation of potential volume requires the number of input and output parameters. For bubble sort, only the array to sort is needed. The low number of potential capacity distorts the program and language level. Most programmers will agree that this algorithm is not complicated. (Marco, 1997)
工具作业
客观质量分析的一个主要目标是创建一系列程序化的度量,这些度量可以使用一致的和可重复的过程按需对复杂性进行评分。这些度量的过程性质意味着它们是包含在编程工具中的主要候选对象。毫不奇怪,有几个项目就是专门为此而设计的。本节比较和对比了两个 JavaScript 复杂性分析程序。
复杂性报告
菲尔·布斯的复杂性报告 13 是一个简单的命令行工具,它分析任何 JavaScript 文件,然后从中生成复杂性报告。复杂性由以下指标决定:
- 代码行
- 每个函数的参数
- 圈复杂度
- 霍尔斯特德度量
- 保养率指数
因为 complexity-report 是一个命令行工具,部署工程师可以毫不费力地将其添加到他们持续集成工作流中。它可以配置为当源文件低于任意质量阈值时阻止代码部署。
基本示例
要查看此库如何工作,您必须首先将其作为 npm 模块安装:
npm install -g complexity-report
为了测试复杂性报告的输出,您将对它自己的一个源文件运行该工具,这被亲切地称为吃自己的狗粮。从命令行中,键入以下代码:
cr ./node_modules/complexity-report/src/cli.js
Note
您可能需要将目录更改为复杂性报告节点模块的本地目录。
一旦完成,库应该将结果打印到终端窗口。该报告首先对整个文件的复杂性进行评分,然后分别评估每个函数。以下是整份报告的摘录:
Maintainability index: 125.84886810899188
Aggregate cyclomatic complexity: 32
Mean parameter count: 0.9615384615384616
Function: parseCommandLine
Line No.: 27
Physical SLOC: 103
Logical SLOC: 19
Parameter count: 0
Cyclomatic complexity: 7
Halstead difficulty: 11.428571428571427
Halstead volume: 1289.3654689326472
Halstead effort: 14735.605359230252
Function: expectFiles
Line No.: 131
Physical SLOC: 5
Logical SLOC: 2
Parameter count: 2
Cyclomatic complexity: 2
Halstead difficulty: 3
Halstead volume: 30
Halstead effort: 90
// report continues
复杂性报告非常有用,因为它不仅自动化了对源代码评分的手工工作,而且还在文件和函数级别分析了源代码。这为开发者提供了一种机制来评估一个尺度上的变化如何影响另一个尺度上的分数。尽管该图书馆的报告信息丰富,但它们并没有为技术含量较低的利益相关者提供一个获得整体复杂性快照的途径。幸运的是,还有其他专门为此目的设计的工具。
柏拉图
Jarrod Overson 的 Plato 14 是一个代码质量分析仪表板,它创建了一组视觉上令人愉悦且信息丰富的报告。Plato 利用 JSHint 和 complexity-report 进行实际的分析,然后将它们的原始报告整理成一组信息丰富的图表。像任何好的可视化套件一样,Plato 理解当在不同的上下文中查看数据时,可以有不同的理解。出于这个原因,柏拉图将原始分数转换成各种信息空间,我将在接下来讨论。在本节中,我将使用关于 Grunt 15 项目的柏拉图报告的截图。
项目质量时间表
Plato 的第一个报告界面是项目质量时间表(见图 9-1 )。它提供了项目整体质量变化的一英里高的视图。
图 9-1。
Plato’s project quality timeline charts
不像其他的质量报告,在任何给定的时间仅仅给你一个快照,Plato 的总结视图,绘制了项目质量随时间的变化。这是非常重要的,因为它允许开发人员或经理了解质量的趋势。
项目度量视图
图 9-2。
Plato’s project maintainability chart
在程序摘要下面,Plato 显示了一组条形图,如图 9-2 所示。这些图表显示了常见测试的各种度量分数:“可维护性”(如图所示)、“代码行数”、“估计错误”和“lint 错误”。使用该视图,用户可以在选择一个文件进行详细检查之前,从整体上对文件进行视觉评估。
文件质量概述
图 9-3。
Plato’s file quality overview charts
最后一个概览图组织了每个文件的各种指标得分,如图 9-3 所示。度量视图允许您在心里将文件的性能相对于其对等文件进行排名;文件质量视图让您全面了解哪些文件在所有指标中问题最大。
Plato 总结视图的要点是快速识别代码库中的全局关注区域。然后,您可以深入检查任意文件。文件视图使用由数据源提供的相同的原始数据,但是将它们限定为在文件级别有意义,我将在下面解释这一点。
文件质量时间线
图 9-4。
Plato’s file quality timeline charts
文件质量时间线绘制了给定文件的质量随时间的变化,如图 9-4 所示。它们与项目时间表非常相似。Overson 已经有意识地决定只绘制可维护性和 LOC 度量作为时间表。他将难度和估计误差度量表示为单个值。然而,如果这些也是时间序列,将会提供更多的信息。
功能质量
图 9-5。
Plato’s function quality charts
一旦 Plato 建立了全局文件质量,文件级报告的剩余部分将致力于功能级分析。Plato 将文件的所有功能表示为一对环形图(见图 9-5 )。切片和颜色代表每个函数的不同分数。选择环形图是明智的,因为一个文件在函数总数上可以有很大的变化。然而,就信息密度而言,这些图表是最不成功的。
当用户选择一个圆环切片时,剩余圆环中的相应切片也被选择将是有益的。允许多重选择将使得复杂性和 LOC 之间的关系变得清晰。然而,两个图表甚至不需要。这两个指标可以很容易地在一个圆环图中表示出来,其中 LOC 控制切片的大小,complexity 控制颜色。更成问题的是选择使图表单色化。例如,除非你知道大的复杂性分数是不可取的,否则你很难仅凭柏拉图的颜色选择得出这个结论。更好的方法是重新引入概览图表中使用的红色、橙色和蓝色编码。这些颜色清楚地描述了哪个分数是理想的,哪个不是。更重要的是,柏拉图已经训练它的用户理解这些颜色语义,所以不再次利用它们是一种浪费。
源代码视图
柏拉图的最终视图根本不是图,而是程序源代码的注释视图(见图 9-6 )。查看者可以手动滚动到该部分,也可以单击功能质量图表中的任意环形切片。单击一个切片会立即将它们直接带到源代码中该函数出现的位置。通过点击函数的名称,查看者可以看到它收到的各种分数。在视觉上将分数定位到源中为观看者提供了在更大的源主体的上下文中考虑分数的机会。
图 9-6。
Plato’s source view screen
Plato 是探索特定代码库的质量度量的一个极好的工具。它做了所有好的可视化所做的事情,让浏览者对数据有更深的理解。柏拉图通过允许观众在不同的尺度和不同的语境中思考相同的分数来达到这个目的。对于非技术人员来说,当涉及到代码库的质量时,它尤其有用。对于这些观众来说,它提供了一种与开发人员就质量展开有见识的对话的方式,而不需要首先理解程序的实现。
摘要
本章考虑了 JavaScript 中代码质量的需求和推理。一个程序的质量经常影响程序员维护、增强甚至完全理解其源代码的能力。质量差的代码通常被描述为一种技术债务,它剥夺了项目的时间和资源,而这些时间和资源本可以更好地用在其他地方。然而,编程是一门学科,经常跨越艺术和科学之间的界限,使得质量的定义更加复杂。此外,质量同时是主观和客观的衡量标准。
可以说,素质受一个人的当代文化和个人经历的影响。这种形式将质量描述为具有一种“内在的卓越”,这种卓越必须由一个有这方面经验的人来识别。这可以解释为什么随着质量观念的改变,美术等领域的某些运动会经历潮起潮落。主观质量经常出现在手工编程的描述中(例如,软件工匠)。
相反,客观质量分析认为质量可以被提炼为一系列可重复的步骤。这些步骤可以通过质量度量来监控,这为程序员提供了如何改进代码的洞察力。这些度量很大程度上围绕着代码的静态分析,这是在不首先运行代码的情况下研究代码的能力。本章研究了静态分析的三种用途:
- 检查语法正确性
- 识别程序员偏离既定最佳实践的领域
- 找到其他人难以理解的代码
这一章的大部分内容都是关于其他人为复杂代码评分而创建的算法度量。在编程中,复杂性是对开发人员为了完全理解一个代码单元而必须忍受的脑力劳动的度量。然而,这些措施中的许多,尽管信息丰富,但也不是没有它们自己的盲点。有些衡量标准,如霍尔斯特德的度量标准,利用了关于人类认知生理学的可疑假设。其他人,如 NPATH,认为额外的复杂性是基于某些流结构本来就比其他流结构更难理解。为了适应这些缺陷,最好是使用彼此一致的复杂性度量,并且只有当它们符合你自己对复杂性的世界观时。
本章的剩余部分致力于各种现成的工具,为您完成质量分析的重任。将这些工具作为持续集成工作流的一部分来使用,可以确保当您将代码发布到野外时,它将有最好的机会在将来被理解和维护。
Footnotes 1
http://en.wikipedia.org/wiki/Quality_assurance
2
http://37signals.com/svn/posts/3159-testing-like-the-tsa
3
4
5
https://github.com/ariya/esprima
6
http://www.skrenta.com/2007/05/code_is_our_enemy.html
7
http://en.wikipedia.org/wiki/Control_flow_graph
8
http://www.verifysoft.com/en_halstead_metrics.html
9
http://en.wikipedia.org/wiki/Halstead_complexity_measures
10
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators
11
http://esprima.org/demo/parse.html
12
http://www.eetimes.com/author.asp?section_id=36&doc_id=1265859
13
https://github.com/philbooth/complexityReport.js
14
https://github.com/es-analysis/plato
15
十、提高可测试性
Abstract
对于每一个复杂的问题,都有一个简单、简洁而错误的解决方案。
门肯
每一个复杂的问题都有一个简单、简洁而错误的解决方案。—H. L .门肯
在一本书里完全涵盖 JavaScript 测试可能是不可能的,更不用说一章了。如果做得正确,测试应该是一个全神贯注的挑战,提供了一个令人信服的创造性和技术障碍的组合来克服。计算机科学中许多最聪明的头脑已经致力于工具、方法和模式的创造,这些工具、方法和模式使测试人员能够提高他们所负责的程序的质量和可靠性。因此,将测试排除在本书之外至少是对读者的一种伤害,并且可能会从整体上降低测试 JavaScript 的重要性。
鉴于测试是一本书大小的主题,我将本章的范围浓缩为提高 JavaScript 的可测试性。通过我的研究和作为开发人员的个人经验,我已经确定了几个通常阻止开发人员成功测试代码的因素。通常,失败是由于代码编写和评估中的各种偏见,当错误的测试目标被应用时,这些偏见就更加严重了。本章将指出开发人员在测试代码时不知不觉中陷入的各种偏见和盲点。本章的剩余部分将集中在通过重新聚焦测试来帮助减轻这些问题并提高代码质量的工具和过程上。
为什么测试失败了
当测试套件通过时,JavaScript 测试失败。测试的目标不是写测试,而是通过让程序失败来发现 bug。稍后我会更详细地探讨这个断言,但现在我想把这个想法放入你的头脑中。知道如何编写测试不仅仅包括这样做的技术能力。为了正确地测试一个程序,你必须有正确的心理定势,以及一个在技术资料中很少讨论的测试目标的清晰定义。尽管如此,正如 Glenford Myers 在他的书《软件测试的艺术》中所指出的,“对测试采用适当的心态似乎比纯粹的技术考虑更有助于测试的成功”(Myers,1979)。
偶尔,我会遇到一些开发人员,他们把测试推迟到他们的程序完成之后。他们没有把最好的留到最后,就好像写测试是一顿饭最后的甜点。相反,他们正在拖延他们认为是一项不幸的苦差事,只需要找到他们代码中偶尔的迟钝错误。如果你是一个有好奇心和创造力的程序员,并且编写测试不是饭后甜点,那么你很可能做错了。然而,为了完全公开,我对 JavaScript 测试的感觉已经发展到这种观点。在过去的十年中,我花了很多时间尝试热爱测试 JavaScript。
2005 年,我加入了我的第一个敏捷开发团队。我们练习了测试驱动开发(TDD)和结对编程。当时,我们主要编写 Ruby web 应用,它们有一个开发良好的测试生态系统。TDD 就像任何新工具一样;一旦我知道如何使用它,我就想在所有东西上试一试。然而,当我从服务器级 Ruby 代码转移到控制视图的 JavaScript 时,我的测试变得几乎不存在了。在公开场合,当谈到 TDD 和 JavaScript 测试的重要性时,我继续热情地挥舞着拳头,但是私下里,我实际上没有编写任何测试。
我记得我被其他高级开发人员中的一个人骂了一顿。在我的辩护中,我声称 JavaScript 测试是一种棘手的问题,由语言特有的问题交织在一起。我认为,这些隐含的困难会棘手地阻碍任何开发人员准确测试程序的尝试。这些复杂性使得测试 JavaScript 成为一个耗时的过程,几乎没有回报的机会。为了说明我的观点,我列举了可测试性结中的线程。
- JavaScript 社区不像其他语言那样拥有测试代码所需的同等质量的工具。没有质量工具,编写测试是不切实际的,甚至是不可能的。
- JavaScript 通常与用户界面有着千丝万缕的联系,用于产生必须由人类体验和评估的视觉效果。对手工评估的需求使得 JavaScript 无法以编程方式进行测试。
- 您无法像使用其他语言中的命令行调试器那样快速通过正在运行的测试。如果没有这种能力,调试 JavaScript 就是一场噩梦。
- 不断变化的主机环境(浏览器)使测试变得不切实际,因为开发人员必须为每个主机环境重复测试相同的程序逻辑。
在 2005 年,我并不是唯一一个对测试 JavaScript 的现实进行失望评估的人。然而,今天,JavaScript 已经有了一个强大而充满活力的测试社区,有了越来越多的工具和框架来寻找程序缺陷。然而,即使是经验丰富的开发人员仍然抱怨 JavaScript 很难测试。在最近的一篇博客文章中,Rebecca Murphy 要求举例说明为什么开发人员不测试他们的 JavaScript。在她发布的回复列表中,你会发现近十年前我给我同事的每一个借口都有不同的版本。但是有一个例外:不是测试工具太少,而是抱怨工具太多了!事实上,JavaScript 语言和测试工具是程序员误用和误解测试的方便替罪羊。这种误用很大程度上可以归结为使用了错误的测试定义,这反过来设定了错误的目标,并最终产生了错误的结果。
测试谬误
本节列举并纠正关于测试的常见误解。这些误解经常导致开发人员采用错误的测试目标,这决定了他们如何以及何时编写测试。为了充分理解这些决策最终如何影响最终产品的质量,我将解开这些谬误,并解释它们对测试实践的影响。
测试证明程序中没有错误
程序测试可以用来显示缺陷的存在,但永远不能显示缺陷的不存在!艾兹格·迪科斯彻
在继续之前,简要考虑一下 Dijkstra 关于测试的引用。它打破了测试确保程序没有错误的普遍误解。这是一个谬误,因为正如 Dijkstra 指出的,它是无法证明的。更重要的是,当一个目标基于一个无法量化的指标时,这个目标就变得无法实现。从理性的角度来看,在无法达到的目标下进行测试意味着测试过程注定要失败,因为任务没有逻辑结论。因此,经理、程序员和利益相关者对测试过程充满矛盾,因为它永远不会结束。这使得测试成为事实上的资源税,随着时间的推移,各方都会对此不满。
成功的测试是那些没有错误地完成的测试
Glenford Myers 详细描述了通过测试给开发团队带来的虚假的安全感。他断言,许多开发人员和管理人员以完全错误的方式衡量测试的成功,指出没有发现 bug 的测试套件是他们程序健康的标志。迈尔斯用了一个绝妙的类比来贬低这种相关性:
Consider the analogy of a person going to see a doctor because of general malaise. If doctors have conducted some laboratory tests without positioning problems, we don't call these laboratory tests "successful"; They are unsuccessful experiments. .. the patient is still ill, and the patient now questions the doctor's ability as a diagnostician. (Miles, 1979)
很明显,为什么这个特殊的测试神话在我们的行业中如此根深蒂固。编写软件通常是高度个人化和详尽的努力。当发现错误时,很容易理解程序员会觉得错误是他们自身能力的反映。所以程序员必须抵制这种把 bug 个人化的冲动。这种对代码的个人依恋感是他们经常编写肤浅的构象测试的原因,这种测试更多的是保护脆弱的自我,而不是真正地询问程序。正如 Boris Beizer 所写:
Programmer! Throw away your guilt! Spend half the time on happy testing and debugging! Track bugs with care, methods and reasons. Build traps for them. More cunning than those cunning bugs, enjoy the fun of innocent programming! (Baesel, Software Testing Technology, 1990)
测试确保程序是高质量的
当管理人员使用测试作为对低质量软件的防御封锁时,他们可能会确保相反的结果。当测试成为程序员需要克服的障碍时,它们就变成了障碍,而不是素材。虽然识别 bug 的测试确实提供了提高程序质量的机会,但是当测试成为一种正式的质量度量时就有危险了。测试作为质量的衡量标准是有问题的,原因如下:
- 作为质量度量的测试挫伤了程序员的士气,因为他们推断他们的源代码从一开始就是低质量的。
- 它在测试和开发过程之间构建了一个无用的二分法。这也可能会加剧团队中的紧张气氛,将测试人员和开发人员分开。
- 这表明测试本身可以增加代码库的质量。
- 如果测试是质量的仲裁者,他们推断测试人员和开发人员之间的权力是不对称的,就好像开发人员代表他们的程序向测试人员恳求一样。
你不能仅仅通过测试来提高程序的质量。Beizer 再次指出了测试作为质量度量的误用,他写道:
Programmers are responsible for software quality-the quality of their own work, including the quality of the products they work on, and the quality of interfaces between components. Quality has never been and will never be. This is not only a moral responsibility, but also a professional responsibility. (Baesel 1990)
测试防止未来的错误
测试用例是程序执行环境的开发工件,它们是用来测试的。随着计划目标的发展,在早期测试中做出的假设可能不再有效。在这些情况下,测试中断是受欢迎的,因为它说明了测试套件在哪里已经与代码库分离。
在其他情况下,对程序的新修改可能会破坏仍然有效的旧测试。在这些情况下,通过之前的测试覆盖,新的 bug 会立即被发现。经理们经常使用这样的事件作为测试防止未来错误的证据。这种观点将测试视为一种程序化的免疫系统,保护应用免受未来未知错误的影响。
这种观点的谬误在于,它假设测试应该具有某种预测性。这导致开发人员编写多余的假设测试,这是对测试目的的打击。
测试证明该程序按设计运行
许多开发方法,如 TDD,使用测试作为一种验证程序是否按设计运行的手段。从业者首先编写解释方法预期功能的测试。在 TDD 中,测试套件总是在运行,所以新的测试最初会失败,因为它验证的方法还不存在。TDD 的支柱之一是编写足够的代码来通过测试。这样做可以确保额外的和不需要的功能不会作为所谓的自由功能添加到程序中。此外,如果开发人员只是试图推断特性需要什么,那么将测试作为规范与源代码紧密耦合会限制引入的代码量。
TDD 的盲点是它会导致一些开发人员狭隘地考虑他们的测试,并且仅仅作为一种事实上的文档形式来证明函数做了它应该做的事情。“按规格测试”方法的意想不到的结果是,将编写测试来确保该方法是有效的。毕竟,规范应该是表达性的和简洁的,所以测试可能会肯定一种方法,而不是发现其局限性。以证明功能为目的的测试忽略了一个事实,即一个功能可以工作,但仍然包含 bug。没有发现任何错误的肤浅的肯定测试最终是浪费时间和精力。
对于像 TDD 这样的方法来说,要正确地工作,他们依赖于一个程序员,这个程序员可以一边写代码,一边试图破解它。也就是说,设计测试的行为与编写通过测试的代码一样重要。再次引用 Boris Biezer 的话:
Design behavior is one of the best known error prevention measures. Thinking necessary to create a useful test can find and eliminate errors before they are coded-in fact, test design thinking can find and eliminate errors in every stage of software creation, from concept to specification, to design, coding and so on. (Baesel 1990)
单独一个人并不总是能够充分设计测试。确认偏差——我将在下一节解释——经常阻止开发人员深入思考测试。许多方法通过让开发人员一前一后地编写代码来减轻这种偏见。当一个程序员试图证明一种方法时,另一个程序员会试图寻找例外。只有当参与者在技能和等级上相对平等时,这种结对程序员才起作用;否则,两者中的较低者将倾向于服从领导。
确认偏差
确认偏差描述了一个人倾向于支持其世界观的信息而忽视相反证据的可能性。政治家和宗教狂热者是两个以生活在泡沫中而闻名的群体,这实际上是他们确认偏见的表现。毫不奇怪,花大量时间思考的程序员也会遭受确认偏差,尽管它的表达可能更微妙。
本节解释了软件开发过程中导致编程中确认偏差的各种因素。这些原因包括开发人员固有的认知失调,测试框架中经常未被认识到的偏见,以及测试人员在错误的地方寻找 bug 的倾向。
选择性观看
当我完成这本书时,我花了几个小时写和重写段落,尽一切努力解除我早先埋下的语法和拼写地雷。在把每一章提交给我的编辑之前,我会先默读一遍,然后再大声朗读。然而,毫无疑问,当这一章被归还时,它充满了更正。我敢打赌,你们中的许多人都有过类似的经历,因为这种有选择地看东西的倾向在人类生活中相当普遍。
写软件和写书一样,应该是作者手艺的个人表现。因此,程序员和其他工匠一样,倾向于在他们的工作产品中看到自己的影子。程序员倾向于对自己的能力持乐观态度,进而对他们编写的软件持乐观态度。程序员和他们的工作之间的这种亲密关系会阻止他们诚实地评估它。他们倾向于有选择地从他们打算运行的参考框架中看到他们的功能,而不是他们实际实现的。
知识的诅咒
知识的诅咒有时被描述为不能敲钟。这个比喻提供了一个令人愉快的视觉效果:声波永远向外传播到太空中。显而易见,声波一旦在空气中荡漾,就无法被吸回钟口。在编程中,知识的诅咒是程序员不能从一个不太了解的用户的角度考虑他们的软件。
你可能认为对一个程序的深入理解会给开发人员提供围绕它编写健壮测试的能力。然而,在现实中,这些测试不太可能发现函数中隐藏的边缘情况,因为程序员无法从他们的方法中获得足够的临界距离。知识的诅咒增加了缺陷密度,缺陷密度是 bug 挤在一起的可能性。这些错误被一个关于应用如何工作的错误假设从程序员的角度屏蔽了。
如果一开始你成功了
假设您是一名开发人员,任务是对一个具有大量测试覆盖的应用中的现有功能进行微小的更改。在做出改变并运行测试之后,你会因为它们都通过了而感到欣慰。你觉得代码仍然是健康的,这是可以理解的,尤其是当一个大规模的测试套件重申了这一信念。然而,这被称为“无错误谬误”,意思是仅仅因为你的测试没有发现任何错误并不意味着没有错误可找。为了确保您的更改实际上是安全的,您必须将您的更改与测试的意图进行交叉引用。所写的测试是为了覆盖你的变化,还是因为不相关的原因而继续通过?对抗这种偏见时,我遵循的座右铭是:如果你第一次成功,尝试,再尝试。
农药悖论
Boris Beizer 的软件测试第一定律陈述如下:
"Every method you use to prevent or find mistakes will leave more subtle mistakes that are ineffective." (Baesel 1990)
杀虫剂悖论解释了过去的测试会发现未来错误的谬论。事实上,这些测试想要捕捉的错误已经被捕捉到了。这并不是说这些测试没有回归测试的价值,回归测试可以确保这些已知的错误不会再次出现。然而,你不应该期望通过在旧的地方寻找测试来发现新的错误。
缺陷簇
在现实世界中,bug 并不是均匀分布在整个场景中的。相反,他们挤在角落里,冰箱下面,以及其他难以触及的地方。冒险进入太空的虫子很容易被鞋子碰到并被压扁。在软件中,这被称为帕累托原则,该原则指出,几乎 80%的结果来自 20%的原因。 3 简单来说,bug 就是其他 bug 在的地方。
Bug 集群经常是由于开发人员成为他们自己的知识诅咒的牺牲品而出现的。然而,一旦程序员识别出这种偏差,群集就可以指导程序员在哪里应用未来的努力。例如,如果相同数量的测试在一个模块中发现了六个错误,而在另一个模块中只发现了一个,那么您很可能会在前者中发现更多的错误。这是因为 bug 集群可以向程序员揭示关于他们程序的关键误解。
框架偏差
测试自动化框架是为了捕捉软件缺陷而有计划地运行测试用例的软件工具。这些框架通过使测试运行更容易来降低维护成本。通常,这些框架成为开发工作流程链中的一个环节,它允许测试在最大化其有效性的环境中运行。测试框架是现代开发生活的重要组成部分,应该尽可能地被接受。
然而,测试框架不是在真空中创建的;他们受到创造者认为正确的关于测试的一套假设和哲学的影响。就像任何通过隐喻联系起来的过程一样,测试框架有可能使一项任务变得更简单,而使另一项任务变得更困难。这是因为隐喻允许你把对一个主题的理解移植到对陌生事物的理解中。然而,隐喻所提供的启示并不总是与它要解释的主题完全重叠。
例如,浏览器供应商最初用一本书的比喻来解释互连服务器的网络,这对于非技术人员来说很难概念化。被告知浏览器是阅读互联书籍的工具,新用户可以利用他们现有的关于书籍如何工作的知识。阅读网页和在浏览器中放置书签变得很自然。不幸的是,书的比喻掩盖了访问网络的许多更有趣的潜力,例如不要将数据视为一系列不同的页面,而是视为一个持续流动的信息流,用户将其捕获到池中使用,然后发布。
当程序员将他们的测试范围限制在框架的能力之内时,就会出现框架偏差。例如,许多应用依赖远程 API 提供的数据。然而,在测试过程中对 API 进行实时调用是不可接受的,因为这会降低测试运行程序的速度,并对实时 API 进行不必要的调用。相反,许多框架提供了模拟或剔除 API 集成的机制,从而允许测试接受与被测试的上下文相关的固定答案。如果您的测试框架没有为测试编写人员提供这些功能,他们必须通过重载函数来缩短生产代码以使测试工作,或者(更糟糕的是)避免一起测试这些特性。
减轻确认偏差
上一节详细介绍了无意偏差渗入测试实践的各种方式。这些偏见中的大多数会导致开发人员忽视或忽略他们可能会捕捉到的潜在错误。本节描述了纠正措施,这些措施有助于减轻测试中的确认偏差。
测试失败
许多偏见的根源在于这样一个事实,即程序员将失败的测试视为他们自己无知的标志,而不是他们追踪缺陷的坚韧的证明。测试的目标是让程序失败。运行时没有引发异常的测试套件应该被视为资源浪费,就像不能诊断问题的机械师的旅行被视为浪费金钱一样。
获得临界距离
当开发人员失去了对他们测试的代码进行批判的能力时,就会出现确认偏差。这在测试自己代码的开发人员中尤其明显。让一个独立的团体编写测试,或者开发一系列概念性的提示或程序性的步骤来帮助构建测试环境,对于保持临界距离是有帮助的。
找到边缘
测试通常是探索未知,验证假设,然后使用这些发现来重新调整你对程序如何工作的心理模型。为了找到边缘案例,开发人员需要抵制有选择地查看他们认为代码在做什么的倾向。找到一个函数的边界情况的一个方法是列举你对这个方法的假设,然后分别测试它们。这种系统化的方法可以迫使开发人员在更全面的环境中考虑这些功能。这种方法有助于减轻掩盖功能细节的倾向,因为这些细节已经变得太熟悉而不能进行批判性的考虑。第二种选择是实现特定类型的测试,这些测试旨在以开发人员可能不希望的方式使用该函数。模糊测试——您将在后面详细讨论——是一种找到程序边缘的方法。
寻找基线
测试套件在覆盖所有重要部分的能力方面被认为是床罩。当通过应用的所有路径都被至少一个测试覆盖时,程序被认为是测试良好的。具有足够测试覆盖率的程序被认为不太可能包含错误,因此经常被用作程序质量的衡量标准。开发人员维护足够的测试覆盖率的能力是累积测试实践中的一个重要因素。
然而,测试写作中的几个倾向会导致覆盖率降低。当测试没有与应用代码并行编写时,它们可能会失去同步。随着大量开发下的程序的发展,测试可能会落后。此外,很难一眼看出测试是否真正覆盖了程序中的所有不同路径。对一个快速发展的应用的整体测试覆盖率进行精确的度量有点像在某人跑步的时候测量他的身高。
幸运的是,测试覆盖率可以通过使用代码覆盖工具来自动计算。这些工具库可以与测试运行程序进程一起运行。当测试运行时,覆盖率工具会跟踪程序源代码的哪些部分在测试执行时被调用。
在测试套件完成之后,覆盖工具可以生成一个报告供程序员查看。许多最强大的覆盖报告是交互式的和高度可视化的。它们允许读者衡量整个应用的整体覆盖范围,或者深入到特定的源文件。在文件级别,报告通常会进行注释和颜色编码,以反映代码覆盖率的各个方面。图 10-1 显示了伊斯坦布尔覆盖工具的截图,我将在本章的后面演示。这个视图代表一个单一文件的覆盖率报告。较黑的线条代表文件中没有被测试触发的区域。
图 10-1。
The Istanbul coverage tool in action
本节将讨论这些工具用来测量测试覆盖率的几种常见的覆盖率算法。一旦您有了理解代码覆盖率计算如何工作的基线,我将演示如何在您自己的 JavaScript 应用中使用它。最后,为了与本章的主题保持一致,我还将讨论这些工具可能引入的意想不到的偏见。
报表覆盖范围
语句覆盖是代码覆盖最直接的形式。它只是记录语句执行的时间。考虑下面的例子,其中测试在user
为undefined
的上下文中运行。在本例中,加粗的语句表示使用语句覆盖率计算的行数。
var friendNames;
function findFriends(user) {
var friends = [];
if (user) {
friends = user.getFriends().map(function (friend) {
return friend.firstName + " " + friend.lastName;
});
} else {
friends = ["You are unpopular!"];
}
return friends;
}
friendNames = findFriends();
功能覆盖
代码覆盖的另一个基本形式是函数覆盖,它决定任何测试是否至少调用一次函数。函数覆盖并不跟踪测试如何摆弄函数的内部结构;它只是关心函数是否被调用。这个度量标准的一个派生叫做function call coverage
,它计算测试调用的函数的总百分比。要使用函数调用覆盖率达到 100%的分数,每个函数必须至少调用一次。继续前面的例子,您可以看到代码的哪些部分将被函数覆盖:
var friendNames;
function findFriends(user) {
var friends = [];
if (user) {
friends = user.getFriends().map(function (friend) {
return friend.firstName + " " + friend.lastName;
});
} else {
friends = ["You are unpopular!"];
}
return friends;
}
friendNames = findFriends();
分支覆盖
为了有足够的分支覆盖率,测试中必须覆盖通过一个函数的每一条路径。在我的代码示例中,只有else
分支会被覆盖。这意味着至少应该添加一个其他的测试来获得这个功能的完整的分支覆盖。分支覆盖对于突出可能未被测试的边缘案例非常有用。下面的代码演示了分支覆盖率指标将计算的内容:
var friendNames;
function findFriends(user) {
var friends = [];
if (user) {
friends = user.getFriends().map(function (friend) {
return friend.firstName + " " + friend.lastName;
});
}
else {
friends = ["You are unpopular!"];
}
return friends;
}
friendNames = findFriends();
伊斯坦布尔
使用上一节中的覆盖率度量,很明显可以将它们结合起来为一个程序创建一个总的覆盖率分数。在测试运行过程中,这些指标的编组是代码覆盖工具的工作。在这一节中,我将介绍伊斯坦布尔, 4 这是克里希南·阿南瑟瓦兰创造的一个覆盖工具。伊斯坦布尔是用 JavaScript 为 JavaScript 写的。因此,与许多其他覆盖工具不同,伊斯坦布尔被设计为在 JavaScript 可以运行的任何地方运行,因此支持基于浏览器的执行和命令行工具。根据文档,伊斯坦布尔的设计考虑了以下使用案例:
- 为了提供
nodejs
单元测试的透明覆盖 - 为了适应
npm
测试脚本的使用,以允许有条件的覆盖 - 允许批量检测测试,这对浏览器测试很有用
- 支持与自定义 Node.js 中间件的集成,该中间件支持服务器端的代码覆盖
安装伊斯坦布尔
伊斯坦布尔需要 Node.js 才能运行,可以像这样作为npm
包安装:
npm install -g istanbul
在下一节中,我将演示伊斯坦布尔的cover
命令,这只是伊斯坦布尔提供的几个有用工具中的一个。
涉及
命令为任意文件生成一个覆盖对象和报告。coverage 对象不仅是程序的单个元素(即函数和语句)的 JSON 表示,也是描述执行路径的分支图。伊斯坦布尔使用 coverage 对象作为它创建的可视报告的输入。例如,您可以像这样通过cover
命令运行findFriend
程序:
istanbul cover find-friend.js
此时,伊斯坦布尔不仅会生成coverage
对象,还会将一些有用的概述统计数据打印到终端窗口:
=============================================================================
Writing coverage object [/Users/heavysixer/Desktop/js/coverage/coverage.json]
Writing coverage reports at [/Users/heavysixer/Desktop/js/coverage]
=============================================================================
=============================== Coverage summary ===============================
Statements : 77.78% ( 7/9 )
Branches : 50% ( 1/2 )
Functions : 50% ( 1/2 )
Lines : 77.78% ( 7/9 )
================================================================================
请注意,这个覆盖率总结与您之前可以确认的内容相一致——通过手动逐步执行各种覆盖率度量标准。因为用户变量是undefined
,所以从来没有执行过findFriends
函数。这种未开发的路径解释了为什么您在分支和功能上都只有 50%的覆盖率,以及为什么超过 20%的代码没有运行。
为了更清楚地可视化测试覆盖率,打开伊斯坦布尔创建的覆盖率报告。它应该位于相对于测试文件的 coverage 文件夹中。首先,在浏览器中打开索引文件;根据您的系统,您可能能够像这样打开和查看文件:
open coverage/lcov-report/index.html
打开后,进入js
文件夹,查看相关文件的覆盖报告。从这个屏幕上(见图 10-2 ,应该很清楚哪些行没有被执行。
图 10-2。
Istanbul coverage report for a single file Note
图 10-2 中被黑框包围的“I”是伊斯坦布尔用来描述缺乏保险原因的术语的一部分。在这种情况下,I 代表未被采用的分支。
覆盖偏差
像伊斯坦布尔这样的代码覆盖工具对于快速和可视化地探索一个程序的覆盖是非常棒的。他们使观众不费吹灰之力就能发现测试中的不足。然而,覆盖工具有它们自己的偏见,你应该知道。执行一行代码与测试一行代码有很大的不同。代码覆盖率度量会给人一种 100%覆盖率等同于 100%测试的虚假安全感。为了不陷入这种偏见,开发人员应该使用覆盖工具来发现测试覆盖的缺失,而不是作为证明它的一种方式。
偏差破坏测试
程序的可测试性很大程度上取决于开发人员克服各种偏见的能力,否则这些偏见可能会阻止应用得到充分的测试。正如我所讨论的,这些偏见可能包括心理障碍,如知识诅咒或选择性观看,这阻止了测试人员客观地思考测试。或者,开发人员可能在对抗另一个工具中隐含的偏见;例如,一个测试框架支持一种类型的测试,同时阻碍另一种类型的测试。本节提供了三个偏差消除测试,您可以使用它们来增强您的测试方法。
模糊测试
正如我之前详述的,当开发人员不能客观地思考他们自己的代码时,测试偏差经常发生。在这些情况下,程序员可能不再编写测试来使代码失败,而是编写测试来证明功能按设计运行。为了减少这种偏见,开发人员从不测试他们自己的代码。然而,这并不总是可能的,甚至不是所期望的。另一种方法是使用测试实践,强制程序以意想不到的方式使用。
模糊测试(Fuzz testing), 5 或 fuzzing,是一种软件分析技术,它试图通过向应用提供意外的、无效的或随机的输入,然后评估其行为,来自动发现缺陷。巴顿·米勒创造了“模糊”一词。当被问及这个名字的由来时,他说这个 ?? 6T7:
The original work was inspired by logging into a modem in a storm with a lot of line noise. The line noise produced garbage characters, which seemed to cause the program to crash. Noise is reminiscent of the word "fuzzy".
模糊化对于黑盒测试特别有用,黑盒测试的目标是发现程序的功能边界。模糊化也被黑帽和白帽安全专家广泛用作挖掘系统漏洞的工具。
模糊测试的两种最常见的形式是基于突变的和基于生成的。基于突变的模糊器对其数据的格式或结构知之甚少。他们盲目地修改他们的数据,仅仅在异常发生时记录下来。由于这个原因,这些模糊器通常被称为(哑模糊器。)基于生成的模糊器理解它们的数据格式的语义,因此在那些约束内创建它们的输入。在预定义的规则空间内操作通常意味着它们的结果会更精确。然而,正如你所料,制作一个基于世代的模糊器比基于突变的模糊器需要更多的时间。
JavaScript fuzz tester 的目标是强制主机环境在运行时执行或至少编译模糊的输入。为了实现这一目标,模糊器必须遵守语言的句法规则。否则,解释器将无法执行代码,这与发现错误是不同的。jsfunfuzzer(由杰西·鲁德曼 7 编写)是最著名的 JavaScript fuzzers 之一。Jsfunfuzz 被证明在寻找漏洞方面有点太有效了,并且不再公开提供。在针对火狐浏览器的最初模糊测试活动中,jsfunfuzz 在火狐 JavaScript 引擎中发现了 280 个错误 8 。从那以后,它已经找到了 1000 多个漏洞。想一想。这个测试工具在 JavaScript 解释器中发现了数百个错误,解释器的开发是由语言的创造者管理的!鲁德曼推测为什么 jsfunfuzz 能找到这么多 bug:
- 它知道 JavaScript 语言的规则,这使得它能够很好地覆盖语言特性的组合。
- 它打破了规则,允许它在语法错误处理中找到错误,如 bug 350415,并且更普遍地帮助 fuzzer 避免出现“盲点”。
- 它不怕以相当复杂的方式嵌套 JavaScript 结构,比如当它发现 bug 353079 时。
- 它允许通过在循环中创建和运行函数来累积状态。(请参见 bug 361346,它是一个很难找到的 bug 的例子。)
- 它测试正确性,而不仅仅是崩溃和断言。
自从 jsfunfuzz 发布以来,出现了其他著名的模糊工具,如 LangFuzz 和 Crossfuzz。 9 Crossfuzz 甚至可以直接在浏览器中运行,这大大方便了使用。许多模糊器不仅触发异常,还能够将导致失败的步骤转录到生成的测试中。例如,以下测试 10 由 LangFuzz 生成,以在 Google V8 JavaScript 引擎中产生断言失败:
var loop_count = 5;
function innerArrayLiteral(n) {
var a = new Array(n);
for (var i = 0; i < n; i++) {
a[i] = void ! delete 'object' % ∼ delete 4;
}
}
function testConstructOfSizeSize(n) {
var str = innerArrayLiteral(n);
}
for (var i = 0; i < loop_count; i++) {
for (var j = 1000; j < 12000; j += 1000) {
testConstructOfSizeSize(j);
}
}
然而,尽管这些模糊器在寻找程序中的安全漏洞和盲点方面非常强大和有效,但它们有几个缺点:
- 基于突变的模糊器可以永远运行。因此,很难选择一个持续时间,给你一个有意义的机会在不消耗太多时间的情况下找到 bug。
- 大多数 JavaScript fuzzers 主要针对主机环境,如浏览器和 JavaScript 引擎;将模糊引向独立 JavaScript 程序的选择有限。
- 模糊器可以通过一个共同的故障点找到数百个相关的 bug,这意味着得到数百个有不必要重叠的测试。因此,在将生成的测试添加到永久测试套件之前,每个缺陷都必须被单独考虑,并放在整个 bug 集合的上下文中。
- 模糊器不仅能发现程序中的错误,还能发现底层语言中的错误。因此,很难区分程序中的错误和 JavaScript 解释器中的错误。
尽管模糊化 JavaScript 应用确实会让程序员对他们根除 bug 的能力大吃一惊,但很难让他们直接关注您的应用。然而,有一些“fuzzeresque”工具可以以意想不到的方式扭曲应用,并且只关注程序,在下一节介绍 JSCheck 时,您将会看到这一点。
JSCheck
JSCheck 11 是由道格拉斯·克洛克福特编写的测试工具,灵感来自 QuickCheck。在模糊化部分,我解释了基于生成的模糊化器使用约束和规则空间来限制它们产生的随机数据的类型。JSCheck 以类似的方式工作,但是调用这些约束规范。它使用规范来验证关于程序的声明。由于这个原因,JSCheck 被认为是一个规范驱动的测试工具。JSCheck 通过处理关于程序的声明来生成测试,试图根除边缘情况和异常。就像模糊测试一样,这种方法的优势在于打破了程序员对他们程序的偏见。为了更好地理解 JSCheck 是如何工作的,我将带您看一个基本的例子。
安装 JSCheck
不幸的是,JSCheck 不能通过任何您喜欢的包或依赖项管理器获得,所以您必须从 git repo:
https://github.com/douglascrockford/JSCheck/archive/master.zip
基本示例
我将使用 Node.js 来运行测试,所以如果您想继续的话,请确保您已经安装了它。首先,我需要一个函数来测试。将以下代码另存为flip-test.js
:
function flipSign(val) {
return ∼(val - 1);
}
这个函数的目的是取任意一个数字并相应地翻转符号。我现在的目标是使用 Node.js 用 JSCheck 对此进行严格的测试。为了将 JSCheck 包含到测试中,我需要求助于一些eval
诡计,因为它不能作为 Node.js 模块导出。(当然,除了在示例中瞎搞之外,不建议做任何其他事情。)如果您想将 JSCheck 作为集成测试工作流的一部分,您必须首先为它创建一个合适的导出工具。flip-test.js
文件现在应该看起来像这样:
eval(require('fs').readFileSync('./jscheck.js', 'utf8'));
function flipSign(val) {
return ∼(val - 1);
}
Note
请确保 JSCheck 的路径是正确的。如果您直接下载了 zip 文件,jscheck.js
可能会嵌套在另一个文件夹中。
测试文件中包含了 JSCheck,并且编写了flipSign
函数,我已经准备好对该功能做一些声明,JSCheck 将会验证这些声明。
提出索赔
声明由三个必需属性和第四个可选属性组成。
名字
JSCheck 总是期望第一个参数是name
。JSCheck 报告功能使用它来简要解释索赔的上下文。
述语
所有的声明都需要一个predicate
参数,它指定了一个能够返回布尔值的函数,这取决于声明是否能够被证实。在这种情况下,您需要确保符号被翻转,这样函数看起来就像这样:
function predicate(verdict, value) {
return verdict(value === flipSign(flipSign(value)));
}
predicate()
方法至少有两个参数。第一个参数总是verdict
函数,JSCheck 使用它来报告比较的结果。verdict
功能非常健壮,旨在支持需要网络事务或异步请求的功能。其余的参数是 JSCheck 根据您在签名数组中配置的说明符为您生成的值。
签名
签名是描述参数范围的说明符数组,可以提供给predicate
函数。JSCheck 提供了一系列说明符模板,可用于约束随机值。例如,JSC.integer(0,10)
会生成一个 0 到 10 之间的整数。其他说明符可能相当复杂。object
说明符接受一个对象作为模板,它反过来利用其他说明符:
JSC.object({
left: JSC.integer(640)
,
top: JSC.integer(480)
,
color: JSC.one_of(['black', 'white', 'red', 'blue', 'green', 'gray'])
})
在您的程序中,您只需要一个数字,因此签名数组可能是这样的:
[JSC.integer(-10, 10)]
分类者
classifier
是唯一可选的参数,根据 JSCheck 文档,它有两个主要用途:
- 它可以检查参数并返回一个对案例进行分类的字符串。该字符串是描述性的。该报告可以包括显示属于每个分类的案例数量的摘要。它可以用来识别琐碎的或有问题的类,或者帮助分析结果。
- 因为案例是随机生成的,所以有些案例可能没有意义或没有用。分类器可以通过返回
false
拒绝案例。JSCheck 将尝试生成另一个案例来替换它。建议分类器拒绝少于 90%的情况。如果你接受的潜在案例少于 10 %,你也许应该重新阐述你的主张。
因为你的函数很琐碎,所以 JSCheck 提供的几乎任何值都是可用的;因此,测试不需要分类器。
核实索赔
现在我有了一个predicate
和一个合适的签名数组,我准备像这样连接 JSCheck 测试:
eval(require('fs').readFileSync('./jscheck.js', 'utf8'));
function flipSign(val) {
return ∼ (val - 1);
}
function predicate(verdict, value) {
return verdict(value === flipSign(flipSign(value)));
}
JSC.on_report(function(str) {
console.log(str);
});
JSC.test("flips integers", predicate, [JSC.integer(-10, 10)]);
注意,我为JSC
变量提供了一点配置。现在,每当 JSCheck b
对on_report
事件进行广播时,结果都会被写入控制台。配置完成后,脚本调用test
函数,并为其提供各种所需的属性。将更改保存到文件,然后从命令行以如下方式运行它:
node flip-test.js
该命令完成后,JSCheck 会将套件的结果输出到终端窗口:
flips integers: 100 cases tested, 100 pass
Total pass 100
优秀;不出差错,正如我所料!不过,为了确保安全,我将添加另一个测试,除了整数之外,它还使用了数字说明符。这将允许我测试浮点数。测试文件现在应该如下所示:
eval(require('fs').readFileSync('./jscheck.js', 'utf8'));
function flipSign(val) {
return ∼ (val - 1);
}
JSC.on_report(function(str) {
console.log(str);
});
function predicate(verdict, value) {
return verdict(value === flipSign(flipSign(value)));
}
JSC.test("flips integers", predicate, [JSC.integer(-10, 10)]);
JSC.test("flips numbers", predicate, [JSC.number(-10,10)]);
我将再次从命令行对这个文件运行 JSCheck。然而,这次我遇到了一些意想不到的失败:
node flip-test.js
flips integers: 100 cases tested, 100 pass
Total pass 100
flips numbers: 100 cases tested, 0 pass, 100 fail
FAIL [1] (-4.945855345577002)
FAIL [2] (0.6835379591211677)
...
FAIL [100] (0.6536271329969168)
更仔细地看一下flipSign
函数,它在浮点数上失败的原因就清楚了。虽然按位NOT
运算符像我希望的那样反转操作数的位,但它也将结果转换为整数。这种副作用正是 JSCheck 擅长发现的错误类型,因为它发生在我对该函数的心理模型之外。现在我已经确定了函数中的盲点,我可以这样重写它:
function flipSign(val) {
return val * -1;
}
再次运行测试,我可以看到修改达到了预期的效果,我的函数现在可以很好地处理浮点数和整数了:
node flip-test.js
flips integers: 100 cases tested, 100 pass
Total pass 100
flips numbers: 100 cases tested, 100 pass
Total pass 100
自动化测试
许多工业制造商使用机器人作为产品测试的一个组成部分。如果你走进任何一家宜家商场,你都有可能看到他们的机器人在展示。在一个巨大的玻璃盒子里,机器人将重复地、有条不紊地把它的模拟屁股坐在一把椅子上。宜家希望其消费者将机器人的严格重新安置与宜家自己彻底测试其产品的尝试联系起来。
在自动化软件测试领域,大多数用户交互都是使用 PhantomJS 之类的无头浏览器或 Selenium 之类的浏览器插件模拟的。这些工具被编写成像人一样与应用交互。然而,他们假设用户使用鼠标和键盘通过计算机进行交互,并且交互由点击和按键组成。在当今世界,这是一个过时的假设,一个节目可以在数百种不同的设备上观看,这些设备的屏幕大小、输入能力和响应能力各不相同。此外,许多设备现在支持复杂的手势;例如在屏幕上滑动手指。
我毫不怀疑,像苹果电脑这样的主要硬件和软件制造商拥有私人机器人测试人员大军,他们可以通过以脚本化和自动化的方式物理地使用他们的设备来不断地回归测试他们的产品。不幸的是,对于一般的开发团队来说,在目标设备上对其产品进行物理回归测试的唯一方法是以一种痛苦且耗时的方式进行。由于没有办法在设备上合理地测试应用,许多团队选择让最终用户为他们测试,当他们报告任何错误时,让他们自己去调查。显然,这不是一个理想的方法。幸运的是,即使是小团队也可以成为机器人测试革命的一部分,只要他们懂一点 JavaScript。
酒吧的酒保
Jason Huggins 接受自动化测试的程度可能让许多开发人员感到敬畏。他是 Sauce Labs 的首席技术官,该公司提供各种不同的产品,允许客户将设备测试外包给他们。Huggins 的部分工作是找到从他们的测试流程中剔除手动过程的方法,同时确保测试尽可能高保真。因此,必须在使用软件模拟用户选择的速度和通过物理控制设备手动重建用户交互之间取得平衡。他可能已经找到了 Tapster 项目的最佳切入点。 13 Tapster 是图 10-3 中看到的机器人,它以可脚本化和自动化的方式模拟用户的物理交互。
图 10-3。
Tapster robot
在《连线》杂志最近的一篇文章中, 14 哈金斯解释了为什么他认为 Tapster 这样的项目是必不可少的:
Future tests will be more and more difficult to replicate in laboratory or software simulator. My favorite example is Zipcar's iPhone application, which can open your car for you. To really test it, you need an iPhone and a car-not something easy to virtualize in the cloud. You can also use your mobile phone to buy coffee or control TV at Starbucks. I believe that in the future, the mobile phone will be the remote control of everything. However, with the application of the world, many new problems have emerged. Digital systems are more complex and fragile than analog systems. With complexity comes a higher risk of mistakes. The way to avoid this risk is to conduct more tests-lots of tests-faster and more "real". This is where robots appear.
驱动 Tapster 的技术都是开源的。事实上,大部分技术,包括 johnny-five、grunt 和 node-serialport,已经在前面的章节中详细介绍过了。甚至 Tapster 的实体零件都可以用你最喜欢的 3D 打印机打印出来。
Tapster 提供了一个创造性的尝试来解决测试中最普遍的问题之一,即框架偏差。正如我前面提到的,开发人员倾向于不测试程序中因测试过程而变得困难的方面。Tapster 代表了一个坚实的尝试,我认为这是一个社区开发和开源物理测试设备的新领域。当我们寻找新的方式将技术嵌入到我们的日常生活中时,我们也必须同时确保这些新的形式是经过精心设计和充分测试的。
摘要
知道如何编写测试不仅仅包括这样做的技术能力。要正确地测试一个程序,你必须有正确的心理定势和清晰的测试目标定义。这些目标经常被各种测试谬误所混淆,程序员可能会错误地推广这些谬误。在其他情况下,程序员自己的确认偏差可能会影响他们为自己的工作准确编写测试的能力。
幸运的是,程序员可以通过使用帮助他们对代码进行分析性思考的工具来克服这些偏见。例如,通过使用代码覆盖工具,开发人员可以快速可视化他们代码覆盖中的缺陷。此外,开发人员可以使用模糊测试或 JSCheck 以他们从未想到的方式以编程方式扭曲他们的代码。最后,程序员可以混合和匹配测试框架,以抵消一个框架可能对测试产生的任何偏见。当所有这些技术以一种深思熟虑的令人信服的方式一起使用时,你会发现你的代码的可测试性必然会提高。
Footnotes 1
http://bob.ippoli.to/archives/2005/06/02/javascript-sucks-volume-1/
2
http://storify.com/rmurphey/what-s-making-it-hard-to-get-started-with-js-testi
3
http://en.wikipedia.org/wiki/Pareto_principle#In_software
4
http://gotwarlost.github.io/istanbul/
5
http://en.wikipedia.org/wiki/Fuzz_testing
6
http://resources.infosecinstitute.com/fuzzing-mutation-vs-generation/
7
http://www.squarefree.com/2007/08/02/introducing-jsfunfuzz/
8
https://bugzilla.mozilla.org/show_bug.cgi?id=jsfunfuzz
9
http://lcamtuf.blogspot.com/2011/01/announcing-crossfuzz-potential-0-day-in.html
10
https://www.usenix.org/system/files/conference/usenixsecurity12/sec12-final73.pdf
11
12
http://en.wikipedia.org/wiki/QuickCheck
13
https://github.com/hugs/tapsterbot
14
http://www.wired.com/insights/2012/12/robots-at-the-intersection-of-cool-and-useful/