你不懂-JS-全-

你不懂 JS(全)

原文:You-Dont-Know-JS

译者:HetfieldJoe

你不懂 JS:类型与文法

来源:你不懂 JS:类型与文法

你不懂 JS:类型与文法 第一章:类型

大多数开发者会说,动态语言(就像 JS)没有 类型。让我们看看 ES5.1 语言规范(www.ecma-international.org/ecma-262/5.1/)在这个问题上是怎么说的:

在本语言规范中的算法所操作的每一个值都有一种关联的类型。可能的值的类型就是那些在本条款中定义的类型。类型还进一步被分为 ECMAScript 语言类型和语言规范类型

一个 ECMAScript 语言类型对应于 ECMAScript 程序员使用 ECMAScript 语言直接操作的值。ECMAScript 语言类型有 Undefined,Null,Boolean,String,Number,和 Object。

现在,如果你是一个强类型(静态类型的)语言的爱好者,你可能会反对“类型”一词的用法。在那些语言中,“类型”的含义要比它在 JS 这里的含义丰富得

有些人说 JS 不应该声称拥有“类型”,它们应被称为“标签”或者“子类型”。

去他的!我们将使用这个粗糙的定义(看起来和语言规范的定义相同,只是改变了措辞):一个 类型 是一组固有的,内建的性质,对于引擎 和开发者 来说,它独一无二地标识了一个特定的值的行为,并将它与其他值区分开。

换句话说,如果引擎和开发者看待值42(数字)与看待值"42"(字符串)的方式不同,那么这两个值就拥有不同的 类型 -- 分别是numberstring。当你使用42时,你就在 试图 做一些数字的事情,比如计算。但当你使用"42"时,你就在 试图 做一些字符串的事情,比如输出到页面上,等等。这两个值有着不同的类型。

这绝不是一个完美的定义。但是对于这里的讨论足够好了。而且它与 JS 描述它的方式并不矛盾。

无论类型被称为什么,它很重要

抛开学术上关于定义的分歧,为什么 JavaScript 有或者没有 类型 那么重要?

对每一种 类型 和它的固有行为有一个正确的理解,对于理解如何正确和准确地转换两个不同类型的值来说是绝对必要的(参见第四章,强制转换)。几乎每一个被编写过的 JS 程序都需要以某种形式处理类型的强制转换,所以,你能负责任,有信心地这么做是很重要的。

如果你有一个number42,但你想像一个string那样对待它,比如从位置1中将"2"作为一个字符抽取出来,那么显然你需要首先将值从number(强制)转换成一个string

这看起来十分简单。

但是这样的强制转换可能以许多不同的方式发生。其中有些方式是明确的,很容易推理的,和可靠的。但是如果你不小心,强制转换就可能以非常奇怪的,令人吃惊的方式发生。

强制转换的困惑可能是 JavaScript 开发者所经历的最深刻的挫败感之一。它曾经总是因为如此 危险 而为人所诟病,被认为是一个语言设计上的缺陷而应当被回避。

带着对 JavaScript 类型的全面理解,我们将要阐明为什么强制转换的 坏名声 是言过其实的,而且是有些冤枉的 -- 以此来反转你的视角,来看清强制转换的力量和用处。但首先,我们不得不更好地把握值与类型。

内建类型

JavaScript 定义了 7 种内建类型:

  • null
  • undefined
  • boolean
  • number
  • string
  • object
  • symbol -- 在 ES6 中被加入的!

注意: 除了object所有这些类型都被称为“基本类型(primitives)”。

typeof操作符可以检测给定值的类型,而且总是返回 7 种字符串值中的一种 -- 令人吃惊的是,对于我们刚刚列出的 7 中内建类型,它没有一个恰好的一对一匹配。

typeof undefined     === "undefined"; // true
typeof true          === "boolean";   // true
typeof 42            === "number";    // true
typeof "42"          === "string";    // true
typeof { life: 42 }  === "object";    // true

// 在 ES6 中被加入的!
typeof Symbol()      === "symbol";    // true

如上所示,这 6 种列出来的类型拥有相应类型的值,并返回一个与类型名称相同的字符串值。Symbol是 ES6 的新数据类型,我们将在第三章中讨论它。

正如你可能已经注意到的,我在上面的列表中剔除了null。它是 特殊的 -- 特殊在它与typeof操作符组合时是有 bug 的。

typeof null === "object"; // true

要是它返回"null"就好了(而且是正确的!),但是这个原有的 bug 已经存在了近 20 年,而且好像永远也不会被修复了,因为有太多已经存在的 web 的内容依存着这个 bug 的行为,“修复”这个 bug 将会 制造 更多的“bug”并毁掉许多 web 软件。

如果你想要使用null类型来测试null值,你需要一个复合条件:

var a = null;

(!a && typeof a === "object"); // true

null是唯一一个“falsy”(也叫类 false;见第四章),但是在typeof检查中返回"object"的基本类型。

那么typeof可以返回的第 7 种字符串值是什么?

typeof function a(){ /* .. */ } === "function"; // true

很容易认为在 JS 中function是一种顶层的内建类型,特别是看到typeof操作符的这种行为时。然而,如果你阅读语言规范,你会看到它实际上是对象(object)的“子类型”。特别地,一个函数(function)被称为“可调用对象” —— 一个拥有[[Call]]内部属性,允许被调用的对象。

函数实际上是对象的事实十分有用。最重要的是,它们可以拥有属性。例如:

function a(b,c) {
    /* .. */
}

这个函数对象拥有一个length属性,它被设置为函数被声明时的正式参数的数量。

a.length; // 2

因为你使用了两个正式命名的参数(bc)声明了函数,所以“函数的长度”是2

那么数组呢?它们是 JS 原生的,所以它们是一个特殊的类型咯?

typeof [1,2,3] === "object"; // true

不,它们仅仅是对象。考虑它们的最恰当的方法是,也将它们认为是对象的“子类型”(见第三章),带有被数字索引的附加性质(与仅仅使用字符串键的普通对象相反),并维护着一个自动更新的.length属性。

值作为类型

在 JavaScript 中,变量没有类型 -- 值才有类型。变量可以在任何时候,持有任何值。

另一种考虑 JS 类型的方式是,JS 没有“类型强制”,也就是引擎不坚持认为一个 变量 总是持有与它开始存在时相同的 初始类型 的值。在一个赋值语句中,一个变量可以持有一个string,而在下一个赋值语句中持有一个nubmer,如此类推。

42有固有的类型number,而且它的 类型 是不能被改变的。另一个值,比如string类型的"42",可以通过一个称为 强制转换 的处理从number类型的值42中创建出来(见第四章)。

如果你对一个变量使用typeof,它不会像表面上看起来那样询问“这个变量的类型是什么?”,因为 JS 变量是没有类型的。取而代之的是,它会询问“在这个变量里的值的类型是什么?”

var a = 42;
typeof a; // "number"

a = true;
typeof a; // "boolean"

typeof操作符总是返回字符串。所以:

typeof typeof 42; // "string"

第一个typeof 42返回"number",而typeof "number""string"

undefined vs "undeclared"

当前 还不拥有值的变量,实际上拥有undefined值。对这样的变量调用typeof将会返回"undefined"

var a;

typeof a; // "undefined"

var b = 42;
var c;

// 稍后
b = c;

typeof b; // "undefined"
typeof c; // "undefined"

大多数开发者考虑“undefined”这个词的方式会诱使他们认为它是“undeclared(未声明)”的同义词。然而在 JS 中,这两个概念十分不同。

一个“undefined”变量是在可访问的作用域中已经被声明过的,但是在 这个时刻 它里面没有任何值。相比之下,一个“undeclared”变量是在可访问的作用域中还没有被正式声明的。

考虑这段代码:

var a;

a; // undefined
b; // ReferenceError: b is not defined

一个恼人的困惑是浏览器给这种情形分配的错误消息。正如你所看到的,这个消息是“b is not defined”,这当然很容易而且很合理地使人将它与“b is undefined.”搞混。需要重申的是,“undefined”和“is not defined”是非常不同的东西。要是浏览器能告诉我们类似于“b is not found”或者“b is no declared”之类的东西就好了,那会减少这种困惑!

还有一种typeof与未声明变量关联的特殊行为,进一步增强了这种困惑。考虑这段代码:

var a;

typeof a; // "undefined"

typeof b; // "undefined"

typeof操作符甚至为“undeclared”(或“not defined”)变量返回"undefined"。要注意的是,当我们执行typeof b时,即使b是一个未声明变量,也不会有错误被抛出。这是typeof的一种特殊的安全防卫行为。

和上面类似地,要是typeof与未声明变量一起使用时返回“undeclared”就好了,而不是将其结果值与不同的“undefined”情况混为一谈。

typeof Undeclared

不管怎样,当在浏览器中处理 JavaScript 时这种安全防卫是一种有用的特性,因为浏览器中多个脚本文件会将变量加载到共享的全局名称空间。

注意: 许多开发者相信,在全局名称空间中绝不应该有任何变量,而且所有东西应当被包含在模块和私有/隔离的名称空间中。这在理论上很伟大但在实践中几乎是不可能的;但它仍然是一个值得的努力方向!幸运的是,ES6 为模块加入了头等支持,这终于使这一理论变得可行的多。

作为一个简单的例子,想象在你的程序中有一个“调试模式”,它是通过一个称为DEBUG的全局变量(标志)来控制的。在实施类似于在控制台上输出一条日志消息这样的调试任务之前,你想要检查这个变量是否被声明了。一个顶层的全局var DEBUG = true声明只包含在一个“debug.js”文件中,这个文件仅在你开发/测试时才被加载到浏览器中,而在生产环境中则不会。

然而,在你其他的程序代码中,你不得不小心你是如何检查这个全局的DEBUG变量的,这样你才不会抛出一个ReferenceError。这种情况下typeof上的安全防卫就是我们的朋友。

// 噢,这将抛出一个错误!
if (DEBUG) {
    console.log( "Debugging is starting" );
}

// 这是一个安全的存在性检查
if (typeof DEBUG !== "undefined") {
    console.log( "Debugging is starting" );
}

即便你不是在对付用户定义的变量(比如DEBUG),这种检查也是很有用的。如果你为一个内建的 API 做特性检查,你也会发现不带有抛出错误的检查很有帮助:

if (typeof atob === "undefined") {
    atob = function() { /*..*/ };
}

注意: 如果你在为一个还不存在的特性定义一个“填补”,你可能想要避免使用var来声明atob。如果你在if语句内部声明var atob,即使这个if条件没有通过(因为全局的atob已经存在),这个声明也会被提升(参见本系列的 作用域与闭包)到作用域的顶端。在某些浏览器中,对一些特殊类型的内建全局变量(常被称为“宿主对象”),这种重复声明也许会抛出错误。忽略var可以防止这种提升声明。

另一种不带有typeof的安全防卫特性,而对全局变量进行这些检查的方法是,将所有的全局变量作为全局对象的属性来观察,在浏览器中这个全局对象基本上是window对象。所以,上面的检查可以(十分安全地)这样做:

if (window.DEBUG) {
    // ..
}

if (!window.atob) {
    // ..
}

和引用未声明变量不同的是,在你试着访问一个不存在的对象属性时(即便是在全局的window对象上),不会有ReferenceError被抛出。

另一方面,一些开发者偏好避免手动使用window引用全局变量,特别是当你的代码需要运行在多种 JS 环境中时(例如不仅是在浏览器中,还在服务器端的 node.js 中),全局变量可能不总是称为window

技术上讲,这种typeof上的安全防卫即使在你不使用全局变量时也很有用,虽然这些情况不那么常见,而且一些开发者也许发现这种设计方式不那么理想。想象一个你想要其他人复制-粘贴到他们程序中或模块中的工具函数,在它里面你想要检查包含它的程序是否已经定义了一个特定的变量(以便于你可以使用它):

function doSomethingCool() {
    var helper =
        (typeof FeatureXYZ !== "undefined") ?
        FeatureXYZ :
        function() { /*.. 默认的特性 ..*/ };

    var val = helper();
    // ..
}

doSomethingCool()对称为FeatureXYZ变量进行检查,如果找到,就使用它,如果没找到,使用它自己的。现在,如果某个人在他的模块/程序中引入了这个工具,它会安全地检查我们是否已经定义了FeatureXYZ

// 一个 IIFE(参见本系列的 *作用域与闭包* 中的“立即被调用的函数表达式”)
(function(){
    function FeatureXYZ() { /*.. my XYZ feature ..*/ }

    // 引入 `doSomethingCool(..)`
    function doSomethingCool() {
        var helper =
            (typeof FeatureXYZ !== "undefined") ?
            FeatureXYZ :
            function() { /*.. 默认的特性 ..*/ };

        var val = helper();
        // ..
    }

    doSomethingCool();
})();

这里,FeatureXYZ根本不是一个全局变量,但我们仍然使用typeof的安全防卫来使检查变得安全。而且重要的是,我们在这里 没有 可以用于检查的对象(就像我们使用window.___对全局变量做的那样),所以typeof十分有帮助。

另一些开发者偏好一种称为“依赖注入”的设计模式,与doSomethingCool()隐含地检查FeatureXYZ是否在它外部/周围被定义过不同的是,它需要依赖明确地传递进来,就像这样:

function doSomethingCool(FeatureXYZ) {
    var helper = FeatureXYZ ||
        function() { /*.. 默认的特性 ..*/ };

    var val = helper();
    // ..
}

在设计这样的功能时有许多选择。这些模式里没有“正确”或“错误” -- 每种方式都有各种权衡。但总的来说,typeof的未声明安全防卫给了我们更多选项,这还是很不错的。

复习

JavaScript 有 7 种内建 类型nullundefinedbooleannumberstringobjectsymbol。它们可以被typeof操作符识别。

变量没有类型,但是值有类型。这些类型定义了值的固有行为。

许多开发者会认为“undefined”和“undeclared”大体上是同一个东西,但是在 JavaScript 中,它们是十分不同的。undefined是一个可以由被声明的变量持有的值。“未声明”意味着一个变量从来没有被声明过。

JavaScript 很不幸地将这两个词在某种程度上混为了一谈,不仅体现在它的错误消息上(“ReferenceError: a is not defined”),也体现在typeof的返回值上:对于两者它都返回"undefined"

然而,当对一个未声明的变量使用typeof时,typeof上的安全防卫机制(防止一个错误)可以在特定的情况下非常有用。

你不懂 JS:类型与文法 第二章:值

arraystring,和number是任何程序的最基础构建块,但是 JavaScript 在这些类型上有一些要么使你惊喜要么使你惊讶的独特性质。

让我们来看几种 JS 内建的值类型,并探讨一下我们如何才能更加全面地理解并正确地利用它们的行为。

Array

和其他强制类型的语言相比,JavaScript 的array只是值的容器,而这些值可以是任何类型:string或者number或者object,甚至是另一个array(这也是你得到多维数组的方法)。

var a = [ 1, "2", [3] ];

a.length;        // 3
a[0] === 1;        // true
a[2][0] === 3;    // true

你不需要预先指定array的大小,你可以仅声明它们并加入你觉得合适的值:

var a = [ ];

a.length;    // 0

a[0] = 1;
a[1] = "2";
a[2] = [ 3 ];

a.length;    // 3

警告: 在一个array值上使用delete将会从这个array上移除一个值槽,但就算你移除了最后一个元素,它也 不会 更新length属性,所以多加小心!我们会在第五章讨论delete操作符的更多细节。

要小心创建“稀散”的array(留下或创建空的/丢失的值槽):

var a = [ ];

a[0] = 1;
// 这里没有设置值槽`a[1]`
a[2] = [ 3 ];

a[1];        // undefined

a.length;    // 3

虽然这可以工作,但你留下的“空值槽”可能会导致一些令人困惑的行为。虽然这样的值槽看起来拥有undefined值,但是它不会像被明确设置(a[1] = undefined)的值槽那样动作。更多信息可以参见第三章的“Array”。

array是被数字索引的(正如你认为的那样),但微妙的是它们也是对象,可以在它们上面添加string键/属性(但是这些属性不会计算在arraylength中):

var a = [ ];

a[0] = 1;
a["foobar"] = 2;

a.length;        // 1
a["foobar"];    // 2
a.foobar;        // 2

然而,一个需要小心的坑是,如果一个可以被强制转换为 10 进制numberstring值被用作键的话,它会认为你想使用number索引而不是一个string键!

var a = [ ];

a["13"] = 42;

a.length; // 14

一般来说,向array添加string键/属性不是一个好主意。最好使用object来持有键/属性形式的值,而将array专用于严格地数字索引的值。

类 Array

偶尔你需要将一个类array值(一个数字索引的值的集合)转换为一个真正的array,通常你可以对这些值的集合调用数组的工具函数(比如indexOf(..)concat(..)forEach(..)等等)。

举个例子,各种 DOM 查询操作会返回一个 DOM 元素的列表,对于我们转换的目的来说,这些列表不是真正的array但是也足够类似array。另一个常见的例子是,函数为了像列表一样访问它的参数值,而暴露了arugumens对象(类array,在 ES6 中被废弃了)。

一个进行这种转换的很常见的方法是对这个值借用slice(..)工具:

function foo() {
    var arr = Array.prototype.slice.call( arguments );
    arr.push( "bam" );
    console.log( arr );
}

foo( "bar", "baz" ); // ["bar","baz","bam"]

如果slice()没有用其他额外的参数调用,就像上面的代码段那样,它的参数的默认值会使它具有复制这个array(或者,在这个例子中,是一个类array)的效果。

在 ES6 中,还有一种称为Array.from(..)的内建工具可以执行相同的任务:

...
var arr = Array.from( arguments );
...

注意: Array.from(..)拥有几种其他强大的能力,我们将在本系列的 ES6 与未来 中涵盖它的细节。

String

一个很常见的想法是,string实质上只是字符的array。虽然内部的实现可能是也可能不是array,但重要的是要理解 JavaScript 的string与字符的array确实不一样。它们的相似性几乎只是表面上的。

举个例子,让我们考虑这两个值:

var a = "foo";
var b = ["f","o","o"];

String 确实与array有很肤浅的相似性 -- 也就是上面说的,类array -- 举例来说,它们都有一个length属性,一个indexOf(..)方法(在 ES5 中仅有array版本),和一个concat(..)方法:

a.length;                            // 3
b.length;                            // 3

a.indexOf( "o" );                    // 1
b.indexOf( "o" );                    // 1

var c = a.concat( "bar" );            // "foobar"
var d = b.concat( ["b","a","r"] );    // ["f","o","o","b","a","r"]

a === c;                            // false
b === d;                            // false

a;                                    // "foo"
b;                                    // ["f","o","o"]

那么,它们基本上都仅仅是“字符的数组”,对吧? 不确切

a[1] = "O";
b[1] = "O";

a; // "foo"
b; // ["f","O","o"]

JavaScript 的string是不可变的,而array是相当可变的。另外,在 JavaScript 中用位置访问字符的a[1]形式不总是广泛合法的。老版本的 IE 就不允许这种语法(但是它们现在允许了)。相反,正确的 方式是a.charAt(1)

string不可变性的进一步的后果是,string上没有一个方法是可以原地修改它的内容的,而是创建并返回一个新的string。与之相对的是,许多改变array内容的方法实际上 原地修改的。

c = a.toUpperCase();
a === c;    // false
a;            // "foo"
c;            // "FOO"

b.push( "!" );
b;            // ["f","O","o","!"]

另外,许多array方法在处理string时非常有用,虽然这些方法不属于string,但我们可以对我们的string“借用”非变化的array方法:

a.join;            // undefined
a.map;            // undefined

var c = Array.prototype.join.call( a, "-" );
var d = Array.prototype.map.call( a, function(v){
    return v.toUpperCase() + ".";
} ).join( "" );

c;                // "f-o-o"
d;                // "F.O.O."

让我们来看另一个例子:翻转一个string(顺带一提,这是一个 JavaScript 面试中常见的细小问题!)。array拥有一个原地的reverse()修改器方法,但是string没有:

a.reverse;        // undefined

b.reverse();    // ["!","o","O","f"]
b;                // ["!","o","O","f"]

不幸的是,这种“借用”array修改器不起作用,因为string是不可变的,因此它不能被原地修改:

Array.prototype.reverse.call( a );
// 仍然返回一个“foo”的 String 对象包装器(见第三章) :(

另一种迂回的做法(也是黑科技)是,将string转换为一个array,实施我们想做的操作,然后将它转回string

var c = a
    // 将`a`切分成一个字符的数组
    .split( "" )
    // 翻转字符的数组
    .reverse()
    // 将字符的数组连接回一个字符串
    .join( "" );

c; // "oof"

如果你觉得这很难看,没错。不管怎样,对于简单的string好用,所以如果你需要某些快速但是“脏”的东西,像这样的方式经常能满足你。

警告: 小心!这种方法对含有复杂(unicode)字符(星号,多字节字符等)的string 不起作用。你需要支持 unicode 的更精巧的工具库来准确地处理这种操作。在这个问题上可以咨询 Mathias Bynens 的作品:Esrevergithub.com/mathiasbynens/esrever)。

另外一种考虑这个问题的方式是:如果你更经常地将你的“string”基本上作为 字符的数组 来执行一些任务的话,也许就将它们作为array而不是作为string存储更好。你可能会因此省去很多每次都将string转换为array的麻烦。无论何时你确实需要string的表现形式的话,你总是可以调用 字符的 arrayjoin("")方法。

Number

JavaScript 只有一种数字类型:number。这种类型包含“整数”值和小数值。我说“整数”时加了引号,因为 JS 的一个长久以来为人诟病的原因是,和其他语言不同,JS 没有真正的整数。这可能在未来某个时候会改变,但是目前,我们只有number可用。

所以,在 JS 中,一个“整数”只是一个没有小数部分的小数值。也就是说,42.042一样是“整数”。

像大多数现代计算机语言,以及几乎所有的脚本语言一样,JavaScript 的number的实现基于“IEEE 754”标准,通常被称为“浮点”。JavaScript 明确地使用了这个标准的“双精度”(也就是“64 位二进制”)格式。

在网络上有许多了不起的文章都在介绍二进制浮点数如何在内存中存储的细节,以及选择这些做法的意义。因为对于理解如何在 JS 中正确使用number来说,理解内存中的位模式不是必须的,所以我们将这个话题作为练习留给那些想要进一步挖掘 IEEE 754 的细节的读者。

数字的语法

在 JavaScript 中字面数字一般用 10 进制小数表达。例如:

var a = 42;
var b = 42.3;

小数的整数部分如果是0,是可选的:

var a = 0.42;
var b = .42;

相似地,一个小数在.之后的小数部分如果是0,是可选的:

var a = 42.0;
var b = 42.;

警告: 42.是极不常见的,如果你正在努力避免别人阅读你的代码时感到困惑,它可能不是一个好主意。但不管怎样,它是合法的。

默认情况下,大多数number将会以 10 进制小数的形式输出,并去掉末尾小数部分的0。所以:

var a = 42.300;
var b = 42.0;

a; // 42.3
b; // 42

非常大或非常小的number将默认以指数形式输出,与toExponential()方法的输出一样,比如:

var a = 5E10;
a;                    // 50000000000
a.toExponential();    // "5e+10"

var b = a * a;
b;                    // 2.5e+21

var c = 1 / a;
c;                    // 2e-11

因为number值可以用Number对象包装器封装(见第三章),number值可以访问内建在Number.prototype上的方法(见第三章)。举个例子,toFixed(..)方法允许你指定一个值在被表示时,带有多少位小数:

var a = 42.59;

a.toFixed( 0 ); // "43"
a.toFixed( 1 ); // "42.6"
a.toFixed( 2 ); // "42.59"
a.toFixed( 3 ); // "42.590"
a.toFixed( 4 ); // "42.5900"

要注意的是,它的输出实际上是一个numberstring表现形式,而且如果你指定的位数多于值持有的小数位数时,会在右侧补0

toPrecision(..)很相似,但它指定的是有多少 有效数字 用来表示这个值:

var a = 42.59;

a.toPrecision( 1 ); // "4e+1"
a.toPrecision( 2 ); // "43"
a.toPrecision( 3 ); // "42.6"
a.toPrecision( 4 ); // "42.59"
a.toPrecision( 5 ); // "42.590"
a.toPrecision( 6 ); // "42.5900"

你不必非得使用持有这个值的变量来访问这些方法;你可以直接在number的字面上访问这些方法。但你不得不小心.操作符。因为.是一个合法数字字符,如果可能的话,它会首先被翻译为number字面的一部分,而不是被翻译为属性访问操作符。

// 不合法的语法:
42.toFixed( 3 );    // SyntaxError

// 这些都是合法的:
(42).toFixed( 3 );    // "42.000"
0.42.toFixed( 3 );    // "0.420"
42..toFixed( 3 );    // "42.000"

42.toFixed(3)是不合法的语法,因为.作为42.字面(这是合法的 -- 参见上面的讨论!)的一部分被吞掉了,因此没有.属性操作符来表示.toFixed访问。

42..toFixed(3)可以工作,因为第一个.number的一部分,而第二个.是属性操作符。但它可能看起来很古怪,而且确实在实际的 JavaScript 代码中很少会看到这样的东西。实际上,在任何基本类型上直接访问方法是十分不常见的。但是不常见并不意味着 或者

注意: 有一些库扩展了内建的Number.prototype(见第三章),使用number或在number上提供了额外的操作,所以在这些情况下,像使用10..makeItRain()来设定一个 10 秒钟的下钱雨的动画,或者其他诸如此类的傻事是完全合法的。

在技术上讲,这也是合法的(注意那个空格):

42 .toFixed(3); // "42.000"

但是,尤其是对number字面量来说,这是特别使人糊涂的代码风格,而且除了使其他开发者(和未来的你)糊涂以外没有任何用处。避免它。

number还可以使用科学计数法的形式指定,这在表示很大的number时很常见,比如:

var onethousand = 1E3;                        // 代表 1 * 10³
var onemilliononehundredthousand = 1.1E6;    // 代表 1.1 * 10⁶

number字面量还可以使用其他进制表达,比如二进制,八进制,和十六进制。

这些格式是可以在当前版本的 JavaScript 中使用的:

0xf3; // 十六进制的: 243
0Xf3; // 同上

0363; // 八进制的: 243

注意: 从 ES6 + strict模式开始,不再允许0363这样的的八进制形式(新的形式参见后面的讨论)。0363在非strict模式下依然是允许的,但是不管怎样你应当停止使用它,来拥抱未来(而且因为你现在应当在使用strict模式了!)。

至于 ES6,下面的新形式也是合法的:

0o363;        // 八进制的: 243
0O363;        // 同上

0b11110011;    // 二进制的: 243
0B11110011; // 同上

请为你的开发者同胞们做件好事:绝不要使用0O363形式。把0放在大写的O旁边就是在制造困惑。保持使用小写的谓词0x0b,和0o

小数值

使用二进制浮点数的最出名(臭名昭著)的副作用是(记住,这是对 所有 使用 IEEE 754 的语言都成立的——不是许多人认为/假装 在 JavaScript 中存在的问题):

0.1 + 0.2 === 0.3; // false

从数学的意义上,我们知道这个语句应当为true。为什么它是false

简单地说,0.10.2的二进制表示形式是不精确的,所以它们相加时,结果不是精确地0.3。而是 非常 接近的值:0.30000000000000004,但是如果你的比较失败了,“接近”是无关紧要的。

注意: JavaScript 应当切换到可以精确表达所有值的一个不同的number实现吗?有些人认为应该。多年以来有许多选项出现过。但是没有一个被采纳,而且也许永远也不会。它看起来就像挥挥手然后说“已经改好那个 bug 了!”那么简单,但根本不是那么回事儿。如果真有这么简单,它绝对就在很久以前被改掉了。

现在的问题是,如果一些number不能被 信任 为精确的,这不是意味着我们根本不能使用number吗? 当然不是。

在一些应用程序中你需要多加小心,特别是在对付小数的时候。还有许多(也许是大多数?)应用程序只处理整数,而且,最大只处理到几百万到几万亿。这些应用程序使用 JS 中的数字操作是,而且将总是,非常安全 的。

要是我们 确实 需要比较两个number,就像0.1 + 0.20.3,而且知道这个简单的相等测试将会失败呢?

可以接受的最常见的做法是使用一个很小的“错误舍入”值作为比较的 容差。这个很小的值经常被称为“机械极小值(machine epsilon)”,对于 JavaScript 来说这种number通常为2^-522.220446049250313e-16)。

在 ES6 中,使用这个容差值预定义了Number.EPSILON,所以你将会想要使用它,你也可以在前 ES6 中安全地填补这个定义:

if (!Number.EPSILON) {
    Number.EPSILON = Math.pow(2,-52);
}

我们可以使用这个Number.EPSILON来比较两个number的“等价性”(带有错误舍入的容差):

function numbersCloseEnoughToEqual(n1,n2) {
    return Math.abs( n1 - n2 ) < Number.EPSILON;
}

var a = 0.1 + 0.2;
var b = 0.3;

numbersCloseEnoughToEqual( a, b );                    // true
numbersCloseEnoughToEqual( 0.0000001, 0.0000002 );    // false

可以被表示的最大的浮点值大概是1.798e+308(它真的非常,非常,非常大!),它为你预定义为Number.MAX_VALUE。在极小的一端,Number.MIN_VALUE大概是5e-324,它不是负数但是非常接近于 0!

安全整数范围

由于number的表示方式,对完全是number的“整数”而言有一个“安全”的值的范围,而且它要比Number.MAX_VALUE小得多。

可以“安全地”被表示的最大整数(也就是说,可以保证被表示的值是实际可以无误地表示的)是2⁵³ - 1,也就是9007199254740991,如果你插入一些数字分隔符,可以看到它刚好超过 9 万亿。所以对于number能表示的上限来说它确实是够 TM 大的。

在 ES6 中这个值实际上是自动预定义的,它是Number.MAX_SAFE_INTEGER。意料之中的是,还有一个最小值,-9007199254740991,它在 ES6 中定义为Number.MIN_SAFE_INTEGER

JS 程序面临处理这样大的数字的主要情况是,处理数据库中的 64 位 ID 等等。64 位数字不能使用number类型准确表达,所以在 JavaScript 中必须使用string表现形式存储(和传递)。

谢天谢地,在这样的大 IDnumber值上的数字操作(除了比较,它使用string也没问题)并不很常见。但是如果你 确实 需要在这些非常大的值上实施数学操作,目前来讲你需要使用一个 大数字 工具。在未来版本的 JavaScript 中,大数字也许会得到官方支持。

测试整数

测试一个值是否是整数,你可以使用 ES6 定义的Number.isInteger(..)

Number.isInteger( 42 );        // true
Number.isInteger( 42.000 );    // true
Number.isInteger( 42.3 );    // false

可以为前 ES6 填补Number.isInteger(..)

if (!Number.isInteger) {
    Number.isInteger = function(num) {
        return typeof num == "number" && num % 1 == 0;
    };
}

要测试一个值是否是 安全整数,使用 ES6 定义的Number.isSafeInteger(..)

Number.isSafeInteger( Number.MAX_SAFE_INTEGER );    // true
Number.isSafeInteger( Math.pow( 2, 53 ) );            // false
Number.isSafeInteger( Math.pow( 2, 53 ) - 1 );        // true

可以为前 ES6 浏览器填补Number.isSafeInteger(..)

if (!Number.isSafeInteger) {
    Number.isSafeInteger = function(num) {
        return Number.isInteger( num ) &&
            Math.abs( num ) <= Number.MAX_SAFE_INTEGER;
    };
}

32 位(有符号)整数

虽然整数可以安全地最大达到约 9 万亿(53 比特),但有一些数字操作(比如位操作符)是仅仅为 32 位number定义的,所以对于被这样使用的number来说,“安全范围”一定会小得多。

这个范围是从Math.pow(-2,31)-2147483648,大约-21 亿)到Math.pow(2,31)-12147483647,大约+21 亿)。

要强制a中的number值是 32 位有符号整数,使用a | 0。这可以工作是因为|位操作符仅仅对 32 位值起作用(意味着它可以只关注 32 位,而其他的位将被丢掉)。而且,和 0 进行“或”的位操作实质上是什么也不做。

注意: 特定的特殊值(我们将在下一节讨论),比如NaNInfinity不是“32 位安全”的,当这些值被传入位操作符时将会通过一个抽象操作ToInt32(见第四章)并为了位操作而简单地变成+0值。

特殊值

在各种类型中散布着一些特殊值,需要 警惕 的 JS 开发者小心,并正确使用。

不是值的值

对于undefined类型来说,有且仅有一个值:undefined。对于null类型来说,有且仅有一个值:null。所以对它们而言,这些文字既是它们的类型也是它们的值。

undefinednull作为“空”值或者“没有”值,经常被认为是可以互换的。另一些开发者偏好于使用微妙的区别将它们区分开。举例来讲:

  • null是一个空值
  • undefined是一个丢失的值

或者:

  • undefined还没有值
  • null曾经有过值但现在没有

不管你选择如何“定义”和使用这两个值,null是一个特殊的关键字,不是一个标识符,因此你不能将它作为一个变量对待来给它赋值(为什么你要给它赋值呢?!)。然而,undefined(不幸地) 一个标识符。噢。

Undefined

在非strict模式下,给在全局上提供的undefined标识符赋一个值实际上是可能的(虽然这是一个非常不好的做法!):

function foo() {
    undefined = 2; // 非常差劲儿的主意!
}

foo();
function foo() {
 "use strict";
    undefined = 2; // TypeError!
}

foo();

但是,在非strict模式和strict模式下,你可以创建一个名叫undefined局部变量。但这又是一个很差劲儿的主意!

function foo() {
 "use strict";
    var undefined = 2;
    console.log( undefined ); // 2
}

foo();

朋友永远不让朋友覆盖undefined

void操作符

虽然undefined是一个持有内建的值undefined的内建标识符(除非被修改——见上面的讨论!),另一个得到这个值的方法是void操作符。

表达式void __会“躲开”任何值,所以这个表达式的结果总是值undefined。它不会修改任何已经存在的值;只是确保不会有值从操作符表达式中返回来。

var a = 42;

console.log( void a, a ); // undefined 42

从惯例上讲(大约是从 C 语言编程中发展而来),要通过使用void来独立表现值undefined,你可以使用void 0(虽然,很明显,void true或者任何其他的void表达式都做同样的事情)。在void 0void 1undefined之间没有实际上的区别。

但是在几种其他的环境下void操作符可以十分有用:如果你需要确保一个表达式没有结果值(即便它有副作用)。

举个例子:

function doSomething() {
    // 注意:`APP.ready`是由我们的应用程序提供的
    if (!APP.ready) {
        // 稍后再试一次
        return void setTimeout( doSomething, 100 );
    }

    var result;

    // 做其他一些事情
    return result;
}

// 我们能立即执行吗?
if (doSomething()) {
    // 马上处理其他任务
}

这里,setTimeout(..)函数返回一个数字值(时间间隔定时器的唯一标识符,用于取消它自己),但是我们想void它,这样我们函数的返回值不会在if语句上给出一个成立的误报。

许多开发者宁愿将这些动作分开,这样的效用相同但不使用void操作符:

if (!APP.ready) {
    // 稍后再试一次
    setTimeout( doSomething, 100 );
    return;
}

一般来说,如果有那么一个地方,有一个值存在(来自某个表达式)而你发现这个值如果是undefined才有用,就使用void操作符。这可能在你的程序中不是非常常见,但如果在一些稀有的情况下你需要它,它就十分有用。

特殊的数字

number类型包含几种特殊值。我们将会仔细考察每一种。

不是数字的数字

如果你不使用同为number(或者可以被翻译为 10 进制或 16 进制的普通number的值)的两个操作数进行任何算数操作,那么操作的结果将失败而产生一个不合法的number,在这种情况下你将得到NaN值。

NaN在字面上代表“不是一个number(Not a Number)”,但是正如我们即将看到的,这种文字描述十分失败而且容易误导人。将NaN考虑为“不合法数字”,“失败的数字”,甚至是“坏掉的数字”都要比“不是一个数字”准确得多。

举例来说:

var a = 2 / "foo";        // NaN

typeof a === "number";    // true

换句话说:“‘不是一个数字’的类型是‘数字’”!为这使人糊涂的名字和语义欢呼吧。

NaN是一种“哨兵值”(一个被赋予了特殊意义的普通的值),它代表number集合内的一种特殊的错误情况。这种错误情况实质上是:“我试着进行数学操作但是失败了,而这就是失败的number结果。”

那么,如果你有一个值存在某个变量中,而且你想要测试它是否是这个特殊的失败数字NaN,你也许认为你可以直接将它与NaN本身比较,就像你能对其它的值做的那样,比如nullundefined。不是这样。

var a = 2 / "foo";

a == NaN;    // false
a === NaN;    // false

NaN是一个非常特殊的值,它从来不会等于另一个NaN值(也就是,它从来不等于它自己)。实际上,它是唯一一个不具有反射性的值(没有恒等性x === x)。所以,NaN !== NaN。有点奇怪,对吧?

那么,如果不能与NaN进行比较(因为这种比较将总是失败),我们该如何测试它呢?

var a = 2 / "foo";

isNaN( a ); // true

够简单的吧?我们使用称为isNaN(..)的内建全局工具,它告诉我们这个值是否是NaN。问题解决了!

别高兴得太早。

isNaN(..)工具有一个重大缺陷。它似乎过于按照字面的意思(“不是一个数字”)去理解NaN的含义了——它的工作基本上是:“测试这个传进来的东西是否不是一个number或者是一个number”。但这不是十分准确。

var a = 2 / "foo";
var b = "foo";

a; // NaN
b; // "foo"

window.isNaN( a ); // true
window.isNaN( b ); // true -- 噢!

很明显,"foo"根本 不是一个number,但它也绝不是一个NaN值!这个 bug 从最开始的时候就存在于 JS 中了(存在超过 19 年的坑)。

在 ES6 中,终于提供了一个替代它的工具:Number.isNaN(..)。有一个简单的填补,可以让你即使是在前 ES6 的浏览器中安全地检查NaN值:

if (!Number.isNaN) {
    Number.isNaN = function(n) {
        return (
            typeof n === "number" &&
            window.isNaN( n )
        );
    };
}

var a = 2 / "foo";
var b = "foo";

Number.isNaN( a ); // true
Number.isNaN( b ); // false -- 咻!

实际上,通过利用NaN与它自己不相等这个特殊的事实,我们可以更简单地实现Number.isNaN(..)的填补。在整个语言中NaN是唯一一个这样的值;其他的值都总是 等于它自己

所以:

if (!Number.isNaN) {
    Number.isNaN = function(n) {
        return n !== n;
    };
}

怪吧?但是好用!

不管有意还是无意,在许多真实世界的 JS 程序中NaN可能是一个现实的问题。使用Number.isNaN(..)(或者它的填补)这样的可靠测试来正确地识别它们是一个非常好的主意。

如果你正在程序中仅使用isNaN(..),悲惨的现实是你的程序 有 bug,即便是你还没有被它咬到!

无穷

来自于像 C 这样的传统编译型语言的开发者,可能习惯于看到编译器错误或者是运行时异常,比如对这样一个操作给出的“除数为 0”:

var a = 1 / 0;

然而在 JS 中,这个操作是明确定义的,而且它的结果是值Infinity(也就是Number.POSITIVE_INFINITY)。意料之中的是:

var a = 1 / 0;    // Infinity
var b = -1 / 0;    // -Infinity

如你所见,-Infinity(也就是Number.NEGATIVE_INFINITY)是从任一个被除数为负(不是两个都是负数!)的除 0 操作得来的。

JS 使用有限的数字表现形式(IEEE 754 浮点,我们早先讨论过),所以和单纯的数学相比,它看起来甚至在做加法和减法这样的操作时都有可能溢出,这样的情况下你将会得到Infinity-Infinity

例如:

var a = Number.MAX_VALUE;    // 1.7976931348623157e+308
a + a;                        // Infinity
a + Math.pow( 2, 970 );        // Infinity
a + Math.pow( 2, 969 );        // 1.7976931348623157e+308

根据语言规范,如果一个像加法这样的操作得到一个太大而不能表示的值,IEEE 754“就近舍入”模式将会指明结果应该是什么。所以粗略的意义上,Number.MAX_VALUE + Math.pow( 2, 969 )比起Infinity更接近于Number.MAX_VALUE,所以它“向下舍入”,而Number.MAX_VALUE + Math.pow( 2, 970 )距离Infinity更近,所以它“向上舍入”。

如果你对此考虑的太多,它会使你头疼的。所以别想了。我是认真的,停!

一旦你溢出了任意一个 无限值,那么,就没有回头路了。换句最有诗意的话说,你可以从有限迈向无限,但不能从无限回归有限。

“无限除以无限等于什么”,这简直是一个哲学问题。我们幼稚的大脑可能会说“1”或“无限”。事实表明它们都不对。在数学上和在 JavaScript 中,Infinity / Infinity不是一个有定义的操作。在 JS 中,它的结果为NaN

一个有限的正number除以Infinity呢?简单!0。那一个有限的负number处理Infinity呢?接着往下读!

虽然这可能使有数学头脑的读者困惑,JavaScript 拥有普通的零0(也称为正零+0 一个负零-0。在我们讲解为什么-0存在之前,我们应该考察 JS 如何处理它,因为它可能十分令人困惑。

除了使用字面量-0指定,负的零还可以从特定的数学操作中得出。比如:

var a = 0 / -3; // -0
var b = 0 * -3; // -0

加法和减法无法得出负零。

在开发者控制台中考察一个负的零,经常显示为-0,然而直到最近这才是一个常见情况,所以一些你可能遇到的老版本浏览器也许依然将它报告为0

但是根据语言规范,如果你试者将一个负零转换为字符串,它将总会被报告为"0"

var a = 0 / -3;

// 至少(有些浏览器)控制台是对的
a;                            // -0

// 但是语言规范坚持要向你撒谎!
a.toString();                // "0"
a + "";                        // "0"
String( a );                // "0"

// 奇怪的是,就连 JSON 也加入了骗局之中
JSON.stringify( a );        // "0"

有趣的是,反向操作(从stringnumber)不会撒谎:

+"-0";                // -0
Number( "-0" );        // -0
JSON.parse( "-0" );    // -0

警告: 当你观察的时候,JSON.stringify( -0 )产生"0"显得特别奇怪,因为它与反向操作不符:JSON.parse( "-0" )将像你期望地那样报告-0

除了一个负零的字符串化会欺骗性地隐藏它实际的值外,比较操作符也被设定为(有意地) 要说谎

var a = 0;
var b = 0 / -3;

a == b;        // true
-0 == 0;    // true

a === b;    // true
-0 === 0;    // true

0 > -0;        // false
a > b;        // false

很明显,如果你想在你的代码中区分-00,你就不能仅依靠开发者控制台的输出,你必须更聪明一些:

function isNegZero(n) {
    n = Number( n );
    return (n === 0) && (1 / n === -Infinity);
}

isNegZero( -0 );        // true
isNegZero( 0 / -3 );    // true
isNegZero( 0 );            // false

那么,除了学院派的细节以外,我们为什么需要一个负零呢?

在一些应用程序中,开发者使用值的大小来表示一部分信息(比如动画中每一帧的速度),而这个number的符号来表示另一部分信息(比如移动的方向)。

在这些应用程序中,举例来说,如果一个变量的值变成了 0,而它丢失了符号,那么你就丢失了它是从哪个方向移动到 0 的信息。保留零的符号避免了潜在的意外信息丢失。

特殊等价

正如我们上面看到的,当使用等价性比较时,值NaN和值-0拥有特殊的行为。NaN永远不会和自己相等,所以你不得不使用 ES6 的Number.isNaN(..)(或者它的填补)。相似地,-0撒谎并假装它和普通的正零相等(即使使用===严格等价——见第四章),所以你不得不使用我们上面建议的某些isNegZero(..)黑科技工具。

在 ES6 中,有一个新工具可以用于测试两个值的绝对等价性,而没有任何这些例外。它称为Object.is(..):

var a = 2 / "foo";
var b = -3 * 0;

Object.is( a, NaN );    // true
Object.is( b, -0 );        // true

Object.is( b, 0 );        // false

对于前 ES6 环境,这是一个相当简单的Object.is(..)填补:

if (!Object.is) {
    Object.is = function(v1, v2) {
        // 测试 `-0`
        if (v1 === 0 && v2 === 0) {
            return 1 / v1 === 1 / v2;
        }
        // 测试 `NaN`
        if (v1 !== v1) {
            return v2 !== v2;
        }
        // 其他情况
        return v1 === v2;
    };
}

Object.is(..)可能不应当用于那些=====已知 安全 的情况(见第四章“强制转换”),因为这些操作符可能高效得多,并且更惯用/常见。Object.is(..)很大程度上是为这些特殊的等价情况准备的。

值与引用

在其他许多语言中,根据你使用的语法,值可以通过值拷贝,也可以通过引用拷贝来赋予/传递。

比如,在 C++中如果你想要把一个number变量传递进一个函数,并使这个变量的值被更新,你可以用int& myNum这样的东西来声明函数参数,当你传入一个变量x时,myNum将是一个 指向x的引用;引用就像一个特殊形式的指针,你得到的是一个指向另一个变量的指针(像一个 别名(alias)) 。如果你没有声明一个引用参数,被传入的值将 总是 被拷贝的,就算它是一个复杂的对象。

在 JavaScript 中,没有指针,并且引用的工作方式有一点儿不同。你不能拥有一个从一个 JS 变量到另一个 JS 变量的引用。这是完全不可能的。

JS 中的引用指向一个(共享的) ,所以如果你有 10 个不同的引用,它们都总是同一个共享值的不同引用;它们没有一个是另一个的引用/指针。

另外,在 JavaScript 中,没有语法上的提示可以控制值和引用的赋值/传递。取而代之的是,值的 类型 用来 唯一 控制值是通过值拷贝,还是引用拷贝来赋予。

让我们来展示一下:

var a = 2;
var b = a; // `b`总是`a`中的值的拷贝
b++;
a; // 2
b; // 3

var c = [1,2,3];
var d = c; // `d`是共享值`[1,2,3]`的引用
d.push( 4 );
c; // [1,2,3,4]
d; // [1,2,3,4]

简单值(也叫基本标量) 总是 通过值拷贝来赋予/传递:nullundefinedstringnumberboolean,以及 ES6 的symbol

复合值——object(包括array,和所有的对象包装器——见第三章)和function——总是 在赋值或传递时创建一个引用的拷贝。

在上面的代码段中,因为2是一个基本标量,a持有一个这个值的初始拷贝,而b被赋予了这个值的另一个拷贝。当改变b时,你根本没有在改变a中的值。

cd两个都 是同一个共享的值[1,2,3]的分离的引用。重要的是,cd对值[1,2,3]的“拥有”程度上是一样的——它们只是同一个值的对等引用。所以,不管使用哪一个引用去修改(.push(4))实际上共享的array值本身,影响的仅仅是这一个共享值,而且这两个引用将会指向新修改的值[1,2,3,4]

因为引用指向的是值本身而不是变量,你不能使用一个引用来改变另一个引用所指向的值:

var a = [1,2,3];
var b = a;
a; // [1,2,3]
b; // [1,2,3]

// 稍后
b = [4,5,6];
a; // [1,2,3]
b; // [4,5,6]

当我们做赋值操作b = [4,5,6]时,我们做的事情绝对不会对a所指向的 位置[1,2,3])造成任何影响。如果那可能的话,b就会是a的指针而不是这个array的引用 —— 但是这样的能力在 JS 中是不存在的!

这样的困惑最常见于函数参数:

function foo(x) {
    x.push( 4 );
    x; // [1,2,3,4]

    // 稍后
    x = [4,5,6];
    x.push( 7 );
    x; // [4,5,6,7]
}

var a = [1,2,3];

foo( a );

a; // [1,2,3,4]  不是  [4,5,6,7]

当我们传入参数a时,它将一份a引用的拷贝赋值给xxa是指向相同的[1,2,3]的不同引用。现在,在函数内部,我们可以使用这个引用来改变值本身(push(4))。但是当我们进行赋值操作x = [4,5,6]时,不可能影响原来的引用a所指向的东西——它仍然指向(已经被修改了的)值[1,2,3,4]

没有办法可以使用x引用来改变a指向哪里。我们只能修改ax共通指向的那个共享值的内容。

要想改变a来使它拥有内容为[4,5,6,7]的值,你不能创建一个新的array并赋值——你必须修改现存的array值:

function foo(x) {
    x.push( 4 );
    x; // [1,2,3,4]

    // 稍后
    x.length = 0; // 原地清空既存的数组
    x.push( 4, 5, 6, 7 );
    x; // [4,5,6,7]
}

var a = [1,2,3];

foo( a );

a; // [4,5,6,7]  不是  [1,2,3,4]

正如你看到的,x.length = 0x.push(4,5,6,7)没有创建一个新的array,但是修改了现存的共享array。所以理所当然地,a引用了新的内容[4,5,6,7]

记住:你不能直接控制/覆盖值拷贝和引用拷贝的行为 —— 这些语义是完全由当前值的类型来控制的。

为了实质上地通过值拷贝传递一个复合值(比如一个array),你需要手动制造一个它的拷贝,使被传递的引用不指向原来的值。比如:

foo( a.slice() );

不带参数的slice(..)方法默认地为这个array制造一个全新的(浅)拷贝。所以,我们传入的引用仅指向拷贝的array,这样foo(..)不会影响a的内容。

反之 —— 传递一个基本标量值,使它的值的变化可见,就像引用那样 —— 你不得不将这个值包装在另一个可以通过引用拷贝来传递的复合值中(objectarray,等等):

function foo(wrapper) {
    wrapper.a = 42;
}

var obj = {
    a: 2
};

foo( obj );

obj.a; // 42

这里,obj作为基本标量属性a的包装。当传递给foo(..)时,一个obj引用的拷贝被传入并设置给wrapper参数。我们现在可以使用wrapper引用来访问这个共享的对象,并更新它的值。在函数完成时,obj.a将被更新为值42

你可能会遇到这样的情况,如果你想要传入一个像2这样的基本标量值的引用,你可以将这个值包装在它的Number对象包装器中(见第三章)。

这个Number对象的引用的拷贝 会被传递给函数是事实,但不幸的是,和你可能期望的不同,拥有一个共享独享的引用不会给你修改这个共享的基本值的能力:

function foo(x) {
    x = x + 1;
    x; // 3
}

var a = 2;
var b = new Number( a ); // 或等价的 `Object(a)`

foo( b );
console.log( b ); // 2, 不是 3

这里的问题是,底层的基本标量值是 不可变的StringBoolean也一样)。如果一个Number对象持有一个基本标量值2,那么这个Number对象就永远不能再持有另一个值;你只能用一个不同的值创建一个全新的Number对象。

x用于表达式x + 1时,底层的基本标量值2被自动地从Number对象中开箱(抽出),所以x = x + 1这一行很微秒地将x从一个共享的Number对象的引用,改变为仅持有加法操作2 + 1的结果3的基本标量值。因此,外面的b仍然引用原来的未被改变/不可变的,持有2Number对象。

可以Number对象上添加属性(只是不要改变它内部的基本值),所以你可间接地通过这些额外的属性交换信息。

不过,这可不太常见;对大多数开发者来说这可能不是一个好的做法。

与其这样使用Number包装器对象,使用早先的代码段中那样的手动对象包装器(obj)要好得多。这不是说像Number这样包装好的对象包装器没有用处——而是说在大多数情况下,你可能应该优先使用基本标量值的形式。

引用十分强大,但是有时候它们碍你的事儿,而有时你会在它们不存在时需要它们。你唯一可以用来控制引用与值拷贝的东西是值本身的类型,所以你必须通过你选用的值的类型来间接地影响赋值/传递行为。

复习

在 JavaScript 中,array仅仅是数字索引的集合,可以容纳任何类型的值。string是某种“类array”,但它们有着不同的行为,如果你想要将它们作为array对待的话,必须要小心。JavaScript 中的数字既包括“整数”也包括浮点数。

几种特殊值被定义在基本类型内部。

null类型只有一个值nullundefined类型同样地只有undefined值。对于任何没有值存在的变量或属性,undefined基本上是默认值。void操作符允许你从任意另一个值中创建undefined值。

number包含几种特殊值,比如NaN(意为“不是一个数字”,但称为“非法数字”更合适);+Infinity-Infinity; 还有-0

简单基本标量(stringnumber等)通过值拷贝进行赋值/传递,而复合值(object等)通过引用拷贝进行赋值/传递。引用与其他语言中的引用/指针不同 —— 它们从不指向其他的变量/引用,而仅指向底层的值。

你不懂 JS:类型与文法 第三章:原生类型

在第一和第二章中,我们几次提到了各种内建类型,通常称为“原生类型”,比如StringNumber。现在让我们来仔细检视它们。

这是最常用的原生类型的一览:

  • String()
  • Number()
  • Boolean()
  • Array()
  • Object()
  • Function()
  • RegExp()
  • Date()
  • Error()
  • Symbol() —— 在 ES6 中被加入的!

如你所见,这些原生类型实际上是内建函数。

如果你拥有像 Java 语言那样的背景,JavaScript 的String()看起来像是你曾经用来创建字符串值的String(..)构造器。所以,你很快就会观察到你可以做这样的事情:

var s = new String( "Hello World!" );

console.log( s.toString() ); // "Hello World!"

这些原生类型的每一种确实可以被用作一个原生类型的构造器。但是被构建的东西可能与你想象的不同:

var a = new String( "abc" );

typeof a; // "object" ... 不是 "String"

a instanceof String; // true

Object.prototype.toString.call( a ); // "[object String]"

创建值的构造器形式(new String("abc"))的结果是一个基本类型值("abc")的包装器对象。

重要的是,typeof显示这些对象不是它们自己的特殊 类型,而是object类型的子类型。

这个包装器对象可以被进一步观察,像这样:

console.log( a );

这个语句的输出会根据你使用的浏览器变化,因为对于开发者的查看,开发者控制台可以自由选择它认为合适的方式来序列化对象。

注意: 在写作本书时,最新版的 Chrome 打印出这样的东西:String {0: "a", 1: "b", 2: "c", length: 3, [[PrimitiveValue]]: "abc"}。但是老版本的 Chrome 曾经只打印出这些:String {0: "a", 1: "b", 2: "c"}。当前最新版的 Firefox 打印String ["a","b","c"],但它曾经以斜体字打印"abc",点击它可以打开对象查看器。当然,这些结果是总频繁变更的,而且你的体验也许不同。

重点是,new String("abc")"abc"创建了一个字符串包装器对象,而不仅是基本类型值"abc"本身。

内部[[Class]]

typeof的结果为"object"的值(比如数组)被额外地打上了一个内部的标签属性[[Class]](请把它考虑为一个内部的分类方法,而非与传统的面向对象编码的类有关)。这个属性不能直接地被访问,但通常可以间接地通过在这个值上借用默认的Object.prototype.toString(..)方法调用来展示。举例来说:

Object.prototype.toString.call( [1,2,3] );            // "[object Array]"

Object.prototype.toString.call( /regex-literal/i );    // "[object RegExp]"

所以,对于这个例子中的数组来说,内部的[[Class]]值是"Array",而对于正则表达式,它是"RegExp"。在大多数情况下,这个内部的[[Class]]值对应于关联这个值的内建的原生类型构造器(见下面的讨论),但事实却不总是这样。

基本类型呢?首先,nullundefined

Object.prototype.toString.call( null );            // "[object Null]"
Object.prototype.toString.call( undefined );    // "[object Undefined]"

你会注意到,不存在Null()Undefined()原生类型构造器,但不管怎样"Null""Undefined"是被暴露出来的内部[[Class]]值。

但是对于像stringnumber,和boolean这样的简单基本类型,实际上会启动另一种行为,通常称为“封箱(boxing)”(见下一节“封箱包装器”):

Object.prototype.toString.call( "abc" );    // "[object String]"
Object.prototype.toString.call( 42 );        // "[object Number]"
Object.prototype.toString.call( true );        // "[object Boolean]"

在这个代码段中,每一个简单基本类型都自动地被它们分别对应的对象包装器封箱,这就是为什么"String""Number",和"Boolean"分别被显示为内部[[Class]]值。

注意: 从 ES5 发展到 ES6 的过程中,这里展示的toString()[[Class]]的行为发生了一点儿改变,但我们会在本系列的 ES6 与未来 一书中讲解它们的细节。

封箱包装器

这些对象包装器服务于一个非常重要的目的。基本类型值没有属性或方法,所以为了访问.length.toString()你需要这个值的对象包装器。值得庆幸的是,JS 将会自动地 封箱(也就是包装)基本类型值来满足这样的访问。

var a = "abc";

a.length; // 3
a.toUpperCase(); // "ABC"

那么,如果你想以通常的方式访问这些字符串值上的属性/方法,比如一个for循环的i < a.length条件,这么做看起来很有道理:一开始就得到一个这个值的对象形式,于是 JS 引擎就不需要隐含地为你创建一个。

但事实证明这是一个坏主意。浏览器们长久以来就对.length这样的常见情况进行性能优化,这意味着如果你试着直接使用对象形式(它们没有被优化过)进行“提前优化”,那么实际上你的程序将会 变慢

一般来说,基本上没有理由直接使用对象形式。让封箱在需要的地方隐含地发生会更好。换句话说,永远也不要做new String("abc")new Number(42)这样的事情——应当总是偏向于使用基本类型字面量"abc"42

对象包装器的坑

如果你 确实 选择要直接使用对象包装器,那么有几个坑你应该注意。

举个例子,考虑Boolean包装的值:

var a = new Boolean( false );

if (!a) {
    console.log( "Oops" ); // never runs
}

这里的问题是,虽然你为值false创建了一个对象包装器,但是对象本身是“truthy”(见第四章),所以使用对象的效果是与使用底层的值false本身相反的,这与通常的期望十分不同。

如果你想手动封箱一个基本类型值,你可以使用Object(..)函数(没有new关键字):

var a = "abc";
var b = new String( a );
var c = Object( a );

typeof a; // "string"
typeof b; // "object"
typeof c; // "object"

b instanceof String; // true
c instanceof String; // true

Object.prototype.toString.call( b ); // "[object String]"
Object.prototype.toString.call( c ); // "[object String]"

再说一遍,通常不鼓励直接使用封箱的包装器对象(比如上面的bc),但你可能会遇到一些它们有用的罕见情况。

开箱

如果你有一个包装器对象,而你想要取出底层的基本类型值,你可以使用valueOf()方法:

var a = new String( "abc" );
var b = new Number( 42 );
var c = new Boolean( true );

a.valueOf(); // "abc"
b.valueOf(); // 42
c.valueOf(); // true

当以一种查询基本类型值的方式使用对象包装器时,开箱也会隐含地发生。这个处理的过程(强制转换)将会在第四章中更详细地讲解,但简单地说:

var a = new String( "abc" );
var b = a + ""; // `b` 拥有开箱后的基本类型值"abc"

typeof a; // "object"
typeof b; // "string"

原生类型作为构造器

对于arrayobjectfunction,和正则表达式值来说,使用字面形式来创建它们的值几乎总是更好的选择,而且字面形式与构造器形式所创建的值是同一种对象(也就是,没有非包装的值)。

正如我们刚刚在上面看到的其他原生类型,除非你真的知道你需要这些构造器形式,一般来说应当避免使用它们,这主要是因为它们会带来一些你可能不会想要对付的异常和陷阱。

Array(..)

var a = new Array( 1, 2, 3 );
a; // [1, 2, 3]

var b = [1, 2, 3];
b; // [1, 2, 3]

注意: Array(..)构造器不要求在它前面使用new关键字。如果你省略它,它也会像你已经使用了一样动作。所以Array(1,2,3)new Array(1,2,3)的结果是一样的。

Array构造器有一种特殊形式,如果它仅仅被传入一个number参数,与将这个值作为数组的 内容 不同,它会被认为是用来“预定数组大小”(嗯,某种意义上)用的长度。

这是个可怕的主意。首先,你会意外地用错这种形式,因为它很容易忘记。

但更重要的是,其实没有预定数组大小这样的东西。你所创建的是一个空数组,并将这个数组的length属性设置为那个指定的数字值。

一个数组在它的值槽上没有明确的值,但是有一个length属性意味着这些值槽是存在的,在 JS 中这是一个诡异的数据结构,它带有一些非常奇怪且令人困惑的行为。可以创建这样的值的能力,完全源自于老旧的,已经废弃的,仅具有历史意义的功能(比如arguments这样的“类数组对象”)。

注意: 带有至少一个“空值槽”的数组经常被称为“稀散数组”。

这是另外一个例子,展示浏览器的开发者控制台在如何表示这样的对象上有所不同,它产生了更多的困惑。

举例来说:

var a = new Array( 3 );

a.length; // 3
a;

在 Chrome 中a的序列化表达是(在本书写作时):[ undefined x 3 ]这真的很不幸。 它暗示着在这个数组的值槽中有三个undefined值,而事实上这样的值槽是不存在的(所谓的“空值槽(empty slots)”——也是一个烂名字!)。

要观察这种不同,试试这段代码:

var a = new Array( 3 );
var b = [ undefined, undefined, undefined ];
var c = [];
c.length = 3;

a;
b;
c;

注意: 正如你在这个例子中看到的c,数组中的空值槽可以在数组的创建之后发生。将数组的length改变为超过它实际定义的槽值的数目,你就隐含地引入了空值槽。事实上,你甚至可以在上面的代码段中调用delete b[1],而这么做将会在b的中间引入一个空值槽。

对于b(在当前的 Chrome 中),你会发现它的序列化表现为[ undefined, undefined, undefined ],与之相对的是ac[ undefined x 3 ]。糊涂了吧?是的,大家都糊涂了。

更糟糕的是,在写作本书时,Firefox 对ac报告[ , , , ]。你发现为什么这使人犯糊涂了吗?仔细看。三个逗号表示有四个值槽,不是我们期望的三个值槽。

什么!? Firefox 在它们的序列化表达的末尾放了一个额外的,,因为在 ES5 中,列表(数组值,属性列表等等)末尾的逗号是允许的(被砍掉并忽略)。所以如果你在你的程序或控制台中敲入[ , , , ]值,你实际上得到的是一个底层为[ , , ]的值(也就是,一个带有三个空值槽的数组)。这种选择,虽然在阅读开发者控制台时使人困惑,但是因为它使拷贝粘贴的时候准确,所以被留了下来。

如果你现在在摇头或翻白眼儿,你并不孤单!(耸肩)

不幸的是,事情越来越糟。比在控制台的输出产生的困惑更糟的是,上面代码段中的ab实际上在有些情况下相同,但在另一些情况下不同

a.join( "-" ); // "--"
b.join( "-" ); // "--"

a.map(function(v,i){ return i; }); // [ undefined x 3 ]
b.map(function(v,i){ return i; }); // [ 0, 1, 2 ]

呃。

a.map(..)调用会 失败 是因为值槽根本就不实际存在,所以map(..)没有东西可以迭代。join(..)的工作方式不同,基本上我们可以认为它是像这样被实现的:

function fakeJoin(arr,connector) {
    var str = "";
    for (var i = 0; i < arr.length; i++) {
        if (i > 0) {
            str += connector;
        }
        if (arr[i] !== undefined) {
            str += arr[i];
        }
    }
    return str;
}

var a = new Array( 3 );
fakeJoin( a, "-" ); // "--"

如你所见,join(..)好用仅仅是因为它 认为 值槽存在,并循环至length值。不管map(..)内部是在做什么,它(显然)没有做出这样的假设,所以源自于奇怪的“空值槽”数组的结果出人意料,而且好像是失败了。

那么,如果你想要 确实 创建一个实际的undefined值的数组(不只是“空值槽”),你如何才能做到呢(除了手动以外)?

var a = Array.apply( null, { length: 3 } );
a; // [ undefined, undefined, undefined ]

糊涂了吧?是的。这里是它大概的工作方式。

apply(..)是一个对所有函数可用的工具方法,它以一种特殊方式调用这个使用它的函数。

第一个参数是一个this对象绑定(在本系列的 this 与对象原型 中有详细讲解),在这里我们不关心它,所以我们将它设置为null。第二个参数应该是一个数组(或 数组的东西——也就是“类数组对象”)。这个“数组”的内容作为这个函数的参数“扩散”开来。

所以,Array.apply(..)在调用Array(..)函数,并将一个值({ length: 3 }对象值)作为它的参数值分散开。

apply(..)内部,我们可以预见这里有另一个for循环(有些像上面的join(..)),它从0开始上升但不包含至length(这个例子中是3)。

对于每一个索引,它从对象中取得相应的键。所以如果这个数组对象参数在apply(..)内部被命名为arr,那么这种属性访问实质上是arr[0]arr[1],和arr[2]。当然,没有一个属性是在{ length: 3 }对象值上存在的,所以这三个属性访问都将返回值undefined

换句话说,调用Array(..)的结局基本上是这样:Array(undefined,undefined,undefined),这就是我们如何得到一个填满undefined值的数组的,而非仅仅是一些(疯狂的)空值槽。

虽然对于创建一个填满undefined值的数组来说,Array.apply( null, { length: 3 } )是一个奇怪而且繁冗的方法,但是它要比使用砸自己的脚似的Array(3)空值槽要可靠和好得 太多了

底线:你 在任何情况下,永远不,也不应该有意地创建并使用诡异的空值槽数组。就别这么干。它们是怪胎。

Object(..)Function(..),和RegExp(..)

Object(..)/Function(..)/RegExp(..)构造器一般来说也是可选的(因此除非是特别的目的,应当避免使用):

var c = new Object();
c.foo = "bar";
c; // { foo: "bar" }

var d = { foo: "bar" };
d; // { foo: "bar" }

var e = new Function( "a", "return a * 2;" );
var f = function(a) { return a * 2; };
function g(a) { return a * 2; }

var h = new RegExp( "^a*b+", "g" );
var i = /^a*b+/g;

几乎没有理由使用new Object()构造器形式,尤其因为它强迫你一个一个地添加属性,而不是像对象的字面形式那样一次添加许多。

Function构造器仅在最最罕见的情况下有用,也就是你需要动态地定义一个函数的参数和/或它的函数体。不要将Function(..)仅仅作为另一种形式的eval(..)。你几乎永远不会需要用这种方式动态定义一个函数。

用字面量形式(/^a*b+/g)定义正则表达式是被大力采用的,不仅因为语法简单,而且还有性能的原因——JS 引擎会在代码执行前预编译并缓存它们。和我们迄今看到的其他构造器形式不同,RegExp(..)有一些合理的用途:用来动态定义一个正则表达式的模式。

var name = "Kyle";
var namePattern = new RegExp( "\\b(?:" + name + ")+\\b", "ig" );

var matches = someText.match( namePattern );

这样的场景在 JS 程序中一次又一次地合法出现,所以你有需要使用new RegExp("pattern","flags")形式。

Date(..)Error(..)

Date(..)Error(..)原生类型构造器要比其他种类的原生类型有用得多,因为它们没有字面量形式。

要创建一个日期对象值,你必须使用new Date()Date(..)构造器接收可选参数值来指定要使用的日期/时间,但是如果省略的话,就会使用当前的日期/时间。

目前你构建一个日期对象的最常见的理由是要得到当前的时间戳(一个有符号整数,从 1970 年 1 月 1 日开始算起的毫秒数)。你可以在一个日期对象实例上调用getTime()得到它。

但是在 ES5 中,一个更简单的方法是调用定义为Date.now()的静态帮助函数。而且在前 ES5 中填补它很容易:

if (!Date.now) {
    Date.now = function(){
        return (new Date()).getTime();
    };
}

注意: 如果你不带new调用Date(),你将会得到一个那个时刻的日期/时间的字符串表达。在语言规范中没有规定这个表达的确切形式,虽然各个浏览器趋向于赞同使用这样的东西:"Fri Jul 18 2014 00:31:02 GMT-0500 (CDT)"

Error(..)构造器(很像上面的Array())在有new与没有new时的行为是相同的。

你想要创建 error 对象的主要原因是,它会将当前的执行栈上下文捕捉进对象中(在大多数 JS 引擎中,在创建后使用只读的.stack属性表示)。这个栈上下文包含函数调用栈和 error 对象被创建时的行号,这使调试这个错误更简单。

典型地,你将与throw操作符一起使用这样的 error 对象:

function foo(x) {
    if (!x) {
        throw new Error( "x wasn't provided" );
    }
    // ..
}

Error 对象实例一般拥有至少一个message属性,有时还有其他属性(你应当将它们作为只读的),比如type。然而,与其检视上面提到的stack属性,最好是在 error 对象上调用toString()(明确地调用,或者是通过强制转换隐含地调用——见第四章)来得到一个格式友好的错误消息。

提示: 技术上讲,除了一般的Error(..)原生类型以外,还有几种特定错误的原生类型:EvalError(..)RangeError(..)ReferenceError(..)SyntaxError(..)TypeError(..),和URIError(..)。但是手动使用这些特定错误原生类型十分少见。如果你的程序确实遭受了一个真实的异常,它们是会自动地被使用的(比如引用一个未声明的变量而得到一个ReferenceError错误)。

Symbol(..)

在 ES6 中,新增了一个基本值类型,称为“Symbol(标志)”。Symbol 是一种特殊的“独一无二”(不是严格保证的!)的值,可以作为对象上的属性使用而几乎不必担心任何冲突。它们主要是为特殊的 ES6 结构的内建行为设计的,但你也可以定义你自己的 symbol。

Symbol 可以用做属性名,但是你不能从你的程序中看到或访问一个 symbol 的实际值,从开发者控制台也不行。例如,如果你在开发者控制台中对一个 Symbol 求值,将会显示Symbol(Symbol.create)之类的东西。

在 ES6 中有几种预定义的 Symbol,做为Symbol函数对象的静态属性访问,比如Symbol.createSymbol.iterator等等。要使用它们,可以这样做:

obj[Symbol.iterator] = function(){ /*..*/ };

要定义你自己的 Symbol,使用Symbol(..)原生类型。Symbol(..)原生类型“构造器”很独特,因为它不允许你将new与它一起使用,这么做会抛出一个错误。

var mysym = Symbol( "my own symbol" );
mysym;                // Symbol(my own symbol)
mysym.toString();    // "Symbol(my own symbol)"
typeof mysym;         // "symbol"

var a = { };
a[mysym] = "foobar";

Object.getOwnPropertySymbols( a );
// [ Symbol(my own symbol) ]

虽然 Symbol 实际上不是私有的(在对象上使用Object.getOwnPropertySymbols(..)反射,揭示了 Symbol 其实是相当公开的),但是它们的主要用途可能是私有属性,或者类似的特殊属性。对于大多数开发者,他们也许会在属性名上加入_下划线前缀,这在经常在惯例上表示:“这是一个私有的/特殊的/内部的属性,别碰!”

注意: Symbol 不是 object,它们是简单的基本标量。

原生类型原型

每一个内建的原生构造器都拥有它自己的.prototype对象——Array.prototypeString.prototype等等。

对于它们特定的对象子类型,这些对象含有独特的行为。

例如,所有的字符串对象,和string基本值的扩展(通过封箱),都可以访问在String.prototype对象上做为方法定义的默认行为。

注意: 做为文档惯例,String.prototype.XYZ会被缩写为String#XYZ,对于其它所有.prototype的属性都是如此。

  • String#indexOf(..):在一个字符串中找出一个子串的位置
  • String#charAt(..):访问一个字符串中某个位置的字符
  • String#substr(..)String#substring(..),和String#slice(..):将字符串的一部分抽取为一个新字符串
  • String#toUpperCase()String#toLowerCase():创建一个转换为大写或小写的新字符串
  • String#trim():创建一个截去开头或结尾空格的新字符串。

这些方法中没有一个是在 原地 修改字符串的。修改(比如大小写变换或去空格)会根据当前的值来创建一个新的值。

有赖于原型委托(见本系列的 this 与对象原型),任何字符串值都可以访问这些方法:

var a = " abc ";

a.indexOf( "c" ); // 3
a.toUpperCase(); // " ABC "
a.trim(); // "abc"

其他构造器的原型包含适用于它们类型的行为,比如Number#toFixed(..)(将一个数字转换为一个固定小数位的字符串)和Array#concat(..)(混合数组)。所有这些函数都可以访问apply(..)call(..),和bind(..),因为Function.prototype定义了它们。

但是,一些原生类型的原型不 仅仅 是单纯的对象:

typeof Function.prototype;            // "function"
Function.prototype();                // 它是一个空函数!

RegExp.prototype.toString();        // "/(?:)/" —— 空的正则表达式
"abc".match( RegExp.prototype );    // [""]

一个特别差劲儿的主意是,你甚至可以修改这些原生类型的原型(不仅仅是你可能熟悉的添加属性):

Array.isArray( Array.prototype );    // true
Array.prototype.push( 1, 2, 3 );    // 3
Array.prototype;                    // [1,2,3]

// 别这么留着它,要不就等着怪事发生吧!
// 将`Array.prototype`重置为空
Array.prototype.length = 0;

如你所见,Function.prototype是一个函数,RegExp.prototype是一个正则表达式,而Array.prototype是一个数组。有趣吧?酷吧?

原型作为默认值

Function.prototype是一个空函数,RegExp.prototype是一个“空”正则表达式(也就是不匹配任何东西),而Array.prototype是一个空数组,这使它们成了可以赋值给变量的,很好的“默认”值——如果这些类型的变量还没有值。

例如:

function isThisCool(vals,fn,rx) {
    vals = vals || Array.prototype;
    fn = fn || Function.prototype;
    rx = rx || RegExp.prototype;

    return rx.test(
        vals.map( fn ).join( "" )
    );
}

isThisCool();        // true

isThisCool(
    ["a","b","c"],
    function(v){ return v.toUpperCase(); },
    /D/
);                    // false

注意: 在 ES6 中,我们不再需要使用vals = vals || ..这样的默认值语法技巧了(见第四章),因为在函数声明中可以通过原生语法为参数设定默认值(见第五章)。

这个方式的一个微小的副作用是,.prototype已经被创建了,而且是内建的,因此它仅被创建 一次。相比之下,使用[]function(){},和/(?:)/这些值本身作为默认值,将会(很可能,要看引擎如何实现)在每次调用isThisCool(..)时重新创建这些值(而且稍可能要回收它们)。这可能会消耗内存/CPU。

另外,要非常小心不要对 后续要被修改的值 使用Array.prototype做为默认值。在这个例子中,vals是只读的,但如果你要在原地对vals进行修改,那你实际上修改的是Array.prototype本身,这将把你引到刚才提到的坑里!

注意: 虽然我们指出了这些原生类型的原型和一些用处,但是依赖它们的时候要小心,更要小心以任何形式修改它们。更多的讨论见附录 A“原生原型”。

复习

JavaScript 为基本类型提供了对象包装器,被称为原生类型(StringNumberBoolean,等等)。这些对象包装器使这些值可以访问每种对象子类型的恰当行为(String#trim()Array#concat(..))。

如果你有一个像"abc"这样的简答基本类型标量,而且你想要访问它的length属性或某些String.prototype方法,JS 会自动地“封箱”这个值(用它所对应种类的对象包装器把它包起来),以满足这样的属性/方法访问。

你不懂 JS:类型与文法 第四章:强制转换(上)

现在我们更全面地了解了 JavaScript 的类型和值,我们将注意力转向一个极具争议的话题:强制转换。

正如我们在第一章中提到的,关于强制转换到底是一个有用的特性,还是一个语言设计上的缺陷(或位于两者之间!),早就开始就争论不休了。如果你读过关于 JS 的其他书籍,你就会知道流行在世面上那种淹没一切的 声音:强制转换是魔法,是邪恶的,令人困惑的,而且就是彻头彻尾的坏主意。

本着这个系列丛书的总体精神,我认为你应当直面你不理解的东西并设法更全面地 搞懂它。而不是因为大家都这样做,或是你曾经被一些怪东西咬到就逃避强制转换。

我们的目标是全面地探索强制转换的优点和缺点(是的,它们 优点!),这样你就能在程序中对它是否合适做出明智的决定。

转换值

将一个值从一个类型明确地转换到另一个类型通常称为“类型转换(type casting)”,当这个操作隐含地完成时称为“强制转换(coercion)”(根据一个值如何被使用的规则来强制它变换类型)。

注意: 这可能不明显,但是 JavaScript 强制转换总是得到基本标量值的一种,比如stringnumber,或boolean。没有强制转换可以得到像objectfunction这样的复杂值。第三章讲解了“封箱”,它将一个基本类型标量值包装在它们相应的object中,但在准确的意义上这不是真正的强制转换。

另一种区别这些术语的常见方法是:“类型转换(type casting/conversion)”发生在静态类型语言的编译时,而“类型强制转换(type coercion)”是动态类型语言的运行时转换。

然而,在 JavaScript 中,大多数人将所有这些类型的转换都称为 强制转换(coercion),所以我偏好的区别方式是使用“隐含强制转换(implicit coercion)”与“明确强制转换(explicit coercion)”。

其中的区别应当是很明显的:在观察代码时如果一个类型转换明显是有意为之的,那么它就是“明确强制转换”,而如果这个类型转换是做为其他操作的不那么明显的副作用发生的,那么它就是“隐含强制转换”。

例如,考虑这两种强制转换的方式:

var a = 42;

var b = a + "";            // 隐含强制转换

var c = String( a );    // 明确强制转换

对于b来说,强制转换是隐含地发生的,因为如果与+操作符组合的操作数之一是一个string值(""),这将使+操作成为一个string连接(将两个字符串加在一起),而string连接的 一个(隐藏的)副作用a中的值42强制转换为它的string等价物:"42"

相比之下,String(..)函数使一切相当明显,它明确地取得a中的值,并把它强制转换为一个string表现形式。

两种方式都能达到相同的效果:从42变成"42"。但它们 如何 达到这种效果,才是关于 JavaScript 强制转换的热烈争论的核心。

注意: 技术上讲,这里有一些在语法形式区别之上的,行为上的微妙区别。我们将在本章稍后,“隐含:Strings <--> Numbers”一节中仔细讲解。

“明确地”,“隐含地”,或“明显地”和“隐藏的副作用”这些术语,是 相对的

如果你确切地知道a + ""是在做什么,并且你有意地这么做来强制转换一个string,你可能感觉这个操作已经足够“明确”了。相反,如果你从没见过String(..)函数被用于string强制转换,那么对你来说它的行为可能看起来太过隐蔽而让你感到“隐含”。

但我们是基于一个 大众的,充分了解,但不是专家或 JS 规范爱好者的 开发者的观点来讨论“明确”与“隐含”的。无论你的程度如何,或是没有在这个范畴内准确地找到自己,你都需要根据我们在这里的观察方式,相应地调整你的角度。

记住:我们自己写代码而也只有我们自己会读它,通常是很少见的。即便你是一个精通 JS 里里外外的专家,也要考虑一个经验没那么丰富的队友在读你的代码时感受如何。对于他们和对于你来说,“明确”或“隐含”的意义相同吗?

抽象值操作

在我们可以探究 明确隐含 强制转换之前,我们需要学习一些基本规则,是它们控制着值如何 变成 一个stringnumber,或boolean的。ES5 语言规范的第 9 部分用值的变形规则定义了几种“抽象操作”(“仅供内部使用的操作”的高大上说法)。我们将特别关注于:ToStringToNumber,和ToBoolean,并稍稍关注一下ToPrimitive

ToString

当任何一个非string值被强制转换为一个string表现形式时,这个转换的过程是由语言规范的 9.8 部分的ToString抽象操作处理的。

内建的基本类型值拥有自然的字符串化形式:null变为"null"undefined变为"undefined"true变为"true"number一般会以你期望的自然方式表达,但正如我们在第二章中讨论的,非常小或非常大的number将会以指数形式表达:

// `1.07`乘以`1000`,7 次
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;

// 7 次乘以 3 位 => 21 位
a.toString(); // "1.07e21"

对于普通的对象,除非你指定你自己的,默认的toString()(可以在Object.prototype.toString()找到)将返回 internal [[Class]](见第三章),例如"[object Object]"

但正如早先所展示的,如果一个对象上拥有它自己的toString()方法,而你又以一种类似string的方式使用这个对象,那么它的toString()将会被自动调用,而且这个调用的string结果将被使用。

注意: 技术上讲,一个对象被强制转换为一个string要通过ToPrimitive抽象操作(ES5 语言规范,9.1 部分),但是那其中的微妙细节将会在本章稍后的ToNumber部分中讲解,所以我们在这里先跳过它。

数组拥有一个覆盖版本的默认toString(),将数组字符串化为它所有的值(每个都字符串化)的(字符串)连接,并用","分割每个值。

var a = [1,2,3];

a.toString(); // "1,2,3"

重申一次,toString()可以明确地被调用,也可以通过在一个需要string的上下文环境中使用一个非string来自动地被调用。

JSON 字符串化

另一种看起来与ToString密切相关的操作是,使用JSON.stringify(..)工具将一个值序列化为一个 JSON 兼容的string值。

重要的是要注意,这种字符串化与强制转换并不完全是同一种东西。但是因为它与上面讲的ToString规则有关联,我们将在这里稍微转移一下话题,来讲解 JSON 字符串化行为。

对于最简单的值,JSON 字符串化行为基本上和toString()转换是相同的,除了序列化的结果 总是一个string

JSON.stringify( 42 );    // "42"
JSON.stringify( "42" );    // ""42"" (一个包含双引号的字符串)
JSON.stringify( null );    // "null"
JSON.stringify( true );    // "true"

任何 JSON 安全 的值都可以被JSON.stringify(..)字符串化。但是什么是 JSON 安全的?任何可以用 JSON 表现形式合法表达的值。

考虑 JSON 安全的值可能更容易一些。一些例子是:undefinedfunction,(ES6+)symbol,和带有循环引用的object(一个对象结构中的属性互相引用而造成了一个永不终结的循环)。对于标准的 JSON 结构来说这些都是非法的值,主要是因为他们不能移植到消费 JSON 值的其他语言中。

JSON.stringify(..)工具在遇到undefinedfunction,和symbol时将会自动地忽略它们。如果在一个array中遇到这样的值,它会被替换为null(这样数组的位置信息就不会改变)。如果在一个object的属性中遇到这样的值,这个属性会被简单地剔除掉。

考虑下面的代码:

JSON.stringify( undefined );                    // undefined
JSON.stringify( function(){} );                    // undefined

JSON.stringify( [1,undefined,function(){},4] );    // "[1,null,null,4]"
JSON.stringify( { a:2, b:function(){} } );        // "{"a":2}"

但如果你试着JSON.stringify(..)一个带有循环引用的object,就会抛出一个错误。

JSON 字符串化有一个特殊行为,如果一个object值定义了一个toJSON()方法,这个方法将会被首先调用,以取得用于序列化的值。

如果你打算 JSON 字符串化一个可能含有非法 JSON 值的对象,或者如果这个对象中正好有不适于序列化的值,那么你就应当为它定义一个toJSON()方法,返回这个object的一个 JSON 安全 版本。

例如:

var o = { };

var a = {
    b: 42,
    c: o,
    d: function(){}
};

// 在`a`内部制造一个循环引用
o.e = a;

// 这回因循环引用而抛出一个错误
// JSON.stringify( a );

// 自定义一个 JSON 值序列化
a.toJSON = function() {
    // 序列化仅包含属性`b`
    return { b: this.b };
};

JSON.stringify( a ); // "{"b":42}"

一个很常见的误解是,toJSON()应当返回一个 JSON 字符串化的表现形式。这可能是不正确的,除非你事实上想要字符串化string本身(通常不会!)。toJSON()应当返回合适的实际普通值(无论什么类型),而JSON.stringify(..)自己会处理字符串化。

换句话说,toJSON()应当被翻译为:“变为一个适用于字符串化的 JSON 安全的值”,不是像许多开发者错误认为的那样,“变为一个 JSON 字符串”。

考虑下面的代码:

var a = {
    val: [1,2,3],

    // 可能正确!
    toJSON: function(){
        return this.val.slice( 1 );
    }
};

var b = {
    val: [1,2,3],

    // 可能不正确!
    toJSON: function(){
        return "[" +
            this.val.slice( 1 ).join() +
        "]";
    }
};

JSON.stringify( a ); // "[2,3]"

JSON.stringify( b ); // ""[2,3]""

在第二个调用中,我们字符串化了返回的string而不是array本身,这可能不是我们想要做的。

既然我们说到了JSON.stringify(..),那么就让我们来讨论一些不那么广为人知,但是仍然很有用的功能吧。

JSON.stringify(..)的第二个参数值是可选的,它称为 替换器(replacer)。这个参数值既可以是一个array也可以是一个function。与toJSON()为序列化准备一个值的方式类似,它提供一种过滤机制,指出一个object的哪一个属性应该或不应该被包含在序列化形式中,来自定义这个object的递归序列化行为。

如果 替换器 是一个array,那么它应当是一个stringarray,它的每一个元素指定了允许被包含在这个object的序列化形式中的属性名称。如果一个属性不存在于这个列表中,那么它就会被跳过。

如果 替换器 是一个function,那么它会为object本身而被调用一次,并且为这个object中的每个属性都被调用一次,而且每次都被传入两个参数值,keyvalue。要在序列化中跳过一个 key,可以返回undefined。否则,就返回被提供的 value

var a = {
    b: 42,
    c: "42",
    d: [1,2,3]
};

JSON.stringify( a, ["b","c"] ); // "{"b":42,"c":"42"}"

JSON.stringify( a, function(k,v){
    if (k !== "c") return v;
} );
// "{"b":42,"d":[1,2,3]}"

注意:function替换器 的情况下,第一次调用时 key 参数kundefined(而对象a本身会被传入)。if语句会 过滤掉 名称为c的属性。字符串化是递归的,所以数组[1,2,3]会将它的每一个值(12,和3)都作为v传递给 替换器,并将索引值(01,和2)作为k

JSON.stringify(..)还可以接收第三个可选参数值,称为 填充符(space),在对人类友好的输出中它被用做缩进。填充符 可以是一个正整数,用来指示每一级缩进中应当使用多少个空格字符。或者,填充符 可以是一个string,这时每一级缩进将会使用它的前十个字符。

var a = {
    b: 42,
    c: "42",
    d: [1,2,3]
};

JSON.stringify( a, null, 3 );
// "{
//    "b": 42,
//    "c": "42",
//    "d": [
//       1,
//       2,
//       3
//    ]
// }"

JSON.stringify( a, null, "-----" );
// "{
// -----"b": 42,
// -----"c": "42",
// -----"d": [
// ----------1,
// ----------2,
// ----------3
// -----]
// }"

记住,JSON.stringify(..)并不直接是一种强制转换的形式。但是,我们在这里讨论它,是由于两个与ToString强制转换有关联的行为:

  1. stringnumberboolean,和null值在 JSON 字符串化时,与它们通过ToString抽象操作的规则强制转换为string值的方式基本上是相同的。
  2. 如果传递一个object值给JSON.stringify(..),而这个object上拥有一个toJSON()方法,那么在字符串化之前,toJSON()就会被自动调用来将这个值(某种意义上)“强制转换”为 JSON 安全 的。

ToNumber

如果任何非number值,以一种要求它是number的方式被使用,比如数学操作,就会发生 ES5 语言规范在 9.3 部分定义的ToNumber抽象操作。

例如,true变为1false变为0undefined变为NaN,而(奇怪的是)null变为0

对于一个string值来说,ToNumber工作起来很大程度上与数字字面量的规则/语法很相似(见第三章)。如果它失败了,结果将是NaN(而不是number字面量中会出现的语法错误)。一个不同之处的例子是,在这个操作中0前缀的八进制数不会被作为八进制数来处理(而仅作为普通的十进制小数),虽然这样的八进制数作为number字面量是合法的。

注意: number字面量文法与用于string值的ToNumber间的区别极其微妙,在这里就不进一步讲解了。更多的信息可以参考 ES 语言规范的 9.3.1 部分。

对象(以及数组)将会首先被转换为它们的基本类型值的等价物,而后这个结果值(如果它还不是一个number基本类型)会根据刚才提到的ToNumber规则被强制转换为一个number

为了转换为基本类型值的等价物,ToPrimitive抽象操作(ES5 语言规范,9.1 部分)将会查询这个值(使用内部的DefaultValue操作 —— ES5 语言规范,8.12.8 部分),看它有没有valueOf()方法。如果valueOf()可用并且它返回一个基本类型值,那么 这个 值就将用于强制转换。如果不是这样,但toString()可用,那么就由它来提供用于强制转换的值。

如果这两种操作都没提供一个基本类型值,就会抛出一个TypeError

在 ES5 中,你可以创建这样一个不可强制转换的对象 —— 没有valueOf()toString() —— 如果他的[[Prototype]]的值为null,这通常是通过Object.create(null)来创建的。关于[[Prototype]]的详细信息参见本系列的 this 与对象原型

注意: 我们会在本章稍后讲解如何强制转换至number,但对于下面的代码段,想象Number(..)函数就是那样做的。

考虑如下代码:

var a = {
    valueOf: function(){
        return "42";
    }
};

var b = {
    toString: function(){
        return "42";
    }
};

var c = [4,2];
c.toString = function(){
    return this.join( "" );    // "42"
};

Number( a );            // 42
Number( b );            // 42
Number( c );            // 42
Number( "" );            // 0
Number( [] );            // 0
Number( [ "abc" ] );    // NaN

ToBoolean

下面,让我们聊一聊在 JS 中boolean如何动作。世面上关于这个话题有 许多的困惑和误解,所以集中注意力!

首先而且最重要的是,JS 实际上拥有truefalse关键字,而且它们的行为正如你所期望的boolean值一样。一个常见的误解是,值10true/false是相同的。虽然这可能在其他语言中是成立的,但在 JS 中number就是number,而boolean就是boolean。你可以将1强制转换为true(或反之),或将0强制转换为false(或反之)。但它们不是相同的。

Falsy 值

但这还不是故事的结尾。我们需要讨论一下,除了这两个boolean值以外,当你把其他值强制转换为它们的boolean等价物时如何动作。

所有的 JavaScript 值都可以被划分进两个类别:

  1. 如果被强制转换为boolean,将成为false的值
  2. 其它的一切值(很明显将变为true

我不是在出洋相。JS 语言规范给那些在强制转换为boolean值时将会变为false的值定义了一个明确的,小范围的列表。

我们如何才能知道这个列表中的值是什么?在 ES5 语言规范中,9.2 部分定义了一个ToBoolean抽象操作,它讲述了对所有可能的值而言,当你试着强制转换它们为 boolean 时究竟会发生什么。

从这个表格中,我们得到了下面所谓的“falsy”值列表:

  • undefined
  • null
  • false
  • +0, -0, and NaN
  • ""

就是这些。如果一个值在这个列表中,它就是一个“falsy”值,而且当你在它上面进行boolean强制转换时它会转换为false

通过逻辑上的推论,如果一个值 在这个列表中,那么它一定在 另一个列表 中,也就是我们称为“truthy”值的列表。但是 JS 没有真正定义一个“truthy”列表。它给出了一些例子,比如它说所有的对象都是 truthy,但是语言规范大致上暗示着:任何没有明确地存在于 falsy 列表中的东西,都是 truthy

Falsy 对象

等一下,这一节的标题听起来简直是矛盾的。我 刚刚才说过 语言规范将所有对象称为 truthy,对吧?应该没有“falsy 对象”这样的东西。

这会是什么意思呢?

它可能诱使你认为它意味着一个包装了 falsy 值(比如""0false)的对象包装器(见第三章)。但别掉到这个 陷阱 中。

注意: 这个可能是一个语言规范的微妙笑话。

考虑下面的代码:

var a = new Boolean( false );
var b = new Number( 0 );
var c = new String( "" );

我们知道这三个值都是包装了明显是 falsy 值的对象(见第三章)。但这些对象是作为true还是作为false动作呢?这很容易回答:

var d = Boolean( a && b && c );

d; // true

所以,三个都作为true动作,这是唯一能使d得到true的方法。

提示: 注意包在a && b && c表达式外面的Boolean( .. ) —— 你可能想知道为什么它在这儿。我们会在本章稍后回到这个话题,所以先做个心理准备。为了先睹为快,你可以自己试试如果没有Boolean( .. )调用而只有d = a && b && cd是什么。

那么,如果“falsy 对象” 不是包装着 falsy 值的对象,它们是什么鬼东西?

刁钻的地方在于,它们可以出现在你的 JS 程序中,但它们实际上不是 JavaScript 本身的一部分。

什么!?

有些特定的情况,在普通的 JS 语义之上,浏览器已经创建了它们自己的某种 外来 值的行为,也就是这种“falsy 对象”的想法。

一个“falsy 对象”看起来和动起来都像一个普通对象(属性,等等)的值,但是当你强制转换它为一个boolean时,它会变为一个false值。

为什么!?

最著名的例子是document.all:一个 由 DOM(不是 JS 引擎本身) 给你的 JS 程序提供的类数组(对象),它向你的 JS 程序暴露你页面上的元素。它 曾经 像一个普通对象那样动作 —— 是一个 truthy。但不再是了。

document.all本身从来就不是“标准的”,而且从很早以前就被废弃/抛弃了。

“那他们就不能删掉它吗?” 对不起,想得不错。但愿它们能。但是世面上有太多的遗产 JS 代码库依赖于它。

那么,为什么使它像 falsy 一样动作?因为从document.allboolean的强制转换(比如在if语句中)几乎总是用来检测老的,非标准的 IE。

IE 从很早以前就开始顺应规范了,而且在许多情况下它在推动 web 向前发展的作用和其他浏览器一样多,甚至更多。但是所有那些老旧的if (document.all) { /* it's IE */ }代码依然留在世面上,而且大多数可能永远都不会消失。所有这些遗产代码依然假设它们运行在那些给 IE 用户带来差劲儿的浏览体验的,几十年前的老 IE 上,

所以,我们不能完全移除document.all,但是 IE 不再想让if (document.all) { .. }代码继续工作了,这样现代 IE 的用户就能得到新的,符合标准的代码逻辑。

“我们应当怎么做?” “我知道了!让我们黑进 JS 的类型系统并假装document.all是 falsy!”

呃。这很烂。这是一个大多数 JS 开发者们都不理解的疯狂的坑。但是其它的替代方案(对上面两败俱伤的问题什么都不做)还要烂得 多那么一点点

所以……这就是我们得到的:由浏览器给 JavaScript 添加的疯狂,非标准的“falsy 对象”。耶!

Truthy 值

回到 truthy 列表。到底什么是 truthy 值?记住:如果一个值不在 falsy 列表中,它就是 truthy

考虑下面代码:

var a = "false";
var b = "0";
var c = "''";

var d = Boolean( a && b && c );

d;

你期望这里的d是什么值?它要么是true要么是false

它是true。为什么?因为尽管这些string值的内容看起来是 falsy 值,但是string值本身都是 truthy,而这是因为在 falsy 列表中""是唯一的string值。

那么这些呢?

var a = [];                // 空数组 -- truthy 还是 falsy?
var b = {};                // 空对象 -- truthy 还是 falsy?
var c = function(){};    // 空函数 -- truthy 还是 falsy?

var d = Boolean( a && b && c );

d;

是的,你猜到了,这里的d依然是true。为什么?和前面的原因一样。尽管它们看起来像,但是[]{},和function(){} 不在 falsy 列表中,因此它们是 truthy 值。

换句话说,truthy 列表是无限长的。不可能制成一个这样的列表。你只能制造一个 falsy 列表并查询它。

花五分钟,把 falsy 列表写在便利贴上,然后粘在你的电脑显示器上,或者如果你愿意就记住它。不管哪种方法,你都可以在自己需要的时候通过简单地查询一个值是否在 falsy 列表中,来构建一个虚拟的 truthy 列表。

truthy 和 falsy 的重要性在于,理解如果一个值在被(明确地或隐含地)强制转换为boolean值的话,它将如何动作。现在你的大脑中有了这两个列表,我们可以深入强制转换的例子本身了。

明确的强制转换

明确的 强制转换指的是明显且明确的类型转换。对于大多数开发者来说,有很多类型转换的用法可以清楚地归类于这种 明确的 强制转换。

我们在这里的目标是,在我们的代码中指明一些模式,在这些模式中我们可以清楚明白地将一个值从一种类型转换至另一种类型,以确保不给未来将读到这段代码的开发者留下任何坑。我们越明确,后来的人就越容易读懂我们的代码,也不必费太多的力气去理解我们的意图。

关于 明确的 强制转换可能很难找到什么主要的不同意见,因为它与被广泛接受的静态类型语言中的类型转换的工作方式非常接近。因此,我们理所当然地认为(暂且) 明确的 强制转换可以被认同为不是邪恶的,或没有争议的。虽然我们稍后会回到这个话题。

明确地:Strings <--> Numbers

我们将从最简单,也许是最常见强制转换操作开始:将值在stringnumber表现形式之间进行强制转换。

为了在stringnumber之间进行强制转换,我们使用内建的String(..)Number(..)函数(我们在第三章中所指的“原生构造器”),但 非常重要的是,我们不在它们前面使用new关键字。这样,我们就不是在创建对象包装器。

取而代之的是,我们实际上在两种类型之间进行 明确地强制转换

var a = 42;
var b = String( a );

var c = "3.14";
var d = Number( c );

b; // "42"
d; // 3.14

String(..)使用早先讨论的ToString操作的规则,将任意其它的值强制转换为一个基本类型的string值。Number(..)使用早先讨论过的ToNumber操作的规则,将任意其他的值强制转换为一个基本类型的number值。

我称此为 明确的 强制转换是因为,一般对于大多数开发者来说这是十分明显的:这些操作的最终结果是适当的类型转换。

实际上,这种用法看起来与其他的静态类型语言中的用法非常相像。

举个例子,在 C/C++中,你既可以说(int)x也可以说int(x),而且它们都将x中的值转换为一个整数。两种形式都是合法的,但是许多人偏向于后者,它看起来有点儿像一个函数调用。在 JavaScript 中,当你说Number(x)时,它看起来极其相似。在 JS 中它实际上是一个函数调用这个事实重要吗?并非如此。

除了String(..)Number(..),还有其他的方法可以把这些值在stringnumber之间进行“明确地”转换:

var a = 42;
var b = a.toString();

var c = "3.14";
var d = +c;

b; // "42"
d; // 3.14

调用a.toString()在表面上是明确的(“toString”意味着“变成一个字符串”是很明白的),但是这里有一些藏起来的隐含性。toString()不能在像42这样的 基本类型 值上调用。所以 JS 会自动地将42“封箱”在一个对象包装器中(见第三章),这样toString()就可以针对这个对象调用。换句话讲,你可能会叫它“明确的隐含”。

这里的+c+操作符的 一元操作符(操作符只有一个操作数)形式。取代进行数学加法(或字符串连接 —— 见下面的讨论)的是,一元的+明确地将它的操作数(c)强制转换为一个number值。

+c明确的 强制转换吗?这要看你的经验和角度。如果你知道(现在你知道了!)一元+明确地意味着number强制转换,那么它就是相当明确和明显的。但是,如果你以前从没见过它,那么它看起来就极其困惑,晦涩,带有隐含的副作用,等等。

注意: 在开源的 JS 社区中一般被接受的观点是,一元+是一个 明确的 强制转换形式。

即使你真的喜欢+c这种形式,它绝对会在有的地方看起来非常令人困惑。考虑下面的代码:

var c = "3.14";
var d = 5+ +c;

d; // 8.14

一元-操作符也像+一样进行强制转换,但它还会翻转数字的符号。但是你不能放两个减号--来使符号翻转回来,因为那将被解释为递减操作符。取代它的是,你需要这么做:- -"3.14",在两个减号之间加入空格,这将会使强制转换的结果为3.14

你可能会想到所有种类的可怕组合 —— 一个二元操作符挨着另一个操作符的一元形式。这里有另一个疯狂的例子:

1 + - + + + - + 1;    // 2

当一个一元+(或-)紧邻其他操作符时,你应当强烈地考虑避免使用它。虽然上面的代码可以工作,但几乎全世界都认为它是一个坏主意。即使是d = +c(或者d =+ c!)都太容易与d += c像混淆了,而后者完全是不同的东西!

注意: 一元+的另一个极端使人困惑的地方是,被用于紧挨着另一个将要作为++递增操作符和--递减操作符的操作数。例如:a +++ba + ++b,和a + + +b。更多关于++的信息,参见第五章的“表达式副作用”。

记住,我们正努力变得明确并 减少 困惑,不是把事情弄得更糟!

Datenumber

另一个一元+操作符的常见用法是将一个Date对象强制转换为一个number,其结果是这个日期/时间值的 unix 时间戳(从世界协调时间的 1970 年 1 月 1 日 0 点开始计算,经过的毫秒数)表现形式:

var d = new Date( "Mon, 18 Aug 2014 08:53:06 CDT" );

+d; // 1408369986000

这种习惯性用法经常用于取得当前的 现在 时刻的时间戳,比如:

var timestamp = +new Date();

注意: 一些开发者知道一个 JavaScript 中的特别的语法“技巧”,就是在构造器调用(一个带有new的函数调用)中如果没有参数值要传递的话,()可选的。所以你可能遇到var timestamp = +new Date;形式。然而,不是所有的开发者都同意忽略()可以增强可读性,因为它是一种不寻常的语法特例,只能适用于new fn()调用形式,而不能用于普通的fn()调用形式。

但强制转换不是从Date对象中取得时间戳的唯一方法。一个不使用强制转换的方式可能更好,因为它更加明确:

var timestamp = new Date().getTime();
// var timestamp = (new Date()).getTime();
// var timestamp = (new Date).getTime();

但是一个 更更好的 不使用强制转换的选择是使用 ES5 加入的Date.now()静态函数:

var timestamp = Date.now();

而且如果你想要为老版本的浏览器填补Date.now()的话,也十分简单:

if (!Date.now) {
    Date.now = function() {
        return +new Date();
    };
}

我推荐跳过与日期有关的强制转换形式。使用Date.now()来取得当前 现在 的时间戳,而使用new Date( .. ).getTime()来取得一个需要你指定的 非现在 日期/时间的时间戳。

奇异的~

一个经常被忽视并通常让人糊涂的 JS 强制操作符是波浪线~操作符(也叫“按位取反”,“比特非”)。许多理解它在做什么的人也总是想要避开它。但是为了坚持我们在本书和本系列中的精神,让我们深入并找出~是否有一些对我们有用的东西。

在第二章的“32 位(有符号)整数”一节,我们讲解了在 JS 中位操作符是如何仅为 32 位操作定义的,这意味着我们强制它们的操作数遵循 32 位值的表现形式。这个规则如何发生是由ToInt32抽象操作(ES5 语言规范,9.5 部分)控制的。

ToInt32首先进行ToNumber强制转换,这就是说如果值是"123",它在ToInt32规则实施之前会首先变成123

虽然它本身没有 技术上进行 强制转换(因为类型没有改变),但对一些特定的特殊number值使用位操作符(比如|~)会产生一种强制转换效果,这种效果的结果是一个不同的number值。

举例来说,让我们首先考虑惯用的空操作0 | x(在第二种章有展示)中使用的|“比特或”操作符,它实质上仅仅进行ToInt32转换:

0 | -0;            // 0
0 | NaN;        // 0
0 | Infinity;    // 0
0 | -Infinity;    // 0

这些特殊的数字是不可用 32 位表现的(因为它们源自 64 位的 IEEE 754 标准 —— 见第二章),所以ToInt32将这些值的结果指定为0

有争议的是,0 | __是否是一种ToInt32强制转换操作的 明确的 形式,还是更倾向于 隐含。从语言规范的角度来说,毫无疑问是 明确的,但是如果你没有在这样的层次上理解位操作,它就可能看起来有点像 隐含的 魔法。不管怎样,为了与本章中其他的断言保持一致,我们称它为 明确的

那么,让我们把注意力转回~~操作符首先将值“强制转换”为一个 32 位number值,然后实施按位取反(翻转每一个比特位)。

注意: 这与!不仅强制转换它的值为boolean而且还翻转它的每一位很相似(见后面关于“一元!”的讨论)。

但是……什么!?为什么我们要关心被翻转的比特位?这是一些相当特殊的,微妙的东西。JS 开发者需要推理个别比特位是十分少见的。

另一种考虑~定义的方法是,~源自学校中的计算机科学/离散数学:~进行二进制取补操作。太好了,谢谢,我完全明白了!

我们再试一次:~x大致与-(x+1)相同。这很奇怪,但是稍微容易推理一些。所以:

~42;    // -(42+1) ==> -43

你可能还在想~这个鬼东西到底和什么有关,或者对于强制转换的讨论它究竟有什么要紧。让我们快速进入要点。

考虑一下-(x+1)。通过进行这个操作,能够产生结果0(或者从技术上说-0!)的唯一的值是什么?-1。换句话说,~用于一个范围的number值时,将会为输入值-1产生一个 falsy(很容易强制转换为false)的0,而为任意其他的输入产生 truthy 的number

为什么这要紧?

-1通常称为一个“哨兵值”,它基本上意味着一个在同类型值(number)的更大的集合中被赋予了任意的语义。在 C 语言中许多函数使用哨兵值-1,它们返回>= 0的值表示“成功”,返回-1表示“失败”。

JavaScript 在定义string操作indexOf(..)时采纳了这种先例,它搜索一个子字符串,如果找到就返回它从 0 开始计算的索引位置,没有找到的话就返回-1

这样的情况很常见:不仅仅将indexOf(..)作为取得位置的操作,而且作为检查一个子字符串存在/不存在于另一个string中的boolean值。这就是开发者们通常如何进行这样的检查:

var a = "Hello World";

if (a.indexOf( "lo" ) >= 0) {    // true
    // 找到了!
}
if (a.indexOf( "lo" ) != -1) {    // true
    // 找到了
}

if (a.indexOf( "ol" ) < 0) {    // true
    // 没找到!
}
if (a.indexOf( "ol" ) == -1) {    // true
    // 没找到!
}

我感觉看着>= 0== -1有些恶心。它基本上是一种“抽象泄漏”,这里它将底层的实现行为 —— 使用哨兵值-1表示“失败” —— 泄漏到我的代码中。我倒是乐意隐藏这样的细节。

现在,我们终于看到为什~可以帮到我们了!将~indexOf()一起使用可以将值“强制转换”(实际上只是变形)为 可以适当地强制转换为boolean的值

var a = "Hello World";

~a.indexOf( "lo" );            // -4   <-- truthy!

if (~a.indexOf( "lo" )) {    // true
    // 找到了!
}

~a.indexOf( "ol" );            // 0    <-- falsy!
!~a.indexOf( "ol" );        // true

if (!~a.indexOf( "ol" )) {    // true
    // 没找到!
}

~拿到indexOf(..)的返回值并将它变形:对于“失败”的-1我们得到 falsy 的0,而其他的值都是 truthy。

注意: ~的假想算法-(x+1)暗示着~-1-0,但是实际上它产生0,因为底层的操作其实是按位的,不是数学操作。

技术上将,if (~a.indexOf(..))仍然依靠 隐含的 强制转换将它的结果0变为false或非零变为true。但总的来说,对我而言~更像一种 明确的 强制转换机制,只要你知道在这种惯用法中它的意图是什么。

我感觉这样的代码要比前面凌乱的>= 0 / == -1更干净。

截断比特位

在你遇到的代码中,还有一个地方可能出现~:一些开发者使用双波浪线~~来截断一个number的小数部分(也就是,将它“强制转换”为一个“整数”)。这通常(虽然是错误的)被说成与调用Math.floor(..)的结果相同。

~ ~的工作方式是,第一个~实施ToInt32“强制转换”并进行按位取反,然后第二个~进行另一次按位取反,将每一个比特位都翻转回原来的状态。于是最终的结果就是ToInt32“强制转换”(也叫截断)。

注意: ~~的按位双翻转,与双否定!!的行为非常相似,它将在稍后的“明确地:* --> Boolean”一节中讲解。

然而,~~需要一些注意/澄清。首先,它仅在 32 位值上可以可靠地工作。但更重要的是,它在负数上工作的方式与Math.floor(..)不同!

Math.floor( -49.6 );    // -50
~~-49.6;                // -49

Math.floor(..)的不同放在一边,~~x可以将值截断为一个(32 位)整数。但是x | 0也可以,而且看起来还(稍微)省事儿 一些。

那么,为什么你可能会选择~~x而不是x | 0?操作符优先权(见第五章):

~~1E20 / 10;        // 166199296

1E20 | 0 / 10;        // 1661992960
(1E20 | 0) / 10;    // 166199296

正如这里给出的其他建议一样,仅在读/写这样的代码的每一个人都知道这些操作符如何工作的情况下,才将~~~作为“强制转换”和将值变形的明确机制。

明确地:解析数字字符串

将一个string强制转换为一个number的类似结果,可以通过从string的字符内容中解析(parsing)出一个number得到。然而在这种解析和我们上面讲解的类型转换之间存在着区别。

考虑下面的代码:

var a = "42";
var b = "42px";

Number( a );    // 42
parseInt( a );    // 42

Number( b );    // NaN
parseInt( b );    // 42

从一个字符串中解析出一个数字是 容忍 非数字字符的 —— 从左到右,如果遇到非数字字符就停止解析 —— 而强制转换是 不容忍 并且会失败而得出值NaN

解析不应当被视为强制转换的替代品。这两种任务虽然相似,但是有着不同的目的。当你不知道/不关心右手边可能有什么其他的非数字字符时,你可以将一个string作为number解析。当只有数字才是可接受的值,而且像"42px"这样的东西作为数字应当被排处时,就强制转换一个string(变为一个number)。

提示: parseInt(..)有一个孪生兄弟,parseFloat(..),它(听起来)从一个字符串中拉出一个浮点数。

不要忘了parseInt(..)工作在string值上。向parseInt(..)传递一个number绝对没有任何意义。传递其他任何类型也都没有意义,比如truefunction(){..}[1,2,3]

如果你传入一个非string,你所传入的值首先将自动地被强制转换为一个string(见早先的“ToString”),这很明显是一种隐藏的 隐含 强制转换。在你的程序中依赖这样的行为真的是一个坏主意,所以永远也不要将parseInt(..)与非string值一起使用。

在 ES5 之前,parseInt(..)还存在另外一个坑,这曾是许多 JS 程序的 bug 的根源。如果你不传递第二个参数来指定使用哪种进制(也叫基数)来翻译数字的string内容,parseInt(..)将会根据开头的字符进行猜测。

如果开头的两个字符是"0x""0X",那么猜测(根据惯例)将是你想要将这个string翻译为一个 16 进制number。否则,如果第一个字符是"0",那么猜测(也是根据惯例)将是你想要将这个string翻译成 8 进制number

16 进制的string(以0x0X开头)没那么容易搞混。但是事实证明 8 进制数字的猜测过于常见了。比如:

var hour = parseInt( selectedHour.value );
var minute = parseInt( selectedMinute.value );

console.log( "The time you selected was: " + hour + ":" + minute);

看起来无害,对吧?试着在小时上选择08在分钟上选择09。你会得到0:0。为什么?因为89都不是合法的 8 进制数。

ES5 之前的修改很简单,但是很容易忘:总是在第二个参数值上传递10。这完全是安全的:

var hour = parseInt( selectedHour.value, 10 );
var minute = parseInt( selectedMiniute.value, 10 );

在 ES5 中,parseInt(..)不再猜测八进制数了。除非你指定,否则它会假定为 10 进制(或者为"0x"前缀猜测 16 进制数)。这好多了。只是要小心,如果你的代码不得不运行在前 ES5 环境中,你仍然需要为基数传递10

解析非字符串

几年以前有一个挖苦 JS 的玩笑,使一个关于parseInt(..)行为的一个臭名昭著的例子备受关注,它取笑 JS 的这个行为:

parseInt( 1/0, 19 ); // 18

这里面设想(但完全不合法)的断言是,“如果我传入一个无限大,并从中解析出一个整数的话,我应该得到一个无限大,不是 18”。没错,JS 一定是疯了才得出这个结果,对吧?

虽然这是个明显故意造成的,不真实的例子,但是让我们放纵这种疯狂一小会儿,来检视一下 JS 是否真的那么疯狂。

首先,这其中最明显的原罪是将一个非string传入了parseInt(..)。这是不对的。这么做是自找麻烦。但就算你这么做了,JS 也会礼貌地将你传入的东西强制转换为它可以解析的string

有些人可能会争论说这是一种不合理的行为,parseInt(..)应当拒绝在一个非string值上操作。它应该抛出一个错误吗?坦白地说,像 Java 那样。但是一想到 JS 应当开始在满世界抛出错误,以至于几乎每一行代码都需要用try..catch围起来,我就不寒而栗。

它应当返回NaN吗?也许。但是……要是这样呢:

parseInt( new String( "42") );

这也应当失败吗?它是一个非string值啊。如果你想让String对象包装器被开箱成"42",那么42先变成"42",以使42可以被解析回来就那么不寻常吗?

我会争论说,这种可能发生的半 明确隐含 的强制转换经常可以成为非常有用的东西。比如:

var a = {
    num: 21,
    toString: function() { return String( this.num * 2 ); }
};

parseInt( a ); // 42

事实上parseInt(..)将它的值强制转换为string来实施解析是十分合理的。如果你传垃圾进去,那么你就会得到垃圾,不要责备垃圾桶 —— 它只是忠实地尽自己的责任。

那么,如果你传入像Infinity(很明显是1 / 0的结果)这样的值,对于它的强制转换来说哪种string表现形式最有道理呢?我脑中只有两种合理的选择:"Infinity""∞"。JS 选择了"Infinity"。我很高兴它这么选。

我认为在 JS 中 所有的值 都有某种默认的string表现形式是一件好事,这样它们就不是我们不能调试和推理的神秘黑箱了。

现在,关于 19 进制呢?很明显,这完全是伪命题和造作。没有真实的 JS 程序使用 19 进制。那太荒谬了。但是,让我们再一次放任这种荒谬。在 19 进制中,合法的数字字符是0 - 9a - i(大小写无关)。

那么,回到我们的parseInt( 1/0, 19 )例子。它实质上是parseInt( "Infinity", 19 )。它如何解析?第一个字符是"I",在愚蠢的 19 进制中是值18。第二个字符"n"不再合法的数字字符集内,所以这样的解析就礼貌地停止了,就像它在"42px"中遇到"p"那样。

结果呢?18。正如它应该的那样。对 JS 来说,并非一个错误或者Infinity本身,而是将我们带到这里的一系列的行为才是 非常重要 的,不应当那么简单地被丢弃。

其他关于parseInt(..)行为的,令人吃惊但又十分合理的例子还包括:

parseInt( 0.000008 );        // 0   ("0" from "0.000008")
parseInt( 0.0000008 );        // 8   ("8" from "8e-7")
parseInt( false, 16 );        // 250 ("fa" from "false")
parseInt( parseInt, 16 );    // 15  ("f" from "function..")

parseInt( "0x10" );            // 16
parseInt( "103", 2 );        // 2

其实parseInt(..)在它的行为上是相当可预见和一致的。如果你正确地使用它,你就能得到合理的结果。如果你不正确地使用它,那么你得到的疯狂结果并不是 JavaScript 的错。

明确地:* --> Boolean

现在,我们来检视从任意的非boolean值到一个boolean值的强制转换。

正如上面的String(..)Number(..)Boolean(..)(当然,不带new!)是强制进行ToBoolean转换的明确方法:

var a = "0";
var b = [];
var c = {};

var d = "";
var e = 0;
var f = null;
var g;

Boolean( a ); // true
Boolean( b ); // true
Boolean( c ); // true

Boolean( d ); // false
Boolean( e ); // false

Boolean( f ); // false
Boolean( g ); // false

虽然Boolean(..)是非常明确的,但是它并不常见也不为人所惯用。

正如一元+操作符将一个值强制转换为一个number(参见上面的讨论),一元的!否定操作符可以将一个值明确地强制转换为一个boolean问题 是它还将值从 truthy 翻转为 falsy,或反之。所以,大多数 JS 开发者使用!!双否定操作符进行boolean强制转换,因为第二个!将会把它翻转回原本的 true 或 false:

var a = "0";
var b = [];
var c = {};

var d = "";
var e = 0;
var f = null;
var g;

!!a;    // true
!!b;    // true
!!c;    // true

!!d;    // false
!!e;    // false
!!f;    // false
!!g;    // false

没有Boolean(..)!!的话,任何这些ToBoolean强制转换都将 隐含地 发生,比如在一个if (..) ..语句这样使用boolean的上下文中。但这里的目标是,明确地强制一个值成为boolean来使ToBoolean强制转换的意图显得明明白白。

另一个ToBoolean强制转换的用例是,如果你想在数据结构的 JSON 序列化中强制转换一个true/false

var a = [
    1,
    function(){ /*..*/ },
    2,
    function(){ /*..*/ }
];

JSON.stringify( a ); // "[1,null,2,null]"

JSON.stringify( a, function(key,val){
    if (typeof val == "function") {
        // 强制函数进行 `ToBoolean` 转换
        return !!val;
    }
    else {
        return val;
    }
} );
// "[1,true,2,true]"

如果你是从 Java 来到 JavaScript 的话,你可能会认得这个惯用法:

var a = 42;

var b = a ? true : false;

? :三元操作符将会测试a的真假,然后根据这个测试的结果相应地将truefalse赋值给b

表面上,这个惯用法看起来是一种 明确的 ToBoolean类型强制转换形式,因为很明显它操作的结果要么是true要么是false

然而,这里有一个隐藏的 隐含 强制转换,就是表达式a不得不首先被强制转换为boolean来进行真假测试。我称这种惯用法为“明确地隐含”。另外,我建议你在 JavaScript 中 完全避免这种惯用法。它不会提供真正的好处,而且会让事情变得更糟。

对于 明确的 强制转换Boolean(a)!!a是好得多的选项。

隐含的强制转换

隐含的 强制转换是指这样的类型转换:它们是隐藏的,由于其他的动作隐含地发生的不明显的副作用。换句话说,任何(对你)不明显的类型转换都是 隐含的强制转换

虽然 明确的 强制转换的目的很明白,但是这可能 太过 明显 —— 隐含的 强制转换拥有相反的目的:使代码更难理解。

从表面上来看,我相信这就是许多关于强制转换的愤怒的源头。绝大多数关于“JavaScript 强制转换”的抱怨实际上都指向了(不管他们是否理解它) 隐含的 强制转换。

注意: Douglas Crockford,"JavaScript: The Good Parts" 的作者,在许多会议和他的作品中声称应当避免 JavaScript 强制转换。但看起来他的意思是 隐含的 强制转换是不好的(以他的意见)。然而,如果你读他自己的代码的话,你会发现相当多的强制转换的例子,明确隐含 都有!事实上,他的担忧主要在于==操作,但正如你将在本章中看到的,那只是强制转换机制的一部分。

那么,隐含强制转换 是邪恶的吗?它很危险吗?它是 JavaScript 设计上的缺陷吗?我们应该尽一切力量避免它吗?

我打赌大多数读者都倾向于踊跃地欢呼,“是的!”

别那么着急。听我把话说完。

让我们在 隐含的 强制转换是什么,和可以是什么这个问题上采取一个不同的角度,而不是仅仅说它是“好的明确强制转换的反面”。这太过狭隘,而且忽视了一个重要的微妙细节。

让我们将 隐含的 强制转换的目的定义为:减少搞乱我们代码的繁冗,模板代码,和/或不必要的实现细节,不使它们的噪音掩盖更重要的意图。

用于简化的隐含

在我们进入 JavaScript 以前,我建议使用某个理论上是强类型的语言的假想代码来说明一下:

SomeType x = SomeType( AnotherType( y ) )

在这个例子中,我在y中有一些任意类型的值,想把它转换为SomeType类型。问题是,这种语言不能从当前y的类型直接走到SomeType。它需要一个中间步骤,它首先转换为AnotherType,然后从AnotherType转换到SomeType

现在,要是这种语言(或者你可用这种语言创建自己的定义)允许你这么说呢:

SomeType x = SomeType( y )

难道一般来说你不会同意我们简化了这里的类型转换,降低了中间转换步骤的无谓的“噪音”吗?我的意思是,在这段代码的这一点上,能看到并处理y先变为AnotherType然后再变为SomeType的事实,真的 是很重要的一件事吗?

有些人可能会争辩,至少在某些环境下,是的。但我想我可以做出相同的争辩说,在许多其他的环境下,不管是通过语言本身的还是我们自己的抽象,这样的简化通过抽象或隐藏这些细节 确实增强了代码的可读性

毫无疑问,在幕后的某些地方,那个中间的步骤依然是发生的。但如果这样的细节在视野中隐藏起来,我们就可以将使y变为类型SomeType作为一个泛化操作来推理,并隐藏混乱的细节。

虽然不是一个完美的类比,我要在本章剩余部分争论的是,JS 的 隐含的 强制转换可以被认为是给你的代码提供了一个类似的辅助。

但是,很重要的是,这不是一个无边界的,绝对的论断。绝对有许多 邪恶的东西 潜伏在 隐含 强制转换周围,它们对你的代码造成的损害要比任何潜在的可读性改善厉害的多。很清楚,我们不得不学习如何避免这样的结构,使我们不会用各种 bug 来毒害我们的代码。

许多开发者相信,如果一个机制可以做某些有用的事儿 A,但也可以被滥用或误用来做某些可怕的事儿 Z,那么我们就应当将这种机制整个儿扔掉,仅仅是为了安全。

我对你的鼓励是:不要安心于此。不要“把孩子跟洗澡水一起泼出去”。不要因为你只见到过它的“坏的一面”就假设 隐含 强制转换都是坏的。我认为这里有“好的一面”,而我想要帮助和启发你们更多的人找到并接纳它们!

隐含地:Strings <--> Numbers

在本章的早先,我们探索了stringnumber值之间的 明确 强制转换。现在,让我们使用 隐含 强制转换的方式探索相同的任务。但在我们开始之前,我们不得不检视一些将会 隐含地 发生强制转换的操作的微妙之处。

为了服务于number的相加和string的连接两个目的,+操作符被重载了。那么 JS 如何知道你想用的是哪一种操作呢?考虑下面的代码:

var a = "42";
var b = "0";

var c = 42;
var d = 0;

a + b; // "420"
c + d; // 42

是什么不同导致了"420"42?一个常见的误解是,这个不同之处在于操作数之一或两者是否是一个string,这意味着+将假设string连接。虽然这有一部分是对的,但实际情况要更复杂。

考虑如下代码:

var a = [1,2];
var b = [3,4];

a + b; // "1,23,4"

两个操作数都不是string,但很明显它们都被强制转换为string然后启动了string连接。那么到底发生了什么?

警告: 语言规范式的深度细节就要来了,如果这会吓到你就跳过下面两段!)


根据 ES5 语言规范的 11.6.1 部分,+的算法是(当一个操作数是object值时),如果两个操作数之一已经是一个string,或者下列步骤产生一个string表达形式,+将会进行连接。所以,当+的两个操作数之一收到一个object(包括array)时,它首先在这个值上调用ToPrimitive抽象操作(9.1 部分),而它会带着number的上下文环境提示来调用[[DefaultValue]]算法(8.12.8 部分)。

如果你仔细观察,你会发现这个操作现在和ToNumber抽象操作处理object的过程是一样的(参见早先的“ToNumber”一节)。在array上的valueOf()操作将会在产生一个简单基本类型时失败,于是它退回到一个toString()表现形式。两个array因此分别变成了"1,2""3,4"。现在,+就如你通常期望的那样连接这两个string"1,23,4"


让我们把这些乱七八糟的细节放在一边,回到一个早前的,简化的解释:如果+的两个操作数之一是一个string(或在上面的步骤中成为一个string),那么操作就会是string连接。否则,它总是数字加法。

注意: 关于强制转换,一个经常被引用的坑是[] + {}{} + [],这两个表达式的结果分别是"[object Object]"0。虽然对此有更多的东西,但是我们将在第五章的“Block”中讲解这其中的细节。

这对 隐含 强制转换意味着什么?

你可以简单地通过将number和空string``""“相加”来把一个number强制转换为一个string

var a = 42;
var b = a + "";

b; // "42"

提示: 使用+操作符的数字加法是可交换的,这意味着2 + 33 + 2是相同的。使用+的字符串连接很明显通常不是可交换的,但是 对于""的特定情况,它实质上是可交换的,因为a + """" + a会产生相同的结果。

使用一个+ ""操作将number隐含地)强制转换为string是极其常见/惯用的。事实上,有趣的是,一些在口头上批评 隐含 强制转换得最严厉的人仍然在他们自己的代码中使用这种方式,而不是使用它的 明确的 替代形式。

隐含 强制转换的有用形式中,我认为这是一个很棒的例子,尽管这种机制那么频繁地被人诟病!

a + ""这种 隐含的 强制转换与我们早先的String(a)明确的 强制转换的例子相比较,有一个另外的需要小心的奇怪之处。由于ToPrimitive抽象操作的工作方式,a + ""在值a上调用valueOf(),它的返回值再最终通过内部的ToString抽象操作转换为一个string。但是String(a)只直接调用toString()

两种方式的最终结果都是一个string,但如果你使用一个object而不是一个普通的基本类型number的值,你可能不一定得到 相同的 string值!

考虑这段代码:

var a = {
    valueOf: function() { return 42; },
    toString: function() { return 4; }
};

a + "";            // "42"

String( a );    // "4"

一般来说这样的坑不会咬到你,除非你真的试着创建令人困惑的数据结构和操作,但如果你为某些object同时定义了你自己的valueOf()toString()方法,你就应当小心,因为你强制转换这些值的方式将影响到结果。

那么另外一个方向呢?我们如何将一个string 隐含强制转换 为一个number

var a = "3.14";
var b = a - 0;

b; // 3.14

-操作符是仅为数字减法定义的,所以a - 0强制a的值被转换为一个number。虽然少见得多,a * 1a / 1也会得到相同的结果,因为这些操作符也是仅为数字操作定义的。

那么对-操作符使用object值会怎样呢?和上面的+的故事相似:

var a = [3];
var b = [1];

a - b; // 2

两个array值都不得不变为number,但它们首先会被强制转换为string(使用意料之中的toString()序列化),然后再强制转换为number,以便-减法操作可以实施。

那么,stringnumber值之间的 隐含 强制转换还是你总是在恐怖故事当中听到的丑陋怪物吗?我个人不这么认为。

比较b = String(a)明确的)和b = a + ""隐含的)。我认为在你的代码中会出现两种方式都有用的情况。当然b = a + ""在 JS 程序中更常见一些,不管一般意义上 隐含 强制转换的好处或害处的 感觉 如何,他都提供了自己的用途。

你不懂 JS:类型与文法 第四章:强制转换(上)

现在我们更全面地了解了 JavaScript 的类型和值,我们将注意力转向一个极具争议的话题:强制转换。

正如我们在第一章中提到的,关于强制转换到底是一个有用的特性,还是一个语言设计上的缺陷(或位于两者之间!),早就开始就争论不休了。如果你读过关于 JS 的其他书籍,你就会知道流行在世面上那种淹没一切的 声音:强制转换是魔法,是邪恶的,令人困惑的,而且就是彻头彻尾的坏主意。

本着这个系列丛书的总体精神,我认为你应当直面你不理解的东西并设法更全面地 搞懂它。而不是因为大家都这样做,或是你曾经被一些怪东西咬到就逃避强制转换。

我们的目标是全面地探索强制转换的优点和缺点(是的,它们 优点!),这样你就能在程序中对它是否合适做出明智的决定。

转换值

将一个值从一个类型明确地转换到另一个类型通常称为“类型转换(type casting)”,当这个操作隐含地完成时称为“强制转换(coercion)”(根据一个值如何被使用的规则来强制它变换类型)。

注意: 这可能不明显,但是 JavaScript 强制转换总是得到基本标量值的一种,比如stringnumber,或boolean。没有强制转换可以得到像objectfunction这样的复杂值。第三章讲解了“封箱”,它将一个基本类型标量值包装在它们相应的object中,但在准确的意义上这不是真正的强制转换。

另一种区别这些术语的常见方法是:“类型转换(type casting/conversion)”发生在静态类型语言的编译时,而“类型强制转换(type coercion)”是动态类型语言的运行时转换。

然而,在 JavaScript 中,大多数人将所有这些类型的转换都称为 强制转换(coercion),所以我偏好的区别方式是使用“隐含强制转换(implicit coercion)”与“明确强制转换(explicit coercion)”。

其中的区别应当是很明显的:在观察代码时如果一个类型转换明显是有意为之的,那么它就是“明确强制转换”,而如果这个类型转换是做为其他操作的不那么明显的副作用发生的,那么它就是“隐含强制转换”。

例如,考虑这两种强制转换的方式:

var a = 42;

var b = a + "";            // 隐含强制转换

var c = String( a );    // 明确强制转换

对于b来说,强制转换是隐含地发生的,因为如果与+操作符组合的操作数之一是一个string值(""),这将使+操作成为一个string连接(将两个字符串加在一起),而string连接的 一个(隐藏的)副作用a中的值42强制转换为它的string等价物:"42"

相比之下,String(..)函数使一切相当明显,它明确地取得a中的值,并把它强制转换为一个string表现形式。

两种方式都能达到相同的效果:从42变成"42"。但它们 如何 达到这种效果,才是关于 JavaScript 强制转换的热烈争论的核心。

注意: 技术上讲,这里有一些在语法形式区别之上的,行为上的微妙区别。我们将在本章稍后,“隐含:Strings <--> Numbers”一节中仔细讲解。

“明确地”,“隐含地”,或“明显地”和“隐藏的副作用”这些术语,是 相对的

如果你确切地知道a + ""是在做什么,并且你有意地这么做来强制转换一个string,你可能感觉这个操作已经足够“明确”了。相反,如果你从没见过String(..)函数被用于string强制转换,那么对你来说它的行为可能看起来太过隐蔽而让你感到“隐含”。

但我们是基于一个 大众的,充分了解,但不是专家或 JS 规范爱好者的 开发者的观点来讨论“明确”与“隐含”的。无论你的程度如何,或是没有在这个范畴内准确地找到自己,你都需要根据我们在这里的观察方式,相应地调整你的角度。

记住:我们自己写代码而也只有我们自己会读它,通常是很少见的。即便你是一个精通 JS 里里外外的专家,也要考虑一个经验没那么丰富的队友在读你的代码时感受如何。对于他们和对于你来说,“明确”或“隐含”的意义相同吗?

抽象值操作

在我们可以探究 明确隐含 强制转换之前,我们需要学习一些基本规则,是它们控制着值如何 变成 一个stringnumber,或boolean的。ES5 语言规范的第 9 部分用值的变形规则定义了几种“抽象操作”(“仅供内部使用的操作”的高大上说法)。我们将特别关注于:ToStringToNumber,和ToBoolean,并稍稍关注一下ToPrimitive

ToString

当任何一个非string值被强制转换为一个string表现形式时,这个转换的过程是由语言规范的 9.8 部分的ToString抽象操作处理的。

内建的基本类型值拥有自然的字符串化形式:null变为"null"undefined变为"undefined"true变为"true"number一般会以你期望的自然方式表达,但正如我们在第二章中讨论的,非常小或非常大的number将会以指数形式表达:

// `1.07`乘以`1000`,7 次
var a = 1.07 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000 * 1000;

// 7 次乘以 3 位 => 21 位
a.toString(); // "1.07e21"

对于普通的对象,除非你指定你自己的,默认的toString()(可以在Object.prototype.toString()找到)将返回 internal [[Class]](见第三章),例如"[object Object]"

但正如早先所展示的,如果一个对象上拥有它自己的toString()方法,而你又以一种类似string的方式使用这个对象,那么它的toString()将会被自动调用,而且这个调用的string结果将被使用。

注意: 技术上讲,一个对象被强制转换为一个string要通过ToPrimitive抽象操作(ES5 语言规范,9.1 部分),但是那其中的微妙细节将会在本章稍后的ToNumber部分中讲解,所以我们在这里先跳过它。

数组拥有一个覆盖版本的默认toString(),将数组字符串化为它所有的值(每个都字符串化)的(字符串)连接,并用","分割每个值。

var a = [1,2,3];

a.toString(); // "1,2,3"

重申一次,toString()可以明确地被调用,也可以通过在一个需要string的上下文环境中使用一个非string来自动地被调用。

JSON 字符串化

另一种看起来与ToString密切相关的操作是,使用JSON.stringify(..)工具将一个值序列化为一个 JSON 兼容的string值。

重要的是要注意,这种字符串化与强制转换并不完全是同一种东西。但是因为它与上面讲的ToString规则有关联,我们将在这里稍微转移一下话题,来讲解 JSON 字符串化行为。

对于最简单的值,JSON 字符串化行为基本上和toString()转换是相同的,除了序列化的结果 总是一个string

JSON.stringify( 42 );    // "42"
JSON.stringify( "42" );    // ""42"" (一个包含双引号的字符串)
JSON.stringify( null );    // "null"
JSON.stringify( true );    // "true"

任何 JSON 安全 的值都可以被JSON.stringify(..)字符串化。但是什么是 JSON 安全的?任何可以用 JSON 表现形式合法表达的值。

考虑 JSON 安全的值可能更容易一些。一些例子是:undefinedfunction,(ES6+)symbol,和带有循环引用的object(一个对象结构中的属性互相引用而造成了一个永不终结的循环)。对于标准的 JSON 结构来说这些都是非法的值,主要是因为他们不能移植到消费 JSON 值的其他语言中。

JSON.stringify(..)工具在遇到undefinedfunction,和symbol时将会自动地忽略它们。如果在一个array中遇到这样的值,它会被替换为null(这样数组的位置信息就不会改变)。如果在一个object的属性中遇到这样的值,这个属性会被简单地剔除掉。

考虑下面的代码:

JSON.stringify( undefined );                    // undefined
JSON.stringify( function(){} );                    // undefined

JSON.stringify( [1,undefined,function(){},4] );    // "[1,null,null,4]"
JSON.stringify( { a:2, b:function(){} } );        // "{"a":2}"

但如果你试着JSON.stringify(..)一个带有循环引用的object,就会抛出一个错误。

JSON 字符串化有一个特殊行为,如果一个object值定义了一个toJSON()方法,这个方法将会被首先调用,以取得用于序列化的值。

如果你打算 JSON 字符串化一个可能含有非法 JSON 值的对象,或者如果这个对象中正好有不适于序列化的值,那么你就应当为它定义一个toJSON()方法,返回这个object的一个 JSON 安全 版本。

例如:

var o = { };

var a = {
    b: 42,
    c: o,
    d: function(){}
};

// 在`a`内部制造一个循环引用
o.e = a;

// 这回因循环引用而抛出一个错误
// JSON.stringify( a );

// 自定义一个 JSON 值序列化
a.toJSON = function() {
    // 序列化仅包含属性`b`
    return { b: this.b };
};

JSON.stringify( a ); // "{"b":42}"

一个很常见的误解是,toJSON()应当返回一个 JSON 字符串化的表现形式。这可能是不正确的,除非你事实上想要字符串化string本身(通常不会!)。toJSON()应当返回合适的实际普通值(无论什么类型),而JSON.stringify(..)自己会处理字符串化。

换句话说,toJSON()应当被翻译为:“变为一个适用于字符串化的 JSON 安全的值”,不是像许多开发者错误认为的那样,“变为一个 JSON 字符串”。

考虑下面的代码:

var a = {
    val: [1,2,3],

    // 可能正确!
    toJSON: function(){
        return this.val.slice( 1 );
    }
};

var b = {
    val: [1,2,3],

    // 可能不正确!
    toJSON: function(){
        return "[" +
            this.val.slice( 1 ).join() +
        "]";
    }
};

JSON.stringify( a ); // "[2,3]"

JSON.stringify( b ); // ""[2,3]""

在第二个调用中,我们字符串化了返回的string而不是array本身,这可能不是我们想要做的。

既然我们说到了JSON.stringify(..),那么就让我们来讨论一些不那么广为人知,但是仍然很有用的功能吧。

JSON.stringify(..)的第二个参数值是可选的,它称为 替换器(replacer)。这个参数值既可以是一个array也可以是一个function。与toJSON()为序列化准备一个值的方式类似,它提供一种过滤机制,指出一个object的哪一个属性应该或不应该被包含在序列化形式中,来自定义这个object的递归序列化行为。

如果 替换器 是一个array,那么它应当是一个stringarray,它的每一个元素指定了允许被包含在这个object的序列化形式中的属性名称。如果一个属性不存在于这个列表中,那么它就会被跳过。

如果 替换器 是一个function,那么它会为object本身而被调用一次,并且为这个object中的每个属性都被调用一次,而且每次都被传入两个参数值,keyvalue。要在序列化中跳过一个 key,可以返回undefined。否则,就返回被提供的 value

var a = {
    b: 42,
    c: "42",
    d: [1,2,3]
};

JSON.stringify( a, ["b","c"] ); // "{"b":42,"c":"42"}"

JSON.stringify( a, function(k,v){
    if (k !== "c") return v;
} );
// "{"b":42,"d":[1,2,3]}"

注意:function替换器 的情况下,第一次调用时 key 参数kundefined(而对象a本身会被传入)。if语句会 过滤掉 名称为c的属性。字符串化是递归的,所以数组[1,2,3]会将它的每一个值(12,和3)都作为v传递给 替换器,并将索引值(01,和2)作为k

JSON.stringify(..)还可以接收第三个可选参数值,称为 填充符(space),在对人类友好的输出中它被用做缩进。填充符 可以是一个正整数,用来指示每一级缩进中应当使用多少个空格字符。或者,填充符 可以是一个string,这时每一级缩进将会使用它的前十个字符。

var a = {
    b: 42,
    c: "42",
    d: [1,2,3]
};

JSON.stringify( a, null, 3 );
// "{
//    "b": 42,
//    "c": "42",
//    "d": [
//       1,
//       2,
//       3
//    ]
// }"

JSON.stringify( a, null, "-----" );
// "{
// -----"b": 42,
// -----"c": "42",
// -----"d": [
// ----------1,
// ----------2,
// ----------3
// -----]
// }"

记住,JSON.stringify(..)并不直接是一种强制转换的形式。但是,我们在这里讨论它,是由于两个与ToString强制转换有关联的行为:

  1. stringnumberboolean,和null值在 JSON 字符串化时,与它们通过ToString抽象操作的规则强制转换为string值的方式基本上是相同的。
  2. 如果传递一个object值给JSON.stringify(..),而这个object上拥有一个toJSON()方法,那么在字符串化之前,toJSON()就会被自动调用来将这个值(某种意义上)“强制转换”为 JSON 安全 的。

ToNumber

如果任何非number值,以一种要求它是number的方式被使用,比如数学操作,就会发生 ES5 语言规范在 9.3 部分定义的ToNumber抽象操作。

例如,true变为1false变为0undefined变为NaN,而(奇怪的是)null变为0

对于一个string值来说,ToNumber工作起来很大程度上与数字字面量的规则/语法很相似(见第三章)。如果它失败了,结果将是NaN(而不是number字面量中会出现的语法错误)。一个不同之处的例子是,在这个操作中0前缀的八进制数不会被作为八进制数来处理(而仅作为普通的十进制小数),虽然这样的八进制数作为number字面量是合法的。

注意: number字面量文法与用于string值的ToNumber间的区别极其微妙,在这里就不进一步讲解了。更多的信息可以参考 ES 语言规范的 9.3.1 部分。

对象(以及数组)将会首先被转换为它们的基本类型值的等价物,而后这个结果值(如果它还不是一个number基本类型)会根据刚才提到的ToNumber规则被强制转换为一个number

为了转换为基本类型值的等价物,ToPrimitive抽象操作(ES5 语言规范,9.1 部分)将会查询这个值(使用内部的DefaultValue操作 —— ES5 语言规范,8.12.8 部分),看它有没有valueOf()方法。如果valueOf()可用并且它返回一个基本类型值,那么 这个 值就将用于强制转换。如果不是这样,但toString()可用,那么就由它来提供用于强制转换的值。

如果这两种操作都没提供一个基本类型值,就会抛出一个TypeError

在 ES5 中,你可以创建这样一个不可强制转换的对象 —— 没有valueOf()toString() —— 如果他的[[Prototype]]的值为null,这通常是通过Object.create(null)来创建的。关于[[Prototype]]的详细信息参见本系列的 this 与对象原型

注意: 我们会在本章稍后讲解如何强制转换至number,但对于下面的代码段,想象Number(..)函数就是那样做的。

考虑如下代码:

var a = {
    valueOf: function(){
        return "42";
    }
};

var b = {
    toString: function(){
        return "42";
    }
};

var c = [4,2];
c.toString = function(){
    return this.join( "" );    // "42"
};

Number( a );            // 42
Number( b );            // 42
Number( c );            // 42
Number( "" );            // 0
Number( [] );            // 0
Number( [ "abc" ] );    // NaN

ToBoolean

下面,让我们聊一聊在 JS 中boolean如何动作。世面上关于这个话题有 许多的困惑和误解,所以集中注意力!

首先而且最重要的是,JS 实际上拥有truefalse关键字,而且它们的行为正如你所期望的boolean值一样。一个常见的误解是,值10true/false是相同的。虽然这可能在其他语言中是成立的,但在 JS 中number就是number,而boolean就是boolean。你可以将1强制转换为true(或反之),或将0强制转换为false(或反之)。但它们不是相同的。

Falsy 值

但这还不是故事的结尾。我们需要讨论一下,除了这两个boolean值以外,当你把其他值强制转换为它们的boolean等价物时如何动作。

所有的 JavaScript 值都可以被划分进两个类别:

  1. 如果被强制转换为boolean,将成为false的值
  2. 其它的一切值(很明显将变为true

我不是在出洋相。JS 语言规范给那些在强制转换为boolean值时将会变为false的值定义了一个明确的,小范围的列表。

我们如何才能知道这个列表中的值是什么?在 ES5 语言规范中,9.2 部分定义了一个ToBoolean抽象操作,它讲述了对所有可能的值而言,当你试着强制转换它们为 boolean 时究竟会发生什么。

从这个表格中,我们得到了下面所谓的“falsy”值列表:

  • undefined
  • null
  • false
  • +0, -0, and NaN
  • ""

就是这些。如果一个值在这个列表中,它就是一个“falsy”值,而且当你在它上面进行boolean强制转换时它会转换为false

通过逻辑上的推论,如果一个值 在这个列表中,那么它一定在 另一个列表 中,也就是我们称为“truthy”值的列表。但是 JS 没有真正定义一个“truthy”列表。它给出了一些例子,比如它说所有的对象都是 truthy,但是语言规范大致上暗示着:任何没有明确地存在于 falsy 列表中的东西,都是 truthy

Falsy 对象

等一下,这一节的标题听起来简直是矛盾的。我 刚刚才说过 语言规范将所有对象称为 truthy,对吧?应该没有“falsy 对象”这样的东西。

这会是什么意思呢?

它可能诱使你认为它意味着一个包装了 falsy 值(比如""0false)的对象包装器(见第三章)。但别掉到这个 陷阱 中。

注意: 这个可能是一个语言规范的微妙笑话。

考虑下面的代码:

var a = new Boolean( false );
var b = new Number( 0 );
var c = new String( "" );

我们知道这三个值都是包装了明显是 falsy 值的对象(见第三章)。但这些对象是作为true还是作为false动作呢?这很容易回答:

var d = Boolean( a && b && c );

d; // true

所以,三个都作为true动作,这是唯一能使d得到true的方法。

提示: 注意包在a && b && c表达式外面的Boolean( .. ) —— 你可能想知道为什么它在这儿。我们会在本章稍后回到这个话题,所以先做个心理准备。为了先睹为快,你可以自己试试如果没有Boolean( .. )调用而只有d = a && b && cd是什么。

那么,如果“falsy 对象” 不是包装着 falsy 值的对象,它们是什么鬼东西?

刁钻的地方在于,它们可以出现在你的 JS 程序中,但它们实际上不是 JavaScript 本身的一部分。

什么!?

有些特定的情况,在普通的 JS 语义之上,浏览器已经创建了它们自己的某种 外来 值的行为,也就是这种“falsy 对象”的想法。

一个“falsy 对象”看起来和动起来都像一个普通对象(属性,等等)的值,但是当你强制转换它为一个boolean时,它会变为一个false值。

为什么!?

最著名的例子是document.all:一个 由 DOM(不是 JS 引擎本身) 给你的 JS 程序提供的类数组(对象),它向你的 JS 程序暴露你页面上的元素。它 曾经 像一个普通对象那样动作 —— 是一个 truthy。但不再是了。

document.all本身从来就不是“标准的”,而且从很早以前就被废弃/抛弃了。

“那他们就不能删掉它吗?” 对不起,想得不错。但愿它们能。但是世面上有太多的遗产 JS 代码库依赖于它。

那么,为什么使它像 falsy 一样动作?因为从document.allboolean的强制转换(比如在if语句中)几乎总是用来检测老的,非标准的 IE。

IE 从很早以前就开始顺应规范了,而且在许多情况下它在推动 web 向前发展的作用和其他浏览器一样多,甚至更多。但是所有那些老旧的if (document.all) { /* it's IE */ }代码依然留在世面上,而且大多数可能永远都不会消失。所有这些遗产代码依然假设它们运行在那些给 IE 用户带来差劲儿的浏览体验的,几十年前的老 IE 上,

所以,我们不能完全移除document.all,但是 IE 不再想让if (document.all) { .. }代码继续工作了,这样现代 IE 的用户就能得到新的,符合标准的代码逻辑。

“我们应当怎么做?” “我知道了!让我们黑进 JS 的类型系统并假装document.all是 falsy!”

呃。这很烂。这是一个大多数 JS 开发者们都不理解的疯狂的坑。但是其它的替代方案(对上面两败俱伤的问题什么都不做)还要烂得 多那么一点点

所以……这就是我们得到的:由浏览器给 JavaScript 添加的疯狂,非标准的“falsy 对象”。耶!

Truthy 值

回到 truthy 列表。到底什么是 truthy 值?记住:如果一个值不在 falsy 列表中,它就是 truthy

考虑下面代码:

var a = "false";
var b = "0";
var c = "''";

var d = Boolean( a && b && c );

d;

你期望这里的d是什么值?它要么是true要么是false

它是true。为什么?因为尽管这些string值的内容看起来是 falsy 值,但是string值本身都是 truthy,而这是因为在 falsy 列表中""是唯一的string值。

那么这些呢?

var a = [];                // 空数组 -- truthy 还是 falsy?
var b = {};                // 空对象 -- truthy 还是 falsy?
var c = function(){};    // 空函数 -- truthy 还是 falsy?

var d = Boolean( a && b && c );

d;

是的,你猜到了,这里的d依然是true。为什么?和前面的原因一样。尽管它们看起来像,但是[]{},和function(){} 不在 falsy 列表中,因此它们是 truthy 值。

换句话说,truthy 列表是无限长的。不可能制成一个这样的列表。你只能制造一个 falsy 列表并查询它。

花五分钟,把 falsy 列表写在便利贴上,然后粘在你的电脑显示器上,或者如果你愿意就记住它。不管哪种方法,你都可以在自己需要的时候通过简单地查询一个值是否在 falsy 列表中,来构建一个虚拟的 truthy 列表。

truthy 和 falsy 的重要性在于,理解如果一个值在被(明确地或隐含地)强制转换为boolean值的话,它将如何动作。现在你的大脑中有了这两个列表,我们可以深入强制转换的例子本身了。

明确的强制转换

明确的 强制转换指的是明显且明确的类型转换。对于大多数开发者来说,有很多类型转换的用法可以清楚地归类于这种 明确的 强制转换。

我们在这里的目标是,在我们的代码中指明一些模式,在这些模式中我们可以清楚明白地将一个值从一种类型转换至另一种类型,以确保不给未来将读到这段代码的开发者留下任何坑。我们越明确,后来的人就越容易读懂我们的代码,也不必费太多的力气去理解我们的意图。

关于 明确的 强制转换可能很难找到什么主要的不同意见,因为它与被广泛接受的静态类型语言中的类型转换的工作方式非常接近。因此,我们理所当然地认为(暂且) 明确的 强制转换可以被认同为不是邪恶的,或没有争议的。虽然我们稍后会回到这个话题。

明确地:Strings <--> Numbers

我们将从最简单,也许是最常见强制转换操作开始:将值在stringnumber表现形式之间进行强制转换。

为了在stringnumber之间进行强制转换,我们使用内建的String(..)Number(..)函数(我们在第三章中所指的“原生构造器”),但 非常重要的是,我们不在它们前面使用new关键字。这样,我们就不是在创建对象包装器。

取而代之的是,我们实际上在两种类型之间进行 明确地强制转换

var a = 42;
var b = String( a );

var c = "3.14";
var d = Number( c );

b; // "42"
d; // 3.14

String(..)使用早先讨论的ToString操作的规则,将任意其它的值强制转换为一个基本类型的string值。Number(..)使用早先讨论过的ToNumber操作的规则,将任意其他的值强制转换为一个基本类型的number值。

我称此为 明确的 强制转换是因为,一般对于大多数开发者来说这是十分明显的:这些操作的最终结果是适当的类型转换。

实际上,这种用法看起来与其他的静态类型语言中的用法非常相像。

举个例子,在 C/C++中,你既可以说(int)x也可以说int(x),而且它们都将x中的值转换为一个整数。两种形式都是合法的,但是许多人偏向于后者,它看起来有点儿像一个函数调用。在 JavaScript 中,当你说Number(x)时,它看起来极其相似。在 JS 中它实际上是一个函数调用这个事实重要吗?并非如此。

除了String(..)Number(..),还有其他的方法可以把这些值在stringnumber之间进行“明确地”转换:

var a = 42;
var b = a.toString();

var c = "3.14";
var d = +c;

b; // "42"
d; // 3.14

调用a.toString()在表面上是明确的(“toString”意味着“变成一个字符串”是很明白的),但是这里有一些藏起来的隐含性。toString()不能在像42这样的 基本类型 值上调用。所以 JS 会自动地将42“封箱”在一个对象包装器中(见第三章),这样toString()就可以针对这个对象调用。换句话讲,你可能会叫它“明确的隐含”。

这里的+c+操作符的 一元操作符(操作符只有一个操作数)形式。取代进行数学加法(或字符串连接 —— 见下面的讨论)的是,一元的+明确地将它的操作数(c)强制转换为一个number值。

+c明确的 强制转换吗?这要看你的经验和角度。如果你知道(现在你知道了!)一元+明确地意味着number强制转换,那么它就是相当明确和明显的。但是,如果你以前从没见过它,那么它看起来就极其困惑,晦涩,带有隐含的副作用,等等。

注意: 在开源的 JS 社区中一般被接受的观点是,一元+是一个 明确的 强制转换形式。

即使你真的喜欢+c这种形式,它绝对会在有的地方看起来非常令人困惑。考虑下面的代码:

var c = "3.14";
var d = 5+ +c;

d; // 8.14

一元-操作符也像+一样进行强制转换,但它还会翻转数字的符号。但是你不能放两个减号--来使符号翻转回来,因为那将被解释为递减操作符。取代它的是,你需要这么做:- -"3.14",在两个减号之间加入空格,这将会使强制转换的结果为3.14

你可能会想到所有种类的可怕组合 —— 一个二元操作符挨着另一个操作符的一元形式。这里有另一个疯狂的例子:

1 + - + + + - + 1;    // 2

当一个一元+(或-)紧邻其他操作符时,你应当强烈地考虑避免使用它。虽然上面的代码可以工作,但几乎全世界都认为它是一个坏主意。即使是d = +c(或者d =+ c!)都太容易与d += c像混淆了,而后者完全是不同的东西!

注意: 一元+的另一个极端使人困惑的地方是,被用于紧挨着另一个将要作为++递增操作符和--递减操作符的操作数。例如:a +++ba + ++b,和a + + +b。更多关于++的信息,参见第五章的“表达式副作用”。

记住,我们正努力变得明确并 减少 困惑,不是把事情弄得更糟!

Datenumber

另一个一元+操作符的常见用法是将一个Date对象强制转换为一个number,其结果是这个日期/时间值的 unix 时间戳(从世界协调时间的 1970 年 1 月 1 日 0 点开始计算,经过的毫秒数)表现形式:

var d = new Date( "Mon, 18 Aug 2014 08:53:06 CDT" );

+d; // 1408369986000

这种习惯性用法经常用于取得当前的 现在 时刻的时间戳,比如:

var timestamp = +new Date();

注意: 一些开发者知道一个 JavaScript 中的特别的语法“技巧”,就是在构造器调用(一个带有new的函数调用)中如果没有参数值要传递的话,()可选的。所以你可能遇到var timestamp = +new Date;形式。然而,不是所有的开发者都同意忽略()可以增强可读性,因为它是一种不寻常的语法特例,只能适用于new fn()调用形式,而不能用于普通的fn()调用形式。

但强制转换不是从Date对象中取得时间戳的唯一方法。一个不使用强制转换的方式可能更好,因为它更加明确:

var timestamp = new Date().getTime();
// var timestamp = (new Date()).getTime();
// var timestamp = (new Date).getTime();

但是一个 更更好的 不使用强制转换的选择是使用 ES5 加入的Date.now()静态函数:

var timestamp = Date.now();

而且如果你想要为老版本的浏览器填补Date.now()的话,也十分简单:

if (!Date.now) {
    Date.now = function() {
        return +new Date();
    };
}

我推荐跳过与日期有关的强制转换形式。使用Date.now()来取得当前 现在 的时间戳,而使用new Date( .. ).getTime()来取得一个需要你指定的 非现在 日期/时间的时间戳。

奇异的~

一个经常被忽视并通常让人糊涂的 JS 强制操作符是波浪线~操作符(也叫“按位取反”,“比特非”)。许多理解它在做什么的人也总是想要避开它。但是为了坚持我们在本书和本系列中的精神,让我们深入并找出~是否有一些对我们有用的东西。

在第二章的“32 位(有符号)整数”一节,我们讲解了在 JS 中位操作符是如何仅为 32 位操作定义的,这意味着我们强制它们的操作数遵循 32 位值的表现形式。这个规则如何发生是由ToInt32抽象操作(ES5 语言规范,9.5 部分)控制的。

ToInt32首先进行ToNumber强制转换,这就是说如果值是"123",它在ToInt32规则实施之前会首先变成123

虽然它本身没有 技术上进行 强制转换(因为类型没有改变),但对一些特定的特殊number值使用位操作符(比如|~)会产生一种强制转换效果,这种效果的结果是一个不同的number值。

举例来说,让我们首先考虑惯用的空操作0 | x(在第二种章有展示)中使用的|“比特或”操作符,它实质上仅仅进行ToInt32转换:

0 | -0;            // 0
0 | NaN;        // 0
0 | Infinity;    // 0
0 | -Infinity;    // 0

这些特殊的数字是不可用 32 位表现的(因为它们源自 64 位的 IEEE 754 标准 —— 见第二章),所以ToInt32将这些值的结果指定为0

有争议的是,0 | __是否是一种ToInt32强制转换操作的 明确的 形式,还是更倾向于 隐含。从语言规范的角度来说,毫无疑问是 明确的,但是如果你没有在这样的层次上理解位操作,它就可能看起来有点像 隐含的 魔法。不管怎样,为了与本章中其他的断言保持一致,我们称它为 明确的

那么,让我们把注意力转回~~操作符首先将值“强制转换”为一个 32 位number值,然后实施按位取反(翻转每一个比特位)。

注意: 这与!不仅强制转换它的值为boolean而且还翻转它的每一位很相似(见后面关于“一元!”的讨论)。

但是……什么!?为什么我们要关心被翻转的比特位?这是一些相当特殊的,微妙的东西。JS 开发者需要推理个别比特位是十分少见的。

另一种考虑~定义的方法是,~源自学校中的计算机科学/离散数学:~进行二进制取补操作。太好了,谢谢,我完全明白了!

我们再试一次:~x大致与-(x+1)相同。这很奇怪,但是稍微容易推理一些。所以:

~42;    // -(42+1) ==> -43

你可能还在想~这个鬼东西到底和什么有关,或者对于强制转换的讨论它究竟有什么要紧。让我们快速进入要点。

考虑一下-(x+1)。通过进行这个操作,能够产生结果0(或者从技术上说-0!)的唯一的值是什么?-1。换句话说,~用于一个范围的number值时,将会为输入值-1产生一个 falsy(很容易强制转换为false)的0,而为任意其他的输入产生 truthy 的number

为什么这要紧?

-1通常称为一个“哨兵值”,它基本上意味着一个在同类型值(number)的更大的集合中被赋予了任意的语义。在 C 语言中许多函数使用哨兵值-1,它们返回>= 0的值表示“成功”,返回-1表示“失败”。

JavaScript 在定义string操作indexOf(..)时采纳了这种先例,它搜索一个子字符串,如果找到就返回它从 0 开始计算的索引位置,没有找到的话就返回-1

这样的情况很常见:不仅仅将indexOf(..)作为取得位置的操作,而且作为检查一个子字符串存在/不存在于另一个string中的boolean值。这就是开发者们通常如何进行这样的检查:

var a = "Hello World";

if (a.indexOf( "lo" ) >= 0) {    // true
    // 找到了!
}
if (a.indexOf( "lo" ) != -1) {    // true
    // 找到了
}

if (a.indexOf( "ol" ) < 0) {    // true
    // 没找到!
}
if (a.indexOf( "ol" ) == -1) {    // true
    // 没找到!
}

我感觉看着>= 0== -1有些恶心。它基本上是一种“抽象泄漏”,这里它将底层的实现行为 —— 使用哨兵值-1表示“失败” —— 泄漏到我的代码中。我倒是乐意隐藏这样的细节。

现在,我们终于看到为什~可以帮到我们了!将~indexOf()一起使用可以将值“强制转换”(实际上只是变形)为 可以适当地强制转换为boolean的值

var a = "Hello World";

~a.indexOf( "lo" );            // -4   <-- truthy!

if (~a.indexOf( "lo" )) {    // true
    // 找到了!
}

~a.indexOf( "ol" );            // 0    <-- falsy!
!~a.indexOf( "ol" );        // true

if (!~a.indexOf( "ol" )) {    // true
    // 没找到!
}

~拿到indexOf(..)的返回值并将它变形:对于“失败”的-1我们得到 falsy 的0,而其他的值都是 truthy。

注意: ~的假想算法-(x+1)暗示着~-1-0,但是实际上它产生0,因为底层的操作其实是按位的,不是数学操作。

技术上将,if (~a.indexOf(..))仍然依靠 隐含的 强制转换将它的结果0变为false或非零变为true。但总的来说,对我而言~更像一种 明确的 强制转换机制,只要你知道在这种惯用法中它的意图是什么。

我感觉这样的代码要比前面凌乱的>= 0 / == -1更干净。

截断比特位

在你遇到的代码中,还有一个地方可能出现~:一些开发者使用双波浪线~~来截断一个number的小数部分(也就是,将它“强制转换”为一个“整数”)。这通常(虽然是错误的)被说成与调用Math.floor(..)的结果相同。

~ ~的工作方式是,第一个~实施ToInt32“强制转换”并进行按位取反,然后第二个~进行另一次按位取反,将每一个比特位都翻转回原来的状态。于是最终的结果就是ToInt32“强制转换”(也叫截断)。

注意: ~~的按位双翻转,与双否定!!的行为非常相似,它将在稍后的“明确地:* --> Boolean”一节中讲解。

然而,~~需要一些注意/澄清。首先,它仅在 32 位值上可以可靠地工作。但更重要的是,它在负数上工作的方式与Math.floor(..)不同!

Math.floor( -49.6 );    // -50
~~-49.6;                // -49

Math.floor(..)的不同放在一边,~~x可以将值截断为一个(32 位)整数。但是x | 0也可以,而且看起来还(稍微)省事儿 一些。

那么,为什么你可能会选择~~x而不是x | 0?操作符优先权(见第五章):

~~1E20 / 10;        // 166199296

1E20 | 0 / 10;        // 1661992960
(1E20 | 0) / 10;    // 166199296

正如这里给出的其他建议一样,仅在读/写这样的代码的每一个人都知道这些操作符如何工作的情况下,才将~~~作为“强制转换”和将值变形的明确机制。

明确地:解析数字字符串

将一个string强制转换为一个number的类似结果,可以通过从string的字符内容中解析(parsing)出一个number得到。然而在这种解析和我们上面讲解的类型转换之间存在着区别。

考虑下面的代码:

var a = "42";
var b = "42px";

Number( a );    // 42
parseInt( a );    // 42

Number( b );    // NaN
parseInt( b );    // 42

从一个字符串中解析出一个数字是 容忍 非数字字符的 —— 从左到右,如果遇到非数字字符就停止解析 —— 而强制转换是 不容忍 并且会失败而得出值NaN

解析不应当被视为强制转换的替代品。这两种任务虽然相似,但是有着不同的目的。当你不知道/不关心右手边可能有什么其他的非数字字符时,你可以将一个string作为number解析。当只有数字才是可接受的值,而且像"42px"这样的东西作为数字应当被排处时,就强制转换一个string(变为一个number)。

提示: parseInt(..)有一个孪生兄弟,parseFloat(..),它(听起来)从一个字符串中拉出一个浮点数。

不要忘了parseInt(..)工作在string值上。向parseInt(..)传递一个number绝对没有任何意义。传递其他任何类型也都没有意义,比如truefunction(){..}[1,2,3]

如果你传入一个非string,你所传入的值首先将自动地被强制转换为一个string(见早先的“ToString”),这很明显是一种隐藏的 隐含 强制转换。在你的程序中依赖这样的行为真的是一个坏主意,所以永远也不要将parseInt(..)与非string值一起使用。

在 ES5 之前,parseInt(..)还存在另外一个坑,这曾是许多 JS 程序的 bug 的根源。如果你不传递第二个参数来指定使用哪种进制(也叫基数)来翻译数字的string内容,parseInt(..)将会根据开头的字符进行猜测。

如果开头的两个字符是"0x""0X",那么猜测(根据惯例)将是你想要将这个string翻译为一个 16 进制number。否则,如果第一个字符是"0",那么猜测(也是根据惯例)将是你想要将这个string翻译成 8 进制number

16 进制的string(以0x0X开头)没那么容易搞混。但是事实证明 8 进制数字的猜测过于常见了。比如:

var hour = parseInt( selectedHour.value );
var minute = parseInt( selectedMinute.value );

console.log( "The time you selected was: " + hour + ":" + minute);

看起来无害,对吧?试着在小时上选择08在分钟上选择09。你会得到0:0。为什么?因为89都不是合法的 8 进制数。

ES5 之前的修改很简单,但是很容易忘:总是在第二个参数值上传递10。这完全是安全的:

var hour = parseInt( selectedHour.value, 10 );
var minute = parseInt( selectedMiniute.value, 10 );

在 ES5 中,parseInt(..)不再猜测八进制数了。除非你指定,否则它会假定为 10 进制(或者为"0x"前缀猜测 16 进制数)。这好多了。只是要小心,如果你的代码不得不运行在前 ES5 环境中,你仍然需要为基数传递10

解析非字符串

几年以前有一个挖苦 JS 的玩笑,使一个关于parseInt(..)行为的一个臭名昭著的例子备受关注,它取笑 JS 的这个行为:

parseInt( 1/0, 19 ); // 18

这里面设想(但完全不合法)的断言是,“如果我传入一个无限大,并从中解析出一个整数的话,我应该得到一个无限大,不是 18”。没错,JS 一定是疯了才得出这个结果,对吧?

虽然这是个明显故意造成的,不真实的例子,但是让我们放纵这种疯狂一小会儿,来检视一下 JS 是否真的那么疯狂。

首先,这其中最明显的原罪是将一个非string传入了parseInt(..)。这是不对的。这么做是自找麻烦。但就算你这么做了,JS 也会礼貌地将你传入的东西强制转换为它可以解析的string

有些人可能会争论说这是一种不合理的行为,parseInt(..)应当拒绝在一个非string值上操作。它应该抛出一个错误吗?坦白地说,像 Java 那样。但是一想到 JS 应当开始在满世界抛出错误,以至于几乎每一行代码都需要用try..catch围起来,我就不寒而栗。

它应当返回NaN吗?也许。但是……要是这样呢:

parseInt( new String( "42") );

这也应当失败吗?它是一个非string值啊。如果你想让String对象包装器被开箱成"42",那么42先变成"42",以使42可以被解析回来就那么不寻常吗?

我会争论说,这种可能发生的半 明确隐含 的强制转换经常可以成为非常有用的东西。比如:

var a = {
    num: 21,
    toString: function() { return String( this.num * 2 ); }
};

parseInt( a ); // 42

事实上parseInt(..)将它的值强制转换为string来实施解析是十分合理的。如果你传垃圾进去,那么你就会得到垃圾,不要责备垃圾桶 —— 它只是忠实地尽自己的责任。

那么,如果你传入像Infinity(很明显是1 / 0的结果)这样的值,对于它的强制转换来说哪种string表现形式最有道理呢?我脑中只有两种合理的选择:"Infinity""∞"。JS 选择了"Infinity"。我很高兴它这么选。

我认为在 JS 中 所有的值 都有某种默认的string表现形式是一件好事,这样它们就不是我们不能调试和推理的神秘黑箱了。

现在,关于 19 进制呢?很明显,这完全是伪命题和造作。没有真实的 JS 程序使用 19 进制。那太荒谬了。但是,让我们再一次放任这种荒谬。在 19 进制中,合法的数字字符是0 - 9a - i(大小写无关)。

那么,回到我们的parseInt( 1/0, 19 )例子。它实质上是parseInt( "Infinity", 19 )。它如何解析?第一个字符是"I",在愚蠢的 19 进制中是值18。第二个字符"n"不再合法的数字字符集内,所以这样的解析就礼貌地停止了,就像它在"42px"中遇到"p"那样。

结果呢?18。正如它应该的那样。对 JS 来说,并非一个错误或者Infinity本身,而是将我们带到这里的一系列的行为才是 非常重要 的,不应当那么简单地被丢弃。

其他关于parseInt(..)行为的,令人吃惊但又十分合理的例子还包括:

parseInt( 0.000008 );        // 0   ("0" from "0.000008")
parseInt( 0.0000008 );        // 8   ("8" from "8e-7")
parseInt( false, 16 );        // 250 ("fa" from "false")
parseInt( parseInt, 16 );    // 15  ("f" from "function..")

parseInt( "0x10" );            // 16
parseInt( "103", 2 );        // 2

其实parseInt(..)在它的行为上是相当可预见和一致的。如果你正确地使用它,你就能得到合理的结果。如果你不正确地使用它,那么你得到的疯狂结果并不是 JavaScript 的错。

明确地:* --> Boolean

现在,我们来检视从任意的非boolean值到一个boolean值的强制转换。

正如上面的String(..)Number(..)Boolean(..)(当然,不带new!)是强制进行ToBoolean转换的明确方法:

var a = "0";
var b = [];
var c = {};

var d = "";
var e = 0;
var f = null;
var g;

Boolean( a ); // true
Boolean( b ); // true
Boolean( c ); // true

Boolean( d ); // false
Boolean( e ); // false

Boolean( f ); // false
Boolean( g ); // false

虽然Boolean(..)是非常明确的,但是它并不常见也不为人所惯用。

正如一元+操作符将一个值强制转换为一个number(参见上面的讨论),一元的!否定操作符可以将一个值明确地强制转换为一个boolean问题 是它还将值从 truthy 翻转为 falsy,或反之。所以,大多数 JS 开发者使用!!双否定操作符进行boolean强制转换,因为第二个!将会把它翻转回原本的 true 或 false:

var a = "0";
var b = [];
var c = {};

var d = "";
var e = 0;
var f = null;
var g;

!!a;    // true
!!b;    // true
!!c;    // true

!!d;    // false
!!e;    // false
!!f;    // false
!!g;    // false

没有Boolean(..)!!的话,任何这些ToBoolean强制转换都将 隐含地 发生,比如在一个if (..) ..语句这样使用boolean的上下文中。但这里的目标是,明确地强制一个值成为boolean来使ToBoolean强制转换的意图显得明明白白。

另一个ToBoolean强制转换的用例是,如果你想在数据结构的 JSON 序列化中强制转换一个true/false

var a = [
    1,
    function(){ /*..*/ },
    2,
    function(){ /*..*/ }
];

JSON.stringify( a ); // "[1,null,2,null]"

JSON.stringify( a, function(key,val){
    if (typeof val == "function") {
        // 强制函数进行 `ToBoolean` 转换
        return !!val;
    }
    else {
        return val;
    }
} );
// "[1,true,2,true]"

如果你是从 Java 来到 JavaScript 的话,你可能会认得这个惯用法:

var a = 42;

var b = a ? true : false;

? :三元操作符将会测试a的真假,然后根据这个测试的结果相应地将truefalse赋值给b

表面上,这个惯用法看起来是一种 明确的 ToBoolean类型强制转换形式,因为很明显它操作的结果要么是true要么是false

然而,这里有一个隐藏的 隐含 强制转换,就是表达式a不得不首先被强制转换为boolean来进行真假测试。我称这种惯用法为“明确地隐含”。另外,我建议你在 JavaScript 中 完全避免这种惯用法。它不会提供真正的好处,而且会让事情变得更糟。

对于 明确的 强制转换Boolean(a)!!a是好得多的选项。

隐含的强制转换

隐含的 强制转换是指这样的类型转换:它们是隐藏的,由于其他的动作隐含地发生的不明显的副作用。换句话说,任何(对你)不明显的类型转换都是 隐含的强制转换

虽然 明确的 强制转换的目的很明白,但是这可能 太过 明显 —— 隐含的 强制转换拥有相反的目的:使代码更难理解。

从表面上来看,我相信这就是许多关于强制转换的愤怒的源头。绝大多数关于“JavaScript 强制转换”的抱怨实际上都指向了(不管他们是否理解它) 隐含的 强制转换。

注意: Douglas Crockford,"JavaScript: The Good Parts" 的作者,在许多会议和他的作品中声称应当避免 JavaScript 强制转换。但看起来他的意思是 隐含的 强制转换是不好的(以他的意见)。然而,如果你读他自己的代码的话,你会发现相当多的强制转换的例子,明确隐含 都有!事实上,他的担忧主要在于==操作,但正如你将在本章中看到的,那只是强制转换机制的一部分。

那么,隐含强制转换 是邪恶的吗?它很危险吗?它是 JavaScript 设计上的缺陷吗?我们应该尽一切力量避免它吗?

我打赌大多数读者都倾向于踊跃地欢呼,“是的!”

别那么着急。听我把话说完。

让我们在 隐含的 强制转换是什么,和可以是什么这个问题上采取一个不同的角度,而不是仅仅说它是“好的明确强制转换的反面”。这太过狭隘,而且忽视了一个重要的微妙细节。

让我们将 隐含的 强制转换的目的定义为:减少搞乱我们代码的繁冗,模板代码,和/或不必要的实现细节,不使它们的噪音掩盖更重要的意图。

用于简化的隐含

在我们进入 JavaScript 以前,我建议使用某个理论上是强类型的语言的假想代码来说明一下:

SomeType x = SomeType( AnotherType( y ) )

在这个例子中,我在y中有一些任意类型的值,想把它转换为SomeType类型。问题是,这种语言不能从当前y的类型直接走到SomeType。它需要一个中间步骤,它首先转换为AnotherType,然后从AnotherType转换到SomeType

现在,要是这种语言(或者你可用这种语言创建自己的定义)允许你这么说呢:

SomeType x = SomeType( y )

难道一般来说你不会同意我们简化了这里的类型转换,降低了中间转换步骤的无谓的“噪音”吗?我的意思是,在这段代码的这一点上,能看到并处理y先变为AnotherType然后再变为SomeType的事实,真的 是很重要的一件事吗?

有些人可能会争辩,至少在某些环境下,是的。但我想我可以做出相同的争辩说,在许多其他的环境下,不管是通过语言本身的还是我们自己的抽象,这样的简化通过抽象或隐藏这些细节 确实增强了代码的可读性

毫无疑问,在幕后的某些地方,那个中间的步骤依然是发生的。但如果这样的细节在视野中隐藏起来,我们就可以将使y变为类型SomeType作为一个泛化操作来推理,并隐藏混乱的细节。

虽然不是一个完美的类比,我要在本章剩余部分争论的是,JS 的 隐含的 强制转换可以被认为是给你的代码提供了一个类似的辅助。

但是,很重要的是,这不是一个无边界的,绝对的论断。绝对有许多 邪恶的东西 潜伏在 隐含 强制转换周围,它们对你的代码造成的损害要比任何潜在的可读性改善厉害的多。很清楚,我们不得不学习如何避免这样的结构,使我们不会用各种 bug 来毒害我们的代码。

许多开发者相信,如果一个机制可以做某些有用的事儿 A,但也可以被滥用或误用来做某些可怕的事儿 Z,那么我们就应当将这种机制整个儿扔掉,仅仅是为了安全。

我对你的鼓励是:不要安心于此。不要“把孩子跟洗澡水一起泼出去”。不要因为你只见到过它的“坏的一面”就假设 隐含 强制转换都是坏的。我认为这里有“好的一面”,而我想要帮助和启发你们更多的人找到并接纳它们!

隐含地:Strings <--> Numbers

在本章的早先,我们探索了stringnumber值之间的 明确 强制转换。现在,让我们使用 隐含 强制转换的方式探索相同的任务。但在我们开始之前,我们不得不检视一些将会 隐含地 发生强制转换的操作的微妙之处。

为了服务于number的相加和string的连接两个目的,+操作符被重载了。那么 JS 如何知道你想用的是哪一种操作呢?考虑下面的代码:

var a = "42";
var b = "0";

var c = 42;
var d = 0;

a + b; // "420"
c + d; // 42

是什么不同导致了"420"42?一个常见的误解是,这个不同之处在于操作数之一或两者是否是一个string,这意味着+将假设string连接。虽然这有一部分是对的,但实际情况要更复杂。

考虑如下代码:

var a = [1,2];
var b = [3,4];

a + b; // "1,23,4"

两个操作数都不是string,但很明显它们都被强制转换为string然后启动了string连接。那么到底发生了什么?

警告: 语言规范式的深度细节就要来了,如果这会吓到你就跳过下面两段!)


根据 ES5 语言规范的 11.6.1 部分,+的算法是(当一个操作数是object值时),如果两个操作数之一已经是一个string,或者下列步骤产生一个string表达形式,+将会进行连接。所以,当+的两个操作数之一收到一个object(包括array)时,它首先在这个值上调用ToPrimitive抽象操作(9.1 部分),而它会带着number的上下文环境提示来调用[[DefaultValue]]算法(8.12.8 部分)。

如果你仔细观察,你会发现这个操作现在和ToNumber抽象操作处理object的过程是一样的(参见早先的“ToNumber”一节)。在array上的valueOf()操作将会在产生一个简单基本类型时失败,于是它退回到一个toString()表现形式。两个array因此分别变成了"1,2""3,4"。现在,+就如你通常期望的那样连接这两个string"1,23,4"


让我们把这些乱七八糟的细节放在一边,回到一个早前的,简化的解释:如果+的两个操作数之一是一个string(或在上面的步骤中成为一个string),那么操作就会是string连接。否则,它总是数字加法。

注意: 关于强制转换,一个经常被引用的坑是[] + {}{} + [],这两个表达式的结果分别是"[object Object]"0。虽然对此有更多的东西,但是我们将在第五章的“Block”中讲解这其中的细节。

这对 隐含 强制转换意味着什么?

你可以简单地通过将number和空string``""“相加”来把一个number强制转换为一个string

var a = 42;
var b = a + "";

b; // "42"

提示: 使用+操作符的数字加法是可交换的,这意味着2 + 33 + 2是相同的。使用+的字符串连接很明显通常不是可交换的,但是 对于""的特定情况,它实质上是可交换的,因为a + """" + a会产生相同的结果。

使用一个+ ""操作将number隐含地)强制转换为string是极其常见/惯用的。事实上,有趣的是,一些在口头上批评 隐含 强制转换得最严厉的人仍然在他们自己的代码中使用这种方式,而不是使用它的 明确的 替代形式。

隐含 强制转换的有用形式中,我认为这是一个很棒的例子,尽管这种机制那么频繁地被人诟病!

a + ""这种 隐含的 强制转换与我们早先的String(a)明确的 强制转换的例子相比较,有一个另外的需要小心的奇怪之处。由于ToPrimitive抽象操作的工作方式,a + ""在值a上调用valueOf(),它的返回值再最终通过内部的ToString抽象操作转换为一个string。但是String(a)只直接调用toString()

两种方式的最终结果都是一个string,但如果你使用一个object而不是一个普通的基本类型number的值,你可能不一定得到 相同的 string值!

考虑这段代码:

var a = {
    valueOf: function() { return 42; },
    toString: function() { return 4; }
};

a + "";            // "42"

String( a );    // "4"

一般来说这样的坑不会咬到你,除非你真的试着创建令人困惑的数据结构和操作,但如果你为某些object同时定义了你自己的valueOf()toString()方法,你就应当小心,因为你强制转换这些值的方式将影响到结果。

那么另外一个方向呢?我们如何将一个string 隐含强制转换 为一个number

var a = "3.14";
var b = a - 0;

b; // 3.14

-操作符是仅为数字减法定义的,所以a - 0强制a的值被转换为一个number。虽然少见得多,a * 1a / 1也会得到相同的结果,因为这些操作符也是仅为数字操作定义的。

那么对-操作符使用object值会怎样呢?和上面的+的故事相似:

var a = [3];
var b = [1];

a - b; // 2

两个array值都不得不变为number,但它们首先会被强制转换为string(使用意料之中的toString()序列化),然后再强制转换为number,以便-减法操作可以实施。

那么,stringnumber值之间的 隐含 强制转换还是你总是在恐怖故事当中听到的丑陋怪物吗?我个人不这么认为。

比较b = String(a)明确的)和b = a + ""隐含的)。我认为在你的代码中会出现两种方式都有用的情况。当然b = a + ""在 JS 程序中更常见一些,不管一般意义上 隐含 强制转换的好处或害处的 感觉 如何,他都提供了自己的用途。

你不懂 JS:类型与文法 第五章:文法(上)

我们想要解决的最后一个主要话题是 JavaScript 的语法如何工作(也称为它的文法)。你可能认为你懂得如何编写 JS,但是语言文法的各个部分中有太多微妙的地方导致了困惑和误解,所以我们想要深入这些部分并搞清楚一些事情。

注意: 对于读者们来说,“文法(grammar)”一词不像“语法(syntax)”一词那么为人熟知。在许多意义上,它们是相似的词,描述语言如何工作的 规则。它们有一些微妙的不同,但是大部分对于我们在这里的讨论无关紧要。JavaScript 的文法是一种结构化的方式,来描述语法(操作符,关键字,等等)如何组合在一起形成结构良好,合法的程序。换句话说,抛开文法来讨论语法将会忽略许多重要的细节。所以我们在本章中注目的内容的最准确的描述是 文法,尽管语言中的纯语法才是开发者们直接交互的。

语句与表达式

一个很常见的现象是,开发者们假定“语句(statement)”和“表达式(expression)”是大致等价的。但是这里我们需要区分它们俩,因为在我们的 JS 程序中它们有一些非常重要的区别。

为了描述这种区别,让我们借用一下你可能更熟悉的术语:英语。

一个“句子(sentence)”是一个表达想法的词汇的完整构造。它由一个或多个“短语(phrase)”组成,它们每一个都可以用标点符号或连词(“和”,“或”等等)连接。一个短语本身可以由更小的短语组成。一些短语是不完整的,而且本身没有太多含义,而另一些短语可以自成一句。这些规则总体地称为英语的 文法

JavaScript 文法也类似。语句就是句子,表达式就是短语,而操作符就是连词/标点。

JS 中的每一个表达式都可以被求值而成为一个单独的,具体的结果值。举例来说:

var a = 3 * 6;
var b = a;
b;

在这个代码段中,3 * 6是一个表达式(求值得值18)。而第二行的a也是一个表达式,第三行的b也一样。对表达式ab求值都会得到在那一时刻存储在这些变量中的值,也就偶然是18

另外,这三行的每一行都是一个包含表达式的语句。var a = 3 * 6var b = a称为“声明语句(declaration statments)”因为它们每一个都声明了一个变量(并选择性地给它赋值)。赋值a = 3 * 6b = a(除去var)被称为赋值表达式(assignment expressions)。

第三行仅仅含有一个表达式b,但是它本身也是一个语句(虽然不是非常有趣的一个!)。这一般称为一个“表达式语句(expression statement)”。

语句完成值

一个鲜为人知的事实是,所有语句都有完成值(即使这个值只是undefined)。

你要如何做才能看到一个语句的完成值呢?

最明显的答案是把语句敲进你的浏览器开发者控制台,因为当你运行它时,默认地控制台会报告最近一次执行的语句的完成值。

让我们考虑一下var b = a。这个语句的完成值是什么?

b = a赋值表达式给出的结果是被赋予的值(上面的18),但是var语句本身给出的结果是undefined。为什么?因为在语言规范中var语句就是这么定义的。如果你在你的控制台中敲入var a = 42,你会看到undefined被报告而不是42

注意: 技术上讲,事情要比这复杂一些。在 ES5 语言规范,12.2 部分的“变量语句”中,VariableDeclaration算法实际上返回了一个值(一个包含被声明变量的名称的string —— 诡异吧!?),但是这个值基本上被VariableStatement算法吞掉了(除了在for..in循环中使用),而这强制产生一个空的(也就是undefined)完成值。

事实上,如果你曾在你的控制台上(或者一个 JavaScript 环境的 REPL —— read/evaluate/print/loop 工具)做过很多的代码实验的话,你可能看到过许多不同的语句都报告undefined,而且你也许从来没理解它是什么和为什么。简单地说,控制台仅仅报告语句的完成值。

但是控制台打印出的完成值并不是我们可以在程序中使用的东西。那么我们该如何捕获完成值呢?

这是个更加复杂的任务。在我们解释 如何 之前,让我们先探索一下 为什么 你想这样做。

我们需要考虑其他类型的语句的完成值。例如,任何普通的{ .. }块儿都有一个完成值,即它所包含的最后一个语句/表达式的完成值。

考虑如下代码:

var b;

if (true) {
    b = 4 + 38;
}

如果你将这段代码敲入你的控制台/REPL,你可能会看到它报告42,因为42if块儿的完成值,它取自if的最后一个复制表达式语句b = 4 + 38

换句话说,一个块儿的完成值就像 隐含地返回 块儿中最后一个语句的值。

注意: 这在概念上与 CoffeeScript 这样的语言很类似,它们隐含地从functionreturn值,这些值与函数中最后一个语句的值是相同的。

但这里有一个明显的问题。这样的代码是不工作的:

var a, b;

a = if (true) {
    b = 4 + 38;
};

我们不能以任何简单的语法/文法来捕获一个语句的完成值并将它赋值给另一个变量(至少是还不能!)。

那么,我们能做什么?

警告: 仅用于演示的目的 —— 不要实际地在你的真实代码中做如下内容!

我们可以使用臭名昭著的eval(..)(有时读成“evil”)函数来捕获这个完成值。

var a, b;

a = eval( "if (true) { b = 4 + 38; }" );

a;    // 42

啊呀呀。这太难看了。但是这好用!而且它展示了语句的完成值是一个真实的东西,不仅仅是在控制台中,还可以在我们的程序中被捕获。

有一个称为“do 表达式”的 ES7 提案。这是它可能工作的方式:

var a, b;

a = do {
    if (true) {
        b = 4 + 38;
    }
};

a;    // 42

do { .. }表达式执行一个块儿(其中有一个或多个语句),这个块儿中的最后一个语句的完成值将成为do表达式的完成值,它可以像展示的那样被赋值给a

这里的大意是能够将语句作为表达式对待 —— 他们可以出现在其他语句内部 —— 而不必将它们包装在一个内联的函数表达式中,并实施一个明确的return ..

到目前为止,语句的完成值不过是一些琐碎的事情。不顾随着 JS 的进化它们的重要性可能会进一步提高,而且很有希望的是do { .. }表达式将会降低使用eval(..)这样的东西的冲动。

警告: 重复我刚才的训诫:避开eval(..)。真的。更多解释参见本系列的 作用域与闭包 一书。

表达式副作用

大多数表达式没有副作用。例如:

var a = 2;
var b = a + 3;

表达式a + 3本身并没有副作用,例如改变a。它有一个结果,就是5,而且这个结果在语句b = a + 3中被赋值给b

一个最常见的(可能)带有副作用的表达式的例子是函数调用表达式:

function foo() {
    a = a + 1;
}

var a = 1;
foo();        // 结果:`undefined`,副作用:改变 `a`

还有其他的副作用表达式。例如:

var a = 42;
var b = a++;

表达式a++有两个分离的行为。首先,它返回a的当前值,也就是42(然后它被赋值给b)。但 接下来,它改变a本身的值,将它增加 1。

var a = 42;
var b = a++;

a;    // 43
b;    // 42

许多开发者错误的认为ba一样拥有值43。这种困惑源自没有完全考虑++操作符的副作用在 什么时候 发生。

++递增操作符和--递减操作符都是一元操作符(见第四章),它们既可以用于后缀(“后面”)位置也可用于前缀(“前面”)位置。

var a = 42;

a++;    // 42
a;        // 43

++a;    // 44
a;        // 44

++++a这样用于前缀位置时,它的副作用(递增a)发生在值从表达式中返回 之前,而不是a++那样发生在 之后

注意: 你认为++a++是一个合法的语法吗?如果你试一下,你将会得到一个ReferenceError错误,但为什么?因为有副作用的操作符 要求一个变量引用 来作为它们副作用的目标。对于++a++来说,a++这部分会首先被求值(因为操作符优先级 —— 参见下面的讨论),它会给出a在递增 之前 的值。担然后它试着对++42求值,这将(如果你试一下)会给出相同的ReferenceError错误,因为++不能直接在42这样的值上施加副作用。

有时它会被错误地认为,你可以通过将a++包近一个( )中来封装它的 副作用,比如:

var a = 42;
var b = (a++);

a;    // 43
b;    // 42

不幸的是,( )本身不会像我们希望的那样,定义一个新的被包装的表达式,而它会在a++表达式的 后副作用 求值。事实上,就算它能,a++也会首先返回42,而且除非你有另一个表达式在++的副作用之后对a再次求值,你也不会从这个表达式中得到43,于是b不会被赋值为43

虽然,有另一种选择:,语句序列逗号操作符。这个操作符允许你将多个独立的表达式语句连成一个单独的语句:

var a = 42, b;
b = ( a++, a );

a;    // 43
b;    // 43

注意: a++, a周围的( .. )是必需的。其原因的操作符优先级,我们将在本章后面讨论。

表达式a++, a意味着第二个a语句表达式会在第一个a++语句表达式的 后副作用 进行求值,这表明它为b的赋值返回43

另一个副作用操作符的例子是delete。正如我们在第二章中展示的,delete用于从一个object或一个array值槽中移除一个属性。但它经常作为一个独立语句被调用:

var obj = {
    a: 42
};

obj.a;            // 42
delete obj.a;    // true
obj.a;            // undefined

如果被请求的操作是合法/可允许的,delete操作符的结果值为true,否则结果为false。但是这个操作符的副作用是它移除了属性(或数组值槽)。

注意: 我们说合法/可允许是什么意思?不存在的属性,或存在且可配置的属性(见本系列 this 与对象原型 的第三章)将会从delete操作符中返回true。否则,其结果将是false或者一个错误。

副作用操作符的最后一个例子,可能既是明显的也是不明显的,是=赋值操作符。

考虑如下代码:

var a;

a = 42;        // 42
a;            // 42

对于这个表达式来说,a = 42中的=看起来似乎不是一个副作用操作符。但如果我们检视语句a = 42的结果值,会发现它就是刚刚被赋予的值(42),所以向a赋予的相同的值实质上是一中副作用。

提示: 相同的原因也适用于+=-=这样的复合赋值操作符的副作用。例如,a = b += 2被处理为首先进行b += 2(也就是b = b + 2),然后这个赋值的结果被赋予a

这种赋值表达式(语句)得出被赋予的值的行为,主要在链式赋值上十分有用,就像这样:

var a, b, c;

a = b = c = 42;

这里,c = 42被求值得出42(带有将42赋值给c的副作用),然后b = 42被求值得出42(带有将42赋值给b的副作用),而最后a = 42被求值(带有将42赋值给a的副作用)。

警告: 一个开发者们常犯的错误是将链式赋值写成var a = b = 42这样。虽然这看起来是相同的东西,但它不是。如果这个语句发生在没有另外分离的var b(在作用域的某处)来正式声明它的情况下,那么var a = b = 42将不会直接声明b。根据strict模式的状态,它要么抛出一个错误,要么无意中创建一个全局变量(参见本系列的 作用域与闭包)。

另一个要考虑的场景是:

function vowels(str) {
    var matches;

    if (str) {
        // 找出所有的元音字母
        matches = str.match( /[aeiou]/g );

        if (matches) {
            return matches;
        }
    }
}

vowels( "Hello World" ); // ["e","o","o"]

这可以工作,而且许多开发者喜欢这么做。但是使用一个我们可以利用赋值副作用的惯用法,可以通过将两个if语句组合为一个来进行简化:

function vowels(str) {
    var matches;

    // 找出所有的元音字母
    if (str && (matches = str.match( /[aeiou]/g ))) {
        return matches;
    }
}

vowels( "Hello World" ); // ["e","o","o"]

注意: matches = str.match..周围的( .. )是必需的。其原因是操作符优先级,我们将在本章稍后的“操作符优先级”一节中讨论。

我偏好这种短一些的风格,因为我认为它明白地表示了两个条件其实是有关联的,而非分离的。但是与大多数 JS 中的风格选择一样,哪一种 更好 纯粹是个人意见。

上下文规则

在 JavaScript 文法规则中有好几个地方,同样的语法根据它们被使用的地方/方式不同意味着不同的东西。这样的东西可能,孤立的看,导致相当多的困惑。

我们不会在这里详尽地罗列所有这些情况,而只是指出常见的几个。

{ .. } 大括号

在你的代码中一对{ .. }大括号将主要出现在两种地方(随着 JS 的进化会有更多!)。让我们来看看它们每一种。

对象字面量

首先,作为一个object字面量:

// 假定有一个函数`bar()`的定义

var a = {
    foo: bar()
};

我们怎么知道这是一个object字面量?因为{ .. }是一个被赋予给a的值。

注意: a这个引用被称为一个“l-值”(也称为左手边的值)因为它是赋值的目标。{ .. }是一个“r-值”(也称为右手边的值)因为它仅被作为一个值使用(在这里作为赋值的源)。

标签

如果我们移除上面代码的var a =部分会发生什么?

// 假定有一个函数`bar()`的定义

{
    foo: bar()
}

许多开发者臆测{ .. }只是一个独立的没有被赋值给任何地方的object字面量。但事实上完全不同。

这里,{ .. }只是一个普通的代码块儿。在 JavaScript 中拥有一个这样的独立{ .. }块儿并不是一个很惯用的形式(在其他语言中要常见得多!),但它是完美合法的 JS 文法。当与let块儿作用域声明组合使用时非常有用(见本系列的 作用域与闭包)。

这里的{ .. }代码块儿在功能上差不多与附着在一些语句后面的代码块儿是相同的,比如for/while循环,if条件,等等。

但如果它是一个一般代码块儿,那么那个看起来异乎寻常的foo: bar()语法是什么?它怎么会是合法的呢?

这是因为一个鲜为人知的(而且,坦白地说,不鼓励使用的)称为“打标签的语句”的 JavaScript 特性。foo是语句bar()(这个语句省略了末尾的;—— 见本章稍后的“自动分号”)的标签。但一个打了标签的语句有何意义?

如果 JavaScript 有一个goto语句,那么在理论上你就可以说goto foo并使程序的执行跳转到代码中的那个位置。goto通常被认为是一种糟糕的编码惯用形式,因为它们使代码更难于理解(也称为“面条代码”),所以 JavaScript 没有一般的goto语句是一件 非常好的事情

然而,JS 的确支持一种有限的,特殊形式的goto:标签跳转。continuebreak语句都可以选择性地接受一个指定的标签,在这种情况下程序流会有些像goto一样“跳转”。考虑一下代码:

// 用`foo`标记的循环
foo: for (var i=0; i<4; i++) {
    for (var j=0; j<4; j++) {
        // 每当循环相遇,就继续外层循环
        if (j == i) {
            // 跳到被`foo`标记的循环的下一次迭代
            continue foo;
        }

        // 跳过奇数的乘积
        if ((j * i) % 2 == 1) {
            // 内层循环的普通(没有被标记的) `continue`
            continue;
        }

        console.log( i, j );
    }
}
// 1 0
// 2 0
// 2 1
// 3 0
// 3 2

注意: continue foo不意味着“走到标记为‘foo’的位置并继续”,而是,“继续标记为‘foo’的循环,并进行下一次迭代”。所以,它不是一个 真正的 随意的goto

如你所见,我们跳过了乘积为奇数的3 1迭代,而且被打了标签的循环跳转还跳过了1 12 2的迭代。

也许标签跳转的一个稍稍更有用的形式是,使用break __从一个内部循环里面跳出外部循环。没有带标签的break,同样的逻辑有时写起来非常尴尬:

// 用`foo`标记的循环
foo: for (var i=0; i<4; i++) {
    for (var j=0; j<4; j++) {
        if ((i * j) >= 3) {
            console.log( "stopping!", i, j );
            // 跳出被`foo`标记的循环
            break foo;
        }

        console.log( i, j );
    }
}
// 0 0
// 0 1
// 0 2
// 0 3
// 1 0
// 1 1
// 1 2
// stopping! 1 3

注意: break foo不意味着“走到‘foo’标记的位置并继续”,而是,“跳出标记为‘foo’的循环/代码块儿,并继续它 后面 的部分”。不是一个传统意义上的goto,对吧?

对于上面的问题,使用不带标签的break将可能会牵连一个或多个函数,共享作用域中变量的访问,等等。它很可能要比带标签的break更令人糊涂,所以在这里使用带标签的break也许是更好的选择。

一个标签也可以用于一个非循环的块儿,但只有break可以引用这样的非循环标签。你可以使用带标签的break ___跳出任何被标记的块儿,但你不能continue ___一个非循环标签,也不能用一个不带标签的break跳出一个块儿。

function foo() {
    // 用`bar`标记的块儿
    bar: {
        console.log( "Hello" );
        break bar;
        console.log( "never runs" );
    }
    console.log( "World" );
}

foo();
// Hello
// World

带标签的循环/块儿极不常见,而且经常使人皱眉头。最好尽可能地避开它们;比如使用函数调用取代循环跳转。但是也许在一些有限的情况下它们会有用。如果你打算使用标签跳转,那么就确保使用大量注释在文档中记下你在做什么!

一个很常见的想法是,JSON 是一个 JS 的恰当子集,所以一个 JSON 字符串(比如{"a":42} —— 注意属性名周围的引号是 JSON 必需的!)被认为是一个合法的 JavaScript 程序。不是这样的! 如果你试着把{"a":42}敲进你的 JS 控制台,你会得到一个错误。

这是因为语句标签周围不能有引号,所以"a"不是一个合法的标签,因此:不能出现在它后面。

所以,JSON 确实是 JS 语法的子集,但是 JSON 本身不是合法的 JS 文法。

按照这个路线产生的一个极其常见的误解是,如果你将一个 JS 文件加载进一个<script src=..>标签,而它里面仅含有 JSON 内容的话(就像从 API 调用中得到那样),这些数据将作为合法的 JavaScript 被读取,但只是不能从程序中访问。JSON-P(将 JSON 数据包进一个函数调用的做法,比如foo({"a":42}))经常被说成是解决了这种不可访问性,通过向你程序中的一个函数发送这些值。

不是这样的! 实际上完全合法的 JSON 值{"a":42}本身将会抛出一个 JS 错误,因为它被翻译为一个带有非法标签的语句块儿。但是foo({"a":42})是一个合法的 JS,因为在它里面,{"a":42}是一个被传入foo(..)object字面量值。所以,更合适的说法是,JSON-P 使 JSON 成为合法的 JS 文法!

块儿

另一个常为人所诟病的 JS 坑(与强制转换有关 —— 见第四章)是:

[] + {}; // "[object Object]"
{} + []; // 0

这看起来暗示着+操作符会根据第一个操作数是[]还是{}而给出不同的结果。但实际上这与它一点儿关系都没有!

在第一行中,{}出现在+操作符的表达式中,因此被翻译为一个实际的值(一个空object)。第四章解释过,[]被强制转换为""因此{}也会被强制转换为一个string"[object Object]"

但在第二行中,{}被翻译为一个独立的{}空代码块儿(它什么也不做)。块儿不需要分号来终结它们,所以这里缺少分号不是一个问题。最终,+ []是一个将[]明确强制转换number的表达式,而它的值是0

对象解构

从 ES6 开始,你将看到{ .. }出现的另一个地方是“解构赋值”(更多信息参见本系列的 ES6 与未来),确切地说是object解构。考虑下面的代码:

function getData() {
    // ..
    return {
        a: 42,
        b: "foo"
    };
}

var { a, b } = getData();

console.log( a, b ); // 42 "foo"

正如你可能看出来的,var { a , b } = ..是 ES6 解构赋值的一种形式,它大体等价于:

var res = getData();
var a = res.a;
var b = res.b;

注意: { a, b } 实际上是{ a: a, b: b }的 ES6 解构缩写,两者都能工作,但是人们期望短一些的{ a, b }能成为首选的形式。

使用一个{ .. }进行对象解构也可用于被命名的函数参数,这时它是同种类的隐含对象属性赋值的语法糖:

function foo({ a, b, c }) {
    // 不再需要:
    // var a = obj.a, b = obj.b, c = obj.c
    console.log( a, b, c );
}

foo( {
    c: [1,2,3],
    a: 42,
    b: "foo"
} );    // 42 "foo" [1, 2, 3]

所以,我们使用{ .. }的上下文环境整体上决定了它们的含义,这展示了语法和文法之间的区别。理解这些微妙之处以回避 JS 引擎进行意外的翻译是很重要的。

else if 和可选块儿

一个常见的误解是 JavaScript 拥有一个else if子句,因为你可以这么做:

if (a) {
    // ..
}
else if (b) {
    // ..
}
else {
    // ..
}

但是这里有一个 JS 文法隐藏的性质:它没有else if。但是如果附着在ifelse语句后面的代码块儿仅包含一个语句时,ifelse语句允许省略这些代码块儿周围的{ }。毫无疑问,你以前已经见过这种现象很多次了:

if (a) doSomething( a );

许多 JS 编码风格指引坚持认为,你应当总是在一个单独的语句块儿周围使用{ },就像:

if (a) { doSomething( a ); }

然而,完全相同的文法规则也适用于else子句,所以你经常编写的else if形式 实际上 被解析为:

if (a) {
    // ..
}
else {
    if (b) {
        // ..
    }
    else {
        // ..
    }
}

if (b) { .. } else { .. }是一个紧随着else的单独的语句,所以你在它周围放不放一个{ }都可以。换句话说,当你使用else if的时候,从技术上讲你就打破了那个常见的编码风格指导的规则,而且只是用一个单独的if语句定义了你的else

当然,else if惯用法极其常见,而且减少了一级缩进,所以它很吸引人。无论你用哪种方式,就在你自己的编码风格指导/规则中明确地指出它,并且不要臆测else if是直接的文法规则。

操作符优先级

就像我们在第四章中讲解的,JavaScript 版本的&&||很有趣,因为它们选择并返回它们的操作数之一,而不是仅仅得出truefalse的结果。如果只有两个操作数和一个操作符,这很容易推理。

var a = 42;
var b = "foo";

a && b;    // "foo"
a || b;    // 42

但是如果牵扯到两个操作符,和三个操作数呢?

var a = 42;
var b = "foo";
var c = [1,2,3];

a && b || c; // ???
a || b && c; // ???

要明白这些表达式产生什么结果,我们就需要理解当在一个表达式中有多于一个操作符时,什么样的规则统治着操作符被处理的方式。

这些规则称为“操作符优先级”。

我打赌大多数读者都觉得自己已经很好地理解了操作符优先级。但是和我们在本系列丛书中讲解的其他一切东西一样,我们将拨弄这种理解来看看它到底有多扎实,并希望能在这个过程中学到一些新东西。

回想上面的例子:

var a = 42, b;
b = ( a++, a );

a;    // 43
b;    // 43

要是我们移除了( )会怎样?

var a = 42, b;
b = a++, a;

a;    // 43
b;    // 42

等一下!为什么这改变了赋给b的值?

因为,操作符要比=操作符的优先级低。所以,b = a++, a被翻译为(b = a++), a。因为(如我们前面讲解的)a++拥有 后副作用,赋值给b的值就是在++改变a之前的值42

这只是为了理解操作符优先级所需的一个简单事实。如果你将要把,作为一个语句序列操作符使用,那么知道它实际上拥有最低的优先级是很重要的。任何其他的操作符都将要比,结合得更紧密。

现在,回想上面的这个例子:

if (str && (matches = str.match( /[aeiou]/g ))) {
    // ..
}

我们说过赋值语句周围的( )是必须的,但为什么?因为&&拥有的优先级比=更高,所以如果没有( )来强制结合,这个表达式将被作为(str && matches) = str.match..对待。但是这将是个错误,因为(str && matches)的结果将不是一个变量(在这里是undefined),而是一个值,因此它不能成为=赋值的左边!

好了,那么你可能认为你已经搞定操作符优先级了。

让我们移动到更复杂的例子(在本章下面几节中我们将一直使用这个例子),来 真正 测试一下你的理解:

var a = 42;
var b = "foo";
var c = false;

var d = a && b || c ? c || b ? a : c && b : a;

d;        // ??

好的,邪恶,我承认。没有人会写这样的表达式串,对吧?也许 不会,但是我们将使用它来检视将多个操作符链接在一起时的各种问题,而链接多个操作符是一个非常常见的任务。

上面的结果是42。但是这根本没意思,除非我们自己能搞清楚这个答案,而不是将它插进 JS 程序来让 JavaScript 搞定它。

让我们深入挖掘一下。

第一个问题 —— 你可能还从来没问过 —— 是,第一个部分(a && b || c)是像(a && b) || c那样动作,还是像a && (b || c)那样动作?你能确定吗?你能说服你自己它们实际上是不同的吗?

(false && true) || true;    // true
false && (true || true);    // false

那么,这就是它们不同的证据。但是false && true || true到底是如何动作的?答案是:

false && true || true;        // true
(false && true) || true;    // true

那么我们有了答案。&&操作符首先被求值,而||操作符第二被求值。

但这不是因为从左到右的处理顺序吗?让我们把操作符的顺序倒过来:

true || false && false;        // true

(true || false) && false;    // false -- 不
true || (false && false);    // true -- 这才是胜利者!

现在我们证明了&&首先被求值,然后才是||,而且在这个例子中的顺序实际上是与一般希望的从左到右的顺序相反的。

那么什么导致了这种行为?操作符优先级。

每种语言都定义了自己的操作符优先级列表。虽然令人焦虑,但是 JS 开发者读过 JS 的列表却不太常见。

如果你熟知它,上面的例子一点儿都不会绊到你,因为你已经知道了&&要比||优先级高。但是我打赌有相当一部分读者不得不将它考虑一会。

注意: 不幸的是,JS 语言规范没有将它的操作符优先级罗列在一个方便,单独的位置。你不得不通读并理解所有的文法规则。所以我们将试着以一种更方便的格式排列出更常见和更有用的部分。要得到完整的操作符优先级列表,参见 MDN 网站的“操作符优先级”(* developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence)。%E3%80%82)

短接

在第四章中,我们在一个边注中提到了操作符&&||的“短接”性质。让我们更详细地重温它们。

对于&&||两个操作符来说,如果左手边的操作数足够确定操作的结果,那么右手边的操作数将 不会被求值。故而,有了“短接”(如果可能,它就会取捷径退出)这个名字。

例如,说a && b,如果a是 falsyb就不会被求值,因为&&操作数的结果已经确定了,所以再去麻烦地检查b是没有意义的。同样的,说a || b,如果a是 truthy,那么操作的结果就已经确定了,所以没有理由再去检查b

这种短接非常有帮助,而且经常被使用:

function doSomething(opts) {
    if (opts && opts.cool) {
        // ..
    }
}

opts && opts.cool测试的opts部分就像某种保护,因为如果opts没有被赋值(或不是一个object),那么表达式opts.cool就将抛出一个错误。opts测试失败加上短接意味着opts.cool根本不会被求值,因此没有错误!

相似地,你可以用||短接:

function doSomething(opts) {
    if (opts.cache || primeCache()) {
        // ..
    }
}

这里,我们首先检查opts.cache,如果它存在,我们就不会调用primeCache()函数,如此避免了潜在的不必要的工作。

更紧密的绑定

让我们把注意力转回前面全是链接的操作符的复杂语句的例子,特别是? :三元操作符的部分。? :操作对的优先级与&&||操作符比起来是高还是低?

a && b || c ? c || b ? a : c && b : a

它是更像这样:

a && b || (c ? c || (b ? a : c) && b : a)

还是这样?

(a && b || c) ? (c || b) ? a : (c && b) : a

答案是第二个。但为什么?

因为&&优先级比||高,而||优先级比? :高。

所以,表达式(a && b || c)? :参与之前被 首先 求值。另一种常见的解释方式是,&&||要比? :“结合的更紧密”。如果倒过来成立的话,那么c ? c..将结合的更紧密,那么它就会如a && b || (c ? c..)那样动作(就像第一种选择)。

结合性

所以,&&||操作符首先集合,然后是? :操作符。但是多个同等优先级的操作符呢?它们总是从左到右或是从右到左地处理吗?

一般来说,操作符不是左结合的就是右结合的,这要看 分组是从左边发生还是从右边发生。

至关重要的是,结合性与从左到右或从右到左的处理 不是 同一个东西。

但为什么处理是从左到右或从右到左那么重要?因为表达式可以有副作用,例如函数调用:

var a = foo() && bar();

这里,foo()首先被求值,然后根据表达式foo()的结果,bar()可能会求值。如果bar()foo()之前被调用绝对会得出不同的程序行为。

但是这个行为就是从左到右的处理(JavaScript 中的默认行为!)—— 它与&&的结合性无关。在这个例子中,因为这里只有一个&&因此没有相关的分组,所以根本谈不上结合性。

但是像a && b && c这样的表达式,分组将会隐含地发生,意味着不是a && b就是b && c会先被求值。

技术上讲,a && b && c将会作为(a && b) && c处理,因为&&是左结合的(顺带一提,||也是)。然而,右结合的a && (b && c)也表现出相同的行为。对于相同的值,相同的表达式是按照相同的顺序求值的。

注意: 如果假设&&是右结合的,它就会与你手动使用( )建立a && (b && c)这样的分组的处理方式一样。但是这仍然 不意味着 c将会在b之前被处理。右结合性的意思 不是 从右到左求值,它的意思是从右到左 分组。不管哪种方式,无论分组/结合性怎样,严格的求值顺序将是a,然后b,然后c(也就是从左到右)。

因此,除了使我们对它们定义的讨论更准确以外,&&||是左结合这件事没有那么重要。

但事情不总是这样。一些操作符根据左结合性与右结合性将会做出不同的行为。

考虑? :(“三元”或“条件”)操作符:

a ? b : c ? d : e;

? :是右结合的,那么哪种分组表现了它将被处理的方式?

  • a ? b : (c ? d : e)
  • (a ? b : c) ? d : e

答案是a ? b : (c ? d : e)。不像上面的&&||,在这里右结合性很重要,因为对于一些(不是全部!)值的组合来说(a ? b : c) ? d : e的行为将会不同。

一个这样的例子是:

true ? false : true ? true : true;        // false

true ? false : (true ? true : true);    // false
(true ? false : true) ? true : true;    // true

在其他的值的组合中潜伏着更加微妙的不同,即便他们的最终结果是相同的。考虑:

true ? false : true ? true : false;        // false

true ? false : (true ? true : false);    // false
(true ? false : true) ? true : false;    // false

在这个场景中,相同的最终结果暗示着分组是没有实际意义的。然而:

var a = true, b = false, c = true, d = true, e = false;

a ? b : (c ? d : e); // false, 仅仅对 `a` 和 `b` 求值
(a ? b : c) ? d : e; // false, 对 `a`, `b` 和 `e` 求值

这样,我们就清楚地证明了? :是右结合的,而且在这个操作符与它自己链接的方式上,右结合性是发挥影响的。

另一个右结合(分组)的例子是=操作符。回想本章早先的链式赋值的例子:

var a, b, c;

a = b = c = 42;

我们早先断言过,a = b = c = 42的处理方式是,首先对c = 42赋值求值,然后是b = ..,最后是a = ..。为什么?因为右结合性,它实际上这样看待这个语句:a = (b = (c = 42))

记得本章前面,我们的复杂赋值表达式的实例吗?

var a = 42;
var b = "foo";
var c = false;

var d = a && b || c ? c || b ? a : c && b : a;

d;        // 42

随着我们使用优先级和结合性的知识把自己武装起来,我们应当可以像这样把这段代码分解为它的分组行为:

((a && b) || c) ? ((c || b) ? a : (c && b)) : a

或者,如果这样容易理解的话,可以用缩进表达:

(
  (a && b)
    ||
  c
)
  ?
(
  (c || b)
    ?
  a
    :
  (c && b)
)
  :
a

让我们解析它:

  1. (a && b)"foo".
  2. "foo" || c"foo".
  3. 对于第一个?测试,"foo"是 truthy。
  4. (c || b)"foo".
  5. 对于第二个?测试, "foo"是 truthy。
  6. a42.

就是这样,我们搞定了!答案是42,正如我们早先看到的。其实它没那么难,不是吗?

消除歧义

现在你应该对操作符优先级(和结合性)有了更好的把握,并对理解多个链接的操作符如何动作感到更适应了。

但还存在一个重要的问题:我们应当一直编写完美地依赖于操作符优先级/结合性的代码吗?我们应该仅在有必要强制一种不同的处理顺序时使用( )手动分组吗?

或者,另一方面,我们应当这样认识吗:虽然这样的规则 实际上 是可以学懂的,但是太多的坑让我们不得不忽略自动优先级/结合性?如果是这样,我们应当总是使用( )手动分组并移除对这些自动行为的所有依赖吗?

这种争论是非常主观的,而且和第四章中关于 隐含 强制转换的争论是强烈对称的。大多数开发者对这两个争论的感觉是一样的:要么他们同时接受这两种行为并使用它们编码,要么他们同时摒弃两种行为并坚持手动/明确的写法。

当然,在这个问题上,我们不能给出比我在第四章中给出的更绝对的答案。但我向你展示了利弊,并且希望促进了你更深刻的理解,以使你可以做出合理而不是人云亦云的决定。

在我看来,这里有一个重要的中间立场。我们应当将操作符优先级/结合性 ( )手动分组两者混合进我们的程序 —— 我在第四章中对于 隐含的 强制转换的健康/安全用法做过同样的辩论,但当然不会没有界限地仅仅拥护它。

例如,对我来说if (a && b && c) ..是完全没问题的,而我不会为了明确表现结合性而写出if ((a && b) && c) ..,因为我认为这过于繁冗了。

另一方面,如果我需要链接两个? :条件操作符,我会理所当然地使用( )手动分组来使我意图的逻辑表达的绝对清晰。

因此,我在这里的意见和在第四章中的相似:在操作符优先级/结合性可以使代码更短更干净的地方使用操作符优先级/结合性,在( )手动分组可以帮你创建更清晰的代码并减少困惑的地方使用( )手动分组

你不懂 JS:类型与文法 第五章:文法(下)

自动分号

当 JavaScript 认为在你的 JS 程序中特定的地方有一个;时,就算你没在那里放一个;,它就会进行 ASI(Automatic Semicolon Insertion —— 自动分号插入)。

为什么它这么做?因为就算你只省略了一个必需的;,你的程序就会失败。不是非常宽容。ASI 允许 JS 容忍那些通常被认为是不需要;的特定地方省略;

必须注意的是,ASI 将仅在换行存在时起作用。分号不会被插入一行的中间。

基本上,如果 JS 解析器在解析一行时发生了解析错误(缺少一个应有的;),而且它可以合理的插入一个;,它就会这么做。什么样的地方对插入是合理的?仅在一个语句和这一行的换行之间除了空格和/或注释没有别的东西时。

考虑如下代码:

var a = 42, b
c;

JS 应当将下一行的c作为var语句的一部分看待吗?如果在bc之间的任意一个地方出现一个,,它当然会的。但是因为没有,所以 JS 认为在b后面有一个隐含的;(在换行处)。如此c;就剩下来作为一个独立的表达式语句。

类似地:

var a = 42, b = "foo";

a
b    // "foo"

这仍然是一个没有错误的合法程序,因为表达式语句也接受 ASI。

有一些特定的地方 ASI 很有帮助,例如:

var a = 42;

do {
    // ..
} while (a)    // <-- 这里需要;!
a;

文法要求do..while循环后面要有一个;,但是whilefor循环后面则没有。但是大多数开发者都不记得它!所以 ASI 帮助性地介入并插入一个。

如我们在本章早先说过的,语句块儿不需要;终结,所以 ASI 是不必要的:

var a = 42;

while (a) {
    // ..
} // <-- 这里不需要;
a;

另一个 ASI 介入的主要情况是,与breakcontinuereturn,和(ES6)yield关键字:

function foo(a) {
    if (!a) return
    a *= 2;
    // ..
}

这个return语句的作用不会超过换行到a *= 2表达式,因为 ASI 认为;终结了return语句。当然,return语句 可以 很容易地跨越多行,只要return后面不是除了换行外什么都没有就行。

function foo(a) {
    return (
        a * 2 + 3 / 12
    );
}

同样的道理也适用于breakcontinue,和yield

纠错

在 JS 社区中斗得最火热的 宗教战争 之一(除了制表与空格以外),就是是否应当严重/唯一地依赖 ASI。

大多数的,担不是全部,分号是可选的,但是for ( .. ) ..循环的头部的两个;是必须的。

在这场争论的正方,许多开发者相信 ASI 是一种有用的机制,允许他们通过省略除了必须(很少几个)以外的所有;写出更简洁(和更“美观”)的代码。他们经常断言因为 ASI 使许多;成为可选的,所以一个 不带它们 而正确编写的程序,与 带着它们 而正确编写的程序没有区别。

在这场争论的反方,许多开发者将断言有 太多 的地方可以成为意想不到的坑了,特别是对那些新来的,缺乏经验的开发者来说,无意间被魔法般插入的;改变了程序的含义。类似地,一些开发者将会争论如果他们省略了一个分号,这就是一个直白的错误,而且他们希望他们的工具(linter 等等)在 JS 引擎背地里 纠正 它之前就抓住他。

让我分享一下我的观点。仔细阅读语言规范,会发现它暗示 ASI 是一个 纠错 过程。你可能会问,什么样的错误?明确地讲,是一个 解析器错误。换句话说,为了使解析器失败的少一些,ASI 让它更宽容。

但是宽容什么?在我看来,一个 解析器错误 发生的唯一方式是,它被给予了一个不正确/错误的程序去解析。所以虽然 ASI 在严格地纠正解析器错误,但是它得到这样的错误的唯一方式是,程序首先就写错了 —— 在文法要求使用分号的地方忽略了它们。

所以,更直率地讲,当我听到有人声称他们想要省略“可选的分号”时,我的大脑就将它翻译为“我想尽量编写最能破坏解析器但依然可以工作的程序。”

我发现这种立场很荒唐,而且省几下键盘敲击和更“美观的代码”的观点是软弱无力的。

进一步讲,我不同意这和空格与制表符的争论是同一种东西 —— 那纯粹是表面上的 —— 我宁愿相信这是一个根本问题:是编写遵循文法要求的代码,还是编写依赖于文法异常但仅仅将之忽略不计的代码。

另一种看待这个问题的方式是,依赖 ASI 实质上将换行视为有意义的“空格”。像 Python 那样的其他语言中有真正的有意义的空格。但是就今天的 JavaScript 来说,认为它拥有有意义的换行真的合适吗?

我的意见是:在你知道分号是“必需的”地方使用分号,并且把你对 ASI 的臆测限制到最小。

不要光听我的一面之词。回到 2012 年,JavaScript 的创造者 Brendan Eich 说过下面的话(brendaneich.com/2012/04/the-infernal-semicolon/):%EF%BC%9A)

这个故事的精神是:ASI 是一种(正式地说)语法错误纠正过程。如果你在好像有一种普遍的有意义的换行的规则的前提下开始编码,你将会陷入麻烦。
..
如果回到 1995 年五月的那十天,我希望我使换行在 JS 中更有意义。
..
如果 ASI 好像给了 JS 有意义的换行,那么要小心不要使用它。

错误

JavaScript 不仅拥有不同的错误 子类型TypeErrorReferenceErrorSyntaxError等等),而且和其他在运行时期间发生的错误相比,它的文法还定义了在编译时被强制执行的特定错误。

尤其是,早就有许多明确的情况应当被作为“早期错误”(编译期间)被捕获和报告。任何直接的语法错误都是一个早期错误(例如,a = ,),而且文法还定义了一些语法上合法但是无论怎样都不允许的东西。

因为你的代码还没有开始执行,这些错误不能使用try..catch捕获;它们只是会在你的程序进行解析/编译时导致失败。

提示: 在语言规范中没有要求浏览器(和开发者工具)到底应当怎样报告错误。所以在下面的错误例子中,对于哪一种错误的子类型会被报告或它包含什么样的错误消息,你可能会在各种浏览器中看到不同的形式,

一个简单的例子是正则表达式字面量中的语法。这里的 JS 语法没有错误,而是不合法的正则表达式将会抛出一个早期错误:

var a = /+foo/;        // 错误!

一个赋值的目标必须是一个标识符(或者一个产生一个或多个标识符的 ES6 解构表达式),所以一个像42这样的值在这个位置上是不合法的,因此可以立即被报告:

var a;
42 = a;        // 错误!

ES5 的strict模式定义了更多的早期错误。例如,在strict模式中,函数参数的名称不能重复:

function foo(a,b,a) { }                    // 还好

function bar(a,b,a) { "use strict"; }    // 错误!

另一种strict模式的早期错误是,一个对象字面量拥有一个以上的同名属性:

(function(){
 "use strict";

    var a = {
        b: 42,
        b: 43
    };            // 错误!
})();

注意: 从语义上讲,这样的错误技术上不是 语法 错误,而是 文法 错误 —— 上面的代码段是语法上合法的。但是因为没有GrammarError类型,一些浏览器使用SyntaxError代替。

过早使用变量

ES6 定义了一个(坦白地说,让人困惑地命名的)新的概念,称为 TDZ(“Temporal Dead Zone” —— 时间死区)

TDZ 指的是代码中还不能使用变量引用的地方,因为它还没有到完成它所必须的初始化。

对此最明白的例子就是 ES6 的let块儿作用域:

{
    a = 2;        // ReferenceError!
    let a;
}

赋值a = 2在变量a(它确实是在{ .. }块儿作用域中)被声明let a初始化之前就访问它,所以a位于 TDZ 中并抛出一个错误。

有趣的是,虽然typeof有一个例外,它对于未声明的变量是安全的(见第一章),但是对于 TDZ 引用却没有这样的安全例外:

{
    typeof a;    // undefined
    typeof b;    // ReferenceError! (TDZ)
    let b;
}

函数参数值

另一个违反 TDZ 的例子可以在 ES6 的参数默认值(参见本系列的 ES6 与未来)中看到:

var b = 3;

function foo( a = 42, b = a + b + 5 ) {
    // ..
}

在赋值中的b引用将在参数b的 TDZ 中发生(不会被拉到外面的b引用),所以它会抛出一个错误。然而,赋值中的a是没有问题的,因为那时参数a的 TDZ 已经过去了。

当使用 ES6 的参数默认值时,如果你省略一个参数,或者你在它的位置上传递一个undefined值的话,就会应用这个默认值。

function foo( a = 42, b = a + 1 ) {
    console.log( a, b );
}

foo();                    // 42 43
foo( undefined );        // 42 43
foo( 5 );                // 5 6
foo( void 0, 7 );        // 42 7
foo( null );            // null 1

注意: 在表达式a + 1null被强制转换为值0。更多信息参考第四章。

从 ES6 参数默认值的角度看,忽略一个参数和传递一个undefined值之间没有区别。然而,有一个办法可以在一些情况下探测到这种区别:

function foo( a = 42, b = a + 1 ) {
    console.log(
        arguments.length, a, b,
        arguments[0], arguments[1]
    );
}

foo();                    // 0 42 43 undefined undefined
foo( 10 );                // 1 10 11 10 undefined
foo( 10, undefined );    // 2 10 11 10 undefined
foo( 10, null );        // 2 10 null 10 null

即便参数默认值被应用到了参数ab上,但是如果没有参数传入这些值槽,数组arguments也不会有任何元素。

反过来,如果你明确地传入一个undefined参数,在数组argument中就会为这个参数存在一个元素,但它将是undefined,并且与同一值槽中的被命名参数将被提供的默认值不同。

虽然 ES6 参数默认值会在数组arguments的值槽和相应的命名参数变量之间造成差异,但是这种脱节也会以诡异的方式发生在 ES5 中:

function foo(a) {
    a = 42;
    console.log( arguments[0] );
}

foo( 2 );    // 42 (链接了)
foo();        // undefined (没链接)

如果你传递一个参数,arguments的值槽和命名的参数总是链接到同一个值上。如果你省略这个参数,就没有这样的链接会发生。

但是在strict模式下,这种链接无论怎样都不存在了:

function foo(a) {
 "use strict";
    a = 42;
    console.log( arguments[0] );
}

foo( 2 );    // 2 (没链接)
foo();        // undefined (没链接)

依赖于这样的链接几乎可以肯定是一个坏主意,而且事实上这种连接本身是一种抽象泄漏,它暴露了引擎的底层实现细节,而不是一个合适的设计特性。

arguments数组的使用已经废弃了(特别是被 ES6...剩余参数取代以后 —— 参见本系列的 ES6 与未来),但这不意味着它都是不好的。

在 ES6 以前,要得到向另一个函数传递的所有参数值的数组,arguments是唯一的办法,它被证实十分有用。你也可以安全地混用被命名参数和arguments数组,只要你遵循一个简单的规则:绝不同时引用一个被命名参数 它相应的arguments值槽。如果你能避开那种错误的实践,你就永远也不会暴露这种易泄漏的链接行为。

function foo(a) {
    console.log( a + arguments[1] ); // 安全!
}

foo( 10, 32 );    // 42

try..finally

你可能很熟悉try..catch块儿是如何工作的。但是你有没有停下来考虑过可以与之成对出现的finally子句呢?事实上,你有没有意识到try只要求catchfinally两者之一,虽然如果有需要它们可以同时出现。

finally子句中的代码 总是 运行的(无论发生什么),而且它总是在try(和catch,如果存在的话)完成后立即运行,在其他任何代码之前。从一种意义上说,你似乎可以认为finally子句中的代码是一个回调函数,无论块儿中的其他代码如何动作,它总是被调用。

那么如果在try子句内部有一个return语句将会怎样?很明显它将返回一个值,对吧?但是调用端代码是在finally之前还是之后才收到这个值呢?

function foo() {
    try {
        return 42;
    }
    finally {
        console.log( "Hello" );
    }

    console.log( "never runs" );
}

console.log( foo() );
// Hello
// 42

return 42立即运行,它设置好foo()调用的完成值。这个动作完成了try子句而finally子句接下来立即运行。只有这之后foo()函数才算完成,所以被返回的完成值交给console.log(..)语句使用。

对于try内部的throw说,行为是完全相同的:

 function foo() {
    try {
        throw 42;
    }
    finally {
        console.log( "Hello" );
    }

    console.log( "never runs" );
}

console.log( foo() );
// Hello
// Uncaught Exception: 42

现在,如果一个异常从finally子句中被抛出(偶然地或有意地),它将会作为这个函数的主要完成值进行覆盖。如果try块儿中的前一个return已经设置好了这个函数的完成值,那么这个值就会被抛弃。

function foo() {
    try {
        return 42;
    }
    finally {
        throw "Oops!";
    }

    console.log( "never runs" );
}

console.log( foo() );
// Uncaught Exception: Oops!

其他的诸如continuebreak这样的非线性控制语句表现出与returnthrow相似的行为是没什么令人吃惊的:

for (var i=0; i<10; i++) {
    try {
        continue;
    }
    finally {
        console.log( i );
    }
}
// 0 1 2 3 4 5 6 7 8 9

console.log(i)语句在continue语句引起的每次循环迭代的末尾运行。然而,它依然是运行在更新语句i++之前的,这就是为什么打印出的值是0..9而非1..10

注意: ES6 在 generator(参见本系列的 异步与性能)中增加了yield语句,generator 从某些方面可以看作是中间的return语句。然而,和return不同的是,一个yield在 generator 被推进前不会完成,这意味着try { .. yield .. }还没有完成。所以附着在其上的finally子句将不会像它和return一起时那样,在yield之后立即运行。

一个在finally内部的return有着覆盖前一个trycatch子句中的return的特殊能力,但是仅在return被明确调用的情况下:

function foo() {
    try {
        return 42;
    }
    finally {
        // 这里没有 `return ..`,所以返回值不会被覆盖
    }
}

function bar() {
    try {
        return 42;
    }
    finally {
        // 覆盖前面的 `return 42`
        return;
    }
}

function baz() {
    try {
        return 42;
    }
    finally {
        // 覆盖前面的 `return 42`
        return "Hello";
    }
}

foo();    // 42
bar();    // undefined
baz();    // "Hello"

一般来说,在函数中省略returnreturn;或者return undefined;是相同的,但是在一个finally块儿内部,return的省略不是用一个return undefined覆盖;它只是让前一个return继续生效。

事实上,如果将打了标签的break(在本章早先讨论过)与finally相组合,我们真的可以制造一种疯狂:

function foo() {
    bar: {
        try {
            return 42;
        }
        finally {
            // 跳出标记为`bar`的块儿
            break bar;
        }
    }

    console.log( "Crazy" );

    return "Hello";
}

console.log( foo() );
// Crazy
// Hello

但是……别这么做。说真的。使用一个finally + 打了标签的break实质上取消了return,这是你在尽最大的努力制造最令人困惑的代码。我打赌没有任何注释可以拯救这段代码。

switch

让我们简单探索一下switch语句,某种if..else if..else..语句链的语法缩写。

switch (a) {
    case 2:
        // 做一些事
        break;
    case 42:
        // 做另一些事
        break;
    default:
        // 这里是后备操作
}

如你所见,它对a求值一次,然后将结果值与每个case表达式进行匹配(这里只是一些简单的值表达式)。如果找到一个匹配,就会开始执行那个匹配的case,它将会持续执行直到遇到一个break或者遇到switch块儿的末尾。

这些可能不会令你吃惊,但是关于switch,有几个你以前可能从没注意过的奇怪的地方。

首先,在表达式a和每一个case表达式之间的匹配与===算法(见第四章)是相同的。switch经常在case语句中使用绝对值,就像上面展示的,因此严格匹配是恰当的。

然而,你也许希望允许宽松等价(也就是==,见第四章),而这么做你需要“黑”一下switch语句:

var a = "42";

switch (true) {
    case a == 10:
        console.log( "10 or '10'" );
        break;
    case a == 42:
        console.log( "42 or '42'" );
        break;
    default:
        // 永远不会运行到这里
}
// 42 or '42'

这可以工作是因为case子句可以拥有任何表达式(不仅是简单值),这意味着它将用这个表达式的结果与测试表达式(true)进行严格匹配。因为这里a == 42的结果为true,所以匹配成功。

尽管==switch的匹配本身依然是严格的,在这里是truetrue之间。如果case表达式得出 truthy 的结果而不是严格的true,它就不会工作。例如如果在你的表达式中使用||&&这样的“逻辑操作符”,这就可能咬到你:

var a = "hello world";
var b = 10;

switch (true) {
    case (a || b == 10):
        // 永远不会运行到这里
        break;
    default:
        console.log( "Oops" );
}
// Oops

因为(a || b == 10)的结果是"hello world"而不是true,所以严格匹配失败了。这种情况下,修改的方法是强制表达式明确成为一个truefalse,比如case !!(a || b == 10):(见第四章)。

最后,default子句是可选的,而且它不一定非要位于末尾(虽然那是一种强烈的惯例)。即使是在default子句中,是否遇到break的规则也是一样的:

var a = 10;

switch (a) {
    case 1:
    case 2:
        // 永远不会运行到这里
    default:
        console.log( "default" );
    case 3:
        console.log( "3" );
        break;
    case 4:
        console.log( "4" );
}
// default
// 3

注意: 就像我们前面讨论的打标签的breakcase子句内部的break也可以被打标签。

这段代码的处理方式是,它首先通过所有的case子句,没有找到匹配,然后它回到default子句开始执行。因为这里没有break,它会继续走进已经被跳过的块儿case 3,在遇到那个break后才会停止。

虽然这种有些迂回的逻辑在 JavaScript 中是明显可能的,但是它几乎不可能制造出合理或易懂的代码。要对你自己是否想要创建这种环状的逻辑流程保持怀疑,如果你真的想要这么做,确保你留下了大量的代码注释来解释你要做什么!

复习

JavaScript 文法有相当多的微妙之处,我们作为开发者应当比平常多花一点儿时间来关注它。一点儿努力可以帮助你巩固对这个语言更深层次的知识。

语句和表达式在英语中有类似的概念 —— 语句就像句子,而表达式就像短语。表达式可以是纯粹的/自包含的,或者他们可以有副作用。

JavaScript 文法层面的语义用法规则(也就是上下文),是在纯粹的语法之上的。例如,用于你程序中不同地方的{ }可以意味着块儿,object字面量,(ES6)解构语句,或者(ES6)被命名的函数参数。

JavaScript 操作符都有严格定义的优先级(哪一个操作符首先结合)和结合性(多个操作符表达式如何隐含地分组)规则。一旦你学会了这些规则,你就可以自己决定优先级/结合性是否是为了它们自己有利而 过于明确,或者它们是否会对编写更短,更干净的代码有所助益。

ASI(自动分号插入)是一种内建在 JS 引擎找中的解析器纠错机制,它允许 JS 引擎在特定的环境下,在需要;但是被省略了的地方,并且插入可以纠正解析错误时,插入一个;。有一场争论是关于这种行为是否暗示着大多数;都是可选的(而且为了更干净的代码可以/应当省略),或者是否它意味着省略它们是在制造 JS 引擎帮你扫清的错误。

JavaScript 有几种类型的错误,但很少有人知道它有两种类别的错误:“早期”(编译器抛出的不可捕获的)和“运行时”(可以try..catch的)。所有在程序运行之前就使它停止的语法错误都明显是早期错误,但也有一些别的错误。

函数参数值与它们正式声明的命名参数之间有一种有趣的联系。明确地说,如果你不小心,arguments数组会有一些泄漏抽象行为的坑。尽可能避开arguments,但如果你必须使用它,那就设法避免同时使用arguments中带有位置的值槽,和相同参数的命名参数。

附着在try(或try..catch)上的finall在执行处理顺序上提供了一些非常有趣的能力。这些能力中的一些可以很有帮助,但是它也可能制造许多困惑,特别是在与打了标签的块儿组合使用时。像往常一样,为了更好更干净的代码而使用finally,不是为了显得更聪明或更糊涂。

switchif..else if..语句提供了一个不错的缩写形式,但是要小心许多常见的关于它的简化假设。如果你不小心,会有几个奇怪的地方绊倒你,但是switch手上也有一些隐藏的高招!

你不懂 JS:this 与对象原型

来源:你不懂 JS:this 与对象原型

你不懂 JS:this 与对象原型 第一章:this 是什么?

JavaScript 中最令人困惑的机制之一就是this关键字。它是一个在每个函数作用域中自动定义的特殊标识符关键字,但即便是一些老练的开发者也对它到底指向什么感到困扰。

任何足够 先进 的技术都跟魔法没有区别。-- Arthur C. Clarke

JavaScript 的this机制实际上没有 那么 先进,但是开发者们总是在大脑中引用这句话来表达“复杂”和“混乱”,毫无疑问,如果没有清晰的理解,在 你的 困惑中this可能看起来就是彻头彻尾的魔法。

注意: “this”这个词是在一般的论述中极常用的代词。所以,特别是在口头论述中,很难确定我们是在将“this”作为一个代词使用,还是在将它作为一个实际的关键字识别符使用。为了表意清晰,我会总是使用this来代表特殊的关键字,而在其他情况下使用“this”或 this 或 this。

为什么用 this

如果对于那些老练的 JavaScript 开发者来说this机制都是如此的令人费解,那么有人会问为什么这种机制会有用?它带来的麻烦不是比好处多吗?在讲解 如何 有用之前,我们应当先来看看 为什么 有用。

让我们试着展示一下this的动机和用途:

function identify() {
    return this.name.toUpperCase();
}

function speak() {
    var greeting = "Hello, I'm " + identify.call( this );
    console.log( greeting );
}

var me = {
    name: "Kyle"
};

var you = {
    name: "Reader"
};

identify.call( me ); // KYLE
identify.call( you ); // READER

speak.call( me ); // Hello, I'm KYLE
speak.call( you ); // Hello, I'm READER

如果这个代码段 如何 工作让你困惑,不要担心!我们很快就会讲解它。只是简要地将这些问题放在旁边,以便于我们可以更清晰的探究 为什么

这个代码片段允许identify()speak()函数对多个 环境 对象(meyou)进行复用,而不是针对每个对象定义函数的分离版本。

与使用this相反地,你可以明确地将环境对象传递给identify()speak()

function identify(context) {
    return context.name.toUpperCase();
}

function speak(context) {
    var greeting = "Hello, I'm " + identify( context );
    console.log( greeting );
}

identify( you ); // READER
speak( me ); // Hello, I'm KYLE

然而,this机制提供了更优雅的方式来隐含地“传递”一个对象引用,导致更加干净的 API 设计和更容易的复用。

你的使用模式越复杂,你就会越清晰地看到:将执行环境作为一个明确参数传递,通常比传递this执行环境要乱。当我们探索对象和原型时,你将会看到一组可以自动引用恰当执行环境对象的函数是多么有用。

困惑

我们很快就要开始讲解this是如何 实际 工作的,但我们首先要摒弃一些误解——它实际上 不是 如何工作的。

在开发者们用太过于字面的方式考虑“this”这个名字时就会产生困惑。这通常会产生两种臆测,但都是不对的。

它自己

第一种常见的倾向是认为this指向函数自己。至少,这是一种语法上的合理推测。

为什么你想要在函数内部引用它自己?最通常的理由是递归(在函数内部调用它自己)这样的情形,或者是一个在第一次被调用时会解除自己绑定的事件处理器。

初次接触 JS 机制的开发者们通常认为,将函数作为一个对象(JavaScript 中所有的函数都是对象!),可以让你在方法调用之间储存 状态(属性中的值)。这当然是可能的,而且有一些有限的用处,但这本书的其余部分将会阐述许多其他的模式,提供比函数对象 更好 的地方来存储状态。

过一会儿我们将探索一个模式,来展示this是如何不让一个函数像我们可能假设的那样,得到它自身的引用的。

考虑下面的代码,我们试图追踪函数(foo)被调用了多少次:

function foo(num) {
    console.log( "foo: " + num );

    // 追踪`foo`被调用了多少次
    this.count++;
}

foo.count = 0;

var i;

for (i=0; i<10; i++) {
    if (i > 5) {
        foo( i );
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// `foo`被调用了多少次?
console.log( foo.count ); // 0 -- 这他妈怎么回事……?

foo.count 依然0, 即便四个console.log语句明明告诉我们foo(..)实际上被调用了四次。这种失败来源于对于this (在this.count++中)的含义进行了 过于字面化 的解释。

当代码执行foo.count = 0时,它确实在函数对象foo中加入了一个count属性。但是对于函数内部的this.count引用,this其实 根本就不 指向那个函数对象,即便属性名称一样,但根对象也不同,因而产生了混淆。

注意: 一个负责任的开发者 应当 在这里提出一个问题:“如果我递增的count属性不是我以为的那个,那是哪个count被我递增了?”。实际上,如果他再挖的深一些,他会发现自己不小心创建了一个全局变量count(第二章解释了这是 如何 发生的),而且它当前的值是NaN。当然,一旦他发现这个不寻常的结果后,他会有一堆其他的问题:“它怎么是全局的?为什么它是NaN而不是某个正确的计数值?”。(见第二章)

与停在这里来深究为什么this引用看起来不是如我们 期待 的那样工作,并且回答那些尖锐且重要的问题相反,许多开发者简单地完全回避这个问题,转向一些其他的另类解决方法,比如创建另一个对象来持有count属性:

function foo(num) {
    console.log( "foo: " + num );

    // 追踪 foo 被调用了多少次
    data.count++;
}

var data = {
    count: 0
};

var i;

for (i=0; i<10; i++) {
    if (i > 5) {
        foo( i );
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// foo 被调用了多少次?
console.log( data.count ); // 4

虽然这种方式确实“解决”了问题,但不幸的是它简单地忽略了真正的问题——缺乏对于this的含义和其工作方式上的理解——反而退回到了一个他更加熟悉的机制的舒适区:词法作用域。

注意: 词法作用域是一个完善且有用的机制;我不是在用任何方式贬低它的作用(参见本系列的 "作用域与闭包")。但在如何使用this这个问题上总是靠 ,而且通常都犯 ,并不是一个退回到词法作用域,而且从不学习 为什么 this不跟你合作的好理由。

为了从函数对象内部引用它自己,一般来说通过this是不够的。你用通常需要通过一个指向它的词法标识符(变量)得到函数对象的引用。

考虑这两个函数:

function foo() {
    foo.count = 4; // `foo` 引用它自己
}

setTimeout( function(){
    // 匿名函数(没有名字)不能引用它自己
}, 10 );

第一个函数,称为“命名函数”,foo是一个引用,可以用于在它内部引用自己。

但是在第二个例子中,传递给setTimeout(..)的回调函数没有名称标识符(所以被称为“匿名函数”),所以没有恰当的办法引用函数对象自己。

注意: 在函数中有一个老牌儿但是现在被废弃的,而且令人皱眉头的arguments.callee引用 指向当前正在执行的函数的函数对象。这个引用通常是匿名函数在自己内部访问函数对象的唯一方法。然而,最佳的办法是完全避免使用匿名函数,至少是对于那些需要自引用的函数,而使用命名函数(表达式)。arguments.callee已经被废弃而且不应该再使用。

对于当前我们的例子来说,另一个 好用的 解决方案是在每一个地方都使用foo标识符作为函数对象的引用,而根本不用this

function foo(num) {
    console.log( "foo: " + num );

    // 追踪`foo`被调用了多少次
    foo.count++;
}

foo.count = 0;

var i;

for (i=0; i<10; i++) {
    if (i > 5) {
        foo( i );
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// `foo`被调用了多少次?
console.log( foo.count ); // 4

然而,这种方法也类似地回避了对this真正 理解,而且完全依靠变量foo的词法作用域。

另一种解决问题的方法是强迫this指向foo函数对象:

function foo(num) {
    console.log( "foo: " + num );

    // 追踪`foo`被调用了多少次
    // 注意:由于`foo`的被调用方式(见下方),`this`现在确实是`foo`
    this.count++;
}

foo.count = 0;

var i;

for (i=0; i<10; i++) {
    if (i > 5) {
        // 使用 `call(..)`,我们可以保证`this`指向函数对象(`foo`)
        foo.call( foo, i );
    }
}
// foo: 6
// foo: 7
// foo: 8
// foo: 9

// `foo`被调用了多少次?
console.log( foo.count ); // 4

与回避this相反,我们接受它。 我们将会更完整地讲解这样的技术 如何 工作,所以如果你依然有点儿糊涂,不要担心!

它的作用域

第二常见的对this的含义的误解,是它不知怎的指向了函数的作用域。这是一个刁钻的问题,因为在某一种意义上它有正确的部分,而在另外一种意义上,它是严重的误导。

明确地说,this不会以任何方式指向函数的 词法作用域。作用域好像是一个将所有可用标识符作为属性的对象,这从内部来说是对的。但是 JavasScript 代码不能访问作用域“对象”。它是 引擎 的内部实现。

考虑下面代码,它(失败的)企图跨越这个边界,用this来隐含地引用函数的词法作用域:

function foo() {
    var a = 2;
    this.bar();
}

function bar() {
    console.log( this.a );
}

foo(); //undefined

这个代码段里不只有一个错误。虽然它看起来是在故意瞎搞,但你看到的这段代码,是从公共的帮助论坛社区中被交换的真实代码中提取出来的。真是难以想象对this的臆想是多么的误导人。

首先,试图通过this.bar()来引用bar()函数。它几乎可以说是 碰巧 能够工作,我们过一会儿再解释它是 如何 工作的。调用bar()最自然的方式是省略开头的 this.,而仅对标识符进行词法引用。

然而,写下这段代码的开发者试图用thisfoo()bar()的词法作用域间建立一座桥,使得bar()可以访问foo()内部作用域的变量a这样的桥是不可能的。 你不能使用this引用在词法作用域中查找东西。这是不可能的。

每当你感觉自己正在试图使用this来进行词法作用域的查询时,提醒你自己:这里没有桥

什么是this

我们已经列举了各种不正确的臆想,现在让我们把注意力this机制是如何真正工作的。

我们早先说过,this不是编写时绑定,而是运行时绑定。它依赖于函数调用的上下文条件。this绑定和函数声明的位置无关,反而和函数被调用的方式有关。

当一个函数被调用时,会建立一个活动记录,也称为执行环境。这个记录包含函数是从何处(call-stack)被调用的,函数是 如何 被调用的,被传递了什么参数等信息。这个记录的属性之一,就是在函数执行期间将被使用的this引用。

下一章中,我们将会学习寻找函数的 调用点(call-site) 来判定它的执行如何绑定this

复习

对于那些没有花时间学习this绑定机制如何工作的 JavaScript 开发者来说,this绑定一直是困惑的根源。猜测,试错,或者盲目地从 Stack Overflow 的回答中复制粘贴,都不是有效或正确利用this这么重要的机制的方法。

为了学习this,你必须首先学习this不是 什么,不论是哪种把你误导至何处的臆测或误解。this既不是函数自身的引用,也不是函数词法作用域的引用。

this实际上是在函数被调用时建立的一个绑定,它指向 什么 是完全由函数被调用的调用点来决定的。

你不懂 JS:this 与对象原型 第二章:this 豁然开朗!

在第一章中,我们摒弃了种种对this的误解,并且学习了this是一个完全根据调用点(函数是如何被调用的)而为每次函数调用建立的绑定。

调用点(Call-site)

为了理解this绑定,我们不得不理解调用点:函数在代码中被调用的位置(不是被声明的位置)。我们必须考察调用点来回答这个问题:这个this指向什么?

一般来说寻找调用点就是:“找到一个函数是在哪里被调用的”,但不总是那么简单,比如某些特定的编码模式会使 真正的 调用点变得不那么明确。

考虑 调用栈(call-stack) (使我们到达当前执行位置而被调用的所有方法的堆栈)是十分重要的。我们关心的调用点就位于当前执行中的函数 之前 的调用。

我们来展示一下调用栈和调用点:

function baz() {
    // 调用栈是: `baz`
    // 我们的调用点是 global scope(全局作用域)

    console.log( "baz" );
    bar(); // <-- `bar`的调用点
}

function bar() {
    // 调用栈是: `baz` -> `bar`
    // 我们的调用点位于`baz`

    console.log( "bar" );
    foo(); // <-- `foo`的调用点
}

function foo() {
    // 调用栈是: `baz` -> `bar` -> `foo`
    // 我们的调用点位于`bar`

    console.log( "foo" );
}

baz(); // <-- `baz`的调用点

在分析代码来寻找(从调用栈中)真正的调用点时要小心,因为它是影响this绑定的唯一因素。

注意: 你可以通过按顺序观察函数的调用链在你的大脑中建立调用栈的视图,就像我们在上面代码段中的注释那样。但是这很痛苦而且易错。另一种观察调用栈的方式是使用你的浏览器的调试工具。大多数现代的桌面浏览器都内建开发者工具,其中就包含 JS 调试器。在上面的代码段中,你可以在调试工具中为foo()函数的第一行设置一个断点,或者简单的在这第一行上插入一个debugger语句。当你运行这个网页时,调试工具将会停止在这个位置,并且向你展示一个到达这一行之前所有被调用过的函数的列表,这就是你的调用栈。所以,如果你想调查this绑定,可以使用开发者工具取得调用栈,之后从上向下找到第二个记录,那就是你真正的调用点。

仅仅是规则

现在我们将注意力转移到调用点 如何 决定在函数执行期间this指向哪里。

你必须考察 call-site 并判定 4 种规则中的哪一个适用。我们将首先独立的解释一下这 4 种规则中的每一种,之后我们来展示一下如果有多种规则可以适用调用点时,它们的优先顺序。

默认绑定(Default Binding)

我们要考察的第一种规则来源于函数调用的最常见的情况:独立函数调用。可以认为这种this规则是在没有其他规则适用时的默认规则。

考虑这个代码段:

function foo() {
    console.log( this.a );
}

var a = 2;

foo(); // 2

第一点要注意的,如果你还没有察觉到,是在全局作用域中的声明变量,也就是var a = 2,是全局对象的同名属性的同义词。它们不是互相拷贝对方,它们 就是 彼此。正如一个硬币的两面。

第二,我们看到当foo()被调用时,this.a解析为我们的全局变量a。为什么?因为在这种情况下,对此方法调用的this实施了 默认绑定,所以使this指向了全局对象。

我们怎么知道这里适用 默认绑定 ?我们考察调用点来看看foo()是如何被调用的。在我们的代码段中,foo()是被一个直白的,毫无修饰的函数引用调用的。没有其他的我们将要展示的规则适用于这里,所以 默认绑定 在这里适用。

如果strict mode在这里生效,那么对于 默认绑定 来说全局对象是不合法的,所以this将被设置为undefined

function foo() {
 "use strict";

    console.log( this.a );
}

var a = 2;

foo(); // TypeError: `this` is `undefined`

一个微妙但是重要的细节是:即便所有的this绑定规则都是完全基于调用点,如果foo()内容 没有在strint mode下执行,对于 默认绑定 来说全局对象是 唯一 合法的;foo()的 call-site 的strict mode状态与此无关。

function foo() {
    console.log( this.a );
}

var a = 2;

(function(){
 "use strict";

    foo(); // 2
})();

注意: 在你的代码中故意混用strict mode和非strict mode通常是让人皱眉头的。你的程序整体可能应当不是 Strict 就是 非 Strict。然而,有时你可能会引用与你的 Strict 模式不同的第三方包,所以对这些微妙的兼容性细节要多加小心。

隐含绑定(Implicit Binding)

另一种要考虑的规则是:调用点是否有一个环境对象(context object),也称为拥有者(owning)或容器(containing)对象,虽然这些名词可能有些误导人。

考虑这段代码:

function foo() {
    console.log( this.a );
}

var obj = {
    a: 2,
    foo: foo
};

obj.foo(); // 2

首先,注意foo()被声明然后作为引用属性添加到obj上的方式。无论foo()是否一开始就在obj上被声明,还是后来作为引用添加(如上面代码所示),都是这个 函数obj所“拥有”或“包含”。

然而,调用点 使用 obj环境来 引用 函数,所以你 可以说 obj对象在函数被调用的时间点上“拥有”或“包含”这个 函数引用

不论你怎样称呼这个模式,在foo()被调用的位置上,它被冠以一个指向obj的对象引用。当一个方法引用存在一个环境对象时,隐式绑定 规则会说:是这个对象应当被用于这个函数调用的this绑定。

因为objfoo()调用的this,所以this.a就是obj.a的同义词。

只有对象属性引用链的最后一层是影响调用点的。比如:

function foo() {
    console.log( this.a );
}

var obj2 = {
    a: 42,
    foo: foo
};

var obj1 = {
    a: 2,
    obj2: obj2
};

obj1.obj2.foo(); // 42

隐含地丢失(Implicitly Lost)

this绑定最常让人沮丧的事情之一,就是当一个 隐含绑定 丢失了它的绑定,这通常意味着它会退回到 默认绑定, 根据strict mode的状态,结果不是全局对象就是undefined

考虑这段代码:

function foo() {
    console.log( this.a );
}

var obj = {
    a: 2,
    foo: foo
};

var bar = obj.foo; // 函数引用!

var a = "oops, global"; // `a`也是一个全局对象的属性

bar(); // "oops, global"

尽管bar似乎是obj.foo的引用,但实际上它只是另一个foo自己的引用而已。另外,起作用的调用点是bar(),一个直白,毫无修饰的调用,因此 默认绑定 适用于这里。

这种情况发生的更加微妙,更常见,更意外的方式,是当我们考虑传递一个回调函数时:

function foo() {
    console.log( this.a );
}

function doFoo(fn) {
    // `fn` 只不过`foo`的另一个引用

    fn(); // <-- 调用点!
}

var obj = {
    a: 2,
    foo: foo
};

var a = "oops, global"; // `a`也是一个全局对象的属性

doFoo( obj.foo ); // "oops, global"

参数传递仅仅是一种隐含的赋值,而且因为我们在传递一个函数,它是一个隐含的引用赋值,所以最终结果和我们前一个代码段一样。

那么如果接收你所传递回调的函数不是你的,而是语言内建的呢?没有区别,同样的结果。

function foo() {
    console.log( this.a );
}

var obj = {
    a: 2,
    foo: foo
};

var a = "oops, global"; // `a`也是一个全局对象的属性

setTimeout( obj.foo, 100 ); // "oops, global"

把这个粗糙的,理论上的setTimeout()假想实现当做 JavaScript 环境内建的实现的话:

function setTimeout(fn,delay) {
  // (通过某种方法)等待`delay`毫秒
    fn(); // <-- 调用点!
}

正如我们刚刚看到的,我们的回调函数丢掉他们的this绑定是十分常见的事情。但是另一种this使我们吃惊的方式是,接收我们的回调的函数故意改变调用的this。那些很受欢迎的事件处理 JavaScript 包就十分喜欢强制你的回调的this指向触发事件的 DOM 元素。虽然有时这很有用,但其他时候这简直能气死人。不幸的是,这些工具很少给你选择。

不管哪一种意外改变this的方式,你都不能真正地控制你的回调函数引用将如何被执行,所以你(还)没有办法控制调用点给你一个故意的绑定。我们很快就会看到一个方法,通过 固定 this来解决这个问题。

明确绑定(Explicit Binding)

用我们刚看到的 隐含绑定,我们不得不改变目标对象使它自身包含一个对函数的引用,而后使用这个函数引用属性来间接地(隐含地)将this绑定到这个对象上。

但是,如果你想强制一个函数调用使用某个特定对象作为this绑定,而不在这个对象上放置一个函数引用属性呢?

JavaScript 语言中的“所有”函数都有一些工具(通过他们的[[Prototype]]——待会儿详述)可以用于这个任务。特别是,函数拥有call(..)apply(..)方法。从技术上讲,JavaScript 宿主环境有时会提供一些很特别的函数,它们没有这些功能,但这很少见。绝大多数被提供的函数,当然还有你将创建的所有的函数,都可以访问call(..)apply(..)

这些工具如何工作?它们接收的第一个参数都是一个用于this的对象,之后使用这个指定的this来调用函数。因为你已经直接指明你想让this是什么,所以我们称这种方式为 明确绑定(explicit binding)

考虑这段代码:

function foo() {
    console.log( this.a );
}

var obj = {
    a: 2
};

foo.call( obj ); // 2

通过foo.call(..)使用 明确绑定 来调用foo,允许我们强制函数的this指向obj

如果你传递一个简单原始类型值(stringboolean,或 number类型)作为this绑定,那么这个原始类型值会被包装在它的对象类型中(分别是new String(..)new Boolean(..),或new Number(..))。这通常称为“boxing(封箱)”。

注意:this绑定的角度讲,call(..)apply(..)是完全一样的。它们确实在处理其他参数上的方式不同,但那不是我们当前关心的。

不幸的是,单独依靠 明确绑定 仍然不能为我们先前提到的问题提供解决方案,也就是函数“丢失”自己原本的this绑定,或者被第三方框架覆盖,等等问题。

硬绑定(Hard Binding)

但是有一个 明确绑定 的变种确实可以实现这个技巧。考虑这段代码:

function foo() {
    console.log( this.a );
}

var obj = {
    a: 2
};

var bar = function() {
    foo.call( obj );
};

bar(); // 2
setTimeout( bar, 100 ); // 2

// `bar`将`foo`的`this`硬绑定到`obj`
// 所以它不可以被覆盖
bar.call( window ); // 2

我们来看看这个变种是如何工作的。我们创建了一个函数bar(),在它的内部手动调用foo.call(obj),由此强制this绑定到obj并调用foo。无论你过后怎样调用函数bar,它总是手动使用obj调用foo。这种绑定即明确又坚定,所以我们称之为 硬绑定(hard binding)

硬绑定 将一个函数包装起来的最典型的方法,是为所有传入的参数和传出的返回值创建一个通道:

function foo(something) {
    console.log( this.a, something );
    return this.a + something;
}

var obj = {
    a: 2
};

var bar = function() {
    return foo.apply( obj, arguments );
};

var b = bar( 3 ); // 2 3
console.log( b ); // 5

另一种表达这种模式的方法是创建一个可复用的帮助函数:

function foo(something) {
    console.log( this.a, something );
    return this.a + something;
}

// 简单的`bind`帮助函数
function bind(fn, obj) {
    return function() {
        return fn.apply( obj, arguments );
    };
}

var obj = {
    a: 2
};

var bar = bind( foo, obj );

var b = bar( 3 ); // 2 3
console.log( b ); // 5

由于 硬绑定 是一个如此常用的模式,它已作为 ES5 的内建工具提供:Function.prototype.bind,像这样使用:

function foo(something) {
    console.log( this.a, something );
    return this.a + something;
}

var obj = {
    a: 2
};

var bar = foo.bind( obj );

var b = bar( 3 ); // 2 3
console.log( b ); // 5

bind(..)返回一个硬编码的新函数,它使用你指定的this环境来调用原本的函数。

注意: 在 ES6 中,bind(..)生成的硬绑定函数有一个名为.name的属性,它源自于原始的 目标函数(target function)。举例来说:bar = foo.bind(..)应该会有一个bar.name属性,它的值为"bound foo",这个值应当会显示在调用栈轨迹的函数调用名称中。

API 调用的“环境”

确实,许多包中的函数,和许多在 JavaScript 语言以及宿主环境中的内建函数,都提供一个可选参数,通常称为“环境(context)”,这种设计作为一种替代方案来确保你的回调函数使用特定的this而不必非得使用bind(..)

举例来说:

function foo(el) {
    console.log( el, this.id );
}

var obj = {
    id: "awesome"
};

// 使用`obj`作为`this`来调用`foo(..)`
[1, 2, 3].forEach( foo, obj ); // 1 awesome  2 awesome  3 awesome

从内部来说,这种类型的函数几乎可以确定是通过call(..)apply(..)使用了 明确绑定 来节省你的麻烦。

new绑定(new Binding)

第四种也是最后一种this绑定规则,需要我们重新思考关于 JavaScript 中对函数和对象的常见误解。

在传统的面向类语言中,“构造器”是附着在类上的一种特殊方法,当使用new操作符来初始化一个类时,这个类的构造器就会被调用。通常看起来像这样:

something = new MyClass(..);

JavaScript 拥有new操作符,而且它使用的代码模式看起来基本和我们在面向类语言中看到的一样;大多数开发者猜测 JavaScript 机制是某种相似的东西。但是,实际上 JavaScript 的机制和new在 JS 中的用法所暗示的面向类的功能 没有任何联系

首先,让我们重新定义 JavaScript 的“构造器”是什么。在 JS 中,构造器 仅仅是一个函数,它们偶然地被前置的new操作符调用。它们不依附于类,它们也不初始化一个类。它们甚至不是一种特殊的函数类型。它们本质上只是一般的函数,在被使用new来调用时改变了行为。

比如,Number(..)函数作为一个构造器来说,引用 ES5.1 的语言规范:

15.7.2 The Number 构造器

当 Number 作为 new 表达式的一部分被调用时,它是一个构造器:它初始化这个新创建的对象。

所以,任何关联在对象上的函数,包括像Number(..)(见第三章)这样的内建对象函数都可以在前面加上new来被调用,这使函数调用成为一个 构造器调用(constructor call)。这是一个重要且微妙的区别:实际上不存在“构造器函数”这样的东西,而只有函数的构造器调用。

当在函数前面被加入new调用时,也就是构造器调用时,下面这些事情会自动完成:

  1. 一个全新的对象会凭空创建(就是被构建)
  2. 这个新构建的对象会被接入原形链([[Prototype]]-linked)
  3. 这个新构建的对象被设置为函数调用的this绑定
  4. 除非函数返回一个它自己的其他 对象,这个被new调用的函数将 自动 返回这个新构建的对象。

步骤 1,3 和 4 是我们当下要讨论的。我们现在跳过第 2 步,在第五章回来讨论。

考虑这段代码:

function foo(a) {
    this.a = a;
}

var bar = new foo( 2 );
console.log( bar.a ); // 2

通过在前面使用new来调用foo(..),我们构建了一个新的对象并这个新对象作为foo(..)调用的thisnew是函数调用可以绑定this的最后一种方式,我们称之为 new 绑定(new binding)

一切皆有顺序

如此,我们已经揭示了函数调用中的 4 种this绑定规则。你需要做的 一切 就是找到调用点然后考察哪一种规则适用于它。但是,如果调用点上有多种规则都适用呢?这些规则必须有一个优先顺序,我们下面就来展示这些规则以什么样的优先顺序实施。

很显然,默认绑定 在 4 种规则中拥有最低的优先权。所以我们先把它放在一边。

隐含绑定明确绑定 哪一个更优先呢?我们来测试一下:

function foo() {
    console.log( this.a );
}

var obj1 = {
    a: 2,
    foo: foo
};

var obj2 = {
    a: 3,
    foo: foo
};

obj1.foo(); // 2
obj2.foo(); // 3

obj1.foo.call( obj2 ); // 3
obj2.foo.call( obj1 ); // 2

所以, 明确绑定 的优先权要高于 隐含绑定,这意味着你应当在考察 隐含绑定 之前 首先 考察 明确绑定 是否适用。

现在,我们只需要搞清楚 new 绑定 的优先级位于何处。

function foo(something) {
    this.a = something;
}

var obj1 = {
    foo: foo
};

var obj2 = {};

obj1.foo( 2 );
console.log( obj1.a ); // 2

obj1.foo.call( obj2, 3 );
console.log( obj2.a ); // 3

var bar = new obj1.foo( 4 );
console.log( obj1.a ); // 2
console.log( bar.a ); // 4

好了,new 绑定 的优先级要高于 隐含绑定。那么你觉得 new 绑定 的优先级较之于 明确绑定 是高还是低呢?

注意: newcall/apply不能同时使用,所以new foo.call(obj1)是不允许的,也就是不能直接对比测试 new 绑定明确绑定。但是我们依然可以使用 硬绑定 来测试这两个规则的优先级。

在我们进入代码中探索之前,回想一下 硬绑定 物理上是如何工作的,也就是Function.prototype.bind(..)创建了一个新的包装函数,这个函数被硬编码为忽略它自己的this绑定(不管它是什么),转而手动使用我们提供的。

因此,这似乎看起来很明显,硬绑定明确绑定的一种)的优先级要比 new 绑定 高,而且不能被new覆盖。

我们检验一下:

function foo(something) {
    this.a = something;
}

var obj1 = {};

var bar = foo.bind( obj1 );
bar( 2 );
console.log( obj1.a ); // 2

var baz = new bar( 3 );
console.log( obj1.a ); // 2
console.log( baz.a ); // 3

哇!bar是硬绑定到obj1 的,但是new bar(3)没有想我们期待的那样将obj1.a变为3。反而,硬绑定(到obj1)的bar(..)调用 可以new所覆盖。因为new被实施,我们得到一个名为baz的新创建的对象,而且我们确实看到baz.a的值为3

如果你回头看看我们的“山寨”绑定帮助函数,这很令人吃惊:

function bind(fn, obj) {
    return function() {
        fn.apply( obj, arguments );
    };
}

如果你推导这段帮助代码如何工作,会发现对于new操作符调用来说没有办法去像我们观察到的那样,将绑定到obj的硬绑定覆盖。

但是 ES5 的内建Function.prototype.bind(..)更加精妙,实际上十分精妙。这里是 MDN 网页上为bind(..)提供的 polyfill(低版本兼容填补工具):

if (!Function.prototype.bind) {
    Function.prototype.bind = function(oThis) {
        if (typeof this !== "function") {
            // 可能的与 ECMAScript 5 内部的 IsCallable 函数最接近的东西
            throw new TypeError( "Function.prototype.bind - what " +
                "is trying to be bound is not callable"
            );
        }

        var aArgs = Array.prototype.slice.call( arguments, 1 ),
            fToBind = this,
            fNOP = function(){},
            fBound = function(){
                return fToBind.apply(
                    (
                        this instanceof fNOP &&
                        oThis ? this : oThis
                    ),
                    aArgs.concat( Array.prototype.slice.call( arguments ) )
                );
            }
        ;

        fNOP.prototype = this.prototype;
        fBound.prototype = new fNOP();

        return fBound;
    };
}

注意: 在 ES5 中,就将与new一起使用的硬绑定函数(参照下面来看为什么这有用)而言,上面的bind(..)polyfill 与内建的bind(..)是不同的。因为 polyfill 不能像内建工具那样,没有.prototype就能创建函数,这里使用了一些微妙而间接的方法来近似模拟相同的行为。如果你打算将硬绑定函数和new一起使用而且依赖于 polyfill,应当多加小心。

允许new进行覆盖的部分是这里:

this instanceof fNOP &&
oThis ? this : oThis

// ... 和:

fNOP.prototype = this.prototype;
fBound.prototype = new fNOP();

我们不会实际深入解释这个花招儿是如何工作的(这很复杂而且超出了我们当前的讨论范围),但实质上这个工具判断硬绑定函数是否是用new被调用的(结果是用一个它新构建的对象作为this),如果是,它就用那个新构建的this而非先前为this指定的 硬绑定

为什么new可以覆盖 硬绑定 这件事很有用?

这种行为的主要原因是,创建一个实质上忽略this硬绑定 而预先设置一部分或所有的参数的函数(这个函数可以与new一起使用来构建对象)。bind(..)的一个能力是,任何在第一个this绑定参数之后被传入的参数,默认地作为当前函数的标准参数(技术上这称为“局部应用(partial application)”,是一种“柯里化(currying)”)。

比如:

function foo(p1,p2) {
    this.val = p1 + p2;
}

// 在这里使用`null`是因为在这种场景下我们不关心`this`的 hard-binding
// 而且反正它将会被`new`调用覆盖掉!
var bar = foo.bind( null, "p1" );

var baz = new bar( "p2" );

baz.val; // p1p2

判定 this

现在,我们可以按照优先顺序来总结一下从函数调用的调用点来判定this的规则了。按照这个顺序来问问题,然后在第一个规则适用的地方停下。

  1. 函数是和new一起被调用的吗(new 绑定)?如果是,this就是新构建的对象。

    var bar = new foo()

  2. 函数是用callapply被调用(明确绑定),甚至是隐藏在bind 硬绑定 之中吗?如果是,this就是明确指定的对象。

    var bar = foo.call( obj2 )

  3. 函数是用环境对象(也称为拥有者或容器对象)被调用的吗(隐含绑定)?如果是,this就是那个环境对象。

    var bar = obj1.foo()

  4. 否则,使用默认的this默认绑定)。如果在strict mode下,就是undefined,否则是global对象。
    var bar = foo()

以上,就是理解对于普通的函数调用来说的this绑定规则所需的全部。是的···几乎是全部。

绑定的特例

正如通常的那样,对于这些“规则”有一些 例外

在某些场景下this绑定会让人很吃惊,比如在你试图实施一种绑定,然而最终得到的是 默认绑定 规则的绑定行为(见前面的内容)。

被忽略的this

如果你传递nullundefined作为callapplybindthis绑定参数,那么这些值会被忽略掉,取而代之的是 默认绑定 规则将适用于这个调用。

function foo() {
    console.log( this.a );
}

var a = 2;

foo.call( null ); // 2

为什么你会向this绑定故意传递像null这样的值?

使用apply(..)来将一个数组散开,从而作为函数调用的参数,是一个很常见的做法。相似地,bind(..)可以 curry 参数(预设值),也是很有帮助的。

function foo(a,b) {
    console.log( "a:" + a + ", b:" + b );
}

// 将数组散开作为参数
foo.apply( null, [2, 3] ); // a:2, b:3

// 用`bind(..)`进行柯里化
var bar = foo.bind( null, 2 );
bar( 3 ); // a:2, b:3

这两种工具都要求第一个参数是this绑定。如果想让使用的函数不关心this,你就需要一个占位值,而且正如这个代码段中展示的,null看起来是一个合理的选择。

注意: 虽然我们在这本书中没有涵盖,但是 ES6 中有一个扩散操作符:...。它让你无需使用apply(..)而在语法上将一个数组“散开”作为参数,比如foo(...[1,2])表示foo(1,2)——如果this绑定没有必要,可以在语法上回避它。不幸的是,柯里化在 ES6 中没有语法上的替代品,所以bind(..)调用的this参数依然需要注意。

可是,在你不关心this绑定而一直使用null的时候,有些潜在的“危险”。如果你这样处理一些函数调用(比如,不归你管控的第三方包),而且那些函数确实使用了this引用,那么 默认绑定 规则意味着它可能会不经意间引用(或者改变,更糟糕!)global对象(在浏览器中是window)。

很显然,这样的陷阱会导致多种 非常难 诊断和追踪的 Bug。

更安全的this

也许某些“更安全”的实践是:为了this而传递一个特别建立好的对象,这个对象保证不会对你的程序产生副作用。从网络学(或军事)上借用一个词,我们可以建立一个“DMZ”(非军事区)对象——只不过是一个完全为空,没有委托(见第五,六章)的对象。

如果我们为了忽略自己认为不用关心的this绑定,而总是传递一个 DMZ 对象,我们就可以确定任何对this的隐藏或意外的使用将会被限制在这个空对象中,也就是说这个对象将global对象和副作用隔离开来。

因为这个对象是完全为空的,我个人喜欢给他一个变量名为ø(空集合的数学符号的小写)。在许多键盘上(比如 Mac 的美式键盘),这个符号可以很容易地用+o (option+o)打出来。有些系统还允许你为某个特殊符号设置快捷键。如果你不喜欢ø符号,或者你的键盘没那么好打,你当然可以叫它任意你希望的名字。

无论你叫它什么,创建 完全为空的对象 的最简单方法就是Object.create(null)(见第五章)。Object.create(null){}很相似,但是没有Object.prototype的委托,所以它比{}“空得更彻底”。

function foo(a,b) {
    console.log( "a:" + a + ", b:" + b );
}

// 我们的 DMZ 空对象
var ø = Object.create( null );

// 将数组散开作为参数
foo.apply( ø, [2, 3] ); // a:2, b:3

// 用`bind(..)`进行 currying
var bar = foo.bind( ø, 2 );
bar( 3 ); // a:2, b:3

不仅在功能上更“安全”,ø还会在代码风格上产生些好处,它在语义上可能会比null更清晰的表达“我想让this为空”。当然,你可以随自己喜欢来称呼你的 DMZ 对象。

间接

另外一个要注意的是,你可以(故意或非故意地!)创建对函数的“间接引用(indirect reference)”,在那样的情况下,当那个函数引用被调用时,默认绑定 规则也会适用。

一个最常见的 间接引用 产生方式是通过赋值:

function foo() {
    console.log( this.a );
}

var a = 2;
var o = { a: 3, foo: foo };
var p = { a: 4 };

o.foo(); // 3
(p.foo = o.foo)(); // 2

赋值表达式p.foo = o.foo结果值 是一个刚好指向底层函数对象的引用。如此,起作用的调用点就是foo(),而非你期待的p.foo()o.foo()。根据上面的结果,默认绑定 规则适用。

提醒: 无论你如何得到适用 默认绑定 的函数调用,被调用函数的 内容strict mode状态——而非函数的调用点——决定了this引用的值:不是global对象(在非strict mode下),就是undefined(在strict mode下)。

软化绑定(Softening Binding)

我们之前看到 硬绑定 是一种通过强制函数绑定到特定的this上,来防止函数调用在不经意间退回到 默认绑定 的策略(除非你用new去覆盖它!)。问题是,硬绑定 极大地降低了函数的灵活性,阻止我们手动使用 隐式绑定 或后续的 明确绑定 尝试来覆盖this

如果有这样的办法就好了:为 默认绑定 提供不同的默认值(不是globalundefined),同时保持函数可以通过 隐式绑定明确绑定 技术来手动绑定this

我们可以构建一个所谓的 软绑定 工具来模拟我们期望的行为。

if (!Function.prototype.softBind) {
    Function.prototype.softBind = function(obj) {
        var fn = this,
            curried = [].slice.call( arguments, 1 ),
            bound = function bound() {
                return fn.apply(
                    (!this ||
                        (typeof window !== "undefined" &&
                            this === window) ||
                        (typeof global !== "undefined" &&
                            this === global)
                    ) ? obj : this,
                    curried.concat.apply( curried, arguments )
                );
            };
        bound.prototype = Object.create( fn.prototype );
        return bound;
    };
}

这里提供的softBind(..)工具的工作方式和 ES5 内建的bind(..)工具很相似,除了我们的 软绑定 行为。他用一种逻辑将指定的函数包装起来,这个逻辑在函数调用时检查this,如果它是globalundefined,就使用预先指定的 默认值obj),否则保持this不变。它也提供了可选的柯里化行为(见先前的bind(..)讨论)。

我们来看看它的用法:

function foo() {
   console.log("name: " + this.name);
}

var obj = { name: "obj" },
    obj2 = { name: "obj2" },
    obj3 = { name: "obj3" };

var fooOBJ = foo.softBind( obj );

fooOBJ(); // name: obj

obj2.foo = foo.softBind(obj);
obj2.foo(); // name: obj2   <---- 看!!!

fooOBJ.call( obj3 ); // name: obj3   <---- 看!

setTimeout( obj2.foo, 10 ); // name: obj   <---- 退回到软绑定

软绑定版本的foo()函数可以如展示的那样被手动this绑定到obj2obj3,如果 默认绑定 适用时会退到obj

词法this

我们刚刚涵盖了一般函数遵守的 4 种规则。但是 ES6 引入了一种不适用于这些规则特殊的函数:箭头函数(arrow-function)。

箭头函数不是通过function声明的,而是通过所谓的“大箭头”操作符:=>。与使用 4 种标准的this规则不同的是,箭头函数从封闭它的(function 或 global)作用域采用this绑定。

我们来展示一下箭头函数的词法作用域:

function foo() {
  // 返回一个 arrow function
    return (a) => {
    // 这里的`this`是词法上从`foo()`采用
        console.log( this.a );
    };
}

var obj1 = {
    a: 2
};

var obj2 = {
    a: 3
};

var bar = foo.call( obj1 );
bar.call( obj2 ); // 2, 不是 3!

foo()中创建的箭头函数在词法上捕获foo()调用时的this,不管它是什么。因为foo()this绑定到obj1bar(被返回的箭头函数的一个引用)也将会被this绑定到obj1。一个箭头函数的词法绑定是不能被覆盖的(就连new也不行!)。

最常见的用法是用于回调,比如事件处理器或计时器:

function foo() {
    setTimeout(() => {
        // 这里的`this`是词法上从`foo()`采用
        console.log( this.a );
    },100);
}

var obj = {
    a: 2
};

foo.call( obj ); // 2

虽然箭头函数提供除了使用bind(..)外,另外一种在函数上来确保this的方式,这看起来很吸引人,但重要的是要注意它们本质是用被广泛理解的词法作用域来禁止了传统的this机制。在 ES6 之前,我们为此已经有了相当常用的模式,这些模式几乎和 ES6 的箭头函数的精神没有区别:

function foo() {
    var self = this; // 词法上捕获`this`
    setTimeout( function(){
        console.log( self.a );
    }, 100 );
}

var obj = {
    a: 2
};

foo.call( obj ); // 2

虽然对不想用bind(..)的人来说self = this和箭头函数都是看起来不错的“解决方案”,但它们实质上逃避了this而非理解和接受它。

如果你发现你在写this风格的代码,但是大多数或全部时候,你都用词法上的self = this或箭头函数“技巧”抵御this机制,那么也许你应该:

  1. 仅使用词法作用域并忘掉虚伪的this风格代码。

  2. 完全接受this风格机制,包括在必要的时候使用bind(..),并尝试避开self = this和箭头函数的“词法 this”技巧。

一个程序可以有效地同时利用两种风格的代码(词法和this),但是在同一个函数内部,特别是对同种类型的查找,混合这两种机制通常是自找很难维护的代码,而且可能是聪明过了头。

复习

为执行中的函数判定this绑定需要找到这个函数的直接调用点。找到之后,4 种规则将会以 这个 优先顺序施用于调用点:

  1. new调用?使用新构建的对象。

  2. callapply(或 bind)调用?使用指定的对象。

  3. 被持有调用的环境对象调用?使用那个环境对象。

  4. 默认:strict mode下是undefined,否则就是全局对象。

小心偶然或不经意的 默认绑定 规则调用。如果你想“安全”地忽略this绑定,一个像ø = Object.create(null)这样的“DMZ”对象是一个很好的占位值,来保护global对象不受意外的副作用影响。

与这 4 种绑定规则不同,ES6 的箭头方法使用词法作用域来决定this绑定,这意味着它们采用封闭他们的函数调用作为this绑定(无论它是什么)。它们实质上是 ES6 之前的self = this代码的语法替代品。

你不懂 JS:this 与对象原型 第三章:对象

在第一和第二章中,我们讲解了this绑定如何根据函数调用的调用点指向不同的对象。但究竟什么是对象,为什么我们需要指向它们?这一章我们就来详细探索一下对象。

语法

对象来自于两种形式:声明(字面)形式,和构造形式。

一个对象的字面语法看起来像这样:

var myObj = {
    key: value
    // ...
};

构造形式看起来像这样:

var myObj = new Object();
myObj.key = value;

构造形式和字面形式的结果是完全同种类的对象。唯一真正的区别在于你可以向字面声明一次性添加一个或多个键/值对,而对于构造形式,你必须一个一个地添加属性。

注意: 像刚才展示的那样使用“构造形式”来创建对象是极其少见的。你很有可能总是想使用字面语法形式。对大多数内建的对象也一样(后述)。

类型

对象是大多数 JS 工程依赖的基本构建块儿。它们是 JS 的 6 中主要类型(在语言规范中称为“语言类型”)中的一种。

  • string
  • number
  • boolean
  • null
  • undefined
  • object

注意 简单基本类型stringnumberbooleannull,和undefined)自身 不是 objectnull有时会被当成一个对象类型,但是这种误解源自与一个语言中的 Bug,它使得typeof null错误地(令人困惑地)返回字符串"object"。实际上,null是它自己的基本类型

一个常见的错误论断是“JavaScript 中的一切都是对象”。这明显是不对的。

对比来看,存在几种特殊的对象子类型,我们可以称之为 复杂基本类型

function是对象的一种子类型(技术上讲,叫做“可调用对象”)。函数在 JS 中被称为“头等(first class)”类型,就因为它们基本上就是普通的对象(附带有可调用的行为语义),而且它们可以像其他普通的对象那样被处理。

数组也是一种形式的对象,带有特别的行为。数组在内容的组织上要稍稍比一般的对象更加结构化。

内建对象

有几种其他的对象子类型,通常称为内建对象。对于其中的一些来说,它们的名称看起来暗示着它们和它们对应的基本类型有着直接的联系,但事实上,它们的关系更复杂,我们一会儿就开始探索。

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

如果你依照和其他语言的相似性来看的话,比如 Java 语言的String类,这些内建类型有着实际类型的外观,甚至是类(class)的外观,

但是在 JS 中,它们实际上仅仅是内建的函数。这些内建函数的每一个都可以被用作构造器(也就是,一个函数可以和new操作符一起调用——参照第二章),其结果是一个新 构建 的相应子类型的对象。比如:

var strPrimitive = "I am a string";
typeof strPrimitive;                            // "string"
strPrimitive instanceof String;                    // false

var strObject = new String( "I am a string" );
typeof strObject;                                 // "object"
strObject instanceof String;                    // true

// 考察 object 子类型
Object.prototype.toString.call( strObject );    // [object String]

我们会在本章稍后详细地看到Object.prototype.toString...到底是如何工作的,但简单地说,我们可以通过借用基本的默认toString()方法来考察子类型的内部,而且你可以看到它揭示了strObject实际上是由String构造器创建的对象。

基本类型值"I am a string"不是一个对象,它是一个不可变的基本字面值。为了对它进行操作,比如检查它的长度,访问它的各个独立字符内容等等,都需要一个String对象。

幸运的是,在必要的时候语言会自动地将"string"基本类型转换为String对象类型,这意味着你几乎从不需要明确地创建对象。主流的 JS 社区都 强烈推荐 尽可能地使用字面形式的值,而非使用构造的对象形式。

考虑下面的代码:

var strPrimitive = "I am a string";

console.log( strPrimitive.length );            // 13

console.log( strPrimitive.charAt( 3 ) );    // "m"

在这两个例子中,我们在字符串的基本类型上调用属性和方法,引擎会自动地将它转换为String对象,所以这些属性/方法的访问可以工作。

当使用如42.359.toFixed(2)这样的方法时,同样的转换也发生在数字基本字面量42和包装对象new Nubmer(42)之间。同样的还有Boolean对象和"boolean"基本类型。

nullundefined没有对象包装的形式,仅有它们的基本类型值。相比之下,Date的值 仅可以 由它们的构造对象形式创建,因为它们没有对应的字面形式。

无论使用字面还是构造形式,ObjectArrayFunction,和RegExp(正则表达式)都是对象。在某些情况下,构造形式确实会比对应的字面形式提供更多的创建选项。因为对象可以被任意一种方式创建,更简单的字面形式几乎是所有人的首选。仅仅在你需要使用额外的选项时使用构建形式

Error对象很少在代码中明示地被创建,它们通常在抛出异常时自动地被创建。它们可以由new Error(..)构造形式创建,但通常是不必要的。

内容

正如刚才提到的,对象的内容由存储在特定命名的 位置 上的(任意类型的)值组成,我们称这些值为属性。

有一个重要的事情需要注意:当我们说“内容”时,似乎暗示这这些值 实际上 存储在对象内部,但那只不过是表面现象。引擎会根据自己的实现来存储这些值,而且通常都不是把它们存储在容器对象 内部。在容器内存储的是这些属性的名称,它们像指针(技术上讲,叫 引用(reference))一样指向值存储的地方。

考虑下面的代码:

var myObject = {
    a: 2
};

myObject.a;        // 2

myObject["a"];    // 2

为了访问在myObject位置 a的值,我们需要使用.[ ]操作符。.a语法通常称为“属性(property)”访问,而["a"]语法通常称为“键(key)”访问。在现实中,它们俩都访问相同的 位置,而且会拿出相同的值,2,所以这些术语可以互换使用。从现在起,我们将使用最常见的术语——“属性访问”。

两种语法的主要区别在于,.操作符后面需要一个标识符(Identifier)兼容的属性名,而[".."]语法基本可以接收任何兼容 UTF-8/unicode 的字符串作为属性名。举个例子,为了引用一个名为“Super-Fun!”的属性,你不得不使用["Super-Fun!"]语法访问,因为Super-Fun!不是一个合法的Identifier属性名。

而且,由于[".."]语法使用字符串的 来指定位置,这意味着程序可以动态地组建字符串的值。比如:

var wantA = true;
var myObject = {
    a: 2
};

var idx;

if (wantA) {
    idx = "a";
}

// 稍后

console.log( myObject[idx] ); // 2

在对象中,属性名 总是 字符串。如果你使用字符串以外(基本)类型的值,它会首先被转换为字符串。这甚至包括在数组中常用于索引的数字,所以要小心不要将对象和数组使用的数字搞混了。

var myObject = { };

myObject[true] = "foo";
myObject[3] = "bar";
myObject[myObject] = "baz";

myObject["true"];                // "foo"
myObject["3"];                    // "bar"
myObject["[object Object]"];    // "baz"

计算型属性名

如果你需要将一个计算表达式 作为 一个键名称,那么我们刚刚描述的myObject[..]属性访问语法是十分有用的,比如myObject[prefix + name]。但是当使用字面对象语法声明对象时则没有什么帮助。

ES6 加入了 计算型属性名,在一个字面对象声明的键名称位置,你可以指定一个表达式,用[ ]括起来:

var prefix = "foo";

var myObject = {
    [prefix + "bar"]: "hello",
    [prefix + "baz"]: "world"
};

myObject["foobar"]; // hello
myObject["foobaz"]; // world

计算型属性名 的最常见用法,可能是用于 ES6 的Symbol,我们将不会在本书中涵盖关于它的细节。简单地说,它们是新的基本数据类型,拥有一个不透明不可知的值(技术上讲是一个string值)。你将会被强烈地不鼓励使用一个Symbol实际值 (这个值理论上会因 JS 引擎的不同而不同),所以Symbol的名称,比如Symbol.Something(这是个瞎编的名称!),才是你会使用的:

var myObject = {
    [Symbol.Something]: "hello world"
};

属性(Property) vs. 方法(Method)

有些开发者喜欢在讨论对一个对象的属性访问时做一个区别,如果这个被访问的值恰好是一个函数的话。因为这诱使人们认为函数 属于 这个对象,而且在其他语言中,属于对象(也就是“类”)的函数被称作“方法”,所以相对于“属性访问”,我们常能听到“方法访问”。

有趣的是,语言规范也做出了同样的区别

从技术上讲,函数绝不会“属于”对象,所以,说一个对象的引用上刚好被访问的函数自动是一个“方法”,看起来有些像是延伸了语义。

有些函数确实拥有this引用,而且 有时 这些this引用指向调用点的对象引用。但这个用法真的没有使这个函数比其他函数更像“方法”,因为this是在运行时在调用点动态绑定的,这使得它与这个对象的关系至多是间接的。

每次你访问一个对象的属性都是一个 属性访问,无论你得到什么类型的值。如果你 恰好 从属性访问中得到一个函数,它也没有魔法般地在那时成为一个“方法”。一个从属性访问得来的函数没有任何特殊性(隐式this绑定之外的可能性在刚才已经解释过了)。

举个例子:

function foo() {
    console.log( "foo" );
}

var someFoo = foo;    // 对`foo`的变量引用

var myObject = {
    someFoo: foo
};

foo;                // function foo(){..}

someFoo;            // function foo(){..}

myObject.someFoo;    // function foo(){..}

someFoomyObject.someFoo只不过是同一个函数的两个分离的引用,它们中的任何一个都不意味着这个函数很特别或被其他对象所“拥有”。如果上面的foo()定义里面拥有一个this引用,那么myObject.someFoo隐式绑定 将会是这个两个引用间 唯一 可以观察到的不同。它们中的任何一个都没有称为“方法”的道理。

也许有人会争辩,函数 变成了方法,不是在定义期间,而是在调用的执行期间,根据它是如何在调用点被调用的(是否带有一个环境对象引用 —— 细节见第二章)。甚至这种解读也有些牵强。

可能最安全的结论是,在 JavaScript 中,“函数”和“方法”是可以互换使用的。

注意: ES6 加入了super引用,它通常是和class(见附录 A)一起使用的。super的行为方式(静态绑定,而非动态绑定),给了这种说法更多的权重:一个super绑定到某处的函数比起“函数”更像一个“方法”。但是同样地,这仅仅是微妙的语义上的(和机制上的)细微区别。

就算你声明一个函数表达式作为字面对象的一部分,那个函数都不会魔法般地 属于 这个对象——仍然仅仅是同一个函数对象的多个引用罢了。

var myObject = {
    foo: function foo() {
        console.log( "foo" );
    }
};

var someFoo = myObject.foo;

someFoo;        // function foo(){..}

myObject.foo;    // function foo(){..}

注意: 在第六章中,我们会为字面对象的foo: function foo(){ .. }声明语法介绍一种 ES6 的简化语法。

数组

数组也使用[ ]访问形式,但正如上面提到的,在存储值的方式和位置上它们的组织更加结构化(虽然仍然在存储值的类型上没有限制)。数组采用 数字索引,这意味着值被存储的位置,通常称为 下标,是一个非负整数,比如042

var myArray = [ "foo", 42, "bar" ];

myArray.length;        // 3

myArray[0];            // "foo"

myArray[2];            // "bar"

数组也是对象,所以即便每个索引都是正整数,你还可以在数组上添加属性:

var myArray = [ "foo", 42, "bar" ];

myArray.baz = "baz";

myArray.length;    // 3

myArray.baz;    // "baz"

注意,添加命名属性(不论是使用.还是[ ]操作符语法)不会改变数组的length所报告的值。

可以 把一个数组当做普通的键/值对象使用,并且从不添加任何数字下标,但这不是好主意,因为数组对它本来的用途有特定的行为和优化,正如普通对象那样。使用对象来存储键/值对,而用数组在数字下标上存储值。

小心: 如果你试图在一个数组上添加属性,但是属性名 看起来 像一个数字,那么最终它会成为一个数字索引(也就是改变了数组的内容):

var myArray = [ "foo", 42, "bar" ];

myArray["3"] = "baz";

myArray.length;    // 4

myArray[3];        // "baz"

复制对象

当开发者们初次拿起 Javascript 语言时,最常需要的特性就是如何复制一个对象。看起来应该有一个内建的copy()方法,对吧?但是事情实际上比这复杂一些,因为在默认情况下,复制的算法应当是什么,并不明确。

比如,考虑这个对象:

function anotherFunction() { /*..*/ }

var anotherObject = {
    c: true
};

var anotherArray = [];

var myObject = {
    a: 2,
    b: anotherObject,    // 引用,不是拷贝!
    c: anotherArray,    // 又一个引用!
    d: anotherFunction
};

anotherArray.push( anotherObject, myObject );

一个myObject拷贝 究竟应该怎么表现?

首先,我们应该回答它是一个 浅(shallow) 还是一个 深(deep) 拷贝?一个 浅拷贝(shallow copy) 会得到一个新对象,它的a是值2的拷贝,但bcd属性仅仅是引用,它们指向被拷贝对象中引用的相同位置。一个 深拷贝(deep copy) 将不仅复制myObject,还会复制anotherObjectanotherArray。但之后我们让anotherArray拥有anotherObjectmyObject的引用,所以 那些 也应当被复制而不是仅保留引用。现在由于循环引用,我们得到了一个无限循环复制的问题。

我们应当检测循环引用并打破循环遍历吗(不管位于深处的,没有完全复制的元素)?我们应当报错退出吗?或者介于两者之间?

另外,“复制”一个函数意味着什么,也不是很清楚。有一些技巧,比如提取一个函数源代码的toString()序列化表达(这个源代码会因实现不同而不同,而且根据被考察的函数的类型,其结果甚至在所有引擎上都不可靠)。

那么我们如何解决所有这些刁钻的问题?不同的 JS 框架都各自挑选自己的解释并且做出自己的选择。但是哪一种(如果有的话)才是 JS 应当作为标准采用的呢?长久以来,没有明确答案。

一个解决方案是,JSON 安全的对象(也就是,可以被序列化为一个 JSON 字符串,之后还可以被重新变换为拥有相同的结构和值的对象)可以简单地这样 复制

var newObj = JSON.parse( JSON.stringify( someObj ) );

当然,这要求你保证你的对象是 JSON 安全的。对于某些情况,这没什么大不了的。而对另一些情况,这还不够。

同时,浅拷贝相当易懂,而且没有那么多问题,所以 ES6 为此任务已经定义了Object.assign(..)Object.assign(..)接收 目标 对象作为第一个参数,然后是一个或多个 对象作为后续参数。它会在 对象上迭代所有的 可枚举(enumerable)owned keys直接拥有的键),并把它们拷贝到 目标 对象上(仅通过=赋值)。它还会很方便地返回 目标 对象,正如下面你可以看到的:

var newObj = Object.assign( {}, myObject );

newObj.a;                        // 2
newObj.b === anotherObject;        // true
newObj.c === anotherArray;        // true
newObj.d === anotherFunction;    // true

注意: 在下一部分中,我们将讨论“属性描述符(property descriptors)”并展示Object.defineProperty(..)的使用。然而在Object.assign(..)中发生的复制是单纯的=式赋值,所以任何在源对象属性的特殊性质(比如writable)在目标对象上 都不会保留

属性描述符(Property Descriptors)

在 ES5 之前,JavaScript 语言没有给出直接的方法,让你的代码可以考察或描述属性的性质间的区别,比如属性是否为只读。

在 ES5 中,所有的属性都用 属性描述符(Property Descriptors) 来描述。

考虑这段代码:

var myObject = {
    a: 2
};

Object.getOwnPropertyDescriptor( myObject, "a" );
// {
//    value: 2,
//    writable: true,
//    enumerable: true,
//    configurable: true
// }

正如你所见,我们普通的对象属性a的属性描述符(称为“数据描述符”,因为它仅持有一个数据值)的内容要比value2多得多。它还包含另外 3 个性质:writableenumerable,和configurable

当我们创建一个普通属性时,可以看到属性描述符的各种性质的默认值,我们可以用Object.defineProperty(..)来添加新属性,或使用期望的性质来修改既存的属性(如果它是configurable的!)。

举例来说:

var myObject = {};

Object.defineProperty( myObject, "a", {
    value: 2,
    writable: true,
    configurable: true,
    enumerable: true
} );

myObject.a; // 2

使用defineProperty(..),我们手动明确地在myObject上添加了一个直白的,普通的a属性。然而,你通常不会使用这种手动方法,除非你想要把描述符的某个性质修改为不同的值。

可写性(Writable)

writable控制着你改变属性值的能力。

考虑这段代码:

var myObject = {};

Object.defineProperty( myObject, "a", {
    value: 2,
    writable: false, // 不可写!
    configurable: true,
    enumerable: true
} );

myObject.a = 3;

myObject.a; // 2

如你所见,我们对value的修改悄无声息地失败了。如果我们在strict mode下进行尝试,会得到一个错误:

"use strict";

var myObject = {};

Object.defineProperty( myObject, "a", {
    value: 2,
    writable: false, // not writable!
    configurable: true,
    enumerable: true
} );

myObject.a = 3; // TypeError

这个TypeError告诉我们,我们不能改变一个不可写属性。

注意: 我们一会儿就会讨论 getters/setters,但是简单地说,你可以观察到writable:false意味着值不可改变,和你定义一个空的 setter 是有些等价的。实际上,你的空 setter 在被调用时需要扔出一个TypeError,来和writable:false保持一致。

可配置性(Configurable)

只要属性当前是可配置的,我们就可以使用同样的defineProperty(..)工具,修改它的描述符定义。

var myObject = {
    a: 2
};

myObject.a = 3;
myObject.a;                    // 3

Object.defineProperty( myObject, "a", {
    value: 4,
    writable: true,
    configurable: false,    // 不可配置!
    enumerable: true
} );

myObject.a;                    // 4
myObject.a = 5;
myObject.a;                    // 5

Object.defineProperty( myObject, "a", {
    value: 6,
    writable: true,
    configurable: true,
    enumerable: true
} ); // TypeError

最后的defineProperty(..)调用导致了一个 TypeError,这与strict mode无关,如果你试图改变一个不可配置属性的描述符定义,就会发生 TypeError。要小心:如你所看到的,将configurable设置为false一个单向操作,不可撤销!

注意: 这里有一个需要注意的微小例外:即便属性已经是configurable:falsewritable总是可以没有错误地从true改变为false,但如果已经是false的话不能变回true

configurable:false阻止的另外一个事情是使用delete操作符移除既存属性的能力。

var myObject = {
    a: 2
};

myObject.a;                // 2
delete myObject.a;
myObject.a;                // undefined

Object.defineProperty( myObject, "a", {
    value: 2,
    writable: true,
    configurable: false,
    enumerable: true
} );

myObject.a;                // 2
delete myObject.a;
myObject.a;                // 2

如你所见,最后的delete调用失败了(无声地),因为我们将a属性设置成了不可配置。

delete仅用于直接从目标对象移除该对象的属性(可以被移除的属性)。如果一个对象的属性是某个其他对象/函数的最后一个现存的引用,而你delete了它,那么这就移除了这个引用,于是现在那个没有被任何地方引用的对象/函数就可以被作为垃圾回收。但是,将delete当做一个像其他语言(如 C/C++)中那样的释放内存工具是不正确的。delete仅仅是一个对象属性移除操作——没有更多别的含义。

可枚举性(Enumerable)

我们将要在这里提到的最后一个描述符性质是enumerable(还有另外两个,我们将在一会儿讨论 getter/setters 时谈到)。

它的名称可能已经使它的功能很明显了,这个性质控制着一个属性是否能在特定的对象属性枚举操作中出现,比如for..in循环。设置为false将会阻止它出现在这样的枚举中,即使它依然完全是可以访问的。设置为true会使它出现。

所有普通的用户定义属性都默认是可enumerable的,正如你通常希望的那样。但如果你有一个特殊的属性,你想让它对枚举隐藏,就将它设置为enumerable:false

我们一会儿就更加详细地演示可枚举性,所以在大脑中给这个话题上打一个书签。

不可变性(Immutability)

有时我们希望将属性或对象(有意或无意地)设置为不可改变的。ES5 用几种不同的微妙方式,加入了对此功能的支持。

一个重要的注意点是:所有 这些方法都创建的是浅不可变性。也就是,它们仅影响对象和它的直属属性的性质。如果对象拥有对其他对象(数组,对象,函数等)的引用,那个对象的 内容 不会受影响,任然保持可变。

myImmutableObject.foo; // [1,2,3]
myImmutableObject.foo.push( 4 );
myImmutableObject.foo; // [1,2,3,4]

在这段代码中,我们假想myImmutableObject已经被创建,而且被保护为不可变。但是,为了保护myImmutableObject.foo的内容(也是一个对象——数组),你将需要使用下面的一个或多个方法将foo设置为不可变。

注意: 在 JS 程序中创建完全不可动摇的对象是不那么常见的。有些特殊情况当然需要,但作为一个普通的设计模式,如果你发现自己想要 封印(seal)冻结(freeze) 你所有的对象,那么你可能想要退一步来重新考虑你的程序设计,让它对对象值的潜在变化更加健壮。

对象常量(Object Constant)

通过将writable:falseconfigurable:false组合,你可以实质上创建了一个作为对象属性的 常量(不能被改变,重定义或删除),比如:

var myObject = {};

Object.defineProperty( myObject, "FAVORITE_NUMBER", {
    value: 42,
    writable: false,
    configurable: false
} );

防止扩展(Prevent Extensions)

如果你想防止一个对象被添加新的属性,但另一方面保留其他既存的对象属性,调用Object.preventExtensions(..)

var myObject = {
    a: 2
};

Object.preventExtensions( myObject );

myObject.b = 3;
myObject.b; // undefined

非-strict mode模式下,b的创建会无声地失败。在strict mode下,它会抛出TypeError

封印(Seal)

Object.seal(..)创建一个“封印”的对象,这意味着它实质上在当前的对象上调用Object.preventExtensions(..),同时也将它所有的既存属性标记为configurable:false

所以,你既不能添加更多的属性,也不能重新配置或删除既存属性(虽然你依然 可以 修改它们的值)。

冻结(Freeze)

Object.freeze(..)创建一个冻结的对象,这意味着它实质上在当前的对象上调用Object.seal(..),同时也将它所有的“数据访问”属性设置为writable:false,所以他们的值不可改变。

这种方法是你可以从对象自身获得的最高级别的不可变性,因为它阻止任何对对象或对象的直属属性的改变(虽然,如上面提到的,任何被引用的对象的内容不受影响)。

你可以“深度冻结”一个对象:在这个对象上调用Object.freeze(..),然后递归地迭代所有它引用的对象(目前还没有受过影响的),然后在它们上也调用Object.freeze(..)。但是要小心,这可能会影响其他(共享的)你并不打算影响的对象。

[[Get]]

关于属性访问如何工作有一个重要的细节。

考虑下面的代码:

var myObject = {
    a: 2
};

myObject.a; // 2

myObject.a是一个属性访问,但是它并不是看起来那样,仅仅在myObject中寻找一个名为a的属性。

根据语言规范,上面的代码实际上在myObject上执行了一个[[Get]]操作(有些像[[Get]]()函数调用)。对一个对象进行默认的内建[[Get]]操作,会 首先 检查对象,寻找一个拥有被请求的名称的属性,如果找到,就返回相应的值。

然而,如果按照被请求的名称 没能 找到属性,[[Get]]的算法定义了另一个重要的行为。我们会在第五章来解释 接下来 会发生什么(遍历[[Prototype]]链,如果有的话)。

[[Get]]操作的一个重要结果是,如果它通过任何方法都不能找到被请求的属性的值,那么它会返回undefined

var myObject = {
    a: 2
};

myObject.b; // undefined

这个行为和你通过标识符名称来引用 变量 不同。如果你引用了一个在可用的词法作用域内无法解析的变量,其结果不是像对象属性那样返回undefined,而是抛出ReferenceError

var myObject = {
    a: undefined
};

myObject.a; // undefined

myObject.b; // undefined

的角度来说,这两个引用没有区别——它们的结果都是undefined。然而,在[[Get]]操作的底层,虽然不明显,但是比起处理引用myObject.a,处理myObject.b的操作要多做一些潜在的工作。

如果仅仅考察结果的值,你无法分辨一个属性是存在并持有一个undefined值,还是因为属性根本 存在所以[[Get]]无法返回某个特定值而返回默认的undefined。但是,你很快就能看到你其实 可以 分辨这两种场景。

[[Put]]

既然为了从一个属性中取得值而存在一个内部定义的[[Get]]操作,那么很明显应该也存在一个默认的[[Put]]操作。

这很容易让人认为,给一个对象的属性赋值,将会在这个对象上调用[[Put]]来设置或创建这个属性。但是实际情况却有一些微妙的不同。

调用[[Put]]时,它根据几个因素表现不同的行为,包括(影响最大的)属性是否已经在对象中存在了。

如果属性存在,[[Put]]算法将会大致检查:

  1. 这个属性是访问器描述符吗(见下一节"Getters 与 Setters")?如果是,而且是 setter,就调用 setter。
  2. 这个属性是writablefalse数据描述符吗?如果是,在非strict mode下无声地失败,或者在strict mode下抛出TypeError
  3. 否则,像平常一样设置既存属性的值。

如果属性在当前的对象中不存在,[[Put]]操作会变得更微妙和复杂。我们将在第五章讨论[[Prototype]]时再次回到这个场景,更清楚地解释它。

Getters 与 Setters

对象默认的[[Put]][[Get]]操作分别完全控制着如何设置既存或新属性的值,和如何取得既存属性。

注意: 使用较先进的语言特性,覆盖整个对象(不仅是每个属性)的默认[[Put]][[Get]]操作是可能的。这超出了我们要在这本书中讨论的范围,但我们会在后面的“你不懂 JS”系列中涵盖此内容。

ES5 引入了一个方法来覆盖这些默认操作的一部分,但不是在对象级别而是针对每个属性,就是通过 getters 和 setters。Getter 是实际上调用一个隐藏函数来取得值的属性。Setter 是实际上调用一个隐藏函数来设置值的属性。

当你将一个属性定义为拥有 getter 或 setter 或两者兼备,那么它的定义就成为了“访问器描述符”(与“数据描述符”相对)。对于访问器描述符,它的valuewritable性质没有意义而被忽略,取而代之的是 JS 将会考虑属性的setget性质(还有configurableenumerable)。

考虑下面的代码:

var myObject = {
    // 为`a`定义一个 getter
    get a() {
        return 2;
    }
};

Object.defineProperty(
    myObject,    // 目标对象
    "b",        // 属性名
    {            // 描述符
        // 为`b`定义 getter
        get: function(){ return this.a * 2 },

        // 确保`b`作为对象属性出现
        enumerable: true
    }
);

myObject.a; // 2

myObject.b; // 4

不管是通过在字面对象语法中使用get a() { .. },还是通过使用defineProperty(..)明确定义,我们都在对象上创建了一个没有实际持有值的属性,访问它们将会自动地对 getter 函数进行隐藏的函数调用,其返回的任何值就是属性访问的结果。

var myObject = {
    // 为`a`定义 getter
    get a() {
        return 2;
    }
};

myObject.a = 3;

myObject.a; // 2

因为我们仅为a定义了一个 getter,如果之后我们试着设置a的值,赋值操作并不会抛出错误而是无声地将赋值废弃。就算这里有一个合法的 setter,我们的自定义 getter 将返回值硬编码为仅返回2,所以赋值操作是没有意义的。

为了使这个场景更合理,正如你可能期望的那样,每个属性还应当被定义一个覆盖默认[[Put]]操作(也就是赋值)的 setter。几乎可确定,你将总是想要同时声明 getter 和 setter(仅有它们中的一个经常会导致以外的行为):

var myObject = {
    // 为`a`定义 getter
    get a() {
        return this._a_;
    },

    // 为`a`定义 setter
    set a(val) {
        this._a_ = val * 2;
    }
};

myObject.a = 2;

myObject.a; // 4

注意: 在这个例子中,我们实际上将赋值操作([[Put]]操作)指定的值2存储到了另一个变量_a_中。_a_这个名称只是用在这个例子中的单纯的惯例,并不意味着它的行为有什么特别之处——它和其他普通属性没有区别。

存在性(Existence)

我们早先看到,像myObject.a这样的属性访问可能会得到一个undefined值,无论是它明确存储着undefined还是属性a根本就不存在。那么,如果这两种情况的值相同,我们还怎么区别它们呢?

我们可以查询一个对象是否拥有特定的属性,而不必取得那个属性的值:

var myObject = {
    a: 2
};

("a" in myObject);                // true
("b" in myObject);                // false

myObject.hasOwnProperty( "a" );    // true
myObject.hasOwnProperty( "b" );    // false

in操作符会检查属性是否存在于对象 ,或者是否存在于[[Prototype]]链对象遍历的更高层中(详见第五章)。相比之下,hasOwnProperty(..) 仅仅 检查myObject是否拥有属性,但 不会 查询[[Prototype]]链。我们会在第五章详细讲解[[Prototype]]时,回来讨论这个两个操作重要的不同。

通过委托到Object.prototype,所有的普通对象都可以访问hasOwnProperty(..)(详见第五章)。但是创建一个不链接到Object.prototype的对象也是可能的(通过Object.create(null)——详见第五章)。这种情况下,像myObject.hasOwnProperty(..)这样的方法调用将会失败。

在这种场景下,一个进行这种检查的更健壮的方式是Object.prototype.hasOwnProperty.call(myObject,"a"),它借用基本的hasOwnProperty(..)方法而且使用 明确的this绑定(详见第二章)来对我们的myObject实施这个方法。

注意: in操作符看起来像是要检查一个值在容器中的存在性,但是它实际上检查的是属性名的存在性。在使用数组时注意这个区别十分重要,因为我们会有很强的冲动来进行4 in [2, 4, 6]这样的检查,但是这总是不像我们想象的那样工作。

枚举(Enumeration)

先前,在学习enumerable属性描述符性质时,我们简单地解释了"可枚举性(enumerability)"的含义。现在,让我们来更加详细地重新审视它。

var myObject = { };

Object.defineProperty(
    myObject,
    "a",
    // 使`a`可枚举,如一般情况
    { enumerable: true, value: 2 }
);

Object.defineProperty(
    myObject,
    "b",
    // 使`b`不可枚举
    { enumerable: false, value: 3 }
);

myObject.b; // 3
("b" in myObject); // true
myObject.hasOwnProperty( "b" ); // true

// .......

for (var k in myObject) {
    console.log( k, myObject[k] );
}
// "a" 2

你会注意到,myObject.b实际上 存在,而且拥有可以访问的值,但是它不出现在for..in循环中(然而令人诧异的是,它的in操作符的存在性检查通过了)。这是因为“enumerable”基本上意味着“如果对象的属性被迭代时会被包含在内”。

注意:for..in循环实施在数组上可能会给出意外的结果,因为枚举一个数组将不仅包含所有的数字下标,还包含所有的可枚举属性。所以一个好主意是:将for..in循环 用于对象,而为存储在数组中的值使用传统的for循环并用数字索引迭代。

意外一个可以区分可枚举和不可枚举属性的方法是:

var myObject = { };

Object.defineProperty(
    myObject,
    "a",
    // 使`a`可枚举,如一般情况
    { enumerable: true, value: 2 }
);

Object.defineProperty(
    myObject,
    "b",
    // 使`b`不可枚举
    { enumerable: false, value: 3 }
);

myObject.propertyIsEnumerable( "a" ); // true
myObject.propertyIsEnumerable( "b" ); // false

Object.keys( myObject ); // ["a"]
Object.getOwnPropertyNames( myObject ); // ["a", "b"]

propertyIsEnumerable(..)测试一个给定的属性名是否直 接存 在于对象上,并且是enumerable:true

Object.keys(..)返回一个所有可枚举属性的数组,而Object.getOwnPropertyNames(..)返回一个 所有 属性的数组,不论能不能枚举。

inhasOwnProperty(..)区别于它们是否查询[[Prototype]]链,而Object.keys(..)Object.getOwnPropertyNames(..) 考察直接给定的对象。

(当下)没有与in操作符的查询方式(在整个[[Prototype]]链上遍历所有的属性,如我们在第五章解释的)等价的,内建的方法可以得到一个 所有属性 的列表。你可以近似地模拟一个这样的工具:递归地遍历一个对象的[[Prototype]]链,在每一层都从Object.keys(..)中取得一个列表——仅包含可枚举属性。

迭代(Iteration)

for..in循环迭代一个对象上(包括它的[[Prototype]]链)所有的可迭代属性。但如果你想要迭代值呢?

在数字索引的数组中,典型的迭代所有的值的办法是使用标准的for循环,比如:

var myArray = [1, 2, 3];

for (var i = 0; i < myArray.length; i++) {
    console.log( myArray[i] );
}
// 1 2 3

但是这并没有迭代所有的值,而是迭代了所有的下标,然后由你使用索引来引用值,比如myArray[i]

ES5 还为数组加入了几个迭代帮助方法,包括forEach(..)every(..),和some(..)。这些帮助方法的每一个都接收一个回调函数,这个函数将施用于数组中的每一个元素,仅在如何响应回调的返回值上有所不同。

forEach(..)将会迭代数组中所有的值,并且忽略回调的返回值。every(..)会一直迭代到最后,或者 当回调返回一个false(或“falsy”)值,而some(..)会一直迭代到最后,或者 当回调返回一个true(或“truthy”)值。

这些在every(..)some(..)内部的特殊返回值有些像普通for循环中的break语句,它们可以在迭代执行到末尾之前将它结束掉。

如果你使用for..in循环在一个对象上进行迭代,你也只能间接地得到值,因为它实际上仅仅迭代对象的所有可枚举属性,让你自己手动地去访问属性来得到值。

注意: 与以有序数字的方式(for循环或其他迭代器)迭代数组的下标比较起来,迭代对象属性的顺序是 不确定 的,而且可能会因 JS 引擎的不同而不同。对于需要跨平台环境保持一致性的问题,不要依赖 观察到的顺序,因为这个顺序是不可靠的。

但是如果你想直接迭代值,而不是数组下标(或对象属性)呢?ES6 加入了一个有用的for..of循环语法,用来迭代数组(和对象,如果这个对象有定义的迭代器):

var myArray = [ 1, 2, 3 ];

for (var v of myArray) {
    console.log( v );
}
// 1
// 2
// 3

for..of循环要求被迭代的 东西 提供一个迭代器对象(从一个在语言规范中叫做@@iterator的默认内部函数那里得到),每次循环都调用一次这个迭代器对象的next()方法,循环迭代的内容就是这些连续的返回值。

数组拥有内建的@@iterator,所以正如展示的那样,for..of对于它们很容易使用。但是让我们使用内建的@@iterator来手动迭代一个数组,来看看它是怎么工作的:

var myArray = [ 1, 2, 3 ];
var it = myArray[Symbol.iterator]();

it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { done:true }

注意: 我们使用一个 ES6 的SymbolSymbol.iterator来取得一个对象的@@iterator 内部属性。我们在本章中简单地提到过Symbol的语义(见“计算型属性名”),同样的原理适用这里。你总是希望通过Symbol名称引用,而不是它可能持有的特殊的值,来引用这样特殊的属性。同时,与这个名称的含义无关,@@iterator本身 不是迭代器对象, 而是一个返回迭代器对象的 方法 ——一个重要的细节!

正如上面的代码段揭示的,迭代器的next()调用的返回值是一个{ value: .. , done: .. }形式的对象,其中value是当前迭代的值,而done是一个boolean,表示是否还有更多内容可以迭代。

注意值3done:false一起返回,猛地一看会有些奇怪。你不得不第四次调用next()(在前一个代码段的for..of循环会自动这样做)来得到done:true,而使自己知道迭代已经完成。这个特别之处的原因超出了我们要在这里讨论的范围,但是它来自于 ES6 生成器函数的语义。

虽然数组可以在for..of循环中自动迭代,但普通的对象 没有内建的@@iterator。这种故意省略的原因要比我们将在这里解释的更复杂,但一般来说,为了未来的对象类型,最好不要加入那些可能最终被证明是麻烦的实现。

但是 可以 为你想要迭代的对象定义你自己的默认@@iterator。比如:

var myObject = {
    a: 2,
    b: 3
};

Object.defineProperty( myObject, Symbol.iterator, {
    enumerable: false,
    writable: false,
    configurable: true,
    value: function() {
        var o = this;
        var idx = 0;
        var ks = Object.keys( o );
        return {
            next: function() {
                return {
                    value: o[ks[idx++]],
                    done: (idx > ks.length)
                };
            }
        };
    }
} );

// 手动迭代`myObject`
var it = myObject[Symbol.iterator]();
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { value:undefined, done:true }

// 用`for..of`迭代`myObject`
for (var v of myObject) {
    console.log( v );
}
// 2
// 3

注意: 我们使用了Object.defineProperty(..)来自定义我们的@@iterator(很大程度上是因为我们可以将它指定为不可枚举的),但是通过将Symbol作为一个 计算型属性名(在本章前面的部分讨论过),我们也可以直接声明它,比如var myObject = { a:2, b:3, [Symbol.iterator]: function(){ /* .. */ } }

每次for..of循环在myObject的迭代器对象上调用next()时,迭代器内部的指针将会向前移动并返回对象属性列表的下一个值(关于对象属性/值迭代顺序,参照前面的注意事项)。

我们刚刚演示的迭代,是一个简单的一个值一个值的迭代,当然你可以为你的自定义数据结构定义任意复杂的迭代方法,只要你觉得合适。对于操作用自户定义对象来说,自定义迭代器与 ES6 的for..of循环相组合,是一个新的强大的语法工具。

举个例子,一个Pixel(像素)对象列表(拥有xy的坐标值)可以根据距离原点(0,0)的直线距离决定它的迭代顺序,或者过滤掉那些“太远”的点,等等。只要你的迭代器从next()调用返回期望的{ value: .. }返回值,并在迭代结束后返回一个{ done: true }值,ES6 的for..of循环就可以迭代它。

其实,你甚至可以生成一个永远不会“结束”,并且总会返回一个新值(比如随机数,递增值,唯一的识别符等等)的“无穷”迭代器,虽然你可能不会将这样的迭代器用于一个没有边界的for..of循环,因为它永远不会结束,而且会阻塞你的程序。

var randoms = {
    [Symbol.iterator]: function() {
        return {
            next: function() {
                return { value: Math.random() };
            }
        };
    }
};

var randoms_pool = [];
for (var n of randoms) {
    randoms_pool.push( n );

    // 不要超过边界!
    if (randoms_pool.length === 100) break;
}

这个迭代器会“永远”生成随机数,所以我们小心地仅从中取出 100 个值,以使我们的程序不被阻塞。

复习

JS 中的对象拥有字面形式(比如var a = { .. }),和构造形式(比如var a = new Array(..))。字面形式几乎总是首选,但在某些情况下,构造形式提供更多的构建选项。

许多人错误地声称“Javascript 中的一切都是对象”,这是不对的。对象是 6 种(或 7 中,看你从哪个方面说)基本类型之一。对象有子类型,包括function,还可以被行为特化,比如[object Array]作为内部的标签表示子类型数组。

对象是键/值对的集合。通过.propName["propName"]语法,值可以作为属性访问。不管属性什么时候被访问,引擎实际上会调用内部默认的[[Get]]操作(在设置值时调用[[Put]]操作),它不仅直接在对象上查找属性,在没有找到时还会遍历[[Prototype]]链(见第五章)。

属性有一些可以通过属性描述符控制的特定性质,比如writableconfigurable。另外,对象拥有它的不可变性(它们的属性也有),可以通过使用Object.preventExtensions(..)Object.seal(..),和Object.freeze(..)来控制几种不同等级的不可变性。

属性不必非要包含值——它们也可以是带有 getter/setter 的“访问器属性”。它们也可以是可枚举或不可枚举的,这控制它们是否会在for..in这样的循环迭代中出现。

你也可以使用 ES6 的for..of语法,在数据结构(数组,对象等)中迭代 ,它寻找一个内建或自定义的@@iterator对象,这个对象由一个next()方法组成,通过这个next()方法每次迭代一个数据。

你不懂 JS:this 与对象原型 第四章:混合(淆)“类”的对象

接着我们上一章对对象的探索,我们很自然的将注意力转移到“面向对象(OO)编程”,与“类(class)”。我们先将“面向类”作为设计模式来看看,之后我们再考察“类”的机制:“实例化(instantiation)”, “继承(inheritance)”与“相对多态(relative polymorphism)”。

我们将会看到,这些概念并不是非常自然地映射到 JS 的对象机制上,以及许多 JavaScript 开发者为了克服这些挑战所做的努力(mixins 等)。

注意: 这一章花了相当一部分时间(前一半!)在着重解释“面向对象编程”理论上。在后半部分讨论“Mixins(混合)”时,我们最终会将这些理论与真实且实际的 JavaScript 代码联系起来。但是这里首先要蹚过许多概念和假想代码,所以可别迷路了——坚持下去!

类(Class)理论

“类/继承”描述了一种特定的代码组织和结构形式——一种在我们的软件中对真实世界的建模方法。

OO 或者面向类的编程强调数据和操作它的行为之间有固有的联系(当然,依数据的类型和性质不同而不同!),所以合理的设计是将数据和行为打包在一起(也称为封装)。这有时在正式的计算机科学中称为“数据结构”。

比如,表示一个单词或短语的一系列字符通常称为“string(字符串)”。这些字符就是数据。但你几乎从来不关心数据,你总是想对数据 做事情, 所以可以 数据实施的行为(计算它的长度,在末尾添加数据,检索,等等)都被设计成为String类的方法。

任何给定的字符串都是这个类的一个实例,这个类是一个整齐的集合包装:字符数据和我们可以对它进行的操作功能。

类还隐含着对一个特定数据结构的一种 分类 方法。我们这么做的方法是,将一个给定的结构考虑为一个更加泛化的基础定义的具体种类。

让我们通过一个最常被引用的例子来探索这种分类处理。一辆 可以被描述为一“类”更泛化的东西——载具——的具体实现。

我们在软件中通过定义Vehicle类和Car类来模型化这种关系。

Vehicle的定义可能会包含像动力(引擎等),载人能力等等,这些都是行为。我们在Vehicle中定义的都是所有(或大多数)不同类型的载具(飞机,火车,机动车)都共同拥有的东西。

在我们的软件中为每一种不同类型的载具一次又一次地重定义“载人能力”这个基本性质可能没有道理。反而,我们在Vehicle中把这个能力定义一次,之后当我们定义Car时,我们简单地指出它从基本的Vehicle定义中“继承”(或“扩展”)。Car的定义就是特化了一般的Vehicle定义。

虽然VehicleCar用方法的形式集约地定义了行为,但一个实例中的数据就像一个唯一的车牌号一样属于一辆具体的车。

这样,类,继承,和实例化就诞生了。

另一个关于类的关键概念是“多态(polymorphism)”,它描述这样的想法:一个来自于父类的泛化行为可以被子类覆盖,从而使它更加具体。实际上,相对多态让我们在覆盖行为中引用基础行为。

类理论强烈建议父类和子类对相同的行为共享同样的方法名,以便于子类(差异化地)覆盖父类。我们即将看到,在你的 JavaScript 代码中这么做会导致种种困难和脆弱的代码。

"类(Class)"设计模式

你可能从没把类当做一种“设计模式”考虑过,因为最常见的是关于流行的“面向对象设计模式”的讨论,比如“迭代器(Iterator)”,“观察者(Observer)”,“工厂(Factory)”,“单例(Singleton)”等等。当以这种方式表现时,几乎可以假定 OO 的类是我们实现所有(高级)设计模式的底层机制,好像对所有代码来说 OO 是一个给定的基础。

取决于你在编程方面接受过的正规教育的水平,你可能听说过“过程式编程(procedural programming)”:一种不用任何高级抽象,仅仅由过程(也就是函数)调用其他函数来构成的描述代码的方式。你可能被告知过,类是一个将过程式风格的“面条代码”转换为结构良好,组织良好代码的 恰当 的方法。

当然,如果你有“函数式编程(functional programming)”的经验,你可能知道类只是几种常见设计模式中的一种。但是对于其他人来说,这可能是第一次你问自己,类是否真的是代码的根本基础,或者它们是在代码顶层上的选择性抽象。

有些语言(比如 Java)不给你选择,所以这根本没什么 选择性——一切都是类。其他语言如 C/C++或 PHP 同时给你过程式和面向类的语法,在使用哪种风格合适或混合风格上,留给开发者更多选择。

JavaScript 的“类”

在这个问题上 JavaScript 属于哪一边?JS 拥有 一些 像类的语法元素(比如newinstanceof)有一阵子了,而且在最近的 ES6 中,还有一些追加的,比如class关键字(见附录 A)。

但这意味着 JavaScript 实际上 拥有 类吗?直白且简单:没有。

由于类是一种设计模式,你 可以,用相当的努力(我们将在本章剩下的部分看到),近似实现很多经典类的功能。JS 在通过提供看起来像类的语法,来努力满足用类进行设计的极其广泛的渴望。

虽然我们好像有了看起来像类的语法,但是好像 JavaScript 机制在抵抗你使用 类设计模式,因为在底层,这些你正在上面工作的机制运行的十分不同。语法糖和(极其广泛被使用的)JS“Class”库废了很大力气来把这些真实情况对你隐藏起来,但你迟早会面对现实:你在其他语言中遇到的 和你在 JS 中模拟的“类”不同。

总而言之,类是软件设计中的一种可选模式,你可以选择在 JavaScript 中使用或不使用它。因为许多开发者都对面向类的软件设计情有独钟,我们将在本章剩下的部分中探索一下,为了使用 JS 提供的东西维护类的幻觉要付出什么代价,和我们经历的痛苦。

Class 机制

在许多面向类语言中,“标准库”都提供一个叫“栈”(压栈,弹出等)的数据结构,用一个Stack类表示。这个类拥有一组变量来存储数据,还拥有一组可公开访问的行为(“方法”),这些行为使你的代码有能力与(隐藏的)数据互动(添加或移除数据等等)。

但是在这样的语言中,你不是直接在Stack上操作(除非制造一个 静态的 类成员引用,但这超出了我们要讨论的范围)。Stack类仅仅是 任何 的“栈”都会做的事情的一个抽象解释,但它本身不是一个“栈”。为了得到一个可以对之进行操作的实在的数据结构,你必须 实例化 这个Stack类。

建筑物

传统的"类(class)"和"实例(instance)"的比拟源自于建筑物的建造。

一个建筑师会规划出一栋建筑的所有性质:多宽,多高,在哪里有多少窗户,甚至墙壁和天花板用什么材料。在这个时候,她并不关心建筑物将会被建造在 哪里,她也不关心有 多少 这栋建筑的拷贝将被建造。

同时她也不关心这栋建筑的内容——家具,墙纸,吊扇等等——她仅关心建筑物含有何种结构。

她生产的建筑学上的蓝图仅仅是建筑物的“方案”。它们不实际构成我们可以实在进入其中并坐下的建筑物。为了这个任务我们需要一个建筑工。建筑工会拿走方案并精确地依照它们 建造 这栋建筑物。在真正的意义上,他是在将方案中意图的性质 拷贝 到物理建筑物中。

一旦完成,这栋建筑就是蓝图方案的一个物理实例,一个有望是实质完美的 拷贝。然后建筑工就可以移动到隔壁将它再重做一遍,建造另一个 拷贝

建筑物与蓝图间的关系是间接的。你可以检视蓝图来了解建筑物是如何构造的,但对于直接考察建筑物的每一部分,仅有蓝图是不够的。如果你想打开一扇门,你不得不走进建筑物自身——蓝图仅仅是为了用来 表示 门的位置而在纸上画的线条。

一个类就是一个蓝图。为了实际得到一个对象并与之互动,我们必须从类中建造(也就是实例化)某些东西。这种“构建”的最终结果是一个对象,典型地称为一个“实例”,我们可以按需要直接调用它的方法,访问它的公共数据属性。

这个对象是所有在类中被描述的特性的 拷贝

你不太指望走进一栋建筑之后发现,一份用于规划这栋建筑物的蓝图被裱起来挂在墙上,虽然蓝图可能在办公室的公共记录的文件中。相似地,你一般不会使用对象实例来直接访问和操作类,但是这至少对于判定对象实例来自于 哪个类 是可能的。

与考虑对象实例与它源自的类的任何间接关系相比,考虑类和对象实例的直接关系更有用。一个类通过拷贝操作被实例化为对象的形式。

如你所见,箭头由左向右,从上至下,这表示着概念上和物理上发生的拷贝操作。

构造器(Constructor)

类的实例由类的一种特殊方法构建,这个方法的名称通常与类名相同,称为 “构造器(constructor)”。这个方法的明确的工作,就是初始化实例所需的所有信息(状态)。

比如,考虑下面这个类的假想代码(语法是自创的):

class CoolGuy {
    specialTrick = nothing

    CoolGuy( trick ) {
        specialTrick = trick
    }

    showOff() {
        output( "Here's my trick: ", specialTrick )
    }
}

为了 制造 一个CoolGuy实例,我们需要调用类的构造器:

Joe = new CoolGuy( "jumping rope" )

Joe.showOff() // Here's my trick: jumping rope

注意,CoolGuy类有一个构造器CoolGuy(),它实际上就是在我们说new CoolGuy(..)时调用的。我们从这个构造器拿回一个对象(类的一个实例),我们可以调用showOff()方法,来打印这个特定的CoolGuy的特殊才艺。

显然,跳绳使 Joe 看起来很酷。

类的构造器 属于 那个类,几乎总是和类同名。同时,构造器大多数情况下总是需要用new来调用,以便使语言的引擎知道你想要构建一个 新的 类的实例。

类继承(Class Inheritance)

在面向类的语言中,你不仅可以定义一个可以初始化它自己的类,你还可以定义另外一个类 继承 自第一个类。

这第二个类通常被称为“子类”,而第一个类被称为“父类”。这些名词明显地来自于亲子关系的比拟,虽然这种比拟有些扭曲,就像你马上要看到的。

当一个家长拥有一个和他有血缘关系的孩子时,家长的遗传性质会被拷贝到孩子身上。明显地,在大多数生物繁殖系统中,双亲都平等地贡献基因进行混合。但是为了这个比拟的目的,我们假设只有一个亲人。

一旦孩子出现,他或她就从亲人那里分离出来。这个孩子受其亲人的继承因素的严重影响,但是独一无二。如果这个孩子拥有红色的头发,这并不意味这他的亲人的头发 曾经 是红色,或者会自动 变成 红色。

以相似的方式,一旦一个子类被定义,它就分离且区别于父类。子类含有一份从父类那里得来的行为的初始拷贝,但它可以覆盖这些继承的行为,甚至是定义新行为。

重要的是,要记住我们在讨论父 和子 ,而不是物理上的东西。这就是这个亲子比拟让人糊涂的地方,因为我们实际上应当说父类就是亲人的 DNA,而子类就是孩子的 DNA。我们不得不从两套 DNA 制造出(也就是初始化)人,用得到的物理上存在的人来与之进行谈话。

让我们把生物学上的亲子放在一边,通过一个稍稍不同的角度来看看继承:不同种类型的载具。这是用来理解继承的最经典(也是争议不断的)的比拟。

让我们重新审视本章前面的VehicleCar的讨论。考虑下面表达继承的类的假想代码:

class Vehicle {
    engines = 1

    ignition() {
        output( "Turning on my engine." )
    }

    drive() {
        ignition()
        output( "Steering and moving forward!" )
    }
}

class Car inherits Vehicle {
    wheels = 4

    drive() {
        inherited:drive()
        output( "Rolling on all ", wheels, " wheels!" )
    }
}

class SpeedBoat inherits Vehicle {
    engines = 2

    ignition() {
        output( "Turning on my ", engines, " engines." )
    }

    pilot() {
        inherited:drive()
        output( "Speeding through the water with ease!" )
    }
}

注意: 为了简洁明了,这些类的构造器被省略了。

我们定义Vehicle类,假定它有一个引擎,有一个打开打火器的方法,和一个行驶的方法。但你永远也不会制造一个泛化的“载具”,所以在这里它只是一个概念的抽象。

然后我们定义了两种具体的载具:CarSpeedBoat。它们都继承Vehicle的泛化性质,但之后它们都对这些性质进行了合适的特化。一辆车有 4 个轮子,一艘快艇有两个引擎,意味着它需要在打火时需要特别注意要启动两个引擎。

多态(Polymorphism)

Car定义了自己的drive()方法,它覆盖了从Vehicle继承来的同名方法。但是,Cardrive()方法调用了inherited:drive(),这表示Car可以引用它继承的,覆盖之前的原版drive()SpeedBoatpilot()方法也引用了它继承的drive()拷贝。

这种技术称为“多态(polymorphism)”,或“虚拟多态(virtual polymorphism)”。对我们当前的情况更具体一些,我们称之为“相对多态(relative polymorphism)”。

多态这个话题比我们可以在这里谈到的内容要宽泛的多,但我们当前的“相对”意味着一个特殊层面:任何方法都可以引用位于继承层级上更高一层的其他方法(同名或不同名)。我们说“相对”,因为我们不绝对定义我们想访问继承的哪一层(也就是类),而实质上在说“向上一层”来相对地引用。

在许多语言中,在这个例子中使用inherited:的地方使用了super关键字,它的基于这样的想法:一个“超类(super class)”是当前类的父亲/祖先。

多态的另一个方面是,一个方法名可以在继承链的不同层面上有多种定义,而且在解析哪个方法在被调用时,这些定义可以适当地被自动选择。

在我们上面的例子中,我们看到这种行为发生了两次:drive()VehicleCar中定义, 而ignition()VehicleSpeedBoat中定义。

注意: 另一个传统面向类语言通过super给你的能力,是从子类的构造器中直接访问父类构造器。这很大程度上是对的,因为对真正的类来说,构造器属于这个类。然而在 JS 中,这是相反的——实际上认为“类”属于构造器(Foo.prototype...类型引用)更恰当。因为在 JS 中,父子关系仅存在于它们各自的构造器的两个.prototype对象间,构造器本身不直接关联,而且没有简单的方法从一个中相对引用另一个(参见附录 A,看看 ES6 中用super“解决”此问题的class)。

可以从ignition()中具体看出多态的一个有趣的含义。在pilot()内部,一个相对多态引用指向了(继承的)Vehicle版本的drive()。而这个drive()仅仅通过名称(不是相对引用)来引用ignition()方法。

语言的引擎会使用哪一个版本的ignition()?是Vehicle的还是SpeedBoat的?它会使用SpeedBoat版本的ignition() 如果你 初始化Vehicle类自身,并且调用它的drive(),那么语言引擎将会使用Vehicleignition()定义。

换句话说,ignition()方法的定义,根据你引用的实例是哪个类(继承层级)而 多态(改变)。

这看起来过于深入学术细节了。不过为了好好地与 JavaScript 的[[Prototype]]机制的类似行为进行对比,理解这些细节还是很重要的。

如果类是继承而来的,对这些类本身(不是由它们创建的对象)有一个方法可以 相对地 引用它们继承的对象,这个相对引用通常称为super

记得刚才这幅图:

注意对于实例化(a1a2b1,和b2) 继承(Bar),箭头如何表示拷贝操作。

从概念上讲,看起来子类Bar可以使用相对多态引用(也就是super)来访问它的父类Foo的行为。然而在现实中,子类不过是被给与了一份它从父类继承来的行为的拷贝而已。如果子类“覆盖”一个它继承的方法,原版的方法和覆盖版的方法实际上都是存在的,所以它们都是可以访问的。

不要让多态把你搞糊涂,使你认为子类是链接到父类上的。子类得到一份它需要从父类继承的东西的拷贝。类继承意味着拷贝。

多重继承(Multiple Inheritance)

能回想起我们早先提到的亲子和 DNA 吗?我们说过这个比拟有些奇怪,因为生物学上大多数后代来自于双亲。如果类可以继承自其他两个类,那么这个亲子比拟会更合适一些。

有些面向类的语言允许你指定一个以上的“父类”来进行“继承”。多重继承意味着每个父类的定义都被拷贝到子类中。

表面上看来,这是对面向类的一个强大的加成能力,给我们能力去将更多功能组合在一起。然而,这无疑会产生一些复杂的问题。如果两个父类都提供了名为drive()的方法,在子类中的drive()引用将会解析为哪个版本?你会总是不得不手动指明哪个父类的drive()是你想要的,从而失去一些多态继承的优雅之处吗?

还有另外一个所谓的“钻石问题”:子类“D”继承自两个父类(“B”和“C”),它们两个又继承自共通的父类“A”。如果“A”提供了方法drive(),而“B”和“C”都覆盖(多态地)了这个方法,那么当“D”引用drive()时,它应当使用那个版本呢(B:drive()还是C:drive())?

事情会比我们这样窥豹一斑能看到的复杂得多。我们在这里把它们记下来,以便于我们可以将它和 JavaScript 机制的工作方式比较。

JavaScript 更简单:它不为“多重继承”提供原生机制。许多人认为这是好事,因为省去的复杂性要比“减少”的功能多得多。但是这并不能阻挡开发者们用各种方法来模拟它,我们接下来就看看。

Mixins(混合)

当你“继承”或是“实例化”时,JavaScript 的对象机制不会 自动地 执行拷贝行为。很简单,在 JavaScript 中没有“类”可以拿来实例化,只有对象。而且对象也不会被拷贝到另一个对象中,而是被 链接在一起(详见第五章)。

因为在其他语言中观察到的类的行为意味着拷贝,让我们来看看 JS 开发者如何在 JavaScript 中 模拟 这种 缺失 的类的拷贝行为:mixins(混合)。我们会看到两种“mixin”:明确的(explicit)隐含的(implicit)

明确的 Mixins(Explicit Mixins)

让我们再次回顾前面的VehicleCar的例子。因为 JavaScript 不会自动地将行为从Vehicle拷贝到Car,我们可以建造一个工具来手动拷贝。这样的工具经常被许多包/框架称为extend(..),但为了说明的目的,我们在这里叫它mixin(..)

// 大幅简化的`mixin(..)`示例:
function mixin( sourceObj, targetObj ) {
    for (var key in sourceObj) {
        // 仅拷贝非既存内容
        if (!(key in targetObj)) {
            targetObj[key] = sourceObj[key];
        }
    }

    return targetObj;
}

var Vehicle = {
    engines: 1,

    ignition: function() {
        console.log( "Turning on my engine." );
    },

    drive: function() {
        this.ignition();
        console.log( "Steering and moving forward!" );
    }
};

var Car = mixin( Vehicle, {
    wheels: 4,

    drive: function() {
        Vehicle.drive.call( this );
        console.log( "Rolling on all " + this.wheels + " wheels!" );
    }
} );

注意: 重要的细节:我们谈论的不再是类,因为在 JavaScript 中没有类。VehicleCar分别只是我们实施拷贝的源和目标对象。

Car现在拥有了一份从Vehicle得到的属性和函数的拷贝。技术上讲,函数实际上没有被复制,而是指向函数的 引用 被复制了。所以,Car现在有一个称为ignition的属性,它是一个ignition()函数引用的拷贝;而且它还有一个称为engines的属性,持有从Vehicle拷贝来的值1

Car已经 有了drive属性(函数),所以这个属性引用没有被覆盖(参见上面mixin(..)if语句)。

重温"多态(Polymorphism)"

我们来考察一下这个语句:Vehicle.drive.call( this )。我将之称为“显式假想多态(explicit pseudo-polymorphism)”。回想我们前一段假想代码的这一行是我们称之为“相对多态(relative polymorphism)”的inherited:drive()

JavaScript 没有能力实现相对多态(ES6 之前,见附录 A)。所以,因为CarVehicle都有一个名为drive()的函数,为了在它们之间区别调用,我们必须使用绝对(不是相对)引用。我们明确地用名称指出Vehicle对象,然后在它上面调用drive()函数。

但如果我们说Vehicle.drive(),那么这个函数调用的this绑定将会是Vehicle对象,而不是Car对象(见第二章),那不是我们想要的。所以,我们使用.call( this )(见第二章)来保证drive()Car对象的环境中被执行。

注意: 如果Car.drive()的函数名称标识符没有与Vehicle.drive()的重叠(也就是“遮蔽”;见第五章),我们就不会有机会演示“方法多态(method polymorphism)”。因为那样的话,一个指向Vehicle.drive()的引用会被mixin(..)调用拷贝,而我们可以使用this.drive()直接访问它。被选用的标识符重叠 遮蔽 就是为什么我们不得不使用更复杂的 显式假想多态(explicit pseudo-polymorphism) 的原因。

在拥有相对多态的面向类的语言中,CarVehicle间的连接被建立一次,就在类定义的顶端,这里是维护这种关系的唯一场所。

但是由于 JavaScript 的特殊性,显式假想多态(因为遮蔽!) 在每一个你需要这种(假想)多态引用的函数中 建立了一种脆弱的手动/显式链接。这可能会显著地增加维护成本。而且,虽然显式假想多态可以模拟“多重继承”的行为,但这只会增加复杂性和代码脆弱性。

这种方法的结果通常是更加复杂,更难读懂,而且 更难维护的代码。应当尽可能地避免使用显式假想多态,因为在大部分层面上它的代价要高于利益。

混合拷贝(Mixing Copies)

回忆上面的mixin(..)工具:

// 大幅简化的`mixin()`示例:
function mixin( sourceObj, targetObj ) {
    for (var key in sourceObj) {
        // 仅拷贝不存在的属性
        if (!(key in targetObj)) {
            targetObj[key] = sourceObj[key];
        }
    }

    return targetObj;
}

现在,我们考察一下mixin(..)如何工作。它迭代sourceObj(在我们的例子中是Vehicle)的所有属性,如果在targetObj (在我们的例子中是Car)中没有名称与之匹配的属性,它就进行拷贝。因为我们是在初始对象存在的情况下进行拷贝,所以我们要小心不要将目标属性覆盖掉。

如果在指明Car的具体内容之前,我们先进行拷贝,那么我们就可以省略对targetObj检查,但是这样做有些笨拙且低效,所以通常不优先选用:

// 另一种 mixin,对覆盖不太“安全”
function mixin( sourceObj, targetObj ) {
    for (var key in sourceObj) {
        targetObj[key] = sourceObj[key];
    }

    return targetObj;
}

var Vehicle = {
    // ...
};

// 首先,创建一个空对象
// 将 Vehicle 的内容拷贝进去
var Car = mixin( Vehicle, { } );

// 现在拷贝 Car 的具体内容
mixin( {
    wheels: 4,

    drive: function() {
        // ...
    }
}, Car );

不论哪种方法,我们都显式地将Vehicle中的非重叠内容拷贝到Car中。“mixin”这个名称来自于解释这个任务的另一种方法:Car混入Vehicle的内容,就像你吧巧克力碎片混入你最喜欢的曲奇饼面团。

这个拷贝操作的结果,是Car将会独立于Vehicle运行。如果你在Car上添加属性,它不会影响到Vehicle,反之亦然。

注意: 这里有几个小细节被忽略了。仍然有一些微妙的方法使两个对象在拷贝完成后还能互相“影响”对方,比如他们共享一个共通对象(比如数组)的引用。

由于两个对象还共享它们的共通函数的引用,这意味着 即便手动将函数从一个对象拷贝(也就是混入)到另一个对象中,也不能 实际上模拟 发生在面向类的语言中的从类到实例的真正的复制

JavaScript 函数不能真正意义上地被复制(以标准,可靠的方式),所以你最终得到的是同一个共享的函数对象(函数是对象;见第三章)的 被复制的引用。举例来说,如果你在一个共享的函数对象(比如ignition())上添加属性来修改它,VehicleCar都会通过这个共享的引用而受影响。

在 JavaScript 中明确的 mixin 是一种不错的机制。但是它们显得言过其实。和将一个属性定义两次相比,将属性从一个对象拷贝到另一个对象并不会产生多少 实际的 好处。这对我们刚才提到的给函数对象引用增加的微妙变化来说,显得尤为正确。

如果你明确地将两个或更多对象混入你的目标对象,你可以 某种程度上模拟 “多重继承”的行为,但是在将方法或属性从多于一个源对象那里拷贝过来时,没有直接的办法可以解决名称的冲突。有些开发者/包使用“late binding(延迟绑定)”和其他诡异的替代方法来解决问题,但从根本上讲,这些“技巧” 通常 得不偿失(而且低效!)。

要小心的是,仅在明确的 mixin 能够实际提高代码可读性时使用它,而如果你发现它使代码变得更很难追溯,或在对象间建立了不必要或笨重的依赖性时,要避免使用这种模式。

如果正确使用 mixin 使你的问题变得比以前 困难,那么你可能应当停止使用 mixin。实际上,如果你不得不使用复杂的包/工具来处理这些细节,这可能标志着你正走在更困难,也许没必要的道路上。在第六章中,我们将试着提取一种更简单的方法来实现我们期望的结果,同时免去这些周折。

寄生继承(Parasitic Inheritance)

明确的 mixin 模式的一个变种,在某种意义上是明确的而在某种意义上是隐含的,称为“寄生继承(Parasitic Inheritance)”,它主要是由 Douglas Crockford 推广的。

这是它如何工作:

// “传统的 JS 类” `Vehicle`
function Vehicle() {
    this.engines = 1;
}
Vehicle.prototype.ignition = function() {
    console.log( "Turning on my engine." );
};
Vehicle.prototype.drive = function() {
    this.ignition();
    console.log( "Steering and moving forward!" );
};

// “寄生类” `Car`
function Car() {
    // 首先, `car`是一个`Vehicle`
    var car = new Vehicle();

    // 现在, 我们修改`car`使它特化
    car.wheels = 4;

    // 保存一个`Vehicle::drive()`的引用
    var vehDrive = car.drive;

    // 覆盖 `Vehicle::drive()`
    car.drive = function() {
        vehDrive.call( this );
        console.log( "Rolling on all " + this.wheels + " wheels!" );
    };

    return car;
}

var myCar = new Car();

myCar.drive();
// Turning on my engine.
// Steering and moving forward!
// Rolling on all 4 wheels!

如你所见,我们一开始从“父类”(对象)Vehicle制造了一个定义的拷贝,之后将我们的“子类”(对象)定义混入其中(按照需要保留父类的引用),最后将组合好的对象car作为子类实例传递出去。

注意: 当我们调用new Car()时,一个新对象被创建并被Carthis所引用(见第二章)。但是由于我们没有使用这个对象,而是返回我们自己的car对象,所以这个初始化创建的对象就被丢弃了。所以,Car()可以不用new关键字调用,就可以实现和上面代码相同的功能,而且还可以节省对象的创建和回收。

隐含的 Mixin(Implicit Mixins)

隐含的 mixin 和前面解释的 显式假想多态 是紧密相关的。所以它们需要注意相同的事项。

考虑这段代码:

var Something = {
    cool: function() {
        this.greeting = "Hello World";
        this.count = this.count ? this.count + 1 : 1;
    }
};

Something.cool();
Something.greeting; // "Hello World"
Something.count; // 1

var Another = {
    cool: function() {
        // 隐式地将`Something`混入`Another`
        Something.cool.call( this );
    }
};

Another.cool();
Another.greeting; // "Hello World"
Another.count; // 1 (不会和`Something`共享状态)

Something.cool.call( this )既可以在“构造器”调用中使用(最常见的情况),也可以在方法调用中使用(如这里所示),我们实质上“借用”了Something.cool()函数并在Another环境下,而非Something环境下调用它(通过this绑定,见第二章)。结果是,Something.cool()中进行的赋值被实施到了Another对象而非Something对象。

那么,这就是说我们将Something的行为“混入”了Another

虽然这种技术看起来有效利用了this再绑定的功能,也就是生硬地调用Something.cool.call( this ),但是这种调用不能被作为相对(也更灵活的)引用,所以你应当 提高警惕。一般来说,尽量避免使用这种结构 来保持代码干净而且容易维护。

复习

类是一种设计模式。许多语言提供语法来启用自然而然的面向类的软件设计。JS 也有相似的语法,但是它的行为和你在其他语言中熟悉的工作原理 有很大的不同

类意味着拷贝。

当一个传统的类被实例化时,就发生了类的行为向实例中拷贝。当类被继承时,也发生父类的行为向子类的拷贝。

多态(在继承链的不同层级上拥有同名的不同函数)也许看起来意味着一个从子类回到父类的相对引用链接,但是它仍然只是拷贝行的的结果。

JavaScript 不会自动地 (像类那样)在对象间创建拷贝。

mixin 模式常用于在 某种程度上 模拟类的拷贝行为,但是这通常导致像显式假想多态那样(OtherObj.methodName.call(this, ...))难看而且脆弱的语法,这样的语法又常导致更难懂和更难维护的代码。

显式 mixin 和类 拷贝 又不完全相同,因为对象(和函数!)仅仅是共享的引用被复制,不是对象/函数自身被复制。不注意这样的微小之处通常是各种陷阱的根源。

一般来讲,在 JS 中模拟类通常会比解决当前 真正 的问题埋下更多的坑。

你不懂 JS:this 与对象原型 第五章:原型(Prototype)

在第三,四章中,我们几次提到了[[Prototype]]链,但我们没有讨论它到底是什么。现在我们就详细讲解一下原型(prototype)。

注意: 所有模拟类拷贝行为的企图,也就是我们在前面第四章描述的内容,称为各种种类的“mixin”,和我们要在本章中讲解的[[Prototype]]链机制完全不同。

[[Prototype]]

JavaScript 中的对象有一个内部属性,在语言规范中称为[[Prototype]],它只是一个其他对象的引用。几乎所有的对象在被创建时,它的这个属性都被赋予了一个非null值。

注意: 我们马上就会看到,一个对象拥有一个空的[[Prototype]]链接是 可能 的,虽然这有些不寻常。

考虑下面的代码:

var myObject = {
    a: 2
};

myObject.a; // 2

[[Prototype]]引用有什么用?在第三章中,我们讲解了[[Get]]操作,它会在你引用一个对象上的属性时被调用,比如myObject.a。对于默认的[[Get]]操作来说,第一步就是检查对象本身是否拥有一个a属性,如果有,就使用它。

注意: ES6 的代理(Proxy)超出了我们要在本书内讨论的范围(将会在本系列的后续书目中涵盖!),但是如果加入Proxy,我们在这里讨论的关于普通[[Get]][[Put]]的行为都是不被采用的。

但是如果myObject 存在a属性时,我们就将注意力转向对象的[[Prototype]]链。

如果默认的[[Get]]操作不能直接在对象上找到被请求的属性,那么会沿着对象的[[Prototype]] 继续处理。

var anotherObject = {
    a: 2
};

// 创建一个链接到`anotherObject`的对象
var myObject = Object.create( anotherObject );

myObject.a; // 2

注意: 我们马上就会解释Object.create(..)是做什么,如何做的。眼下先假设,它创建了一个对象,这个对象带有一个链到指定的对象的[[Prototype]]链接,这个链接就是我们要讲解的。

那么,我们现在让myObject``[[Prototype]]链到了anotherObject。虽然很明显myObject.a实际上不存在,但是无论如何属性访问成功了(在anotherObject中找到了),而且确实找到了值2

但是,如果在anotherObject上也没有找到a,而且如果它的[[Prototype]]链不为空,就沿着它继续查找。

这个处理持续进行,直到找到名称匹配的属性,或者[[Prototype]]链终结。如果在链条的末尾都没有找到匹配的属性,那么[[Get]]操作的返回结果为undefined

和这种[[Prototype]]链查询处理相似,如果你使用for..in循环迭代一个对象,所有在它的链条上可以到达的(并且是enumerable——见第三章)属性都会被枚举。如果你使用in操作符来测试一个属性在一个对象上的存在性,in将会检查对象的整个链条(不管 可枚举性)。

var anotherObject = {
    a: 2
};

// 创建一个链接到`anotherObject`的对象
var myObject = Object.create( anotherObject );

for (var k in myObject) {
    console.log("found: " + k);
}
// 找到: a

("a" in myObject); // true

所以,当你以各种方式进行属性查询时,[[Prototype]]链就会一个链接一个链接地被查询。一旦找到属性或者链条终结,这种查询会就会停止。

Object.prototype

但是[[Prototype]]链到底在 哪里 “终结”?

每个 普通[[Prototype]]链的最顶端,是内建的Object.prototype。这个对象包含各种在整个 JS 中被使用的共通工具,因为 JavaScript 中所有普通(内建,而非被宿主环境扩展的)的对象都“衍生自”(也就是,使它们的[[Prototype]]顶端为)Object.prototype对象。

你会在这里发现一些你可能很熟悉的工具,比如.toString().valueOf()。在第三章中,我们介绍了另一个:.hasOwnProperty(..)。还有另外一个你可能不太熟悉,但我们将在这一章里讨论的Object.prototype上的函数是.isPrototypeOf(..)

设置与遮蔽属性

回到第三章,我们提到过在对象上设置属性要比仅仅在对象上添加新属性或改变既存属性的值更加微妙。现在我们将更完整地重温这个话题。

myObject.foo = "bar";

如果myObject对象已直接经拥有了普通的名为foo的数据访问器属性,那么这个赋值就和改变既存属性的值一样简单。

如果foo还没有直接存在于myObject[[Prototype]]就会被遍历,就像[[Get]]操作那样。如果在链条的任何地方都没有找到foo,那么就会像我们期望的那样,属性foo就以指定的值被直接添加到myObject上。

然而,如果foo已经存在于链条更高层的某处,myObject.foo = "bar"赋值就可能会发生微妙的(也许令人诧异的)行为。我们一会儿就详细讲解。

如果属性名foo同时存在于myObject本身和从myObject开始的[[Prototype]]链的更高层,这样的情况称为 遮蔽。直接存在于myObject上的foo属性会 遮蔽 任何出现在链条高层的foo属性,因为myObject.foo查询总是在寻找链条最底层的foo属性。

正如我们被暗示的那样,在myObject上的foo遮蔽没有看起来那么简单。我们现在来考察myObject.foo = "bar"赋值的三种场景,当foo 不直接存在myObject,但 存在myObject[[Prototype]]链的更高层:

  1. 如果一个普通的名为foo的数据访问属性在[[Prototype]]链的高层某处被找到,而且没有被标记为只读(writable:false,那么一个名为foo的新属性就直接添加到myObject上,形成一个 遮蔽属性
  2. 如果一个foo[[Prototype]]链的高层某处被找到,但是它被标记为 只读(writable:false ,那么设置既存属性和在myObject上创建遮蔽属性都是 不允许 的。如果代码运行在strict mode下,一个错误会被抛出。否则,这个设置属性值的操作会被无声地忽略。不论怎样,没有发生遮蔽
  3. 如果一个foo[[Prototype]]链的高层某处被找到,而且它是一个 setter(见第三章),那么这个 setter 总是被调用。没有foo会被添加到(也就是遮蔽在)myObject上,这个foosetter 也不会被重定义。

大多数开发者认为,如果一个属性已经存在于[[Prototype]]链的高层,那么对它的赋值([[Put]])将总是造成遮蔽。但如你所见,这仅在刚才描述的三中场景中的一种(第一种)中是对的。

如果你想在第二和第三种情况中遮蔽foo,那你就不能使用=赋值,而必须使用Object.defineProperty(..)(见第三章)将foo添加到myObject

注意: 第二种情况可能是三种情况中最让人诧异的了。只读 属性的存在会阻止同名属性在[[Prototype]]链的低层被创建(遮蔽)。这个限制的主要原因是为了增强类继承属性的幻觉。如果你想象位于链条高层的foo被继承(拷贝)至myObject, 那么在myObject上强制foo属性不可写就有道理。但如果你将幻觉和现实分开,而且认识到 实际上 没有这样的继承拷贝发生(见第四,五章),那么仅因为某些其他的对象上拥有不可写的foo,而导致myObject不能拥有foo属性就有些不自然。而且更奇怪的是,这个限制仅限于=赋值,当使用Object.defineProperty(..)时不被强制。

如果你需要在方法间进行委托,方法 的遮蔽会导致难看的 显式假想多态(见第四章)。一般来说,遮蔽与它带来的好处相比太过复杂和微妙了,所以你应当尽量避免它。第六章介绍另一种设计模式,它提倡干净而且不鼓励遮蔽。

遮蔽甚至会以微妙的方式隐含地发生,所以要想避免它必须小心。考虑这段代码:

var anotherObject = {
    a: 2
};

var myObject = Object.create( anotherObject );

anotherObject.a; // 2
myObject.a; // 2

anotherObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "a" ); // false

myObject.a++; // 噢,隐式遮蔽!

anotherObject.a; // 2
myObject.a; // 3

myObject.hasOwnProperty( "a" ); // true

虽然看起来myObject.a++应当(通过委托)查询并 原地 递增anotherObject.a属性,但是++操作符相当于myObject.a = myObject.a + 1。结果就是在[[Prototype]]上进行a[[Get]]查询,从anotherObject.a得到当前的值2,将这个值递增 1,然后将值3[[Put]]赋值到myObject上的新遮蔽属性a上。噢!

修改你的委托属性时要非常小心。如果你想递增anotherObject.a, 那么唯一正确的方法是anotherObject.a++

“类”

现在你可能会想知道:“为什么 一个对象需要链到另一个对象?”真正的好处是什么?这是一个很恰当的问题,但在我们能够完全理解和体味它是什么和如何有用之前,我们必须首先理解[[Prototype]] 不是 什么。

正如我们在第四章讲解的,在 JavaScript 中,对于对象来说没有抽象模式/蓝图,即没有面向类的语言中那样的称为类的东西。JavaScript 只有 对象。

实际上,在所有语言中,JavaScript 几乎是独一无二的,也许是唯一的可以被称为“面向对象”的语言,因为可以根本没有类而直接创建对象的语言很少,而 JavaScript 就是其中之一。

在 JavaScript 中,类不能(因为根本不存在)描述对象可以做什么。对象直接定义它自己的行为。这里 仅有 对象

“类”函数

在 JavaScript 中有一种奇异的行为被无耻地滥用了许多年来 山寨 成某些 看起来 像“类”的东西。我们来仔细看看这种方式。

“某种程度的类”这种奇特的行为取决于函数的一个奇怪的性质:所有的函数默认都会得到一个公有的,不可枚举的属性,称为prototype,它可以指向任意的对象。

function Foo() {
    // ...
}

Foo.prototype; // { }

这个对象经常被称为“Foo 的原型”,因为我们通过一个不幸地被命名为Foo.prototype的属性引用来访问它。然而,我们马上会看到,这个术语命中注定地将我们搞糊涂。为了取代它,我将它称为“以前被认为是 Foo 的原型的对象”。只是开个玩笑。“一个被随意标记为‘Foo 点儿原型’的对象”,怎么样?

不管我们怎么称呼它,这个对象到底是什么?

解释它的最直接的方法是,每个由调用new Foo()(见第二章)而创建的对象将最终(有些随意地)被[[Prototype]]链接到这个“Foo 点儿原型”对象。

让我们描绘一下:

function Foo() {
    // ...
}

var a = new Foo();

Object.getPrototypeOf( a ) === Foo.prototype; // true

当通过调用new Foo()创建a时,会发生的事情之一(见第二章了解所有 四个 步骤)是,a得到一个内部[[Prototype]]链接,此链接链到Foo.prototype所指向的对象。

停一会来思考一下这句话的含义。

在面向类的语言中,可以制造一个类的多个 拷贝(即“实例”),就像从模具中冲压出某些东西一样。我们在第四章中看到,这是因为初始化(或者继承)类的处理意味着,“将行为计划从这个类拷贝到物理对象中”,对于每个新实例这都会发生。

但是在 JavaScript 中,没有这样的拷贝处理发生。你不会创建类的多个实例。你可以创建多个对象,它们的[[Prototype]]连接至一个共通对象。但默认地,没有拷贝发生,如此这些对象彼此间最终不会完全分离和切断关系,而是 链接在一起

new Foo()得到一个新对象(我们叫他a),这个新对象a内部地被[[Prototype]]链接至Foo.prototype对象。

结果我们得到两个对象,彼此链接。 如是而已。我们没有初始化一个对象。当然我们也没有做任何从一个“类”到一个实体对象拷贝。我们只是让两个对象互相链接在一起。

事实上,这个使大多数 JS 开发者无法理解的秘密,是因为new Foo()函数调用实际上几乎和建立链接的处理没有任何 直接 关系。它是某种偶然的副作用。new Foo()是一个间接的,迂回的方法来得到我们想要的:一个被链接到另一个对象的对象。

我们能用更直接的方法得到我们想要的吗?可以! 这位英雄就是Object.create(..)。我们过会儿就谈到它。

名称的意义何在?

在 JavaScript 中,我们不从一个对象(“类”)向另一个对象(“实例”) 拷贝。我们在对象之间制造 链接。对于[[Prototype]]机制,视觉上,箭头的移动方向是从右至左,由下至上。

这种机制常被称为“原型继承(prototypal inheritance)”(我们很快就用代码说明),它经常被说成是动态语言版的“类继承”。这种说法试图建立在面向类世界中对“继承”含义的共识上。但是 弄拧意思是:抹平) 了被理解语义,来适应动态脚本。

先入为主,“继承”这个词有很强烈的含义(见第四章)。仅仅在它前面加入“原型”来区别于 JavaScript 中 实际上几乎相反 的行为,使真相在泥泞般的困惑中沉睡了近二十年。

我想说,将“原型”贴在“继承”之前很大程度上搞反了它的实际意义,就像一只手拿着一个桔子,另一手拿着一个苹果,而坚持说苹果是一个“红色的桔子”。无论我在它前面放什么令人困惑的标签,那都不会改变一个水果是苹果而另一个是桔子的 事实

更好的方法是直白地将苹果称为苹果——使用最准确和最直接的术语。这样能更容易地理解它们的相似之处和 许多不同之处,因为我们都对“苹果”的意义有一个简单的,共享的理解。

由于用语的模糊和歧义,我相信,对于解释 JavaScript 机制真正如何工作来说,“原型继承”这个标签(以及试图错误地应用所有面向类的术语,比如“类”,“构造器”,“实例”,“多态”等)本身带来的 危害比好处多

“继承”意味着 拷贝 操作,而 JavaScript 不拷贝对象属性(原生上,默认地)。相反,JS 在两个对象间建立链接,一个对象实质上可以将对属性/函数的访问 委托 到另一个对象上。对于描述 JavaScript 对象链接机制来说,“委托”是一个准确得多的术语。

另一个有时被扔到 JavaScript 旁边的术语是“差分继承”。它的想法是,我们可以用一个对象与一个更泛化的对象的 不同 来描述一个它的行为。比如,你要解释汽车是一种载具,与其重新描述组成一个一般载具的所有特点,不如只说它有 4 个轮子。

如果你试着想象,在 JS 中任何给定的对象都是通过委托可用的所有行为的总和,而且 在你思维中你扁平化 所有的行为到一个有形的 东西 中,那么你就可以(八九不离十地)看到“差分继承”是如何自圆其说的。

但正如“原型继承”,“差分继承”假意使你的思维模型比在语言中物理发生的事情更重要。它忽视了这样一个事实:对象B实际上不是一个差异结构,而是由一些定义好的特定性质,与一些没有任何定义的“漏洞”组成的。正是通过这些“漏洞”(缺少定义),委托可以接管并且动态地用委托行为“填补”它们。

对象不是像“差分继承”的思维模型所暗示的那样,原生默认地,通过拷贝 扁平化到一个单独的差异对象中。如此,对于描述 JavaScript 的[[Prototype]]机制如何工作来说,“差分继承”就不是自然合理。

可以选择 偏向“差分继承”这个术语和思维模型,这是个人口味的问题,但是不能否认这个事实:它 仅仅 符合你思维中的主观过程,不是引擎的物理行为。

"构造器"(Constructors)

让我们回到早先的代码:

function Foo() {
    // ...
}

var a = new Foo();

到底是什么导致我们认为Foo是一个“类”?

其一,我们看到了new关键字的使用,就像面向类语言中人们构建类的对象那样。另外,它看起来我们事实上执行了一个类的 构造器 方法,因为Foo()实际上是个被调用的方法,就像当你初始化一个真实的类时这个类的构造器被调用的那样。

为了使“构造器”的语义更使人糊涂,被随意贴上标签的Foo.prototype对象还有另外一招。考虑这段代码:

function Foo() {
    // ...
}

Foo.prototype.constructor === Foo; // true

var a = new Foo();
a.constructor === Foo; // true

Foo.prototype对象默认地(就在代码段中第一行中声明的地方!)得到一个公有的,称为.constructor的不可枚举(见第三章)属性,而且这个属性回头指向这个对象关联的函数(这里是Foo)。另外,我们看到被“构造器”调用new Foo()创建的对象a 看起来 也拥有一个称为.constructor的属性,也相似地指向“创建它的函数”。

注意: 这实际上不是真的。a上没有.constructor属性,而a.constructor确实解析成了Foo函数,“constructor”并不像它看起来的那样实际意味着“被 XX 创建”。我们很快就会解释这个奇怪的地方。

哦,是的,另外……根据 JavaScript 世界中的惯例,“类”都以大写字母开头的单词命名,所以使用Foo而不是foo强烈地意味着我们打算让它成为一个“类”。这对你来说太明显了,对吧!?

注意: 这个惯例是如此强大,以至于如果你在一个小写字母名称的方法上使用new调用,或并没有在一个大写字母开头的函数上使用new,许多 JS 语法检查器将会报告错误。这是因为我们如此努力地想要在 JavaScript 中将(假的)“面向类” 搞对,所以我们建立了这些语法规则来确保我们使用了大写字母,即便对 JS 引擎来讲,大写字母根本没有 任何意义

构造器还是调用?

上面的代码的段中,我们试图认为Foo是一个“构造器”,是因为我们用new调用它,而且我们观察到它“构建”了一个对象。

在现实中,Foo不会比你的程序中的其他任何函数“更像构造器”。函数自身 不是 构造器。但是,当你在普通函数调用前面放一个new关键字时,这就将函数调用变成了“构造器调用”。事实上,new在某种意义上劫持了普通函数并将它以另一种方式调用:构建一个对象,外加这个函数要做的其他任何事

举个例子:

function NothingSpecial() {
    console.log( "Don't mind me!" );
}

var a = new NothingSpecial();
// "Don't mind me!"

a; // {}

NothingSpecial仅仅是一个普通的函数,但当用new调用时,几乎是一种副作用,它会 构建 一个对象,并被我们赋值到a。这个 调用 是一个 构造器调用,但是NothingSpecial本身并不是一个 构造器

换句话说,在 JavaScript 中,更合适的说法是,“构造器”是在前面 new关键字调用的任何函数

函数不是构造器,但是当且仅当new被使用时,函数调用是一个“构造器调用”。

机制

仅仅是这些原因使得 JavaScript 中关于“类”的讨论变得命运多舛吗?

不全是。 JS 开发者们努力地尽可能的模拟面向类:

function Foo(name) {
    this.name = name;
}

Foo.prototype.myName = function() {
    return this.name;
};

var a = new Foo( "a" );
var b = new Foo( "b" );

a.myName(); // "a"
b.myName(); // "b"

这段代码展示了另外两种“面向类”的花招:

  1. this.name = name:在每个对象(分别在ab上;参照第二章关于this绑定的内容)上添加了.name属性,和类的实例包装数据值很相似。

  2. Foo.prototype.myName = ...:这也许是更有趣的技术,它在Foo.prototype对象上添加了一个属性(函数)。现在,也许让人惊奇,a.myName()可以工作。但是是如何工作的?

在上面的代码段中,有很强的倾向认为当ab被创建时,Foo.prototype上的属性/函数被 拷贝 到了ab俩个对象上。但是,这没有发生。

在本章开头,我们解释了[[Prototype]]链,和它作为默认的[[Get]]算法的一部分,如何在不能直接在对象上找到属性引用时提供后备的查询步骤。

于是,得益于他们被创建的方式,ab都最终拥有一个内部的[[Prototype]]链接链到Foo.prototype。当无法分别在ab中找到myName时,就会在Foo.prototype上找到(通过委托,见第六章)。

复活"构造器"

回想我们刚才对.constructor属性的讨论,怎么看起来a.constructor === Foo为 true 意味着a上实际拥有一个.constructor属性,指向Foo不对。

这只是一种不幸的混淆。实际上,.constructor引用也 委托 到了Foo.prototype,它 恰好 有一个指向Foo的默认属性。

看起来 方便得可怕,一个被Foo构建的对象可以访问指向Foo.constructor属性。但这只不过是安全感上的错觉。它是一个欢乐的巧合,几乎是误打误撞,通过默认的[[Prototype]]委托a.constructor 恰好 指向Foo。实际上.construcor意味着“被 XX 构建”这种注定失败的臆测会以几种方式来咬到你。

第一,在Foo.prototype上的.constructor属性仅当Foo函数被声明时才出现在对象上。如果你创建一个新对象,并用它替换函数默认的.prototype对象引用,这个新对象上将不会魔法般地得到.contructor

考虑这段代码:

function Foo() { /* .. */ }

Foo.prototype = { /* .. */ }; // 创建一个新的 prototype 对象

var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!

Object(..)没有“构建”a1,是吧?看起来确实是Foo()“构建了”它。许多开发者认为Foo()在执行构建,但当你认为“构造器”意味着“被 XX 构建”时,一切就都崩塌了,因为如果那样的话,a1.construcor应当是Foo,但它不是!

发生了什么?a1没有.constructor属性,所以它沿者[[Prototype]]链向上委托到了Foo.prototype。但是这个对象也没有.constructor(默认的Foo.prototype对象就会有!),所以它继续委托,这次轮到了Object.prototype,委托链的最顶端。那个 对象上确实拥有.constructor,它指向内建的Object(..)函数。

误解,消除。

当然,你可以把.constructor加回到Foo.prototype对象上,但是要做一些手动工作,特别是如果你想要它与原生的行为吻合,并不可枚举时(见第三章)。

举例来说:

function Foo() { /* .. */ }

Foo.prototype = { /* .. */ }; // 创建一个新的 prototype 对象

// 需要正确地“修复”丢失的`.construcor`
// 新对象上的属性以`Foo.prototype`的形式提供。
// `defineProperty(..)`的内容见第三章。
Object.defineProperty( Foo.prototype, "constructor" , {
    enumerable: false,
    writable: true,
    configurable: true,
    value: Foo    // 使`.constructor`指向`Foo`
} );

要修复.constructor要花不少功夫。而且,我们做的一切是为了延续“构造器”意味着“被 XX 构建”的误解。这是一种昂贵的假象。

事实上,一个对象上的.construcor默认地随意指向一个函数,而这个函数反过来拥有一个指向被这个对象称为.prototype的对象。“构造器”和“原型”这两个词仅有松散的默认含义,可能是真的也可能不是真的。最佳方案是提醒你自己,“构造器不是意味着被 XX 构建”。

.constructor不是一个魔法般不可变的属性。它是不可枚举的(见上面的代码段),但是它的值是可写的(可以改变),而且,你可以在[[Prototype]]链上的任何对象上添加或覆盖(有意或无意地)名为constructor的属性,用你感觉合适的任何值。

根据[[Get]]算法如何遍历[[Prototype]]链,在任何地方找到的一个.constructor属性引用解析的结果可能与你期望的十分不同。

看到它的实际意义有多随便了吗?

结果?某些像a1.constructor这样随意的对象属性引用实际上不能被认为是默认的函数引用。还有,我们马上就会看到,通过一个简单的省略,a1.constructor可以最终指向某些令人诧异,没道理的地方。

a1.constructor是极其不可靠的,在你的代码中不应依赖的不安全引用。一般来说,这样的引用应当尽量避免。

“(原型)继承”

我们已经看到了一些近似的“类”机制骇进 JavaScript 程序。但是如果我们没有一种近似的“继承”,JavaScript 的“类”将会更空洞。

实际上,我们已经看到了一个常被称为“原型继承”的机制如何工作:a可以“继承自”Foo.prototype,并因此可以访问myName()函数。但是我们传统的想法认为“继承”是两个“类”间的关系,而非“类”与“实例”的关系。

回想之前这幅图,它不仅展示了从对象(也就是“实例”)a1到对象Foo.prototype的委托,而且从Bar.prototypeFoo.prototype,这酷似类继承的亲自概念。酷似,除了方向,箭头表示的是委托链接,而不是拷贝操作。

这里是一段典型的创建这样的链接的“原型风格”代码:

function Foo(name) {
    this.name = name;
}

Foo.prototype.myName = function() {
    return this.name;
};

function Bar(name,label) {
    Foo.call( this, name );
    this.label = label;
}

// 这里,我们创建一个新的`Bar.prototype`链接链到`Foo.prototype`
Bar.prototype = Object.create( Foo.prototype );

// 注意!现在`Bar.prototype.constructor`不存在了,
// 如果你有依赖这个属性的习惯的话,可以被手动“修复”。

Bar.prototype.myLabel = function() {
    return this.label;
};

var a = new Bar( "a", "obj a" );

a.myName(); // "a"
a.myLabel(); // "obj a"

注意: 要想知道为什么上面代码中的this指向a,参见第二章。

重要的部分是Bar.prototype = Object.create( Foo.prototype )Object.create(..)凭空 创建 了一个“新”对象,并将这个新对象内部的[[Prototype]]链接到你指定的对象上(在这里是Foo.prototype)。

换句话说,这一行的意思是:“做一个 新的 链接到‘Foo 点儿 prototype’的‘Bar 点儿 prototype’对象”。

function Bar() { .. }被声明时,就像其他函数一样,拥有一个链到默认对象的.prototype链接。但是 那个 对象没有链到我们希望的Foo.prototype。所以,我们创建了一个 对象,链到我们希望的地方,并将原来的错误链接的对象扔掉。

注意: 这里一个常见的误解/困惑是,下面两种方法 能工作,但是他们不会如你期望的那样工作:

// 不会如你期望的那样工作!
Bar.prototype = Foo.prototype;

// 会如你期望的那样工作
// 但会带有你可能不想要的副作用 :(
Bar.prototype = new Foo();

Bar.prototype = Foo.prototype不会创建新对象让Bar.prototype链接。它只是让Bar.prototype成为Foo.prototype的另一个引用,将Bar直接链到Foo链着的 同一个对象Foo.prototype。这意味着当你开始赋值时,比如Bar.prototype.myLabel = ...,你修改的 不是一个分离的对象 而是那个被分享的Foo.prototype对象本身,它将影响到所有链接到Foo.prototype的对象。这几乎可以确定不是你想要的。如果这正是你想要的,那么你根本就不需要Bar,你应当仅使用Foo来使你的代码更简单。

Bar.prototype = new Foo()确实 创建了一个新的对象,这个新对象也的确链接到了我们希望的Foo.prototype。但是,它是用Foo(..)“构造器调用”来这样做的。如果这个函数有任何副作用(比如 logging,改变状态,注册其他对象,this添加数据属性,等等),这些副作用就会在链接时发生(而且很可能是对错误的对象!),而不是像可能希望的那样,仅最终在Bar()的“后裔”被创建时发生。

于是,我们剩下的选择就是使用Object.create(..)来制造一个新对象,这个对象被正确地链接,而且没有调用Foo(..)时所产生的副作用。一个轻微的缺点是,我们不得不创建新对象,并把旧的扔掉,而不是修改提供给我们的默认既存对象。

如果有一种标准且可靠地方法来修改既存对象的链接就好了。ES6 之前,有一个非标准的,而且不是完全对所有浏览器通用的方法:通过可以设置的.__proto__属性。ES6 中增加了Object.setPrototypeOf(..)辅助工具,它提供了标准且可预见的方法。

让我们一对一地比较 ES6 之前和 ES6 标准的技术如何处理将Bar.prototype链接至Foo.prototype

// ES6 以前
// 扔掉默认既存的`Bar.prototype`
Bar.prototype = Object.create( Foo.prototype );

// ES6+
// 修改既存的`Bar.prototype`
Object.setPrototypeOf( Bar.prototype, Foo.prototype );

如果忽略Object.create(..)方式在性能上的轻微劣势(扔掉一个对象,然后被回收),其实它相对短一些而且可能比 ES6+的方式更易读。但两种方式可能都只是语法表面现象。

考察“类”关系

如果你有一个对象a并且希望找到它委托至哪个对象呢(如果有的话)?考察一个实例(一个 JS 对象)的继承血统(在 JS 中是委托链接),在传统的面向类环境中称为 自省(introspection)(或 反射(reflection))。

考虑下面的代码:

function Foo() {
    // ...
}

Foo.prototype.blah = ...;

var a = new Foo();

那么我们如何自省a来找到它的“祖先”(委托链)呢?一种方式是接受“类”的困惑:

a instanceof Foo; // true

instanceof操作符的左边操作数接收一个普通对象,右边操作数接收一个 函数instanceof回答的问题是:a的整个[[Prototype]]链中,有没有出现被那个被Foo.prototype所随便指向的对象?

不幸的是,这意味着如果你拥有可以用于测试的 函数Foo,和它带有的.prototype引用),你只能查询某些对象(a)的“祖先”。如果你有两个任意的对象,比如ab,而且你想调查是否 这些对象 通过[[Prototype]]链相互关联,单靠instanceof帮不上什么忙。

注意: 如果你使用内建的.bind(..)工具来制造一个硬绑定的函数(见第二章),这个被创建的函数将不会拥有.prototype属性。将instanceof与这样的函数一起使用时,将会透明地替换为创建这个硬绑定函数的 目标函数.prototype

将硬绑定函数用于“构造器调用”十分罕见,但如果你这么做,它会表现得好像是 目标函数 被调用了,这意味着将instanceof与硬绑定函数一起使用也会参照原版函数。

下面这段代码展示了试图通过“类”的语义和instanceof来推导 两个对象 间的关系是多么荒谬:

// 用来检查`o1`是否关联到(委托至)`o2`的帮助函数
function isRelatedTo(o1, o2) {
    function F(){}
    F.prototype = o2;
    return o1 instanceof F;
}

var a = {};
var b = Object.create( a );

isRelatedTo( b, a ); // true

isRelatedTo(..)内部,我们借用一个一次性的函数F,重新对它的.prototype赋值,使他随意地指向某个对象o2,之后问是否o1F的“一个实例”。很明显,o1实际上不是继承或遗传自F,甚至不是由F构建的,所以显而易见这种实践是愚蠢且让人困惑的。这个问题归根结底是将类的语义强加于 JavaScript 的尴尬,在这个例子中是由instanceof的间接语义揭露的。

第二种,也是更干净的方式,[[Prototype]]反射:

Foo.prototype.isPrototypeOf( a ); // true

注意在这种情况下,我们并不真正关心(甚至 不需要Foo,我们仅需要一个 对象(在我们的例子中就是随意标志为Foo.prototype)来与另一个 对象 测试。isPrototypeOf(..)回答的问题是:a的整个[[Prototype]]链中,Foo.prototype出现过吗?

同样的问题,和完全同样的答案。但是在第二种方式中,我们实际上不需要间接地引用一个.prototype属性将被自动查询的 函数Foo)。

我们 只需要 两个 对象 来考察它们之间的关系。比如:

// 简单地:`b`在`c`的`[[Prototype]]`链中出现过吗?
b.isPrototypeOf( c );

注意,这种方法根本不要求有一个函数(“类”)。它仅仅使用对象的直接引用bc,来查询他们的关系。换句话说,我们上面的isRelatedTo(..)工具是内建在语言中的,它的名字叫isPrototypeOf(..)

我们也可以直接取得一个对象的[[Prototype]]。在 ES5 中,这么做的标准方法是:

Object.getPrototypeOf( a );

而且你将注意到对象引用是我们期望的:

Object.getPrototypeOf( a ) === Foo.prototype; // true

大多数浏览器(不是全部!)还一种长期支持的,非标准方法可以访问内部的[[Prototype]]

a.__proto__ === Foo.prototype; // true

这个奇怪的.__proto__(直到 ES6 才标准化!)属性“魔法般地”取得一个对象内部的[[Prototype]]作为引用,如果你想要直接考察(甚至遍历:.__proto__.__proto__...[[Prototype]]链,这个引用十分有用。

和我们早先看到的.constructor一样,.__proto__实际上不存在于你考察的对象上(在我们的例子中是a)。事实上,它存在于(不可枚举地;见第二章)内建的Object.prototype上,和其他的共通工具在一起(.toString(), .isPrototypeOf(..), 等等)。

而且,.__proto__看起来像一个属性,但实际上将它看做是一个 getter/setter(见第三章)更合适。

大致地,我们可以这样描述.__proto__实现(见第三章,对象属性的定义):

Object.defineProperty( Object.prototype, "__proto__", {
    get: function() {
        return Object.getPrototypeOf( this );
    },
    set: function(o) {
        // setPrototypeOf(..) as of ES6
        Object.setPrototypeOf( this, o );
        return o;
    }
} );

所以,当我们访问a.__proto__(取得它的值)时,就好像调用a.__proto__()(调用 getter 函数)。虽然 getter 函数存在于Object.prototype上(参照第二章,this绑定规则),但这个函数调用将a用作它的this,所以它相当于在说Object.getPrototypeOf( a )

.__proto__还是一个可设置的属性,就像早先展示过的 ES6Object.setPrototypeOf(..)。然而,一般来说你 不应该改变一个既存对象的[[Prototype]]

在某些允许对Array定义“子类”的框架中,深度地使用了一些非常复杂,高级的技术,但是在一般的编程实践中经常是让人皱眉头的,因为这通常导致非常难理解/维护的代码。

注意: 在 ES6 中,关键字class将允许某些近似方法,对像Array这样的内建类型“定义子类”。参见附录 A 中关于 ES6 中加入的class的讨论。

仅有一小部分例外(就像前面提到过的),会设置一个默认函数.prototype对象的[[Prototype]],使它引用其他的对象(Object.prototype之外的对象)。它们会避免将这个默认对象完全替换为一个新的链接对象。否则,为了在以后更容易地阅读你的代码 最好将对象的[[Prototype]]链接作为只读性质对待

注意: 针对双下划线,特别是在像__proto__这样的属性中开头的部分,JavaScript 社区非官方地创造了一个术语:“dunder”。所以,那些 JavaScript 的“酷小子”们通常将__proto__读作“dunder proto”。

对象链接

正如我们看到的,[[Prototype]]机制是一个内部链接,它存在于一个对象上,这个对象引用一些其他的对象。

这种链接(主要)在对第一个对象进行属性/方法引用,但这样的属性/方法不存在时实施。在这种情况下,[[Prototype]]链接告诉引擎在那个被链接的对象上查找这个属性/方法。接下来,如果这个对象不能满足查询,它的[[Prototype]]又会被查找,如此继续。这个在对象间的一系列链接构成了所谓的“原形链”。

创建链接

我们已经彻底揭露了为什么 JavaScript 的[[Prototype]]机制和 一样,而且我们也看到了如何在正确的对象间创建 链接

[[Prototype]]机制的意义是什么?为什么总是见到 JS 开发者们费那么大力气(模拟类)在他们的代码中搞乱这些链接?

记得我们在本章很靠前的地方说过Object.create(..)是英雄吗?现在,我们准备好看看为什么了。

var foo = {
    something: function() {
        console.log( "Tell me something good..." );
    }
};

var bar = Object.create( foo );

bar.something(); // Tell me something good...

Object.create(..)创建了一个链接到我们指定的对象(foo)上的新对象(bar),这给了我们[[Prototype]]机制的所有力量(委托),而且没有new函数作为类和构造器调用产生的任何没必要的复杂性,搞乱.prototype.constructor 引用,或任何其他的多余的东西。

注意: Object.create(null)创建一个拥有空(也就是null[[Prototype]]链接的对象,如此这个对象不能委托到任何地方。因为这样的对象没有原形链,instancof操作符(前面解释过)没有东西可检查,所以它总返回false。由于他们典型的用途是在属性中存储数据,这种特殊的空[[Prototype]]对象经常被称为“dictionaries(字典)”,这主要是因为它们没有可能受到在[[Prototype]]链上任何委托属性/函数的影响,所以它们是纯粹的扁平数据存储。

我们不 需要 类来在两个对象间创建有意义的关系。我们需要 真正关心 的唯一问题是对象为了委托而链接在一起,而Object.create(..)给我们这种链接并且没有一切关于类的烂设计。

填补Object.create()

Object.create(..)在 ES5 中被加入。你可能需要支持 ES5 之前的环境(比如老版本的 IE),所以让我们来看一个Object.create(..)的简单 部分 填补工具,它甚至能在更老的 JS 环境中给我们所需的能力:

if (!Object.create) {
    Object.create = function(o) {
        function F(){}
        F.prototype = o;
        return new F();
    };
}

这个填补工具通过一个一次性的F函数并覆盖它的.prototype属性来指向我们想连接到的对象。之后我们用new F()构造器调用来制造一个将会链到我们指定对象上的新对象。

Object.create(..)的这种用法是目前最常见的用法,因为他的这一部分是 可以 填补的。ES5 标准的内建Object.create(..)还提供了一个附加的功能,它是 不能 被 ES5 之前的版本填补的。如此,这个功能的使用远没有那么常见。为了完整性,让我么看看这个附加功能:

var anotherObject = {
    a: 2
};

var myObject = Object.create( anotherObject, {
    b: {
        enumerable: false,
        writable: true,
        configurable: false,
        value: 3
    },
    c: {
        enumerable: true,
        writable: false,
        configurable: false,
        value: 4
    }
} );

myObject.hasOwnProperty( "a" ); // false
myObject.hasOwnProperty( "b" ); // true
myObject.hasOwnProperty( "c" ); // true

myObject.a; // 2
myObject.b; // 3
myObject.c; // 4

Object.create(..)的第二个参数指定了要添加在新对象上的属性名,通过声明每个新属性的 属性描述符(见第三章)。因为在 ES5 之前的环境中填补属性描述符是不可能的,所以Object.create(..)的这个附加功能无法填补。

因为Object.create(..)的绝大多数用途都是使用填补安全的功能子集,所以大多数开发者在 ES5 之前的环境中使用这种 部分填补 也没有问题。

有些开发者采取严格得多的观点,也就是除非能够被 完全 填补,否则没有函数应该被填补。因为Object.create(..)可以部分填补的工具之一,这种较狭窄的观点会说,如果你需要在 ES5 之前的环境中使用Object.create(..)的任何功能,你应当使用自定义的工具,而不是填充,而且应当彻底远离使用Object.create这个名字。你可以定义自己的工具,比如:

function createAndLinkObject(o) {
    function F(){}
    F.prototype = o;
    return new F();
}

var anotherObject = {
    a: 2
};

var myObject = createAndLinkObject( anotherObject );

myObject.a; // 2

我不会分享这种严格的观点。我完全拥护如上面展示的Object.create(..)的常见部分填补,甚至在 ES5 之前的环境下在你的代码中使用它。我将选择权留给你。

链接作为候补?

也许这么想很吸引人:这些对象间的链接 主要 是为了给“缺失”的属性和方法提供某种候补。虽然这是一个可观察到的结果,但是我不认为这是考虑[[Prototype]]的正确方法。

考虑下面的代码:

var anotherObject = {
    cool: function() {
        console.log( "cool!" );
    }
};

var myObject = Object.create( anotherObject );

myObject.cool(); // "cool!"

得益于[[Prototype]],这段代码可以工作,但如果你这样写是为了 万一 myObject不能处理某些开发者可能会调用的属性/方法,而让anotherObject作为一个候补,你的软件大概会变得有点儿“魔法”并且更难于理解和维护。

这不是说候补在任何情况下都不是一个合适的设计模式,但它不是一个在 JS 中很常见的用法,所以如果你发现自己在这么做,那么你可能想要退一步并重新考虑它是否真的是合适且合理的设计。

注意: 在 ES6 中,引入了一个称为Proxy(代理)的高级功能,它可以提供某种“方法未找到”类型的行为。Proxy超出了本书的范围,但会在以后的 “你不懂 JS” 系列图书中详细讲解。

这里不要错过一个重要的细节。

例如,你打算为一个开发者设计软件,如果即使在myObject上没有cool()方法时调用myObject.cool()也能工作,会在你的 API 设计上引入一些“魔法”气息,这可能会使未来维护你的软件的开发者很吃惊。

然而你可以在你的 API 设计上少用些“魔法”,而仍然利用[[Prototype]]链接的力量。

var anotherObject = {
    cool: function() {
        console.log( "cool!" );
    }
};

var myObject = Object.create( anotherObject );

myObject.doCool = function() {
    this.cool(); // internal delegation!
};

myObject.doCool(); // "cool!"

这里,我们调用myObject.doCool(),它是一个 实际存在于 myObject上的方法,这使我们的 API 设计更清晰(没那么“魔法”)。在它内部,我们的实现依照 委托设计模式(见第六章),利用[[Prototype]]委托到anotherObject.cool()

换句话说,如果委托是一个内部实现细节,而非在你的 API 结构设计中简单地暴露出来,它倾向于减少意外/困惑。我们会在下一章中详细解释 委托

复习

当试图在一个对象上进行属性访问,而对象没有该属性时,对象内部的[[Prototype]]链接定义了[[Get]]操作(见第三章)下一步应当到哪里寻找它。这种对象到对象的串行链接定义了对象的“原形链”(和嵌套的作用域链有些相似),在解析属性时发挥作用。

所有普通的对象用内建的Object.prototype作为原形链的顶端(就像作用域查询的顶端是全局作用域),如果属性没能在链条的前面任何地方找到,属性解析就会在这里停止。toString()valueOf(),和其他几种共同工具都存在于这个Object.prototype对象上,这解释了语言中所有的对象是如何能够访问他们的。

使两个对象相互链接在一起的最常见的方法是将new关键字与函数调用一起使用,在它的四个步骤中(见第二章),就会建立一个新对象链接到另一个对象。

那个用new调用的函数有一个被随便地命名为.prototype的属性,这个属性所引用的对象恰好就是这个新对象链接到的“另一个对象”。带有new的函数调用通常被称为“构造器”,尽管实际上它们并没有像传统的面相类语言那样初始化一个类。

虽然这些 JavaScript 机制看起来和传统面向类语言的“初始化类”和“类继承”类似,而在 JavaScript 中的关键区别是,没有拷贝发生。取而代之的是对象最终通过[[Prototype]]链链接在一起。

由于各种原因,不光是前面提到的术语,“继承”(和“原型继承”)与所有其他的 OO 用语,在考虑 JavaScript 实际如何工作时都没有道理。

相反,“委托”是一个更确切的术语,因为这些关系不是 拷贝 而是委托 链接

你不懂 JS:this 与对象原型 第六章:行为委托

在第五章中,我们详细地讨论了[[Prototype]]机制,和 为什么 对于描述“类”或“继承”来说它是那么使人糊涂和不合适。我们一路跋涉,不仅涉及了相当繁冗的语法(使代码凌乱的.prototype),还有各种陷阱(比如使人吃惊的.constructor解析和难看的假想多态语法)。我们探索了许多人试图用抹平这些粗糙的区域而使用的各种“mixin”方法。

这时一个常见的反应是,想知道为什么这些看起来如此简单的事情这么复杂。现在我们已经拉开帷幕看到了它是多么麻烦,这并不奇怪:大多数 JS 开发者从不探究得这么深,而将这一团糟交给一个“类”包去帮他们处理。

我希望到现在你不会甘心于敷衍了事并把这样的细节丢给一个“黑盒”库。现在我们来深入讲解我们 如何与应当如何 以一种比类造成的困惑 简单得多而且更直接的方式 来考虑 JS 中对象的[[Prototype]]机制。

简单地复习一下第五章的结论,[[Prototype]]机制是一种存在于一个对象上的内部链接,它指向一个其他对象。

当一个属性/方法引用在第一个对象上发生,而这样的属性/方法又不存在时,这个链接就会被使用。在这种情况下,[[Prototype]]链接告诉引擎去那个被链接的对象上寻找该属性/方法。接下来,如果那个对象也不能满足查询,就沿着它的[[Prototype]]查询,如此继续。这种对象间一系列的链接构成了所谓的“原形链”。

换句话说,对于我们能在 JavaScript 中利用的功能的实际机制来说,其重要的实质 全部在于被连接到其他对象的对象。

这个观点是理解本章其余部分的动机和方法的重要基础!

迈向面相委托的设计

为了将我们的思想恰当地集中在如何用最直截了当的方法使用[[Prototype]],我们必须认识到它代表一种根本上与类不同的设计模式(见第四章)。

注意* 某些 面相类的设计依然是很有效的,所以不要扔掉你知道的每一件事(扔掉大多数就行了!)。比如,封装 就十分强大,而且与委托兼容的(虽然不那么常见)。

我们需要试着将我们的思维从类/继承的设计模式转变为行为代理设计模式。如果你已经用在教育/工作生涯中思考类的方式做了大多数或所有的编程工作,这可能感觉不舒服或不自然。你可能需要尝试这种思维过程好几次,才能适应这种非常不同的思考方式。

我将首先带你进行一些理论练习,之后我们会一对一地看一些更实际的例子来为你自己的代码提供实践环境。

类理论

比方说我们有几个相似的任务(“XYZ”,“ABC”,等)需要在我们的软件中建模。

使用类,你设计这个场景的方式是:定义一个泛化的父类(基类)比如Task,为所有的“同类”任务定义共享的行为。然后,你定义子类XYZABC,它们都继承自Task,每个都分别添加了特化的行为来处理各自的任务。

重要的是, 类设计模式将鼓励你发挥继承的最大功效,当你在XYZ任务中覆盖Task的某些泛化方法的定义时,你将会想利用方法覆盖(和多态),也许会利用super来调用这个方法泛化版本,为它添加更多的行为。你很可能会找到几个可以“抽象”到父类中,或在子类中特化(覆盖)的地方

这是一些关于这个场景的假想代码:

class Task {
    id;

    // `Task()`构造器
    Task(ID) { id = ID; }
    outputTask() { output( id ); }
}

class XYZ inherits Task {
    label;

    // `XYZ()`构造器
    XYZ(ID,Label) { super( ID ); label = Label; }
    outputTask() { super(); output( label ); }
}

class ABC inherits Task {
    // ...
}

现在,你可以初始化一个或多个XYZ子类的 拷贝,并且使用这些实例来执行“XYZ”任务。这些实例已经 同时拷贝 了泛化的Task定义的行为和具体的XYZ定义的行为。类似地,ABC类的实例将拷贝Task的行为和具体的ABC的行为。在构建完成之后,你一般会仅与这些实例互动(而不是类),因为每个实例都拷贝了完成计划任务的所有行为。

委托理论

但是现在然我们试着用 行为委托 代替 来思考同样的问题。

你将首先定义一个称为Task对象(不是一个类,也不是一个大多数 JS 开发者想让你相信的function),而且它将拥有具体的行为,这些行为包含各种任务可以使用的(读作:委托至!)工具方法。然后,对于每个任务(“XYZ”,“ABC”),你定义一个 对象 来持有这个特定任务的数据/行为。你 链接 你的特定任务对象到Task工具对象,允许它们在必要的时候可以委托到它。

基本上,你认为执行任务“XYZ”就是从两个兄弟/对等的对象(XYZTask)中请求行为来完成它。与其通过类的拷贝将它们组合在一起,我们可以将他们保持在分离的对象中,而且可以在需要的情况下允许XYZ对象来 委托到 Task

这里是一些简单的代码,示意你如何实现它:

var Task = {
    setID: function(ID) { this.id = ID; },
    outputID: function() { console.log( this.id ); }
};

// 使`XYZ`委托到`Task`
var XYZ = Object.create( Task );

XYZ.prepareTask = function(ID,Label) {
    this.setID( ID );
    this.label = Label;
};

XYZ.outputTaskDetails = function() {
    this.outputID();
    console.log( this.label );
};

// ABC = Object.create( Task );
// ABC ... = ...

在这段代码中,TaskXYZ不是类(也不是函数),它们 仅仅是对象XYZ通过Object.create()创建,来[[Prototype]]委托到Task对象(见第五章)。

作为与面相类(也就是,OO——面相对象)的对比,我称这种风格的代码为 “OLOO”(objects-linked-to-other-objects(链接到其他对象的对象))。所有我们 真正 关心的是,对象XYZ委托到对象Task(对象ABC也一样)。

在 JavaScript 中,[[Prototype]]机制将 对象 链接到其他 对象。无论你多么想说服自己这不是真的,JavaScript 没有像“类”那样的抽象机制。这就像逆水行舟:你 可以 做到,但你 选择 了逆流而上,所以很明显地,你会更困难地达到目的地。

OLOO 风格的代码 中有一些需要注意的不同:

  1. 前一个类的例子中的idlabel数据成员都是XYZ上的直接数据属性(它们都不在Task上)。一般来说,当[[Prototype]]委托引入时,你想使状态保持在委托者上XYZABC),不是在委托上(Task)。
  2. 在类的设计模式中,我们故意在父类(Task)和子类(XYZ)上采用相同的命名outputTask,以至于我们可以利用覆盖(多态)。在委托的行为中,我们反其道而行之:我们尽一切可能避免在[[Prototype]]链的不同层级上给出相同的命名(称为“遮蔽”——见第五章),因为这些命名冲突会导致尴尬/脆弱的语法来消除引用的歧义(见第四章),而我们想避免它。
    这种设计模式不那么要求那些倾向于被覆盖的泛化的方法名,而是要求针对于每个对象的 具体 行为类型给出更具描述性的方法名。这实际上会产生更易于理解/维护的代码,因为方法名(不仅在定义的位置,而是扩散到其他代码中)变得更加明白(代码即文档)。
  3. this.setID(ID);位于对象XYZ的一个方法内部,它首先在XYZ上查找setID(..),但因为它不能在XYZ上找到叫这个名称的方法,[[Prototype]]委托意味着它可以沿着链接到Task来寻找setID(),这样当然就找到了。另外,由于调用点的隐含this绑定规则(见第二章),当setID()运行时,即便方法是在Task上找到的,这个函数调用的this绑定依然是我们期望和想要的XYZ。我们在代码稍后的this.outputID()中也看到了同样的事情。
    换句话说,我们可以使用存在于Task上的泛化工具与XYZ互动,因为XYZ可以委托至Task

行为委托 意味着:在某个对象(XYZ)的属性或方法没能在这个对象(XYZ)上找到时,让这个对象(XYZ)为属性或方法引用提供一个委托(Task)。

这是一个 极其强大 的设计模式,与父类和子类,继承,多态等有很大的不同。与其在你的思维中纵向地,从上面父类到下面子类地组织对象,你应带并列地,对等地考虑对象,而且对象间拥有方向性的委托链接。

注意: 委托更适于作为内部实现的细节,而不是直接暴露在 API 接口的设计中。在上面的例子中,我们的 API 设计没必要有意地让开发者调用XYZ.setID()(当然我们可以!)。我们以某种隐藏的方式将委托作为我们 API 的内部细节,即XYZ.prepareTask(..)委托到Task.setID(..)。详细的内容,参照第五章的“链接作为候补?”中的讨论。

相互委托(不允许)

你不能在两个或多个对象间相互地委托(双向地)对方来创建一个 循环 。如果你使B链接到A,然后试着让A链接到B,那么你将得到一个错误。

这样的事情不被允许有些可惜(不是非常令人惊讶,但稍稍有些恼人)。如果你制造一个在任意一方都不存在的属性/方法引用,你就会在[[Prototype]]上得到一个无限递归的循环。但如果所有的引用都严格存在,那么B就可以委托至A,或相反,而且它可以工作。这意味着你可以为了多种任务用这两个对象互相委托至对方。有一些情况这可能会有用。

但它不被允许是因为引擎的实现者发现,在设置时检查(并拒绝!)无限循环引用一次,要比每次你在一个对象上查询属性时都做相同检查的性能要高。

调试

我们将简单地讨论一个可能困扰开发者的微妙的细节。一般来说,JS 语言规范不会控制浏览器开发者工具如何向开发者表示指定的值/结构,所以每种浏览器/引擎都自由地按需要解释这个事情。因此,浏览器/工具 不总是意见统一。特别地,我们现在要考察的行为就是当前仅在 Chrome 的开发者工具中观察到的。

考虑这段传统的“类构造器”风格的 JS 代码,正如它将在 Chrome 开发者工具 控制台 中出现的:

function Foo() {}

var a1 = new Foo();

a1; // Foo {}

让我们看一下这个代码段的最后一行:对表达式a1进行求值的输出,打印Foo {}。如果你在 FireFox 中试用同样的代码,你很可能会看到Object {}。为什么会有不同?这些输出意味着什么?

Chrome 实质上在说“{}是一个由名为‘Foo’的函数创建的空对象”。Firefox 在说“{}是一个由 Object 普通构建的空对象”。这种微妙的区别是因为 Chrome 在像一个 内部属性 一样,动态跟踪执行创建的实际方法的名称,而其他浏览器不会跟踪这样的附加信息。

试图用 JavaScript 机制来解释它很吸引人:

function Foo() {}

var a1 = new Foo();

a1.constructor; // Foo(){}
a1.constructor.name; // "Foo"

那么,Chrome 就是通过简单地查看对象的.Constructor.name来输出“Foo”的?令人费解的是,答案既是“是”也是“不”。

考虑下面的代码:

function Foo() {}

var a1 = new Foo();

Foo.prototype.constructor = function Gotcha(){};

a1.constructor; // Gotcha(){}
a1.constructor.name; // "Gotcha"

a1; // Foo {}

即便我们将a1.constructor.name合法地改变为其他的东西(“Gotcha”),Chrome 控制台依旧使用名称“Foo”。

那么,说明前面问题(它使用.constructor.name吗?)的答案是 ,他一定在内部追踪其他的什么东西。

但是,且慢!让我们看看这种行为如何与 OLOO 风格的代码一起工作:

var Foo = {};

var a1 = Object.create( Foo );

a1; // Object {}

Object.defineProperty( Foo, "constructor", {
    enumerable: false,
    value: function Gotcha(){}
});

a1; // Gotcha {}

啊哈!Gotcha,Chrome 的控制台 确实 寻找并且使用了.constructor.name。实际上,就在写这本书的时候,正是这个行为被认定为是 Chrome 的一个 Bug,而且就在你读到这里的时候,它可能已经被修复了。所以你可能已经看到了被修改过的 a1; // Object{}

这个 bug 暂且不论,Chrome 执行的(刚刚在代码段中展示的)“构造器名称”内部追踪(目前仅用于调试输出的目的),是一个仅在 Chrome 内部存在的扩张行为,它已经超出了 JS 语言规范要求的范围。

如果你不使用“构造器”来制造你的对象,就像我们在本章的 OLOO 风格代码中不鼓励的那样,那么你将会得到一个 Chrome 不会为其追踪内部“构造器名称”的对象,所以这样的对象将正确地仅仅被输出“Object {}”,意味着“从 Object()构建生成的对象”。

不要认为 这代表一个 OLOO 风格代码的缺点。当你用 OLOO 编码而且用行为代理作为你的设计模式时, “创建了”(也就是,哪个函数 被和new一起调用了?)一些对象是一个无关的细节。Chrome 特殊的内部“构造器名称”追踪仅仅在你完全接受“类风格”编码时才有用,而在你接受 OLOO 委托时是没有意义的。

思维模型比较

现在你至少在理论上可以看到“类”和“委托”设计模式的不同了,让我们看看这些设计模式在我们用来推导我们代码的思维模型上的含义。

我们将查看一些更加理论上的(“Foo”,“Bar”)代码,然后比较两种方法(OO vs. OLOO)的代码实现。第一段代码使用经典的(“原型的”)OO 风格:

function Foo(who) {
    this.me = who;
}
Foo.prototype.identify = function() {
    return "I am " + this.me;
};

function Bar(who) {
    Foo.call( this, who );
}
Bar.prototype = Object.create( Foo.prototype );

Bar.prototype.speak = function() {
    alert( "Hello, " + this.identify() + "." );
};

var b1 = new Bar( "b1" );
var b2 = new Bar( "b2" );

b1.speak();
b2.speak();

父类Foo,被子类Bar继承,之后Bar被初始化两次:b1b2。我们得到的是b1委托至Bar.prototypeBar.prototype委托至Foo.prototype。这对你来说应当看起来十分熟悉。没有太具开拓性的东西发生。

现在,让我们使用 OLOO 风格的代码 实现完全相同的功能

var Foo = {
    init: function(who) {
        this.me = who;
    },
    identify: function() {
        return "I am " + this.me;
    }
};

var Bar = Object.create( Foo );

Bar.speak = function() {
    alert( "Hello, " + this.identify() + "." );
};

var b1 = Object.create( Bar );
b1.init( "b1" );
var b2 = Object.create( Bar );
b2.init( "b2" );

b1.speak();
b2.speak();

我们利用了完全相同的从BarFoo[[Prototype]]委托,正如我们在前一个代码段中b1Bar.prototype,和Foo.prototype之间那样。我们仍然有 3 个对象链接在一起

但重要的是,我们极大地简化了发生的 所有其他事项,因为我们现在仅仅建立了相互链接的 对象,而不需要所有其他讨厌且困惑的看起来像类(但动起来不像)的东西,还有构造器,原型和new调用。

问问你自己:如果我能用 OLOO 风格代码得到我用“类”风格代码得到的一样的东西,但 OLOO 更简单而且需要考虑的事情更少,OLOO 不是更好吗

让我们讲解一下这两个代码段间涉及的思维模型。

首先,类风给的代码段意味着这样的实体与它们的关系的思维模型:

实际上,这有点儿不公平/误导,因为它展示了许多额外的,你在 技术上 一直不需要知道(虽然你 需要 理解它)的细节。一个关键是,它是一系列十分复杂的关系。但另一个关键是:如果你花时间来沿着这些关系的箭头走,在 JS 的机制中 有数量惊人的内部统一性

例如,JS 函数可以访问call(..)apply(..)bind(..)(见第二章)的能力是因为函数本身是对象,而函数对象还拥有一个[[Prototype]]链接,链到Function.prototype对象,它定义了那些任何函数对象都可以委托到的默认方法。JS 可以做这些事情,你也能!

好了,现在让我们看一个这张图的 稍稍 简化的版本,用它来进行比较稍微“公平”一点——它仅展示了 相关 的实体与关系。

任然非常复杂,对吧?虚线描绘了当你在Foo.prototypeBar.prototype间建立“继承”时的隐含关系,而且还没有 修复 丢失的 .constructor属性引用(见第五章“终极构造器”)。即便将虚线去掉,每次你与对象链接打交道时,这个思维模型依然要变很多可怕的戏法。

现在,然我们看看 OLOO 风格代码的思维模型:

正如你所比较它们得到的,十分明显,OLOO 风格的代码 需要关心的东西少太多了,因为 OLOO 风格代码接受了 事实:我们唯一需要真正关心的事情是 链接到其他对象的对象

所有其他“类”的烂设计用一种令人费解而且复杂的方式得到相同的结果。去掉那些东西,事情就变得简单得多(还不会失去任何功能)。

Classes vs. Objects

我们已经看到了各种理论的探索和“类”与“行为委托”的思维模型的比较。现在让我们来看看更具体的代码场景,来展示你如何实际应用这些想法。

我们将首先讲解一种在前端网页开发中的典型场景:建造 UI 部件(按钮,下拉列表等等)。

Widget“类”

因为你可能还是如此地习惯于 OO 设计模式,你很可能会立即这样考虑这个问题:一个父类(也许称为Wedget)拥有所有共通的基本部件行为,然后衍生的子类拥有具体的部件类型(比如Button)。

注意: 为了 DOM 和 CSS 的操作,我们将在这里使用 JQuery,这仅仅是因为对于我们现在的讨论,它不是一个我们真正关心的细节。这些代码中不关心你用哪个 JS 框架(JQuery,Dojo,YUI 等等)来解决如此无趣的问题。

让我们来看看,在没有任何“类”帮助库或语法的情况下,我们如何用经典风格的纯 JS 来实现“类”设计:

// 父类
function Widget(width,height) {
    this.width = width || 50;
    this.height = height || 50;
    this.$elem = null;
}

Widget.prototype.render = function($where){
    if (this.$elem) {
        this.$elem.css( {
            width: this.width + "px",
            height: this.height + "px"
        } ).appendTo( $where );
    }
};

// 子类
function Button(width,height,label) {
    // "super"构造器调用
    Widget.call( this, width, height );
    this.label = label || "Default";

    this.$elem = $( "<button>" ).text( this.label );
}

// 使`Button` “继承” `Widget`
Button.prototype = Object.create( Widget.prototype );

// 覆盖“继承来的” `render(..)`
Button.prototype.render = function($where) {
    // "super"调用
    Widget.prototype.render.call( this, $where );
    this.$elem.click( this.onClick.bind( this ) );
};

Button.prototype.onClick = function(evt) {
    console.log( "Button '" + this.label + "' clicked!" );
};

$( document ).ready( function(){
    var $body = $( document.body );
    var btn1 = new Button( 125, 30, "Hello" );
    var btn2 = new Button( 150, 40, "World" );

    btn1.render( $body );
    btn2.render( $body );
} );

OO 设计模式告诉我们要在父类中声明一个基础render(..),之后在我们的子类中覆盖它,但不是完全替代它,而是用按钮特定的行为增强这个基础功能。

注意 显示假想多态 的丑态,Widget.callWidget.prototype.render.call引用是为了伪装从子“类”方法得到“父类”基础方法支持的“super”调用。呃。

ES6 class 语法糖

我们会在附录 A 中讲解 ES6 的class语法糖,但是让我们演示一下我们如何用class来实现相同的代码。

class Widget {
    constructor(width,height) {
        this.width = width || 50;
        this.height = height || 50;
        this.$elem = null;
    }
    render($where){
        if (this.$elem) {
            this.$elem.css( {
                width: this.width + "px",
                height: this.height + "px"
            } ).appendTo( $where );
        }
    }
}

class Button extends Widget {
    constructor(width,height,label) {
        super( width, height );
        this.label = label || "Default";
        this.$elem = $( "<button>" ).text( this.label );
    }
    render($where) {
        super.render( $where );
        this.$elem.click( this.onClick.bind( this ) );
    }
    onClick(evt) {
        console.log( "Button '" + this.label + "' clicked!" );
    }
}

$( document ).ready( function(){
    var $body = $( document.body );
    var btn1 = new Button( 125, 30, "Hello" );
    var btn2 = new Button( 150, 40, "World" );

    btn1.render( $body );
    btn2.render( $body );
} );

毋庸置疑,通过使用 ES6 的class,许多前面经典方法中语法的丑态被改善了。super(..)的存在看起来非常适宜(但当你深入挖掘它时,不全是好事!)。

除了语法上的改进,这些都不是 真正的,因为他们仍然工作在[[Prototype]]机制之上。它们依然会受到思维模型不匹配的拖累,就像我们在第四,五章中,和直到现在探索的那样。附录 A 将会详细讲解 ES6class语法和他的含义。我们将会看到为什么解决语法上的小问题不会实质上解决我们在 JS 中的类的困惑,虽然它做出了勇敢的努力假装解决了问题!

无论你是使用经典的原型语法还是新的 ES6 语法糖,你依然选择了使用“类”来对问题(UI 部件)进行建模。正如我们前面几章试着展示的,在 JavaScript 中做这个选择会带给你额外的头疼和思维上的弯路。

委托部件对象

这是我们更简单的Widget/Button例子,使用了 OLOO 风格委托

var Widget = {
    init: function(width,height){
        this.width = width || 50;
        this.height = height || 50;
        this.$elem = null;
    },
    insert: function($where){
        if (this.$elem) {
            this.$elem.css( {
                width: this.width + "px",
                height: this.height + "px"
            } ).appendTo( $where );
        }
    }
};

var Button = Object.create( Widget );

Button.setup = function(width,height,label){
    // delegated call
    this.init( width, height );
    this.label = label || "Default";

    this.$elem = $( "<button>" ).text( this.label );
};
Button.build = function($where) {
    // delegated call
    this.insert( $where );
    this.$elem.click( this.onClick.bind( this ) );
};
Button.onClick = function(evt) {
    console.log( "Button '" + this.label + "' clicked!" );
};

$( document ).ready( function(){
    var $body = $( document.body );

    var btn1 = Object.create( Button );
    btn1.setup( 125, 30, "Hello" );

    var btn2 = Object.create( Button );
    btn2.setup( 150, 40, "World" );

    btn1.build( $body );
    btn2.build( $body );
} );

使用这种 OLOO 风格的方法,我们不认为Widget是一个父类而Button是一个子类,Wedget只是一个对象 和某种具体类型的部件也许想要代理到的工具的集合,而且Button也只是一个独立的对象(当然,带有委托至Wedget的链接!)。

从设计模式的角度来看,我们 没有 像类的方法建议的那样,在两个对象中共享相同的render(..)方法名称,而是选择了更能描述每个特定任务的不同的名称。同样的原因,初始化 方法被分别称为init(..)setup(..)

不仅委托设计模式建议使用不同而且更具描述性的名称,而且在 OLOO 中这样做会避免难看的显式假想多态调用,正如你可以通过简单,相对的this.init(..)this.insert(..)委托调用看到的。

语法上,我们也没有任何构造器,.prototype或者new出现,它们事实上是不必要的设计。

现在,如果你再细心考察一下,你可能会注意到之前仅有一个调用(var btn1 = new Button(..)),而现在有了两个(var btn1 = Object.create(Button)btn1.setup(..))。这猛地看起来像是一个缺点(代码变多了)。

然而,即便是这样的事情,和经典原型风格比起来也是 OLOO 风格代码的优点。为什么?

用类的构造器,你“强制”(不完全是这样,但是被强烈建议)构建和初始化在同一个步骤中进行。然而,有许多种情况,能够将这两步分开做(就像你在 OLOO 中做的)更灵活。

举个例子,我们假定你在程序的最开始,在一个池中创建所有的实例,但你等到在它们被从池中找出并使用之前再用指定的设置初始化它们。我们的例子中,这两个调用紧挨在一起,当然它们也可以按需要发生在非常不同的时间和代码中非常不同的部分。

OLOO 对关注点分离原则有 更好 的支持,也就是创建和初始化没有必要合并在同一个操作中。

更简单的设计

OLOO 除了提供表面上更简单(而且更灵活!)的代码之外,行为委托作为一个模式实际上会带来更简单的代码架构。让我们讲解最后一个例子来说明 OLOO 是如何简化你的整体设计的。

这个场景中我们将讲解两个控制器对象,一个用来处理网页的登录 form(表单),另一个实际处理服务器的认证(通信)。

我们需要帮助工具来进行与服务器的 Ajax 通信。我们将使用 JQuery(虽然其他的框架都可以),因为它不仅为我们处理 Ajax,而且还返回一个类似 Promise 的应答,这样我们就可以在代码中使用.then(..)来监听这个应答。

注意: 我们不会再这里讲到 Promise,但我们会在以后的 你不懂 JS 系列中讲到。

根据典型的类的设计模式,我们在一个叫做Controller的类中将任务分解为基本功能,之后我们会衍生出两个子类,LoginControllerAuthController,它们都继承自Controller而且特化某些基本行为。

// 父类
function Controller() {
    this.errors = [];
}
Controller.prototype.showDialog = function(title,msg) {
    // 在对话框中给用户显示标题和消息
};
Controller.prototype.success = function(msg) {
    this.showDialog( "Success", msg );
};
Controller.prototype.failure = function(err) {
    this.errors.push( err );
    this.showDialog( "Error", err );
};
// 子类
function LoginController() {
    Controller.call( this );
}
// 将子类链接到父类
LoginController.prototype = Object.create( Controller.prototype );
LoginController.prototype.getUser = function() {
    return document.getElementById( "login_username" ).value;
};
LoginController.prototype.getPassword = function() {
    return document.getElementById( "login_password" ).value;
};
LoginController.prototype.validateEntry = function(user,pw) {
    user = user || this.getUser();
    pw = pw || this.getPassword();

    if (!(user && pw)) {
        return this.failure( "Please enter a username & password!" );
    }
    else if (pw.length < 5) {
        return this.failure( "Password must be 5+ characters!" );
    }

    // 到这里了?输入合法!
    return true;
};
// 覆盖来扩展基本的`failure()`
LoginController.prototype.failure = function(err) {
    // "super"调用
    Controller.prototype.failure.call( this, "Login invalid: " + err );
};
// 子类
function AuthController(login) {
    Controller.call( this );
    // 除了继承外,我们还需要合成
    this.login = login;
}
// 将子类链接到父类
AuthController.prototype = Object.create( Controller.prototype );
AuthController.prototype.server = function(url,data) {
    return $.ajax( {
        url: url,
        data: data
    } );
};
AuthController.prototype.checkAuth = function() {
    var user = this.login.getUser();
    var pw = this.login.getPassword();

    if (this.login.validateEntry( user, pw )) {
        this.server( "/check-auth",{
            user: user,
            pw: pw
        } )
        .then( this.success.bind( this ) )
        .fail( this.failure.bind( this ) );
    }
};
// 覆盖以扩展基本的`success()`
AuthController.prototype.success = function() {
    // "super"调用
    Controller.prototype.success.call( this, "Authenticated!" );
};
// 覆盖以扩展基本的`failure()`
AuthController.prototype.failure = function(err) {
    // "super"调用
    Controller.prototype.failure.call( this, "Auth Failed: " + err );
};
var auth = new AuthController(
    // 除了继承,我们还需要合成
    new LoginController()
);
auth.checkAuth();

我们有所有控制器分享的基本行为,它们是success(..)failure(..)showDialog(..)。我们的子类LoginControllerAuthController覆盖了failure(..)success(..)来增强基本类的行为。还要注意的是,AuthController需要一个LoginController实例来与登录 form 互动,所以它变成了一个数据属性成员。

另外一件要提的事情是,我们选择一些 合成 散布在继承的顶端。AuthController需要知道LoginController,所以我们初始化它(new LoginController()),使它一个成为this.login的类属性成员来引用它,这样AuthController才可以调用LoginController上的行为。

注意: 这里可能会存在一丝冲动,就是使AuthController继承LoginController,或者反过来,这样的话我们就会通过继承链得到 虚拟合成。但是这是一个非常清晰地例子,表明对这个问题来讲,将类继承作为模型有什么问题,因为AuthControllerLoginController都不特化对方的行为,所以它们之间的继承没有太大的意义,除非类是你唯一的设计模式。与此相反的是,我们在一些简单的合成中分层,然后它们就可以合作了,同时他俩都享有继承自父类Controller的好处。

如果你熟悉面向类(OO)的设计,这都听该看起来十分熟悉和自然。

去类化

但是,我们真的需要用一个父类,两个子类,和一些合成来对这个问题建立模型吗?有办法利用 OLOO 风格的行为委托得到 简单得多 的设计吗?有的!

var LoginController = {
    errors: [],
    getUser: function() {
        return document.getElementById( "login_username" ).value;
    },
    getPassword: function() {
        return document.getElementById( "login_password" ).value;
    },
    validateEntry: function(user,pw) {
        user = user || this.getUser();
        pw = pw || this.getPassword();

        if (!(user && pw)) {
            return this.failure( "Please enter a username & password!" );
        }
        else if (pw.length < 5) {
            return this.failure( "Password must be 5+ characters!" );
        }

        // 到这里了?输入合法!
        return true;
    },
    showDialog: function(title,msg) {
        // 在对话框中向用于展示成功消息
    },
    failure: function(err) {
        this.errors.push( err );
        this.showDialog( "Error", "Login invalid: " + err );
    }
};
// 链接`AuthController`委托到`LoginController`
var AuthController = Object.create( LoginController );

AuthController.errors = [];
AuthController.checkAuth = function() {
    var user = this.getUser();
    var pw = this.getPassword();

    if (this.validateEntry( user, pw )) {
        this.server( "/check-auth",{
            user: user,
            pw: pw
        } )
        .then( this.accepted.bind( this ) )
        .fail( this.rejected.bind( this ) );
    }
};
AuthController.server = function(url,data) {
    return $.ajax( {
        url: url,
        data: data
    } );
};
AuthController.accepted = function() {
    this.showDialog( "Success", "Authenticated!" )
};
AuthController.rejected = function(err) {
    this.failure( "Auth Failed: " + err );
};

因为AuthController只是一个对象(LoginController也是),我们不需要初始化(比如new AuthController())就能执行我们的任务。所有我们要做的是:

AuthController.checkAuth();

当然,通过 OLOO,如果你确实需要在委托链上创建一个或多个附加的对象时也很容易,而且仍然不需要任何像类实例化那样的东西:

var controller1 = Object.create( AuthController );
var controller2 = Object.create( AuthController );

使用行为委托,AuthControllerLoginController仅仅是对象,互相是 水平 对等的,而且没有被安排或关联成面向类中的父与子。我们有些随意地选择让AuthController委托至LoginController —— 相反方向的委托也同样是有效的。

第二个代码段的主要要点是,我们只拥有两个实体(LoginController and AuthController),而 不是之前的三个

我们不需要一个基本的Controller类来在两个子类间“分享”行为,因为委托是一种可以给我们所需功能的,足够强大的机制。同时,就像之前注意的,我们也不需要实例化我们的对象来使它们工作,因为这里没有类,只有对象自身。 另外,这里不需要 合成 作为委托来给两个对象 差异化 地合作的能力。

最后,由于没有让名称success(..)failure(..)在两个对象上相同,我们避开了面向类的设计的多态陷阱:它将会需要难看的显式假想多态。相反,我们在AuthController上称它们为accepted()rejected(..) —— 对于他们的具体任务来说,稍稍更具描述性的名称。

底线: 我们最终得到了相同的结果,但是用了(显著的)更简单的设计。这就是 OLOO 风格代码和 行为委托 设计模式的力量。

更好的语法

一个使 ES6class看似如此诱人的更好的东西是(见附录 A 来了解为什么要避免它!),声明类方法的速记语法:

class Foo {
    methodName() { /* .. */ }
}

我们从声明中扔掉了单词function,这使所有的 JS 开发者欢呼!

你可能已经注意到,而且为此感到沮丧:上面推荐的 OLOO 语法出现了许多function,这看起来像对 OLOO 简化目标的诋毁。但它不必是!

在 ES6 中,我们可以在任何字面对象中使用 简约方法声明,所以一个 OLOO 风格的对象可以用这种方式声明(与class语法中相同的语法糖):

var LoginController = {
    errors: [],
    getUser() { // 看,没有`function`!
        // ...
    },
    getPassword() {
        // ...
    }
    // ...
};

唯一的区别是字面对象的元素间依然需要,逗号分隔符,而class语法不必如此。这是在整件事情上很小的让步。

还有,在 ES6 中,一个你使用的更笨重的语法(比如AuthController的定义中):你一个一个地给属性赋值而不使用字面对象,可以改写为使用字面对象(于是你可以使用简约方法),而且你可以使用Object.setPrototypeOf(..)来修改对象的[[Prototype]],像这样:

// 使用更好的字面对象语法 w/ 简约方法!
var AuthController = {
    errors: [],
    checkAuth() {
        // ...
    },
    server(url,data) {
        // ...
    }
    // ...
};

// 现在, 链接`AuthController`委托至`LoginController`
Object.setPrototypeOf( AuthController, LoginController );

ES6 中的 OLOO 风格,与简明方法一起,变得比它以前 友好得多(即使在以前,它也比经典的原型风格代码简单好看的多)。 你不必非得选用类(复杂性)来得到干净漂亮的对象语法!

没有词法

简约方法确实有一个缺点,一个重要的细节。考虑这段代码:

var Foo = {
    bar() { /*..*/ },
    baz: function baz() { /*..*/ }
};

这是去掉语法糖后,这段代码将如何工作:

var Foo = {
    bar: function() { /*..*/ },
    baz: function baz() { /*..*/ }
};

看到区别了?bar()的速记法变成了一个附着在bar属性上的 匿名函数表达式function()..),因为函数对象本身没有名称标识符。和拥有词法名称标识符baz,附着在.baz属性上的手动指定的 命名函数表达式function baz()..)做个比较。

那又怎么样?在 “你不懂 JS” 系列的 “作用域与闭包” 这本书中,我们详细讲解了 匿名函数表达式 的三个主要缺点。我们简单地重复一下它们,以便于我们和简明方法相比较。

一个匿名函数缺少name标识符:

  1. 使调试时的栈追踪变得困难
  2. 使自引用(递归,事件绑定等)变得困难
  3. 使代码(稍稍)变得难于理解

第一和第三条不适用于简明方法。

虽然去掉语法糖使用 匿名函数表达式 一般会使栈追踪中没有name。简明方法在语言规范中被要求去设置相应的函数对象内部的name属性,所以栈追踪应当可以使用它(这是依赖于具体实现的,所以不能保证)。

不幸的是,第二条 仍然是简明方法的一个缺陷。 它们不会有词法标识符用来自引用。考虑:

var Foo = {
    bar: function(x) {
        if (x < 10) {
            return Foo.bar( x * 2 );
        }
        return x;
    },
    baz: function baz(x) {
        if (x < 10) {
            return baz( x * 2 );
        }
        return x;
    }
};

在这个例子中上面的手动Foo.bar(x*2)引用就足够了,但是在许多情况下,一个函数没必要能够这样做,比如使用this绑定,函数在委托中被分享到不同的对象,等等。你将会想要使用一个真正的自引用,而函数对象的name标识符是实现的最佳方式。

只要小心简明方法的这个注意点,而且如果当你陷入缺少自引用的问题时,仅仅为这个声明 放弃简明方法语法,取代以手动的 命名函数表达式 声明形式:baz: function baz(){..}

自省

如果你花了很长时间在面向类的编程方式(不管是 JS 还是其他的语言),你可能会对 类型自省 很熟悉:自省一个实例来找出它是什么 种类 的对象。在类的实例上进行 类型自省 的主要目的是根据 对象是如何创建的 来推断它的结构/能力。

考虑这段代码,它使用instanceof(见第五章)来自省一个对象a1来推断它的能力:

function Foo() {
    // ...
}
Foo.prototype.something = function(){
    // ...
}

var a1 = new Foo();

// 稍后

if (a1 instanceof Foo) {
    a1.something();
}

因为Foo.prototype(不是Foo!)在a1[[Prototype]]链上(见第五章),instanceof操作符(使人困惑地)假装告诉我们a1是一个Foo“类”的实例。有了这个知识,我们假定a1Foo“类”中描述的能力。

当然,这里没有Foo类,只有一个普通的函数Foo,它恰好拥有一个引用指向一个随意的对象(Foo.prototype),而a1恰好委托链接至这个对象。通过它的语法,instanceof假装检查了a1Foo之间的关系,但它实际上告诉我们的是a1Foo.prototype(这个随意被引用的对象)是否有关联。

instanceof在语义上的混乱(和间接)意味着,要使用以instanceof为基础的自省来查询对象a1是否与讨论中的对象有关联,你 不得不 拥有一个持有对这个对象引用的函数 —— 你不能直接查询这两个对象是否有关联。

回想本章前面的抽象Foo / Bar / b1例子,我们在这里缩写一下:

function Foo() { /* .. */ }
Foo.prototype...

function Bar() { /* .. */ }
Bar.prototype = Object.create( Foo.prototype );

var b1 = new Bar( "b1" );

为了在这个例子中的实体上进行 类型自省, 使用instanceof.prototype语义,这里有各种你可能需要实施的检查:

// 的`Foo`和`Bar`互相联系
Bar.prototype instanceof Foo; // true
Object.getPrototypeOf( Bar.prototype ) === Foo.prototype; // true
Foo.prototype.isPrototypeOf( Bar.prototype ); // true

// `b1`与`Foo`和`Bar`的联系
b1 instanceof Foo; // true
b1 instanceof Bar; // true
Object.getPrototypeOf( b1 ) === Bar.prototype; // true
Foo.prototype.isPrototypeOf( b1 ); // true
Bar.prototype.isPrototypeOf( b1 ); // true

可以说,其中有些烂透了。举个例子,直觉上(用类)你可能想说这样的东西Bar instanceof Foo(因为很容易混淆“实例”的意义认为它包含“继承”),但在 JS 中这不是一个合理的比较。你不得不说Bar.prototype instanceof Foo

另一个常见,但也许健壮性更差的 类型自省 模式叫“duck typing(鸭子类型)”,比起instanceof来许多开发者都倾向于它。这个术语源自一则谚语,“如果它看起来像鸭子,叫起来像鸭子,那么它一定是一只鸭子”。

例如:

if (a1.something) {
    a1.something();
}

与其检查a1和一个持有可委托的something()函数的对象的关系,我们假设a1.something测试通过意味着a1有能力调用.something()(不管是直接在a1上直接找到方法,还是委托至其他对象)。就其本身而言,这种假设没什么风险。

但是“鸭子类型”常常被扩展用于 除了被测试关于对象能力以外的其他假设,这当然会在测试中引入更多风险(比如脆弱的设计)。

“鸭子类型”的一个值得注意的例子来自于 ES6 的 Promises(就是我们前面解释过,将不再本书内涵盖的内容)。

由于种种原因,需要判定任意一个对象引用是否 是一个 Promise,但测试是通过检查对象是否恰好有then()函数出现在它上面来完成的。换句话说,如果任何对象 恰好有一个then()方法,ES6 的 Promises 将会无条件地假设这个对象 是“thenable” 的,而且因此会期望它按照所有的 Promises 标准行为那样一致地动作。

如果你有任何非 Promise 对象,而却不管因为什么它恰好拥有then()方法,你会被强烈建议使它远离 ES6 的 Promise 机制,来避免破坏这种假设。

这个例子清楚地展现了“鸭子类型”的风险。你应当仅在可控的条件下,保守地使用这种方式。

再次将我们的注意力转向本章中出现的 OLOO 风格的代码,类型自省 变得清晰多了。让我们回想(并缩写)本章的Foo / Bar / b1的 OLOO 示例:

var Foo = { /* .. */ };

var Bar = Object.create( Foo );
Bar...

var b1 = Object.create( Bar );

使用这种 OLOO 方式,我们所拥有的一切都是通过[[Prototype]]委托关联起来的普通对象,这是我们可能会用到的大幅简化后的 类型自省

// `Foo`和`Bar`互相的联系
Foo.isPrototypeOf( Bar ); // true
Object.getPrototypeOf( Bar ) === Foo; // true

// `b1`与`Foo`和`Bar`的联系
Foo.isPrototypeOf( b1 ); // true
Bar.isPrototypeOf( b1 ); // true
Object.getPrototypeOf( b1 ) === Bar; // true

我们不再使用instanceof,因为它令人迷惑地假装与类有关系。现在,我们只需要(非正式地)问这个问题,“你是我的 一个 原型吗?”。不再需要用Foo.prototype或者痛苦冗长的Foo.prototype.isPrototypeOf(..)来间接地查询了。

我想可以说这些检查比起前面一组自省检查,极大地减少了复杂性/混乱。又一次,我们看到了在 JavaScript 中 OLOO 要比类风格的编码简单(但有着相同的力量)。

复习

在你的软件体系结构中,类和继承是你可以 选用不选用 的设计模式。多数开发者理所当然地认为类是组织代码的唯一(正确的)方法,但我们在这里看到了另一种不太常被提到的,但实际上十分强大的设计模式:行为委托

行为委托意味着对象彼此是对等的,在它们自己当中相互委托,而不是父类与子类的关系。JavaScript 的[[Prototype]]机制的设计本质,就是行为委托机制。这意味着我们可以选择挣扎着在 JS 上实现类机制,也可以欣然接受[[Prototype]]作为委托机制的本性。

当你仅用对象设计代码时,它不仅能简化你使用的语法,而且它还能实际上引领更简单的代码结构设计。

OLOO(链接到其他对象的对像)是一种没有类的抽象,而直接创建和关联对象的代码风格。OLOO 十分自然地实现了基于[[Prototype]]的行为委托。

你不懂 JS:this 与对象原型 附录 A:ES6 class

如果说本书后半部分(第四到六章)有什么关键信息,那就是类是一种代码的可选设计模式(不是必要的),而且用像 JavaScript 这样的[[Prototype]]语言来实现它总是很尴尬。

虽然这种尴尬很大一部分关于语法,但 不仅 限于此。第四和第五章审视了相当多的难看语法,从使代码杂乱的.prototype引用的繁冗,到 显式假想多态:当你在链条的不同层级上给方法相同的命名以试图实现从低层方法到高层方法的多态引用。.constructor被错误地解释为“被 XX 构建”,这成为了一个不可靠的定义,也成为了另一个难看的语法。

但关于类的设计的问题要深刻多了。第四章指出在传统的面向类语言中,类实际上发生了从父类向子类,由子类向实例的 拷贝 动作,而在[[Prototype]]中,动作 不是 一个拷贝,而是相反——一个委托链接。

OLOO 风格和行为委托接受了[[Prototype]],而不是将它隐藏起来,当比较它们的简单性时,类在 JS 中的问题就凸显出来。

class

我们 不必 再次争论这些问题。我在这里简单地重提这些问题仅仅是为了使它们在你的头脑里保持新鲜,以使我们将注意力转向 ES6 的class机制。我们将在这里展示它如何工作,并且看看class是否实质上解决了任何这些“类”的问题。

让我们重温第六章的Widget/Button例子:

class Widget {
    constructor(width,height) {
        this.width = width || 50;
        this.height = height || 50;
        this.$elem = null;
    }
    render($where){
        if (this.$elem) {
            this.$elem.css( {
                width: this.width + "px",
                height: this.height + "px"
            } ).appendTo( $where );
        }
    }
}

class Button extends Widget {
    constructor(width,height,label) {
        super( width, height );
        this.label = label || "Default";
        this.$elem = $( "<button>" ).text( this.label );
    }
    render($where) {
        super.render( $where );
        this.$elem.click( this.onClick.bind( this ) );
    }
    onClick(evt) {
        console.log( "Button '" + this.label + "' clicked!" );
    }
}

除了语法上 看起来 更好,ES6 还解决了什么?

  1. 不再有(某种意义上的,继续往下看!)指向.prototype的引用来弄乱代码。
  2. Button被声明为直接“继承自”(也就是extendsWidget,而不是需要用Object.create(..)来替换.prototype链接的对象,或者用__proto__Object.setPrototypeOf(..)来设置它。
  3. super(..)现在给了我们非常有用的 相对多态 的能力,所以在链条上某一个层级上的任何方法,可以引用链条上相对上一层的同名方法。第四章中有一个关于构造器的奇怪现象:构造器不属于它们的类,而且因此与类没有联系。super(..)含有一个对此问题的解决方法 —— super()会在构造器内部想正如你期望的那样工作。
  4. class字面语法对指定属性没有什么启发(仅对方法有)。这看起来限制了某些东西,但是绝大多数情况下期望一个属性(状态)存在于链条末端的“实例”以外的地方,这通常是一个错误和令人诧异(因为这个状态被隐含地在所有“实例”中“分享”)的。所以,也可以说class语法防止你出现错误。
  5. extends甚至允许你用非常自然的方式扩展内建的对象(子)类型,比如Array或者RegExp。在没有class .. extends的情况下这样做一直以来是一个极端复杂而令人沮丧的任务,只有最熟练的框架作者曾经正确地解决过这个问题。现在,它是小菜一碟!

凭心而论,对大多数明显的(语法上的)问题,和经典的原型风格代码使人诧异的地方,这些确实是实质上的解决方案。

class的坑

然而,它不全是优点。在 JS 中将“类”作为一种设计模式,仍然有一些深刻和非常令人烦恼的问题。

首先,class语法可能会说服你 JS 在 ES6 中存在一个新的“类”机制。但不是这样。class很大程度上仅仅是一个既存的[[Prototype]](委托)机制的语法糖!

这意味着class实际上不是像传统面向类语言那样,在声明时静态地拷贝定义。如果你在“父类”上更改/替换了一个方法(有意或无意地),子“类”和/或实例将会受到“影响”,因为它们在声明时没有得到一份拷贝,它们依然都使用那个基于[[Prototype]]的实时委托模型。

class C {
    constructor() {
        this.num = Math.random();
    }
    rand() {
        console.log( "Random: " + this.num );
    }
}

var c1 = new C();
c1.rand(); // "Random: 0.4324299..."

C.prototype.rand = function() {
    console.log( "Random: " + Math.round( this.num * 1000 ));
};

var c2 = new C();
c2.rand(); // "Random: 867"

c1.rand(); // "Random: 432" -- oops!!!

这种行为只有在 你已经知道了 关于委托的性质,而不是期待从“真的类”中 拷贝 时,才看起来合理。那么你要问自己的问题是,为什么你为了根本上就和类不同的东西选择class语法?

ES6 的class语法不是使观察和理解传统的类和委托对象间的不同 变得更困难 了吗?

class语法 没有 提供声明类的属性成员的方法(仅对方法有)。所以如果你需要跟踪对象间分享的状态,那么你最终会回到丑陋的.prototype语法,像这样:

class C {
    constructor() {
        // 确保修改的是共享状态
        // 不是设置实例上的遮蔽属性
        C.prototype.count++;

        // 这里,`this.count`通过委托如我们期望的那样工作
        console.log( "Hello: " + this.count );
    }
}

// 直接在原型对象上添加一个共享属性
C.prototype.count = 0;

var c1 = new C();
// Hello: 1

var c2 = new C();
// Hello: 2

c1.count === 2; // true
c1.count === c2.count; // true

这里最大的问题是,由于它将.prototype作为实现细节暴露(泄露!)出来,而背叛了class语法的初衷。

而且,我们还依然面临着那个令人诧异的陷阱:this.count++将会隐含地在c1c2两个对象上创建一个分离的遮蔽属性.count,而不是更新共享的状态。class没有在这个问题上给我们什么安慰,除了(大概是)通过缺少语法支持来暗示你 根本 就不应该这么做。

另外,无意地遮蔽依然是个灾难:

class C {
    constructor(id) {
        // 噢,一个坑,我们用实例上的属性值遮蔽了`id()`方法
        this.id = id;
    }
    id() {
        console.log( "Id: " + id );
    }
}

var c1 = new C( "c1" );
c1.id(); // TypeError -- `c1.id` 现在是字符串"c1"

还有一些关于super如何工作的微妙问题。你可能会假设super将会以一种类似与this得到绑定的方式(间第二章)来被绑定,也就是super总是会绑定到当前方法在[[Prototype]]链中的位置的更高一层。

然而,因为性能问题(this绑定已经很耗费性能了),super不是动态绑定的。它在声明时,被有些“静态地”绑定。不是什么大事儿,对吧?

恩……可能是,可能不是。如果你像大多数 JS 开发者那样,开始把函数赋值给不同的(来自于class定义的)对象,以各种不同的方式,你可能不会意识到在所有这些情况下,底层的super机制会不得不每次都重新绑定。

而且根据你每次赋值采取的语法方式不同,很有可能在某些情况下super不能被正确地绑定(至少不会像你期望的那样),所以你可能(在写作这里时,TC39 正在讨论这个问题)会不得不用toMethod(..)来手动绑定super(有点儿像你不得不用bind(..)绑定this —— 见第二章)。

你曾经可以给不同的对象赋予方法,来通过 隐含绑定 规则(见第二章),自动地利用this的动态性。但对于使用super的方法,同样的事情很可能不会发生。

考虑这里super应当怎样动作(对DE):

class P {
    foo() { console.log( "P.foo" ); }
}

class C extends P {
    foo() {
        super();
    }
}

var c1 = new C();
c1.foo(); // "P.foo"

var D = {
    foo: function() { console.log( "D.foo" ); }
};

var E = {
    foo: C.prototype.foo
};

// E 链接到 D 来进行委托
Object.setPrototypeOf( E, D );

E.foo(); // "P.foo"

如果你(十分合理地!)认为super将会在调用时自动绑定,你可能会期望super()将会自动地认识到E委托至D,所以使用super()E.foo()应当调用D.foo()

不是这样。 由于实用主义的性能原因,super不像this那样 延迟绑定(也就是动态绑定)。相反它从调用时[[HomeObject]].[[Prototype]]派生出来,而[[HomeObject]]实在声明时静态绑定的。

在这个特定的例子中,super()依然解析为P.foo(),因为方法的[[HomeObject]]仍然是C而且C.[[Prototype]]P

可能 会有方法手动地解决这样的陷阱。在这个场景中使用toMethod(..)来绑定/重绑定方法的[[HomeObject]](设置这个对象的[[Prototype]]一起!)似乎会管用:

var D = {
    foo: function() { console.log( "D.foo" ); }
};

// E 链接到 D 来进行委托
var E = Object.create( D );

// 手动绑定`foo`的`[[HomeObject]]`到
// `E`, 因为`E.[[Prototype]]`是`D`,所以
// `super()`是`D.foo()`
E.foo = C.prototype.foo.toMethod( E, "foo" );

E.foo(); // "D.foo"

注意: toMethod()克隆这个方法,然后将它的第一个参数作为homeObject(这就是为什么我们传入E),第二个参数(可选)用来设置新方法的name(保持“foo”不变)。

除了这种场景以外,是否还有其他的极端情况会使开发者们陷入陷阱还有待观察。无论如何,你将不得不费心保持清醒:在哪里引擎自动为你确定super,和在哪里你不得不手动处理它。噢!

静态优于动态?

但是关于 ES6 的最大问题是,所有这些种种陷阱意味着class有点儿将你带入一种语法,它看起来暗示着(像传统的类那样)一旦你声明一个class,它是一个东西的静态定义(将来会实例化)。使你完全忘记了这个事实:C是一个对象,一个你可以直接互动的具体的东西。

在传统面向类的语言中,你从不会在晚些时候调整类的定义,所以类设计模式不提供这样的能力。但是 JS 的 一个最强大的部分 就是它 动态的,而且任何对象的定义都是(除非你将它设定为不可变)不固定的可变的 东西

class看起来在暗示你不应该做这样的事情,通过强制你使用.prototype语法才能做到,或强制你考虑super的陷阱,等等。而且它对这种动态机制可能带来的一切陷阱 几乎不 提供任何支持。

换句话说,class好像在告诉你:“动态太坏了,所以这可能不是一个好主意。这里有看似静态语法,把你的东西静态编码。”

关于 JavaScript 的评论是多么悲伤啊:动态太难了,让我们假装成(但实际上不是!)静态吧

这些就是为什么 ES6 的class伪装成一个语法头痛症的解决方案,但是它实际上把水搅得更浑,而且更不容易对 JS 形成清晰简明的认识。

注意: 如果你使用.bind(..)工具制作一个硬绑定函数(见第二章),那么这个函数是不能像普通函数那样用 ES6 的extend扩展的。

复习

class在假装修复 JS 中的类/继承设计模式的问题上做的很好。但他实际上做的却正相反:它隐藏了许多问题,而且引入了其他微妙而且危险的东西

class为折磨了 JavaScript 语言将近 20 年的“类”的困扰做出了新的贡献。在某些方面,它问的问题比它解决的多,而且在[[Prototype]]机制的优雅和简单之上,它整体上感觉像是一个非常不自然的匹配。

底线:如果 ES6class使稳健地利用[[Prototype]]变得困难,而且隐藏了 JS 对象机制最重要的性质 —— 对象间的实时委托链接 —— 我们不应该认为class产生的麻烦比它解决的更多,并且将它贬低为一种反模式吗?

我真的不能帮你回答这个问题。但我希望这本书已经在你从未经历过的深度上完全地探索了这个问题,而且已经给出了 你自己回答这个问题 所需的信息。

你不懂 JS:异步与性能

来源:你不懂 JS:异步与性能

你不懂 JS: 异步与性能 第一章: 异步: 现在与稍后

在像 JavaScript 这样的语言中最重要但经常被误解的编程技术之一,就是如何表达和操作跨越一段时间的程序行为。

这不仅仅是关于从for循环开始到for循环结束之间发生的事情,当然它确实要花 一些时间(几微秒到几毫秒)才能完成。 它是关于你的程序 现在 运行的部分,和你的程序 稍后 运行的另一部分之间发生的事情——现在稍后 之间有一个间隙,在这个间隙中你的程序没有活跃地执行。

几乎所有被编写过的(特别是用 JS)大型程序都不得不用这样或那样的方法来管理这个间隙,不管是等待用户输入,从数据库或文件系统请求数据,通过网络发送数据并等待应答,还是在规定的时间间隔重复某些任务(比如动画)。在所有这些各种方法中,你的程序都不得不跨越时间间隙管理状态。就像在伦敦众所周知的一句话(地铁门与月台间的缝隙):“小心间隙。”

实际上,你程序中 现在稍后 的部分之间的关系,就是异步编程的核心。

可以确定的是,异步编程在 JS 的最开始就出现了。但是大多数开发者从没认真地考虑过它到底是如何,为什么出现在他们的程序中的,也没有探索过 其他 处理异步的方式。足够好 的方法总是老实巴交的回调函数。今天还有许多人坚持认为回调就绰绰有余了。

但是 JS 在使用范围和复杂性上不停地生长,作为运行在浏览器,服务器和每种可能的设备上的头等编程语言,为了适应它不断扩大的要求,我们在管理异步上感受到的痛苦日趋严重,人们迫切地需要一种更强大更合理的处理方法。

虽然眼前这一切看起来很抽象,但我保证,随着我们通读这本书你会更完整且坚实地解决它。在接下来的几章中我们将会探索各种异步 JavaScript 编程的新兴技术。

但在接触它们之前,我们将不得不更深刻地理解异步是什么,以及它在 JS 中如何运行。

块儿(Chunks)中的程序

你可能将你的 JS 程序写在一个 .js 文件中,但几乎可以确定你的程序是由几个代码块儿构成的,仅有其中的一个将会在 现在 执行,而其他的将会在 稍后 执行。最常见的 代码块儿 单位是function

大多数刚接触 JS 的开发者都可能会有的问题是,稍后 并不严格且立即地在 现在 之后发生。换句话说,根据定义,现在 不能完成的任务将会异步地完成,而且我们因此不会有你可能在直觉上期望或想要的阻塞行为。

考虑这段代码:

// ajax(..)是某个包中任意的 Ajax 函数
var data = ajax( "http://some.url.1" );

console.log( data );
// 噢!`data`一般不会有 Ajax 的结果

你可能意识到 Ajax 请求不会同步地完成,这意味着ajax(..)函数还没有任何返回的值可以赋值给变量data。如果ajax(..)在应答返回之前 能够 阻塞,那么data = ..赋值将会正常工作。

但那不是我们使用 Ajax 的方式。我们 现在 制造一个异步的 Ajax 请求,直到 稍后 我们才会得到结果。

现在 “等到” 稍后 最简单的(但绝对不是唯一的,或最好的)方法,通常称为回调函数:

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", function myCallbackFunction(data){

    console.log( data ); // Yay, 我得到了一些`data`!

} );

警告: 你可能听说过发起同步的 Ajax 请求是可能的。虽然在技术上是这样的,但你永远,永远不应该在任何情况下这样做,因为它将锁定浏览器的 UI(按钮,菜单,滚动条,等等)而且阻止用户与任何东西互动。这是一个非常差劲的主意,你应当永远回避它。

在你提出抗议之前,不,你渴望避免混乱的回调不是使用阻塞的,同步的 Ajax 的正当理由。

举个例子,考虑下面的代码:

function now() {
    return 21;
}

function later() {
    answer = answer * 2;
    console.log( "Meaning of life:", answer );
}

var answer = now();

setTimeout( later, 1000 ); // Meaning of life: 42

这个程序中有两个代码块儿:现在 将会运行的东西,和 稍后 将会运行的东西。这两个代码块分别是什么应当十分明显,但还是让我们以最明确的方式指出来:

现在:

function now() {
    return 21;
}

function later() { .. }

var answer = now();

setTimeout( later, 1000 );

稍后:

answer = answer * 2;
console.log( "Meaning of life:", answer );

你的程序一执行,现在 代码块儿就会立即运行。但setTimeout(..)还设置了一个 稍后 会发生的事件(一个超时事件),所以later()函数的内容将会在一段时间后(从现在开始 1000 毫秒)被执行。

每当你将一部分代码包进function并且规定它应当为了响应某些事件而执行(定时器,鼠标点击,Ajax 应答等等),你就创建了一个 稍后 代码块儿,也因此在你的程序中引入了异步。

异步控制台

关于console.*方法如何工作,没有相应的语言规范或一组需求——它们不是 JavaScript 官方的一部分,而是由 宿主环境 添加到 JS 上的(见本丛书的 类型与文法)。

所以,不同的浏览器和 JS 环境各自为战,这有时会导致令人困惑的行为。

特别地,有些浏览器和某些条件下,console.log(..)实际上不会立即输出它得到的东西。这个现象的主要原因可能是因为 I/O 处理很慢,而且是许多程序的阻塞部分(不仅是 JS)。所以,对一个浏览器来说,可能的性能更好的处理方式是(从网页/UI 的角度看),在后台异步地处理consoleI/O,而你也许根本不知道它发生了。

虽然不是很常见,但是一种可能被观察到(不是从代码本身,而是从外部)的场景是:

var a = {
    index: 1
};

// 稍后
console.log( a ); // ??

// 再稍后
a.index++;

我们一般希望看到的是,就在console.log(..)语句被执行的那一刻,对象a被取得一个快照,打印出如{ index: 1 }的内容,如此在下一个语句a.index++执行时,它修改不同于a的输出,或者严格的在a的输出之后的某些东西。

大多数时候,上面的代码将会在你的开发者工具控制台中产生一个你期望的对象表现形式。但是同样的代码也可能运行在这样的情况下:浏览器告诉后台它需要推迟控制台 I/O,这时,在对象在控制台中被表示的那个时间点,a.index++已经执行了,所以它将显示{ index: 2 }

到底在什么条件下consoleI/O 将被推迟是不确定的,甚至它能不能被观察到都是不确定的。只能当你在调试过程中遇到问题时——对象在console.log(..)语句之后被修改,但你却意外地看到了修改后的内容——意识到 I/O 的这种可能的异步性。

注意: 如果你遇到了这种罕见的情况,最好的选择是使用 JS 调试器的断点,而不是依赖console的输出。第二好的选择是通过将目标对象序列化为一个string强制取得一个它的快照,比如用JSON.stringify(..)

事件轮询(Event Loop)

让我们来做一个(也许是令人震惊的)声明:尽管明确地允许异步 JS 代码(就像我们刚看到的超时),但是实际上,直到最近(ES6)为止,JavaScript 本身从来没有任何内建的异步概念。

什么!? 这听起来简直是疯了,对吧?事实上,它是真的。JS 引擎本身除了在某个在被要求的时刻执行你程序的一个单独的代码块外,没有做过任何其他的事情。

“被'谁'要求”?这才是重要的部分!

JS 引擎没有运行在隔离的区域。它运行在一个 宿主环境 中,对大多数开发者来说这个宿主环境就是浏览器。在过去的几年中(但不特指这几年),JS 超越了浏览器的界限进入到了其他环境中,比如服务器,通过 Node.js 这样的东西。其实,今天 JavaScript 已经被嵌入到所有种类的设备中,从机器人到电灯泡儿。

所有这些环境的一个共通的“线程”(一个“不那么微妙”的异步玩笑,不管怎样)是,他们都有一种机制:在每次调用 JS 引擎时,可以 随着时间的推移 执行你的程序的多个代码块儿,这称为“事件轮询(Event Loop)”。

换句话说,JS 引擎对 时间 没有天生的感觉,反而是一个任意 JS 代码段的按需执行环境。是它周围的环境在不停地安排“事件”(JS 代码的执行)。

那么,举例来说,当你的 JS 程序发起一个从服务器取得数据的 Ajax 请求时,你在一个函数(通常称为回调)中建立好“应答”代码,然后 JS 引擎就会告诉宿主环境,“嘿,我就要暂时停止执行了,但不管你什么时候完成了这个网络请求,而且你还得到一些数据的话,请 回来调 这个函数。”

然后浏览器就会为网络的应答设置一个监听器,当它有东西要交给你的时候,它会通过将回调函数插入 事件轮询 来安排它的执行。

那么什么是 事件轮询

让我们先通过一些假想代码来对它形成一个概念:

// `eventLoop`是一个像队列一样的数组(先进先出)
var eventLoop = [ ];
var event;

// “永远”执行
while (true) {
    // 执行一个"tick"
    if (eventLoop.length > 0) {
        // 在队列中取得下一个事件
        event = eventLoop.shift();

        // 现在执行下一个事件
        try {
            event();
        }
        catch (err) {
            reportError(err);
        }
    }
}

当然,这只是一个用来展示概念的大幅简化的假想代码。但是对于帮助我们建立更好的理解来说应该够了。

如你所见,有一个通过while循环来表现的持续不断的循环,这个循环的每一次迭代称为一个“tick”。在每一个“tick”中,如果队列中有一个事件在等待,它就会被取出执行。这些事件就是你的函数回调。

很重要并需要注意的是,setTimeout(..)不会将你的回调放在事件轮询队列上。它设置一个定时器;当这个定时器超时的时候,环境才会把你的回调放进事件轮询,这样在某个未来的 tick 中它将会被取出执行。

如果在那时事件轮询队列中已经有了 20 个事件会怎么样?你的回调要等待。它会排到队列最后——没有一般的方法可以插队和跳到队列的最前方。这就解释了为什么setTimeout(..)计时器可能不会完美地按照预计时间触发。你得到一个保证(粗略地说):你的回调不会再你指定的时间间隔之前被触发,但是可能会在这个时间间隔之后被触发,具体要看事件队列的状态。

换句话说,你的程序通常被打断成许多小的代码块儿,它们一个接一个地在事件轮询队列中执行。而且从技术上说,其他与你的程序没有直接关系的事件也可以穿插在队列中。

注意: 我们提到了“直到最近”,暗示着 ES6 改变了事件轮询队列在何处被管理的性质。这主要是一个正式的技术规范,ES6 现在明确地指出了事件轮询应当如何工作,这意味着它技术上属于 JS 引擎应当关心的范畴内,而不仅仅是 宿主环境。这么做的一个主要原因是为了引入 ES6 的 Promises(我们将在第三章讨论),因为人们需要有能力对事件轮询队列的排队操作进行直接,细粒度的控制(参见“协作”一节中关于setTimeout(..0)的讨论)。

并行线程

将“异步”与“并行”两个词经常被混为一谈,但它们实际上是十分不同的。记住,异步是关于 现在稍后 之间的间隙。但并行是关于可以同时发生的事情。

关于并行计算最常见的工具就是进程与线程。进程和线程独立地,可能同时地执行:在不同的处理器上,甚至在不同的计算机上,而多个线程可以共享一个进程的内存资源。

相比之下,一个事件轮询将它的工作打碎成一系列任务并串行地执行它们,不允许并行访问和更改共享的内存。并行与“串行”可能以在不同线程上的事件轮询协作的形式共存。

并行线程执行的穿插,与异步事件的穿插发生在完全不同的粒度等级上:

比如:

function later() {
    answer = answer * 2;
    console.log( "Meaning of life:", answer );
}

虽然later()的整个内容将被当做一个事件轮询队列的实体,但当考虑到将要执行这段代码的线程时,实际上也许会有许多不同的底层操作。比如,answer = answer * 2首先需要读取当前answer的值,再把2放在某个地方,然后进行乘法计算,最后把结果存回到answer

在一个单线程环境中,线程队列中的内容都是底层操作真的无关紧要,因为没有什么可以打断线程。但如果你有一个并行系统,在同一个程序中有两个不同的线程,你很可能会得到无法预测的行为:

考虑这段代码:

var a = 20;

function foo() {
    a = a + 1;
}

function bar() {
    a = a * 2;
}

// ajax(..) 是一个给定的库中的随意 Ajax 函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

在 JavaScript 的单线程行为下,如果foo()bar()之前执行,结果a42,但如果bar()foo()之前执行,结果a将是41

如果 JS 事件共享相同的并列执行数据,问题将会变得微妙得多。考虑这两个假想代码段,它们分别描述了运行foo()bar()中代码的线程将要执行的任务,并考虑如果它们在完全相同的时刻运行会发生什么:

线程 1(XY是临时的内存位置):

foo():
    a. 将`a`的值读取到`X`
    b. 将`1`存入`Y`
    c. 把`X`和`Y`相加,将结果存入`X`
  d. 将`X`的值存入`a`

线程 2(XY是临时的内存位置):

bar():
  a. 将`a`的值读取到`X`
    b. 将`2`存入`Y`
    c. 把`X`和`Y`相乘,将结果存入`X`
    d. 将`X`的值存入`a`

现在,让我们假定这两个线程在并行执行。你可能发现了问题,对吧?它们在临时的步骤中使用共享的内存位置XY

如果步骤像这样发生,a的最终结果什么?

1a  (将`a`的值读取到`X`   ==> `20`)
2a  (将`a`的值读取到`X`   ==> `20`)
1b  (将`1`存入`Y`   ==> `1`)
2b  (将`2`存入`Y`   ==> `2`)
1c  (把`X`和`Y`相加,将结果存入`X`   ==> `22`)
1d  (将`X`的值存入`a`   ==> `22`)
2c  (把`X`和`Y`相乘,将结果存入`X`   ==> `44`)
2d  (将`X`的值存入`a`   ==> `44`)

a中的结果将是44。那么这种顺序呢?

1a  (将`a`的值读取到`X`   ==> `20`)
2a  (将`a`的值读取到`X`   ==> `20`)
2b  (将`2`存入`Y`   ==> `2`)
1b  (将`1`存入`Y`   ==> `1`)
2c  (把`X`和`Y`相乘,将结果存入`X`   ==> `20`)
1c  (把`X`和`Y`相加,将结果存入`X`   ==> `21`)
1d  (将`X`的值存入`a`   ==> `21`)
2d  (将`X`的值存入`a`   ==> `21`)

a中的结果将是21

所以,关于线程的编程十分刁钻,因为如果你不采取特殊的步骤来防止这样的干扰/穿插,你会得到令人非常诧异的,不确定的行为。这通常让人头疼。

JavaScript 从不跨线程共享数据,这意味着不必关心这一层的不确定性。但这并不意味着 JS 总是确定性的。记得前面foo()bar()的相对顺序产生两个不同的结果吗(4142)?

注意: 可能还不明显,但不是所有的不确定性都是坏的。有时候它无关紧要,有时候它是故意的。我们会在本章和后续几章中看到更多的例子。

运行至完成

因为 JavaScript 是单线程的,foo()(和bar())中的代码是原子性的,这意味着一旦foo()开始运行,它的全部代码都会在bar()中的任何代码可以运行之前执行完成,反之亦然。这称为“运行至完成”行为。

事实上,运行至完成的语义会在foo()bar()中有更多的代码时更明显,比如:

var a = 1;
var b = 2;

function foo() {
    a++;
    b = b * a;
    a = b + 3;
}

function bar() {
    b--;
    a = 8 + b;
    b = a * 2;
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

因为foo()不能被bar()打断,而且bar()不能被foo()打断,所以这个程序根据哪一个先执行只有两种可能的结果——如果线程存在,foo()bar()中的每一个语句都可能被穿插,可能的结果数量将会极大地增长!

代码块儿 1 是同步的(现在 发生),但代码块儿 2 和 3 是异步的(稍后 发生),这意味着它们的执行将会被时间的间隙分开。

代码块儿 1:

var a = 1;
var b = 2;

代码块儿 2 (foo()):

a++;
b = b * a;
a = b + 3;

代码块儿 3 (bar()):

b--;
a = 8 + b;
b = a * 2;

代码块儿 2 和 3 哪一个都有可能先执行,所以这个程序有两个可能的结果,正如这里展示的:

结果 1:

var a = 1;
var b = 2;

// foo()
a++;
b = b * a;
a = b + 3;

// bar()
b--;
a = 8 + b;
b = a * 2;

a; // 11
b; // 22

结果 2:

var a = 1;
var b = 2;

// bar()
b--;
a = 8 + b;
b = a * 2;

// foo()
a++;
b = b * a;
a = b + 3;

a; // 183
b; // 180

同一段代码有两种结果仍然意味着不确定性!但是这是在函数(事件)顺序的水平上,而不是在使用线程时语句顺序的水平上(或者说,实际上是表达式操作的顺序上)。换句话说,他比线程更具有 确定性

当套用到 JavaScript 行为时,这种函数顺序的不确定性通常称为“竞合状态”,因为foo()bar()在互相竞争看谁会先运行。明确地说,它是一个“竞合状态”因为你不能可靠地预测ab将如何产生。

注意: 如果在 JS 中不知怎的有一个函数没有运行至完成的行为,我们会有更多可能的结果,对吧?ES6 中引入一个这样的东西(见第四章“生成器”),但现在不要担心,我们会回头讨论它。

并发

让我们想象一个网站,它显示一个随着用户向下滚动而逐步加载的状态更新列表(就像社交网络的新消息)。要使这样的特性正确工作,(至少)需要两个分离的“进程” 同时 执行(在同一个时间跨度内,但没必要是同一个时间点)。

注意: 我们在这里使用带引号的“进程”,因为它们不是计算机科学意义上的真正的操作系统级别的进程。它们是虚拟进程,或者说任务,表示一组逻辑上关联,串行顺序的操作。我们将简单地使用“进程”而非“任务”,因为在术语层面它与我们讨论的概念的定义相匹配。

第一个“进程”将响应当用户向下滚动页面时触发的onscroll事件(发起取得新内容的 Ajax 请求)。第二个“进程”将接收返回的 Ajax 应答(将内容绘制在页面上)。

显然,如果用户向下滚动的足够快,你也许会看到在第一个应答返回并处理期间,有两个或更多的onscroll事件被触发,因此你将使onscroll事件和 Ajax 应答事件迅速触发,互相穿插在一起。

并发是当两个或多个“进程”在同一时间段内同时执行,无论构成它们的各个操作是否 并行地(在同一时刻不同的处理器或内核)发生。你可以认为并发是“进程”级别的(或任务级别)的并行机制,而不是操作级别的并行机制(分割进程的线程)。

注意: 并发还引入了这些“进程”间彼此互动的概念。我们稍后会讨论它。

在一个给定的时间跨度内(用户可以滚动的那几秒),让我们将每个独立的“进程”作为一系列事件/操作描绘出来:

“线程”1 (onscroll事件):

onscroll, request 1
onscroll, request 2
onscroll, request 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
onscroll, request 7

“线程”2 (Ajax 应答事件):

response 1
response 2
response 3
response 4
response 5
response 6
response 7

一个onscroll事件与一个 Ajax 应答事件很有可能在同一个 时刻 都准备好被处理了。比如我们在一个时间线上描绘一下这些事件的话:

onscroll, request 1
onscroll, request 2          response 1
onscroll, request 3          response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6          response 4
onscroll, request 7
response 6
response 5
response 7

但是,回到本章前面的事件轮询概念,JS 一次只能处理一个事件,所以不是onscroll, request 2首先发生就是response 1首先发生,但是他们不可能完全在同一时刻发生。就像学校食堂的孩子们一样,不管他们在门口挤成什么样,他们最后都不得不排成一个队来打饭!

让我们来描绘一下所有这些事件在事件轮询队列上穿插的情况:

事件轮询队列:

onscroll, request 1   <--- 进程 1 开始
onscroll, request 2
response 1            <--- 进程 2 开始
onscroll, request 3
response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
response 4
onscroll, request 7   <--- 进程 1 结束
response 6
response 5
response 7            <--- 进程 2 结束

“进程 1”和“进程 2”并发地运行(任务级别的并行),但是它们的个别事件在事件轮询队列上顺序地运行。

顺便说一句,注意到response 6response 5没有按照预想的顺序应答吗?

单线程事件轮询是并发的一种表达(当然还有其他的表达,我们稍后讨论)。

非互动

在同一个程序中两个或更多的“进程”在穿插它们的步骤/事件时,如果它们的任务之间没有联系,那么他们就没必要互动。如果它们不互动,不确定性就是完全可以接受的。

举个例子:

var res = {};

function foo(results) {
    res.foo = results;
}

function bar(results) {
    res.bar = results;
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

foo()bar()是两个并发的“进程”,而且它们被触发的顺序是不确定的。但对我们的程序的结构来讲它们的触发顺序无关紧要,因为它们的行为相互独立所以不需要互动。

这不是一个“竞合状态”Bug,因为这段代码总能够正确工作,与顺序无关。

互动

更常见的是,通过作用域和/或 DOM,并发的“进程”将有必要间接地互动。当这样的互动将要发生时,你需要协调这些互动行为来防止前面讲述的“竞合状态”。

这里是两个由于隐含的顺序而互动的并发“进程”的例子,它 有时会出错

var res = [];

function response(data) {
    res.push( data );
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

并发的“进程”是那两个将要处理 Ajax 应答的response()调用。它们谁都有可能先发生。

假定我们期望的行为是res[0]拥有"http://some.url.1"调用的结果,而res[1]拥有"http://some.url.2"调用的结果。有时候结果确实是这样,而有时候则相反,要看哪一个调用首先完成。很有可能,这种不确定性是一个“竞合状态”Bug。

注意: 在这些情况下要极其警惕你可能做出的主观臆测。比如这样的情况就没什么不寻常:一个开发者观察到"http://some.url.2"的应答“总是”比"http://some.url.1"要慢得多,也许有赖于它们所做的任务(比如,一个执行数据库任务而另一个只是取得静态文件),所以观察到的顺序看起来总是所期望的。就算两个请求都发到同一个服务器,而且它故意以确定的顺序应答,也不能 真正 保证应答回到浏览器的顺序。

所以,为了解决这样的竞合状态,你可以协调互动的顺序:

var res = [];

function response(data) {
    if (data.url == "http://some.url.1") {
        res[0] = data;
    }
    else if (data.url == "http://some.url.2") {
        res[1] = data;
    }
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

无论哪个 Ajax 应答首先返回,我们都考察它的data.url(当然,假设这样的数据会从服务器返回)来找到应答数据应当在res数组中占有的位置。res[0]将总是持有"http://some.url.1"的结果,而res[1]将总是持有"http://some.url.2"的结果。通过简单的协调,我们消除了“竞合状态”的不确定性。

这个场景的同样道理可以适用于这样的情况:多个并发的函数调用通过共享的 DOM 互动,比如一个在更新<div>的内容而另一个在更新<div>的样式或属性(比如一旦 DOM 元素拥有内容就使它变得可见)。你可能不想在 DOM 元素拥有内容之前显示它,所以协调工作就必须保证正确顺序的互动。

没有协调的互动,有些并发的场景 总是出错(不仅仅是 有时)。考虑下面的代码:

var a, b;

function foo(x) {
    a = x * 2;
    baz();
}

function bar(y) {
    b = y * 2;
    baz();
}

function baz() {
    console.log(a + b);
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

在这个例子中,不管foo()bar()谁先触发,总是会使baz()运行的太早了(ab之一还是空的时候),但是第二个baz()调用将可以工作,因为ab将都是可用的。

有许多不同的方法可以解决这个状态。这是简单的一种:

var a, b;

function foo(x) {
    a = x * 2;
    if (a && b) {
        baz();
    }
}

function bar(y) {
    b = y * 2;
    if (a && b) {
        baz();
    }
}

function baz() {
    console.log( a + b );
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

baz()调用周围的if (a && b)条件通常称为“大门”,因为我们不能确定ab到来的顺序,但在打开大门(调用baz())之前我们等待它们全部到达。

另一种你可能会遇到的并发互动状态有时称为“竞争”,单更准确地说应该叫“门闩”。它的行为特点是“先到者胜”。在这里不确定性是可以接受的,因为你明确指出“竞争”的终点线上只有一个胜利者。

考虑这段有问题的代码:

var a;

function foo(x) {
    a = x * 2;
    baz();
}

function bar(x) {
    a = x / 2;
    baz();
}

function baz() {
    console.log( a );
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

不管哪一个函数最后触发(foo()bar()),它不仅会覆盖前一个函数对a的赋值,还会重复调用baz()(不太可能是期望的)。

所以,我们可以用一个简单的门闩来协调互动,仅让第一个过去:

var a;

function foo(x) {
    if (a == undefined) {
        a = x * 2;
        baz();
    }
}

function bar(x) {
    if (a == undefined) {
        a = x / 2;
        baz();
    }
}

function baz() {
    console.log( a );
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

if (a == undefined)条件仅会让foo()bar()中的第一个通过,而第二个(以及后续所有的)调用将会被忽略。第二名什么也得不到!

注意: 在所有这些场景中,为了简化说明的目的我们都用了全局变量,这里我们没有任何理由需要这么做。只要我们讨论中的函数可以访问变量(通过作用域),它们就可以正常工作。依赖于词法作用域变量(参见本丛书的 作用域与闭包 ),和这些例子中实质上的全局变量,是这种并发协调形式的一个明显的缺点。在以后的几章中,我们会看到其他的在这方面干净得多的协调方法。

协作

另一种并发协调的表达称为“协作并发”,它并不那么看重在作用域中通过共享值互动(虽然这依然是允许的!)。它的目标是将一个长时间运行的“进程”打断为许多步骤或批处理,以至于其他的并发“进程”有机会将它们的操作穿插进事件轮询队列。

举个例子,考虑一个 Ajax 应答处理器,它需要遍历一个很长的结果列表来将值变形。我们将使用Array#map(..)来让代码短一些:

var res = [];

// `response(..)`从 Ajax 调用收到一个结果数组
function response(data) {
    // 连接到既存的`res`数组上
    res = res.concat(
        // 制造一个新的变形过的数组,所有的`data`值都翻倍
        data.map( function(val){
            return val * 2;
        } )
    );
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

如果"http://some.url.1"首先返回它的结果,整个结果列表将会一次性映射进res。如果只有几千或更少的结果记录,一般来说不是什么大事。但假如有 1 千万个记录,那么就可能会花一段时间运行(在强大的笔记本电脑上花几秒钟,在移动设备上花的时间长得多,等等)。

当这样的“处理”运行时,页面上没有任何事情可以发生,包括不能有另一个response(..)调用,不能有 UI 更新,甚至不能有用户事件比如滚动,打字,按钮点击等。非常痛苦。

所以,为了制造协作性更强、更友好而且不独占事件轮询队列的并发系统,你可以在一个异步批处理中处理这些结果,在批处理的每一步都“让出”事件轮询来让其他等待的事件发生。

这是一个非常简单的方法:

var res = [];

// `response(..)`从 Ajax 调用收到一个结果数组
function response(data) {
    // 我们一次只处理 1000 件
    var chunk = data.splice( 0, 1000 );

    // 连接到既存的`res`数组上
    res = res.concat(
        // 制造一个新的变形过的数组,所有的`data`值都翻倍
        chunk.map( function(val){
            return val * 2;
        } )
    );

    // 还有东西要处理吗?
    if (data.length > 0) {
        // 异步规划下一个批处理
        setTimeout( function(){
            response( data );
        }, 0 );
    }
}

// ajax(..) 是某个包中任意的 Ajax 函数
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

我们以每次最大 1000 件作为一个块儿处理数据。这样,我们保证每个“进程”都是短时间运行的,即便这意味着会有许多后续的“进程”,在事件轮询队列上的穿插将会给我们一个响应性(性能)强得多的网站/应用程序。

当然,我们没有对任何这些“进程”的顺序进行互动协调,所以在res中的结果的顺序是不可预知的。如果要求顺序,你需要使用我们之前讨论的互动技术,或者在本书后续章节中介绍的其他技术。

我们使用setTimeout(..0)(黑科技)来异步排程,基本上它的意思是“将这个函数贴在事件轮询队列的末尾”。

注意: 从技术上讲,setTimeout(..0)没有直接将一条记录插入事件轮询队列。计时器将会在下一个运行机会将事件插入。比如,两个连续的setTimeout(..0)调用不会严格保证以调用的顺序被处理,所以我们可能看到各种时间偏移的情况,使这样的事件的顺序是不可预知的。在 Node.js 中,一个相似的方式是process.nextTick(..)。不管那将会有多方便(而且通常性能更好),(还)没有一个直接的方法可以横跨所有环境来保证异步事件顺序。我们会在下一节详细讨论这个话题。

Jobs

在 ES6 中,在事件轮询队列之上引入了一层新概念,称为“工作队列(Job queue)”。你最有可能接触它的地方是在 Promises(见第三章)的异步行为中。

不幸的是,它目前是一个没有公开 API 的机制,因此要演示它有些兜圈子。我们不得不仅仅在概念上描述它,这样当我们在第三章中讨论异步行为时,你将会理解那些动作行为是如何排程与处理的。

那么,我能找到的考虑它的最佳方式是:“工作队列”是一个挂靠在事件轮询队列的每个 tick 末尾的队列。在事件轮询的一个 tick 期间内,某些可能发生的隐含异步动作的行为将不会导致一个全新的事件加入事件轮询队列,而是在当前 tick 的工作队列的末尾加入一个新的记录(也就是一个 Job)。

它好像是在说,“哦,另一件需要我 稍后 去做的事儿,但是保证它在其他任何事情发生之间发生。”

或者,用一个比喻:事件轮询队列就像一个游乐园项目,一旦你乘坐完一次,你就不得不去队尾排队来乘坐下一次。而工作队列就像乘坐完后,立即插队乘坐下一次。

一个 Job 还可能会导致更多的 Job 被加入同一个队列的末尾。所以,一个在理论上可能的情况是,Job“轮询”(一个 Job 持续不断地加入其他 Job 等)会无限地转下去,从而拖住程序不能移动到一下一个事件轮询 tick。这与在你的代码中表达一个长时间运行或无限循环(比如while (true) ..)在概念上几乎是一样的。

Job 的精神有点儿像setTimeout(..0)黑科技,但以一种定义明确得多的方式实现,而且保证顺序: 稍后,但尽快

让我们想象一个用于 Job 排程的 API,并叫它schedule(..)。考虑如下代码:

console.log( "A" );

setTimeout( function(){
    console.log( "B" );
}, 0 );

// 理论上的 "Job API"
schedule( function(){
    console.log( "C" );

    schedule( function(){
        console.log( "D" );
    } );
} );

你肯能会期望它打印出A B C D,但是它将会打出A C D B,因为 Job 发生在当前的事件轮询 tick 的末尾,而定时器会在 下一个 事件轮询 tick(如果可用的话!)触发排程。

在第三章中,我们会看到 Promises 的异步行为是基于 Job 的,所以搞明白它与事件轮询行为的联系是很重要的。

语句排序

我们在代码中表达语句的顺序没有必要与 JS 引擎执行它们的顺序相同。这可能看起来像是个奇怪的论断,所以我们简单地探索一下。

但在我们开始之前,我们应当对一些事情十分清楚:从程序的角度看,语言的规则/文法(参见本丛书的 类型与文法)为语句的顺序决定了一个非常可预知、可靠的行为。所以我们将要讨论的是在你的 JS 程序中 应当永远观察不到的东西

警告: 如果你曾经 观察到 过我们将要描述的编译器语句重排,那明显是违反了语言规范,而且无疑是那个 JS 引擎的 Bug——它应当被报告并且修复!但是更常见的是你 怀疑 JS 引擎里发生了什么疯狂的事,而事实上它只是你自己代码中的一个 Bug(可能是一个“竞合状态”)——所以先检查那里,多检查几遍。在 JS 调试器使用断点并一行一行地步过你的代码,将是帮你在 你的代码 中找出这样的 Bug 的最强大的工具。

考虑下面的代码:

var a, b;

a = 10;
b = 30;

a = a + 1;
b = b + 1;

console.log( a + b ); // 42

这段代码没有任何异步表达(除了早先讨论的罕见的console异步 I/O),所以最有可能的推测是它会一行一行地、从上到下地处理。

但是,JS 引擎 有可能,在编译完这段代码后(是的,JS 是被编译的——见本丛书的 作用域与闭包)发现有机会通过(安全地)重新安排这些语句的顺序来使你的代码运行得更快。实质上,只要你观察不到重排,一切都是合理的。

举个例子,引擎可能会发现如果实际上这样执行代码会更快:

var a, b;

a = 10;
a++;

b = 30;
b++;

console.log( a + b ); // 42

或者是这样:

var a, b;

a = 11;
b = 31;

console.log( a + b ); // 42

或者甚至是:

// 因为`a`和`b`都不再被使用,我们可以内联而且根本不需要它们!
console.log( 42 ); // 42

在所有这些情况下,JS 引擎在它的编译期间进行着安全的优化,而最终的 可观察到 的结果将是相同的。

但也有一个场景,这些特殊的优化是不安全的,因而也是不被允许的(当然,不是说它一点儿都没优化):

var a, b;

a = 10;
b = 30;

// 我们需要`a`和`b`递增之前的状态!
console.log( a * b ); // 300

a = a + 1;
b = b + 1;

console.log( a + b ); // 42

编译器重排会造成可观测的副作用(因此绝不会被允许)的其他例子,包括任何带有副作用的函数调用(特别是 getter 函数),或者 ES6 的 Proxy 对象(参见本丛书的 ES6 与未来)。

考虑如下代码:

function foo() {
    console.log( b );
    return 1;
}

var a, b, c;

// ES5.1 getter 字面语法
c = {
    get bar() {
        console.log( a );
        return 1;
    }
};

a = 10;
b = 30;

a += foo();                // 30
b += c.bar;                // 11

console.log( a + b );    // 42

如果不是为了这个代码段中的console.log(..)语句(只是作为这个例子中观察副作用的方便形式),JS 引擎将会更加自由,如果它想(谁知道它想不想!?),它会重排这段代码:

// ...

a = 10 + foo();
b = 30 + c.bar;

// ...

多亏 JS 语义,我们不会观测到看起来很危险的编译器语句重排,但是理解源代码被编写的方式(从上到下)与它在编译后运行的方式之间的联系是多么微弱,依然是很重要的。

编译器语句重排几乎是并发与互动的微型比喻。作为一个一般概念,这样的意识可以帮你更好地理解异步 JS 代码流问题。

复习

一个 JavaScript 程序总是被打断为两个或更多的代码块儿,第一个代码块儿 现在 运行,下一个代码块儿 稍后 运行,来响应一个事件。虽然程序是一块儿一块儿地被执行的,但它们都共享相同的程序作用域和状态,所以对状态的每次修改都是在前一个状态之上的。

不论何时有事件要运行,事件轮询 将运行至队列为空。事件轮询的每次迭代称为一个“tick”。用户交互,IO,和定时器会将事件在事件队列中排队。

在任意给定的时刻,一次只有一个队列中的事件可以被处理。当事件执行时,他可以直接或间接地导致一个或更多的后续事件。

并发是当两个或多个事件链条随着事件相互穿插,因此从高层的角度来看,它们在 同时 运行(即便在给定的某一时刻只有一个事件在被处理)。

在这些并发“进程”之间进行某种形式的互动协调通常是有必要的,比如保证顺序或防止“竞合状态”。这些“进程”还可以 协作:通过将它们自己打断为小的代码块儿来允许其他“进程”穿插。

你不懂 JS: 异步与性能 第二章: 回调

在第一章中,我们探讨了 JavaScript 中关于异步编程的术语和概念。我们的焦点是理解驱动所有“事件”(异步函数调用)的单线程(一次一个)事件轮询队列。我们还探讨了各种解释 同时 运行的事件链,或“进程”(任务, 函数调用等)间的关系的并发模式。

我们在第一章的所有例子中,将函数作为独立的,不可分割的操作单位使用,在这些函数内部语句按照可预知的顺序运行(在编译器水平之上!),但是在函数顺序水平上,事件(也就是异步函数调用)可以以各种顺序发生。

在所有这些情况中,函数都是一个“回调”。因为无论什么时候事件轮询队列中的事件被处理时,这个函数都作为事件轮询“调用并返回”程序的目标。

正如你观察到的,在 JS 程序中,回调是到目前为止最常见的表达和管理异步的方式。确实,在 JavaScript 语言中回调是最基础的异步模式。

无数的 JS 程序,即便是最精巧最复杂的程序,都曾经除了回调外不依靠任何其他异步模式而编写(当然,和我们在第一章中探讨的并发互动模式一起)。回调函数是 JavaScript 的异步苦工,而且它工作得相当好。

除了……回调并不是没有缺点。许多开发者都对 Promises 提供的更好的异步模式感到兴奋不已。但是如果你不明白它在抽象什么,和为什么抽象,是不可能有效利用任何抽象机制的。

在本章中,我们将深入探讨这些话题,来说明为什么更精巧的异步模式(在本书的后续章节中探讨)是必要和被期望的。

延续

让我们回到在第一章中开始的异步回调的例子,但让我稍微修改它一下来画出重点:

// A
ajax( "..", function(..){
    // C
} );
// B

// A// B代表程序的前半部分(也就是 现在),// C标识了程序的后半部分(也就是 稍后)。前半部分立即执行,然后会出现一个不知多久的“暂停”。在未来某个时刻,如果 Ajax 调用完成了,那么程序会回到它刚才离开的地方,并 继续 执行后半部分。

换句话说,回调函数包装或封装了程序的 延续

让我们把代码弄得更简单一些:

// A
setTimeout( function(){
    // C
}, 1000 );
// B

稍停片刻然后问你自己,你将如何描述(给一个不那么懂 JS 工作方式的人)这个程序的行为。来吧,大声说出来。这个很好的练习将使我的下一个观点更鲜明。

现在大多数读者可能在想或说着这样的话:“做 A,然后设置一个等待 1000 毫秒的定时器,一旦它触发,就做 C”。与你的版本有多接近?

你可能已经发觉了不对劲儿的地方,给了自己一个修正版:“做 A,设置一个 1000 毫秒的定时器,然后做 B,然后在超时事件触发后,做 C”。这比第一个版本更准确。你能发现不同之处吗?

虽然第二个版本更准确,但是对于以一种将我们的大脑匹配代码,代码匹配 JS 引擎的方式讲解这段代码来说,这两个版本都是不足的。这里的鸿沟既是微小的也是巨大的,而且是理解回调作为异步表达和管理的缺点的关键。

只要我们以回调函数的方式引入一个延续(或者像许多程序员那样引入几十个!),我们就允许了一个分歧在我们的大脑如何工作和代码将运行的方式之间形成。当这两者背离时,我们的代码就不可避免地陷入这样的境地:更难理解,更难推理,更难调试,和更难维护。

顺序的大脑

我相信大多数读者都曾经听某个人说过(甚至你自己就曾这么说),“我能一心多用”。试图表现得一心多用的效果包含幽默(孩子们的拍头揉肚子游戏),平常的行为(边走边嚼口香糖),和彻头彻尾的危险(开车时发微信)。

但我们是一心多用的人吗?我们真的能执行两个意识,有意地一起行动并在完全同一时刻思考/推理它们两个吗?我们最高级的大脑功能有并行的多线程功能吗?

答案可能令你吃惊:可能不是这样。

我们的大脑其实就不是这样构成的。我们中大多数人(特别是 A 型人格!)都是自己不情愿承认的一个一心一用者。其实我们只能在任一给定的时刻考虑一件事情。

我不是说我们所有的下意识,潜意识,大脑的自动功能,比如心跳,呼吸,和眨眼。那些都是我们延续生命的重要任务,我们不会有意识地给它们分配大脑的能量。谢天谢地,当我们在 3 分钟内第 15 次刷朋友圈时,我们的大脑在后台(线程!)继续着这些重要任务。

相反我们讨论的是在某时刻我们的意识最前线的任务。对我来说,是现在正在写这本书。我还在这完全同一个时刻做其他高级的大脑活动吗?不,没有。我很快而且容易分心——在这最后的几段中有几十次了!

当我们 模拟 一心多用时,比如试着在打字的同时和朋友或家人通电话,实际上我们表现得更像一个快速环境切换器。换句话说,我们快速交替地在两个或更多任务间来回切换,在微小,快速的区块中 同时 处理每个任务。我们做的是如此之快,以至于从外界看开我们在 平行地 做这些事情。

难道这听起来不像异步事件并发吗(就像 JS 中发生的那样)?!如果不,回去再读一遍第一章!

事实上,将庞大复杂的神经内科世界简化为我希望可以在这里讨论的东西的一个方法是,我们的大脑工作起来有点儿像事件轮询队列。

如果你把我打得每一个字(或词)当做一个单独的异步事件,那么现在这一句话上就有十几处地方,可以让我的大脑被其他的事件打断,比如我的感觉,甚至只是我随机的想法。

我不会在每个可能的地方被打断并被拉到其他的“处理”上去(谢天谢地——要不这本书永远也写不完了!)。但是它发生得也足够频繁,以至于我感到我的大脑几乎持续不断地切换到各种不同的环境(也就是“进程”)。而且这和 JS 引擎可能会感觉到的十分相像。

执行与计划

好了,这么说来我们的大脑可以被认为是运行在一个单线程事件轮询队列中,就像 JS 引擎那样。这听起来是个不错的匹配。

但是我们需要比我们刚才分析的更加细致入微。在我们如何计划各种任务,和我们的大脑实际如何运行这些任务之间,有一个巨大,明显的不同。

再一次,回到这篇文章的写作的比拟上来。在我心里的粗略计划轮廓是继续写啊写,顺序地经过一系列在我思想中定好的点。我没有在这次写作期间计划任何的打扰或非线性的活动。但无论如何,我的大脑依然一直不停地切换。

即便在操作级别上我们的大脑是异步事件的,但我们还是用一种顺序的,同步的方式计划任务。“我得去商店,然后买些牛奶,然后去干洗店”。

你会注意到这种高级思维(规划)方式看起来不是那么“异步”。事实上,我们几乎很少会故意只用事件的形式思考。相反,我们小心,顺序地(A 然后 B 然后 C)计划,而且我们假设一个区间有某种临时的阻塞迫使 B 等待 A,使 C 等待 B。

当开发者编写代码时,他们规划一组将要发生的动作。如果他们是合格的开发者,他们会 小心地规划。比如“我需要将z的值设为x的值,然后将x的值设为y的值”。

当我们编写同步代码时,一个语句接一个语句,它工作起来就像我们的跑腿 todo 清单:

// 交换`x`与`y`(通过临时变量`z`)
z = x;
x = y;
y = z;

这三个赋值语句是同步的,所以x=y会等待z=x完成,而y=z会相应地等待x=y完成。另一种说法是这三个语句临时地按照特定的顺序绑在一起执行,一个接一个。幸好我们不必在这里关心任何异步事件的细节。如果我们关心,代码很快就会变得非常复杂!

如果同步的大脑规划和同步的代码语句匹配的很好,那么我们的大脑能把异步代码规划得多好呢?

事实证明,我们在代码中表达异步的方式(用回调)和我们同步的大脑规划行为根本匹配的不是很好。

你能实际想象一下像这样规划你的跑腿 todo 清单的思维线索吗?

“我得去趟商店,但是我确信在路上我会接到一个电话,于是‘嗨,妈妈’,然后她开始讲话,我会在 GPS 上搜索商店的位置,但那会花几分钟加载,所以我把收音机音量调小以便听到妈妈讲话,然后我发现我忘了穿夹克而且外面很冷,但没关系,继续开车并和妈妈说话,然后安全带警报提醒我要系好,于是‘是的,妈,我系着安全带呢,我总是系着安全带!’。啊,GPS 终于得到方向了,现在……”

虽然作为我们如何度过自己的一天,思考以什么顺序做什么事的规划听起来很荒唐,但这正是我们大脑在功能层面运行的方式。记住,这不是一心多用,而只是快速的环境切换。

我们这些开发者编写异步事件代码困难的原因,特别是当我们只有回调手段可用时,就是意识思考/规划的流动对我们大多数人是不自然的。

我们用一步接一步的方式思考,但是一旦我们从同步走向异步,在代码中可以用的工具(回调)不是以一步接一步的方式表达的。

而且这就是为什么正确编写和推理使用回调的异步 JS 代码是如此困难:因为它不是我们的大脑进行规划的工作方式。

注意: 唯一比不知道为什么代码不好用更糟糕的是,从一开始就不知道为什么代码好用!这是一种经典的“纸牌屋”心理:“它好用,但不知为什,所以大家都别碰!”你可能听说过,“他人即地狱”(萨特),而程序员们模仿这种说法,“他人的代码即地狱”。我相信:“不明白我自己的代码才是地狱。”而回调正是肇事者之一。

嵌套/链接的回调

考虑下面的代码:

listen( "click", function handler(evt){
    setTimeout( function request(){
        ajax( "http://some.url.1", function response(text){
            if (text == "hello") {
                handler();
            }
            else if (text == "world") {
                request();
            }
        } );
    }, 500) ;
} );

你很可能一眼就能认出这样的代码。我们得到了三个嵌套在一起的函数链,每一个函数都代表异步序列(任务,“进程”)的一个步骤。

这样的代码常被称为“回调地狱(callback hell)”,有时也被称为“末日金字塔(pyramid of doom)”(由于嵌套的缩进使它看起来像一个放倒的三角形)。

但是“回调地狱”实际上与嵌套/缩进几乎无关。它是一个深刻得多的问题。我们将继续在本章剩下的部分看到它为什么和如何成为一个问题。

首先,我们等待“click”事件,然后我们等待定时器触发,然后我们等待 Ajax 应答回来,就在这时它可能会将所有这些再做一遍。

猛地一看,这段代码的异步性质可能看起来与顺序的大脑规划相匹配。

首先(现在),我们:

listen( "..", function handler(..){
    // ..
} );

稍后,我们:

setTimeout( function request(..){
    // ..
}, 500) ;

稍后,我们:

ajax( "..", function response(..){
    // ..
} );

最后(最 稍后),我们:

if ( .. ) {
    // ..
}
else ..

不过用这样的方式线性推导这段代码有几个问题。

首先,这个例子中我们的步骤在一条顺序的线上(1,2,3,和 4……)是一个巧合。在真实的异步 JS 程序中,经常会有很多噪音把事情搞乱,在我们从一个函数跳到下一个函数时不得不在大脑中把这些噪音快速地演练一遍。理解这样满载回调的异步流程不是不可能,但绝不自然或容易,即使是经历了很多练习后。

而且,有些更深层的,只是在这段代码中不明显的东西搞错了。让我们建立另一个场景(假想代码)来展示它:

doA( function(){
    doB();

    doC( function(){
        doD();
    } )

    doE();
} );

doF();

虽然根据经验你将正确地指出这些操作的真实顺序,但我打赌它第一眼看上去有些使人糊涂,而且需要一些协调的思维周期才能搞明白。这些操作将会以这种顺序发生:

  • doA()
  • doF()
  • doB()
  • doC()
  • doE()
  • doD()

你是在第一次浏览这段代码就看明白的吗?

好吧,你们肯定有些人在想我在函数的命名上不公平,故意引导你误入歧途。我发誓我只是按照从上到下出现的顺序命名的。不过让我再试一次:

doA( function(){
    doC();

    doD( function(){
        doF();
    } )

    doE();
} );

doB();

现在,我以他们实际执行的顺序用字母命名了。但我依然要打赌,即便是现在对这个场景有经验的情况下,大多数读者追踪A -> B -> C -> D -> E -> F的顺序并不是自然而然的。你的眼睛肯定在这段代码中上上下下跳了许多次,对吧?

就算它对你来说都是自然的,这里依然还有一个可能肆虐的灾难。你能发现它是什么吗?

如果doA(..)doD(..)实际上不是如我们明显地假设的那样,不是异步的呢?嗯,现在顺序不同了。如果它们都是同步的(也许仅仅有时是这样,根据当时程序所处的条件而定),现在的顺序是A -> C -> D -> F -> E -> B

你在背景中隐约听到的声音,正是成千上万双手掩面的 JS 开发者的叹息。

嵌套是问题吗?是它使追踪异步流程变得这么困难吗?当然,有一部分是。

但是让我不用嵌套重写一遍前面事件/超时/Ajax 嵌套的例子:

listen( "click", handler );

function handler() {
    setTimeout( request, 500 );
}

function request(){
    ajax( "http://some.url.1", response );
}

function response(text){
    if (text == "hello") {
        handler();
    }
    else if (text == "world") {
        request();
    }
}

这样的代码组织形式几乎看不出来有前一种形式的嵌套/缩进困境,但它的每一处依然容易受到“回调地狱”的影响。为什么呢?

当我们线性地(顺序地)推理这段代码,我们不得不从一个函数跳到下一个函数,再跳到下一个函数,并在代码中弹来弹去以“看到”顺序流。并且要记住,这个简化的代码风格是某种最佳情况。我们都知道真实的 JS 程序代码经常更加神奇地错综复杂,使这样量级的顺序推理更加困难。

另一件需要注意的事是:为了将第 2,3,4 步链接在一起使他们相继发生,回调独自给我们的启示是将第 2 步硬编码在第 1 步中,将第 3 步硬编码在第 2 步中,将第 4 步硬编码在第 3 步中,如此继续。硬编码不一定是一件坏事,如果第 2 步应当总是在第 3 步之前真的是一个固定条件。

不过硬编码绝对会使代码变得更脆弱,因为它不考虑任何可能使在步骤前行的过程中出现偏差的异常情况。举个例子,如果第 2 步失败了,第 3 步永远不会到达,第 2 步也不会重试,或者移动到一个错误处理流程上,等等。

所有这些问题你都 可以 手动硬编码在每一步中,但那样的代码总是重复性的,而且不能在其他步骤或你程序的其他异步流程中复用。

即便我们的大脑可能以顺序的方式规划一系列任务(这个,然后这个,然后这个),但我们大脑运行的事件的性质,使恢复/重试/分流这样的流程控制几乎毫不费力。如果你出去购物,而且你发现你把购物单忘在家里了,这并不会因为你没有提前计划这种情况而结束这一天。你的大脑会很容易地绕过这个小问题:你回家,取购物单,然后回头去商店。

但是手动硬编码的回调(甚至带有硬编码的错误处理)的脆弱本性通常不那么优雅。一旦你最终指明了(也就是提前规划好了)所有各种可能性/路径,代码就会变得如此复杂以至于几乎不能维护或更新。

才是“回调地狱”想表达的!嵌套/缩进基本上一个余兴表演,转移注意力的东西。

如果以上这些还不够,我们还没有触及两个或更多这些回调延续的链条 同时 发生会怎么样,或者当第三步分叉称为带有大门或门闩的“并行”回调,或者……我的天哪,我脑子疼,你呢?

你抓住这里的重点了吗?我们顺序的,阻塞的大脑规划行为和面向回调的异步代码不能很好地匹配。这就是需要清楚地阐明的关于回调的首要缺陷:它们在代码中表达异步的方式,是需要我们的大脑不得不斗争才能保持一致的。

信任问题

在顺序的大脑规划和 JS 代码中回调驱动的异步处理间的不匹配只是关于回调的问题的一部分。还有一些更深刻的问题值得担忧。

让我们再一次重温这个概念——回调函数是我们程序的延续(也就是程序的第二部分):

// A
ajax( "..", function(..){
    // C
} );
// B

// A// B现在 发生,在 JS 主程序的直接控制之下。但是// C被推迟到 稍后 再发生,并且在另一部分的控制之下——这里是ajax(..)函数。在基本的感觉上,这样的控制交接一般不会让程序产生很多问题。

但是不要被这种控制切换不是什么大事的罕见情况欺骗了。事实上,它是回调驱动的设计的最可怕的(也是最微妙的)问题。这个问题围绕着一个想法展开:有时ajax(..)(或者说你向之提交回调的部分)不是你写的函数,或者不是你可以直接控制的函数。很多时候它是一个由第三方提供的工具。

当你把你程序的一部分拿出来并把它执行的控制权移交给另一个第三方时,我们称这种情况为“控制倒转”。在你的代码和第三方工具之间有一个没有明言的“契约”——一组你期望被维护的东西。

五个回调的故事

为什么这件事情很重要可能不是那么明显。让我们来构建一个夸张的场景来生动地描绘一下信任危机。

想象你是一个开发者,正在建造一个贩卖昂贵电视的网站的结算系统。你已经将结算系统的各种页面顺利地制造完成。在最后一个页面,当用户点解“确定”购买电视时,你需要调用一个第三方函数(假如由一个跟踪分析公司提供),以便使这笔交易能够被追踪。

你注意到它们提供的是某种异步追踪工具,也许是为了最佳的性能,这意味着你需要传递一个回调函数。在你传入的这个程序的延续中,有你最后的代码——划客人的信用卡并显示一个感谢页面。

这段代码可能看起来像这样:

analytics.trackPurchase( purchaseData, function(){
    chargeCreditCard();
    displayThankyouPage();
} );

足够简单,对吧?你写好代码,测试它,一切正常,然后你把它部署到生产环境。大家都很开心!

6 个月过去了,没有任何问题。你几乎已经忘了你曾写过的代码。一天早上,工作之前你先在咖啡店坐坐,悠闲地享用着你的拿铁,直到你接到老板慌张的电话要求你立即扔掉咖啡并冲进办公室。

当你到达时,你发现一位高端客户为了买同一台电视信用卡被划了 5 次,而且可以理解,他不高兴。客服已经道了歉并开始办理退款。但你的老板要求知道这是怎么发生的。“我们没有测试过这样的情况吗!?”

你甚至不记得你写过的代码了。但你还是往回挖掘试着找出是什么出错了。

在分析过一些日志之后,你得出的结论是,唯一的解释是分析工具不知怎么的,由于某些原因,将你的回调函数调用了 5 次而非一次。他们的文档中没有任何东西提到此事。

十分令人沮丧,你联系了客户支持,当然他们和你一样惊讶。他们同意将此事向上提交至开发者,并许诺给你回复。第二天,你收到一封很长的邮件解释他们发现了什么,然后你将它转发给了你的老板。

看起来,分析公司的开发者曾经制作了一些实验性的代码,在一定条件下,将会每秒重试一次收到的回调,在超时之前共计 5 秒。他们从没想要把这部分推到生产环境,但不知怎地他们这样做了,而且他们感到十分难堪而且抱歉。然后是许多他们如何定位错误的细节,和他们将要如何做以保证此事不再发生。等等,等等。

后来呢?

你找你的老板谈了此事,但是他对事情的状态不是感觉特别舒服。他坚持,而且你也勉强地同意,你不能再相信 他们 了(咬到你的东西),而你将需要指出如何保护放出的代码,使它们不再受这样的漏洞威胁。

修修补补之后,你实现了一些如下的特殊逻辑代码,团队中的每个人看起来都挺喜欢:

var tracked = false;

analytics.trackPurchase( purchaseData, function(){
    if (!tracked) {
        tracked = true;
        chargeCreditCard();
        displayThankyouPage();
    }
} );

注意: 对读过第一章的你来说这应当很熟悉,因为我们实质上创建了一个门闩来处理我们的回调被并发调用多次的情况。

但一个 QA 的工程师问,“如果他们没调你的回调怎么办?” 噢。谁也没想过。

你开始布下天罗地网,考虑在他们调用你的回调时所有出错的可能性。这里是你得到的分析工具可能不正常运行的方式的大致列表:

  • 调用回调过早(在它开始追踪之前)
  • 调用回调过晚 (或不调)
  • 调用回调太少或太多次(就像你遇到的问题!)
  • 没能向你的回调传递必要的环境/参数
  • 吞掉了可能发生的错误/异常
  • ...

这感觉像是一个麻烦清单,因为它就是。你可能慢慢开始理解,你将要不得不为 每一个传递到你不能信任的工具中的回调 都创造一大堆的特殊逻辑。

现在你更全面地理解了“回调地狱”有多地狱。

不仅是其他人的代码

现在有些人可能会怀疑事情到底是不是如我所宣扬的这么大条。也许你根本就不和真正的第三方工具互动。也许你用的是进行了版本控制的 API,或者自己保管的库,因此它的行为不会在你不知晓的情况下改变。

那么,好好思考这个问题:你能 真正 信任你理论上控制(在你的代码库中)的工具吗?

这样考虑:我们大多数人都同意,至少在某个区间内我们应当带着一些防御性的输入参数检查制造我们自己的内部函数,来减少/防止以外的问题。

过于相信输入:

function addNumbers(x,y) {
    // + 操作符使用强制转换重载为字符串连接
    // 所以根据传入参数的不同,这个操作不是严格的安全。
    return x + y;
}

addNumbers( 21, 21 );    // 42
addNumbers( 21, "21" );    // "2121"

防御不信任的输入:

function addNumbers(x,y) {
    // 保证数字输入
    if (typeof x != "number" || typeof y != "number") {
        throw Error( "Bad parameters" );
    }

    // 如果我们到达这里,+ 就可以安全地做数字加法
    return x + y;
}

addNumbers( 21, 21 );    // 42
addNumbers( 21, "21" );    // Error: "Bad parameters"

或者也许依然安全但更友好:

function addNumbers(x,y) {
    // 保证数字输入
    x = Number( x );
    y = Number( y );

    // + 将会安全地执行数字加法
    return x + y;
}

addNumbers( 21, 21 );    // 42
addNumbers( 21, "21" );    // 42

不管你怎么做,这类函数参数的检查/规范化是相当常见的,即便是我们理论上完全信任的代码。用一个粗俗的说法,编程好像是地缘政治学的“信任但验证”原则的等价物。

那么,这不是要推论出我们应当对异步函数回调的编写做相同的事,而且不仅是针对真正的外部代码,甚至要对一般认为是“在我们控制之下”的代码?我们当然应该。

但是回调没有给我们提供任何协助。我们不得不自己构建所有的装置,而且这通常最终成为许多我们要在每个异步回调中重复的模板/负担。

有关于回调的最麻烦的问题就是 控制反转 导致所有这些信任完全崩溃。

如果你有代码用到回调,特别是但不特指第三方工具,而且你还没有为所有这些 控制反转 的信任问题实施某些缓和逻辑,那么你的代码现在就 bug,虽然它们还没咬到你。将来的 bug 依然是 bug。

确实是地狱。

尝试拯救回调

有几种回调的设计试图解决一些(不是全部!)我们刚才看到的信任问题。这是一种将回调模式从它自己的崩溃中拯救出来的勇敢,但注定失败的努力。

举个例子,为了更平静地处理错误,有些 API 设计提供了分离的回调(一个用作成功的通知,一个用作错误的通知):

function success(data) {
    console.log( data );
}

function failure(err) {
    console.error( err );
}

ajax( "http://some.url.1", success, failure );

在这种设计的 API 中,failure()错误处理器通常是可选的,而且如果不提供的话它会假定你想让错误被吞掉。呃。

注意: ES6 的 Promises 的 API 使用的就是这种分离回调设计。我们将在下一章中详尽地讨论 ES6 的 Promises。

另一种常见的回调设计模式称为“错误优先风格”(有时称为“Node 风格”,因为它几乎在所有的 Node.js 的 API 中作为惯例使用),一个回调的第一个参数为一个错误对象保留(如果有的话)。如果成功,这个参数将会是空/falsy(而其他后续的参数将是成功的数据),但如果出现了错误的结果,这第一个参数就会被设置/truthy(而且通常没有其他东西会被传递了):

function response(err,data) {
    // 有错?
    if (err) {
        console.error( err );
    }
    // 否则,认为成功
    else {
        console.log( data );
    }
}

ajax( "http://some.url.1", response );

这两种方法都有几件事情应当注意。

首先,它们没有像看起来那样真正解决主要的信任问题。在这两个回调中没有关于防止或过滤意外的重复调用的东西。而且,事情现在更糟糕了,因为你可能同时得到成功和失败信号,或者都得不到,你仍然不得不围绕着这两种情况写代码。

还有,不要忘了这样的事实:虽然它们是你可以引用的标准模式,但它们绝对更加繁冗,而且是不太可能复用的模板代码,所以你将会对在你应用程序的每一个回调中敲出它们感到厌倦。

回调从不被调用的信任问题怎么解决?如果这要紧(而且它可能应当要紧!),你可能需要设置一个超时来取消事件。你可以制作一个工具来帮你:

function timeoutify(fn,delay) {
    var intv = setTimeout( function(){
            intv = null;
            fn( new Error( "Timeout!" ) );
        }, delay )
    ;

    return function() {
        // 超时还没有发生?
        if (intv) {
            clearTimeout( intv );
            fn.apply( this, [ null ].concat( [].slice.call( arguments ) ) );
        }
    };
}

这是你如何使用它:

// 使用“错误优先”风格的回调设计
function foo(err,data) {
    if (err) {
        console.error( err );
    }
    else {
        console.log( data );
    }
}

ajax( "http://some.url.1", timeoutify( foo, 500 ) );

另一个信任问题是被调用的“过早”。在应用程序规范上讲,这可能涉及在某些重要的任务完成之前被调用。但更一般地,在那些即可以 现在(同步地),也可以在 稍后(异步地)调用你提供的回调的工具中这个问题更明显。

这种围绕着同步或异步行为的不确定性,几乎总是导致非常难追踪的 Bug。在某些圈子中,一个名叫 Zalgo 的可以导致人精神错乱的虚构怪物被用来描述这种同步/异步的噩梦。经常能听到人们喊“别放出 Zalgo!”,而且它引出了一个非常响亮的建议:总是异步地调用回调,即便它是“立即”在事件轮询的下一个迭代中,这样所有的回调都是可预见的异步。

注意: 更多关于 Zalgo 的信息,参见 Oren Golan 的“Don't Release Zalgo!(不要释放 Zalgo!)”(github.com/oren/oren.github.io/blob/master/posts/zalgo.md)和 Isaac%E5%92%8CIsaac) Z. Schlueter 的“Designing APIs for Asynchrony(异步 API 设计)”(blog.izs.me/post/59142742143/designing-apis-for-asynchrony)。%E3%80%82)

考虑下面的代码:

function result(data) {
    console.log( a );
}

var a = 0;

ajax( "..pre-cached-url..", result );
a++;

这段代码是打印0(同步回调调用)还是打印1(异步回调调用)?这……要看情况。

你可以看到 Zalgo 的不可预见性能有多快地威胁你的 JS 程序。所以听起来傻呼呼的“别放出 Zalgo”实际上是一个不可思议地常见且实在的建议——总是保持异步。

如果你不知道当前的 API 是否会总是异步地执行呢?你可以制造一个像asyncify(..)这样的工具:

function asyncify(fn) {
    var orig_fn = fn,
        intv = setTimeout( function(){
            intv = null;
            if (fn) fn();
        }, 0 )
    ;

    fn = null;

    return function() {
        // 触发太快,在`intv`计时器触发来
        // 表示异步回合已经过去之前?
        if (intv) {
            fn = orig_fn.bind.apply(
                orig_fn,
                // 将包装函数的`this`加入`bind(..)`调用的
                // 参数,同时 currying 其他所有的传入参数
                [this].concat( [].slice.call( arguments ) )
            );
        }
        // 已经是异步
        else {
            // 调用原版的函数
            orig_fn.apply( this, arguments );
        }
    };
}

你像这样使用asyncify(..):

function result(data) {
    console.log( a );
}

var a = 0;

ajax( "..pre-cached-url..", asyncify( result ) );
a++;

不管 Ajax 请求是由于存在于缓存中而解析为立即调用回调,还是它必须走过网线去取得数据而异步地稍后完成,这段代码总是输出1而不是0——result(..)总是被异步地调用,这意味着a++有机会在result(..)之前运行。

噢耶,又一个信任问题被“解决了”!但它很低效,而且又有更多臃肿的模板代码让你的项目变得沉重。

这只是关于回调一遍又一遍地发生的故事。它们几乎可以做任何你想做的事,但你不得不努力工作来达到目的,而且大多数时候这种努力比你应当在推理这样的代码上所付出的多得多。

你可能发现自己希望有一些内建的 API 或语言机制来解决这些问题。终于 ES6 带着一个伟大的答案到来了,所以继续读下去!

复习

回调是 JS 中异步的基础单位。但是随着 JS 的成熟,它们对于异步编程的演化趋势来讲显得不够。

首先,我们的大脑用顺序的,阻塞的,单线程的语义方式规划事情,但是回调使用非线性,非顺序的方式表达异步流程,这使我们正确推理这样的代码变得非常困难。不好推理的代码是导致不好的 Bug 的不好的代码。

我们需要一个种方法,以更同步化,顺序化,阻塞的方式来表达异步,正如我们的大脑那样。

第二,而且是更重要的,回调遭受着 控制反转 的蹂躏,它们隐含地将控制权交给第三方(通常第三方工具不受你控制!)来调用你程序的 延续。这种控制权的转移使我们得到一张信任问题的令人不安的列表,比如回调是否会比我们期望的被调用更多次。

制造特殊的逻辑来解决这些信任问题是可能的,但是它比它应有的难度高多了,还会产生更笨重和更难维护的代码,而且在 bug 实际咬到你的时候代码会显得在这些危险上被保护的不够。

我们需要一个 所有这些信任问题 的一般化解决方案。一个可以被所有我们制造的回调复用,而且没有多余的模板代码负担的方案。

我们需要比回调更好的东西。目前为止它们做的不错,但 JavaScript 的 未来 要求更精巧和强大的异步模式。本书的后续章节将会深入这些新兴的发展变化。

你不懂 JS: 异步与性能 第三章: Promise(上)

在第二章中,我们定位了在使用回调表达程序异步性和管理并发的两个主要类别的不足:缺乏顺序性和缺乏可靠性。现在我们更亲近地理解了问题,是时候将我们的注意力转向解决它们的模式了。

我们首先想要解决的是 控制倒转 问题,信任是如此脆弱而且是如此的容易丢失。

回想一下,我们将我们的程序的延续包装进一个回调函数中,将这个回调交给另一个团体(甚至是潜在的外部代码),并双手合十祈祷它会做正确的事情并调用这个回调。

我们这么做是因为我们想说,“这是 稍后 将要发生的事,在当前的步骤完成之后。”

但是如果我们能够反向倒转这种 控制倒转 呢?如果不是将我们程序的延续交给另一个团体,而是希望它返回给我们一个可以知道它何时完成的能力,然后我们的代码可以决定下一步做什么呢?

这种规范被称为 Promise

Promise 正在像风暴一样席卷 JS 世界,因为开发者和语言规范作者之流拼命地想要在他们的代码/设计中结束回调地狱的疯狂。事实上,大多数新被加入 JS/DOM 平台的异步 API 都是建立在 Promise 之上的。所以深入学习它们可能是个好主意,你不这么认为吗?

注意: “立即”这个词将在本章频繁使用,一般来说它指代一些 Promise 解析行为。然而,本质上在所有情况下,“立即”意味着就工作队列行为(参见第一章)而言,不是严格同步的 现在 的感觉。

什么是 Promise?

当开发者们决定要学习一种新技术或模式的时候,他们的第一步总是“给我看代码!”。摸着石头过河对我们来讲是十分自然的。

但事实上仅仅考察 API 丢失了一些抽象过程。Promise 是这样一种工具:它能非常明显地看出使用者是否理解了它是为什么和关于什么,还是仅仅学习和使用 API。

所以在我展示 Promise 的代码之前,我想在概念上完整地解释一下 Promise 到底是什么。我希望这能更好地指引你探索如何将 Promise 理论整合到你自己的异步流程中。

带着这样的想法,让我们来看两种类比,来解释 Promise 是什么。

未来的值

想象这样的场景:我走到快餐店的柜台前,点了一个起士汉堡。并交了 1.47 美元的现金。通过点餐和付款,我为得到一个 (起士汉堡)制造了一个请求。我发起了一个事务。

但是通常来说,起士汉堡不会立即到我手中。收银员交给一些东西代替我的起士汉堡:一个带有点餐排队号的收据。这个点餐号是一个“我欠你”的许诺(Promise),它保证我最终会得到我的起士汉堡。

于是我就拿着我的收据和点餐号。我知道它代表我的 未来的起士汉堡,所以我无需再担心它——除了挨饿!

在我等待的时候,我可以做其他的事情,比如给我的朋友发微信说,“嘿,一块儿吃午餐吗?我要吃起士汉堡”。

我已经在用我的 未来的起士汉堡 进行推理了,即便它还没有到我手中。我的大脑可以这么做是因为它将点餐号作为起士汉堡的占位符号。这个占位符号实质上使这个值 与时间无关。它是一个 未来的值

最终,我听到,“113 号!”。于是我愉快地拿着收据走回柜台前。我把收据递给收银员,拿回我的起士汉堡。

换句话说,一旦我的 未来的值 准备好,我就用我的许诺值换回值本身。

但还有另外一种可能的输出。它们叫我的号,但当我去取起士汉堡时,收银员遗憾地告诉我,“对不起,看起来我们的起士汉堡卖光了。”把这种场景下顾客有多沮丧放在一边,我们可以看到 未来的值 的一个重要性质:它们既可以表示成功也可以表示失败。

每次我点起士汉堡时,我都知道我要么最终得到一个起士汉堡,要么得到起士汉堡卖光的坏消息,并且不得不考虑中午吃点儿别的东西。

注意: 在代码中,事情没有这么简单,因为还隐含着一种点餐号永远也不会被叫到的情况,这时我们就被搁置在了一种无限等待的未解析状态。我们待会儿再回头处理这种情况。

现在和稍后的值

这一切也许听起来在思维上太过抽象而不能实施在你的代码中。那么,让我们更具体一些。

然而,在我们能介绍 Promise 是如何以这种方式工作之前,我们先看看我们已经明白的代码——回调!——是如何处理这些 未来值 的。

在你写代码来推导一个值时,比如在一个number上进行数学操作,不论你是否理解,对于这个值你已经假设了某些非常基础的事实——这个值已经是一个实在的 现在 值:

var x, y = 2;

console.log( x + y ); // NaN  <-- 因为`x`还没有被赋值

x + y操作假定xy都已经被设定好了。用我们一会将要阐述的术语来讲,我们假定xy的值已经被 解析(resovle) 了。

期盼+操作符本身能够魔法般地检测并等待xy的值被解析(也就是准备好),然后仅在那之后才进行操作是没道理的。如果不同的语句 现在 完成而其他的 稍后 完成,这就会在程序中造成混乱,对吧?

如果两个语句中的一个(或两者同时)可能还没有完成,你如何才能推断它们的关系呢?如果语句 2 要依赖语句 1 的完成,那么这里仅有两种输出:不是语句 1 现在 立即完成而且一切处理正常进行,就是语句 1 还没有完成,所以语句 2 将会失败。

如果这些东西听起来很像第一章的内容,很好!

回到我们的x + y的数学操作。想象有一种方法可以说,“将xy相加,但如果它们中任意一个还没有被设置,就等到它们都被设置。尽快将它们相加。”

你的大脑也许刚刚跳进回调。好吧,那么...

function add(getX,getY,cb) {
    var x, y;
    getX( function(xVal){
        x = xVal;
        // 两者都准备好了?
        if (y != undefined) {
            cb( x + y );    // 发送加法的结果
        }
    } );
    getY( function(yVal){
        y = yVal;
        // 两者都准备好了?
        if (x != undefined) {
            cb( x + y );    // 发送加法的结果
        }
    } );
}

// `fetchX()`和`fetchY()`是同步或异步的函数
add( fetchX, fetchY, function(sum){
    console.log( sum ); // 很简单吧?
} );

花点儿时间来感受一下这段代码的美妙(或者丑陋),我耐心地等你。

虽然丑陋是无法否认的,但是关于这种异步模式有一些非常重要的事情需要注意。

在这段代码中,我们将xy作为未来的值对待,我们将add(..)操作表达为:(从外部看来)它并不关心xy或它们两者现在是否可用。换句话所,它泛化了 现在稍后,如此我们可以信赖add(..)操作的一个可预测的结果。

通过使用一个临时一致的add(..)——它跨越 现在稍后 的行为是相同的——异步代码的推理变得容易的多了。

更直白地说:为了一致地处理 现在稍后,我们将它们都作为 稍后:所有的操作都变成异步的。

当然,这种粗略的基于回调的方法留下了许多提升的空间。为了理解在不用关心 未来的值 在时间上什么时候变得可用的情况下推理它而带来的好处,这仅仅是迈出的一小步。

Promise 值

我们绝对会在本章的后面深入更多关于 Promise 的细节——所以如果这让你犯糊涂,不要担心——但让我们先简单地看一下我们如何通过Promise来表达x + y的例子:

function add(xPromise,yPromise) {
    // `Promise.all([ .. ])`接收一个 Promise 的数组,
    // 并返回一个等待它们全部完成的新 Promise
    return Promise.all( [xPromise, yPromise] )

    // 当这个 Promise 被解析后,我们拿起收到的`X`和`Y`的值,并把它们相加
    .then( function(values){
        // `values`是一个从先前被解析的 Promise 那里收到的消息数组
        return values[0] + values[1];
    } );
}

// `fetchX()`和`fetchY()`分别为它们的值返回一个 Promise,
// 这些值可能在 *现在* 或 *稍后* 准备好
add( fetchX(), fetchY() )

// 为了将两个数字相加,我们得到一个 Promise。
// 现在我们链式地调用`then(..)`来等待返回的 Promise 被解析
.then( function(sum){
    console.log( sum ); // 这容易多了!
} );

在这个代码段中有两层 Promise。

fetchX()fetchY()被直接调用,它们的返回值(promise!)被传入add(..)。这些 promise 表示的值将在 现在稍后 准备好,但是每个 promise 都将行为泛化为与时间无关。我们以一种时间无关的方式来推理XY的值。它们是 未来值

第二层是由add(..)创建(通过Promise.all([ .. ]))并返回的 promise,我们通过调用then(..)来等待它。当add(..)操作完成后,我们的sum未来值 就准备好并可以打印了。我们将等待XY未来值 的逻辑隐藏在add(..)内部。

注意:add(..)内部。Promise.all([ .. ])调用创建了一个 promise(它在等待promiseXpromiseY被解析)。链式调用.then(..)创建了另一个 promise,它的return values[0] + values[1]这一行会被立即解析(使用加法的结果)。这样,我们链接在add(..)调用末尾的then(..)调用——在代码段最后——实际上是在第二个被返回的 promise 上进行操作,而非被Promise.all([ .. ])创建的第一个 promise。另外,虽然我们没有在这第二个then(..)的末尾链接任何操作,它也已经创建了另一个 promise,我们可以选择监听/使用它。这类 Promise 链的细节将会在本章后面进行讲解。

就像点一个起士汉堡,Promise 的解析可能是一个拒绝(rejection)而非完成(fulfillment)。不同的是,被完成的 Promise 的值总是程序化的,而一个拒绝值——通常被称为“拒绝理由”——既可以被程序逻辑设置,也可以被运行时异常隐含地设置。

使用 Promise,then(..)调用实际上可以接受两个函数,第一个用作完成(正如刚才所示),而第二个用作拒绝:

add( fetchX(), fetchY() )
.then(
    // 完成处理器
    function(sum) {
        console.log( sum );
    },
    // 拒绝处理器
    function(err) {
        console.error( err ); // 倒霉!
    }
);

如果在取得XY时出现了错误,或在加法操作时某些事情不知怎地失败了,add(..)返回的 promise 就被拒绝了,传入then(..)的第二个错误处理回调函数会从 promise 那里收到拒绝的值。

因为 Promise 包装了时间相关的状态——等待当前值的完成或拒绝——从外部看来,Promise 本身是时间无关的,如此 Promise 就可以用可预测的方式组合,而不用关心时间或底层的结果。

另外,一旦 Promise 被解析,它就永远保持那个状态——它在那个时刻变成了一个 不可变的值——而且可以根据需要 被监听 任意多次。

注意: 因为 Promise 一旦被解析就是外部不可变的,所以现在将这个值传递给任何其他团体都是安全的,而且我们知道它不会被意外或恶意地被修改。这在许多团体监听同一个 Promise 的解析时特别有用。一个团体去影响另一个团体对 Promise 解析的监听能力是不可能的。不可变性听起来是一个学院派话题,但它实际上是 Promise 设计中最基础且最重要的方面之一,因此不能将它随意地跳过。

这是用于理解 Promise 的最强大且最重要的概念之一。通过大量的工作,你可以仅仅使用丑陋的回调组合来创建相同的效果,但这真的不是一个高效的策略,特别是你不得不一遍一遍地重复它。

Promise 是一种用来包装与组合 未来值,并且可以很容易复用的机制。

完成事件

正如我们刚才看到的,一个独立的 Promise 作为一个 未来值 动作。但还有另外一种方式考虑 Promise 的解析:在一个异步任务的两个或以上步骤中,作为一种流程控制机制——俗称“这个然后那个”。

让我们想象调用foo(..)来执行某个任务。我们对它的细节一无所知,我们也不关心。它可能会立即完成任务,也可能会花一段时间完成。

我们仅仅想简单地知道foo(..)什么时候完成,以便于我们可以移动到下一个任务。换句话说,我们想要一种方法被告知foo(..)的完成,以便于我们可以 继续

在典型的 JavaScript 风格中,如果你需要监听一个通知,你很可能会想到事件(event)。那么我们可以将我们的通知需求重新表述为,监听由foo(..)发出的 完成(或 继续)事件。

注意: 将它称为一个“完成事件”还是一个“继续事件”取决于你的角度。你是更关心foo(..)发生的事情,还是更关心foo(..)完成 之后 发生的事情?两种角度都对而且都有用。事件通知告诉我们foo(..)已经 完成,但是 继续 到下一个步骤也没问题。的确,你为了事件通知调用而传入的回调函数本身,在前面我们称它为一个 延续。因为 完成事件 更加聚焦于foo(..),也就是我们当前注意的东西,所以在这篇文章的其余部分我们稍稍偏向于使用 完成事件

使用回调,“通知”就是被任务(foo(..))调用的我们的回调函数。但是使用 Promise,我们将关系扭转过来,我们希望能够监听一个来自于foo(..)的事件,当我们被通知时,做相应的处理。

首先,考虑一些假想代码:

foo(x) {
    // 开始做一些可能会花一段时间的事情
}

foo( 42 )

on (foo "completion") {
    // 现在我们可以做下一步了!
}

on (foo "error") {
    // 噢,在`foo(..)`中有某些事情搞错了
}

我们调用foo(..)然后我们设置两个事件监听器,一个给"completion",一个给"error"——foo(..)调用的两种可能的最终结果。实质上,foo(..)甚至不知道调用它的代码监听了这些事件,这构成了一个非常美妙的 关注分离(separation of concerns)

不幸的是,这样的代码将需要 JS 环境不具备的一些“魔法”(而且显得有些不切实际)。这里是一种用 JS 表达它的更自然的方式:

function foo(x) {
    // 开始做一些可能会花一段时间的事情

    // 制造一个`listener`事件通知能力并返回

    return listener;
}

var evt = foo( 42 );

evt.on( "completion", function(){
    // 现在我们可以做下一步了!
} );

evt.on( "failure", function(err){
    // 噢,在`foo(..)`中有某些事情搞错了
} );

foo(..)明确地创建并返回了一个事件监听能力,调用方代码接收并在它上面注册了两个事件监听器。

很明显这反转了一般的面向回调代码,而且是有意为之。与将回调传入foo(..)相反,它返回一个我们称之为ent的事件能力,它接收回调。

但如果你回想第二章,回调本身代表着一种 控制反转。所以反转回调模式实际上是 反转的反转,或者说是一个 控制非反转——将控制权归还给我们希望保持它的调用方代码,

一个重要的好处是,代码的多个分离部分都可以被赋予事件监听能力,而且它们都可在foo(..)完成时被独立地通知,来执行后续的步骤:

var evt = foo( 42 );

// 让`bar(..)`监听`foo(..)`的完成
bar( evt );

// 同时,让`baz(..)`监听`foo(..)`的完成
baz( evt );

控制非反转 导致了更好的 关注分离,也就是bar(..)baz(..)不必卷入foo(..)是如何被调用的问题。相似地,foo(..)也不必知道或关心bar(..)baz(..)的存在或它们是否在等待foo(..)完成的通知。

实质上,这个evt对象是一个中立的第三方团体,在分离的关注点之间进行交涉。

Promise“事件”

正如你可能已经猜到的,evt事件监听能力是一个 Promise 的类比。

在一个基于 Promise 的方式中,前面的代码段将会使foo(..)创建并返回一个Promise实例,而且这个 promise 将会被传入bar(..)baz(..)

注意: 我们监听的 Promise 解析“事件”并不是严格的事件(虽然它们为了某些目的表现得像事件),而且它们也不被典型地称为"completion""error"。相反,我们用then(..)来注册一个"then"事件。或者也许更准确地讲,then(..)注册了"fulfillment(完成)"和/或"rejection(拒绝)"事件,虽然我们在代码中不会看到这些名词被明确地使用。

考虑:

function foo(x) {
    // 开始做一些可能会花一段时间的事情

    // 构建并返回一个 promise
    return new Promise( function(resolve,reject){
        // 最终需要调用`resolve(..)`或`reject(..)`
        // 它们是这个 promise 的解析回调
    } );
}

var p = foo( 42 );

bar( p );

baz( p );

注意:new Promise( function(..){ .. } )中展示的模式通常被称为“揭示构造器(revealing constructor)”。被传入的函数被立即执行(不会被异步推迟,像then(..)的回调那样),而且它被提供了两个参数,我们叫它们resolvereject。这些是 Promise 的解析函数。resolve(..)一般表示完成,而reject(..)表示拒绝。

你可能猜到了bar(..)baz(..)的内部看起来是什么样子:

function bar(fooPromise) {
    // 监听`foo(..)`的完成
    fooPromise.then(
        function(){
            // `foo(..)`现在完成了,那么做`bar(..)`的任务
        },
        function(){
            // 噢,在`foo(..)`中有某些事情搞错了
        }
    );
}

// `baz(..)`同上

Promise 解析没有必要一定发送消息,就像我们将 Promise 作为 未来值 考察时那样。它可以仅仅作为一种流程控制信号,就像前面的代码中那样使用。

另一种表达方式是:

function bar() {
    // `foo(..)`绝对已经完成了,那么做`bar(..)`的任务
}

function oopsBar() {
    // 噢,在`foo(..)`中有某些事情搞错了,那么`bar(..)`不会运行
}

// `baz()`和`oopsBaz()`同上

var p = foo( 42 );

p.then( bar, oopsBar );

p.then( baz, oopsBaz );

注意: 如果你以前见过基于 Promise 的代码,你可能会相信这段代码的最后两行应当写做p.then( .. ).then( .. ),使用链接,而不是p.then(..); p.then(..)。这将会是两种完全不同的行为,所以要小心!这种区别现在看起来可能不明显,但是它们实际上是我们目前还没有见过的异步模式:分割(splitting)/分叉(forking)。不必担心!本章后面我们会回到这个话题。

与将ppromise 传入bar(..)baz(..)相反,我们使用 promise 来控制bar(..)baz(..)何时该运行,如果有这样的时刻。主要区别在于错误处理。

在第一个代码段的方式中,无论foo(..)是否成功bar(..)都会被调用,如果被通知foo(..)失败了的话它提供自己的后备逻辑。显然,baz(..)也是这样做的。

在第二个代码段中,bar(..)仅在foo(..)成功后才被调用,否则oopsBar(..)会被调用。baz(..)也是。

两种方式本身都 。但会有一些情况使一种优于另一种。

在这两种方式中,从foo(..)返回的 promisep都被用于控制下一步发生什么。

另外,两个代码段都以对同一个 promisep调用两次then(..)结束,这展示了先前的观点,也就是 Promise(一旦被解析)会永远保持相同的解析结果(完成或拒绝),而且可以按需要后续地被监听任意多次。

无论何时p被解析,下一步都将总是相同的,包括 现在稍后

Thenable 鸭子类型(Duck Typing)

在 Promise 的世界中,一个重要的细节是如何确定一个值是否是纯粹的 Promise。或者更直接地说,一个值会不会像 Promise 那样动作?

我们知道 Promise 是由new Promise(..)语法构建的,你可能会想p instanceof Promise将是一个可以接受的检查。但不幸的是,有几个理由表明它不是完全够用。

主要原因是,你可以从其他浏览器窗口中收到 Promise 值(iframe 等),其他的浏览器窗口会拥有自己的不同于当前窗口/frame 的 Promise,这种检查将会在定位 Promise 实例时失效。

另外,一个库或框架可能会选择实现自己的 Promise 而不是用 ES6 原生的Promise实现。事实上,你很可能在根本没有 Promise 的老版本浏览器中通过一个库来使用 Promise。

当我们在本章稍后讨论 Promise 的解析过程时,为什么识别并同化一个非纯种但相似 Promise 的值仍然很重要会愈发明显。但目前只需要相信我,它是拼图中很重要的一块。

如此,人们决定识别一个 Promise(或像 Promise 一样动作的某些东西)的方法是定义一种称为“thenable”的东西,也就是任何拥有then(..)方法的对象或函数。这种方法假定任何这样的值都是一个符合 Promise 的 thenable。

根据值的形状(存在什么属性)来推测它的“类型”的“类型检查”有一个一般的名称,称为“鸭子类型检查”——“如果它看起来像一只鸭子,并且叫起来相一致鸭子,那么它一定是一只鸭子”(参见本丛书的 类型与文法)。所以对 thenable 的鸭子类型检查可能大致是这样:

if (
    p !== null &&
    (
        typeof p === "object" ||
        typeof p === "function"
    ) &&
    typeof p.then === "function"
) {
    // 认为它是一个 thenable!
}
else {
    // 不是一个 thenable
}

晕!先把将这种逻辑在各种地方实现有点丑陋的事实放在一边不谈,这里还有更多更深层的麻烦。

如果你试着用一个偶然拥有then(..)函数的任意对象/函数来完成一个 Promise,但你又没想把它当做一个 Promise/thenable 来对待,你的运气就用光了,因为它会被自动地识别为一个 thenable 并以特殊的规则来对待(见本章后面的部分)。

如果你不知道一个值上面拥有then(..)就更是这样。比如:

var o = { then: function(){} };

// 使`v`用`[[Prototype]]`链接到`o`
var v = Object.create( o );

v.someStuff = "cool";
v.otherStuff = "not so cool";

v.hasOwnProperty( "then" );        // false

v看起来根本不像是一个 Promise 或 thanable。它只是一个拥有一些属性的直白的对象。你可能只是想要把这个值像其他对象那样传递而已。

但你不知道的是,v[[Prototype]]连接着(见本丛书的 this 与对象原型)另一个对象o,在它上面偶然拥有一个then(..)。所以 thenable 鸭子类型检查将会认为并假定v是一个 thenable。噢。

它甚至不需要直接故意那么做:

Object.prototype.then = function(){};
Array.prototype.then = function(){};

var v1 = { hello: "world" };
var v2 = [ "Hello", "World" ];

v1v2都将被假定为是 thenalbe 的。你不能控制或预测是否有其他代码偶然或恶意地将then(..)加到Object.prototypeArray.prototype,或其他任何原生原型上。而且如果这个指定的函数并不将它的任何参数作为回调调用,那么任何用这样的值被解析的 Promise 都将无声地永远挂起!疯狂。

听起来难以置信或不太可能?也许。

要知道,在 ES6 之前就有几种广为人知的非 Promise 库在社区中存在了,而且它们已经偶然拥有了称为then(..)的方法。这些库中的一些选择了重命名它们自己的方法来回避冲突(这很烂!)。另一些则因为它们无法改变来回避冲突,简单地降级为“不兼容基于 Promise 的代码”的不幸状态。

用来劫持原先非保留的——而且听起来完全是通用的——then属性名称的标准决议是,没有值(或它的任何委托),无论是过去,现在,还是将来,可以拥有then(..)函数,不管是有意的还是偶然的,否则这个值将在 Promise 系统中被混淆为一个 thenable,从而可能产生非常难以追踪的 Bug。

警告: 我不喜欢我们用 thenable 的鸭子类型来结束对 Promise 认知的方式。还有其他的选项,比如“branding”或者甚至是“anti-branding”;我们得到的似乎是一个最差劲儿的妥协。但它并不全是悲观与失望。thenable 鸭子类型可以很有用,就像我们马上要看到的。只是要小心,如果 thenable 鸭子类型将不是 Promise 的东西误认为是 Promise,它就可能成为灾难。

Promise 的信任

我们已经看过了两个强烈的类比,它们解释了 Promise 可以为我们的异步代码所做的事的不同方面。但如果我们停在这里,我们就可能会错过一个 Promise 模式建立的最重要的性质:信任。

随着 未来值完成事件 的类别在我们探索的代码模式中的明确展开,有一个问题依然没有完全明确:Promise 是为什么,以及如何被设计为来解决所有我们在第二章“信任问题”一节中提出的 控制倒转 的信任问题的。但是只要深挖一点儿,我们就可以发现一些重要的保证,来重建第二章中毁掉的对异步代码的信心!

让我们从复习仅使用回调的代码中的信任问题开始。当你传递一个回调给一个工具foo(..)的时候,它可能:

  • 调用回调太早
  • 调用回调太晚(或根本不调)
  • 调用回调太少或太多次
  • 没能传递必要的环境/参数
  • 吞掉了任何可能发生的错误/异常

Promise 的性质被有意地设计为给这些顾虑提供有用的,可复用的答案。

调的太早

这种顾虑主要是代码是否会引入类 Zalgo 效应,也就是一个任务有时会同步完地成,而有时会异步地完成,这将导致竟合状态。

Promise 被定义为不能受这种顾虑的影响,因为即便是立即完成的 Promise(比如 new Promise(function(resolve){ resolve(42); }))也不可能被同步地 监听

也就是说,但你在 Promise 上调用then(..)的时候,即便这个 Promise 已经被解析了,你给then(..)提供的回调也将 总是 被异步地调用(更多关于这里的内容,参照第一章的"Jobs")。

不必再插入你自己的setTimeout(..,0)黑科技了。Promise 自动地防止了 Zalgo 效应。

调的太晚

和前一点相似,在resolve(..)reject(..)被 Promise 创建机制调用时,一个 Promise 的then(..)上注册的监听回调将自动地被排程。这些被排程好的回调将在下一个异步时刻被可预测地触发(参照第一章的"Jobs")。

同步监听是不可能的,所以不可能有一个同步的任务链的运行来“推迟”另一个回调的发生。也就是说,当一个 Promise 被解析时,所有在then(..)上注册的回调都将被立即,按顺序地,在下一个异步机会时被调用(再一次,参照第一章的"Jobs"),而且没有任何在这些回调中发生的事情可以影响/推迟其他回调的调用。

举例来说:

p.then( function(){
    p.then( function(){
        console.log( "C" );
    } );
    console.log( "A" );
} );
p.then( function(){
    console.log( "B" );
} );
// A B C

这里,有赖于 Promise 如何定义操作,"C"不可能干扰并优先于"B"

Promise 排程的怪现象

重要并需要注意的是,排程有许多微妙的地方:链接在两个分离的 Promise 上的回调之间的相对顺序,是不能可靠预测的。

如果两个 promisep1p2都准备好被解析了,那么p1.then(..); p2.then(..)应当归结为首先调用p1的回调,然后调用p2的。但有一些微妙的情形可能会使这不成立,比如下面这样:

var p3 = new Promise( function(resolve,reject){
    resolve( "B" );
} );

var p1 = new Promise( function(resolve,reject){
    resolve( p3 );
} );

var p2 = new Promise( function(resolve,reject){
    resolve( "A" );
} );

p1.then( function(v){
    console.log( v );
} );

p2.then( function(v){
    console.log( v );
} );

// A B  <-- 不是你可能期望的 B A

我们稍后会更多地讲解这个问题,但如你所见,p1不是被一个立即值所解析的,而是由另一个 promisep3所解析,而p3本身被一个值"B"所解析。这种指定的行为将p3展开p1,但是是异步地,所以在异步工作队列中p1的回调位于p2的回调之后(参照第一章的"Jobs")。

为了回避这样的微妙的噩梦,你绝不应该依靠任何跨 Promise 的回调顺序/排程。事实上,一个好的实践方式是在代码中根本不要让多个回调的顺序成为问题。尽可能回避它。

根本不调回调

这是一个很常见的顾虑。Promise 用几种方式解决它。

首先,没有任何东西(JS 错误都不能)可以阻止一个 Promise 通知你它的解析(如果它被解析了的话)。如果你在一个 Promise 上同时注册了完成和拒绝回调,而且这个 Promise 被解析了,两个回调中的一个总会被调用。

当然,如果你的回调本身有 JS 错误,你可能不会看到你期望的结果,但是回调事实上已经被调用了。我们待会儿就会讲到如何在你的回调中收到关于一个错误的通知,因为就算是它们也不会被吞掉。

那如果 Promise 本身不管怎样永远没有被解析呢?即便是这种状态 Promise 也给出了答案,使用一个称为“竞赛(race)”的高级抽象。

// 一个使 Promise 超时的工具
function timeoutPromise(delay) {
    return new Promise( function(resolve,reject){
        setTimeout( function(){
            reject( "Timeout!" );
        }, delay );
    } );
}

// 为`foo()`设置一个超时
Promise.race( [
    foo(),                    // 尝试调用`foo()`
    timeoutPromise( 3000 )    // 给它 3 秒钟
] )
.then(
    function(){
        // `foo(..)`及时地完成了!
    },
    function(err){
        // `foo()`不是被拒绝了,就是它没有及时完成
        // 那么可以考察`err`来知道是哪种情况
    }
);

这种 Promise 的超时模式有更多的细节需要考虑,但我们待会儿再回头讨论。

重要的是,我们可以确保一个信号作为foo(..)的结果,来防止它无限地挂起我们的程序。

调太少或太多次

根据定义,对于被调用的回调来讲 一次 是一个合适的次数。“太少”的情况将会是 0 次,和我们刚刚考察的从不调用是相同的。

“太多”的情况则很容易解释。Promise 被定义为只能被解析一次。如果因为某些原因,Promise 的创建代码试着调用resolve(..)reject(..)许多次,或者试着同时调用它们俩,Promise 将仅接受第一次解析,而无声地忽略后续的尝试。

因为一个 Promise 仅能被解析一次,所以任何then(..)上注册的(每个)回调将仅仅被调用一次。

当然,如果你把同一个回调注册多次(比如p.then(f); p.then(f);),那么它就会被调用注册的那么多次。响应函数仅被调用一次的保证并不能防止你砸自己的脚。

没能传入任何参数/环境

Promise 可以拥有最多一个解析值(完成或拒绝)。

如果无论怎样你没有用一个值明确地解析它,它的值就是undefined,就像 JS 中典型的那样。但不管是什么值,它总是会被传入所有被注册的(并且适当地:完成或拒绝)回调中,不管是 现在 还是将来。

需要意识到的是:如果你使用多个参数调用resolve(..)reject(..),所有第一个参数之外的后续参数都会被无声地忽略。虽然这看起来违反了我们刚才描述的保证,但并不确切,因为它构成了一种 Promise 机制的无效使用方式。其他的 API 无效使用方式(比如调用resolve(..)许多次)也都相似地 被保护,所以 Promise 的行为在这里是一致的(除了有一点点让人沮丧)。

如果你想传递多个值,你必须将它们包装在另一个单独的值中,比如一个array或一个object

至于环境,JS 中的函数总是保持他们被定义时所在作用域的闭包(见本系列的 作用域与闭包),所以它们理所当然地可以继续访问你提供的环境状态。当然,这对仅使用回调的设计来讲也是对的,所以这不能算是 Promise 带来的增益——但尽管如此,它依然是我们可以依赖的保证。

吞掉所有错误/异常

在基本的感觉上,这是前一点的重述。如果你用一个 理由(也就是错误消息)拒绝一个 Promise,这个值就会被传入拒绝回调。

但是这里有一个更重要的事情。如果在 Promise 的创建过程中的任意一点,或者在监听它的解析的过程中,一个 JS 异常错误发生的话,比如TypeErrorReferenceError,这个异常将会被捕获,并且强制当前的 Promise 变为拒绝。

举例来说:

var p = new Promise( function(resolve,reject){
    foo.bar();    // `foo`没有定义,所以这是一个错误!
    resolve( 42 );    // 永远不会跑到这里 :(
} );

p.then(
    function fulfilled(){
        // 永远不会跑到这里 :(
    },
    function rejected(err){
        // `err`将是一个来自`foo.bar()`那一行的`TypeError`异常对象
    }
);

foo.bar()上发生的 JS 异常变成了一个你可以捕获并响应的 Promise 拒绝。

这是一个重要的细节,因为它有效地解决了另一种潜在的 Zalgo 时刻,也就是错误可能会产生一个同步的反应,而没有错误的部分还是异步的。Promise 甚至将 JS 异常都转化为异步行为,因此极大地降低了发生竟合状态的可能性。

但是如果 Promise 完成了,但是在监听过程中(在一个then(..)上注册的回调上)出现了 JS 异常错误会怎样呢?即便是那些也不会丢失,但你可能会发现处理它们的方式有些令人诧异,除非你深挖一些:

var p = new Promise( function(resolve,reject){
    resolve( 42 );
} );

p.then(
    function fulfilled(msg){
        foo.bar();
        console.log( msg );    // 永远不会跑到这里 :(
    },
    function rejected(err){
        // 也永远不会跑到这里 :(
    }
);

等一下,这看起来foo.bar()发生的异常确实被吞掉了。不要害怕,它没有。但更深层次的东西出问题了,也就是我们没能成功地监听他。p.then(..)调用本身返回另一个 promise,是 那个 promise 将会被TypeError异常拒绝。

为什么它不能调用我们在这里定义的错误处理器呢?表面上看起来是一个符合逻辑的行为。但它会违反 Promise 一旦被解析就 不可变 的基本原则。p已经完成为值42,所以它不能因为在监听p的解析时发生了错误,而在稍后变成一个拒绝。

除了违反原则,这样的行为还可能造成破坏,假如说有多个在 promisep上注册的then(..)回调,因为有些会被调用而有些不会,而且至于为什么是很明显的。

可信的 Promise?

为了基于 Promise 模式建立信任,还有最后一个细节需要考察。

无疑你已经注意到了,Promise 根本没有摆脱回调。它们只是改变了回调传递的位置。与将一个回调传入foo(..)相反,我们从foo(..)那里拿回 某些东西 (表面上是一个纯粹的 Promise),然后我们将回调传入这个 东西

但为什么这要比仅使用回调的方式更可靠呢?我们如何确信我们拿回来的 某些东西 事实上是一个可信的 Promise?这难道不是说我们相信它仅仅因为我们已经相信它了吗?

一个 Promise 经常被忽视,但是最重要的细节之一,就是它也为这个问题给出了解决方案。包含在原生的 ES6Promise实现中,它就是Promise.resolve(..)

如果你传递一个立即的,非 Promise 的,非 thenable 的值给Promise.resolve(..),你会得到一个用这个值完成的 promise。换句话说,下面两个 promisep1p2的行为基本上完全相同:

var p1 = new Promise( function(resolve,reject){
    resolve( 42 );
} );

var p2 = Promise.resolve( 42 );

但如果你传递一个纯粹的 Promise 给Promise.resolve(..),你会得到这个完全相同的 promise:

var p1 = Promise.resolve( 42 );

var p2 = Promise.resolve( p1 );

p1 === p2; // true

更重要的是,如果你传递一个非 Promise 的 thenable 值给Promise.resolve(..),它会试着将这个值展开,而且直到抽出一个最终具体的非 Promise 值之前,展开操作将会一直继续下去。

还记得我们先前讨论的 thenable 吗?

考虑这段代码:

var p = {
    then: function(cb) {
        cb( 42 );
    }
};

// 这工作起来没问题,但要靠运气
p
.then(
    function fulfilled(val){
        console.log( val ); // 42
    },
    function rejected(err){
        // 永远不会跑到这里
    }
);

这个p是一个 thenable,但它不是一个纯粹的 Promise。很走运,它是合理的,正如大多数情况那样。但是如果你得到的是看起来像这样的东西:

var p = {
    then: function(cb,errcb) {
        cb( 42 );
        errcb( "evil laugh" );
    }
};

p
.then(
    function fulfilled(val){
        console.log( val ); // 42
    },
    function rejected(err){
        // 噢,这里本不该运行
        console.log( err ); // evil laugh
    }
);

这个p是一个 thenable,但它不是表现良好的 promise。它是恶意的吗?或者它只是不知道 Promise 应当如何工作?老实说,这不重要。不管哪种情况,它都不那么可靠。

尽管如此,我们可以将这两个版本的p传入Promise.resolve(..),而且我们将会得到一个我们期望的泛化,安全的结果:

Promise.resolve( p )
.then(
    function fulfilled(val){
        console.log( val ); // 42
    },
    function rejected(err){
        // 永远不会跑到这里
    }
);

Promise.resolve(..)会接受任何 thenable,而且将它展开直至非 thenable 值。但你会从Promise.resolve(..)那里得到一个真正的,纯粹的 Promise,一个你可以信任的东西。如果你传入的东西已经是一个纯粹的 Promise 了,那么你会单纯地将它拿回来,所以通过Promise.resolve(..)过滤来得到信任没有任何坏处。

那么我们假定,我们在调用一个foo(..)工具,而且不能确定我们能相信它的返回值是一个行为规范的 Promise,但我们知道它至少是一个 thenable。Promise.resolve(..)将会给我们一个可靠的 Promise 包装器来进行链式调用:

// 不要只是这么做:
foo( 42 )
.then( function(v){
    console.log( v );
} );

// 相反,这样做:
Promise.resolve( foo( 42 ) )
.then( function(v){
    console.log( v );
} );

注意: 将任意函数的返回值(thenable 或不是 thenable)包装在Promise.resolve(..)中的另一个好的副作用是,它可以很容易地将函数调用泛化为一个行为规范的异步任务。如果foo(42)有时返回一个立即值,而其他时候返回一个 Promise,Promise.resolve(foo(42)),将确保它总是返回 Promise。并且使代码成为回避 Zalgo 效应的更好的代码。

信任建立了

希望前面的讨论使你现在完全理解了 Promise 是可靠的,而且更为重要的是,为什么信任对于建造强壮,可维护的软件来说是如此关键。

没有信任,你能用 JS 编写异步代码吗?你当然能。我们 JS 开发者在除了回调以外没有任何东西的情况下,写了将近 20 年的异步代码了。

但是一旦你开始质疑你到底能够以多大的程度相信你的底层机制,它实际上多么可预见,多么可靠,你就会开始理解回调的信任基础多么的摇摇欲坠。

Promise 是一个用可靠语义来增强回调的模式,所以它的行为更合理更可靠。通过将回调的 控制倒转 反置过来,我们将控制交给一个可靠的系统(Promise),它是为了将你的异步处理进行清晰的表达而特意设计的。

链式流程

我们已经被暗示过几次,但 Promise 不仅是是一个单步的 这个然后那个 操作机制。当然,那是构建块儿,但事实证明我们可以将多个 Promise 串联在一起来表达一系列的异步步骤。

使这一切能够工作的关键,是 Promise 的两个固有行为:

  • 每次你在一个 Promise 上调用then(..)的时候,它都创建并返回一个新的 Promise,我们可以在它上面进行 链接
  • 无论你从then(..)调用的完成回调中(第一个参数)返回什么值,它都做为被链接的 Promise 的完成。

我们首先来说明一下这是什么意思,然后我们将会延伸出它是如何帮助我们创建异步顺序的控制流程的。考虑下面的代码:

var p = Promise.resolve( 21 );

var p2 = p.then( function(v){
    console.log( v );    // 21

    // 使用值`42`完成`p2`
    return v * 2;
} );

// 在`p2`后链接
p2.then( function(v){
    console.log( v );    // 42
} );

通过返回v * 2(也就是42),我们完成了由第一个then(..)调用创建并返回的p2promise。当p2then(..)调用运行时,它从return v * 2语句那里收到完成信号。当然,p2.then(..)还会创建另一个 promise,我们将它存储在变量p3中。

但是不得不创建临时变量p2(或p3等)有点儿恼人。幸运的是,我们可以简单地将这些链接在一起:

var p = Promise.resolve( 21 );

p
.then( function(v){
    console.log( v );    // 21

    // 使用值`42`完成被链接的 promise
    return v * 2;
} )
// 这里是被链接的 promise
.then( function(v){
    console.log( v );    // 42
} );

那么现在第一个then(..)是异步序列的第一步,而第二个then(..)就是第二步。它可以根据你的需要延伸至任意长。只要持续不断地用每个自动创建的 Promise 在前一个then(..)末尾进行连接即可。

但是这里错过了某些东西。要是我们想让第 2 步等待第 1 步去做一些异步的事情呢?我们使用的是一个立即的return语句,它立即完成了链接中的 promise。

使 Promise 序列在每一步上都是真正异步的关键,需要回忆一下当你向Promise.resolve(..)传递一个 Promise 或 thenable 而非一个最终值时它如何执行。Promise.resolve(..)会直接返回收到的纯粹 Promise,或者它会展开收到的 thenable 的值——并且它会递归地持续展开 thenable。

如果你从完成(或拒绝)处理器中返回一个 thenable 或 Promise,同样的展开操作也会发生。考虑这段代码:

var p = Promise.resolve( 21 );

p.then( function(v){
    console.log( v );    // 21

    // 创建一个 promise 并返回它
    return new Promise( function(resolve,reject){
        // 使用值`42`完成
        resolve( v * 2 );
    } );
} )
.then( function(v){
    console.log( v );    // 42
} );

即便我们把42包装在一个我们返回的 promise 中,它依然会被展开并作为下一个被链接的 promise 的解析,如此第二个then(..)仍然收到42。如果我们在这个包装 promise 中引入异步,一切还是会同样正常的工作:

var p = Promise.resolve( 21 );

p.then( function(v){
    console.log( v );    // 21

    // 创建一个 promise 并返回
    return new Promise( function(resolve,reject){
        // 引入异步!
        setTimeout( function(){
            // 使用值`42`完成
            resolve( v * 2 );
        }, 100 );
    } );
} )
.then( function(v){
    // 在上一步中的 100 毫秒延迟之后运行
    console.log( v );    // 42
} );

这真是不可思议的强大!现在我们可以构建一个序列,它可以有我们想要的任意多的步骤,而且每一步都可以按照需要来推迟下一步(或者不推迟)。

当然,在这些例子中一步一步向下传递的值是可选的。如果你没有返回一个明确的值,那么它假定一个隐含的undefined,而且 promise 依然会以同样的方式链接在一起。如此,每个 Promise 的解析只不过是进行至下一步的信号。

为了演示更长的链接,让我们把推迟 Promise 的创建(没有解析信息)泛化为一个我们可以在多个步骤中复用的工具:

function delay(time) {
    return new Promise( function(resolve,reject){
        setTimeout( resolve, time );
    } );
}

delay( 100 ) // step 1
.then( function STEP2(){
    console.log( "step 2 (after 100ms)" );
    return delay( 200 );
} )
.then( function STEP3(){
    console.log( "step 3 (after another 200ms)" );
} )
.then( function STEP4(){
    console.log( "step 4 (next Job)" );
    return delay( 50 );
} )
.then( function STEP5(){
    console.log( "step 5 (after another 50ms)" );
} )
...

调用delay(200)创建了一个将在 200 毫秒内完成的 promise,然后我们在第一个then(..)的完成回调中返回它,这将使第二个then(..)的 promise 等待这个 200 毫秒的 promise。

注意: 正如刚才描述的,技术上讲在这个交替中有两个 promise:一个 200 毫秒延迟的 promise,和一个被第二个then(..)链接的 promise。但你可能会发现将这两个 promise 组合在一起更容易思考,因为 Promise 机制帮你把它们的状态自动地混合到了一起。从这个角度讲,你可以认为return delay(200)创建了一个 promise 来取代早前一个返回的被链接的 promise。

老实说,没有任何消息进行传递的一系列延迟作为 Promise 流程控制的例子不是很有用。让我们来看一个更加实在的场景:

与计时器不同,让我们考虑发起 Ajax 请求:

// 假定一个`ajax( {url}, {callback} )`工具

// 带有 Promise 的 ajax
function request(url) {
    return new Promise( function(resolve,reject){
        // `ajax(..)`的回调应当是我们的 promise 的`resolve(..)`函数
        ajax( url, resolve );
    } );
}

我们首先定义一个request(..)工具,它构建一个 promise 表示ajax(..)调用的完成:

request( "http://some.url.1/" )
.then( function(response1){
    return request( "http://some.url.2/?v=" + response1 );
} )
.then( function(response2){
    console.log( response2 );
} );

注意: 开发者们通常遭遇的一种情况是,他们想用本身不支持 Promise 的工具(就像这里的ajax(..),它期待一个回调)进行 Promise 式的异步流程控制。虽然 ES6 原生的Promise机制不会自动帮我们解决这种模式,但是在实践中所有的 Promise 库会帮我们这么做。它们通常称这种处理为“提升(lifting)”或“promise 化”或其他的什么名词。我们稍后再回头讨论这种技术。

使用返回 Promise 的request(..),通过用第一个 URL 调用它我们在链条中隐式地创建了第一步,然后我们用第一个then(..)在返回的 promise 末尾进行连接。

一旦response1返回,我们用它的值来构建第二个 URL,并且发起第二个request(..)调用。这第二个promisereturn的,所以我们的异步流程控制的第三步将会等待这个 Ajax 调用完成。最终,一旦response2返回,我们就打印它。

我们构建的 Promise 链不仅是一个表达多步骤异步序列的流程控制,它还扮演者将消息从一步传递到下一步的消息管道。

要是 Promise 链中的某一步出错了会怎样呢?一个错误/异常是基于每个 Promise 的,意味着在链条的任意一点捕获这些错误是可能的,而且这些捕获操作在那一点上将链条“重置”,使它回到正常的操作上来:

// 步骤 1:
request( "http://some.url.1/" )

// 步骤 2:
.then( function(response1){
    foo.bar(); // 没有定义,错误!

    // 永远不会跑到这里
    return request( "http://some.url.2/?v=" + response1 );
} )

// 步骤 3:
.then(
    function fulfilled(response2){
        // 永远不会跑到这里
    },
    // 拒绝处理器捕捉错误
    function rejected(err){
        console.log( err );    // 来自 `foo.bar()` 的 `TypeError` 错误
        return 42;
    }
)

// 步骤 4:
.then( function(msg){
    console.log( msg );        // 42
} );

当错误在第 2 步中发生时,第 3 步的拒绝处理器将它捕获。拒绝处理器的返回值(在这个代码段里是42),如果有的话,将会完成下一步(第 4 步)的 promise,如此整个链条又回到完成的状态。

注意: 就像我们刚才讨论过的,当我们从一个完成处理器中返回一个 promise 时,它会被展开并有可能推迟下一步。这对从拒绝处理器中返回的 promise 也是成立的,这样如果我们在第 3 步返回一个 promise 而不是return 42,那么这个 promise 就可能会推迟第 4 步。不管是在then(..)的完成还是拒绝处理器中,一个被抛出的异常都将导致下一个(链接着的)promise 立即用这个异常拒绝。

如果你在一个 promise 上调用then(..),而且你只向他传递了一个完成处理器,一个假定的拒绝处理器会取而代之:

var p = new Promise( function(resolve,reject){
    reject( "Oops" );
} );

var p2 = p.then(
    function fulfilled(){
        // 永远不会跑到这里
    }
    // 如果忽略或者传入任何非函数的值,
    // 会有假定有一个这样的拒绝处理器
    // function(err) {
    //     throw err;
    // }
);

如你所见,这个假定的拒绝处理器仅仅简单地重新抛出错误,它最终强制p2(链接着的 promise)用同样的错误进行拒绝。实质上,它允许错误持续地在 Promise 链上传播,直到遇到一个明确定义的拒绝处理器。

注意: 稍后我们会讲到更多关于使用 Promise 进行错误处理的细节,因为会有更多微妙的细节需要关心。

如果没有一个恰当的合法的函数作为then(..)的完成处理器参数,也会有一个默认的处理器取而代之:

var p = Promise.resolve( 42 );

p.then(
    // 如果忽略或者传入任何非函数的值,
    // 会有假定有一个这样的完成处理器
    // function(v) {
    //     return v;
    // }
    null,
    function rejected(err){
        // 永远不会跑到这里
    }
);

如你所见,默认的完成处理器简单地将它收到的任何值传递给下一步(Promise)。

注意: then(null,function(err){ .. })这种模式——仅处理拒绝(如果发生的话)但让成功通过——有一个缩写的 API:catch(function(err){ .. })。我们会在下一节中更全面地涵盖catch(..)

然我们简要地复习一下使链式流程控制成为可能的 Promise 固有行为:

  • 在一个 Promise 上的then(..)调用会自动生成一个新的 Promise 并返回。
  • 在完成/拒绝处理器内部,如果你返回一个值或抛出一个异常,新返回的 Promise(可以被链接的)将会相应地被解析。
  • 如果完成或拒绝处理器返回一个 Promise,它会被展开,所以无论它被解析为什么值,这个值都将变成从当前的then(..)
    回的被链接的 Promise 的解析。

虽然链式流程控制很有用,但是将它认为是 Promise 的组合方式的副作用可能最准确,而不是它的主要意图。正如我们已经详细讨论过许多次的,Promise 泛化了异步处理并且包装了与时间相关的值和状态,这才是让我们以这种有用的方式将它们链接在一起的原因。

当然,相对于我们在第二章中看到的一堆混乱的回调,这种链条的顺序表达是一个巨大的改进。但是仍然要蹚过相当多的模板代码(then(..) and function(){ .. })。在下一章中,我们将看到一种极大美化顺序流程控制的表达模式,生成器(generators)。

术语: Resolve(解析),Fulfill(完成),和 Reject(拒绝)

在你更多深入地学习 Promise 之前,在“解析(resolve)”,“完成(fulfill)”,和“拒绝(reject)”这些名词之间还有一些我们需要辨明的小困惑。首先让我们考虑一下Promise(..)构造器:

var p = new Promise( function(X,Y){
    // X() 给 fulfillment(完成)
    // Y() 给 rejection(拒绝)
} );

如你所见,有两个回调(标识为XY)被提供了。第一个 通常 用于表示 Promise 完成了,而第二个 总是 表示 Promise 拒绝了。但“通常”是什么意思?它对这些参数的正确命名暗示着什么呢?

最终,这只是你的用户代码,和将被引擎翻译为没有任何含义的东西的标识符,所以在 技术上 它无紧要;foo(..)bar(..)在功能性上是相等的。但是你用的词不仅会影响你如何考虑这段代码,还会影响你所在团队的其他开发者如何考虑它。将精心策划的异步代码错误地考虑,几乎可以说要比面条一般的回调还要差劲儿。

所以,某种意义上你如何称呼它们很关键。

第二个参数很容易决定。几乎所有的文献都使用reject(..)做为它的名称,应为这正是它(唯一!)要做的,对于命名来说这是一个很好的选择。我也强烈推荐你一直使用reject(..)

但是关于第一个参数还是有些带有歧义,它在许多关于 Promise 的文献中常被标识为resolve(..)。这个词明显地是与“resolution(解析)”有关,它在所有的文献中(包括本书)广泛用于描述给 Promise 设定一个最终的值/状态。我们已经使用“解析 Promise(resolve the Promise)”许多次来意味 Promise 的完成(fulfilling)或拒绝(rejecting)。

但是如果这个参数看起来被用于特指 Promise 的完成,为什么我们不更准确地叫它fulfill(..),而是用resolve(..)呢?要回答这个问题,让我们看一下Promise的两个 API 方法:

var fulfilledPr = Promise.resolve( 42 );

var rejectedPr = Promise.reject( "Oops" );

Promise.resolve(..)创建了一个 Promise,它被解析为它被给予的值。在这个例子中,42是一个一般的,非 Promise,非 thenable 的值,所以完成的 promisefulfilledPr是为值42创建的。Promise.reject("Oops")为了原因"Oops"创建的拒绝的 promiserejectedPr

现在让我们来解释为什么如果“resolve”这个词(正如Promise.resolve(..)里的)被明确用于一个既可能完成也可能拒绝的环境时,它没有歧义,反而更加准确:

var rejectedTh = {
    then: function(resolved,rejected) {
        rejected( "Oops" );
    }
};

var rejectedPr = Promise.resolve( rejectedTh );

就像我们在本章前面讨论的,Promise.resolve(..)将会直接返回收到的纯粹的 Promise,或者将收到的 thenable 展开。如果展开这个 thenable 之后是一个拒绝状态,那么从Promise.resolve(..)返回的 Promise 事实上是相同的拒绝状态。

所以对于这个 API 方法来说,Promise.resolve(..)是一个好的,准确的名称,因为它实际上既可以得到完成的结果,也可以得到拒绝的结果。

Promise(..)构造器的第一个回调参数既可以展开一个 thenable(与Promise.resolve(..)相同),也可以展开一个 Promise:

var rejectedPr = new Promise( function(resolve,reject){
    // 用一个被拒绝的 promise 来解析这个 promise
    resolve( Promise.reject( "Oops" ) );
} );

rejectedPr.then(
    function fulfilled(){
        // 永远不会跑到这里
    },
    function rejected(err){
        console.log( err );    // "Oops"
    }
);

现在应当清楚了,对于Promise(..)构造器的第一个参数来说resolve(..)是一个合适的名称。

警告: 前面提到的reject(..) 不会resolve(..)那样进行展开。如果你向reject(..)传递一个 Promise/thenable 值,这个没有被碰过的值将作为拒绝的理由。一个后续的拒绝处理器将会受到你传递给reject(..)的实际的 Promise/thenable,而不是它底层的立即值。

现在让我们将注意力转向提供给then(..)的回调。它们应当叫什么(在文献和代码中)?我的建议是fulfilled(..)rejected(..)

function fulfilled(msg) {
    console.log( msg );
}

function rejected(err) {
    console.error( err );
}

p.then(
    fulfilled,
    rejected
);

对于then(..)的第一个参数的情况,它没有歧义地总是完成状态,所以没有必要使用带有双重意义的“resolve”术语。另一方面,ES6 语言规范中使用onFulfilled(..)onRejected(..) 来标识这两个回调,所以它们是准确的术语。

你不懂 JS: 异步与性能 第三章: Promise(下)

错误处理

我们已经看过几个例子,Promise 拒绝——既可以通过有意调用reject(..),也可以通过意外的 JS 异常——是如何在异步编程中允许清晰的错误处理的。让我们兜个圈子回去,将我们一带而过的一些细节弄清楚。

对大多数开发者来说,最自然的错误处理形式是同步的try..catch结构。不幸的是,它仅能用于同步状态,所以在异步代码模式中它帮不上什么忙:

function foo() {
    setTimeout( function(){
        baz.bar();
    }, 100 );
}

try {
    foo();
    // 稍后会从`baz.bar()`抛出全局错误
}
catch (err) {
    // 永远不会到这里
}

能有try..catch当然很好,但除非有某些附加的环境支持,它无法与异步操作一起工作。我们将会在第四章中讨论 generator 时回到这个话题。

在回调中,对于错误处理的模式已经有了一些新兴的模式,最有名的就是“错误优先回调”风格:

function foo(cb) {
    setTimeout( function(){
        try {
            var x = baz.bar();
            cb( null, x ); // 成功!
        }
        catch (err) {
            cb( err );
        }
    }, 100 );
}

foo( function(err,val){
    if (err) {
        console.error( err ); // 倒霉 :(
    }
    else {
        console.log( val );
    }
} );

注意: 这里的try..catch仅在baz.bar()调用立即地,同步地成功或失败时才能工作。如果baz.bar()本身是一个异步完成的函数,它内部的任何异步错误都不能被捕获。

我们传递给foo(..)的回调期望通过预留的err参数收到一个表示错误的信号。如果存在,就假定出错。如果不存在,就假定成功。

这类错误处理在技术上是 异步兼容的,但它根本组织的不好。用无处不在的if语句检查将多层错误优先回调编织在一起,将不可避免地将你置于回调地狱的危险之中(见第二章)。

那么我们回到 Promise 的错误处理,使用传递给then(..)的拒绝处理器。Promise 不使用流行的“错误优先回调”设计风格,反而使用“分割回调”的风格;一个回调给完成,一个回调给拒绝:

var p = Promise.reject( "Oops" );

p.then(
    function fulfilled(){
        // 永远不会到这里
    },
    function rejected(err){
        console.log( err ); // "Oops"
    }
);

虽然这种模式表面上看起来十分有道理,但是 Promise 错误处理的微妙之处经常使它有点儿相当难以全面把握。

考虑下面的代码:

var p = Promise.resolve( 42 );

p.then(
    function fulfilled(msg){
        // 数字没有字符串方法,
        // 所以这里抛出一个错误
        console.log( msg.toLowerCase() );
    },
    function rejected(err){
        // 永远不会到这里
    }
);

如果msg.toLowerCase()合法地抛出一个错误(它会的!),为什么我们的错误处理器没有得到通知?正如我们早先解释的,这是因为 这个 错误处理器是为ppromise 准备的,也就是已经被值42完成的那个 promise。ppromise 是不可变的,所以唯一可以得到错误通知的 promise 是由p.then(..)返回的那个,而在这里我们没有捕获它。

这应当解释了:为什么 Promise 的错误处理是易错的。错误太容易被吞掉了,而这很少是你有意这么做的。

警告: 如果你以一种不合法的方式使用 Promise API,而且有错误阻止正常的 Promise 构建,其结果将是一个立即被抛出的异常,而不是一个拒绝 Promise。这是一些导致 Promise 构建失败的错误用法:new Promise(null)Promise.all()Promise.race(42)等等。如果你没有足够合法地使用 Promise API 来首先实际构建一个 Promise,你就不能得到一个拒绝 Promise!

绝望的深渊

几年前 Jeff Atwood 曾经写到:编程语言总是默认地以这样的方式建立,开发者们会掉入“绝望的深渊”(blog.codinghorror.com/falling-into-the-pit-of-success/ )——在这里意外会被惩罚——而你不得不更努力地使它正确。他恳求我们相反地创建“成功的深渊”,就是你会默认地掉入期望的(成功的)行为,而如此你不得不更努力地去失败。

毫无疑问,Promise 的错误处理是一种“绝望的深渊”的设计。默认情况下,它假定你想让所有的错误都被 Promise 的状态吞掉,而且如果你忘记监听这个状态,错误就会默默地凋零/死去——通常是绝望的。

为了回避把一个被遗忘/抛弃的 Promise 的错误无声地丢失,一些开发者宣称 Promise 链的“最佳实践”是,总是将你的链条以catch(..)终结,就像这样:

var p = Promise.resolve( 42 );

p.then(
    function fulfilled(msg){
        // 数字没有字符串方法,
        // 所以这里抛出一个错误
        console.log( msg.toLowerCase() );
    }
)
.catch( handleErrors );

因为我们没有给then(..)传递拒绝处理器,默认的处理器会顶替上来,它仅仅简单地将错误传播到链条的下一个 promise 中。如此,在p中发生的错误,与在p之后的解析中(比如msg.toLowerCase())发生的错误都将会过滤到最后的handleErrors(..)中。

问题解决了,对吧?没那么容易!

要是handleErrors(..)本身也有错误呢?谁来捕获它?这里还有一个没人注意的 promise:catch(..)返回的 promise,我们没有对它进行捕获,也没注册拒绝处理器。

你不能仅仅将另一个catch(..)贴在链条末尾,因为它也可能失败。Promise 链的最后一步,无论它是什么,总有可能,即便这种可能性逐渐减少,悬挂着一个困在未被监听的 Promise 中的,未被捕获的错误。

听起来像一个不可解的迷吧?

处理未被捕获的错误

这不是一个很容易就能完全解决的问题。但是有些接近于解决的方法,或者说 更好的方法

一些 Promise 库有一些附加的方法,可以注册某些类似于“全局的未处理拒绝”的处理器,全局上不会抛出错误,而是调用它。但是他们识别一个错误是“未被捕获的错误”的方案是,使用一个任意长的计时器,比如说 3 秒,从拒绝的那一刻开始计时。如果一个 Promise 被拒绝但没有错误处理在计时器被触发前注册,那么它就假定你不会注册监听器了,所以它是“未被捕获的”。

实践中,这个方法在许多库中工作的很好,因为大多数用法不会在 Promise 拒绝和监听这个拒绝之间有很明显的延迟。但是这个模式有点儿麻烦,因为 3 秒实在太随意了(即便它是实证过的),还因为确实有些情况你想让一个 Promise 在一段不确定的时间内持有它的拒绝状态,而且你不希望你的“未捕获错误”处理器因为这些具有正面含义的不成立(还没处理的“未捕获错误”)而被调用。

另一种常见的建议是,Promise 应当增加一个done(..)方法,它实质上标志着 Promise 链的“终结”。done(..)不会创建并返回一个 Promise,所以传递给done(..)的回调很明显地不会链接上一个不存在的 Promise 链,并向它报告问题。

那么接下来会发什么?正如你通常在未处理错误状态下希望的那样,在done(..)的拒绝处理器内部的任何异常都作为全局的未捕获错误抛出(基本上扔到开发者控制台):

var p = Promise.resolve( 42 );

p.then(
    function fulfilled(msg){
        // 数字没有字符串方法,
        // 所以这里抛出一个错误
        console.log( msg.toLowerCase() );
    }
)
.done( null, handleErrors );

// 如果`handleErrors(..)`自身发生异常,它会在这里被抛出到全局

这听起来要比永不终结的链条或随意的超时要吸引人。但最大的问题是,它不是 ES6 标准,所以不管听起来多么好,它成为一个可靠而普遍的解决方案还有很长的距离。

那我们就卡在这里了?不完全是。

浏览器有一个我们的代码没有的能力:它们可以追踪并确定一个对象什么时候被废弃并可以作为垃圾回收。所以,浏览器可以追踪 Promise 对象,当它们被当做垃圾回收时,如果在它们内部存在一个拒绝状态,浏览器就可以确信这是一个合法的“未捕获错误”,它可以信心十足地知道应当在开发者控制台上报告这一情况。

注意: 在写作本书的时候,Chrome 和 Firefox 都早已试图实现这种“未捕获拒绝”的能力,虽然至多也就是支持的不完整。

然而,如果一个 Promise 不被垃圾回收——通过许多不同的代码模式,这极其容易不经意地发生——浏览器的垃圾回收检测不会帮你知道或诊断你有一个拒绝的 Promise 静静地躺在附近。

还有其他选项吗?有。

成功的深渊

以下讲的仅仅是理论上,Promise 可能 在某一天变成什么样的行为。我相信那会比我们现在拥有的优越许多。而且我想这种改变可能会发生在后 ES6 时代,因为我不认为它会破坏 Web 的兼容性。另外,如果你小心行事,它是可以被填补(polyfilled)/预填补(prollyfilled)的。让我们来看一下:

  • Promise 可以默认为是报告(向开发者控制台)一切拒绝的,就在下一个 Job 或事件轮询 tick,如果就在这时 Promise 上没有注册任何错误处理器。
  • 如果你希望拒绝的 Promise 在被监听前,将其拒绝状态保持一段不确定的时间。你可以调用defer(),它会压制这个 Promise 自动报告错误。

如果一个 Promise 被拒绝,默认地它会吵吵闹闹地向开发者控制台报告这个情况(而不是默认不出声)。你既可以选择隐式地处理这个报告(通过在拒绝之前注册错误处理器),也可以选择明确地处理这个报告(使用defer())。无论哪种情况, 都控制着这种具有正面意义的不成立。

考虑下面的代码:

var p = Promise.reject( "Oops" ).defer();

// `foo(..)`返回 Promise
foo( 42 )
.then(
    function fulfilled(){
        return p;
    },
    function rejected(err){
        // 处理`foo(..)`的错误
    }
);
...

我们创建了p,我们知道我们会为了使用/监听它的拒绝而等待一会儿,所以我们调用defer()——如此就不会有全局的报告。defer()单纯地返回同一个 promise,为了链接的目的。

foo(..)返回的 promise 当即 就添附了一个错误处理器,所以这隐含地跳出了默认行为,而且不会有全局的关于错误的报告。

但是从then(..)调用返回的 promise 没有defer()或添附错误处理器,所以如果它被拒绝(从它内部的任意一个解析处理器中),那么它就会向开发者控制台报告一个未捕获错误。

这种设计称为成功的深渊。默认情况下,所有的错误不是被处理就是被报告——这几乎是所有开发者在几乎所有情况下所期望的。你要么不得不注册一个监听器,要么不得不有意什么都不做,并指示你要将错误处理推迟到 稍后;你仅为这种特定情况选择承担额外的责任。

这种方式唯一真正的危险是,你defer()了一个 Promise 但是实际上没有监听/处理它的拒绝。

但你不得不有意地调用defer()来选择进入绝望深渊——默认是成功深渊——所以对于从你自己的错误中拯救你这件事来说,我们能做的不多。

我觉得对于 Promise 的错误处理还有希望(在后 ES6 时代)。我希望上层人物将会重新思考这种情况并考虑选用这种方式。同时,你可以自己实现这种方式(给读者们的挑战练习!),或使用一个 聪明 的 Promise 库来为你这么做。

注意: 这种错误处理/报告的确切的模型已经在我的 asynquence Promise 抽象库中实现,我们会在本书的附录 A 中讨论它。

Promise 模式

我们已经隐含地看到了使用 Promise 链的顺序模式(这个-然后-这个-然后-那个的流程控制),但是我们还可以在 Promise 的基础上抽象出许多其他种类的异步模式。这些模式用于简化异步流程控制的的表达——它可以使我们的代码更易于推理并且更易于维护——即便是我们程序中最复杂的部分。

有两个这样的模式被直接编码在 ES6 原生的Promise实现中,所以我们免费的得到了它们,来作为我们其他模式的构建块儿。

Promise.all([ .. ])

在一个异步序列(Promise 链)中,在任何给定的时刻都只有一个异步任务在被协调——第 2 步严格地接着第 1 步,而第 3 步严格地接着第 2 步。但要是并发(也叫“并行地”)地去做两个或以上的步骤呢?

用经典的编程术语,一个“门(gate)”是一种等待两个或更多并行/并发任务都执行完再继续的机制。它们完成的顺序无关紧要,只是它们不得不都完成才能让门打开,继而让流程控制通过。

在 Promise API 中,我们称这种模式为all([ .. ])

比方说你想同时发起两个 Ajax 请求,在发起第三个 Ajax 请求发起之前,等待它们都完成,而不管它们的顺序。考虑这段代码:

// `request(..)`是一个兼容 Promise 的 Ajax 工具
// 就像我们在本章早前定义的

var p1 = request( "http://some.url.1/" );
var p2 = request( "http://some.url.2/" );

Promise.all( [p1,p2] )
.then( function(msgs){
    // `p1`和`p2`都已完成,这里将它们的消息传入
    return request(
        "http://some.url.3/?v=" + msgs.join(",")
    );
} )
.then( function(msg){
    console.log( msg );
} );

Promise.all([ .. ])期待一个单独的参数,一个array,一般由 Promise 的实例组成。从Promise.all([ .. ])返回的 promise 将会收到完成的消息(在这段代码中是msgs),它是一个由所有被传入的 promise 的完成消息按照被传入的顺序构成的array(与完成的顺序无关)。

注意: 技术上讲,被传入Promise.all([ .. ])array的值可以包括 Promise,thenable,甚至是立即值。这个列表中的每一个值都实质上通过Promise.resolve(..)来确保它是一个可以被等待的纯粹的 Promise,所以一个立即值将被范化为这个值的一个 Promise。如果这个array是空的,主 Promise 将会立即完成。

Promise.resolve(..)返回的主 Promise 将会在所有组成它的 promise 完成之后才会被完成。如果其中任意一个 promise 被拒绝,Promise.all([ .. ])的主 Promise 将立即被拒绝,并放弃所有其他 promise 的结果。

要记得总是给每个 promise 添加拒绝/错误处理器,即使和特别是那个从Promise.all([ .. ])返回的 promise。

Promise.race([ .. ])

虽然Promise.all([ .. ])并发地协调多个 Promise 并假定它们都需要被完成,但是有时候你只想应答“冲过终点的第一个 Promise”,而让其他的 Promise 被丢弃。

这种模式经典地被称为“闩”,但在 Promise 中它被称为一个“竞合(race)”。

警告: 虽然“只有第一个冲过终点的算赢”是一个非常合适被比喻,但不幸的是“竞合(race)”是一个被占用的词,因为“竞合状态(race conditions)”通常被认为是程序中的 Bug(见第一章)。不要把Promise.race([ .. ])与“竞合状态(race conditions)”搞混了。

“竞合状态(race conditions)”也期待一个单独的array参数,含有一个或多个 Promise,thenable,或立即值。与立即值进行竞合并没有多大实际意义,因为很明显列表中的第一个会胜出——就像赛跑时有一个选手在终点线上起跑!

Promise.all([ .. ])相似,Promise.race([ .. ])将会在任意一个 Promise 解析为完成时完成,而且它会在任意一个 Promise 解析为拒绝时拒绝。

注意: 一个“竞合(race)”需要至少一个“选手”,所以如果你传入一个空的arrayrace([..])的主 Promise 将不会立即解析,反而是永远不会被解析。这是砸自己的脚!ES6 应当将它规范为要么完成,要么拒绝,或者要么抛出某种同步错误。不幸的是,因为在 ES6 的Promise之前的 Promise 库的优先权高,他们不得不把这个坑留在这儿,所以要小心绝不要传入一个空array

让我们重温刚才的并发 Ajax 的例子,但是在p1p2竞合的环境下:

// `request(..)`是一个兼容 Promise 的 Ajax 工具
// 就像我们在本章早前定义的

var p1 = request( "http://some.url.1/" );
var p2 = request( "http://some.url.2/" );

Promise.race( [p1,p2] )
.then( function(msg){
    // `p1`或`p2`会赢得竞合
    return request(
        "http://some.url.3/?v=" + msg
    );
} )
.then( function(msg){
    console.log( msg );
} );

因为只有一个 Promise 会胜出,所以完成的值是一个单独的消息,而不是一个像Promise.all([ .. ])中那样的array

超时竞合

我们早先看过这个例子,描述Promise.race([ .. ])如何能够用于表达“promise 超时”模式:

// `foo()`是一个兼容 Promise

// `timeoutPromise(..)`在早前定义过,
// 返回一个在指定延迟之后会被拒绝的 Promise

// 为`foo()`设置一个超时
Promise.race( [
    foo(),                    // 尝试`foo()`
    timeoutPromise( 3000 )    // 给它 3 秒钟
] )
.then(
    function(){
        // `foo(..)`及时地完成了!
    },
    function(err){
        // `foo()`要么是被拒绝了,要么就是没有及时完成
        // 可以考察`err`来知道是哪一个原因
    }
);

这种超时模式在绝大多数情况下工作的很好。但这里有一些微妙的细节要考虑,而且坦率的说它们对于Promise.race([ .. ])Promise.all([ .. ])都同样需要考虑。

"Finally"

要问的关键问题是,“那些被丢弃/忽略的 promise 发生了什么?”我们不是从性能的角度在问这个问题——它们通常最终会变成垃圾回收的合法对象——而是从行为的角度(副作用等等)。Promise 不能被取消——而且不应当被取消,因为那会摧毁本章稍后的“Promise 不可取消”一节中要讨论的外部不可变性——所以它们只能被无声地忽略。

但如果前面例子中的foo()占用了某些资源,但超时首先触发而且导致这个 promise 被忽略了呢?这种模式中存在某种东西可以在超时后主动释放被占用的资源,或者取消任何它可能带来的副作用吗?要是你想做的全部只是记录下foo()超时的事实呢?

一些开发者提议,Promise 需要一个finally(..)回调注册机制,它总是在 Promise 解析时被调用,而且允许你制定任何可能的清理操作。在当前的语言规范中它还不存在,但它可能会在 ES7+中加入。我们不得不边走边看了。

它看起来可能是这样:

var p = Promise.resolve( 42 );

p.then( something )
.finally( cleanup )
.then( another )
.finally( cleanup );

注意: 在各种 Promise 库中,finally(..)依然会创建并返回一个新的 Promise(为了使链条延续下去)。如果cleanup(..)函数返回一个 Promise,它将会链入链条,这意味着你可能还有我们刚才讨论的未处理拒绝的问题。

同时,我们可以制造一个静态的帮助工具来让我们观察(但不干涉)Promise 的解析:

// 填补的安全检查
if (!Promise.observe) {
    Promise.observe = function(pr,cb) {
        // 从侧面观察`pr`的解析
        pr.then(
            function fulfilled(msg){
                // 异步安排回调(作为 Job)
                Promise.resolve( msg ).then( cb );
            },
            function rejected(err){
                // 异步安排回调(作为 Job)
                Promise.resolve( err ).then( cb );
            }
        );

        // 返回原本的 promise
        return pr;
    };
}

这是我们在前面的超时例子中如何使用它:

Promise.race( [
    Promise.observe(
        foo(),                    // 尝试`foo()`
        function cleanup(msg){
            // 在`foo()`之后进行清理,即便它没有及时完成
        }
    ),
    timeoutPromise( 3000 )    // 给它 3 秒钟
] )

这个Promise.observe(..)帮助工具只是描述你如何在不干扰 Promise 的情况下观测它的完成。其他的 Promise 库有他们自己的解决方案。不论你怎么做,你都将很可能有个地方想用来确认你的 Promise 没有意外地被无声地忽略掉。

Variations on all([ .. ]) and race([ .. ])

原生的 ES6Promise 带有内建的Promise.all([ .. ])Promise.race([ .. ]),这里还有几个关于这些语义的其他常用的变种模式:

  • none([ .. ])很像all([ .. ]),但是完成和拒绝被转置了。所有的 Promise 都需要被拒绝——拒绝变成了完成值,反之亦然。
  • any([ .. ])很像all([ .. ]),但它忽略任何拒绝,所以只有一个需要完成即可,而不是它们所有的。
  • first([ .. ])像是一个带有any([ .. ])的竞合,它忽略任何拒绝,而且一旦有一个 Promise 完成时,它就立即完成。
  • last([ .. ])很像first([ .. ]),但是只有最后一个完成胜出。

某些 Promise 抽象工具库提供这些方法,但你也可以用 Promise 机制的race([ .. ])all([ .. ]),自己定义他们。

比如,这是我们如何定义first([..]):

// 填补的安全检查
if (!Promise.first) {
    Promise.first = function(prs) {
        return new Promise( function(resolve,reject){
            // 迭代所有的 promise
            prs.forEach( function(pr){
                // 泛化它的值
                Promise.resolve( pr )
                // 无论哪一个首先成功完成,都由它来解析主 promise
                .then( resolve );
            } );
        } );
    };
}

注意: 这个first(..)的实现不会在它所有的 promise 都被拒绝时拒绝;它会简单地挂起,很像Promise.race([])。如果需要,你可以添加一些附加逻辑来追踪每个 promise 的拒绝,而且如果所有的都被拒绝,就在主 promise 上调用reject()。我们将此作为练习留给读者。

并发迭代

有时候你想迭代一个 Promise 的列表,并对它们所有都实施一些任务,就像你可以对同步的array做的那样(比如,forEach(..)map(..)some(..),和every(..))。如果对每个 Promise 实施的操作根本上是同步的,它们工作的很好,正如我们在前面的代码段中用过的forEach(..)

但如果任务在根本上是异步的,或者可以/应当并发地实施,你可以使用许多库提供的异步版本的这些工具方法。

比如,让我们考虑一个异步的map(..)工具,它接收一个array值(可以是 Promise 或任何东西),外加一个对数组中每一个值实施的函数(任务)。map(..)本身返回一个 promise,它的完成值是一个持有每个任务的异步完成值的array(以与映射(mapping)相同的顺序):

if (!Promise.map) {
    Promise.map = function(vals,cb) {
        // 一个等待所有被映射的 promise 的新 promise
        return Promise.all(
            // 注意:普通的数组`map(..)`,
            // 将值的数组变为 promise 的数组
            vals.map( function(val){
                // 将`val`替换为一个在`val`
                // 异步映射完成后才解析的新 promise
                return new Promise( function(resolve){
                    cb( val, resolve );
                } );
            } )
        );
    };
}

注意: 在这种map(..)的实现中,你无法表示异步拒绝,但如果一个在映射的回调内部发生一个同步的异常/错误,那么Promise.map(..)返回的主 Promise 就会拒绝。

让我们描绘一下对一组 Promise(不是简单的值)使用map(..)

var p1 = Promise.resolve( 21 );
var p2 = Promise.resolve( 42 );
var p3 = Promise.reject( "Oops" );

// 将列表中的值翻倍,即便它们在 Promise 中
Promise.map( [p1,p2,p3], function(pr,done){
    // 确保列表中每一个值都是 Promise
    Promise.resolve( pr )
    .then(
        // 将值作为`v`抽取出来
        function(v){
            // 将完成的`v`映射到新的值
            done( v * 2 );
        },
        // 或者,映射到 promise 的拒绝消息上
        done
    );
} )
.then( function(vals){
    console.log( vals );    // [42,84,"Oops"]
} );

Promise API 概览

让我们复习一下我们已经在本章中零散地展开的 ES6PromiseAPI。

注意: 下面的 API 尽在 ES6 中是原生的,但也存在一些语言规范兼容的填补(不光是扩展 Promise 库),它们定义了Promise和与之相关的所有行为,所以即使是在前 ES6 时代的浏览器中你也以使用原生的 Promise。这类填补的其中之一是“Native Promise Only”(github.com/getify/native-promise-only),我写的!

new Promise(..)构造器

揭示构造器(revealing constructor) Promise(..)必须与new一起使用,而且必须提供一个被同步/立即调用的回调函数。这个函数被传入两个回调函数,它们作为 promise 的解析能力。我们通常将它们标识为resolve(..)reject(..)

var p = new Promise( function(resolve,reject){
    // `resolve(..)`给解析/完成的 promise
    // `reject(..)`给拒绝的 promise
} );

reject(..)简单地拒绝 promise,但是resolve(..)既可以完成 promise,也可以拒绝 promise,这要看它被传入什么值。如果resolve(..)被传入一个立即的,非 Promise,非 thenable 的值,那么这个 promise 将用这个值完成。

但如果resolve(..)被传入一个 Promise 或者 thenable 的值,那么这个值将被递归地展开,而且无论它最终解析结果/状态是什么,都将被 promise 采用。

Promise.resolve(..) 和 Promise.reject(..)

一个用于创建已被拒绝的 Promise 的简便方法是Promise.reject(..),所以这两个 promise 是等价的:

var p1 = new Promise( function(resolve,reject){
    reject( "Oops" );
} );

var p2 = Promise.reject( "Oops" );

Promise.reject(..)相似,Promise.resolve(..)通常用来创建一个已完成的 Promise。然而,Promise.resolve(..)还会展开 thenale 值(就像我们已经几次讨论过的)。在这种情况下,返回的 Promise 将会采用你传入的 thenable 的解析,它既可能是完成,也可能是拒绝:

var fulfilledTh = {
    then: function(cb) { cb( 42 ); }
};
var rejectedTh = {
    then: function(cb,errCb) {
        errCb( "Oops" );
    }
};

var p1 = Promise.resolve( fulfilledTh );
var p2 = Promise.resolve( rejectedTh );

// `p1`将是一个完成的 promise
// `p2`将是一个拒绝的 promise

而且要记住,如果你传入一个纯粹的 Promise,Promise.resolve(..)不会做任何事情;它仅仅会直接返回这个值。所以在你不知道其本性的值上调用Promise.resolve(..)不会有额外的开销,如果它偶然已经是一个纯粹的 Promise。

then(..) 和 catch(..)

每个 Promise 实例(不是 Promise API 名称空间)都有then(..)catch(..)方法,它们允许你为 Promise 注册成功或拒绝处理器。一旦 Promise 被解析,它们中的一个就会被调用,但不是都会被调用,而且它们总是会被异步地调用(参见第一章的“Jobs”)。

then(..)接收两个参数,第一个用于完成回调,第二个用户拒绝回调。如果它们其中之一被省略,或者被传入一个非函数的值,那么一个默认的回调就会分别顶替上来。默认的完成回调简单地将值向下传递,而默认的拒绝回调简单地重新抛出(传播)收到的拒绝理由。

catch(..)仅仅接收一个拒绝回调作为参数,而且会自动的顶替一个默认的成功回调,就像我们讨论过的。换句话说,它等价于then(null,..)

p.then( fulfilled );

p.then( fulfilled, rejected );

p.catch( rejected ); // 或者`p.then( null, rejected )`

then(..)catch(..)也会创建并返回一个新的 promise,它可以用来表达 Promise 链式流程控制。如果完成或拒绝回调有异常被抛出,这个返回的 promise 就会被拒绝。如果这两个回调之一返回一个立即,非 Promise,非 thenable 值,那么这个值就会作为被返回的 promise 的完成。如果完成处理器指定地返回一个 promise 或 thenable 值这个值就会被展开而且变成被返回的 promise 的解析。

Promise.all([ .. ]) 和 Promise.race([ .. ])

在 ES6 的PromiseAPI 的静态帮助方法Promise.all([ .. ])Promise.race([ .. ])都创建一个 Promise 作为它们的返回值。这个 promise 的解析完全由你传入的 promise 数组控制。

对于Promise.all([ .. ]),为了被返回的 promise 完成,所有你传入的 promise 都必须完成。如果其中任意一个被拒绝,返回的主 promise 也会立即被拒绝(丢弃其他所有 promise 的结果)。至于完成状态,你会收到一个含有所有被传入的 promise 的完成值的array。至于拒绝状态,你仅会收到第一个 promise 拒绝的理由值。这种模式通常称为“门”:在门打开前所有人都必须到达。

对于Promise.race([ .. ]),只有第一个解析(成功或拒绝)的 promise 会“胜出”,而且不论解析的结果是什么,都会成为被返回的 promise 的解析结果。这种模式通常成为“闩”:第一个打开门闩的人才能进来。考虑这段代码:

var p1 = Promise.resolve( 42 );
var p2 = Promise.resolve( "Hello World" );
var p3 = Promise.reject( "Oops" );

Promise.race( [p1,p2,p3] )
.then( function(msg){
    console.log( msg );        // 42
} );

Promise.all( [p1,p2,p3] )
.catch( function(err){
    console.error( err );    // "Oops"
} );

Promise.all( [p1,p2] )
.then( function(msgs){
    console.log( msgs );    // [42,"Hello World"]
} );

警告: 要小心!如果一个空的array被传入Promise.all([ .. ]),它会立即完成,但Promise.race([ .. ])却会永远挂起,永远不会解析。

ES6 的PromiseAPI 十分简单和直接。对服务于大多数基本的异步情况来说它足够好了,而且当你要把你的代码从回调地狱变为某些更好的东西时,它是一个开始的好地方。

但是依然还有许多应用程序所要求的精巧的异步处理,由于 Promise 本身所受的限制而不能解决。在下一节中,我们将深入这些限制,来看看 Promise 库的优点。

Promise 限制

本节中我们将要讨论的许多细节已经在这一章中被提及了,但我们将明确地复习这些限制。

顺序的错误处理

我们在本章前面的部分详细讲解了 Promise 风格的错误处理。Promise 的设计方式——特别是他们如何链接——所产生的限制,创建了一个非常容易掉进去的陷阱,Promise 链中的错误会被意外地无声地忽略掉。

但关于 Promise 的错误还有一些其他事情要考虑。因为 Promise 链只不过是组成它的 Promise 连在一起,没有一个实体可以用来将整个链条表达为一个单独的 东西,这意味着没有外部的方法能够监听可能发生的任何错误。

如果你构建一个不包含错误处理器的 Promise 链,这个链条的任意位置发生的任何错误都将沿着链条向下无限传播,直到被监听为止(通过在某一步上注册拒绝处理器)。所以,在这种特定情况下,拥有链条的最后一个 promise 的引用就够了(下面代码段中的p),因为你可以在这里注册拒绝处理器,而且它会被所有传播的错误通知:

// `foo(..)`, `STEP2(..)` 和 `STEP3(..)`
// 都是 promise 兼容的工具

var p = foo( 42 )
.then( STEP2 )
.then( STEP3 );

虽然这看起来有点儿小糊涂,但是这里的p没有指向链条中的第一个 promise(foo(42)调用中来的那一个),而是指向了最后一个 promise,来自于then(STEP3)调用的那一个。

另外,这个 promise 链条上看不到一个步骤做了自己的错误处理。这意味着你可以在p上注册一个拒绝处理器,如果在链条的任意位置发生了错误,它就会被通知。

p.catch( handleErrors );

但如果这个链条中的某一步事实上做了自己的错误处理(也许是隐藏/抽象出去了,所以你看不到),那么你的handleErrors(..)就不会被通知。这可能是你想要的——它毕竟是一个“被处理过的拒绝”——但它也可能 是你想要的。完全缺乏被通知的能力(被“已处理过的”拒绝错误通知)是一个在某些用法中约束功能的一种限制。

它基本上和try..catch中存在的限制是相同的,它可以捕获一个异常并简单地吞掉。所以这不是一个 Promise 特有 的问题,但它确实是一个我们希望绕过的限制。

不幸的是,许多时候 Promise 链序列的中间步骤不会被留下引用,所以没有这些引用,你就不能添加错误处理器来可靠地监听错误。

单独的值

根据定义,Promise 只能有一个单独的完成值或一个单独的拒绝理由。在简单的例子中,这没什么大不了的,但在更精巧的场景下,你可能发现这个限制。

典型的建议是构建一个包装值(比如objectarray)来包含这些多个消息。这个方法好用,但是在你的 Promise 链的每一步上把消息包装再拆开显得十分尴尬和烦人。

分割值

有时你可以将这种情况当做一个信号,表示你可以/应当将问题拆分为两个或更多的 Promise。

想象你有一个工具foo(..),它异步地产生两个值(xy):

function getY(x) {
    return new Promise( function(resolve,reject){
        setTimeout( function(){
            resolve( (3 * x) - 1 );
        }, 100 );
    } );
}

function foo(bar,baz) {
    var x = bar * baz;

    return getY( x )
    .then( function(y){
        // 将两个值包装近一个容器
        return [x,y];
    } );
}

foo( 10, 20 )
.then( function(msgs){
    var x = msgs[0];
    var y = msgs[1];

    console.log( x, y );    // 200 599
} );

首先,让我们重新安排一下foo(..)返回的东西,以便于我们不必再将xy包装进一个单独的array值中来传送给一个 Promise。相反,我们将每一个值包装进它自己的 promise:

function foo(bar,baz) {
    var x = bar * baz;

    // 将两个 promise 返回
    return [
        Promise.resolve( x ),
        getY( x )
    ];
}

Promise.all(
    foo( 10, 20 )
)
.then( function(msgs){
    var x = msgs[0];
    var y = msgs[1];

    console.log( x, y );
} );

一个 promise 的array真的要比传递给一个单独的 Promise 的值的array要好吗?语法上,它没有太多改进。

但是这种方式更加接近于 Promise 的设计原理。现在它更易于在未来将xy的计算分开,重构进两个分离的函数中。它更清晰,也允许调用端代码更灵活地安排这两个 promise——这里使用了Promise.all([ .. ]),但它当然不是唯一的选择——而不是将这样的细节在foo(..)内部进行抽象。

展开/散开参数

var x = ..var y = ..的赋值依然是一个尴尬的负担。我们可以在一个帮助工具中利用一些函数式技巧(向 Reginald Braithwaite 致敬,在推特上 @raganwald ):

function spread(fn) {
    return Function.apply.bind( fn, null );
}

Promise.all(
    foo( 10, 20 )
)
.then(
    spread( function(x,y){
        console.log( x, y );    // 200 599
    } )
)

看起来好些了!当然,你可以内联这个函数式魔法来避免额外的帮助函数:

Promise.all(
    foo( 10, 20 )
)
.then( Function.apply.bind(
    function(x,y){
        console.log( x, y );    // 200 599
    },
    null
) );

这个技巧可能很整洁,但是 ES6 给了我们一个更好的答案:解构(destructuring)。数组的解构赋值形式看起来像这样:

Promise.all(
    foo( 10, 20 )
)
.then( function(msgs){
    var [x,y] = msgs;

    console.log( x, y );    // 200 599
} );

最棒的是,ES6 提供了数组参数解构形式:

Promise.all(
    foo( 10, 20 )
)
.then( function([x,y]){
    console.log( x, y );    // 200 599
} );

我们现在已经接受了“每个 Promise 一个值”的准则,继续让我们把模板代码最小化!

注意: 更多关于 ES6 解构形式的信息,参阅本系列的 ES6 与未来

单次解析

Promise 的一个最固有的行为之一就是,一个 Promise 只能被解析一次(成功或拒绝)。对于多数异步用例来说,你仅仅取用这个值一次,所以这工作的很好。

但也有许多异步情况适用于一个不同的模型——更类似于事件和/或数据流。表面上看不清 Promise 能对这种用例适应的多好,如果能的话。没有基于 Promise 的重大抽象过程,它们完全缺乏对多个值解析的处理。

想象这样一个场景,你可能想要为响应一个刺激(比如事件)触发一系列异步处理步骤,而这实际上将会发生多次,比如按钮点击。

这可能不会像你想的那样工作:

// `click(..)` 绑定了一个 DOM 元素的 `"click"` 事件
// `request(..)` 是先前定义的支持 Promise 的 Ajax

var p = new Promise( function(resolve,reject){
    click( "#mybtn", resolve );
} );

p.then( function(evt){
    var btnID = evt.currentTarget.id;
    return request( "http://some.url.1/?id=" + btnID );
} )
.then( function(text){
    console.log( text );
} );

这里的行为仅能在你的应用程序只让按钮被点击一次的情况下工作。如果按钮被点击第二次,promisep已经被解析了,所以第二个resolve(..)将被忽略。

相反的,你可能需要将模式反过来,在每次事件触发时创建一个全新的 Promise 链:

click( "#mybtn", function(evt){
    var btnID = evt.currentTarget.id;

    request( "http://some.url.1/?id=" + btnID )
    .then( function(text){
        console.log( text );
    } );
} );

这种方式会 好用,为每个按钮上的"click"事件发起一个全新的 Promise 序列。

但是除了在事件处理器内部定义一整套 Promise 链看起来很丑以外,这样的设计在某种意义上违背了关注/能力分离原则(SoC)。你可能非常想在一个你的代码不同的地方定义事件处理器:你定义对事件的 响应(Promise 链)的地方。如果没有帮助机制,在这种模式下这么做很尴尬。

注意: 这种限制的另一种表述方法是,如果我们能够构建某种能在它上面进行 Promise 链监听的“可监听对象(observable)”就好了。有一些库已经建立这些抽象(比如 RxJS——rxjs.codeplex.com/),但是这种抽象看起来是如此的重,以至于你甚至再也看不到 Promise 的性质。这样的重抽象带来一个重要的问题:这些机制是否像 Promise 本身被设计的一样 可靠。我们将会在附录 B 中重新讨论“观察者(Observable)”模式。

惰性

对于在你的代码中使用 Promise 而言一个实在的壁垒是,现存的所有代码都没有支持 Promise。如果你有许多基于回调的代码,让代码保持相同的风格容易多了。

“一段基于动作(用回调)的代码将仍然基于动作(用回调),除非一个更聪明,具有 Promise 意识的开发者对它采取行动。”

Promise 提供了一种不同的模式规范,如此,代码的表达方式可能会变得有一点儿不同,某些情况下,则根不同。你不得不有意这么做,因为 Promise 不仅只是把那些为你服务至今的老式编码方法自然地抖落掉。

考虑一个像这样的基于回调的场景:

function foo(x,y,cb) {
    ajax(
        "http://some.url.1/?x=" + x + "&y=" + y,
        cb
    );
}

foo( 11, 31, function(err,text) {
    if (err) {
        console.error( err );
    }
    else {
        console.log( text );
    }
} );

将这个基于回调的代码转换为支持 Promise 的代码的第一步该怎么做,是立即明确的吗?这要看你的经验。你练习的越多,它就感觉越自然。但当然,Promise 没有明确告知到底怎么做——没有一个放之四海而皆准的答案——所以这要靠你的责任心。

就像我们以前讲过的,我们绝对需要一种支持 Promise 的 Ajax 工具来取代基于回调的工具,我们可以称它为request(..)。你可以制造自己的,正如我们已经做过的。但是不得不为每个基于回调的工具手动定义 Promise 相关的包装器的负担,使得你根本就不太可能选择将代码重构为 Promise 相关的。

Promise 没有为这种限制提供直接的答案。但是大多数 Promise 库确实提供了帮助函数。想象一个这样的帮助函数:

// 填补的安全检查
if (!Promise.wrap) {
    Promise.wrap = function(fn) {
        return function() {
            var args = [].slice.call( arguments );

            return new Promise( function(resolve,reject){
                fn.apply(
                    null,
                    args.concat( function(err,v){
                        if (err) {
                            reject( err );
                        }
                        else {
                            resolve( v );
                        }
                    } )
                );
            } );
        };
    };
}

好吧,这可不是一个微不足道的工具。然而,虽然他可能看起来有点儿令人生畏,但也没有你想的那么糟。它接收一个函数,这个函数期望一个错误优先风格的回调作为第一个参数,然后返回一个可以自动创建 Promise 并返回的新函数,然后为你替换掉回调,与 Promise 的完成/拒绝连接在一起。

与其浪费太多时间谈论这个Promise.wrap(..)帮助函数 如何 工作,还不如让我们来看看如何使用它:

var request = Promise.wrap( ajax );

request( "http://some.url.1/" )
.then( .. )
..

哇哦,真简单!

Promise.wrap(..) 不会 生产 Promise。它生产一个将会生产 Promise 的函数。某种意义上,一个 Promise 生产函数可以被看做一个“Promise 工厂”。我提议将这样的东西命名为“promisory”("Promise" + "factory")。

这种将期望回调的函数包装为一个 Promise 相关的函数的行为,有时被称为“提升(lifting)”或“promise 化(promisifying)”。但是除了“提升过的函数”以外,看起来没有一个标准的名词来称呼这个结果函数,所以我更喜欢“promisory”,因为我认为他更具描述性。

注意: Promisory 不是一个瞎编的词。它是一个真实存在的词汇,而且它的定义是含有或载有一个 promise。这正是这些函数所做的,所以这个术语匹配得简直完美!

那么,Promise.wrap(ajax)生产了一个我们称为request(..)ajax(..)promisory,而这个 promisory 为 Ajax 应答生产 Promise。

如果所有的函数已经都是 promisory,我们就不需要自己制造它们,所以额外的步骤就有点儿多余。但是至少包装模式是(通常都是)可重复的,所以我们可以把它放进Promise.wrap(..)帮助函数中来支援我们的 promise 编码。

那么回到刚才的例子,我们需要为ajax(..)foo(..)都做一个 promisory。

// 为`ajax(..)`制造一个 promisory
var request = Promise.wrap( ajax );

// 重构`foo(..)`,但是为了代码其他部分
// 的兼容性暂且保持它对外是基于回调的
// ——仅在内部使用`request(..)`'的 promise
function foo(x,y,cb) {
    request(
        "http://some.url.1/?x=" + x + "&y=" + y
    )
    .then(
        function fulfilled(text){
            cb( null, text );
        },
        cb
    );
}

// 现在,为了这段代码本来的目的,为`foo(..)`制造一个 promisory
var betterFoo = Promise.wrap( foo );

// 并使用这个 promisory
betterFoo( 11, 31 )
.then(
    function fulfilled(text){
        console.log( text );
    },
    function rejected(err){
        console.error( err );
    }
);

当然,虽然我们将foo(..)重构为使用我们的新request(..)promisory,我们可以将foo(..)本身制成 promisory,而不是保留基于会掉的实现并需要制造和使用后续的betterFoo(..)promisory。这个决定只是要看foo(..)是否需要保持基于回调的形式以便于代码的其他部分兼容。

考虑这段代码:

// 现在,`foo(..)`也是一个 promisory
// 因为它委托到`request(..)` promisory
function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

foo( 11, 31 )
.then( .. )
..

虽然 ES6 的 Promise 没有为这样的 promisory 包装提供原生的帮助函数,但是大多数库提供它们,或者你可以制造自己的。不管哪种方法,这种 Promise 特定的限制是可以不费太多劲儿就可以解决的(当然是和回调地狱的痛苦相比!)。

Promise 不可撤销

一旦你创建了一个 Promise 并给它注册了一个完成和/或拒绝处理器,就没有什么你可以从外部做的事情能停止这个进程,即使是某些其他的事情使这个任务变得毫无意义。

注意: 许多 Promise 抽象库都提供取消 Promise 的功能,但这是一个非常坏的主意!许多开发者都希望 Promise 被原生地设计为具有外部取消能力,但问题是这将允许 Promise 的一个消费者/监听器影响某些其他消费者监听同一个 Promise 的能力。这违反了未来值得可靠性原则(外部不可变),另外就是嵌入了“远距离行为(action at a distance)”的反模式(en.wikipedia.org/wiki/Action_at_a_distance_%28computer_programming%29)。不管它看起来多么有用,它实际上会直接将你引回与回调地狱相同的噩梦。

考虑我们早先的 Promise 超时场景:

var p = foo( 42 );

Promise.race( [
    p,
    timeoutPromise( 3000 )
] )
.then(
    doSomething,
    handleError
);

p.then( function(){
    // 即使是在超时的情况下也会发生 :(
} );

“超时”对于 promisep来说是外部的,所以p本身继续运行,这可能不是我们想要的。

一个选项是侵入性地定义你的解析回调:

var OK = true;

var p = foo( 42 );

Promise.race( [
    p,
    timeoutPromise( 3000 )
    .catch( function(err){
        OK = false;
        throw err;
    } )
] )
.then(
    doSomething,
    handleError
);

p.then( function(){
    if (OK) {
        // only happens if no timeout! :)
    }
} );

这很丑。这可以工作,但是远不理想。一般来说,你应当避免这样的场景。

但是如果你不能,这种解决方案的丑陋应当是一个线索,说明 取消 是一种属于在 Promise 之上的更高层抽象的功能。我推荐你找一个 Promise 抽象库来辅助你,而不是自己使用黑科技。

注意: 我的 asynquence Promise 抽象库提供了这样的抽象,还为序列提供了一个abort()能力,这一切将在附录 A 中讨论。

一个单独的 Promise 不是真正的流程控制机制(至少没有多大实际意义),而流程控制机制正是 取消 要表达的;这就是为什么 Promise 取消显得尴尬。

相比之下,一个链条的 Promise 集合在一起——我称之为“序列”—— 一个流程控制的表达,如此在这一层面的抽象上它就适于定义取消。

没有一个单独的 Promise 应该是可以取消的,但是一个 序列 可以取消是有道理的,因为你不会将一个序列作为一个不可变值传来传去,就像 Promise 那样。

Promise 性能

这种限制既简单又复杂。

比较一下在基于回调的异步任务链和 Promise 链上有多少东西在动,很明显 Promise 有多得多的事情发生,这意味着它们自然地会更慢一点点。回想一下 Promise 提供的保证信任的简单列表,将它和你为了达到相同保护效果而在回调上面添加的特殊代码比较一下。

更多工作要做,更多的安全要保护,意味着 Promise 与赤裸裸的,不可靠的回调相比 确实 更慢。这些都很明显,可能很容易萦绕在你脑海中。

但是慢多少?好吧……这实际上是一个难到不可思议的问题,无法绝对,全面地回答。

坦白地说,这是一个比较苹果和橘子的问题,所以可能是问错了。你实际上应当比较的是,带有所有手动保护层的经过特殊处理的回调系统,是否比一个 Promise 实现要快。

如果说 Promise 有一种合理的性能限制,那就是它并不将可靠性保护的选项罗列出来让你选择——你总是一下得到全部。

如果我们承认 Promise 一般来说要比它的非 Promise,不可靠的回调等价物 慢一点儿——假定在有些地方你觉得你可以自己调整可靠性的缺失——难道这意味着 Promise 应当被全面地避免,就好像你的整个应用程序仅仅由一些可能的“必须绝对最快”的代码驱动着?

合理性检查:如果你的代码有那么合理,那么 对于这样的任务,JavaScript 是正确的选择吗? 为了运行应用程序 JavaScript 可以被优化得十分高效(参见第五章和第六章)。但是在 Promise 提供的所有好处的光辉之下,过于沉迷它微小的性能权衡,真的 合适吗?

另一个微妙的问题是 Promise 使 所有事情 都成为异步的,这意味着有些应当立即完成的(同步的)步骤也要推迟到下一个 Job 步骤中(参见第一章)。也就是说一个 Promise 任务序列要比使用回调连接的相同序列要完成的稍微慢一些是可能的。

当然,这里的问题是:这些关于性能的微小零头的潜在疏忽,和我们在本章通篇阐述的 Promise 带来的益处相比,还值得考虑吗?

我的观点是,在几乎所有你可能认为 Promise 的性能慢到了需要被考虑的情况下,完全回避 Promise 并将它的可靠性和组合性优化掉,实际上一种反模式。

相反地,你应当默认地在代码中广泛使用它们,然后再记录并分析你的应用程序的热(关键)路径。Promise 真的 是瓶颈?还是它们只是理论上慢了下来?只有在那 之后,拿着实际合法的基准分析观测数据(参见第六章),再将 Promise 从这些关键区域中重构移除才称得上是合理与谨慎。

Promise 是有一点儿慢,但作为交换你得到了很多内建的可靠性,无 Zalgo 的可预测性,与组合性。也许真正的限制不是它们的性能,而是你对它们的益处缺乏认识?

复习

Promise 很牛。用它们。它们解决了肆虐在回调代码中的 控制倒转 问题。

它们没有摆脱回调,而是重新定向了这些回调的组织安排方式,是它成为一种坐落于我们和其他工具之间的可靠的中间机制。

Promise 链还开始以顺序的风格定义了一种更好的(当然,还不完美)表达异步流程的方式,它帮我们的大脑更好的规划和维护异步 JS 代码。我们会在下一章中看到一个更好的解决 这个 问题的方法!

你不懂 JS: 异步与性能 第四章: Generator(上)

在第二章中,我们发现了在使用回调表达异步流程控制时的两个关键缺陷:

  • 基于回调的异步与我们的大脑规划任务的各个步骤的过程不相符。
  • 由于 控制倒转 回调是不可靠的,也是不可组合的。

在第三章中,我们详细地讨论了 Promise 如何反转回调的 控制倒转,重建了可靠性/可组合性。

现在让我们把注意力集中到用一种顺序的,看起来同步的风格来表达异步流程控制。使这一切成为可能的“魔法”是 ES6 的 generator

打破运行至完成

在第一章中,我们讲解了一个 JS 开发者们在他们的代码中几乎永恒依仗的一个认识:一旦函数开始执行,它将运行直至完成,没有其他的代码可以在运行期间干扰它。

这看起来可能很滑稽,ES6 引入了一种新型的函数,它不按照“运行至完成”的行为进行动作。这种新型的函数称为“generator(生成器)”。

为了理解它的含义,然我们看看这个例子:

var x = 1;

function foo() {
    x++;
    bar();                // <-- 这一行会发生什么?
    console.log( "x:", x );
}

function bar() {
    x++;
}

foo();                    // x: 3

在这个例子中,我们确信bar()会在x++console.log(x)之间运行。但如果bar()不在这里呢?很明显结果将是2而不是3

现在让我们来燃烧你的大脑。要是bar()不存在,但以某种方式依然可以在x++console.log(x)语句之间运行呢?这可能吗?

抢占式(preemptive) 多线程语言中,bar()去“干扰”并正好在两个语句之间那一时刻运行,实质上时可能的。但 JS 不是抢占式的,也(还)不是多线程的。但是,如果foo()本身可以用某种办法在代码的这一部分指示一个“暂停”,那么这种“干扰”(并发)的 协作 形式就是可能的。

注意: 我使用“协作”这个词,不仅是因为它与经典的并发术语有关联(见第一章),也因为正如你将在下一个代码段中看到的,ES6 在代码中指示暂停点的语法是yield——暗示一个让出控制权的礼貌的 协作

这就是实现这种协作并发的 ES6 代码:

var x = 1;

function *foo() {
    x++;
    yield; // 暂停!
    console.log( "x:", x );
}

function bar() {
    x++;
}

注意: 你将很可能在大多数其他的 JS 文档/代码中看到,一个 generator 的声明被格式化为function* foo() { .. }而不是我在这里使用的function *foo() { .. }——唯一的区别是摆放*位置的风格。这两种形式在功能性/语法上是完全一样的,还有第三种function*foo() { .. }(没空格)形式。这两种风格存在争议,但我基本上偏好function *foo..,因为当我在写作中用*foo()引用一个 generator 时,这种形式可以匹配我写的东西。如果我只说foo(),你就不会清楚地知道我是在说一个 generator 还是一个一般的函数。这纯粹是一个风格偏好的问题。

现在,我们该如何运行上面的代码,使bar()yield那一点取代*foo()的执行?

// 构建一个迭代器`it`来控制 generator
var it = foo();

// 在这里开始`foo()`!
it.next();
x;                        // 2
bar();
x;                        // 3
it.next();                // x: 3

好了,这两段代码中有不少新的,可能使人困惑的东西,所以我们得跋涉好一段了。在我们用 ES6 的 generator 来讲解不同的机制/语法之前,让我们过一遍这个行为的流程:

  1. it = foo()操作 不会 执行*foo()generator,它只不过构建了一个用来控制它执行的 迭代器(iterator)。我们一会更多地讨论 迭代器
  2. 第一个it.next()启动了*foo()generator,并且运行*foo()第一行上的x++
  3. *foo()yield语句处暂停,就在这时第一个it.next()调用结束。在这个时刻,*foo()依然运行而且是活动的,但是处于暂停状态。
  4. 我们观察x的值,现在它是2.
  5. 我们调用bar(),它再一次用x++递增x
  6. 我们再一次观察x的值,现在它是3
  7. 最后的it.next()调用使*foo()generator 从它暂停的地方继续运行,而后运行使用x的当前值3console.log(..)语句。

清楚的是,*foo()启动了,但 没有 运行到底——它停在yield。我们稍后继续*foo(),让它完成,但这甚至不是必须的。

所以,一个 generator 是一种函数,它可以开始和停止一次或多次,甚至没必要一定要完成。虽然为什么它很强大看起来不那么明显,但正如我们将要在本章剩下的部分将要讲到的,它是我们用于在我们的代码中构建“generator 异步流程控制”模式的基础构建块儿之一。

输入和输出

一个 generator 函数是一种带有我们刚才提到的新型处理模型的函数。但它仍然是一个函数,这意味着依旧有一些不变的基本原则——即,它依然接收参数(也就是“输入”),而且它依然返回一个值(也就是“输出”):

function *foo(x,y) {
    return x * y;
}

var it = foo( 6, 7 );

var res = it.next();

res.value;        // 42

我们将67分别作为参数xy传递给*foo(..)。而*foo(..)将值42返回给调用端代码。

现在我们可以看到发生器的调用和一般函数的调用的一个不同之处了。foo(6,7)显然看起来很熟悉。但微妙的是,*foo(..)generator 不会像一个函数那样实际运行起来。

相反,我们只是创建了 迭代器 对象,将它赋值给变量it,来控制*foo(..)generator。当我们调用it.next()时,它指示*foo(..)generator 从现在的位置向前推进,直到下一个yield或者 generator 的最后。

next(..)调用的结果是一个带有value属性的对象,它持有从*foo(..)返回的任何值(如果有的话)。换句话说,yield导致在 generator 运行期间,一个值被从中发送出来,有点儿像一个中间的return

但是,为什么我们需要这个完全间接的 迭代器 对象来控制 generator 还不清楚。我们回头会讨论它的,我保证。

迭代通信

generator 除了接收参数和拥有返回值,它们还内建有更强大,更吸引人的输入/输出消息能力,这是通过使用yieldnext(..)实现的。

考虑下面的代码:

function *foo(x) {
    var y = x * (yield);
    return y;
}

var it = foo( 6 );

// 开始`foo(..)`
it.next();

var res = it.next( 7 );

res.value;        // 42

首先,我们将6作为参数x传入。之后我们调用it.next(),它启动了*foo(..).

*foo(..)内部,var y = x ..语句开始被处理,但它运行到了一个yield表达式。就在这时,它暂停了*foo(..)(就在赋值语句的中间!),而且请求调用端代码为yield表达式提供一个结果值。接下来,我们调用it.next(7),将7这个值传回去作为暂停的yield表达式的结果。

所以,在这个时候,赋值语句实质上是var y = 6 * 7。现在,return y将值42作为结果返回给it.next( 7 )调用。

注意一个非常重要,而且即便是对于老练的 JS 开发者也非常容易犯糊涂的事情:根据你的角度,在yieldnext(..)调用之间存在着错位。一般来说,你所拥有的next(..)调用的数量,会比你所拥有的yield语句的数量多一个——前面的代码段中有一个yield和两个next(..)调用。

为什么会有这样的错位?

因为第一个next(..)总是启动一个 generator,然后运行至第一个yield。但是第二个next(..)调用满足了第一个暂停的yield表达式,而第三个next(..)将满足第二个yield,如此反复。

两个疑问的故事

实际上,你主要考虑的是哪部分代码会影响你是否感知到错位。

仅考虑 generator 代码:

var y = x * (yield);
return y;

第一个 yield基本上是在 问一个问题:“我应该在这里插入什么值?”

谁来回答这个问题?好吧,第一个 next()在这个时候已经为了启动 generator 而运行过了,所以很明显 不能回答这个问题。所以,第二个 next(..)调用必须回答由 第一个 yield提出的问题。

看到错位了吧——第二个对第一个?

但是让我们反转一下我们的角度。让我们不从 generator 的角度看问题,而从迭代器的角度看。

为了恰当地描述这种角度,我们还需要解释一下,消息可以双向发送——yield ..作为表达式可以发送消息来应答next(..)调用,而next(..)可以发送值给暂停的yield表达式。考虑一下这段稍稍调整过的代码:

function *foo(x) {
    var y = x * (yield "Hello");    // <-- 让出一个值!
    return y;
}

var it = foo( 6 );

var res = it.next();    // 第一个`next()`,不传递任何东西
res.value;                // "Hello"

res = it.next( 7 );        // 传递`7`给等待中的`yield`
res.value;                // 42

yield ..next(..)一起成对地 在 generator 运行期间 构成了一个双向消息传递系统。

那么,如果只看 迭代器 代码:

var res = it.next();    // 第一个`next()`,不传递任何东西
res.value;                // "Hello"

res = it.next( 7 );        // 传递`7`给等待中的`yield`
res.value;                // 42

注意: 我们没有传递任何值给第一个next()调用,而且是故意的。只有一个暂停的yield才能接收这样一个被next(..)传递的值,但是当我们调用第一个next()时,在 generator 的最开始并 没有任何暂停的yield 可以接收这样的值。语言规范和所有兼容此语言规范的浏览器只会无声地 丢弃 任何传入第一个next()的东西。传递这样的值是一个坏主意,因为你只不过创建了一些令人困惑的无声“失败”的代码。所以,记得总是用一个无参数的next()来启动 generator。

第一个next()调用(没有任何参数的)基本上是在 问一个问题:“*foo(..)generator 将要给我的 下一个 值是什么?”,谁来回答这个问题?第一个yield表达式。

看到了?这里没有错位。

根据你认为是 在问问题,在yieldnext(..)之间的错位既存在又不存在。

但等一下!跟yield语句的数量比起来,还有一个额外的next()。那么,这个最后的it.next(7)调用又一次在询问 generator 下一个 产生的值是什么。但是没有yield语句剩下可以回答了,不是吗?那么谁来回答?

return语句回答这个问题!

而且如果在你的 generator 中 没有return——比起一般的函数,generator 中的return当然不再是必须的——总会有一个假定/隐式的return;(也就是return undefined;),它默认的目的就是回答由最后的it.next(7)调用 提出 的问题。

这些问题与回答——用yieldnext(..)进行双向消息传递——十分强大,但还是看不出来这些机制与异步流程控制有什么联系。我们正在接近真相!

多迭代器

从语法使用上来看,当你用一个 迭代器 来控制 generator 时,你正在控制声明的 generator 函数本身。但这里有一个容易忽视的微妙细节:每当你构建一个 迭代器,你都隐含地构建了一个将由这个 迭代器 控制的 generator 的实例。

你可以让同一个 generator 的多个实例同时运行,它们甚至可以互动:

function *foo() {
    var x = yield 2;
    z++;
    var y = yield (x * z);
    console.log( x, y, z );
}

var z = 1;

var it1 = foo();
var it2 = foo();

var val1 = it1.next().value;            // 2 <-- 让出 2
var val2 = it2.next().value;            // 2 <-- 让出 2

val1 = it1.next( val2 * 10 ).value;        // 40  <-- x:20,  z:2
val2 = it2.next( val1 * 5 ).value;        // 600 <-- x:200, z:3

it1.next( val2 / 2 );                    // y:300
                                        // 20 300 3
it2.next( val1 / 4 );                    // y:10
                                        // 200 10 3

警告: 同一个 generator 的多个并发运行实例的最常见的用法,不是这样的互动,而是 generator 在没有输入的情况下,从一些连接着的独立资源中产生它自己的值。我们将在下一节中更多地讨论产生值。

让我们简单地走一遍这个处理过程:

  1. 两个*foo()在同时启动,而且两个next()都分别从yield 2语句中得到了2value
  2. val2 * 10就是2 * 10,它被发送到第一个 generator 实例it1,所以x得到值20z1递增至2,然后20 * 2yield出来,将val1设置为40
  3. val1 * 5就是40 * 5,它被发送到第二个 generator 实例it2中,所以x得到值200z又一次递增,从23,然后200 * 3yield出来,将val2设置为600
  4. val2 / 2就是600 / 2,它被发送到第一个 generator 实例it1,所以y得到值300,然后分别为它的x y z值打印出20 300 3
  5. val1 / 4就是40 / 4,它被发送到第一个 generator 实例it2,所以y得到值10,然后分别为它的x y z值打印出200 10 3

这是在你脑海中跑过的一个“有趣”的例子。你还能保持清醒?

穿插

回想第一章中“运行至完成”一节的这个场景:

var a = 1;
var b = 2;

function foo() {
    a++;
    b = b * a;
    a = b + 3;
}

function bar() {
    b--;
    a = 8 + b;
    b = a * 2;
}

使用普通的 JS 函数,当然要么是foo()可以首先运行完成,要么是bar()可以首先运行至完成,但是foo()不可能与bar()穿插它的独立语句。所以,前面这段代码只有两个可能的结果。

然而,使用 generator,明确地穿插(甚至是在语句中间!)是可能的:

var a = 1;
var b = 2;

function *foo() {
    a++;
    yield;
    b = b * a;
    a = (yield b) + 3;
}

function *bar() {
    b--;
    yield;
    a = (yield 8) + b;
    b = a * (yield 2);
}

根据 迭代器 控制*foo()*bar()分别以什么样的顺序被调用,前面这段代码可以产生几种不同的结果。换句话说,通过两个 generator 在同一个共享的变量上穿插,我们实际上可以展示(以一种模拟的方式)在第一章中讨论的,理论上的“线程的竞合状态”环境。

首先,让我们制造一个称为step(..)的帮助函数,让它控制 迭代器

function step(gen) {
    var it = gen();
    var last;

    return function() {
        // 不论`yield`出什么,只管在下一次时直接把它塞回去!
        last = it.next( last ).value;
    };
}

step(..)初始化一个 generator 来创建它的it 迭代器,然后它返回一个函数,每次这个函数被调用时,都将 迭代器 向前推一步。另外,前一个被yield出来的值将被直接发给下一步。所以,yield 8将变成8yield b将成为b(不管它在yield时是什么值)。

现在,为了好玩儿,让我们做一些实验,来看看将这些*foo()*bar()的不同块儿穿插时的效果。我们从一个无聊的基本情况开始,保证*foo()*bar()之前全部完成(就像我们在第一章中做的那样):

// 确保重置了`a`和`b`
a = 1;
b = 2;

var s1 = step( foo );
var s2 = step( bar );

// 首先完全运行`*foo()`
s1();
s1();
s1();

// 现在运行`*bar()`
s2();
s2();
s2();
s2();

console.log( a, b );    // 11 22

最终结果是1122,就像第一章的版本那样。现在让我们把顺序混合穿插,来看看它如何改变ab的值。

// 确保重置了`a`和`b`
a = 1;
b = 2;

var s1 = step( foo );
var s2 = step( bar );

s2();        // b--;
s2();        // 让出 8
s1();        // a++;
s2();        // a = 8 + b;
            // 让出 2
s1();        // b = b * a;
            // 让出 b
s1();        // a = b + 3;
s2();        // b = a * 2;

在我告诉你结果之前,你能指出在前面的程序运行之后ab的值是什么吗?不要作弊!

console.log( a, b );    // 12 18

注意: 作为留给读者的练习,试试通过重新安排s1()s2()调用的顺序,看看你能得到多少种结果组合。别忘了你总是需要三个s1()调用和四个s2()调用。至于为什么,回想一下刚才关于使用yield匹配next()的讨论。

当然,你几乎不会想有意制造 这种 水平的,令人糊涂的穿插,因为他创建了非常难理解的代码。但是这个练习很有趣,而且对于理解多个 generator 如何并发地运行在相同的共享作用域来说很有教育意义,因为会有一些地方这种能力十分有用。

我们会在本章末尾更详细地讨论 generator 并发。

生成值

在前一节中,我们提到了一个 generator 的有趣用法,作为一种生产值的方式。这 不是 我们本章主要关注的,但如果我们不在这里讲一下基本我们会想念它的,特别是因为这种用法实质上是它的名称的由来:生成器。

我们将要稍稍深入一下 迭代器 的话题,但我们会绕回到它们如何与 generator 关联,并使用 generator 来 生成 值。

发生器与迭代器

想象你正在生产一系列的值,它们中的每一个都与前一个值有可定义的关系。为此,你将需要一个有状态的发生器来记住上一个给出的值。

你可以用函数闭包(参加本系列的 作用域与闭包)来直接地实现这样的东西:

var gimmeSomething = (function(){
    var nextVal;

    return function(){
        if (nextVal === undefined) {
            nextVal = 1;
        }
        else {
            nextVal = (3 * nextVal) + 6;
        }

        return nextVal;
    };
})();

gimmeSomething();        // 1
gimmeSomething();        // 9
gimmeSomething();        // 33
gimmeSomething();        // 105

注意: 这里nextVal的计算逻辑已经被简化了,但从概念上讲,直到 下一次 gimmeSomething()调用发生之前,我们不想计算 下一个值(也就是nextVal),因为一般对于持久性更强的,或者比简单的number更有限的资源的发生器来说,那可能是一种资源泄漏的设计。

生成随意的数字序列不是是一个很真实的例子。但是如果你从一个数据源中生成记录呢?你可以想象很多相同的代码。

事实上,这种任务是一种非常常见的设计模式,通常用迭代器解决。一个 迭代器 是一个明确定义的接口,用来逐个通过一系列从发生器得到的值。迭代器的 JS 接口,和大多数语言一样,是在你每次想从发生器中得到下一个值时调用的next()

我们可以为我们的数字序列发生器实现标准的 迭代器

var something = (function(){
    var nextVal;

    return {
        // `for..of`循环需要这个
        [Symbol.iterator]: function(){ return this; },

        // 标准的迭代器接口方法
        next: function(){
            if (nextVal === undefined) {
                nextVal = 1;
            }
            else {
                nextVal = (3 * nextVal) + 6;
            }

            return { done:false, value:nextVal };
        }
    };
})();

something.next().value;        // 1
something.next().value;        // 9
something.next().value;        // 33
something.next().value;        // 105

注意: 我们将在“Iterables”一节中讲解为什么我们在这个代码段中需要[Symbol.iterator]: ..这一部分。在语法上讲,两个 ES6 特性在发挥作用。首先,[ .. ]语法称为一个 计算属性名(参见本系列的 this 与对象原型)。它是一种字面对象定义方法,用来指定一个表达式并使用这个表达式的结果作为属性名。另一个,Symbol.iterator是 ES6 预定义的特殊Symbol值。

next()调用返回一个对象,它带有两个属性:done是一个boolean值表示 迭代器 的完成状态;value持有迭代的值。

ES6 还增加了for..of循环,它意味着一个标准的 迭代器 可以使用原生的循环语法来自动地被消费:

for (var v of something) {
    console.log( v );

    // 不要让循环永无休止!
    if (v > 500) {
        break;
    }
}
// 1 9 33 105 321 969

注意: 因为我们的something迭代器总是返回done:false,这个for..of循环将会永远运行,这就是为什么我们条件性地放进一个break。对于迭代器来说永不终结是完全没有问题的,但是也有一些情况 迭代器 将运行在有限的值的集合上,而最终返回done:true

for..of循环为每一次迭代自动调用next()——他不会给next()传入任何值——而且他将会在收到一个done:true时自动终结。这对于在一个集合的数据中进行循环十分方便。

当然,你可以手动循环一个迭代器,调用next()并检查done:true条件来知道什么时候停止:

for (
    var ret;
    (ret = something.next()) && !ret.done;
) {
    console.log( ret.value );

    // 不要让循环永无休止!
    if (ret.value > 500) {
        break;
    }
}
// 1 9 33 105 321 969

注意: 这种手动的for方式当然要比 ES6 的for..of循环语法难看,但它的好处是它提供给你一个机会,在有必要时传值给next(..)调用。

除了制造你自己的 迭代器 之外,许多 JS 中(就 ES6 来说)内建的数据结构,比如array,也有默认的 迭代器

var a = [1,3,5,7,9];

for (var v of a) {
    console.log( v );
}
// 1 3 5 7 9

for..of循环向a要来它的迭代器,并自动使用它迭代a的值。

注意: 看起来像是一个 ES6 的奇怪省略,普通的object有意地不带有像array那样的默认 迭代器。原因比我们要在这里讲的深刻得多。如果你想要的只是迭代一个对象的属性(不特别保证顺序),Object.keys(..)返回一个array,它可以像for (var k of Object.keys(obj)) { ..这样使用。像这样用for..of循环一个对象上的键,与用for..in循环内很相似,除了在for..in中会包含[[Prototype]]链的属性,而Object.keys(..)不会(参见本系列的 this 与对象原型)。

Iterables

在我们运行的例子中的something对象被称为一个 迭代器,因为它的接口中有next()方法。但一个紧密关联的术语是 iterable,它指 包含有 一个可以迭代它所有值的迭代器的对象。

在 ES6 中,从一个 iterable 中取得一个 迭代器 的方法是,iterable 上必须有一个函数,它的名称是特殊的 ES6 符号值Symbol.iterator。当这个函数被调用是,它就会返回一个 迭代器。虽然不是必须的,但一般来说每次调用应当返回一个全新的 迭代器

前一个代码段的a就是一个 iterablefor..of循环自动地调用它的Symbol.iterator函数来构建一个 迭代器。我们当然可以手动地调用这个函数,然后使用它返回的 iterator

var a = [1,3,5,7,9];

var it = a[Symbol.iterator]();

it.next().value;    // 1
it.next().value;    // 3
it.next().value;    // 5
..

在前面定义something的代码段中,你可能已经注意到了这一行:

[Symbol.iterator]: function(){ return this; }

这段有点让人困惑的代码制造了something值——something迭代器 的接口——也是一个 iterable;现在它既是一个 iterable 也是一个 迭代器。然后,我们把something传递给for..of循环:

for (var v of something) {
    ..
}

for..of循环期待something是一个 iterable,所以它会寻找并调用它的Symbol.iterator函数。我们将这个函数定义为简单地return this,所以它将自己给出,而for..of不会知道这些。

Generator 迭代器

带着 迭代器 的背景知识,让我们把注意力移回 generator。一个 generator 可以被看做一个值的发生器,我们通过一个 迭代器 接口的next()调用每次从中抽取一个值。

所以,一个 generator 本身在技术上讲并不是一个 iterable,虽然很相似——当你执行 generator 时,你就得到一个 迭代器

function *foo(){ .. }

var it = foo();

我们可以用 generator 实现早前的something无限数字序列发生器,就像这样:

function *something() {
    var nextVal;

    while (true) {
        if (nextVal === undefined) {
            nextVal = 1;
        }
        else {
            nextVal = (3 * nextVal) + 6;
        }

        yield nextVal;
    }
}

注意: 在一个真实的 JS 程序中含有一个while..true循环通常是一件非常不好的事情,至少如果它没有一个breakreturn语句,那么它就很可能永远运行,并同步地,阻塞/锁定浏览器 UI。然而,在 generator 中,如果这样的循环含有一个yield,那它就是完全没有问题的,因为 generator 将在每次迭代后暂停,yield回主程序和/或事件轮询队列。说的明白点儿,“generator 把while..true带回到 JS 编程中了!”

这变得相当干净和简单点儿了,对吧?因为 generator 会暂停在每个yield*something()函数的状态(作用域)被保持着,这意味着没有必要用闭包的模板代码来跨调用保留变量的状态了。

不仅是更简单的代码——我们不必自己制造 迭代器 借口了——它实际上是更合理的代码,因为它更清晰地表达了意图。比如,while..true循环告诉我们这个 generator 将要永远运行——只要我们一直向它请求,它就一直 产生 值。

现在我们可以在for..of循环中使用新得发亮的*something()generator 了,而且你会看到它工作起来基本一模一样:

for (var v of something()) {
    console.log( v );

    // 不要让循环永无休止!
    if (v > 500) {
        break;
    }
}
// 1 9 33 105 321 969

不要跳过for (var v of something()) ..!我们不仅仅像之前的例子那样将something作为一个值引用了,而是调用*something()generator 来得到它的 迭代器,并交给for..of使用。

如果你仔细观察,在这个 generator 和循环的互动中,你可能会有两个疑问:

  • 为什么我们不能说for (var v of something) ..?因为这个something是一个 generator,而不是一个 iterable。我们不得不调用something()来构建一个发生器给for..of,以便它可以迭代。
  • something()调用创建一个 迭代器,但是for..of想要一个 iterable,对吧?对,generator 的 迭代器 上也有一个Symbol.iterator函数,这个函数基本上就是return this,就像我们刚才定义的somethingiterable。换句话说 generator 的 迭代器 也是一个 iterable

停止 Generator

在前一个例子中,看起来在循环的break别调用后,*something()generator 的 迭代器 实例基本上被留在了一个永远挂起的状态。

但是这里有一个隐藏的行为为你处理这件事。for..of循环的“异常完成”(“提前终结”等等)——一般是由breakreturn,或未捕捉的异常导致的——会向 generator 的 迭代器 发送一个信号,以使它终结。

注意: 技术上讲,for..of循环也会在循环正常完成时向 迭代器 发送这个信号。对于 generator 来说,这实质上是一个无实际意义的操作,因为 generator 的 迭代器 要首先完成,for..of循环才能完成。然而,自定义的 迭代器 可能会希望从for..of循环的消费者那里得到另外的信号。

虽然一个for..of循环将会自动发送这种信号,你可能会希望手动发送信号给一个 迭代器;你可以通过调用return(..)来这么做。

如果你在 generator 内部指定一个try..finally从句,它将总是被执行,即便是 generator 从外部被完成。这在你需要进行资源清理时很有用(数据库连接等):

function *something() {
    try {
        var nextVal;

        while (true) {
            if (nextVal === undefined) {
                nextVal = 1;
            }
            else {
                nextVal = (3 * nextVal) + 6;
            }

            yield nextVal;
        }
    }
    // 清理用的从句
    finally {
        console.log( "cleaning up!" );
    }
}

前面那个在for..of中带有break的例子将会触发finally从句。但是你可以用return(..)从外部来手动终结 generator 的 迭代器 实例:

var it = something();
for (var v of it) {
    console.log( v );

    // 不要让循环永无休止!
    if (v > 500) {
        console.log(
            // 使 generator 得迭代器完成
            it.return( "Hello World" ).value
        );
        // 这里不需要`break`
    }
}
// 1 9 33 105 321 969
// cleaning up!
// Hello World

当我们调用it.return(..)时,它会立即终结 generator,从而运行finally从句。而且,它会将返回的value设置为你传入return(..)的任何东西,这就是Hellow World如何立即返回来的。我们现在也不必再包含一个break,因为 generator 的 迭代器 会被设置为done:true,所以for..of循环会在下一次迭代时终结。

generator 的命名大部分源自于这种 消费生产的值 的用法。但要重申的是,这只是 generator 的用法之一,而且坦白的说,在这本书的背景下这甚至不是我们主要关注的。

但是现在我们更加全面地了解它们的机制是如何工作的,我们接下来可以将注意力转向 generator 如何实施于异步并发。

异步地迭代 Generator

generator 要怎样处理异步编码模式,解决回调和类似的问题?让我们开始回答这个重要的问题。

我们应当重温一下第三章的一个场景。回想一下这个回调方式:

function foo(x,y,cb) {
    ajax(
        "http://some.url.1/?x=" + x + "&y=" + y,
        cb
    );
}

foo( 11, 31, function(err,text) {
    if (err) {
        console.error( err );
    }
    else {
        console.log( text );
    }
} );

如果我们想用 generator 表示相同的任务流控制,我们可以:

function foo(x,y) {
    ajax(
        "http://some.url.1/?x=" + x + "&y=" + y,
        function(err,data){
            if (err) {
                // 向`*main()`中扔进一个错误
                it.throw( err );
            }
            else {
                // 使用收到的`data`来继续`*main()`
                it.next( data );
            }
        }
    );
}

function *main() {
    try {
        var text = yield foo( 11, 31 );
        console.log( text );
    }
    catch (err) {
        console.error( err );
    }
}

var it = main();

// 使一切开始运行!
it.next();

一眼看上去,这个代码段要比以前的回调代码更长,而且也许看起来更复杂。但不要让这种印象误导你。generator 的代码段实际上要好 太多 了!但是这里有很多我们需要讲解的。

首先,让我们看看代码的这一部分,也是最重要的部分:

var text = yield foo( 11, 31 );
console.log( text );

花一点时间考虑一下这段代码如何工作。我们调用了一个普通的函数foo(..),而且我们显然可以从 Ajax 调用那里得到text,即便它是异步的。

这怎么可能?如果你回忆一下第一章的最开始,我们有一个几乎完全一样的代码:

var data = ajax( "..url 1.." );
console.log( data );

但是这段代码不好用!你能发现不同吗?它就是在 generator 中使用的yield

这就是魔法发生的地方!是它允许我们拥有一个看起来是阻塞的,同步的,但实际上不会阻塞整个程序的代码;它仅仅暂停/阻塞在 generator 本身的代码。

yield foo(11,31)中,首先foo(11,31)调用被发起,它什么也不返回(也就是undefined),所以我们发起了数据请求,然后我们实际上做的是yield undefined。这没问题,因为这段代码现在没有依赖yield的值来做任何有趣的事。我们在本章稍后再重新讨论这个问题。

在这里,我们没有将yield作为消息传递的工具,只是作为进行暂停/阻塞的流程控制的工具。实际上,它会传递消息,但是只是单向的,在 generator 被继续运行之后。

那么,generator 暂停在了yield,它实质上再问一个问题,“我该将什么值返回并赋给变量text?”谁来回答这个问题?

看一下foo(..)。如果 Ajax 请求成功,我们调用:

it.next( data );

这将使 generator 使用应答数据继续运行,这意味着我们暂停的yield表达式直接收到这个值,然后因为它重新开始以运行 generator 代码,所以这个值被赋给本地变量text

很酷吧?

退一步考虑一下它的意义。我们在 generator 内部的代码看起来完全是同步的(除了yield关键字本身),但隐藏在幕后的是,在foo(..)内部,操作可以完全是异步的。

这很伟大! 这几乎完美地解决了我们前面遇到的问题:回调不能像我们的大脑可以关联的那样,以一种顺序,同步的风格表达异步处理。

实质上,我们将异步处理作为实现细节抽象出去,以至于我们可以同步地/顺序地推理我们的流程控制:“发起 Ajax 请求,然后在它完成之后打印应答。” 当然,我们仅仅在这个流程控制中表达了两个步骤,但同样的能力可以无边界地延伸,让我们需要表达多少步骤,就表达多少。

提示: 这是一个如此重要的认识,为了充分理解,现在回过头去再把最后三段读一遍!

同步错误处理

但是前面的 generator 代码会 出更多的好处给我们。让我们把注意力移到 generator 内部的try..catch上:

try {
    var text = yield foo( 11, 31 );
    console.log( text );
}
catch (err) {
    console.error( err );
}

这是怎么工作的?foo(..)调用是异步完成的,try..catch不是无法捕捉异步错误吗?就像我们在第三章中看到的?

我们已经看到了yield如何让赋值语句暂停,来等待foo(..)去完成,以至于完成的响应可以被赋予text。牛 X 的是,yield暂停 允许 generator 来catch一个错误。我们在前面的例子,我们用这一部分代码将这个错误抛出到 generator 中:

if (err) {
    // 向`*main()`中扔进一个错误
    it.throw( err );
}

generator 的yield暂停特性不仅意味着我们可以从异步的函数调用那里得到看起来同步的return值,还意味着我们可以同步地捕获这些异步函数调用的错误!

那么我们看到了,我们可以将错误 抛入 generator,但是将错误 抛出 一个 generator 呢?和你期望的一样:

function *main() {
    var x = yield "Hello World";

    yield x.toLowerCase();    // 引发一个异常!
}

var it = main();

it.next().value;            // Hello World

try {
    it.next( 42 );
}
catch (err) {
    console.error( err );    // TypeError
}

当然,我们本可以用throw ..手动地抛出一个错误,而不是制造一个异常。

我们甚至可以catch我们throw(..)进 generator 的同一个错误,实质上给了 generator 一个机会来处理它,但如果 generator 没处理,那么 迭代器 代码必须处理它:

function *main() {
    var x = yield "Hello World";

    // 永远不会跑到这里
    console.log( x );
}

var it = main();

it.next();

try {
    // `*main()`会处理这个错误吗?我们走着瞧!
    it.throw( "Oops" );
}
catch (err) {
    // 不,它没处理!
    console.error( err );            // Oops
}

使用异步代码的,看似同步的错误处理(通过try..catch)在可读性和可推理性上大获全胜。

Generators + Promises

在我们前面的讨论中,我们展示了 generator 如何可以异步地迭代,这是一个用顺序的可推理性来取代混乱如面条的回调的一个巨大进步。但我们丢掉了两个非常重要的东西:Promise 的可靠性和可组合性(见第三章)!

别担心——我们会把它们拿回来。在 ES6 的世界中最棒的就是将 generator(看似同步的异步代码)与 Promise(可靠性和可组合性)组合起来。

但怎么做呢?

回想一下第三章中我们基于 Promise 的方式运行 Ajax 的例子:

function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

foo( 11, 31 )
.then(
    function(text){
        console.log( text );
    },
    function(err){
        console.error( err );
    }
);

在我们早先的运行 Ajax 的例子的 generator 代码中,foo(..)什么也不返回(undefined),而且我们的 迭代器 控制代码也不关心yield的值。

但这里的 Promise 相关的foo(..)在发起 Ajax 调用后返回一个 promise。这暗示着我们可以用foo(..)构建一个 promise,然后从 generator 中yield出来,而后 迭代器 控制代码将可以收到这个 promise。

那么 迭代器 应当对 promise 做什么?

它应当监听 promise 的解析(完成或拒绝),然后要么使用完成消息继续运行 generator,要么使用拒绝理由向 generator 抛出错误。

让我重复一遍,因为它如此重要。发挥 Promise 和 generator 的最大功效的自然方法是 yield一个 Promise,并将这个 Promise 连接到 generator 的 迭代器 的控制端。

让我们试一下!首先,我们将 Promise 相关的foo(..)与 generator*main()放在一起:

function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

function *main() {
    try {
        var text = yield foo( 11, 31 );
        console.log( text );
    }
    catch (err) {
        console.error( err );
    }
}

在这个重构中最强大的启示是,*main()内部的代码 更本就没变! 在 generator 内部,无论什么样的值被yield出去都是一个不可见的实现细节,所以我们甚至不会察觉它发生了,也不用担心它。

那么我们现在如何运行*main()?我们还有一些管道的实现工作要做,接收并连接yield的 promise,使它能够根据解析来继续运行 generator。我们从手动这么做开始:

var it = main();

var p = it.next().value;

// 等待`p` promise 解析
p.then(
    function(text){
        it.next( text );
    },
    function(err){
        it.throw( err );
    }
);

其实,根本不费事,对吧?

这段代码应当看起来与我们早前做的很相似:手动地连接被错误优先的回调控制的 generator。与if (err) { it.throw..不同的是,promise 已经为我们分割为完成(成功)与拒绝(失败),否则 迭代器 控制是完全相同的。

现在,我们已经掩盖了一些重要的细节。

最重要的是,我们利用了这样一个事实:我们知道*main()里面只有一个 Promise 相关的步骤。如果我们想要能用 Promise 驱动一个 generator 而不管它有多少步骤呢?我们当然不想为每一个 generator 手动编写一个不同的 Promise 链!要是有这样一种方法该多好:可以重复(也就是“循环”)迭代的控制,而且每次一有 Promise 出来,就在继续之前等待它的解析。

另外,如果 generator 在it.next()调用期间抛出一个错误怎么办?我们是该退出,还是应该catch它并把它送回去?相似地,要是我们it.throw(..)一个 Promise 拒绝给 generator,但是没有被处理,又直接回来了呢?

带有 Promise 的 Generator 运行器

你在这条路上探索得越远,你就越能感到,“哇,要是有一些工具能帮我做这些就好了。”而且你绝对是对的。这是一种如此重要的模式,而且你不想把它弄错(或者因为一遍又一遍地重复它而把自己累死),所以你最好的选择是把赌注压在一个工具上,而它以我们将要描述的方式使用这种特定设计的工具来 运行 yieldPromise 的 generator。

有几种 Promise 抽象库提供了这样的工具,包括我的 asynquence 库和它的runner(..),我们将在本书的在附录 A 中讨论它。

但看在学习和讲解的份儿上,让我们定义我们自己的名为run(..)的独立工具:

// 感谢 Benjamin Gruenbaum (@benjamingr 在 GitHub)在此做出的巨大改进!
function run(gen) {
    var args = [].slice.call( arguments, 1), it;

    // 在当前的上下文环境中初始化 generator
    it = gen.apply( this, args );

    // 为 generator 的完成返回一个 promise
    return Promise.resolve()
        .then( function handleNext(value){
            // 运行至下一个让出的值
            var next = it.next( value );

            return (function handleResult(next){
                // generator 已经完成运行了?
                if (next.done) {
                    return next.value;
                }
                // 否则继续执行
                else {
                    return Promise.resolve( next.value )
                        .then(
                            // 在成功的情况下继续异步循环,将解析的值送回 generator
                            handleNext,

                            // 如果`value`是一个拒绝的 promise,就将错误传播回 generator 自己的错误处理 g
                            function handleErr(err) {
                                return Promise.resolve(
                                    it.throw( err )
                                )
                                .then( handleResult );
                            }
                        );
                }
            })(next);
        } );
}

如你所见,它可能比你想要自己编写的东西复杂得多,特别是你将不会想为每个你使用的 generator 重复这段代码。所以,一个帮助工具/库绝对是可行的。虽然,我鼓励你花几分钟时间研究一下这点代码,以便对如何管理 generator+Promise 交涉得到更好的感觉。

你如何在我们 正在讨论 的 Ajax 例子中将run(..)*main()一起使用呢?

function *main() {
    // ..
}

run( main );

就是这样!按照我们连接run(..)的方式,它将自动地,异步地推进你传入的 generator,直到完成。

注意: 我们定义的run(..)返回一个 promise,它被连接成一旦 generator 完成就立即解析,或者收到一个未捕获的异常,而 generator 没有处理它。我们没有在这里展示这种能力,但我们会在本章稍后回到这个话题。

ES7: asyncawait

前面的模式——generator 让出一个 Promise,然后这个 Promise 控制 generator 的 迭代器 向前推进至它完成——是一个如此强大和有用的方法,如果我们能不通过乱七八糟的帮助工具库(也就是run(..))来使用它就更好了。

在这方面可能有一些好消息。在写作这本书的时候,后 ES6,ES7 化的时间表上已经出现了草案,对这个问题提供早期但强大的附加语法支持。显然,现在还太早而不能保证其细节,但是有相当大的可能性它将蜕变为类似于下面的东西:

function foo(x,y) {
    return request(
        "http://some.url.1/?x=" + x + "&y=" + y
    );
}

async function main() {
    try {
        var text = await foo( 11, 31 );
        console.log( text );
    }
    catch (err) {
        console.error( err );
    }
}

main();

如你所见,这里没有run(..)调用(意味着不需要工具库!)来驱动和调用main()——它仅仅像一个普通函数那样被调用。另外,main()不再作为一个 generator 函数声明;它是一种新型的函数:async function。而最后,与yield一个 Promise 相反,我们await它解析。

如果你await一个 Promise,async function会自动地知道做什么——它会暂停这个函数(就像使用 generator 那样)直到 Promise 解析。我们没有在这个代码段中展示,但是调用一个像main()这样的异步函数将自动地返回一个 promise,它会在函数完全完成时被解析。

提示: async / await的语法应该对拥有 C#经验的读者看起来非常熟悉,因为它们基本上是一样的。

这个草案实质上是为我们已经衍生出的模式进行代码化的支持,成为一种语法机制:用看似同步的流程控制代码与 Promise 组合。将两个世界的最好部分组合,来有效解决我们用回调遇到的几乎所有主要问题。

这样的 ES7 化草案已经存在,并且有了早期的支持和热忱的拥护。这一事实为这种异步模式在未来的重要性上信心满满地投了有力的一票。

Generator 中的 Promise 并发

至此,所有我们展示过的是一种使用 Promise+generator 的单步异步流程。但是现实世界的代码将总是有许多异步步骤。

如果你不小心,generator 看似同步的风格也许会蒙蔽你,使你在如何构造你的异步并发上感到自满,导致性能次优的模式。那么我们想花一点时间来探索一下其他选项。

想象一个场景,你需要从两个不同的数据源取得数据,然后将这些应答组合来发起第三个请求,最后打印出最终的应答。我们在第三章中用 Promise 探索过类似的场景,但这次让我们在 generator 的环境下考虑它。

你的第一直觉可能是像这样的东西:

function *foo() {
    var r1 = yield request( "http://some.url.1" );
    var r2 = yield request( "http://some.url.2" );

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// 使用刚才定义的`run(..)`工具
run( foo );

这段代码可以工作,但在我们特定的这个场景中,它不是最优的。你能发现为什么吗?

因为r1r2请求可以——而且为了性能的原因,应该——并发运行,但在这段代码中它们将顺序地运行;直到"http://some.url.1"请求完成之前,"http://some.url.2"URL 不会被 Ajax 取得。这两个请求是独立的,所以性能更好的方式可能是让它们同时运行。

但是使用 generator 和yield,到底应该怎么做?我们知道yield在代码中只是一个单独的暂停点,所以你根本不能再同一时刻做两次暂停。

最自然和有效的答案是基于 Promise 的异步流程,特别是因为它们的时间无关的状态管理能力(参见第三章的“未来的值”)。

最简单的方式:

function *foo() {
    // 使两个请求“并行”
    var p1 = request( "http://some.url.1" );
    var p2 = request( "http://some.url.2" );

    // 等待两个 promise 都被解析
    var r1 = yield p1;
    var r2 = yield p2;

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// 使用刚才定义的`run(..)`工具
run( foo );

为什么这与前一个代码段不同?看看yield在哪里和不在哪里。p1p2是并发地(也就是“并行”)发起的 Ajax 请求 promise。它们哪一个先完成都不要紧,因为 promise 会一直保持它们的解析状态。

然后我们使用两个连续的yield语句等待并从 promise 中取得解析值(分别取到r1r2中)。如果p1首先解析,yield p1会首先继续执行然后等待yield p2继续执行。如果p2首先解析,它将会耐心地保持解析值知道被请求,但是yield p1将会首先停住,直到p1解析。

不管是哪一种情况,p1p2都将并发地运行,并且在r3 = yield request..Ajax 请求发起之前,都必须完成,无论以哪种顺序。

如果这种流程控制处理模型听起来很熟悉,那是因为它基本上和我们在第三章中介绍的,因Promise.all([ .. ])工具成为可能的“门”模式是相同的。所以,我们也可以像这样表达这种流程控制:

function *foo() {
    // 使两个请求“并行”并等待两个 promise 都被解析
    var results = yield Promise.all( [
        request( "http://some.url.1" ),
        request( "http://some.url.2" )
    ] );

    var r1 = results[0];
    var r2 = results[1];

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// 使用前面定义的`run(..)`工具
run( foo );

注意: 就像我们在第三章中讨论的,我们甚至可以用 ES6 解构赋值来把var r1 = .. var r2 = ..赋值简写为var [r1,r2] = results

换句话说,在 generator+Promise 的方式中,Promise 所有的并发能力都是可用的。所以在任何地方,如果你需要比“这个然后那个”要复杂的顺序异步流程步骤时,Promise 都可能是最佳选择。

Promises,隐藏起来

作为代码风格的警告要说一句,要小心你在 你的 generator 内部 包含了多少 Promise 逻辑。以我们描述过的方式在异步性上使用 generator 的全部意义,是要创建简单,顺序,看似同步的代码,并尽可能多地将异步性细节隐藏在这些代码之外。

比如,这可能是一种更干净的方式:

// 注意:这是一个普通函数,不是 generator
function bar(url1,url2) {
    return Promise.all( [
        request( url1 ),
        request( url2 )
    ] );
}

function *foo() {
    // 将基于 Promise 的并发细节隐藏在`bar(..)`内部
    var results = yield bar(
        "http://some.url.1",
        "http://some.url.2"
    );

    var r1 = results[0];
    var r2 = results[1];

    var r3 = yield request(
        "http://some.url.3/?v=" + r1 + "," + r2
    );

    console.log( r3 );
}

// 使用刚才定义的`run(..)`工具
run( foo );

*foo()内部,它更干净更清晰地表达了我们要做的事情:我们要求bar(..)给我们一些results,而我们将用yield等待它的发生。我们不必关心在底层一个Promise.all([ .. ])的 Promise 组合将被用来完成任务。

我们将异步性,特别是 Promise,作为一种实现细节。

如果你要做一种精巧的序列流控制,那么将你的 Promise 逻辑隐藏在一个仅仅从你的 generator 中调用的函数里特别有用。举个例子:

function bar() {
    Promise.all( [
        baz( .. )
        .then( .. ),
        Promise.race( [ .. ] )
    ] )
    .then( .. )
}

有时候这种逻辑是必须的,而如果你直接把它扔在你的 generator 内部,你就违背了大多数你使用 generator 的初衷。我们 应当 有意地将这样的细节从 generator 代码中抽象出去,以使它们不会搞乱更高层的任务表达。

在创建功能强与性能好的代码之上,你还应当努力使代码尽可能地容易推理和维护。

注意: 对于编程来说,抽象不总是一种健康的东西——许多时候它可能在得到简洁的同时增加复杂性。但是在这种情况下,我相信你的 generator+Promise 异步代码要比其他的选择健康得多。虽然有所有这些建议,你仍然要注意你的特殊情况,并为你和你的团队做出合适的决策。

你不懂 JS: 异步与性能 第四章: Generator(下)

Generator 委托

在上一节中,我们展示了从 generator 内部调用普通函数,和它如何作为一种有用的技术来将实现细节(比如异步 Promise 流程)抽象出去。但是为这样的任务使用普通函数的缺陷是,它必须按照普通函数的规则行动,也就是说它不能像 generator 那样用yield来暂停自己。

在你身上可能发生这样的事情:你可能会试着使用我们的run(..)帮助函数,从一个 generator 中调用另个一 generator。比如:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

function *bar() {
    var r1 = yield request( "http://some.url.1" );

    // 通过`run(..)`“委托”到`*foo()`
    var r3 = yield run( foo );

    console.log( r3 );
}

run( bar );

通过再一次使用我们的run(..)工具,我们在*bar()内部运行*foo()。我们利用了这样一个事实:我们早先定义的run(..)返回一个 promise,这个 promise 在 generator 运行至完成时才解析(或发生错误),所以如果我们从一个run(..)调用中yield出一个 promise 给另一个run(..),它就会自动暂停*bar()直到*foo()完成。

但这里有一个更好的办法将*foo()调用整合进*bar(),它称为yield委托。yield委托的特殊语法是:yield * __(注意额外的*)。让它在我们前面的例子中工作之前,让我们看一个更简单的场景:

function *foo() {
    console.log( "`*foo()` starting" );
    yield 3;
    yield 4;
    console.log( "`*foo()` finished" );
}

function *bar() {
    yield 1;
    yield 2;
    yield *foo();    // `yield`-delegation!
    yield 5;
}

var it = bar();

it.next().value;    // 1
it.next().value;    // 2
it.next().value;    // `*foo()` starting
                    // 3
it.next().value;    // 4
it.next().value;    // `*foo()` finished
                    // 5

注意: 在本章早前的一个注意点中,我解释了为什么我偏好function *foo() ..而不是function* foo() ..,相似地,我也偏好——与关于这个话题的其他大多数文档不同——说yield *foo()而不是yield* foo()*的摆放是纯粹的风格问题,而且要看你的最佳判断。但我发现保持统一风格很吸引人。

yield *foo()委托是如何工作的?

首先,正如我们看到过的那样,调用foo()创建了一个 迭代器。然后,yield *将(当前*bar()generator 的) 迭代器 的控制委托/传递给这另一个*foo()迭代器

那么,前两个it.next()调用控制着*bar(),但当我们发起第三个it.next()调用时,*foo()就启动了,而且这时我们控制的是*foo()而非*bar()。这就是为什么它称为委托——*bar()将它的迭代控制委托给*foo()

只要it迭代器 的控制耗尽了整个*foo()迭代器,它就会自动地将控制返回到*bar()

那么现在回到前面的三个顺序 Ajax 请求的例子:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

function *bar() {
    var r1 = yield request( "http://some.url.1" );

    // 通过`run(..)`“委托”到`*foo()`
    var r3 = yield *foo();

    console.log( r3 );
}

run( bar );

这个代码段和前面使用的版本的唯一区别是,使用了yield *foo()而不是前面的yield run(foo)

注意: yield *让出了迭代控制,不是 generator 控制;当你调用*foo()generator 时,你就yield委托给它的 迭代器。但你实际上可以yield委托给任何 迭代器yield *[1,2,3]将会消费默认的[1,2,3]数组值 迭代器

为什么委托?

yield委托的目的很大程度上是为了代码组织,而且这种方式是与普通函数调用对称的。

想象两个分别提供了foo()bar()方法的模块,其中bar()调用foo()。它们俩分开的原因一般是由于为了程序将它们作为分离的程序来调用而进行的恰当组织。例如,可能会有一些情况foo()需要被独立调用,而其他地方bar()来调用foo()

由于这些完全相同的原因,将 generator 分开可以增强程序的可读性,可维护性,与可调试性。从这个角度讲,yield *是一种快捷的语法,用来在*bar()内部手动地迭代*foo()的步骤。

如果*foo()中的步骤是异步的,这样的手动方式可能会特别复杂,这就是为什么你可能会需要那个run(..)工具来做它。正如我们已经展示的,yield *foo()消灭了使用run(..)工具的子实例(比如run(foo))的需要。

委托消息

你可能想知道,这种yield委托在除了与 迭代器 控制一起工作以外,是如何与双向消息传递一起工作的。仔细查看下面这些通过yield委托进进出出的消息流:

function *foo() {
    console.log( "inside `*foo()`:", yield "B" );

    console.log( "inside `*foo()`:", yield "C" );

    return "D";
}

function *bar() {
    console.log( "inside `*bar()`:", yield "A" );

    // `yield`-委托!
    console.log( "inside `*bar()`:", yield *foo() );

    console.log( "inside `*bar()`:", yield "E" );

    return "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B

console.log( "outside:", it.next( 2 ).value );
// inside `*foo()`: 2
// outside: C

console.log( "outside:", it.next( 3 ).value );
// inside `*foo()`: 3
// inside `*bar()`: D
// outside: E

console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: 4
// outside: F

特别注意一下it.next(3)调用之后的处理步骤:

  1. 3被传入(通过*bar里的yield委托)在*foo()内部等待中的yield "C"表达式。
  2. 然后*foo()调用return "D",但是这个值不会一路返回到外面的it.next(3)调用。
  3. 相反地,值"D"作为结果被发送到在*bar()内部等待中的yield *foo()表示式——这个yield委托表达式实质上在*foo()被耗尽之前一直被暂停着。所以"D"被送到*bar()内部来让它打印。
  4. yield "E"*bar()内部被调用,而且值"E"被让出到外部作为it.next(3)调用的结果。

从外部 迭代器it)的角度来看,在初始的 generator 和被委托的 generator 之间的控制没有任何区别。

事实上,yield委托甚至不必指向另一个 generator;它可以仅被指向一个非 generator 的,一般的 iterable。比如:

function *bar() {
    console.log( "inside `*bar()`:", yield "A" );

    // `yield`-委托至一个非 generator
    console.log( "inside `*bar()`:", yield *[ "B", "C", "D" ] );

    console.log( "inside `*bar()`:", yield "E" );

    return "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// inside `*bar()`: 1
// outside: B

console.log( "outside:", it.next( 2 ).value );
// outside: C

console.log( "outside:", it.next( 3 ).value );
// outside: D

console.log( "outside:", it.next( 4 ).value );
// inside `*bar()`: undefined
// outside: E

console.log( "outside:", it.next( 5 ).value );
// inside `*bar()`: 5
// outside: F

注意这个例子与前一个之间,被接收/报告的消息的不同之处。

最惊人的是,默认的array迭代器 不关心任何通过next(..)调用被发送的消息,所以值23,与4实质上被忽略了。另外,因为这个 迭代器 没有明确的return值(不像前面使用的*foo()),所以yield *表达式在它完成时得到一个undefined

异常也委托!

yield委托在两个方向上透明地传递消息的方式相同,错误/异常也在双向传递:

function *foo() {
    try {
        yield "B";
    }
    catch (err) {
        console.log( "error caught inside `*foo()`:", err );
    }

    yield "C";

    throw "D";
}

function *bar() {
    yield "A";

    try {
        yield *foo();
    }
    catch (err) {
        console.log( "error caught inside `*bar()`:", err );
    }

    yield "E";

    yield *baz();

    // note: can't get here!
    yield "G";
}

function *baz() {
    throw "F";
}

var it = bar();

console.log( "outside:", it.next().value );
// outside: A

console.log( "outside:", it.next( 1 ).value );
// outside: B

console.log( "outside:", it.throw( 2 ).value );
// error caught inside `*foo()`: 2
// outside: C

console.log( "outside:", it.next( 3 ).value );
// error caught inside `*bar()`: D
// outside: E

try {
    console.log( "outside:", it.next( 4 ).value );
}
catch (err) {
    console.log( "error caught outside:", err );
}
// error caught outside: F

在这段代码中有一些事情要注意:

  1. 但我们调用it.throw(2)时,它发送一个错误消息2*bar(),而*bar()将它委托至*foo(),然后*foo()catch它并平静地处理。之后,yield "C""C"作为返回的value发送回it.throw(2)调用。
  2. 接下来值"D"被从*foo()内部throw出来并传播到*bar()*bar()catch它并平静地处理。然后yield "E""E"作为返回的value发送回it.next(3)调用。
  3. 接下来,一个异常从*baz()throw出来,而没有被*bar()捕获——我们没在外面catch它——所以*baz()*bar()都被设置为完成状态。这段代码结束后,即便有后续的next(..)调用,你也不会得到值"G"——它们的value将返回undefined

异步委托

最后让我们回到早先的多个顺序 Ajax 请求的例子,使用yield委托:

function *foo() {
    var r2 = yield request( "http://some.url.2" );
    var r3 = yield request( "http://some.url.3/?v=" + r2 );

    return r3;
}

function *bar() {
    var r1 = yield request( "http://some.url.1" );

    var r3 = yield *foo();

    console.log( r3 );
}

run( bar );

*bar()内部,与调用yield run(foo)不同的是,我们调用yield *foo()就可以了。

在前一个版本的这个例子中,Promise 机制(通过run(..)控制的)被用于将值从*foo()中的return r3传送到*bar()内部的本地变量r3。现在,这个值通过yield *机制直接返回。

除此以外,它们的行为是一样的。

“递归”委托

当然,yield委托可以一直持续委托下去,你想连接多少步骤就连接多少。你甚至可以在具有异步能力的 generator 上“递归”使用yield委托——一个yield委托至自己的 generator:

function *foo(val) {
    if (val > 1) {
        // 递归委托
        val = yield *foo( val - 1 );
    }

    return yield request( "http://some.url/?v=" + val );
}

function *bar() {
    var r1 = yield *foo( 3 );
    console.log( r1 );
}

run( bar );

注意: 我们的run(..)工具本可以用run( foo, 3 )来调用,因为它支持用额外传递的参数来进行 generator 的初始化。然而,为了在这里高调展示yield *的灵活性,我们使用了无参数的*bar()

这段代码之后的处理步骤是什么?坚持住,它的细节要描述起来可是十分错综复杂:

  1. run(bar)启动了*bar()generator。
  2. foo(3)*foo(..)创建了 迭代器 并传递3作为它的val参数。
  3. 因为3 > 1foo(2)创建了另一个 迭代器 并传递2作为它的val参数。
  4. 因为2 > 1foo(1)又创建了另一个 迭代器 并传递1作为它的val参数。
  5. 1 > 1false,所以我们接下来用值1调用request(..),并得到一个代表第一个 Ajax 调用的 promise。
  6. 这个 promise 被yield出来,回到*foo(2)generator 实例。
  7. yield *将这个 promise 传出并回到*foo(3)生成 generator。另一个yield *把这个 promise 传出到*bar()generator 实例。而又有另一个yield *把这个 promise 传出到run(..)工具,而它将会等待这个 promise(第一个 Ajax 请求)再处理。
  8. 当这个 promise 解析时,它的完成消息会被发送以继续*bar()*bar()通过yield *把消息传递进*foo(3)实例,*foo(3)实例通过yield *把消息传递进*foo(2)generator 实例,*foo(2)实例通过yield *把消息传给那个在*foo(3)generator 实例中等待的一般的yield
  9. 这第一个 Ajax 调用的应答现在立即从*foo(3)generator 实例中被return,作为*foo(2)实例中yield *表达式的结果发送回来,并赋值给本地val变量。
  10. *foo(2)内部,第二个 Ajax 请求用request(..)发起,它的 promise 被yield回到*foo(1)实例,然后一路yield *传播到run(..)(回到第 7 步)。当 promise 解析时,第二个 Ajax 应答一路传播回到*foo(2)generator 实例,并赋值到他本地的val变量。
  11. 最终,第三个 Ajax 请求用request(..)发起,它的 promise 走出到run(..),然后它的解析值一路返回,最后被return到在*bar()中等待的yield *表达式。

天!许多疯狂的头脑杂技,对吧?你可能想要把它通读几遍,然后抓点儿零食放松一下大脑!

Generator 并发

正如我们在第一章和本章早先讨论过的,另个同时运行的“进程”可以协作地穿插它们的操作,而且许多时候这可以产生非常强大的异步表达式。

坦白地说,我们前面关于多个 generator 并发穿插的例子,展示了这真的容易让人糊涂。但我们也受到了启发,有些地方这种能力十分有用。

回想我们在第一章中看过的场景,两个不同但同时的 Ajax 应答处理需要互相协调,来确保数据通信不是竟合状态。我们这样把应答分别放在res数组的不同位置中:

function response(data) {
    if (data.url == "http://some.url.1") {
        res[0] = data;
    }
    else if (data.url == "http://some.url.2") {
        res[1] = data;
    }
}

但是我们如何在这种场景下使用多 generator 呢?

// `request(..)` 是一个基于 Promise 的 Ajax 工具

var res = [];

function *reqData(url) {
    res.push(
        yield request( url )
    );
}

注意: 我们将在这里使用两个*reqData(..)generator 的实例,但是这和分别使用两个不同 generator 的一个实例没有区别;这两种方式在道理上完全一样的。我们过一会儿就会看到两个 generator 的协调操作。

与不得不将res[0]res[1]赋值手动排序不同,我们将使用协调过的顺序,让res.push(..)以可预见的顺序恰当地将值放在预期的位置。如此被表达的逻辑会让人感觉更干净。

但是我们将如何实际安排这种互动呢?首先,让我们手动实现它:

var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );

var p1 = it1.next().value;
var p2 = it2.next().value;

p1
.then( function(data){
    it1.next( data );
    return p2;
} )
.then( function(data){
    it2.next( data );
} );

*reqData(..)的两个实例都开始发起它们的 Ajax 请求,然后用yield暂停。之后我们再p1解析时继续运行第一个实例,而后来的p2的解析将会重启第二个实例。以这种方式,我们使用 Promise 的安排来确保res[0]将持有第一个应答,而res[1]持有第二个应答。

但坦白地说,这是可怕的手动,而且它没有真正让 generator 组织它们自己,而那才是真正的力量。让我们用不同的方法试一下:

// `request(..)` 是一个基于 Promise 的 Ajax 工具

var res = [];

function *reqData(url) {
    var data = yield request( url );

    // 传递控制权
    yield;

    res.push( data );
}

var it1 = reqData( "http://some.url.1" );
var it2 = reqData( "http://some.url.2" );

var p1 = it1.next().value;
var p2 = it2.next().value;

p1.then( function(data){
    it1.next( data );
} );

p2.then( function(data){
    it2.next( data );
} );

Promise.all( [p1,p2] )
.then( function(){
    it1.next();
    it2.next();
} );

好的,这看起来好些了(虽然仍然是手动),因为现在两个*reqData(..)的实例真正地并发运行了,而且(至少是在第一部分)是独立的。

在前一个代码段中,第二个实例在第一个实例完全完成之前没有给出它的数据。但是这里,只要它们的应答一返回这两个实例就立即分别收到他们的数据,然后每个实例调用另一个yield来传送控制。最后我们在Promise.all([ .. ])的处理器中选择用什么样的顺序继续它们。

可能不太明显的是,这种方式因其对称性启发了一种可复用工具的简单形式。让我们想象使用一个称为runAll(..)的工具:

// `request(..)` 是一个基于 Promise 的 Ajax 工具

var res = [];

runAll(
    function*(){
        var p1 = request( "http://some.url.1" );

        // 传递控制权
        yield;

        res.push( yield p1 );
    },
    function*(){
        var p2 = request( "http://some.url.2" );

        // 传递控制权
        yield;

        res.push( yield p2 );
    }
);

注意: 我们没有包含runAll(..)的实现代码,不仅因为它长得无法行文,也因为它是一个我们已经在先前的 run(..)中实现的逻辑的扩展。所以,作为留给读者的一个很好的补充性练习,请你自己动手改进run(..)的代码,来使它像想象中的runAll(..)那样工作。另外,我的 asynquence 库提供了一个前面提到过的runner(..)工具,它内建了这种能力,我们将在本书的附录 A 中讨论它。

这是runAll(..)内部的处理将如何操作:

  1. 第一个 generator 得到一个代表从"http://some.url.1"来的 Ajax 应答,然后将控制权yield回到runAll(..)工具。
  2. 第二个 generator 运行,并对"http://some.url.2"做相同的事,将控制权yield回到runAll(..)工具。
  3. 第一个 generator 继续,然后yield出他的 promisep1。在这种情况下runAll(..)工具和我们前面的run(..)做同样的事,它等待 promise 解析,然后继续这同一个 generator(没有控制传递!)。当p1解析时,runAll(..)使用解析值再一次继续第一个 generator,而后res[0]得到它的值。在第一个 generator 完成之后,有一个隐式的控制权传递。
  4. 第二个 generator 继续,yield出它的 promisep2,并等待它的解析。一旦p2解析,runAll(..)使用这个解析值继续第二个 generator,于是res[1]被设置。

在这个例子中,我们使用了一个称为res的外部变量来保存两个不同的 Ajax 应答的结果——这是我们的并发协调。

但是这样做可能十分有帮助:进一步扩展runAll(..)使它为多个 generator 实例提供 分享的 内部的变量作用域,比如一个我们将在下面称为data的空对象。另外,它可以接收被yield的非 Promise 值,并把它们交给下一个 generator。

考虑这段代码:

// `request(..)` 是一个基于 Promise 的 Ajax 工具

runAll(
    function*(data){
        data.res = [];

        // 传递控制权(并传递消息)
        var url1 = yield "http://some.url.2";

        var p1 = request( url1 ); // "http://some.url.1"

        // 传递控制权
        yield;

        data.res.push( yield p1 );
    },
    function*(data){
        // 传递控制权(并传递消息)
        var url2 = yield "http://some.url.1";

        var p2 = request( url2 ); // "http://some.url.2"

        // 传递控制权
        yield;

        data.res.push( yield p2 );
    }
);

在这个公式中,两个 generator 不仅协调控制传递,实际上还互相通信:通过data.res,和交换url1url2的值的yield消息。这强大到不可思议!

这样的认识也是一种更为精巧的称为 CSP(Communicating Sequential Processes——通信顺序处理)的异步技术的概念基础,我们将在本书的附录 B 中讨论它。

Thunks

至此,我们都假定从一个 generator 中yield一个 Promise——让这个 Promise 使用像run(..)这样的帮助工具来推进 generator——是管理使用 generator 的异步处理的最佳方法。明白地说,它是的。

但是我们跳过了一个被轻度广泛使用的模式,为了完整性我们将简单地看一看它。

在一般的计算机科学中,有一种老旧的前 JS 时代的概念,称为“thunk”。我们不在这里赘述它的历史,一个狭隘的表达是,thunk 是一个 JS 函数——没有任何参数——它连接并调用另一个函数。

换句话讲,你用一个函数定义包装函数调用——带着它需要的所有参数——来 推迟 这个调用的执行,而这个包装用的函数就是 thunk。当你稍后执行 thunk 时,你最终会调用那个原始的函数。

举个例子:

function foo(x,y) {
    return x + y;
}

function fooThunk() {
    return foo( 3, 4 );
}

// 稍后

console.log( fooThunk() );    // 7

所以,一个同步的 thunk 是十分直白的。但是一个异步的 thunk 呢?我们实质上可以扩展这个狭隘的 thunk 定义,让它接收一个回调。

考虑这段代码:

function foo(x,y,cb) {
    setTimeout( function(){
        cb( x + y );
    }, 1000 );
}

function fooThunk(cb) {
    foo( 3, 4, cb );
}

// 稍后

fooThunk( function(sum){
    console.log( sum );        // 7
} );

如你所见,fooThunk(..)仅需要一个cb(..)参数,因为它已经预先制定了值34(分别为xy)并准备传递给foo(..)。一个 thunk 只是在外面耐心地等待着它开始工作所需的最后一部分信息:回调。

但是你不会想要手动制造 thunk。那么,让我们发明一个工具来为我们进行这种包装。

考虑这段代码:

function thunkify(fn) {
    var args = [].slice.call( arguments, 1 );
    return function(cb) {
        args.push( cb );
        return fn.apply( null, args );
    };
}

var fooThunk = thunkify( foo, 3, 4 );

// 稍后

fooThunk( function(sum) {
    console.log( sum );        // 7
} );

提示: 这里我们假定原始的(foo(..))函数签名希望它的回调的位置在最后,而其它的参数在这之前。这是一个异步 JS 函数的相当普遍的“标准”。你可以称它为“回调后置风格”。如果因为某些原因你需要处理“回调优先风格”的签名,你只需要制造一个使用args.unshift(..)而非args.push(..)的工具。

前面的thunkify(..)公式接收foo(..)函数的引用,和任何它所需的参数,并返回 thunk 本身(fooThunk(..))。然而,这并不是你将在 JS 中发现的 thunk 的典型表达方式。

thunkify(..)制造 thunk 本身相反,典型的——可能有点儿让人困惑的——thunkify(..)工具将产生一个制造 thunk 的函数。

额...是的。

考虑这段代码:

function thunkify(fn) {
    return function() {
        var args = [].slice.call( arguments );
        return function(cb) {
            args.push( cb );
            return fn.apply( null, args );
        };
    };
}

这里主要的不同之处是有一个额外的return function() { .. }。这是它在用法上的不同:

var whatIsThis = thunkify( foo );

var fooThunk = whatIsThis( 3, 4 );

// 稍后

fooThunk( function(sum) {
    console.log( sum );        // 7
} );

明显地,这段代码隐含的最大的问题是,whatIsThis叫什么合适?它不是 thunk,它是一个从foo(..)调用生产 thunk 的东西。它是一种“thunk”的“工厂”。而且看起来没有任何标准的意见来命名这种东西。

所以,我的提议是“thunkory”("thunk" + "factory")。于是,thunkify(..)制造了一个 thunkory,而一个 thunkory 制造 thunks。这个道理与第三章中我的“promisory”提议是对称的:

var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// 稍后

fooThunk1( function(sum) {
    console.log( sum );        // 7
} );

fooThunk2( function(sum) {
    console.log( sum );        // 11
} );

注意: 这个例子中的foo(..)期望的回调不是“错误优先风格”。当然,“错误优先风格”更常见。如果foo(..)有某种合理的错误发生机制,我们可以改变而使它期望并使用一个错误优先的回调。后续的thunkify(..)不会关心回调被预想成什么样。用法的唯一区别是fooThunk1(function(err,sum){..

暴露出 thunkory 方法——而不是像早先的thunkify(..)那样将中间步骤隐藏起来——可能看起来像是没必要的混乱。但是一般来讲,在你的程序一开始就制造一些 thunkory 来包装既存 API 的方法是十分有用的,然后你就可以在你需要 thunk 的时候传递并调用这些 thunkory。这两个区别开的步骤保证了功能上更干净的分离。

来展示一下的话:

// 更干净:
var fooThunkory = thunkify( foo );

var fooThunk1 = fooThunkory( 3, 4 );
var fooThunk2 = fooThunkory( 5, 6 );

// 而这个不干净:
var fooThunk1 = thunkify( foo, 3, 4 );
var fooThunk2 = thunkify( foo, 5, 6 );

不管你是否愿意明确对付 thunkory,thunk(fooThunk1(..)fooThunk2(..))的用法还是一样的。

s/promise/thunk/

那么所有这些 thunk 的东西与 generator 有什么关系?

一般性地比较一下 thunk 和 promise:它们是不能直接互换的,因为它们在行为上不是等价的。比起单纯的 thunk,Promise 可用性更广泛,而且更可靠。

但从另一种意义上讲,它们都可以被看作是对一个值的请求,这个请求可能被异步地应答。

回忆第三章,我们定义了一个工具来 promise 化一个函数,我们称之为Promise.wrap(..)——我们本来也可以叫它promisify(..)的!这个 Promise 化包装工具不会生产 Promise;它生产那些继而生产 Promise 的 promisories。这和我们当前讨论的 thunkory 和 thunk 是完全对称的。

为了描绘这种对称性,让我们首先将foo(..)的例子改为假定一个“错误优先风格”回调的形式:

function foo(x,y,cb) {
    setTimeout( function(){
        // 假定 `cb(..)` 是“错误优先风格”
        cb( null, x + y );
    }, 1000 );
}

现在,我们将比较thunkify(..)promisify(..)(也就是第三章的Promise.wrap(..)):

// 对称的:构建问题的回答者
var fooThunkory = thunkify( foo );
var fooPromisory = promisify( foo );

// 对称的:提出问题
var fooThunk = fooThunkory( 3, 4 );
var fooPromise = fooPromisory( 3, 4 );

// 取得 thunk 的回答
fooThunk( function(err,sum){
    if (err) {
        console.error( err );
    }
    else {
        console.log( sum );        // 7
    }
} );

// 取得 promise 的回答
fooPromise
.then(
    function(sum){
        console.log( sum );        // 7
    },
    function(err){
        console.error( err );
    }
);

thunkory 和 promisory 实质上都是在问一个问题(一个值),thunk 的fooThunk和 promise 的fooPromise分别代表这个问题的未来的答案。这样看来,对称性就清楚了。

带着这个视角,我们可以看到为了异步而yieldPromise 的 generator,也可以为异步而yieldthunk。我们需要的只是一个更聪明的run(..)工具(就像以前一样),它不仅可以寻找并连接一个被yield的 Promise,而且可以给一个被yield的 thunk 提供回调。

考虑这段代码:

function *foo() {
    var val = yield request( "http://some.url.1" );
    console.log( val );
}

run( foo );

在这个例子中,request(..)既可以是一个返回一个 promise 的 promisory,也可以是一个返回一个 thunk 的 thunkory。从 generator 的内部代码逻辑的角度看,我们不关心这个实现细节,这就它强大的地方!

所以,request(..)可以使以下任何一种形式:

// promisory `request(..)` (见第三章)
var request = Promise.wrap( ajax );

// vs.

// thunkory `request(..)`
var request = thunkify( ajax );

最后,作为一个让我们早先的run(..)工具支持 thunk 的补丁,我们可能会需要这样的逻辑:

// ..
// 我们收到了一个回调吗?
else if (typeof next.value == "function") {
    return new Promise( function(resolve,reject){
        // 使用一个错误优先回调调用 thunk
        next.value( function(err,msg) {
            if (err) {
                reject( err );
            }
            else {
                resolve( msg );
            }
        } );
    } )
    .then(
        handleNext,
        function handleErr(err) {
            return Promise.resolve(
                it.throw( err )
            )
            .then( handleResult );
        }
    );
}

现在,我们 generator 既可以调用 promisory 来yieldPromise,也可以调用 thunkory 来yieldthunk,而不论那种情况,run(..)都将处理这个值并等待它的完成,以继续 generator。

在对称性上,这两个方式是看起来相同的。然而,我们应当指出这仅仅从 Promise 或 thunk 表示延续 generator 的未来值的角度讲是成立的。

从更高的角度讲,与 Promise 被设计成的那样不同,thunk 没有提供,它们本身也几乎没有任何可靠性和可组合性的保证。在这种特定的 generator 异步模式下使用一个 thunk 作为 Promise 的替代品是可以工作的,但与 Promise 提供的所有好处相比,这应当被看做是一种次理想的方法。

如果你有选择,那就偏向yield pr而非yield th。但是使run(..)工具可以处理两种类型的值本身没有什么问题。

注意: 在我们将要在附录 A 中讨论的,我的 asynquence 库中的runner(..)工具,可以处理yield的 Promise,thunk 和 asynquence 序列。

前 ES6 时代的 Generator

我希望你已经被说服了,generator 是一个异步编程工具箱里的非常重要的增强工具。但它是 ES6 中的新语法,这意味着你不能像填补 Promise(它只是新的 API)那样填补 generator。那么如果我们不能奢望忽略前 ES6 时代的浏览器,我们该如何将 generator 带到浏览器中呢?

对所有 ES6 中的新语法的扩展,有一些工具——称呼他们最常见的名词是转译器(transpilers),也就是转换编译器(trans-compilers)——它们会拿起你的 ES6 语法,并转换为前 ES6 时代的等价代码(但是明显地变难看了!)。所以,generator 可以被转译为具有相同行为但可以在 ES5 或以下版本进行工作的代码。

但是怎么做到的?yield的“魔法”听起来不像是那么容易转译的。在我们早先的基于闭包的 迭代器 例子中,实际上提示了一种解决方法。

手动变形

在我们讨论转译器之前,让我们延伸一下,在 generator 的情况下如何手动转译。这不仅是一个学院派的练习,因为这样做实际上可以帮助我们进一步理解它们如何工作。

考虑这段代码:

// `request(..)` 是一个支持 Promise 的 Ajax 工具

function *foo(url) {
    try {
        console.log( "requesting:", url );
        var val = yield request( url );
        console.log( val );
    }
    catch (err) {
        console.log( "Oops:", err );
        return false;
    }
}

var it = foo( "http://some.url.1" );

第一个要注意的事情是,我们仍然需要一个可以被调用的普通的foo()函数,而且它仍然需要返回一个 迭代器。那么让我们来画出非 generator 的变形草图:

function foo(url) {

    // ..

    // 制造并返回 iterator
    return {
        next: function(v) {
            // ..
        },
        throw: function(e) {
            // ..
        }
    };
}

var it = foo( "http://some.url.1" );

下一个需要注意的地方是,generator 通过挂起它的作用域/状态来施展它的“魔法”,但我们可以用函数闭包来模拟。为了理解如何写出这样的代码,我们将先用状态值注释 generator 不同的部分:

// `request(..)` 是一个支持 Promise 的 Ajax 工具

function *foo(url) {
    // 状态 *1*

    try {
        console.log( "requesting:", url );
        var TMP1 = request( url );

        // 状态 *2*
        var val = yield TMP1;
        console.log( val );
    }
    catch (err) {
        // 状态 *3*
        console.log( "Oops:", err );
        return false;
    }
}

注意: 为了更准去地讲解,我们使用TMP1变量将val = yield request..语句分割为两部分。request(..)发生在状态*1*,而将完成值赋给val发生在状态*2*。在我们将代码转换为非 generator 的等价物后,我们就可以摆脱中间的TMP1

换句话所,*1*是初始状态,*2*request(..)成功的状态,*3*request(..)失败的状态。你可能会想象额外的yield步骤将如何编码为额外的状态。

回到我们被转译的 generator,让我们在这个闭包中定义一个变量state,用它来追踪状态:

function foo(url) {
    // 管理 generator 状态
    var state;

    // ..
}

现在,让我们在闭包内部定义一个称为process(..)的内部函数,它用switch语句来处理各种状态。

// `request(..)` 是一个支持 Promise 的 Ajax 工具

function foo(url) {
    // 管理 generator 状态
    var state;

    // generator-范围的变量声明
    var val;

    function process(v) {
        switch (state) {
            case 1:
                console.log( "requesting:", url );
                return request( url );
            case 2:
                val = v;
                console.log( val );
                return;
            case 3:
                var err = v;
                console.log( "Oops:", err );
                return false;
        }
    }

    // ..
}

在我们的 generator 中每种状态都在switch语句中有它自己的case。每当我们需要处理一个新状态时,process(..)就会被调用。我们一会就回来讨论它如何工作。

对任何 generator 范围的变量声明(val),我们将它们移动到process(..)外面的var声明中,这样它们就可以在process(..)的多次调用中存活下来。但是“块儿作用域”的err变量仅在*3*状态下需要,所以我们将它留在原处。

在状态*1*,与yield request(..)相反,我们return request(..)。在终结状态*2*,没有明确的return,所以我们仅仅return;也就是return undefined。在终结状态*3*,有一个return false,我们保留它。

现在我们需要定义 迭代器 函数的代码,以便人们恰当地调用process(..)

function foo(url) {
    // 管理 generator 状态
    var state;

    // generator-范围的变量声明
    var val;

    function process(v) {
        switch (state) {
            case 1:
                console.log( "requesting:", url );
                return request( url );
            case 2:
                val = v;
                console.log( val );
                return;
            case 3:
                var err = v;
                console.log( "Oops:", err );
                return false;
        }
    }

    // 制造并返回 iterator
    return {
        next: function(v) {
            // 初始状态
            if (!state) {
                state = 1;
                return {
                    done: false,
                    value: process()
                };
            }
            // 成功地让出继续值
            else if (state == 1) {
                state = 2;
                return {
                    done: true,
                    value: process( v )
                };
            }
            // generator 已经完成了
            else {
                return {
                    done: true,
                    value: undefined
                };
            }
        },
        "throw": function(e) {
            // 在状态 *1* 中,有唯一明确的错误处理
            if (state == 1) {
                state = 3;
                return {
                    done: true,
                    value: process( e )
                };
            }
            // 否则,是一个不会被处理的错误,所以我们仅仅把它扔回去
            else {
                throw e;
            }
        }
    };
}

这段代码如何工作?

  1. 第一个对 迭代器next()调用将把 gtenerator 从未初始化的状态移动到状态1,然后调用process()来处理这个状态。request(..)的返回值是一个代表 Ajax 应答的 promise,它作为value属性从next()调用被返回。
  2. 如果 Ajax 请求成功,第二个next(..)调用应当送进 Ajax 的应答值,它将我们的状态移动到2process(..)再次被调用(这次它被传入 Ajax 应答的值),而从next(..)返回的value属性将是undefined
  3. 然而,如果 Ajax 请求失败,应当用错误调用throw(..),它将状态从1移动到3(而不是2)。process(..)再一次被调用,这词被传入了错误的值。这个case返回false,所以false作为throw(..)调用返回的value属性。

从外面看——也就是仅仅与 迭代器 互动——这个普通的foo(..)函数与*foo(..)generator 的工作方式是一样的。所以我们有效地将 ES6 generator“转译”为前 ES6 可兼容的!

然后我们就可以手动初始化我们的 generator 并控制它的迭代器——调用var it = foo("..")it.next(..)等等——或更好地,我们可以将它传递给我们先前定义的run(..)工具,比如run(foo,"..")

自动转译

前面的练习——手动编写从 ES6 generator 到前 ES6 的等价物的变形过程——教会了我们 generator 在概念上是如何工作的。但是这种变形真的是错综复杂,而且不能很好地移植到我们代码中的其他 generator 上。手动做这些工作是不切实际的,而且将会把 generator 的好处完全抵消掉。

但走运的是,已经存在几种工具可以自动地将 ES6 generator 转换为我们在前一节延伸出的东西。它们不仅帮我们做力气活儿,还可以处理几种我们敷衍而过的情况。

一个这样的工具是 regenerator(facebook.github.io/regenerator/),由 Facebook 的聪明伙计们开发的。

如果我们用 regenerator 来转译我们前面的 generator,这就是产生的代码(在编写本文时):

// `request(..)` 是一个支持 Promise 的 Ajax 工具

var foo = regeneratorRuntime.mark(function foo(url) {
    var val;

    return regeneratorRuntime.wrap(function foo$(context$1$0) {
        while (1) switch (context$1$0.prev = context$1$0.next) {
        case 0:
            context$1$0.prev = 0;
            console.log( "requesting:", url );
            context$1$0.next = 4;
            return request( url );
        case 4:
            val = context$1$0.sent;
            console.log( val );
            context$1$0.next = 12;
            break;
        case 8:
            context$1$0.prev = 8;
            context$1$0.t0 = context$1$0.catch(0);
            console.log("Oops:", context$1$0.t0);
            return context$1$0.abrupt("return", false);
        case 12:
        case "end":
            return context$1$0.stop();
        }
    }, foo, this, [[0, 8]]);
});

这和我们的手动推导有明显的相似性,比如switch/case语句,而且我们甚至可以看到,val被拉到了闭包外面,正如我们做的那样。

当然,一个代价是这个 generator 的转译需要一个帮助工具库regeneratorRuntime,它持有全部管理一个普通 generator/迭代器 所需的可复用逻辑。它的许多模板代码看起来和我们的版本不同,但即便如此,概念还是可以看到的,比如使用context$1$0.next = 4追踪 generator 的下一个状态。

主要的结论是,generator 不仅限于 ES6+的环境中才有用。一旦你理解了它的概念,你可以在你的所有代码中利用他们,并使用工具将代码变形为旧环境兼容的。

这比使用PromiseAPI 的填补来实现前 ES6 的 Promise 要做更多的工作,但是努力完全是值得的,因为对于以一种可推理的,合理的,看似同步的顺序风格来表达异步流程控制来说,generator 实在是好太多了。

一旦你适应了 generator,你将永远不会回到面条般的回调地狱了!

复习

generator 是一种 ES6 的新函数类型,它不像普通函数那样运行至完成。相反,generator 可以暂停在一种中间完成状态(完整地保留它的状态),而且它可以从暂停的地方重新开始。

这种暂停/继续的互换是一种协作而非抢占,这意味着 generator 拥有的唯一能力是使用yield关键字暂停它自己,而且控制这个 generator 的 迭代器 拥有的唯一能力是继续这个 generator(通过next(..))。

yield/next(..)的对偶不仅是一种控制机制,它实际上是一种双向消息传递机制。一个yield ..表达式实质上为了等待一个值而暂停,而下一个next(..)调用将把值(或隐含的undefined)传递回这个暂停的yield表达式。

与异步流程控制关联的 generator 的主要好处是,在一个 generator 内部的代码以一种自然的同步/顺序风格表达一个任务的各个步骤的序列。这其中的技巧是我们实质上将潜在的异步处理隐藏在yield关键字的后面——将异步处理移动到控制 generator 的 迭代器 代码中。

换句话说,generator 为异步代码保留了顺序的,同步的,阻塞的代码模式,这允许我们的大脑更自然地推理代码,解决了基于回调的异步产生的两个关键问题中的一个。

你不懂 JS: 异步与性能 第五章: 程序性能

这本书至此一直是关于如何更有效地利用异步模式。但是我们还没有直接解释为什么异步对于 JS 如此重要。最明显明确的理由就是 性能

举个例子,如果你要发起两个 Ajax 请求,而且他们是相互独立的,但你在进行下一个任务之前需要等到他们全部完成,你就有两种选择来对这种互动建立模型:顺序和并发。

你可以发起第一个请求并等到它完成再发起第二个请求。或者,就像我们在 promise 和 generator 中看到的那样,你可以“并列地”发起两个请求,并在继续下一步之前让一个“门”等待它们全部完成。

显然,后者要比前者性能更好。而更好的性能一般都会带来更好的用户体验。

异步(并发穿插)甚至可能仅仅增强高性能的印象,即便整个程序依然要用相同的时间才成完成。用户对性能的印象意味着一切——如果不能再多的话!——和实际可测量的性能一样重要。

现在,我们想超越局部的异步模式,转而在程序级别的水平上讨论一些宏观的性能细节。

注意: 你可能会想知道关于微性能问题,比如a++++a哪个更快。我们会在下一章“基准分析与调优”中讨论这类性能细节。

Web Workers

如果你有一些处理密集型的任务,但你不想让它们在主线程上运行(那样会使浏览器/UI 变慢),你可能会希望 JavaScript 可以以多线程的方式操作。

在第一章中,我们详细地谈到了关于 JavaScript 如何是单线程的。那仍然是成立的。但是单线程不是组织你程序运行的唯一方法。

想象将你的程序分割成两块儿,在 UI 主线程上运行其中的一块儿,而在一个完全分离的线程上运行另一块儿。

这样的结构会引发什么我们需要关心的问题?

其一,你会想知道运行在一个分离的线程上是否意味着它在并行运行(在多 CPU/内核的系统上),如此在第二个线程上长时间运行的处理将 不会 阻塞主程序线程。否则,“虚拟线程”所带来的好处,不会比我们已经在异步并发的 JS 中得到的更多。

而且你会想知道这两块儿程序是否访问共享的作用域/资源。如果是,那么你就要对付多线程语言(Java,C++等等)的所有问题,比如协作式或抢占式锁定(互斥,等)。这是很多额外的工作,而且不应当轻易着手。

换一个角度,如果这两块儿程序不能共享作用域/资源,你会想知道它们将如何“通信”。

所有这些我们需要考虑的问题,指引我们探索一个在近 HTML5 时代被加入 web 平台的特性,称为“Web Worker”。这是一个浏览器(也就是宿主环境)特性,而且几乎和 JS 语言本身没有任何关系。也就是说,JavaScript 当前 并没有任何特性可以支持多线程运行。

但是一个像你的浏览器那样的环境可以很容易地提供多个 JavaScript 引擎实例,每个都在自己的线程上,并允许你在每个线程上运行不同的程序。你的程序中分离的线程块儿中的每一个都称为一个“(Web)Worker”。这种并行机制叫做“任务并行机制”,它强调将你的程序分割成块儿来并行运行。

在你的主 JS 程序(或另一个 Worker)中,你可以这样初始化一个 Worker:

var w1 = new Worker( "http://some.url.1/mycoolworker.js" );

这个 URL 应当指向 JS 文件的位置(不是一个 HTML 网页!),它将会被加载到一个 Worker。然后浏览器会启动一个分离的线程,让这个文件在这个线程上作为独立的程序运行。

注意: 这种用这样的 URL 创建的 Worker 称为“专用(Dedicated)Wroker”。但与提供一个外部文件的 URL 不同的是,你也可以通过提供一个 Blob URL(另一个 HTML5 特性)来创建一个“内联(Inline)Worker”;它实质上是一个存储在单一(二进制)值中的内联文件。但是,Blob 超出了我们要在这里讨论的范围。

Worker 不会相互,或者与主程序共享任何作用域或资源——那会将所有的多线程编程的噩梦带到我们面前——取而代之的是一种连接它们的基本事件消息机制。

w1Worker 对象是一个事件监听器和触发器,它允许你监听 Worker 发出的事件也允许你向 Worker 发送事件。

这是如何监听事件(实际上,是固定的"message"事件):

w1.addEventListener( "message", function(evt){
    // evt.data
} );

而且你可以发送"message"事件给 Worker:

w1.postMessage( "something cool to say" );

在 Worker 内部,消息是完全对称的:

// "mycoolworker.js"

addEventListener( "message", function(evt){
    // evt.data
} );

postMessage( "a really cool reply" );

要注意的是,一个专用 Worker 与它创建的程序是一对一的关系。也就是,"message"事件不需要消除任何歧义,因为我们可以确定它只可能来自于这种一对一关系——不是从 Wroker 来的,就是从主页面来的。

通常主页面的程序会创建 Worker,但是一个 Worker 可以根据需要初始化它自己的子 Worker——称为 subworker。有时将这样的细节委托给一个“主”Worker 十分有用,它可以生成其他 Worker 来处理任务的一部分。不幸的是,在本书写作的时候,Chrome 还没有支持 subworker,然而 Firefox 支持。

要从创建一个 Worker 的程序中立即杀死它,可以在 Worker 对象(就像前一个代码段中的w1)上调用terminate()。突然终结一个 Worker 线程不会给它任何机会结束它的工作,或清理任何资源。这和你关闭浏览器的标签页来杀死一个页面相似。

如果你在浏览器中有两个或多个页面(或者打开同一个页面的多个标签页!),试着从同一个文件 URL 中创建 Worker,实际上最终结果是完全分离的 Worker。待一会儿我们就会讨论“共享”Worker 的方法。

注意: 看起来一个恶意的或者是呆头呆脑的 JS 程序可以很容易地通过在系统上生成数百个 Worker 来发起拒绝服务攻击(Dos 攻击),看起来每个 Worker 都在自己的线程上。虽然一个 Worker 将会在存在于一个分离的线程上是有某种保证的,但这种保证不是没有限制的。系统可以自由决定有多少实际的线程/CPU/内核要去创建。没有办法预测或保证你能访问多少,虽然很多人假定它至少和可用的 CPU/内核数一样多。我认为最安全的臆测是,除了主 UI 线程外至少有一个线程,仅此而已。

Worker 环境

在 Worker 内部,你不能访问主程序的任何资源。这意味着你不能访问它的任何全局变量,你也不能访问页面的 DOM 或其他资源。记住:它是一个完全分离的线程。

然而,你可以实施网络操作(Ajax,WebSocket)和设置定时器。另外,Worker 可以访问它自己的几个重要全局变量/特性的拷贝,包括navigatorlocationJSON,和applicationCache

你还可以使用importScripts(..)加载额外的 JS 脚本到你的 Worker 中:

// 在 Worker 内部
importScripts( "foo.js", "bar.js" );

这些脚本会被同步地加载,这意味着在文件完成加载和运行之前,importScripts(..)调用会阻塞 Worker 的执行。

注意: 还有一些关于暴露<canvas>API 给 Worker 的讨论,其中包括使 canvas 成为 Transferable 的(见“数据传送”一节),这将允许 Worker 来实施一些精细的脱线程图形处理,在高性能的游戏(WebGL)和其他类似应用中可能很有用。虽然这在任何浏览器中都还不存在,但是很有可能在近未来发生。

Web Worker 的常见用途是什么?

  • 处理密集型的数学计算
  • 大数据集合的排序
  • 数据操作(压缩,音频分析,图像像素操作等等)
  • 高流量网络通信

数据传送

你可能注意到了这些用途中的大多数的一个共同性质,就是它们要求使用事件机制穿越线程间的壁垒来传递大量的信息,也许是双向的。

在 Worker 的早期,将所有数据序列化为字符串是唯一的选择。除了在两个方向上进行序列化时速度上变慢了,另外一个主要缺点是,数据是被拷贝的,这意味着内存用量翻了一倍(以及在后续垃圾回收上的流失)。

谢天谢地,现在我们有了几个更好的选择。

如果你传递一个对象,在另一端一个所谓的“结构化克隆算法(Structured Cloning Algorithm)”(developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/The_structured_clone_algorithm)会用于拷贝/复制这个对象。这个算法相当精巧,甚至可以处理带有循环引用的对象复制。to-string/from-string 的性能劣化没有了,但用这种方式我们依然面对着内存用量的翻倍。IE10 以上版本,和其他主流浏览器都对此有支持。

一个更好的选择,特别是对大的数据集合而言,是“Transferable 对象”(updates.html5rocks.com/2011/12/Transferable-Objects-Lightning-Fast)。它使对象的“所有权”被传送,而对象本身没动。一旦你传送一个对象给 Worker,它在原来的位置就空了出来或者不可访问——这消除了共享作用域的多线程编程中的灾难。当然,所有权的传送可以双向进行。

选择使用 Transferable 对象不需要你做太多;任何实现了 Transferable 接口(developer.mozilla.org/en-US/docs/Web/API/Transferable)的数据结构都将自动地以这种方式传递(Firefox 和 Chrome 支持此特性)。

举个例子,有类型的数组如Uint8Array(见本系列的 ES6 与未来)是一个“Transferables”。这是你如何用postMessage(..)来传送一个 Transferable 对象:

// `foo` 是一个 `Uint8Array`

postMessage( foo.buffer, [ foo.buffer ] );

第一个参数是未经加工的缓冲,而第二个参数是要传送的内容的列表。

不支持 Transferable 对象的浏览器简单地降级到结构化克隆,这意味着性能上的降低,而不是彻底的特性失灵。

共享的 Workers

如果你的网站或应用允许多个标签页加载同一个网页(一个常见的特性),你也许非常想通过防止复制专用 Worker 来降低系统资源的使用量;这方面最常见的资源限制是网络套接字链接,因为浏览器限制同时连接到一个服务器的连接数量。当然,限制从客户端来的链接数也缓和了你的服务器资源需求。

在这种情况下,创建一个单独的中心化 Worker,让你的网站或应用的所有网页实例可以 共享 它是十分有用的。

这称为SharedWorker,你会这样创建它(仅有 Firefox 与 Chrome 支持此特性):

var w1 = new SharedWorker( "http://some.url.1/mycoolworker.js" );

因为一个共享 Worker 可以连接或被连接到你的网站上的多个程序实例或网页,Worker 需要一个方法来知道消息来自哪个程序。这种唯一的标识称为“端口(port)”——联想网络套接字端口。所以调用端程序必须使用 Worker 的port对象来通信:

w1.port.addEventListener( "message", handleMessages );

// ..

w1.port.postMessage( "something cool" );

另外,端口连接必须被初始化,就像这样:

w1.port.start();

在共享 Worker 内部,一个额外的事件必须被处理:"connect"。这个事件为这个特定的连接提供端口object。保持多个分离的连接最简单的方法是在port上使用闭包,就像下面展示的那样,同时在"connect"事件的处理器内部定义这个连接的事件监听与传送:

// 在共享 Worker 的内部
addEventListener( "connect", function(evt){
    // 为这个连接分配的端口
    var port = evt.ports[0];

    port.addEventListener( "message", function(evt){
        // ..

        port.postMessage( .. );

        // ..
    } );

    // 初始化端口连接
    port.start();
} );

除了这点不同,共享与专用 Worker 的功能和语义是一样的。

注意: 如果在一个端口的连接终结时还有其他端口的连接存活着的话,共享 Worker 也会存活下来,而专用 Worker 会在与初始化它的程序间接终结时终结。

填补 Web Workers

对于并行运行的 JS 程序在性能考量上,Web Worker 十分吸引人。然而,你的代码可能运行在对此缺乏支持的老版本浏览器上。因为 Worker 是一个 API 而不是语法,所以在某种程度上它们可以被填补。

如果浏览器不支持 Worker,那就根本没有办法从性能的角度来模拟多线程。Iframe 通常被认为可以提供并行环境,但在所有的现代浏览器中它们实际上和主页运行在同一个线程上,所以用它们来模拟并行机制是不够的。

正如我们在第一章中详细讨论的,JS 的异步能力(不是并行机制)来自于事件轮询队列,所以你可以用计时器(setTimeout(..)等等)来强制模拟的 Worker 是异步的。然后你只需要提供 Worker API 的填补就行了。这里有一份列表(github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills#web-workers),但坦白地说它们看起来都不怎么样。

我在这里(gist.github.com/getify/1b26accb1a09aa53ad25)写了一个填补Worker的轮廓。它很基础,但应该满足了简单的Worker支持,它的双向信息传递可以正确工作,还有"onerror"处理。你可能会扩展它来支持更多特性,比如terminate()或模拟共享 Worker,只要你觉得合适。

注意: 你不能模拟同步阻塞,所以这个填补不允许使用importScripts(..)。另一个选择可能是转换并传递 Worker 的代码(一旦 Ajax 加载后),来重写一个importScripts(..)填补的一些异步形式,也许使用一个 promise 相关的接口。

SIMD

一个指令,多个数据(SIMD)是一种“数据并行机制”形式,与 Web Worker 的“任务并行机制”相对应,因为他强调的不是程序逻辑的块儿被并行化,而是多个字节的数据被并行地处理。

使用 SIMD,线程不提供并行机制。相反,现代 CPU 用数字的“向量”提供 SIMD 能力——想想:指定类型的数组——还有可以在所有这些数字上并行操作的指令;这些是利用底层操作的指令级别的并行机制。

使 SIMD 能力包含在 JavaScript 中的努力主要是由 Intel 带头的(01.org/node/1495),名义上是 Mohammad Haghighat(在本书写作的时候),与 Firefox 和 Chrome 团队合作。SIMD 处于早期标准化阶段,而且很有可能被加入未来版本的 JavaScript 中,很可能在 ES7 的时间框架内。

SIMD JavaScript 提议向 JS 代码暴露短向量类型与 API,它们在 SIMD 可用的系统中将操作直接映射为 CPU 指令的等价物,同时在非 SIMD 系统中退回到非并行化操作的“shim”。

对于数据密集型的应用程序(信号分析,对图形的矩阵操作等等)来说,这种并行数学处理在性能上的优势是十分明显的!

在本书写作时,SIMD API 的早期提案形式看起来像这样:

var v1 = SIMD.float32x4( 3.14159, 21.0, 32.3, 55.55 );
var v2 = SIMD.float32x4( 2.1, 3.2, 4.3, 5.4 );

var v3 = SIMD.int32x4( 10, 101, 1001, 10001 );
var v4 = SIMD.int32x4( 10, 20, 30, 40 );

SIMD.float32x4.mul( v1, v2 );    // [ 6.597339, 67.2, 138.89, 299.97 ]
SIMD.int32x4.add( v3, v4 );        // [ 20, 121, 1031, 10041 ]

这里展示了两种不同的向量数据类型,32 位浮点数和 32 位整数。你可以看到这些向量正好被设置为 4 个 32 位元素,这与大多数 CPU 中可用的 SIMD 向量的大小(128 位)相匹配。在未来我们看到一个x8(或更大!)版本的这些 API 也是可能的。

除了mul()add(),许多其他操作也很可能被加入,比如sub()div()abs()neg()sqrt()reciprocal()reciprocalSqrt() (算数运算),shuffle()(重拍向量元素),and()or()xor()not()(逻辑运算),equal()greaterThan()lessThan() (比较运算),shiftLeft()shiftRightLogical()shiftRightArithmetic()(轮换),fromFloat32x4(),和fromInt32x4()(变换)。

注意: 这里有一个 SIMD 功能的官方“填补”(很有希望,预期的,着眼未来的填补)(github.com/johnmccutchan/ecmascript_simd),它描述了许多比我们在这一节中没有讲到的许多计划中的 SIMD 功能。

asm.js

“asm.js”(asmjs.org/)是可以被高度优化的 JavaScript 语言子集的标志。通过小心地回避那些特定的很难优化的(垃圾回收,强制转换,等等)机制和模式,asm.js 风格的代码可以被 JS 引擎识别,而且用主动地底层优化进行特殊的处理。

与本章中讨论的其他性能优化机制不同的是,asm.js 没必须要是必须被 JS 语言规范所采纳的东西。确实有一个 asm.js 规范(asmjs.org/spec/latest/),但它主要是追踪一组关于优化的候选对象的推论,而不是 JS 引擎的需求。

目前还没有新的语法被提案。取而代之的是,ams.js 建议了一些方法,用来识别那些符合 ams.js 规则的既存标准 JS 语法,并且让引擎相应地实现它们自己的优化功能。

关于 ams.js 应当如何在程序中活动的问题,在浏览器生产商之间存在一些争议。早期版本的 asm.js 实验中,要求一个"use asm";编译附注(与 strict 模式的"use strict";类似)来帮助 JS 引擎来寻找 asm.js 优化的机会和提示。另一些人则断言 asm.js 应当只是一组启发式算法,让引擎自动地识别而不用作者做任何额外的事情,这意味着理论上既存的程序可以在不用做任何特殊的事情的情况下从 asm.js 优化中获益。

如何使用 asm.js 进行优化

关于 asm.js 需要理解的第一件事情是类型和强制转换。如果 JS 引擎不得不在变量的操作期间一直追踪一个变量内的值的类型,以便于在必要时它可以处理强制转换,那么就会有许多额外的工作使程序处于次优化状态。

注意: 为了说明的目的,我们将在这里使用 ams.js 风格的代码,但要意识到的是你手写这些代码的情况不是很常见。asm.js 的本意更多的是作为其他工具的编译目标,比如 Emscripten(github.com/kripken/emscripten/wiki)。当然你写自己的 asm.js 代码也是可能的,但是这通常不是一个好主意,因为那样的代码非常底层,而这意味着它会非常耗时而且易错。尽管如此,也会有情况使你想要为了 ams.js 优化的目的手动调整代码。

这里有一些“技巧”,你可以使用它们来提示支持 asm.js 的 JS 引擎变量/操作预期的类型是什么,以便于它可以跳过那些强制转换追踪的步骤。

举个例子:

var a = 42;

// ..

var b = a;

在这个程序中,赋值b = a在变量中留下了类型分歧的问题。然而,它可以写成这样:

var a = 42;

// ..

var b = a | 0;

这里,我们与值0一起使用了|(“二进制或”),虽然它对值没有任何影响,但它确保这个值是一个 32 位整数。这段代码在普通的 JS 引擎中可以工作,但是当它运行在支持 asm.js 的 JS 引擎上时,它 可以 表示b应当总是被作为 32 位整数来对待,所以强制转换追踪可以被跳过。

类似地,两个变量之间的加法操作可以被限定为性能更好的整数加法(而不是浮点数):

(a + b) | 0

再一次,支持 asm.js 的 JS 引擎可以看到这个提示,并推断+操作应当是一个 32 位整数加法,因为不论怎样整个表达式的最终结果都将自动是 32 位整数。

asm.js 模块

在 JS 中最托性能后腿的东西之一是关于内存分怕,垃圾回收,与作用域访问。asm.js 对于这些问题建一个的一个方法是,声明一个更加正式的 asm.js“模块”——不要和 ES6 模块搞混;参见本系列的 ES6 与未来

对于一个 asm.js 模块,你需要明确传入一个被严格遵循的名称空间——在规范中以stdlib引用,因为它应当代表需要的标准库——来引入需要的符号,而不是通过词法作用域来使用全局对象。在最基本的情况下,window对象就是一个可接受的用于 asm.js 模块的stdlib对象,但是你可能应该构建一个更加被严格限制的对象。

你还必须定义一个“堆(heap)”——这只是一个别致的词汇,它表示在内存中被保留的位置,变量不必要求内存分配或释放已使用内存就可以使用——并将它传入,这样 asm.js 模块就不必做任何导致内存流失的的事情;它可以使用提前保留的空间。

一个“堆”就像一个有类型的ArrayBuffer,比如:

var heap = new ArrayBuffer( 0x10000 );    // 64k 的堆

使用这个提前保留的 64k 的二进制空间,一个 asm.js 模块可以在这个缓冲区中存储或读取值,而不受任何内存分配与垃圾回收的性能损耗。比如,heap缓冲区可以在模块内部用于备份一个 64 位浮点数值的数组,像这样:

var arr = new Float64Array( heap );

好了,让我制作一个 asm.js 风格模块的快速,愚蠢的例子来描述这些东西是如何联系在一起的。我们将定义一个foo(..),它为一个范围接收一个开始位置(x)和一个终止位置(y),并且计算这个范围内所有相邻的数字的积,然后最终计算这些值的平均值:

function fooASM(stdlib,foreign,heap) {
    "use asm";

    var arr = new stdlib.Int32Array( heap );

    function foo(x,y) {
        x = x | 0;
        y = y | 0;

        var i = 0;
        var p = 0;
        var sum = 0;
        var count = ((y|0) - (x|0)) | 0;

        // 计算范围内所有相邻的数字的积
        for (i = x | 0;
            (i | 0) < (y | 0);
            p = (p + 8) | 0, i = (i + 1) | 0
        ) {
            // 存储结果
            arr[ p >> 3 ] = (i * (i + 1)) | 0;
        }

        // 计算所有中间值的平均值
        for (i = 0, p = 0;
            (i | 0) < (count | 0);
            p = (p + 8) | 0, i = (i + 1) | 0
        ) {
            sum = (sum + arr[ p >> 3 ]) | 0;
        }

        return +(sum / count);
    }

    return {
        foo: foo
    };
}

var heap = new ArrayBuffer( 0x1000 );
var foo = fooASM( window, null, heap ).foo;

foo( 10, 20 );        // 233

注意: 这个 asm.js 例子是为了演示的目的手动编写的,所以它与那些支持 asm.js 的编译工具生产的代码的表现不同。但是它展示了 asm.js 代码的典型性质,特别是类型提示与为了临时变量存储而使用heap缓冲。

第一个fooASM(..)调用用它的heap分配区建立了我们的 asm.js 模块。结果是一个我们可以调用任意多次的foo(..)函数。这些调用应当会被支持 asm.js 的 JS 引擎特别优化。重要的是,前面的代码完全是标准 JS,而且会在非 asm.js 引擎中工作的很好(但没有特别优化)。

很明显,使 asm.js 代码可优化的各种限制降低了广泛使用这种代码的可能性。对于任意给出的 JS 程序,asm.js 没有必要为成为一个一般化的优化集合。相反,它的本意是提供针对一种处理特定任务——如密集数学操作(那些用于游戏中图形处理的)——的优化方法。

复习

本书的前四章基于这样的前提:异步编码模式给了你编写更高效代码的能力,这通常是一个非常重要的改进。但是异步行为也就能帮你这么多,因为它在基础上仍然使用一个单独的事件轮询线程。

所以在这一章我们涵盖了几种程序级别的机制来进一步提升性能。

Web Worker 让你在一个分离的线程上运行一个 JS 文件(也就是程序),使用异步事件在线程之间传递消息。对于将长时间运行或资源密集型任务挂载到一个不同线程,从而让主 UI 线程保持相应来说,它们非常棒。

SIMD 提议将 CPU 级别的并行数学操作映射到 JavaScript API 上来提供高性能数据并行操作,比如在大数据集合上进行数字处理。

最后,asm.js 描述了一个 JavaScript 的小的子集,它回避了 JS 中不易优化的部分(比如垃圾回收与强制转换)并让 JS 引擎通过主动优化识别并运行这样的代码。asm.js 可以手动编写,但是极其麻烦且易错,就像手动编写汇编语言。相反,asm.js 的主要意图是作为一个从其他高度优化的程序语言交叉编译来的目标——例如,Emscripten(github.com/kripken/emscripten/wiki)可以将 C/C++转译为 JavaScript。

虽然在本章没有明确地提及,在很早以前的有关 JavaScript 的讨论中存在着更激进的想法,包括近似地直接多线程功能(不仅仅是隐藏在数据结构 API 后面)。无论这是否会明确地发生,还是我们将看到更多并行机制偷偷潜入 JS,但是在 JS 中发生更多程序级别优化的未来是可以确定的。

你不懂 JS: 异步与性能 第六章: 基准分析与调优

本书的前四章都是关于代码模式(异步与同步)的性能,而第五章是关于宏观的程序结构层面的性能,本章从微观层面继续性能的话题,关注的焦点在一个表达式/语句上。

好奇心的一个最常见的领域——确实,一些开发者十分痴迷于此——是分析和测试如何写一行或一块儿代码的各种选项,看哪一个更快。

我们将会看到这些问题中的一些,但重要的是要理解从最开始这一章就 不是 为了满足对微性能调优的痴迷,比如某种给定的 JS 引擎运行++a是否要比运行a++快。这一章更重要的目标是,搞清楚哪种 JS 性能要紧而哪种不要紧,和如何指出这种不同

但在我们达到目的之前,我们需要探索一下如何最准确和最可靠地测试 JS 性能,因为有太多的误解和谜题充斥着我们集体主义崇拜的知识库。我们需要将这些垃圾筛出去以便找到清晰的答案。

基准分析(Benchmarking)

好了,是时候开始消除一些误解了。我敢打赌,最广大的 JS 开发者们,如果被问到如何测量一个特定操作的速度(执行时间),将会一头扎进这样的东西:

var start = (new Date()).getTime();    // 或者`Date.now()`

// 做一些操作

var end = (new Date()).getTime();

console.log( "Duration:", (end - start) );

如果这大致就是你想到的,请举手。是的,我就知道你会这么想。这个方式有许多错误,但是别难过;我们都这么干过。

这种测量到底告诉了你什么?对于当前的操作的执行时间来说,理解它告诉了你什么和没告诉你什么是学习如何正确测量 JavaScript 的性能的关键。

如果持续的时间报告为0,你也许会试图认为它花的时间少于 1 毫秒。但是这不是非常准确。一些平台不能精确到毫秒,反而是在更大的时间单位上更新计时器。举个例子,老版本的 windows(IE 也是如此)只有 15 毫秒的精确度,这意味着要得到与0不同的报告,操作就必须至少要花这么长时间!

另外,不管被报告的持续时间是多少,你唯一真实知道的是,操作在当前这一次运行中大概花了这么长时间。你几乎没有信心说它将总是以这个速度运行。你不知道引擎或系统是否在就在那个确切的时刻进行了干扰,而在其他的时候这个操作可能会运行的快一些。

要是持续的时间报告为4呢?你确信它花了大概 4 毫秒?不,它可能没花那么长时间,而且在取得startend时间戳时会有一些其他的延迟。

更麻烦的是,你也不知道这个操作测试所在的环境是不是过于优化了。这样的情况是有可能的:JS 引擎找到了一个办法来优化你的测试用例,但是在更真实的程序中这样的优化将会被稀释或者根本不可能,如此这个操作将会比你测试时运行的慢。

那么...我们知道什么?不幸的是,在这种状态下,我们几乎什么都不知道。 可信度如此低的东西甚至不够你建立自己的判断。你的“基准分析”基本没用。更糟的是,它隐含的这种不成立的可信度很危险,不仅是对你,而且对其他人也一样:认为导致这些结果的条件不重要。

重复

“好的,”你说,“在它周围放一个循环,让整个测试需要的时间长一些。”如果你重复一个操作 100 次,而整个循环在报告上说总共花了 137ms,那么你可以除以 100 并得到每次操作平均持续时间 1.37ms,对吧?

其实,不确切。

对于你打算在你的整个应用程序范围内推广的操作的性能,仅靠一个直白的数据上的平均做出判断绝对是不够的。在一百次迭代中,即使是几个极端值(或高或低)就可以歪曲平均值,而后当你反复实施这个结论时,你就更进一步扩大了这种歪曲。

与仅仅运行固定次数的迭代不同,你可以选择将测试的循环运行一个特定长的时间。那可能更可靠,但是你如何决定运行多长时间?你可能会猜它应该是你的操作运行一次所需时间的倍数。错。

实际上,循环持续的时间应当基于你使用的计时器的精度,具体地将不精确的 ·可能性最小化。你的计时器精度越低,你就需要运行更长时间来确保你将错误的概率最小化了。一个 15ms 的计时器对于精确的基准分析来说太差劲儿了;为了把它的不确定性(也就是“错误率”)最小化到低于 1%,你需要将测试的迭代循环运行 750ms。一个 1ms 的计时器只需要一个循环运行 50ms 就可以得到相同的可信度。

但,这只是一个样本。为了确信你排除了歪曲结果的因素,你将会想要许多样本来求平均值。你还会想要明白最差的样本有多慢,最佳的样本有多快,最差与最佳的情况相差多少等等。你想知道的不仅是一个数字告诉你某个东西跑的多块,而且还需要一个关于这个数字有多可信的量化表达。

另外,你可能想要组合这些不同的技术(还有其他的),以便于你可以在所有这些可能的方式中找到最佳的平衡。

这一切只不过是开始所需的最低限度的认识。如果你曾经使用比我刚才几句话带过的东西更不严谨的方式进行基准分析,那么...“你不懂:正确的基准分析”。

Benchmark.js

任何有用而且可靠的基准分析应当基于统计学上的实践。我不是要在这里写一章统计学,所以我会带过一些名词:标准差,方差,误差边际。如果你不知道这些名词意味着什么——我在大学上过统计学课程,而我依然对他们有点儿晕——那么实际上你没有资格去写你自己的基准分析逻辑。

幸运的是,一些像 John-David Dalton 和 Mathias Bynens 这样的聪明家伙明白这些概念,并且写了一个统计学上的基准分析工具,称为 Benchmark.js(benchmarkjs.com/)。所以我可以简单地说:“用这个工具就行了。”来终结这个悬念。

我不会重复他们的整个文档来讲解 Benchmark.js 如何工作;他们有很棒的 API 文档(benchmarkjs.com/docs)你可以阅读。另外这里还有一些了不起的文章(http://calendar.perfplanet.com/2010/bulletproof-javascript-benchmarks/)(http://monsur.hossa.in/2012/12/11/benchmarkjs.html)讲解细节与方法学。

但是为了快速演示一下,这是你如何用 Benchmark.js 来运行一个快速的性能测试:

function foo() {
    // 需要测试的操作
}

var bench = new Benchmark(
    "foo test",                // 测试的名称
    foo,                    // 要测试的函数(仅仅是内容)
    {
        // ..                // 额外的选项(参见文档)
    }
);

bench.hz;                    // 每秒钟执行的操作数
bench.stats.moe;            // 误差边际
bench.stats.variance;        // 所有样本上的方差
// ..

比起我在这里的窥豹一斑,关于使用 Benchmark.js 还有 许多 需要学习的东西。不过重点是,为了给一段给定的 JavaScript 代码建立一个公平,可靠,并且合法的性能基准分析,Benchmark.js 包揽了所有的复杂性。如果你想要试着对你的代码进行测试和基准分析,这个库应当是你第一个想到的地方。

我们在这里展示的是测试一个单独操作 X 的用法,但是相当常见的情况是你想要用 X 和 Y 进行比较。这可以通过简单地在一个“Suite”(一个 Benchmark.js 的组织特性)中建立两个测试来很容易做到。然后,你对照地运行它们,然后比较统计结果来对为什么 X 或 Y 更快做出论断。

Benchmark.js 理所当然地可以被用于在浏览器中测试 JavaScript(参见本章稍后的“jsPerf.com”一节),但它也可以运行在非浏览器环境中(Node.js 等等)。

一个很大程度上没有触及的 Benchmark.js 的潜在用例是,在你的 Dev 或 QA 环境中针对你的应用程序的 JavaScript 的关键路径运行自动化的性能回归测试。与在部署之前你可能运行单元测试的方式相似,你也可以将性能与前一次基准分析进行比较,来观测你是否改进或恶化了应用程序性能。

Setup/Teardown

在前一个代码段中,我们略过了“额外选项(extra options)”{ .. }对象。但是这里有两个我们应当讨论的选项setupteardown

这两个选项让你定义在你的测试用例开始运行前和运行后被调用的函数。

一个需要理解的极其重要的事情是,你的setupteardown代码 不会为每一次测试迭代而运行。考虑它的最佳方式是,存在一个外部循环(重复的轮回),和一个内部循环(重复的测试迭代)。setupteardown会在每个 外部 循环(也就是轮回)迭代的开始和末尾运行,但不是在内部循环。

为什么这很重要?让我们想象你有一个看起来像这样的测试用例:

a = a + "w";
b = a.charAt( 1 );

然后,你这样建立你的测试setup

var a = "x";

你的意图可能是相信对每一次测试迭代a都以值"x"开始。

但它不是!它使a在每一次测试轮回中以"x"开始,而后你的反复的+ "w"连接将使a的值越来越大,即便你永远唯一访问的是位于位置1的字符"w"

当你想利用副作用来改变某些东西比如 DOM,向它追加一个子元素时,这种意外经常会咬到你。你可能认为的父元素每次都被设置为空,但他实际上被追加了许多元素,而这可能会显著地歪曲你的测试结果。

上下文为王

不要忘了检查一个指定的性能基准分析的上下文环境,特别是在 X 与 Y 之间进行比较时。仅仅因为你的测试显示 X 比 Y 速度快,并不意味着“X 比 Y 快”这个结论是实际上有意义的。

举个例子,让我们假定一个性能测试显示出 X 每秒可以运行 1 千万次操作,而 Y 每秒运行 8 百万次。你可以声称 Y 比 X 慢 20%,而且在数学上你是对的,但是你的断言并不向像你认为的那么有用。

让我们更加苛刻地考虑这个测试结果:每秒 1 千万次操作就是每毫秒 1 万次操作,就是每微秒 10 次操作。换句话说,一次操作要花 0.1 毫秒,或者 100 纳秒。很难体会 100 纳秒到底有多小,可以这样比较一下,通常认为人类的眼睛一般不能分辨小于 100 毫秒的变化,而这要比 X 操作的 100 纳秒的速度慢 100 万倍。

即便最近的科学研究显示,大脑可能的最快处理速度是 13 毫秒(比先前的论断快大约 8 倍),这意味着 X 的运行速度依然要比人类大脑可以感知事情的发生要快 12 万 5 千倍。X 运行的非常,非常快。

但更重要的是,让我们来谈谈 X 与 Y 之间的不同,每秒 2 百万次的差。如果 X 花 100 纳秒,而 Y 花 80 纳秒,差就是 20 纳秒,也就是人类大脑可以感知的间隔的 65 万分之一。

我要说什么?这种性能上的差别根本就一点儿都不重要!

但是等一下,如果这种操作将要一个接一个地发生许多次呢?那么差异就会累加起来,对吧?

好的,那么我们就要问,操作 X 有多大可能性将要一次又一次,一个接一个地运行,而且为了人类大脑能够感知的一线希望而不得不发生 65 万次。而且,它不得不在一个紧凑的循环中发生 5 百万到 1 千万次,才能接近于有意义。

虽然你们之中的计算机科学家会反对说这是可能的,但是你们之中的现实主义者们应当对这究竟有多大可能性进行可行性检查。即使在极其稀少的偶然中这有实际意义,但是在绝大多数情况下它没有。

你们大量的针对微小操作的基准分析结果——比如++xx++的神话——完全是伪命题,只不过是用来支持在性能的基准上 X 应当取代 Y 的结论。

引擎优化

你根本无法可靠地这样推断:如果在你的独立测试中 X 要比 Y 快 10 微秒,这意味着 X 总是比 Y 快所以应当总是被使用。这不是性能的工作方式。它要复杂太多了。

举个例子,让我们想象(纯粹地假想)你在测试某些行为的微观性能,比如比较:

var twelve = "12";
var foo = "foo";

// 测试 1
var X1 = parseInt( twelve );
var X2 = parseInt( foo );

// 测试 2
var Y1 = Number( twelve );
var Y2 = Number( foo );

如果你明白与Number(..)比起来parseInt(..)做了什么,你可能会在直觉上认为parseInt(..)潜在地有“更多工作”要做,特别是在foo的测试用例下。或者你可能在直觉上认为在foo的测试用例下它们应当有同样多的工作要做,因为它们俩应当能够在第一个字符"f"处停下。

哪一种直觉正确?老实说我不知道。但是我会制造一个与你的直觉无关的测试用例。当你测试它的时候结果会是什么?我又一次在这里制造一个纯粹的假想,我们没实际上尝试过,我也不关心。

让我们假装XY的测试结果在统计上是相同的。那么你关于"f"字符上发生的事情的直觉得到确认了吗?没有。

在我们的假想中可能发生这样的事情:引擎可能会识别出变量twelvefoo在每个测试中仅被使用了一次,因此它可能会决定要内联这些值。然后它可能发现Number("12")可以替换为12。而且也许在parseInt(..)上得到相同的结论,也许不会。

或者一个引擎的死代码移除启发式算法会搅和进来,而且它发现变量XY都没有被使用,所以声明它们是没有意义的,所以最终在任一个测试中都不做任何事情。

而且所有这些都只是关于一个单独测试运行的假设而言的。比我们在这里用直觉想象的,现代的引擎复杂得更加难以置信。它们会使用所有的招数,比如追踪并记录一段代码在一段很短的时间内的行为,或者使用一组特别限定的输入。

如果引擎由于固定的输入而用特定的方法进行了优化,但是在你的真实的程序中你给出了更多种类的输入,以至于优化机制决定使用不同的方式呢(或者根本不优化!)?或者如果因为引擎看到代码被基准分析工具运行了成千上万次而进行了优化,但在你的真实程序中它将仅会运行大约 100 次,而在这些条件下引擎认定优化不值得呢?

所有这些我们刚刚假想的优化措施可能会发生在我们的被限定的测试中,但在更复杂的程序中引擎可能不会那么做(由于种种原因)。或者正相反——引擎可能不会优化这样不起眼的代码,但是可能会更倾向于在系统已经被一个更精巧的程序消耗后更加积极地优化。

我想要说的是,你不能确切地知道这背后究竟发生了什么。你能搜罗的所有猜测和假想几乎不会提炼成任何坚实的依据。

难道这意味着你不能真正地做有用的测试了吗?绝对不是!

这可以归结为测试 不真实 的代码会给你 不真实 的结果。在尽可能的情况下,你应当测试真实的,有意义的代码段,并且在最接近你实际能够期望的真实条件下进行。只有这样你得到的结果才有机会模拟现实。

++xx++这样的微观基准分析简直和伪命题一模一样,我们也许应该直接认为它就是。

jsPerf.com

虽然 Bechmark.js 对于在你使用的任何 JS 环境中测试代码性能很有用,但是如果你需要从许多不同的环境(桌面浏览器,移动设备等)汇总测试结果并期望得到可靠的测试结论,它就显得能力不足。

举例来说,Chrome 在高端的桌面电脑上与 Chrome 移动版在智能手机上的表现就大相径庭。而一个充满电的智能手机与一个只剩 2%电量,设备开始降低无线电和处理器的能源供应的智能手机的表现也完全不同。

如果在横跨多于一种环境的情况下,你想在任何合理的意义上宣称“X 比 Y 快”,那么你就需要实际测试尽可能多的真实世界的环境。只因为 Chrome 执行某种 X 操作比 Y 快并不意味着所有的浏览器都是这样。而且你还可能想要根据你的用户的人口统计交叉参照多种浏览器测试运行的结果。

有一个为此目的而生的牛 X 网站,称为 jsPerf(jsperf.com)。它使用我们前面提到的 Benchmark.js 库来运行统计上正确且可靠的测试,并且可以让测试运行在一个你可交给其他人的公开 URL 上。

每当一个测试运行后,其结果都被收集并与这个测试一起保存,同时累积的测试结果将在网页上被绘制成图供所有人阅览。

当在这个网站上创建测试时,你一开始有两个测试用例可以填写,但你可以根据需要添加任意多个。你还可以建立在每次测试轮回开始时运行的setup代码,和在每次测试轮回结束前运行的teardown代码。

注意: 一个只做一个测试用例(如果你只对一个方案进行基准分析而不是相互对照)的技巧是,在第一次创建时使用输入框的占位提示文本填写第二个测试输入框,之后编辑这个测试并将第二个测试留为空白,这样它就会被删除。你可以稍后添加更多测试用例。

你可以顶一个页面的初始配置(引入库文件,定义工具函数,声明变量,等等)。如有需要这里也有选项可以定义 setup 和 teardow 行为——参照前面关于 Benchmark.js 的讨论中的“Setup/Teardown”一节。

可行性检查

jsPerf 是一个奇妙的资源,但它上面有许多公开的糟糕测试,当你分析它们时会发现,由于在本章目前为止罗列的各种原因,它们有很大的漏洞或者是伪命题。

考虑:

// 用例 1
var x = [];
for (var i=0; i<10; i++) {
    x[i] = "x";
}

// 用例 2
var x = [];
for (var i=0; i<10; i++) {
    x[x.length] = "x";
}

// 用例 3
var x = [];
for (var i=0; i<10; i++) {
    x.push( "x" );
}

关于这个测试场景有一些现象值得我们深思:

  • 开发者们在测试用例中加入自己的循环极其常见,而他们忘记了 Benchmark.js 已经做了你所需要的所有反复。这些测试用例中的for循环有很大的可能是完全不必要的噪音。

  • 在每一个测试用例中都包含了x的声明与初始化,似乎是不必要的。回想早前如果x = []存在于setup代码中,它实际上不会在每一次测试迭代前执行,而是在每一个轮回的开始执行一次。这意味这x将会持续地增长到非常大,而不仅是for循环中暗示的大小10

    那么这是有意确保测试仅被限制在很小的数组上(大小为10)来观察 JS 引擎如何动作?这 可能 是有意的,但如果是,你就不得不考虑它是否过于关注内微妙的部实现细节了。

    另一方面,这个测试的意图包含数组实际上会增长到非常大的情况吗?JS 引擎对大数组的行为与真实世界中预期的用法相比有意义且正确吗?

  • 它的意图是要找出x.lengthx.push(..)在数组x的追加操作上拖慢了多少性能吗?好吧,这可能是一个合法的测试。但再一次,push(..)是一个函数调用,所以它理所当然地要比[..]访问慢。可以说,用例 1 与用例 2 比用例 3 更合理。

这里有另一个展示苹果比橘子的常见漏洞的例子:

// 用例 1
var x = ["John","Albert","Sue","Frank","Bob"];
x.sort();

// 用例 2
var x = ["John","Albert","Sue","Frank","Bob"];
x.sort( function mySort(a,b){
    if (a < b) return -1;
    if (a > b) return 1;
    return 0;
} );

这里,明显的意图是要找出自定义的mySort(..)比较器比内建的默认比较器慢多少。但是通过将函数mySort(..)作为内联的函数表达式生命,你就创建了一个不合理的/伪命题的测试。这里,第二个测试用例不仅测试用户自定义的 JS 函数,而且它还测试为每一个迭代创建一个新的函数表达式。

不知这会不会吓到你,如果你运行一个相似的测试,但是将它更改为比较内联函数表达式与预先声明的函数,内联函数表达式的创建可能要慢 2%到 20%!

除非你的测试的意图 就是 要考虑内联函数表达式创建的“成本”,一个更好/更合理的测试是将mySort(..)的声明放在页面的 setup 中——不要放在测试的setup中,因为这会为每次轮回进行不必要的重复声明——然后简单地在测试用例中通过名称引用它:x.sort(mySort)

基于前一个例子,另一种造成苹果比橘子场景的陷阱是,不透明地对一个测试用例回避或添加“额外的工作”:

// 用例 1
var x = [12,-14,0,3,18,0,2.9];
x.sort();

// 用例 2
var x = [12,-14,0,3,18,0,2.9];
x.sort( function mySort(a,b){
    return a - b;
} );

将先前提到的内联函数表达式陷阱放在一边不谈,第二个用例的mySort(..)可以在这里工作是因为你给它提供了一组数字,而在字符串的情况下肯定会失败。第一个用例不会扔出错误,但是它的实际行为将会不同而且会有不同的结果!这应当很明显,但是:两个测试用例之间结果的不同,几乎可以否定了整个测试的合法性!

但是除了结果的不同,在这个用例中,内建的sort(..)比较器实际上要比mySort()做了更多“额外的工作”,内建的比较器将被比较的值转换为字符串,然后进行字典顺序的比较。这样第一个代码段的结果为[-14, 0, 0, 12, 18, 2.9, 3]而第二段代码的结果为[-14, 0, 0, 2.9, 3, 12, 18](就测试的意图来讲可能更准确)。

所以这个测试是不合理的,因为它的两个测试用例实际上没有做相同的任务。你得到的任何结果都将是伪命题。

这些同样的陷阱可以微妙的多:

// 用例 1
var x = false;
var y = x ? 1 : 2;

// 用例 2
var x;
var y = x ? 1 : 2;

这里的意图可能是要测试如果x表达式不是 Boolean 的情况下,? :操作符将要进行的 Boolean 转换对性能的影响(参见本系列的 类型与文法)。那么,根据在第二个用例中将会有额外的工作进行转换的事实,你看起来没问题。

微妙的问题呢?你在第一个测试用例中设定了x的值,而没在另一个中设置,那么你实际上在第一个用例中做了在第二个用例中没做的工作。为了消灭任何潜在的扭曲(尽管很微小),可以这样:

// 用例 1
var x = false;
var y = x ? 1 : 2;

// 用例 2
var x = undefined;
var y = x ? 1 : 2;

现在两个用例都有一个赋值了,这样你想要测试的东西——x的转换或者不转换——会更加正确的被隔离并测试。

编写好的测试

来看看我能否清晰地表达我想在这里申明的更重要的事情。

好的测试作者需要细心地分析性地思考两个测试用例之间存在什么样的差别,和它们之间的差别是否是 有意的无意的

有意的差别当然是正常的,但是产生歪曲结果的无意的差异实在太容易了。你不得不非常非常小心地回避这种歪曲。另外,你可能预期一个差异,但是你的意图是什么对于你的测试的其他读者来讲不那么明显,所以他们可能会错误地怀疑(或者相信!)你的测试。你如何搞定这个呢?

编写更好,更清晰的测试。 另外,花些时间用文档确切地记录下你的测试意图是什么(使用 jsPerf.com 的“Description”字段,或/和代码注释),即使是微小的细节。明确地表示有意的差别,这将帮助其他人和未来的你自己更好地找出那些可能歪曲测试结果的无意的差别。

将与你的测试无关的东西隔离开来,通过在页面或测试的 setup 设置中预先声明它们,使它们位于测试计时部分的外面。

与将你的真实代码限制在很小的一块,并脱离上下文环境来进行基准分析相比,测试与基准分析在它们包含更大的上下文环境(但仍然有意义)时表现更好。这些测试将会趋向于运行得更慢,这意味着你发现的任何差别都在上下文环境中更有意义。

微观性能

好了,直至现在我们一直围绕着微观性能的问题跳舞,并且一般上不赞成痴迷于它们。我想花一点儿时间直接解决它们。

当你考虑对你的代码进行性能基准分析时,第一件需要习惯的事情就是你写的代码不总是引擎实际运行的代码。我们在第一章中讨论编译器的语句重排时简单地看过这个话题,但是这里我们将要说明编译器能有时决定运行与你编写的不同的代码,不仅是不同的顺序,而是不同的替代品。

让我们考虑这段代码:

var foo = 41;

(function(){
    (function(){
        (function(baz){
            var bar = foo + baz;
            // ..
        })(1);
    })();
})();

你也许会认为在最里面的函数的foo引用需要做一个三层作用域查询。我们在这个系列丛书的 作用域与闭包 一卷中涵盖了词法作用域如何工作,而事实上编译器通常缓存这样的查询,以至于从不同的作用域引用foo不会实质上“花费”任何额外的东西。

但是这里有些更深刻的东西需要思考。如果编译器认识到foo除了这一个位置外没有被任何其他地方引用,进而注意到它的值除了这里的41外没有任何变化会怎么样呢?

JS 编译器能够决定干脆完全移除foo变量,并 内联 它的值是可能和可接受的,比如这样:

(function(){
    (function(){
        (function(baz){
            var bar = 41 + baz;
            // ..
        })(1);
    })();
})();

注意: 当然,编译器可能也会对这里的baz变量进行相似的分析和重写。

但你开始将你的 JS 代码作为一种告诉引擎去做什么的提示或建议来考虑,而不是一种字面上的需求,你就会理解许多对零碎的语法细节的痴迷几乎是毫无根据的。

另一个例子:

function factorial(n) {
    if (n < 2) return 1;
    return n * factorial( n - 1 );
}

factorial( 5 );        // 120

啊,一个老式的“阶乘”算法!你可能会认为 JS 引擎将会原封不动地运行这段代码。老实说,它可能会——但我不是很确定。

但作为一段轶事,用 C 语言表达的同样的代码并使用先进的优化处理进行编译时,将会导致编译器认为factorial(5)调用可以被替换为常数值120,完全消除这个函数以及调用!

另外,一些引擎有一种称为“递归展开(unrolling recursion)”的行为,它会意识到你表达的递归实际上可以用循环“更容易”(也就是更优化地)地完成。前面的代码可能会被 JS 引擎 重写 为:

function factorial(n) {
    if (n < 2) return 1;

    var res = 1;
    for (var i=n; i>1; i--) {
        res *= i;
    }
    return res;
}

factorial( 5 );        // 120

现在,让我们想象在前一个片段中你曾经担心n * factorial(n-1)n *= factorial(--n)哪一个运行的更快。也许你甚至做了性能基准分析来试着找出哪个更好。但是你忽略了一个事实,就是在更大的上下文环境中,引擎也许不会运行任何一行代码,因为它可能展开了递归!

说到----nn--的对比,经常被认为可以通过选择--n的版本进行优化,因为理论上在汇编语言层面的处理上,它要做的努力少一些。

在现代的 JavaScript 中这种痴迷基本上是没道理的。这种事情应当留给引擎来处理。你应该编写最合理的代码。比较这三个for循环:

// 方式 1
for (var i=0; i<10; i++) {
    console.log( i );
}

// 方式 2
for (var i=0; i<10; ++i) {
    console.log( i );
}

// 方式 3
for (var i=-1; ++i<10; ) {
    console.log( i );
}

就算你有一些理论支持第二或第三种选择要比第一种的性能好那么一点点,充其量只能算是可疑,第三个循环更加使人困惑,因为为了使提前递增的++i被使用,你不得不让i-1开始来计算。而第一个与第二个选择之间的区别实际上无关紧要。

这样的事情是完全有可能的:JS 引擎也许看到一个i++被使用的地方,并意识到它可以安全地替换为等价的++i,这意味着你决定挑选它们中的哪一个所花的时间完全被浪费了,而且这么做的产出毫无意义。

这是另外一个常见的愚蠢的痴迷于微观性能的例子:

var x = [ .. ];

// 方式 1
for (var i=0; i < x.length; i++) {
    // ..
}

// 方式 2
for (var i=0, len = x.length; i < len; i++) {
    // ..
}

这里的理论是,你应当在变量len中缓存数组x的长度,因为从表面上看它不会改变,来避免在循环的每一次迭代中都查询x.length所花的开销。

如果你围绕x.length的用法进行性能基准分析,与将它缓存在变量len中的用法进行比较,你会发现虽然理论听起来不错,但是在实践中任何测量出的差异都是在统计学上完全没有意义的。

事实上,在像 v8 这样的引擎中,可以看到(mrale.ph/blog/2014/12/24/array-length-caching.html)通过提前缓存长度而不是让引擎帮你处理它会使事情稍稍恶化。不要尝试在聪明上战胜你的 JavaScript 引擎,当它来到性能优化的地方时你可能会输给它。%E9%80%9A%E8%BF%87%E6%8F%90%E5%89%8D%E7%BC%93%E5%AD%98%E9%95%BF%E5%BA%A6%E8%80%8C%E4%B8%8D%E6%98%AF%E8%AE%A9%E5%BC%95%E6%93%8E%E5%B8%AE%E4%BD%A0%E5%A4%84%E7%90%86%E5%AE%83%E4%BC%9A%E4%BD%BF%E4%BA%8B%E6%83%85%E7%A8%8D%E7%A8%8D%E6%81%B6%E5%8C%96%E3%80%82%E4%B8%8D%E8%A6%81%E5%B0%9D%E8%AF%95%E5%9C%A8%E8%81%AA%E6%98%8E%E4%B8%8A%E6%88%98%E8%83%9C%E4%BD%A0%E7%9A%84JavaScript%E5%BC%95%E6%93%8E%EF%BC%8C%E5%BD%93%E5%AE%83%E6%9D%A5%E5%88%B0%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E7%9A%84%E5%9C%B0%E6%96%B9%E6%97%B6%E4%BD%A0%E5%8F%AF%E8%83%BD%E4%BC%9A%E8%BE%93%E7%BB%99%E5%AE%83%E3%80%82)

不是所有的引擎都一样

在各种浏览器中的不同 JS 引擎可以称为“规范兼容的”,虽然各自有完全不同的方式处理代码。JS 语言规范不要求与性能相关的任何事情——除了将在本章稍后将要讲解的 ES6“尾部调用优化(Tail Call Optimization)”。

引擎可以自由决定哪一个操作将会受到它的关注而被优化,也许代价是在另一种操作上的性能降低一些。要为一种操作找到一种在所有的浏览器中总是运行的更快的方式是非常不现实的。

在 JS 开发者社区的一些人发起了一项运动,特别是那些使用 Node.js 工作的人,去分析 v8 JavaScript 引擎的具体内部实现细节,并决定如何编写定制的 JS 代码来最大限度的利用 v8 的工作方式。通过这样的努力你实际上可以在性能优化上达到惊人的高度,所以这种努力的收益可能十分高。

一些针对 v8 的经常被引用的例子是(github.com/petkaantonov/bluebird/wiki/Optimization-killers) :

  • 不要将arguments变量从一个函数传递到任何其他函数中,因为这样的“泄露”放慢了函数实现。
  • 将一个try..catch隔离到它自己的函数中。浏览器在优化任何含有try..catch的函数时都会苦苦挣扎,所以将这样的结构移动到它自己的函数中意味着你持有不可优化的危害的同时,让其周围的代码是可以优化的。

但与其聚焦在这些具体的窍门上,不如让我们在一般意义上对 v8 专用的优化方式进行一下合理性检验。

你真的在编写仅仅需要在一种 JS 引擎上运行的代码吗?即便你的代码 当前 是完全为了 Node.js,那么假设 v8 将 总是 被使用的 JS 引擎可靠吗?从现在开始的几年以后的某一天,你有没有可能会选择除了 Node.js 之外的另一种服务器端 JS 平台来运行你的程序?如果你以前所做的优化现在在新的引擎上成为了执行这种操作的很慢的方式怎么办?

或者如果你的代码总是在 v8 上运行,但是 v8 在某个时点决定改变一组操作的工作方式,是的曾经快的现在变慢了,曾经慢的变快了呢?

这些场景也都不只是理论上的。曾经,将多个字符串值放在一个数组中然后在这个数组上调用join("")来连接这些值,要比仅使用+直接连接这些值要快。这件事的历史原因很微妙,但它与字符串值如何被存储和在内存中如何管理的内部实现细节有关。

结果,当时在业界广泛传播的“最佳实践”建议开发者们总是使用数组join(..)的方式。而且有许多人遵循了。

但是,某一天,JS 引擎改变了内部管理字符串的方式,而且特别在+连接上做了优化。他们并没有放慢join(..),但是他们在帮助+用法上做了更多的努力,因为它依然十分普遍。

注意: 某些特定方法的标准化和优化的实施,很大程度上决定于它被使用的广泛程度。这经常(隐喻地)称为“paving the cowpath”(不提前做好方案,而是等到事情发生了再去应对)。

一旦处理字符串和连接的新方式定型,所有在世界上运行的,使用数组join(..)来连接字符串的代码都不幸地变成了次优的方式。

另一个例子:曾经,Opera 浏览器在如何处理基本包装对象的封箱/拆箱(参见本系列的 类型与文法)上与其他浏览器不同。因此他们给开发者的建议是,如果一个原生string值的属性(如length)或方法(如charAt(..))需要被访问,就使用一个String对象取代它。这个建议也许对那时的 Opera 是正确的,但是对于同时代的其他浏览器来说简直就是完全相反的,因为它们都对原生string进行了专门的优化,而不是对它们的包装对象。

我认为即使是对今天的代码,这种种陷阱即便可能性不高,至少也是可能的。所以对于在我的 JS 代码中单纯地根据引擎的实现细节来进行大范围的优化这件事来说我会非常小心,特别是如果这些细节仅对一种引擎成立时。

反过来也有一些事情需要警惕:你不应当为了绕过某一种引擎难于处理的地方而改变一块代码。

历史上,IE 是导致许多这种挫折的领头羊,在老版本的 IE 中曾经有许多场景,在当时的其他主流浏览器中看起来没有太多麻烦的性能方面苦苦挣扎。我们刚刚讨论的字符串连接在 IE6 和 IE7 的年代就是一个真实的问题,那时候使用join(..)就可能要比使用+能得到更好的性能。

不过为了一种浏览器的性能问题而使用一种很有可能在其他所有浏览器上是次优的编码方式,很难说是正当的。即便这种浏览器占有了你的网站用户的很大市场份额,编写恰当的代码并仰仗浏览器最终在更好的优化机制上更新自己可能更实际。

“没什么是比暂时的黑科技更永恒的。”你现在为了绕过一些性能的 Bug 而编写的代码可能要比这个 Bug 在浏览器中存在的时间长的多。

在那个浏览器每五年才更新一次的年代,这是个很难做的决定。但是如今,所有的浏览器都在快速地更新(虽然移动端的世界还有些滞后),而且它们都在竞争而使得 web 优化特性变得越来越好。

如果你真的碰到了一个浏览器有其他浏览器没有的性能瑕疵,那么就确保用你一切可用的手段来报告它。绝大多数浏览器都有为此而公开的 Bug 追迹系统。

提示: 我只建议,如果一个在某种浏览器中的性能问题真的是极端搅局的问题时才绕过它,而不是仅仅因为它使人厌烦或沮丧。而且我会非常小心地检查这种性能黑科技有没有在其他浏览器中产生负面影响。

大局

与担心所有这些微观性能的细节相反,我们应但关注大局类型的优化。

你怎么知道什么东西是不是大局的?你首先必须理解你的代码是否运行在关键路径上。如果它没在关键路径上,你的优化可能就没有太大价值。

“这是过早的优化!”你听过这种训诫吗?它源自 Donald Knuth 的一段著名的话:“过早的优化是万恶之源。”。许多开发者都引用这段话来说明大多数优化都是“过早”的而且是一种精力的浪费。事实是,像往常一样,更加微妙。

这是 Knuth 在语境中的原话:

程序员们浪费了大量的时间考虑,或者担心,他们的程序中的 不关键 部分的速度,而在考虑调试和维护时这些在效率上的企图实际上有很强大的负面影响。我们应当忘记微小的效率,可以说在大概 97%的情况下:过早的优化是万恶之源。然而我们不应该忽略那 关键的 3%中的机会。[强调]

(web.archive.org/web/20130731202547/http://pplab.snu.ac.kr/courses/adv_pl05/papers/p261-knuth.pdf, Computing Surveys, Vol 6, No 4, December 1974)

我相信这样转述 Knuth 的 意思 是合理的:“非关键路径的优化是万恶之源。”所以问题的关键是弄清楚你的代码是否在关键路径上——你因该优化它!——或者不。

我甚至可以激进地这么说:没有花在优化关键路径上的时间是浪费的,不管它的效果多么微小。没有花在优化非关键路径上的时间是合理的,不管它的效果多么大。

如果你的代码在关键路径上,比如将要一次又一次被运行的“热”代码块儿,或者在用户将要注意到的 UX 关键位置,比如循环动画或者 CSS 样式更新,那么你应当不遗余力地进行有意义的,可测量的重大优化。

举个例子,考虑一个动画循环的关键路径,它需要将一个字符串值转换为一个数字。这当然有多种方法做到,但是哪一个是最快的呢?

var x = "42";    // 需要数字 `42`

// 选择 1:让隐式强制转换自动完成工作
var y = x / 2;

// 选择 2:使用`parseInt(..)`
var y = parseInt( x, 0 ) / 2;

// 选择 3:使用`Number(..)`
var y = Number( x ) / 2;

// 选择 4:使用`+`二元操作符
var y = +x / 2;

// 选择 5:使用`|`二元操作符
var y = (x | 0) / 2;

注意: 我将这个问题留作给读者们的练习,如果你对这些选择之间性能上的微小区别感兴趣的话,可以做一个测试。

当你考虑这些不同的选择时,就像人们说的,“有一个和其他的不一样。”parseInt(..)可以工作,但它做的事情多的多——它会解析字符串而不是转换它。你可能会正确地猜想parseInt(..)是一个更慢的选择,而你可能应当避免使用它。

当然,如果x可能是一个 需要被解析 的值,比如"42px"(比如 CSS 样式查询),那么parseInt(..)确实是唯一合适的选择!

Number(..)也是一个函数调用。从行为的角度讲,它与+二元操作符是相同的,但它事实上可能慢一点儿,需要更多的机器指令运转来执行这个函数。当然,JS 引擎也可能识别出了这种行为上的对称性,而仅仅为你处理Number(..)行为的内联形式(也就是+x)!

但是要记住,痴迷于+xx | 0的比较在大多数情况下都是浪费精力。这是一个微观性能问题,而且你不应该让它使你的程序的可读性降低。

虽然你的程序的关键路径性能非常重要,但它不是唯一的因素。在几种性能上大体相似的选择中,可读性应当是另一个重要的考量。

尾部调用优化 (TCO)

正如我们早前简单提到的,ES6 包含了一个冒险进入性能世界的具体需求。它是关于在函数调用时可能会发生的一种具体的优化形式:尾部调用优化(TCO)

简单地说,一个“尾部调用”是一个出现在另一个函数“尾部”的函数调用,于是在这个调用完成后,就没有其他的事情要做了(除了也许要返回结果值)。

例如,这是一个带有尾部调用的非递归形式:

function foo(x) {
    return x;
}

function bar(y) {
    return foo( y + 1 );    // 尾部调用
}

function baz() {
    return 1 + bar( 40 );    // 不是尾部调用
}

baz();                        // 42

foo(y+1)是一个在bar(..)中的尾部调用,因为在foo(..)完成之后,bar(..)也即而完成,除了在这里需要返回foo(..)调用的结果。然而,bar(40) 不是 一个尾部调用,因为在它完成后,在baz()能返回它的结果前,这个结果必须被加 1。

不过于深入本质细节而简单地说,调用一个新函数需要保留额外的内存来管理调用栈,它称为一个“栈帧(stack frame)”。所以前面的代码段通常需要同时为baz()bar(..),和foo(..)都准备一个栈帧。

然而,如果一个支持 TCO 的引擎可以认识到foo(y+1)调用位于 尾部位置 意味着bar(..)基本上完成了,那么当调用foo(..)时,它就并没有必要创建一个新的栈帧,而是可以重复利用既存的bar(..)的栈帧。这不仅更快,而且也更节省内存。

在一个简单的代码段中,这种优化机制没什么大不了的,但是当对付递归,特别是当递归会造成成百上千的栈帧时,它就变成了 相当有用的技术。引擎可以使用 TCO 在一个栈帧内完成所有调用!

在 JS 中递归是一个令人不安的话题,因为没有 TCO,引擎就不得不实现一个随意的(而且各不相同的)限制,规定它们允许递归栈能有多深,来防止内存耗尽。使用 TCO,带有 尾部位置 调用的递归函数实质上可以没有边界地运行,因为从没有额外的内存使用!

考虑前面的递归factorial(..),但是将它重写为对 TCO 友好的:

function factorial(n) {
    function fact(n,res) {
        if (n < 2) return res;

        return fact( n - 1, n * res );
    }

    return fact( n, 1 );
}

factorial( 5 );        // 120

这个版本的factorial(..)仍然是递归的,而且它还是可以进行 TCO 优化的,因为两个内部的fact(..)调用都在 尾部位置

注意: 一个需要注意的重点是,TCO 尽在尾部调用实际存在时才会实施。如果你没用尾部调用编写递归函数,性能机制将仍然退回到普通的栈帧分配,而且引擎对于这样的递归的调用栈限制依然有效。许多递归函数可以像我们刚刚展示的factorial(..)那样重写,但是要小心处理细节。

ES6 要求各个引擎实现 TCO 而不是留给它们自行考虑的原因之一是,由于对调用栈限制的恐惧,缺少 TCO 实际上趋向于减少特定的算法在 JS 中使用递归实现的机会。

如果无论什么情况下引擎缺少 TCO 只是安静地退化到性能差一些的方式上,那么它可能不会是 ES6 需要 要求 的东西。但是因为缺乏 TCO 可能会实际上使特定的程序不现实,所以与其说它只是一种隐藏的实现细节,不如说它是一个重要的语言特性更合适。

ES6 保证,从现在开始,JS 开发者们能够在所有兼容 ES6+的浏览器上信赖这种优化机制。这是 JS 性能的一个胜利!

复习

有效地对一段代码进行性能基准分析,特别是将它与同样代码的另一种写法相比较来看哪一种方式更快,需要小心地关注细节。

与其运行你自己的统计学上合法的基准分析逻辑,不如使用 Benchmark.js 库,它会为你搞定。但要小心你如何编写测试,因为太容易构建一个看起来合法但实际上有漏洞的测试了——即使是一个微小的区别也会使结果歪曲到完全不可靠。

尽可能多地从不同的环境中得到尽可能多的测试结果来消除硬件/设备偏差很重要。jsPerf.com 是一个用于大众外包性能基准分析测试的神奇网站。

许多常见的性能测试不幸地痴迷于无关紧要的微观性能细节,比如比较x++++x。编写好的测试意味着理解如何聚焦大局上关注的问题,比如在关键路径上优化,和避免落入不同 JS 引擎的实现细节的陷阱。

尾部调用优化(TCO)是一个 ES6 要求的优化机制,它会使一些以前在 JS 中不可能的递归模式变得可能。TCO 允许一个位于另一个函数的 尾部位置 的函数调用不需要额外的资源就可以执行,这意味着引擎不再需要对递归算法的调用栈深度设置一个随意的限制了。

你不懂 JS:作用域与闭包

来源:你不懂 JS:作用域与闭包

你不懂 JS:作用域与闭包 第一章:什么是作用域?

几乎所有语言的最基础模型之一就是在变量中存储值,并且在稍后取出或修改这些值的能力。事实上,在变量中存储值和取出值的能力,给程序赋予了 状态

如果没有这样的概念,一个程序虽然可以执行一些任务,但是它们将会受到极大的限制而且不会非常有趣。

但是在我们的程序中纳入变量,引出了我们现在将要解决的最有趣的问题:这些变量 存活 在哪里?换句话说,它们被存储在哪儿?而且,最重要的是,我们的程序如何在需要它们的时候找到它们?

回答这些问题需要一组明确定义的规则,它定义如何在某些位置存储变量,以及如何在稍后找到这些变量。我们称这组规则为:作用域

但是,这些 作用域 规则是在哪里,如何被设置的?

编译器理论

根据你与各种编程语言打交道的水平不同,这也许是不证自明的,或者这也许令人吃惊,尽管 JavaScript 一般被划分到“动态”或者“解释型”语言的范畴,但是其实它是一个编译型语言。它 不是 像许多传统意义上的编译型语言那样预先被编译好,编译的结果也不能在各种不同的分布式系统间移植。

但是无论如何,JavaScript 引擎在实施许多与传统的语言编译器相同的步骤,虽然是以一种我们平常不能发觉的更精巧的方式。

在传统的编译型语言处理中,一块儿源代码,你的程序,在它被执行 之前 典型地将会经历三个步骤,大致被称为“编译”:

  1. 分词/词法分析: 将一连串字符打断成(对于语言来说)有意义的片段,称为 token(记号)。举例来说,考虑这段程序:var a = 2;。这段程序很可能会被打断成如下 token:vara=2,和;。空格也许会被保留为一个 token,这要看它是否是有意义的。

    注意: 分词和词法分析之间的区别是微妙和学术上的,其中心在于这些 token 是否以 无状态有状态 的方式被识别。简而言之,如果分词器去调用有状态的解析规则来弄清a是否应当被考虑为一个不同的 token,还是只是其他 token 的一部分,那么这就是 词法分析

  2. 解析: 将一个 token 的流(数组)转换为一个嵌套元素的树,它总体上表示了程序的语法结构。这棵树称为“AST”(Abstract Syntax Tree —— 抽象语法树)。

    var a = 2;的树也许开始于称为VariableDeclaration(变量声明)顶层节点,带有一个称为Identifier(标识符)的子节点(它的值为a),和另一个称为AssignmentExpression(赋值表达式)的子节点,而这个子节点本身带有一个称为NumericLiteral(数字字面量)的子节点(它的值为2)。

  3. 代码生成: 这个处理将 AST 转换为可执行的代码。这一部分将根据语言,它的目标平台等因素有很大的不同。

    所以,与其深陷细节,我们不如笼统地说,有一种方法将我们上面描述的var a = 2;的 AST 转换为机器指令,来实际上 创建 一个称为a的变量(包括分配内存等等),然后在a中存入一个值。

    注意: 引擎如何管理系统资源的细节远比我们要挖掘的东西深刻,所以我们将理所当然地认为引擎有能力按其需要创建和存储变量。

和大多数其他语言的编译器一样,JavaScript 引擎要比这区区三步复杂太多了。例如,在解析和代码生成的处理中,一定会存在优化执行效率的步骤,包括压缩冗余元素,等等。

所以,我在此描绘的只是大框架。但是我想你很快就会明白为什么我们涵盖的这些细节是重要的,虽然是在很高的层次上。

其一,JavaScript 引擎没有(像其他语言的编译器那样)大把的时间去优化,因为 JavaScript 的编译和其他语言不同,不是提前发生在一个编译的步骤中。

对 JavaScript 来说,在许多情况下,编译发生在代码被执行前的仅仅几微妙之内(或更少!)。为了确保最快的性能,JS 引擎将使用所有的招数(比如 JIT,它可以懒编译甚至是热编译,等等),而这远超出了我们的关于“作用域”的讨论。

为了简单起见,我们可以说,任何 JavaScript 代码段在它执行之前(通常是 刚好 在它执行之前!)都必须被编译。所以,JS 编译器将把程序var a = 2;拿过来,并首先编译它,然后准备运行它,通常是立即的。

理解作用域

我们将采用的学习作用域的方法,是将这个处理过程想象为一场对话。但是, 在进行这场对话呢?

演员

让我们见一见处理程序var a = 2;时进行互动的演员吧,这样我们就能理解稍后将要听到的它们的对话:

  1. 引擎:负责从始至终的编译和执行我们的 JavaScript 程序。

  2. 编译器引擎 的朋友之一;处理所有的解析和代码生成的重活儿(见前一节)。

  3. 作用域引擎 的另一个朋友;收集并维护一张所有被声明的标识符(变量)的列表,并对当前执行中的代码如何访问这些变量强制实施一组严格的规则。

为了 全面理解 JavaScript 是如何工作的,你需要开始像 引擎(和它的朋友们)那样 思考,问它们问的问题,并像它们一样回答。

反复

当你看到程序var a = 2;时,你很可能认为它是一个语句。但这不是我们的新朋友 引擎 所看到的。事实上,引擎 看到两个不同的语句,一个是 编译器 将在编译期间处理的,一个是 引擎 将在执行期间处理的。

那么,然我们来分析 引擎 和它的朋友们将如何处理程序var a = 2;

编译器 将对这个程序做的第一件事情,是进行词法分析来将它分解为一系列 token,然后这些 token 被解析为一棵树。但是当 编译器 到了代码生成阶段时,它会以一种与我们可能想象的不同的方式来对待这段程序。

一个合理的假设是,编译器 将会产生可以用这种假想代码概括的代码:“为一个变量分配内存,将它标记为a,然后将值2贴在这个变量里”。不幸的是,这不是十分准确。

编译器 将会这样处理:

  1. 遇到var a编译器作用域 去查看对于这个特定的作用域集合,变量a是否已经存在了。如果是,编译器 就忽略这个声明并继续前进。否则,编译器 就让 作用域 去为这个作用域集合声明一个称为a的新变量。

  2. 然后 编译器引擎 生成稍后要执行的代码,来处理赋值a = 2引擎 运行的代码首先让 作用域 去查看在当前的作用域集合中是否有一个称为a的变量可以访问。如果有,引擎 就使用这个变量。如果没有,引擎 就查看 其他地方(参加下面的嵌套 作用域 一节)。

如果 引擎 最终找到一个变量,它就将值2赋予它。如果没有,引擎 将会举起它的手并喊出一个错误!

总结来说:对于一个变量赋值,发生了两个不同的动作:第一,编译器 声明一个变量(如果先前没有在当前作用域中声明过),第二,当执行时,引擎作用域 中查询这个变量并给它赋值,如果找到的话。

编译器术语

为了继续更深入地理解,我们需要一点儿更多的编译器术语。

引擎 执行 编译器 在第二步中为它产生的代码时,它必须查询变量a来看它是否已经被声明过了,而且这个查询是咨询 作用域 的。但是 引擎 实施的查询的类型会影响查询的结果。

在我们这个例子中,引擎 将会对变量a实施一个“LHS”查询。另一种查询的类型称为“RHS”。

我打赌你能猜出“L”和“R”是什么意思。这两个术语表示“Left-hand Side(左手边)”和“Right-hand Side(右手边)”

什么的……边?赋值操作的。

换言之,当一个变量出现在赋值操作的左手边时,会进行 LHS 查询,当一个变量出现在赋值操作的右手边时,会进行 RHS 查询。

实际上,我们可以表述得更准确一点儿。对于我们的目的来说,一个 RHS 是难以察觉的,因为它简单地查询某个变量的值,而 LHS 查询是试着找到变量容器本身,以便它可以赋值。从这种意义上说,RHS 的含义实质上不是 真正的 “一个赋值的右手边”,更准确地说,它只是意味着“不是左手边”。

在这一番油腔滑调之后,你也可以认为“RHS”意味着“取得他/她的源(值)”,暗示着 RHS 的意思是“去取……的值”。

让我们挖掘得更深一些。

当我说:

console.log( a );

这个指向a的引用是一个 RHS 引用,因为这里没有东西被赋值给a。而是我们在查询a并取得它的值,这样这个值可以被传递进console.log(..)

作为对比:

a = 2;

这里指向a的引用是一个 LHS 引用,因为我们实际上不关心当前的值是什么,我们只是想找到这个变量,将它作为= 2赋值操作的目标。

注意: LHS 和 RHS 意味着“赋值的左/右手边”未必像字面上那样意味着“=赋值操作符的左/右边”。赋值有几种其他的发生形式,所以最好在概念上将它考虑为:“赋值的目标(LHS)”和“赋值的源(RHS)”。

考虑这段程序,它既有 LHS 引用又有 RHS 引用:

function foo(a) {
    console.log( a ); // 2
}

foo( 2 );

调用foo(..)的最后一行作为一个函数调用要求一个指向foo的 RHS 引用,意味着,“去查询foo的值,并把它交给我”。另外,(..)意味着foo的值应当被执行,所以它最好实际上是一个函数!

这里有一个微妙但重要的赋值。你发现了吗?

你可能错过了这个代码段隐含的a = 2。它发生在当值2作为参数值传递给foo(..)函数时,这时值2 被赋值 给参数a。为了(隐含地)给参数a赋值,进行了一个 LHS 查询。

这里还有一个a的值的 RHS 引用,它的结果值被传入console.log(..)console.log(..)需要一个引用来执行。它为console对象进行一个 RHS 查询,然后发生一个属性解析来看它是否有一个称为log的方法。

最后,我们可以将这一过程概念化为,在将值2(通过变量a的 RHS 查询得到的)传入log(..)时发生了一次 LHS/RHS 的交换。在log(..)的原生实现内部,我们可以假定它拥有参数,其中的第一个(也许被称为arg1)在2被赋值给它之前,进行了一次 LHS 引用查询。

注意: 你可能会试图将函数声明function foo(a) {...概念化为一个普通的变量声明和赋值,比如var foofoo = function(a){...。这样做会诱使你认为函数声明涉及了一次 LHS 查询。

然而,一个微妙但重要的不同是,在这种情况下 编译器 同时处理声明和代码生成期间的值的定义,如此当 引擎 执行代码时,没有必要将一个函数值“赋予”foo。因此,认为函数声明是一个我们在这里讨论的 LHS 查询赋值是不太合适的。

引擎/作用域对话

function foo(a) {
    console.log( a ); // 2
}

foo( 2 );

让我们将上面的(处理这个代码段的)交互想象为一场对话。这场对话将会有点儿像这样进行:

引擎:嘿 作用域,我有一个foo的 RHS 引用。听说过它吗?

作用域;啊,是的,听说过。编译器 刚在一秒钟之前声明了它。它是一个函数。给你。

引擎:太棒了,谢谢!好的,我要执行foo了。

引擎:嘿,作用域,我得到了一个a的 LHS 引用,听说过它吗?

作用域:啊,是的,听说过。编译器 刚才将它声明为foo的一个正式参数了。给你。

引擎:一如既往的给力,作用域。再次感谢你。现在,该把2赋值给a了。

引擎:嘿,作用域,很抱歉又一次打扰你。我需要 RHS 查询console。听说过它吗?

作用域:没关系,引擎,这是我一天到晚的工作。是的,我得到console了。它是一个内建对象。给你。

引擎:完美。查找log(..)。好的,很好,它是一个函数。

引擎:嘿,作用域。你能帮我查一下a的 RHS 引用吗?我想我记得它,但只是想再次确认一下。

作用域:你是对的,引擎。同一个家伙,没变。给你。

引擎:酷。传递a的值,也就是2,给log(..)

...

小测验

检查你目前为止的理解。确保你扮演 引擎,并与 作用域 “对话”:

function foo(a) {
    var b = a;
    return a + b;
}

var c = foo( 2 );
  1. 找到所有的 LHS 查询(有 3 处!)。

  2. 找到所有的 RHS 查询(有 4 处!)。

注意: 小测验答案参见本章的复习部分!

嵌套的作用域

我们说过 作用域 是通过标识符名称查询变量的一组规则。但是,通常会有多于一个的 作用域 需要考虑。

就像一个代码块儿或函数被嵌套在另一个代码块儿或函数中一样,作用域被嵌套在其他的作用域中。所以,如果在直接作用域中找不到一个变量的话,引擎 就会咨询下一个外层作用域,如此继续直到找到这个变量或者到达最外层作用域(也就是全局作用域)。

考虑这段代码:

function foo(a) {
    console.log( a + b );
}

var b = 2;

foo( 2 ); // 4

b的 RHS 引用不能在函数foo的内部被解析,但是可以在包围着它的 作用域(这个例子中是全局作用域)中解析。

所以,重返 引擎作用域 的对话,我们会听到:

引擎:“嘿,foo作用域,听说过b吗?我得到一个它的 RHS 引用。”

作用域:“没有,从没听说过。问问别人吧。”

引擎:“嘿,foo外面的 作用域,哦,你是全局 作用域,好吧,酷。听说过b吗?我得到一个它的 RHS 引用。”

作用域:“是的,当然有。给你。”

遍历嵌套 作用域 的简单规则:引擎 从当前执行的 作用域 开始,在那里查找变量,如果没有找到,就在上一级继续查找,如此类推。如果到了最外层的全局作用域,那么查找就会停止,无论它是否找到了变量。

建筑的隐喻

为了将嵌套 作用域 解析的过程可视化,我想让你考虑一下这个高层建筑。


fig1.png

这个建筑物表示我们程序的嵌套 作用域 规则集合。无论你在哪里,建筑的第一层表示你当前执行的 作用域。建筑的顶层表示全局 作用域

你通过在你当前的楼层中查找来解析 LHS 和 RHS 引用,如果你没有找到它,就做电梯到上一层楼,在那里寻找,然后再上一层,如此类推。一旦你到了顶层(全局 作用域),你要么找到了你想要的东西,要么没有。但是不管怎样你都不得不停止了。

错误

为什么我们区别 LHS 和 RHS 那么重要?

因为在变量还没有被声明(在所有被查询的 作用域 中都没找到)的情况下,这两种类型的查询的行为不同。

考虑如下代码:

function foo(a) {
    console.log( a + b );
    b = a;
}

foo( 2 );

b的 RHS 查询第一次发生时,它是找不到的。它被说成是一个“未声明”的变量,因为它在作用域中找不到。

如果 RHS 查询在嵌套的 作用域 的任何地方都找不到一个值,这会导致 引擎 抛出一个ReferenceError。必须要注意的是这个错误的类型是ReferenceError

相比之下,如果 引擎 在进行一个 LHS 查询并到达了顶层(全局 作用域)都没有找到它,而且如果程序没有运行在“Strict 模式”^([1])下,那么这个全局 作用域 将会在 全局作用域中 创建一个同名的新变量,并把它交给引擎

“不,之前没有这样的东西,但是我可以帮忙给你创建一个。”

在 ES5 中被加入的“Strict 模式”^([1]),有许多与一般/宽松/懒惰模式不同的行为。其中之一就是不允许自动/隐含的全局变量创建。在这种情况下,将不会有全局 作用域 的变量交回给 LHS 查询,并且类似于 RHS 的情况, 引擎 将抛出一个ReferenceError

现在,如果一个 RHS 查询的变量被找到了,但是你试着去做一些这个值不可能做到的事,比如将一个非函数的值作为函数运行,或者引用null或者undefined值的属性,那么 引擎 就会抛出一个不同种类的错误,称为TypeError

ReferenceError是关于 作用域 解析失败的,而TypeError暗示着 作用域 解析成功了,但是试图对这个结果进行了一个非法/不可能的动作。

复习

作用域是一组规则,它决定了在哪里和如何查找一个变量(标识符)。这种查询也许是为了向这个变量赋值,这时变量是一个 LHS(左手边)引用,或者是为取得它的值,这时变量是一个 RHS(右手边)引用。

LHS 引用得自赋值操作。作用域 相关的赋值可以通过=操作符发生,也可以通过向函数参数传递(赋予)参数值发生。

JavaScript 引擎 在执行代码之前首先会编译它,这样做,它将var a = 2;这样的语句分割为两个分离的步骤:

  1. 首先,var a在当前 作用域 中声明。这是在最开始,代码执行之前实施的。

  2. 稍后,a = 2查找这个变量(LHS 引用),并且如果找到就向它赋值。

LHS 和 RHS 引用查询都从当前执行中的 作用域 开始,如果有需要(也就是,它们在这里没能找到它们要找的东西),它们会在嵌套的 作用域 中一路向上,一次一个作用域(层)地查找这个标识符,直到它们到达全局作用域(顶层)并停止,既可能找到也可能没找到。

未满足的 RHS 引用会导致ReferenceError被抛出。未满足的 LHS 引用会导致一个自动的,隐含地创建的同名全局变量(如果不是“Strict 模式”^([1])),或者一个ReferenceError(如果是“Strict 模式”^([1]))。

小测验答案

function foo(a) {
    var b = a;
    return a + b;
}

var c = foo( 2 );
  1. 找出所有的 LHS 查询(有 3 处!)。

    c = .., a = 2(隐含的参数赋值)和b = ..

  2. 找出所有的 RHS 查询(有 4 处!)。

    foo(2.., = a;, a + .... + b


[1]:MDN: Strict Mode

你不懂 JS:作用域与闭包 第二章:词法作用域

在第一章中,我们将“作用域”定义为一组规则,它主宰着 引擎 如何通过标识符名称在当前的 作用域,或者在包含它的任意 嵌套作用域 中来查询一个变量,

作用域的工作方式有两种占统治地位的模型。其中的第一种是最最常见,在绝大多数的编程语言中被使用的。它称为 词法作用域,我们将深入检视它。另一种仍然被一些语言(比如 Bash 脚本,Perl 中的一些模式,等等)使用的模型,称为 动态作用域

动态作用域在附录 A 中讲解。我在这里提到它仅仅是为词法作用域提供一个对比,而词法作用域是 JavaScript 采用的作用域模型。

词法分析时

正如我们在第一章中讨论的,标准语言编译器的第一个传统步骤称为词法分析(也就是分词)。如果你回忆一下,词法分析处理是检查一串源代码字符,并给 token 赋予语法含义作为某种有状态解析的输出。

正是这个概念给理解词法作用域是什么提供了基础,也是这个名词的渊源。

要定义它有点儿兜圈子,词法作用域是在词法分析时被定义的作用域。换句话说,词法作用域是基于,你,在写程序时,变量和作用域的块儿在何处被编写决定的,因此它在词法分析器处理你的代码时(基本上)是固定不变的。

注意: 我们将会稍稍看到有一些方法可以骗过词法作用域,从而在词法分析器处理过后改变它,但是这些方法都是使人皱眉头的。事实上公认的最佳实践是,将词法作用域看作是仅仅依靠词法的,因此在本质上完全是编写时决定的。

让我们考虑这段代码:

function foo(a) {

    var b = a * 2;

    function bar(c) {
        console.log( a, b, c );
    }

    bar(b * 3);
}

foo( 2 ); // 2 4 12

在这个代码实例中有三个固有的嵌套作用域。将这些作用域考虑为套在一起的气泡可能有助于思考。


fig2.png

气泡 1 包围着全局作用域,它里面只有一个标识符:foo

气泡 2 包围着作用域foo,它含有三个标识符:abarb

气泡 3 包围着作用域bar,它里面只包含一个标识符:c

作用域气泡是根据作用域的块儿被写在何处定义的,一个嵌套在另一个内部,等等。在下一章中,我们将讨论作用域的不同单位,但是就现在来说,让我们认为每一个函数创建了一个新的作用域气泡。

bar的气泡完全被包含在foo的气泡中,因为(而且只因为)这就是我们选择定义函数bar的位置。

注意这些嵌套的气泡是严格嵌套的。我们没有讨论气泡可以跨越边界的维恩图(Venn diagrams)。换句话说,没有那个函数的气泡可以同时(部分地)存在于另外两个外部的作用域气泡中,就像没有函数可以部分地存在于它的两个父函数中一样。

查询

这些作用域气泡的结构和相对位置完全解释了 引擎 在查找一个标识符时,它需要查看的所有地方。

在上面的代码段中,引擎 执行语句console.log(..)并开始查找三个被引用的变量abc。它首先从最内部的作用域气泡开始,也就是bar(..)函数的作用域。在这里它找不到a,所以它向上走一层,到外面下一个最近的作用域气泡,foo(..)的作用域。它在这里找到了a,于是它就使用这个a。同样的事情也发生在b上。但是对于c,它在bar(..)内部就找到了。

如果在bar(..)内部和foo(..)内部都有一个c,那么console.log(..)语句将会找到并使用bar(..)中的那一个,绝不会到达foo(..)中的那一个。

一旦找到第一个匹配,作用域查询就停止了。相同的标识符名称可以在嵌套作用域的多个层中被指定,这称为“遮蔽(shadowing)”(内部的标识符“遮蔽”了外部的标识符)。无论如何遮蔽,作用域查询总是从当前被执行的最内侧的作用域开始,向外/向上不断查找,直到第一个匹配才停止。

注意: 全局变量也自动地是全局对象(在浏览器中是window,等等)的属性,所以不直接通过全局变量的词法名称,而通过将它作为全局对象的一个属性引用来间接地引用,是可能的。

window.a

这种技术给出了访问全局变量的方法,没有它全局变量将因为被遮蔽而不可访问。然而,被遮蔽的非全局变量是无法访问的。

不管函数是从 哪里 被调用的,也不论它是 如何 被调用的,它的词法作用域是由这个函数被声明的位置 唯一 定义的。

词法作用域查询 仅仅 在处理头等标识符时实施,比如ab,和c。如果你在一段代码中拥有一个foo.bar.baz的引用,词法作用域查询将在查找foo标识符时实施,但一旦定位这个变量,对象属性访问规则将会分别接管barbaz属性的解析。

欺骗词法作用域

如果词法作用域仅仅是由函数被声明的位置定义的,而且这个位置完全是一个编写时的决定,那么怎么可能有办法在运行时“修改”(也就是,作弊欺骗)词法作用域呢?

JavaScript 有两种这样的机制。在广大的社区中它们都等同地被认为是让人皱眉头的,在你代码中使用它们是一种差劲儿的做法。但是关于它们的具有代表性的争论经常错过了最重要的一点:欺骗词法作用域会导致更低下的性能。

在我讲解性能的问题以前,先让我们看看这两种机制是如何工作的。

eval

JavaScript 中的eval(..)函数接收一个字符串作为参数值,并将这个字符串的内容看作是好像它已经被实际编写在程序的那个位置上。换句话说,你可以用编程的方式在你编写好的代码内部生成代码,而且你可以运行这个生成的代码,就好像它在编写时就已经在那里了一样。

如果以这种观点来评价eval(..),那么eval(..)是如何允许你修改词法作用域环境应当是很清楚的:欺骗并假装这个编写时(也就是,词法)代码一直就在那里。

eval(..)被执行的后续代码行中,引擎 将不会“知道”或“关心”前面的代码是被动态翻译的,而且因此修改了词法作用域环境。引擎 将会像它一直做的那样,简单地进行词法作用域查询。

考虑如下代码:

function foo(str, a) {
    eval( str ); // 作弊!
    console.log( a, b );
}

var b = 2;

foo( "var b = 3;", 1 ); // 1, 3

eval(..)调用的位置上,字符串"var b = 3"被看作是一直就存在在那里的代码。因为这个代码恰巧声明了一个新的变量b,它就修改了现存的foo(..)的词法作用域。事实上,就像上面提到的那样,这个代码实际上在foo(..)内部创建了变量b,它遮蔽了声明在外部(全局)作用域中的b

console.log(..)调用发生时,它会在foo(..)的作用域中找到ab,而且绝不会找到外部的b。这样,我们就打印出"1, 3"而不是一般情况下的"1, 2"。

注意: 在这个例子中,为了简单起见,我们传入的“代码”字符串是固定的文字。但是它可以通过根据你的程序逻辑将字符拼接在一起,很容易地以编程方式创建。eval(..)通常被用于执行动态创建的代码,因为动态地对一段实质上源自字符串字面值的静态代码进行求值,并不会比直接编写这样的代码带来更多真正的好处。

默认情况下,如果eval(..)执行的代码字符串包含一个或多个声明(变量或函数)的话,这个动作就会修改这个eval(..)所在的词法作用域。技术上讲,eval(..)可以通过种种技巧(超出了我们这里的讨论范围)被“间接”调用,而使它在全局作用域的上下文中执行,如此修改全局作用域。但不论那种情况,eval(..)都可以在运行时修改一个编写时的词法作用域。

注意:eval(..)被用于一个操作它自己的词法作用域的 strict 模式程序时,在eval(..)内部做出的声明不会实际上修改包围它的作用域。

function foo(str) {
 "use strict";
   eval( str );
   console.log( a ); // ReferenceError: a is not defined
}

foo( "var a = 2" );

在 JavaScript 中还有其他的工具拥有与eval(..)非常类似的效果。setTimeout(..)setInterval(..)可以 为它们各自的第一个参数值接收一个字符串,其内容将会被eval为一个动态生成的函数的代码。这种老旧的,遗产行为早就被废弃了。别这么做!

new Function(..)函数构造器类似地为它的 最后 一个参数值接收一个代码字符串,来把它转换为一个动态生成的函数(前面的参数值,如果有的话,将作为新函数的命名参数)。这种函数构造器语法要比eval(..)稍稍安全一些,但在你的代码中它仍然应当被避免。

在你的代码中动态生成代码的用例少的不可思议,因为在性能上的倒退使得这种能力几乎总是得不偿失。

with

JavaScript 的另一个使人皱眉头(而且现在被废弃了!),而且可以欺骗词法作用域的特性是with关键字。有许多种合法的方式可以讲解with,但是我在此选择从它如何与词法作用域互动并影响词法作用域的角度来讲解它。

讲解with的典型方式是作为一种缩写,来引用一个对象的多个属性,而 不必 每次都重复对象引用本身。

例如:

var obj = {
    a: 1,
    b: 2,
    c: 3
};

//  重复“obj”显得更“繁冗”
obj.a = 2;
obj.b = 3;
obj.c = 4;

// “更简单”的缩写
with (obj) {
    a = 3;
    b = 4;
    c = 5;
}

然而,这里发生的事情要比只是一个对象属性访问的便捷缩写要多得多。考虑如下代码:

function foo(obj) {
    with (obj) {
        a = 2;
    }
}

var o1 = {
    a: 3
};

var o2 = {
    b: 3
};

foo( o1 );
console.log( o1.a ); // 2

foo( o2 );
console.log( o2.a ); // undefined
console.log( a ); // 2 -- 哦,全局作用域被泄漏了!

在这个代码示例中,创建了两个对象o1o2。一个有a属性,而另一个没有。foo(..)函数接收一个对象引用obj作为参数值,并在这个引用上调用with (obj) {..}。在with块儿内部,我们制造了一个变量a的看似是普通词法引用的东西,实际上是一个 LHS 引用(见第一章),并将值2赋予它。

当我们传入o1时,赋值a = 2找到属性o1.a并赋予它值2,正如在后续的console.log(o1.a)语句反应的那样。然而,当我们传入o2,因为它没有a属性,没有这样的属性被创建,所以o2.a还是undefined

但是之后我们注意到一个特别的副作用,赋值a = 2创建了一个全局变量a。这怎么可能?

with语句接收一个对象,这个对象有 0 个或多个属性,并 将这个对象视为好像它是一个完全隔离的词法作用域,因此这个对象的属性被视为在这个“作用域”中词法定义的标识符。

注意: 尽管一个with块儿将一个对象视为一个词法作用域,但是在with块儿内部的一个普通var声明将不会归于这个with块儿的作用域,而是归于包含它的函数作用域。

如果eval(..)函数接收一个含有一个或多个声明的代码字符串,它就会修改现存的词法作用域,而with语句实际上是从你传递给它的对象中凭空制造了一个 全新的词法作用域

以这种方式理解的话,当我们传入o1with语句声明的“作用域”就是o1,而且这个“作用域”拥有一个对应于o1.a属性的“标识符”。但当我们使用o2作为“作用域”时,它里面没有这样的a“标识符”,于是 LHS 标识符查询(见第一章)的普通规则发生了。

“作用域”o2中没有,foo(..)的作用域中也没有,甚至连全局作作用域中都没有找到标识符a,所以当a = 2被执行时,其结果就是自动全局变量被创建(因为我们没有在 strict 模式下)。

with在运行时将一个对象和它的属性转换为一个带有“标识符”的“作用域”,这个奇怪想法有些烧脑。但是对于我们看到的结果来说,这是我能给出的最清晰的解释。

注意: 除了使用它们是个坏主意意外,eval(..)with都受 Strict 模式的影响(制约)。with干脆就不允许使用,而虽然eval(..)还保有其核心功能,但各种间接形式的或不安全的eval(..)是不允许的。

性能

通过在运行时修改,或创建新的词法作用域,eval(..)with都可以欺骗编写时定义的词法作用域。

你可能会问,那又有什么大不了的?如果它们提供了更精巧的功能和编码灵活性,那它们不是 好的 特性吗?不。

JavaScript 引擎 在编译阶段期行许多性能优化工作。其中的一些优化原理都归结为实质上在进行词法分析时可以静态地分析代码,并提前决定所有的变量和函数声明都在什么位置,这样在执行期间就可以少花些力气来解析标识符。

但如果 引擎 在代码中找到一个eval(..)with,它实质上就不得不 假定 自己知道的所有的标识符的位置可能是不合法的,因为它不可能在词法分析时就知道你将会向eval(..)传递什么样的代码来修改词法作用域,或者你可能会向with传递的对象有什么样的内容来创建一个新的将被查询的词法作用域。

换句话说,悲观地看,如果eval(..)with出现,那么它 做的几乎所有的优化都会变得没有意义,所以它就会简单地根本不做任何优化。

你的代码几乎肯定会趋于运行的更慢,只因为你在代码的任何地方引入了一个了eval(..)with。无论 引擎 将在努力限制这些悲观臆测的副作用上表现得多么聪明,都没有任何办法可以绕过这个事实:没有优化,代码就运行的更慢。

复习

词法作用域意味着作用域是由编写时函数被声明的位置的决策定义的。编译器的词法分析阶段实质上可以知道所有的标识符是在哪里和如何声明的,并如此在执行期间预测它们将如何被查询。

在 JavaScript 中有两种机制可以“欺骗”词法作用域:eval(..)with。前者可以通过对一个拥有一个或多个声明的“代码”字符串进行求值,来(在运行时)修改现存的词法作用域。后者实质上是通过将一个对象引用看作一个“作用域”,并将这个对象的属性看作作用域中的标识符,(同样,也是在运行时)创建一个全新的词法作用域。

这些机制的缺点是,它压制了 引擎 在作用域查询上进行编译期优化的能力,因为 引擎 不得不悲观地假定这样的优化是不合法的。这两种特性的结果就是代码 会运行的更慢。不要使用它们。

你不懂 JS:作用域与闭包 第三章:函数与块儿作用域

正如我们在第二章中探索的,作用域由一系列“气泡”组成,这些“气泡”的每一个就像一个容器或篮子,标识符(变量,函数)就在它里面被声明。这些气泡整齐地互相嵌套在一起,而且这种嵌套是在编写时定义的。

但是到底是什么才能制造一个新气泡?只能是函数吗?JavaScript 中的其他结构可以创建作用域的气泡吗?

函数中的作用域

对这些问题的最常见的回答是,JavaScript 拥有基于函数的作用域。也就是,你声明的每一个函数都为自己创建了一个气泡,而且没有其他的结构可以创建它们自己的作用域气泡。但是就像我们一会儿就会看到的,这不完全正确。

但首先,让我们探索一下函数作用域和它的含义。

考虑这段代码:

function foo(a) {
    var b = 2;

    // 一些代码

    function bar() {
        // ...
    }

    // 更多代码

    var c = 3;
}

在这个代码段中,foo(..)的作用域气泡包含标识符abcbar。一个声明出现在作用域 何处无关紧要的,不管怎样,变量和函数属于包含它们的作用域气泡。在下一章中我们将会探索这到底是如何工作的。

bar(..)拥有它自己的作用域气泡。全局作用域也一样,它仅含有一个标识符:foo

因为abc,和bar都属于foo(..)的作用域气泡,所以它们在foo(..)外部是不可访问的。也就是,接下来的代码都会得到ReferenceError错误,因为这些标识符在全局作用域中都不可用:

bar(); // 失败

console.log( a, b, c ); // 3 个都失败

然而,所有这些标识符(abc,和bar)在foo(..)内部 都是可以访问的,而且在bar(..)内部实际上也都是可用的(假定在bar(..)内部没有遮蔽标识符的声明)。

函数作用域支持着这样的想法:所有变量都属于函数,而且贯穿整个函数始终都可以使用和重用(而且甚至可以在嵌套的作用域中访问)。这种设计方式可以十分有用,而且肯定可以完全利用 JavaScript 的“动态”性质 —— 变量可以根据需要接受不同种类型的值。

另一方面,如果你不小心提防,跨越整个作用域存在的变量可能会导致一些以外的陷阱。

隐藏于普通作用域

考虑一个函数的传统方式是,你声明一个函数,并在它内部添加代码。但是相反的想法也同样强大和有用:拿你所编写的代码的任意一部分,在它周围包装一个函数声明,这实质上“隐藏”了这段代码。

其实际结果是在这段代码周围创建了一个作用域气泡,这意味着现在在这段代码中的任何声明都将绑在这个新的包装函数的作用域上,而不是前一个包含它们的作用域。换句话说,你可以通过将变量和函数围在一个函数的作用域中来“隐藏”它们。

为什么“隐藏”变量和函数是一种有用的技术?

有各种原因驱使着这种基于作用域的隐藏。它们主要是由一种称为“最低权限原则”的软件设计原则引起的^([1]),有时也被称为“最低授权”或“最少曝光”。这个原则规定,在软件设计中,比如一个模块/对象的 API,你应当只暴露所需要的最低限度的东西,而“隐藏”其他的一切。

这个原则可以扩展到用哪个作用域来包含变量和函数的选择。如果所有的变量和函数在全局作用域中,它们将理所当然地对任何嵌套的作用域是可访问的。但这回违背“最少……”原则,因为你(很可能)暴露了许多你本应当保持为私有的变量和函数,而这些代码的恰当用法是不鼓励访问这些变量/函数的。

例如:

function doSomething(a) {
    b = a + doSomethingElse( a * 2 );

    console.log( b * 3 );
}

function doSomethingElse(a) {
    return a - 1;
}

var b;

doSomething( 2 ); // 15

在这个代码段中,变量b和函数doSomethingElse(..)很可能是doSomething(..)如何工作的“私有”细节。允许外围的作用域“访问”bdoSomethingElse(..)不仅没必要而且可能是“危险的”,因为它们可能会以种种意外的方式,有意或无意地被使用,而这也许违背了doSomething(..)假设的前提条件。

一个更“恰当”的设计是讲这些私有细节隐藏在doSomething(..)的作用域内部,比如:

function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1;
    }

    var b;

    b = a + doSomethingElse( a * 2 );

    console.log( (b * 3) );
}

doSomething( 2 ); // 15

现在,bdoSomethingElse(..)对任何外界影响都是不可访问的,而是仅仅由doSomething(..)控制。它的功能和最终结果不受影响,但是这种设计将私有细节保持为私有的,这通常被认为是好的软件。

避免冲突

将变量和函数“隐藏”在一个作用域内部的另一个好处是,避免两个同名但用处不同的标识符之间发生无意的冲突。冲突经常导致值被意外地覆盖。

例如:

function foo() {
    function bar(a) {
        i = 3; // 在外围的 for 循环的作用域中改变`i`
        console.log( a + i );
    }

    for (var i=0; i<10; i++) {
        bar( i * 2 ); // 噢,无限循环!
    }
}

foo();

bar(..)内部的赋值i = 3意外地覆盖了在foo(..)的 for 循环中声明的i。在这个例子中,这将导致一个无限循环,因为i被设定为固定的值3,而它将永远< 10

bar(..)内部的赋值需要声明一个本地变量来使用,不论选用什么样的标识符名称。var i = 3;将修复这个问题(并将为i创建一个前面提到的“遮蔽变量”声明)。一个 另外的,不是代替的,选项是完全选择另外一个标识符名称,比如var j = 3;。但是你的软件设计也许会自然而然地使用相同的标识符名称,所以在这种情况下利用作用域来“隐藏”你的内部声明是你最好/唯一的选择。

全局“名称空间”

变量冲突(可能)发生的一个特别强有力的例子是在全局作用域中。多个库被加载到你的程序中时可以十分容易地互相冲突,如果它们没有适当地隐藏它们的内部/私有函数和变量。

这样的库通常会在全局作用域中使用一个足够独特的名称来创建一个单独的变量声明,它经常是一个对象。然后这个对象被用作这个库的一个“名称空间”,所有要明确暴露出来的功能都被作为属性挂在这个对象(名称空间)上,而不是将它们自身作为顶层词法作用域的标识符。

例如:

var MyReallyCoolLibrary = {
    awesome: "stuff",
    doSomething: function() {
        // ...
    },
    doAnotherThing: function() {
        // ...
    }
};

模块管理

另一种回避冲突的选择是更加现代的“模块”方式,它使用任意一种依赖管理器。使用这些工具,没有库可以向全局作用域添加任何标识符,取而代之的是使用依赖管理器的各种机制,要求库的标识符被明确地导入到另一个指定的作用域中。

应该可以看到,这些工具并不拥有可以豁免于词法作用域规则的“魔法”功能。它们简单地使用这里讲解的作用域规则,来强制标识符不会被注入任何共享的作用域,而是保持在私有的,不易冲突的作用域中,这防止了任何意外的作用域冲突。

因此,如果你选择这样做的话,你可以防御性地编码,并在实际上不使用依赖管理器的情况下,取得与使用它们相同的结果。关于模块模式的更多信息参见第五章。

函数作为作用域

我们已经看到,我们可以拿来一段代码并在它周围包装一个函数,而这实质上对外部作用域“隐藏”了这个函数内部作用域包含的任何变量或函数声明。

例如:

var a = 2;

function foo() { // <-- 插入这个

    var a = 3;
    console.log( a ); // 3

} // <-- 和这个
foo(); // <-- 还有这个

console.log( a ); // 2

虽然这种技术“可以工作”,但它不一定非常理想。它引入了几个问题。首先是我们不得不声明一个命名函数foo(),这意味着这个标识符名称foo本身就“污染”了外围作用域(在这个例子中是全局)。我们要不得不通过名称(foo())明确地调用这个函数来使被包装的代码真正运行。

如果这个函数不需要名称(或者,这个名称不污染外围作用域),而且如果这个函数能自动地被执行就更理想了。

幸运的是,JavaScript 给这两个问题提供了一个解决方法。

var a = 2;

(function foo(){ // <-- 插入这个

    var a = 3;
    console.log( a ); // 3

})(); // <-- 和这个

console.log( a ); // 2

让我们分析一下这里发生了什么。

首先注意,与仅仅是function...相对,这个包装函数语句以(function...)开头。虽然这看起来像是一个微小的细节,但实际上这是重大改变。与将这个函数视为一个标准的声明不同的是,这个函数被视为一个函数表达式。

注意: 区分声明与表达式的最简单的方法是,这个语句中(不仅仅是一行,而是一个独立的语句)“function”一词的位置。如果“function”是这个语句中的第一个东西,那么它就是一个函数声明。否则,它就是一个函数表达式。

这里我们可以观察到一个函数声明和一个函数表达式之间的关键不同是,它的名称作为一个标识符被绑定在何处。

比较这前两个代码段。在第一个代码段中,名称foo被绑定在外围作用域中,我们用foo()直接调用它。在第二个代码段中,名称foo没有被绑定在外围作用域中,而是被绑定在它自己的函数内部。

换句话说,(function foo(){ .. })作为一个表达式意味着标识符foo仅能在..代表的作用域中被找到,而不是在外部作用域中。将名称foo隐藏在它自己内部意味着它不会多余地污染外围作用域。

匿名与命名

你可能对函数表达式作为回调参数再熟悉不过了,比如:

setTimeout( function(){
    console.log("I waited 1 second!");
}, 1000 );

这称为一个“匿名函数表达式”,因为function()...上没有名称标识符。函数表达式可以是匿名的,但是函数声明不能省略名称 —— 那将是不合法的 JS 程序。

匿名函数表达式可以快速和很容易地键入,而且许多库和工具往往鼓励使用这种代码惯用风格。然而,它们有几个缺点需要考虑:

  1. 在栈轨迹上匿名函数没有有用的名称可以表示,这使得调试更加困难。

  2. 没有名称的情况下,如果这个函数需要为了递归等目的引用它自己,那么就需要很不幸地使用 被废弃的 arguments.callee引用。另一个需要自引用的例子是,当一个事件处理器函数在被触发后想要把自己解除绑定。

  3. 匿名函数省略的名称经常对提供更易读/易懂的代码很有帮助。一个描述性的名称可以帮助代码自解释。

内联函数表达式 很强大且很有用 —— 匿名和命名的问题并不会贬损这一点。给你的函数表达式提供一个名称就可以十分有效地解决这些缺陷,而且没有实际的坏处。最佳的方法是总是命名你的函数表达式:

setTimeout( function timeoutHandler(){ // <-- 看,我有一个名字!
    console.log( "I waited 1 second!" );
}, 1000 );

立即调用函数表达式

var a = 2;

(function foo(){

    var a = 3;
    console.log( a ); // 3

})();

console.log( a ); // 2

得益于包装在一个()中,我们有了一个作为表达式的函数,我们可以通过在末尾加入另一个()来执行这个函数,就像(function foo(){ .. })()。第一个外围的()使这个函数变成表达式,而第二个()执行这个函数。

这个模式是如此常见,以至于几年前开发者社区同意给它一个术语:IIFE,它表示“立即被调用的函数表达式”(Immediately Invoked Function E xpression)。

当然,IIFE 不一定需要一个名称 —— IIFE 的最常见形式是使用一个匿名函数表达式。虽然少见一些,与匿名函数表达式相比,命名的 IIFE 拥有前述所有的好处,所以它是一个可以采用的好方式。

var a = 2;

(function IIFE(){

    var a = 3;
    console.log( a ); // 3

})();

console.log( a ); // 2

传统的 IIFE 有一种稍稍变化的形式,一些人偏好:(function(){ .. }())。仔细观察不同之处。在第一种形式中,函数表达式被包在( )中,然后用于调用的()出现在它的外侧。在第二种形式中,用于调用的()被移动到用于包装的( )内侧。

这两种形式在功能上完全相同。这纯粹是一个你偏好的风格的选择。

IIFE 的另一种十分常见的变种是,利用它们实际上只是函数调用的事实,来传入参数值。

例如:

var a = 2;

(function IIFE( global ){

    var a = 3;
    console.log( a ); // 3
    console.log( global.a ); // 2

})( window );

console.log( a ); // 2

我们传入window对象引用,但是我们将参数命名为global,这样我们对于全局和非全局引用就有了一个清晰的文体上的划分。当然,你可以从外围作用域传入任何你想要的东西,而且你可以将参数命名为任何适合你的名称。这几乎仅仅是文体上的选择。

这种模式的另一种应用解决了一个小问题:默认的undefined标识符的值也许会被不正确地覆盖掉,而导致意外的结果。通过将参数命名为undefined,同时不为它传递任何参数值,我们就可以保证在一个代码块中undefined标识符确实是是一个未定义的值。

undefined = true; // 给其他的代码埋地雷!别这么干!

(function IIFE( undefined ){

    var a;
    if (a === undefined) {
        console.log( "Undefined is safe here!" );
    }

})();

IIFE 还有另一种变种将事情的顺序倒了过来,要被执行的函数在调用和传递给它的参数 之后 给出。这种模式被用于 UMD(Universal Module Definition —— 统一模块定义)项目。一些人发现它更干净和移动一些,虽然有点儿繁冗。

var a = 2;

(function IIFE( def ){
    def( window );
})(function def( global ){

    var a = 3;
    console.log( a ); // 3
    console.log( global.a ); // 2

});

def函数表达式带这个代码段的后半部分被定义,然后作为一个参数(也叫def)被传递给在代码段前半部分定义的IIFE函数。最后,参数def(函数)被调用,并将window作为global参数传入。

块儿作为作用域

虽然函数是最常见的作用域单位,而且当然也是在世面上流通的绝大多数 JS 中最为广泛传播的设计方式,但是其他的作用域单位也是可能的,而且使用这些作用域单位可以导致更好,对于维护来说更干净的代码。

JavaScript 之外的许多其他语言都支持块儿作用域,所以有这些语言背景的开发者习惯于这种思维模式,然而那些主要在 JavaScript 中工作的开发者可能会发现这个概念有些陌生。

但即使你从没用块儿作用域的方式写过一行代码,你可能依然对 JavaScript 中这种极其常见的惯用法很熟悉:

for (var i=0; i<10; i++) {
    console.log( i );
}

我们在 for 循环头的内部直接声明了变量i,因为我们意图很可能是仅在这个 for 循环内部的上下文环境中使用i,而实质上忽略了这个变量实际上将自己划入了外围作用域中(函数或全局)的事实。

这就是有关块儿作用域的一切。尽可能封闭地,尽可能局部地,在变量将被使用的位置声明它。另一个例子是:

var foo = true;

if (foo) {
    var bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

我们仅在 if 语句的上下文环境中使用变量bar,所以我们将它声明在 if 块儿的内部是有些道理的。然而,当使用var时,我们在何处声明变量是无关紧要的,因为它们将总是属于外围作用域。这个代码段实质上为了代码风格的原因“假冒”了块儿作用域,并依赖于我们要管好自己,不要在这个作用域的其他地方意外地使用bar

从将信息隐藏在函数中到将信息隐藏在我们代码的块儿中,块儿作用域是一种扩展了早先的“最低 权限 暴露原则”^([1])的工具。

再次考虑这个 for 循环的例子:

for (var i=0; i<10; i++) {
    console.log( i );
}

为什么要用仅将(或者至少是,仅 应当)在这个 for 循环中使用的变量i去污染一个函数的整个作用域呢?

但更重要的是,开发者们也许偏好于 检查 他们自己来防止在变量预期的目的之外意外地(重)使用它们,例如如果你试着在错误的地方使用变量会导致一个未知变量的错误。对于变量i的块儿作用域(如果它是可能的话)将使i仅在 for 循环内部可用,使得如果在函数的其他地方访问i将导致一个错误。这有助于保证变量不会被糊涂地重用或者难于维护。

但是,悲惨的现实是,表面上看来,JavaScript 没有块儿作用域的能力。

更确切地说,直到你再深入一些才有。

with

我们在第二章中学习了with。虽然它是一个使人皱眉头的结构,但它确实是一个(一种形式的)块儿作用域的例子,它从对象中创建的作用域仅存在于这个with语句的生命周期中,而不再外围作用域中。

try/catch

一个鲜为人知的是事实,JavaScript 在 ES3 中明确指出在try/catchcatch子句中声明的变量,是属于catch块儿的块儿作用域的。

例如:

try {
    undefined(); //用非法的操作强制产生一个异常!
}
catch (err) {
    console.log( err ); // 好用!
}

console.log( err ); // ReferenceError: `err` not found

如你所见,err仅存在于catch子句中,并且在你试着从其他地方引用他时抛出一个错误。

注意: 虽然这种行为已经被明确规定,而且对于几乎所有的标准 JS 环境(也许除了老 IE)来说都是成立的,但是如果你在同一个作用域中有两个或多个catch子句,而它们又各自用相同的标识符名称声明了它们表示错误的变量时,许多 linter 依然会报警。实际上这不是重定义,因为这些变量都安全地位于块儿作用域中,但是 linter 看起来依然,恼人地,抱怨这个事实。

为了避免这些不必要的警告,一些开发者将他们的catch变量命名为err1err2,等等。另一些开发者干脆关闭 linter 对重复变量名的检查。

catch的块儿作用域性质看起来像是一个没用的,只有学院派意义的事实,但是参看附录 B 来了解更多它如何有用的信息。

let

至此,我们看到 JavaScript 仅仅有一些奇怪的小众行为暴露了块儿作用域功能。如果这就是我们拥有的一切,而且许多许多年以来这 确实就是 我们拥有的一切,那么块作用域对 JavaScript 开发者来说就不是非常有用。

幸运的是,ES6 改变了这种状态,并引入了一个新的关键字let,作为另一种声明变量的方式伴随着var

let关键字将变量声明附着在它所在的任何块儿(通常是一个{ .. })的作用域中。换句话说,let为它的变量声明隐含地劫持了任意块儿的作用域。

var foo = true;

if (foo) {
    let bar = foo * 2;
    bar = something( bar );
    console.log( bar );
}

console.log( bar ); // ReferenceError

使用let将一个变量附着在一个现存的块儿上有些隐晦。它可能会使人困惑 —— 在你开发和设计代码时,如果你不仔细注意哪些块儿的作用域包含了变量,并且习惯于将块儿四处移动,将它们包进其他的块儿中,等等。

为块儿作用域创建明确的块儿可以解决这些问题中的一些,使变量附着在何处更加明显。通常来说,明确的代码要比隐晦或微妙的代码好。这种明确的块儿作用域风格很容易达成,而且它与块儿作用域在其他语言中的工作方式匹配得更自然:

var foo = true;

if (foo) {
    { // <-- 明确的块儿
        let bar = foo * 2;
        bar = something( bar );
        console.log( bar );
    }
}

console.log( bar ); // ReferenceError

我们可以在一个语句是合法文法的任何地方,通过简单地引入一个{ .. }来为let创建一个任意的可以绑定的块儿。在这个例子中,我们在 if 语句内部制造了一个明确的块儿,在以后的重构中将整个块儿四处移动可能会更容易,而且不会影响外围的 if 语句的位置和语义。

注意: 另一个明确表达块儿作用域的方法,参见附录 B。

在第四章中,我们将讲解提升(hoisting),它讲述关于声明在它们所出现的整个作用域中都被认为是存在的。

然而,使用let做出的声明将 不会 在它们所出现的整个块儿的作用域中提升。如此,直到声明语句为止,声明将不会“存在”于块儿中。

{
   console.log( bar ); // ReferenceError!
   let bar = 2;
}

垃圾回收

块儿作用域的另一个有用之处是关于闭包和释放内存的垃圾回收。我们将简单地在这里展示一下,但是闭包机制将在第五章中详细讲解。

考虑这段代码:

function process(data) {
    // 做些有趣的事
}

var someReallyBigData = { .. };

process( someReallyBigData );

var btn = document.getElementById( "my_button" );

btn.addEventListener( "click", function click(evt){
    console.log("button clicked");
}, /*capturingPhase=*/false );

点击事件的处理器回调函数click根本不 需要 someReallyBigData变量。这意味着从理论上讲,在process(..)运行之后,这个消耗巨大内存的数据结构可以被作为垃圾回收。然而,JS 引擎很可能(虽然这要看具体实现)将会仍然将这个结构保持一段时间,因为click函数在整个作用域上拥有一个闭包。

块儿作用域可以解决这个问题,使引擎清楚地知道它不必再保持someReallyBigData了:

function process(data) {
    // 做些有趣的事
}

// 运行过后,任何定义在这个块中的东西都可以消失了
{
    let someReallyBigData = { .. };

    process( someReallyBigData );
}

var btn = document.getElementById( "my_button" );

btn.addEventListener( "click", function click(evt){
    console.log("button clicked");
}, /*capturingPhase=*/false );

声明可以将变量绑定在本地的明确的块儿是一种强大的工具,你可以把它加入你的工具箱。

let循环

一个使let闪光的特殊例子是我们先前讨论的 for 循环。

for (let i=0; i<10; i++) {
    console.log( i );
}

console.log( i ); // ReferenceError

在 for 循环头部的let不仅将i绑定在 for 循环体中,而且实际上,它会对每一次循环的 迭代 重新绑定i,确保它被赋予来自上一次循环迭代末尾的值。

这是描绘这种为每次迭代进行绑定的行为的另一种方式:

{
    let j;
    for (j=0; j<10; j++) {
        let i = j; // 每次迭代都重新绑定
        console.log( i );
    }
}

这种为每次迭代进行的绑定有趣的原因将在第五章中我们讨论闭包时变得明朗。

因为let声明附着于任意的块儿,而不是外围的函数作用域(或全局),所以在重构代码时可能会有一些坑需要额外小心:现存的代码拥有对函数作用域的var声明有隐藏的依赖,但你想要用let来取代var

考虑如下代码:

var foo = true, baz = 10;

if (foo) {
    var bar = 3;

    if (baz > bar) {
        console.log( baz );
    }

    // ...
}

这段代码可以相当容易地重构为:

var foo = true, baz = 10;

if (foo) {
    var bar = 3;

    // ...
}

if (baz > bar) {
    console.log( baz );
}

但是,当使用块儿作用域变量时要小心这样的变化:

var foo = true, baz = 10;

if (foo) {
    let bar = 3;

    if (baz > bar) { // <-- 移动时不要忘了`bar`
        console.log( baz );
    }
}

附录 B 介绍了一种块作用域的(更加明确的)替代形式,它可能会在这些场景下提供更易于维护/重构的更健壮的代码。

const

除了let之外,ES6 还引入了const,它也创建一个块儿作用域变量,但是它的值是固定的(常量)。任何稍后改变它的企图都将导致错误。

var foo = true;

if (foo) {
    var a = 2;
    const b = 3; // 存在于包含它的`if`作用域中

    a = 3; // 没问题!
    b = 4; // 错误!
}

console.log( a ); // 3
console.log( b ); // ReferenceError!

复习

在 JavaScript 中函数是最常见的作用域单位。在另一个函数内部声明的变量和函数,实质上对任何外围“作用域”都是“隐藏的”,这是优秀软件的一个有意的设计原则。

但是函数绝不是唯一的作用域单位。块儿作用域指的是这样一种想法:变量和函数可以属于任意代码块儿(一般来说,就是任意的{ .. }),而不是仅属于外围的函数。

从 ES3 开始,try/catch结构在catch子句上拥有块儿作用域。

在 ES6 中,引入了let关键字(var关键字的表兄弟)允许在任意代码块中声明变量。if (..) { let a = 2; }将会声明变量a,而它实质上劫持了if{ .. }块儿的作用域,并将自己附着在这里。

虽然有些人对此深信不疑,但是块儿作用域不应当被认为是var函数作用域的一个彻头彻尾的替代品。两种机能是共存的,而且开发者们可以并且应当同时使用函数作用域和块儿作用域技术 —— 在它们各自可以产生更好,更易读/易维护代码的地方。


[1]:Principle of Least Privilege

你不懂 JS:作用域与闭包 第四章:提升

至此,你应当对作用域的想法,以及变量如何根据它们被声明的方式和位置附着在不同的作用域层级上感到相当适应了。函数作用域和块儿作用域的行为都是依赖于这个相同规则的:在一个作用域中声明的任何变量都附着在这个作用域上。

但是关于出现在一个作用域内各种位置的声明如何附着在作用域上,有一个微妙的细节,而这个细节正是我们要在这里检视的。

先有鸡还是先有蛋?

有一种倾向认为你在 JavaScript 程序中看到的所有代码,在程序执行的过程中都是从上到下一行一行地被解释执行的。虽然这大致上是对的,但是这种猜测中的一个部分可能会导致你错误地考虑你的程序。

考虑这段代码:

a = 2;

var a;

console.log( a );

你觉得在console.log(..)语句中会打印出什么?

许多开发者会期望undefined,因为语句var a出现在a = 2之后,这很自然地看起来像是这个变量被重定义了,并因此被赋予了默认的undefined。然而,输出将是2

考虑另一个代码段:

console.log( a );

var a = 2;

你可能会被诱导而这样认为,因为上一个代码段展示了一种看起来不是从上到下的行为,也许在这个代码段中,也会打印2。另一些人认为,因为变量a在它被声明之前就被使用了,所以这一定会导致一个ReferenceError被抛出。

不幸的是,两种猜测都不正确。输出是undefined

那么。这里发生了什么? 看起来我们遇到了一个先有鸡还是先有蛋的问题。哪一个现有?声明(“蛋”),还是赋值(“鸡”)?

编译器再次袭来

要回答这个问题,我们需要回头引用第一章关于编译器的讨论。回忆一下,引擎 实际上将会在它解释执行你的 JavaScript 代码之前编译它。编译过程的一部分就是找到所有的声明,并将它们关联在合适的作用域上。第二章向我们展示了这是词法作用域的核心。

所以,考虑这件事情的最佳方式是,在你的代码的任何部分被执行之前,所有的声明,变量和函数,都会首先被处理。

当你看到var a = 2;时,你可能认为这是一个语句。但是 JavaScript 实际上认为这是两个语句:var a;a = 2;。第一个语句,声明,是在编译阶段被处理的。第二个语句,赋值,为了执行阶段而留在 原处

于是我们的第一个代码段应当被认为是这样被处理的:

var a;
a = 2;

console.log( a );

……这里的第一部分是编译,而第二部分是执行。

相似地,我们的第二个代码段实际上被处理为:

var a;
console.log( a );

a = 2;

所以,关于这种处理的一个有些隐喻的考虑方式是,变量和函数声明被从它们在代码流中出现的位置“移动”到代码的顶端。这就产生了“提升”这个名字。

换句话说,先有蛋(声明),后有鸡(赋值)

注意: 只有声明本身被提升了,而任何赋值或者其他的执行逻辑都被留在 原处。如果提升会重新安排我们代码的可执行逻辑,那就会是一场灾难了。

foo();

function foo() {
    console.log( a ); // undefined

    var a = 2;
}

函数foo的声明(在这个例子中它还 包含 一个隐含的,实际为函数的值)被提升了,因此第一行的调用是可以执行的。

还需要注意的是,提升是 以作用域为单位的。所以虽然我们的前一个代码段被简化为仅含有全局作用域,但是我们现在检视的函数foo(..)本身展示了,var a被提升至foo(..)的顶端(很明显,不是程序的顶端)。所以这个程序也许可以更准确地解释为:

function foo() {
    var a;

    console.log( a ); // undefined

    a = 2;
}

foo();

函数声明会被提升,就像我们看到的。但是函数表达式不会。

foo(); // 不是 ReferenceError, 而是 TypeError!

var foo = function bar() {
    // ...
};

变量标识符foo被提升并被附着在这个程序的外围作用域(全局),所以foo()不会作为一个ReferenceError而失败。但foo还没有值(如果它不是函数表达式,而是一个函数声明,那么它就会有值)。所以,foo()就是试图调用一个undefined值,这是一个TypeError非法操作。

同时回想一下,即使它是一个命名的函数表达式,这个名称标识符在外围作用域中也是不可用的:

foo(); // TypeError
bar(); // ReferenceError

var foo = function bar() {
    // ...
};

这个代码段可以(使用提升)更准确地解释为:

var foo;

foo(); // TypeError
bar(); // ReferenceError

foo = function() {
    var bar = ...self...
    // ...
}

函数优先

函数声明和变量声明都会被提升。但一个微妙的细节(可以 在拥有多个“重复的”声明的代码中出现)是,函数会首先被提升,然后才是变量。

考虑这段代码:

foo(); // 1

var foo;

function foo() {
    console.log( 1 );
}

foo = function() {
    console.log( 2 );
};

1被打印了,而不是2!这个代码段被 引擎 解释执行为:

function foo() {
    console.log( 1 );
}

foo(); // 1

foo = function() {
    console.log( 2 );
};

注意那个var foo是一个重复(因此被无视)的声明,即便它出现在function foo()...声明之前,因为函数声明是在普通变量之前被提升的。

虽然多个/重复的var声明实质上是被忽略的,但是后续的函数声明确实会覆盖前一个。

foo(); // 3

function foo() {
    console.log( 1 );
}

var foo = function() {
    console.log( 2 );
};

function foo() {
    console.log( 3 );
}

虽然这一切听起来不过是一些学院派的细节,但是它表明了一个事实:在同一个作用域内的重复定义是一个十分差劲儿的主意,而且经常会导致令人困惑的结果。

在普通的块儿内部出现的函数声明一般会被提升至外围的作用域,而不是像这段代码暗示的那样有条件地被定义:

foo(); // "b"

var a = true;
if (a) {
   function foo() { console.log( "a" ); }
}
else {
   function foo() { console.log( "b" ); }
}

然而,重要的是要注意这种行为是不可靠的,而且是未来版本的 JavaScript 将要改变的对象,所以避免在块儿中声明函数可能是最好的做法。

复习

我们可能被诱导而将var a = 2看作是一个语句,但是 JavaScript 引擎 可不这么看。它将var aa = 2看作两个分离的语句,第一个是编译期的任务,而第二个是执行时的任务。

这将导致在一个作用域内的所有声明,不论它们出现在何处,都会在代码本身被执行前 首先 被处理。你可以将它可视化为声明(变量与函数)被“移动”到它们各自的作用域顶部,这就是我们所说的“提升”。

声明本身会被提升,但不是赋值,即便是函数表达式的赋值,也 不会 被提升。

要小心重复声明,特别是将一般的变量声明和函数声明混在一起 —— 如果你这么做的话,危险就在眼前!

你不懂 JS:作用域与闭包 第五章:作用域闭包

希望我们是带着对作用域工作方式的健全,坚实的理解来到这里的。

我们将我们的注意力转向这个语言中一个重要到不可思议,但是一直难以捉摸的,几乎是神话般的 部分:闭包。如果你至此一直跟随着我们关于词法作用域的讨论,那么你会感觉闭包将在很大程度上没那么令人激动,几乎是显而易见的。有一个魔法师坐在幕后,现在我们即将见到他。不,他的名字不是 Crockford!

如果你还对词法作用域感到不安,那么现在就是在继续之前回过头去再复习一下第二章的好时机。

启示

对于那些对 JavaScript 有些经验,但是也许从没全面掌握闭包概念的人来说,理解闭包 看起来就像是必须努力并作出牺牲才能到达的涅槃状态。

回想几年前我对 JavaScript 有了牢固的掌握,但是不知道闭包是什么。它暗示着这种语言有着另外的一面,它许诺了甚至比我已经拥有的还多的力量,它取笑并嘲弄我。我记得我通读早期框架的源代码试图搞懂它到底是如何工作的。我记得第一次“模块模式”的某些东西融入我的大脑。我记得那依然栩栩如生的 啊哈! 一刻。

那时我不明白的东西,那个花了我好几年时间才搞懂的东西,那个我即将传授给你的东西,是这个秘密:在 JavaScript 中闭包无所不在,你只是必须认出它并接纳它。闭包不是你必须学习新的语法和模式才能使用的特殊的可选的工具。不,闭包甚至不是你必须像卢克在原力中修炼那样,一定要学会使用并掌握的武器。

闭包是依赖于词法作用域编写代码而产生的结果。它们就这么发生了。要利用它们你甚至不需要有意地创建闭包。闭包在你的代码中一直在被创建和使用。你 缺少 的是恰当的思维环境,来识别,接纳,并以自己的意志利用闭包。

启蒙的时刻应该是:哦,闭包已经在我的代码中到处发生了,现在我终于 看到 它们了。理解闭包就像是尼欧第一次见到母体。

事实真相

好了,夸张和对电影的无耻引用够多了。

为了理解和识别闭包,这里有一个你需要知道的简单粗暴的定义:

闭包就是函数能够记住并访问它的词法作用域,即使当这个函数在它的词法作用域之外执行时。

让我们跳进代码来说明这个定义:

function foo() {
    var a = 2;

    function bar() {
        console.log( a ); // 2
    }

    bar();
}

foo();

根据我们对嵌套作用域的讨论,这段代码应当看起来很熟悉。由于词法作用域查询规则(在这个例子中,是一个 RHS 引用查询),函数bar()可以 访问 外围作用域的变量a

这是“闭包”吗?

好吧,技术上……也许是。但是根据我们上面的“你需要知道”的定义……不确切。我认为解释bar()引用a的最准确的方式是根据词法作用域查询规则,但是那些规则 仅仅 是闭包的(一个很重要的!)一部分

从纯粹的学院派角度讲,上面的代码段被认为是函数bar()在函数foo()的作用域上有一个 闭包(而且实际上,它甚至对其他的作用域也可以访问,比如这个例子中的全局作用域)。换一种略有不同的说法是,bar()闭住了foo()的作用域。为什么?因为bar()嵌套地出现在foo()内部。简单直白。

但是,这样一来闭包的定义就是不能直接 观察到 的了,我们也不能看到闭包在这个代码段中 被行使。我们清楚地看到词法作用域,但是闭包仍然像代码后面谜一般的模糊阴影。

让我们考虑这段将闭包完全照亮的代码:

function foo() {
    var a = 2;

    function bar() {
        console.log( a );
    }

    return bar;
}

var baz = foo();

baz(); // 2 -- 哇噢,看到闭包了,伙计。

函数bar()对于foo()内的作用域拥有词法作用域访问权。但是之后,我们拿起bar(),这个函数本身,将它像 一样传递。在这个例子中,我们return``bar引用的函数对象本身。

在执行foo()之后,我们将它返回的值(我们里面的bar()函数)赋予一个称为baz的变量,然后我们实际地调用baz(),这将理所当然地调用我们内部的函数bar(),只不过是通过一个不同的标识符引用。

bar()被执行了,必然的。但是在这个例子中,它是在它被声明的词法作用域 外部 被执行的。

foo()被执行之后,一般说来我们会期望foo()的整个内部作用域都将消失,因为我们知道 引擎 启用了 垃圾回收器 在内存不再被使用时来回收它们。因为很显然foo()的内容不再被使用了,所以看起来它们很自然地应该被认为是 消失了

但是闭包的“魔法”不会让这发生。内部的作用域实际上 依然 “在使用”,因此将不会消失。谁在使用它?函数bar()本身。

有赖于它被声明的位置,bar()拥有一个词法作用域闭包覆盖着foo()的内部作用域,闭包为了能使bar()在以后任意的时刻可以引用这个作用域而保持它的存在。

bar()依然拥有对那个作用域的引用,而这个引用称为闭包。

所以,在几微秒之后,当变量baz被调用时(调用我们最开始标记为bar的内部函数),它理所应当地对编写时的词法作用域拥有 访问 权,所以它可以如我们所愿地访问变量a

这个函数在它被编写时的词法作用域之外被调用。闭包 使这个函数可以继续访问它在编写时被定义的词法作用域。

当然,函数可以被作为值传递,而且实际上在其他位置被调用的所有各种方式,都是观察/行使闭包的例子。

function foo() {
    var a = 2;

    function baz() {
        console.log( a ); // 2
    }

    bar( baz );
}

function bar(fn) {
    fn(); // 看妈妈,我看到闭包了!
}

我们将内部函数baz传递给bar,并调用这个内部函数(现在被标记为fn),当我们这么做时,它覆盖在foo()内部作用域的闭包就可以通过a的访问观察到。

这样的函数传递也可以是间接的。

var fn;

function foo() {
    var a = 2;

    function baz() {
        console.log( a );
    }

    fn = baz; // 将`baz`赋值给一个全局变量
}

function bar() {
    fn(); // 看妈妈,我看到闭包了!
}

foo();

bar(); // 2

无论我们使用什么方法将内部函数 传送 到它的词法作用域之外,它都将维护一个指向它最开始被声明时的作用域的引用,而且无论我们什么时候执行它,这个闭包就会被行使。

现在我能看到了

前面的代码段有些学术化,而且是人工构建来说明 闭包的使用 的。但我保证过给你的东西不止是一个新的酷玩具。我保证过闭包是在你的现存代码中无处不在的东西。现在让我们 看看 真相。

function wait(message) {

    setTimeout( function timer(){
        console.log( message );
    }, 1000 );

}

wait( "Hello, closure!" );

我们拿来一个内部函数(名为timer)将它传递给setTimeout(..)。但是timer拥有覆盖wait(..)的作用域的闭包,实际上保持并使用着对变量message的引用。

在我们执行wait(..)一千毫秒之后,要不是内部函数timer依然拥有覆盖着wait()内部作用域的闭包,它早就会消失了。

引擎 的内脏深处,内建的工具setTimeout(..)拥有一些参数的引用,可能称为fn或者func或者其他诸如此类的东西。引擎 去调用这个函数,它调用我们的内部timer函数,而词法作用域依然完好无损。

闭包。

或者,如果你信仰 jQuery(或者就此而言,其他的任何 JS 框架):

function setupBot(name,selector) {
    $( selector ).click( function activator(){
        console.log( "Activating: " + name );
    } );
}

setupBot( "Closure Bot 1", "#bot_1" );
setupBot( "Closure Bot 2", "#bot_2" );

我不确定你写的是什么代码,但我通常写一些代码来负责控制全球的闭包无人机军团,所以这完全是真实的!

把玩笑放在一边,实质上 无论何时何地 只要你将函数作为头等的值看待并将它们传来传去的话,你就可能看到这些函数行使闭包。计时器,事件处理器,Ajax 请求,跨窗口消息,web worker,或者任何其他的异步(或同步!)任务,当你传入一个 回调函数,你就在它周围悬挂了一些闭包!

注意: 第三章介绍了 IIFE 模式。虽然人们常说 IIFE(独自)是一个可以观察到闭包的例子,但是根据我们上面的定义,我有些不同意。

var a = 2;

(function IIFE(){
    console.log( a );
})();

这段代码“好用”,但严格来说它不是在观察闭包。为什么?因为这个函数(就是我们这里命名为“IIFE”的那个)没有在它的词法作用域之外执行。它仍然在它被声明的相同作用域中(那个同时持有a的外围/全局作用域)被调用。a是通过普通的词法作用域查询找到的,不是通过真正的闭包。

虽说技术上闭包可能发生在声明时,但它 不是 严格地可以观察到的,因此,就像人们说的,它是一颗在森林中倒掉的树,但没人听得到它

虽然 IIFE 本身 不是一个闭包的例子,但是它绝对创建了作用域,而且它是我们用来创建可以被闭包的最常见的工具之一。所以 IIFE 确实与闭包有强烈的关联,即便它们本身不行使闭包。

亲爱的读者,现在把这本书放下。我有一个任务给你。去打开一些你最近的 JavaScript 代码。寻找那些被你作为值的函数,并识别你已经在那里使用了闭包,而你以前甚至可能不知道它。

我会等你。

现在……你看到了!

循环 + 闭包

用来展示闭包最常见最权威的例子是老实巴交的 for 循环。

for (var i=1; i<=5; i++) {
    setTimeout( function timer(){
        console.log( i );
    }, i*1000 );
}

注意: 当你将函数放在循环内部时 Linter 经常会抱怨,因为不理解闭包的错误 在开发者中太常见了。我们在这里讲解如何正确地利用闭包的全部力量。但是 Linter 通常不理解这样的微妙之处,所以它们不管怎样都将抱怨,认为你 实际上 不知道你在做什么。

这段代码的精神是,我们一般将期待它的行为是分别打印数字“1”,“2”,……“5”,一次一个,一秒一个。

实际上,如果你运行这段代码,你会得到“6”被打印 5 次,在一秒的间隔内。

啊?

首先,让我们解释一下“6”是从哪儿来的。循环的终结条件是i <=5。第一次满足这个条件时i是 6。所以,输出的结果反映的是i在循环终结后的最终值。

如果多看两眼的话这其实很明显。超时的回调函数都将在循环的完成之后立即运行。实际上,就计时器而言,即便在每次迭代中它是setTimeout(.., 0),所有这些回调函数也都仍然是严格地在循环之后运行的,因此每次都打印6

但是这里有个更深刻的问题。要是想让它实际上如我们在语义上暗示的那样动作,我们的代码缺少了什么?

缺少的东西是,我们试图 暗示 在迭代期间,循环的每次迭代都“捕捉”一份对i的拷贝。但是,虽然所有这 5 个函数在每次循环迭代中分离地定义,由于作用域的工作方式,它们 都闭包在同一个共享的全局作用域上,而它事实上只有一个i

这么说来,所有函数共享一个指向相同的i的引用是 理所当然 的。循环结构的某些东西往往迷惑我们,使我们认为这里有其他更精巧的东西在工作。但是这里没有。这与根本没有循环,5 个超时回调仅仅一个接一个地被声明没有区别。

好了,那么,回到我们火烧眉毛的问题。缺少了什么?我们需要更多 铃声 被闭包的作用域。明确地说,我们需要为循环的每次迭代都准备一个新的被闭包的作用域。

我们在第三章中学到,IIFE 通过声明并立即执行一个函数来创建作用域。

让我们试试:

for (var i=1; i<=5; i++) {
    (function(){
        setTimeout( function timer(){
            console.log( i );
        }, i*1000 );
    })();
}

这好用吗?试试。我还会等你。

我来为你终结悬念。不好用。 但是为什么?很明显我们现在有了更多的词法作用域。每个超时回调函数确实闭包在每次迭代时分别被每个 IIFE 创建的作用域中。

拥有一个被闭包的空的作用域是不够的。仔细观察。我们的 IIFE 只是一个空的什么也不做的作用域。它内部需要 一些东西 才能变得对我们有用。

它需要它自己的变量,在每次迭代时持有值i的一个拷贝。

for (var i=1; i<=5; i++) {
    (function(){
        var j = i;
        setTimeout( function timer(){
            console.log( j );
        }, j*1000 );
    })();
}

万岁!它好用了!

有些人偏好一种稍稍变形的形式:

for (var i=1; i<=5; i++) {
    (function(j){
        setTimeout( function timer(){
            console.log( j );
        }, j*1000 );
    })( i );
}

当然,因为这些 IIFE 只是函数,我们可以传入i,如果我们乐意的话可以称它为为j,或者我们甚至可以再次称它为i。不管哪种方式,这段代码都能工作。

在每次迭代内部使用的 IIFE 为每次迭代创建了新的作用域,这给了我们的超时回调函数一个机会在每次迭代时闭包一个新的作用域,这些作用域中的每一个都拥有一个持有正确的迭代值的变量给我们访问。

问题解决了!

重温块儿作用域

仔细观察我们前一个解决方案的分析。我们使用了一个 IIFE 来在每一次迭代中创建新的作用域。换句话说,我们实际上每次迭代都 需要 一个 块儿作用域。我们在第三章展示了let声明,它劫持一个块儿并且就在这个块儿中声明一个变量。

这实质上将块儿变成了一个我们可以闭包的作用域。所以接下来的牛逼代码“就是好用”:

for (var i=1; i<=5; i++) {
    let j = i; // 呀,给闭包的块儿作用域!
    setTimeout( function timer(){
        console.log( j );
    }, j*1000 );
}

但是,这还不是全部!(用我最棒的 Bob Barker 嗓音)在用于 for 循环头部的let声明被定义了一种特殊行为。这种行为说,这个变量将不是只为循环声明一次,而是为每次迭代声明一次。并且,它将在每次后续的迭代中被上一次迭代末尾的值初始化。

for (let i=1; i<=5; i++) {
    setTimeout( function timer(){
        console.log( i );
    }, i*1000 );
}

这有多酷?块儿作用域和闭包携手工作,解决世界上所有的问题。我不知道你怎么样,但这使我成了一个快乐的 JavaScript 开发者。

模块

还有其他的代码模式利用了闭包的力量,但是它们都不像回调那样浮于表面。让我们来检视它们中最强大的一种:模块

function foo() {
    var something = "cool";
    var another = [1, 2, 3];

    function doSomething() {
        console.log( something );
    }

    function doAnother() {
        console.log( another.join( " ! " ) );
    }
}

就现在这段代码来说,没有发生明显的闭包。我们只是拥有一些私有数据变量somethinganother,和几个内部函数doSomething()doAnother(),它们都拥有覆盖在foo()内部作用域上的词法作用域(因此是闭包!)。

但是现在考虑这段代码:

function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];

    function doSomething() {
        console.log( something );
    }

    function doAnother() {
        console.log( another.join( " ! " ) );
    }

    return {
        doSomething: doSomething,
        doAnother: doAnother
    };
}

var foo = CoolModule();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

在 JavaScript 中我们称这种模式为 模块。实现模块模式的最常见方法经常被称为“揭示模块”,它是我们在这里展示的方式的变种。

让我们检视关于这段代码的一些事情。

首先,CoolModule()只是一个函数,但它 必须被调用 才能成为一个被创建的模块实例。没有外部函数的执行,内部作用域的创建和闭包都不会发生。

第二,CoolModule()函数返回一个对象,通过对象字面量语法{ key: value, ... }标记。这个我们返回的对象拥有指向我们内部函数的引用,但是 没有 指向我们内部数据变量的引用。我们可以将它们保持为隐藏和私有的。可以很恰当地认为这个返回值对象实质上是一个 我们模块的公有 API

这个返回值对象最终被赋值给外部变量foo,然后我们可以在这个 API 上访问那些属性,比如foo.doSomething()

注意: 从我们的模块中返回一个实际的对象(字面量)不是必须的。我们可以仅仅直接返回一个内部函数。jQuery 就是一个很好地例子。jQuery$标识符是 jQuery“模块”的公有 API,但是它们本身只是一个函数(这个函数本身可以有属性,因为所有的函数都是对象)。

doSomething()doAnother()函数拥有模块“实例”内部作用域的闭包(通过实际调用CoolModule()得到的)。当我们通过返回值对象的属性引用,将这些函数传送到词法作用域外部时,我们就建立好了可以观察和行使闭包的条件。

更简单地说,行使模块模式有两个“必要条件”:

  1. 必须有一个外部的外围函数,而且它必须至少被调用一次(每次创建一个新的模块实例)。

  2. 外围的函数必须至少返回一个内部函数,这样这个内部函数才拥有私有作用域的闭包,并且可以访问和/或修改这个私有状态。

一个仅带有一个函数属性的对象不是 真正 的模块。从可观察的角度来说,一个从函数调用中返回的对象,仅带有数据属性而没有闭包的函数,也不是 真正 的模块。

上面的代码段展示了一个称为CoolModule()独立的模块创建器,它可以被调用任意多次,每次创建一个新的模块实例。这种模式的一个稍稍的变化是当你只想要一个实例的时候,某种“单例”:

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1, 2, 3];

    function doSomething() {
        console.log( something );
    }

    function doAnother() {
        console.log( another.join( " ! " ) );
    }

    return {
        doSomething: doSomething,
        doAnother: doAnother
    };
})();

foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

这里,我们将模块放进一个 IIFE(见第三章)中,而且我们 立即 调用它,并把它的返回值直接赋值给我们单独的模块实例标识符foo

模块只是函数,所以它们可以接收参数:

function CoolModule(id) {
    function identify() {
        console.log( id );
    }

    return {
        identify: identify
    };
}

var foo1 = CoolModule( "foo 1" );
var foo2 = CoolModule( "foo 2" );

foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"

另一种在模块模式上微小但是强大的变化是,为你作为公有 API 返回的对象命名:

var foo = (function CoolModule(id) {
    function change() {
        // 修改公有 API
        publicAPI.identify = identify2;
    }

    function identify1() {
        console.log( id );
    }

    function identify2() {
        console.log( id.toUpperCase() );
    }

    var publicAPI = {
        change: change,
        identify: identify1
    };

    return publicAPI;
})( "foo module" );

foo.identify(); // foo module
foo.change();
foo.identify(); // FOO MODULE

通过在模块实例内部持有一个指向公有 API 对象的内部引用,你可以 从内部 修改这个模块,包括添加和删除方法,属性, 改变它们的值。

现代的模块

各种模块依赖加载器/消息机制实质上都是将这种模块定义包装进一个友好的 API。与其检视任意一个特定的库,不如让我 (仅)为了说明的目的 展示一个 非常简单 的概念证明:

var MyModules = (function Manager() {
    var modules = {};

    function define(name, deps, impl) {
        for (var i=0; i<deps.length; i++) {
            deps[i] = modules[deps[i]];
        }
        modules[name] = impl.apply( impl, deps );
    }

    function get(name) {
        return modules[name];
    }

    return {
        define: define,
        get: get
    };
})();

这段代码的关键部分是modules[name] = impl.apply(impl, deps)。这为一个模块调用了它的定义的包装函数(传入所有依赖),并将返回值,也就是模块的 API,存储到一个用名称追踪的内部模块列表中。

这里是我可能如何使用它来定义一个模块:

MyModules.define( "bar", [], function(){
    function hello(who) {
        return "Let me introduce: " + who;
    }

    return {
        hello: hello
    };
} );

MyModules.define( "foo", ["bar"], function(bar){
    var hungry = "hippo";

    function awesome() {
        console.log( bar.hello( hungry ).toUpperCase() );
    }

    return {
        awesome: awesome
    };
} );

var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );

console.log(
    bar.hello( "hippo" )
); // Let me introduce: hippo

foo.awesome(); // LET ME INTRODUCE: HIPPO

模块“foo”和“bar”都使用一个返回公有 API 的函数来定义。“foo”甚至接收一个“bar”的实例作为依赖参数,并且可以因此使用它。

花些时间检视这些代码段,来完全理解将闭包的力量付诸实践给我们带来的好处。关键之处在于,对于模块管理器来说真的没有什么特殊的“魔法”。它们只是满足了我在上面列出的模块模式的两个性质:调用一个函数定义包装器,并将它的返回值作为这个模块的 API 保存下来。

换句话说,模块就是模块,即便你在它们上面放了一个友好的包装工具。

未来的模块

ES6 为模块的概念增加了头等的语法支持。当通过模块系统加载时,ES6 将一个文件视为一个独立的模块。每个模块可以导入其他的模块或者特定的 API 成员,也可以导出它们自己的公有 API 成员。

注意: 基于函数的模块不是一个可以被静态识别的模式(编译器可以知道的东西),所以它们的 API 语义直到运行时才会被考虑。也就是,你实际上可以在运行时期间修改模块的 API(参见早先publicAPI的讨论)。

相比之下,ES6 模块 API 是静态的(这些 API 不会在运行时改变)。因为编译器知道它,它可以(也确实在作!)在(文件加载和)编译期间检查一个指向被导入模块的成员的引用是否 实际存在。如果 API 引用不存在,编译器就会在编译时抛出一个“早期”错误,而不是等待传统的动态运行时解决方案(和错误,如果有的话)。

ES6 模块 没有 “内联”格式,它们必须被定义在一个分离的文件中(每个模块一个)。浏览器/引擎拥有一个默认的“模块加载器”(它是可以被覆盖的,但是这超出我们在此讨论的范围),它在模块被导入时同步地加载模块文件。

考虑这段代码:

bar.js

function hello(who) {
    return "Let me introduce: " + who;
}

export hello;

foo.js

// 仅仅从“bar”模块中导入`hello()`
import hello from "bar";

var hungry = "hippo";

function awesome() {
    console.log(
        hello( hungry ).toUpperCase()
    );
}

export awesome;
// 导入`foo`和`bar`整个模块
module foo from "foo";
module bar from "bar";

console.log(
    bar.hello( "rhino" )
); // Let me introduce: rhino

foo.awesome(); // LET ME INTRODUCE: HIPPO

注意: 需要使用前两个代码片段中的内容分别创建两个分离的文件 “foo.js”“bar.js”。然后,你的程序将加载/导入这些模块来使用它们,就像第三个片段那样。

import在当前的作用域中导入一个模块的 API 的一个或多个成员,每个都绑定到一个变量(这个例子中是hello)。module将整个模块的 API 导入到一个被绑定的变量(这个例子中是foobar)。export为当前模块的公有 API 导出一个标识符(变量,函数)。在一个模块的定义中,这些操作符可以根据需要使用任意多次。

模块文件 内部的内容被视为像是包围在一个作用域闭包中,就像早先看到的使用函数闭包的模块那样。

复习

闭包就像在 JavaScript 内部被隔离开的魔法世界,看起来少为人知,只有很少一些最勇敢的灵魂才能到达。但是它实际上只是一个标准的,而且几乎明显的事实 —— 我们如何在函数即是值,而且可以被随意传递的词法作用域环境中编写代码,

闭包就是当一个函数即使是在它的词法作用域之外被调用时,也可以记住并访问它的词法作用域。

如果我们不能小心地识别它们和它们的工作方式,闭包可能会绊住我们,例如在循环中。但它们也是一种极其强大的工具,以各种形式开启了像 模块 这样的模式。

模块要求两个关键性质:1)一个被调用的外部包装函数,来创建外围作用域。2)这个包装函数的返回值必须包含至少一个内部函数的引用,这个函数才拥有包装函数内部作用域的闭包。

现在我们看到了闭包在我们的代码中无处不在,而且我们有能力识别它们,并为了我们自己的利益利用它们!

你不懂 JS:作用域与闭包 附录 A:动态作用域

在第二章中,作为与 JavaScript 中(事实上,其他大多数语言也是)作用域的工作方式模型 —— “词法作用域”的对比,我们谈到了“动态作用域”。

我们将简单地检视动态作用域,来彻底说明这种比较。但更重要的是,对于 JavaScript 中的另一种机制(this)来说动态作用域实际上是它的一个近亲表兄,我们将在本系列的“this 与对象原型”中详细讲解这种机制。

正如我们在第二章中看到的,词法作用域是一组关于 引擎 如何查询变量和它在何处能够找到变量的规则。词法作用域的关键性质是,它是在代码编写时被定义的(假定你不使用eval()with作弊的话)。

动态作用域看起来在暗示,有充分的理由,存在这样一种模型,它的作用域是在运行时被确定的,而不是在编写时静态地确定的。让我们通过代码来说明这样的实际情况:

function foo() {
    console.log( a ); // 2
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;

bar();

foo()的词法作用域中指向a的 RHS 引用将被解析为全局变量a,它将导致输出结果为值2

相比之下,动态作用域本身不关心函数和作用域是在哪里和如何被声明的,而是关心 它们是从何处被调用的。换句话说,它的作用域链条是基于调用栈的,而不是代码中作用域的嵌套。

所以,如果 JavaScript 拥有动态作用域,当foo()被执行时,理论上 下面的代码将得出3作为输出结果。

function foo() {
    console.log( a ); // 3  (不是 2!)
}

function bar() {
    var a = 3;
    foo();
}

var a = 2;

bar();

这怎么可能?因为当foo()不能为a解析出一个变量引用时,它不会沿着嵌套的(词法)作用域链向上走一层,而是沿着调用栈向上走,以找到foo()从何处 被调用的。因为foo()是从bar()中被调用的,它就会在bar()的作用域中检查变量,并且在这里找到持有值3a

奇怪吗?此时此刻你可能会这样认为。

但这可能只是因为你仅在拥有词法作用域的代码中工作过。所以动态作用域看起来陌生。如果你仅使用动态作用域的语言编写过代码,它看起来就是很自然的,而词法作用域将是个怪东西。

要清楚,JavaScript 实际上没有动态作用域。它拥有词法作用域。简单明了。但是this机制有些像动态作用域。

关键的差异:词法作用域是编写时的,而动态作用域(和this)是运行时的。词法作用域关心的是 函数在何处被声明,但是动态作用域关心的是函数 从何处 被调用。

最后:this关心的是 函数是如何被调用的,这揭示了this机制与动态作用域的想法有多么紧密的关联。要了解更多关于this的细节,请阅读 “this 与对象原型”。

你不懂 JS:作用域与闭包 附录 B:填补块儿作用域

在第三章中,我们探索了块儿作用域。我们看到最早在 ES3 中引入的withcatch子句都是存在于 JavaScript 中的块儿作用域的小例子。

但是 ES6 引入的let最终使我们的代码有了完整的,不受约束的块作用域能力。不论是在功能上还是在代码风格上,块作用域都使许多激动人心的事情成为可能。

但要是我们想在前 ES6 环境中使用块儿作用域呢?

考虑这段代码:

{
    let a = 2;
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

它在 ES6 环境下工作的非常好。但是我们能在前 ES6 中这么做吗?catch就是答案。

try{throw 2}catch(a){
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

哇!这真是看起来丑陋和奇怪的代码。我们看到一个try/catch似乎强制抛出一个错误,但是这个它抛出的“错误”只是一个值2。然后接收它的变量声明是在catch(a)子句中。三观:毁尽。

没错,catch子句拥有块儿作用域,这意味着它可以被用于在前 ES6 环境中填补块儿作用域。

“但是……”,你说。“……没人愿意写这么丑的代码!”你是对的。也没人编写由 CoffeeScript 编译器输出的(某些)代码。这不是重点。

重点是工具可以将 ES6 代码转译为能够在前 ES6 环境中工作的代码。你可以使用块儿作用域编写代码,并从这样的功能中获益,然后让一个编译工具来掌管生成将在部署之后实际 工作 的代码。

这实际上是所有(嗯哼,大多数)ES6 特性首选的迁移路径:在从前 ES6 到 ES6 的转变过程中,使用一个代码转译器将 ES6 代码转换为 ES5 兼容的代码。

Traceur

Google 维护着一个称为“Traceur”^([1])的项目,它的任务正是为了广泛使用 ES6 特性而将它转译为前 ES6(大多数是 ES5,但不是全部!)代码。TC39 协会依赖这个工具(和其他的工具)来测试他们所规定的特性的语义。

Traceur 将从我们的代码段中产生出什么?你猜对了!

{
    try {
        throw undefined;
    } catch (a) {
        a = 2;
        console.log( a );
    }
}

console.log( a );

所以,使用这种工具,我们可以开始利用块儿作用域,无论我们是否面向 ES6,因为try/catch从 ES3 那时就开始存在了(并且这样工作)。

隐含的与明确的块儿

在第三章中,在我们介绍块儿作用域时,我们认识了一些关于代码可维护性/可重构性的潜在陷阱。有什么其他的方法可以利用块儿作用域同时减少这些负面影响吗?

考虑一下let的这种形式,它被称为“let 块儿”或“let 语句”(和以前的“let 声明”对比来说)。

let (a = 2) {
    console.log( a ); // 2
}

console.log( a ); // ReferenceError

与隐含地劫持一个既存的块儿不同,let 语句为它的作用域绑定明确地创建了一个块儿。这个明确的块儿不仅更显眼,而且在代码重构方面健壮得多,从文法上讲,它通过强制所有的声明都位于块儿的顶部而产生了某种程度上更干净的代码。这使任何块儿都更易于观察,更易于知道什么属于这个作用域和什么不属于这个作用域。

作为一种模式,它是与许多人在函数作用域中采纳的方式相对照的 —— 它们手动地将所有var声明移动/提升到函数的顶部。let 语句有意地将它们放在块儿的顶部,而且如果你没有通篇到处使用let声明,那么你的块儿作用域声明就会在某种程度上更易于识别和维护。

但是,这里有一个问题。let 语句的形式没有包含在 ES6 中。就连官方的 Traceur 编译器也不接受这种形式的代码。

我们有两个选择。我们可以使用 ES6 合法的语法格式化,再加上一点儿代码规则:

/*let*/ { let a = 2;
    console.log( a );
}

console.log( a ); // ReferenceError

但是,工具就意味着要解决我们的问题。所以另一个选项是编写明确的 let 语句块儿,并让工具将他转换为合理的,可以工作的代码。

所以,我建造了一个称为“let-er”^([2])的工具来解决这个问题。let-er 是一个编译期代码转译器,它唯一的任务就是找到 let 语句形式并转译它们。它将你的代码其他部分原封不动地留下,包括任何 let 声明。你可以安全地将 let-er 用于 ES6 转译器的第一步,然后如果有需要,你可以将你的代码通过 Traceur 这样的东西。

另外,let-er 有一个配置标志--es6,当它打开时(默认是关闭),会改变生成的代码的种类。与使用try/catch的 ES3 填补黑科技不同的是,let-er 将拿着我们的代码并产生完全兼容 ES6 的代码,没有黑科技:

{
    let a = 2;
    console.log( a );
}

console.log( a ); // ReferenceError

所以,你可以立即开始使用 let-er,而且可以面向所有前 ES6 环境,当你仅关心 ES6 时,你可以加入配置标志并立即仅面向 ES6。

而且最重要的是,你可以使用更好的和更明确的 let 语句形式,即便它(还)不是任何 ES 官方版本的一部分。

性能

让我在try/catch的性能问题上加入最后一个快速的说明,并/或解决这个问题:“为什么不使用一个 IIFE 来创建作用域?”

首先,try/catch的性能 慢一些,但是没有任何合理的假设表明它 必须 是这样,或者它 总是 这样。因为 TC39 认可的官方 ES6 转译器使用try/catch,Traceur 团队已经让 Chrome 去改进try/catch的性能了,而且它们有很明显的动力这样做。

第二,IIFE 和try/catch不是一个公平的“苹果对苹果”的比较,因为一个包装着任意代码的函数改变了这段代码的含义,以及它的thisreturnbreak,和continue的含义。IIFE 不是一个合适一般替代品。它只能在特定的情况下手动使用。

真正的问题变成了:你是否想要使用块儿作用域。如果是,这些工具给你提供了这些选择。如果不,那就在你的代码中继续使用var


[1]:Google Traceur

[2]:let-er

你不懂 JS:作用域与闭包 附录 C:词法 this

这本书通篇没有讲解this机制的任何细节,有一个 ES6 的话题以一种重要的方式将this与词法作用域联系了起来,我们将快速检视它一下。

ES6 为函数声明增加了一种特殊的语法形式,称为“箭头函数”。它看起来像这样:

var foo = a => {
    console.log( a );
};

foo( 2 ); // 2

这个所谓的“大箭头”经常被称为是 乏味烦冗的(讽刺)function关键字的缩写。

但是在箭头函数上发生的一些事情要重要得多,而且这与在你的声明中少敲几下键盘无关。

简单地说,这段代码有一个问题:

var obj = {
    id: "awesome",
    cool: function coolFn() {
        console.log( this.id );
    }
};

var id = "not awesome";

obj.cool(); // awesome

setTimeout( obj.cool, 100 ); // not awesome

这个问题就是在cool()函数上丢失了this绑定。有各种方法可以解决这个问题,但一个经常被重复的解决方案是var self = this;

它可能看起来像:

var obj = {
    count: 0,
    cool: function coolFn() {
        var self = this;

        if (self.count < 1) {
            setTimeout( function timer(){
                self.count++;
                console.log( "awesome?" );
            }, 100 );
        }
    }
};

obj.cool(); // awesome?

用不过于深入细节的方式讲,var self = this的“解决方案”免除了理解和正确使用this绑定的整个问题,而是退回到我们也许感到更舒服的东西上面:词法作用域。self变成了一个可以通过词法作用域和闭包解析的标识符,而且一直不关心this绑定发生了什么。

人们不喜欢写繁冗的东西,特别是当他们一次又一次重复它的时候。于是,ES6 的一个动机是帮助缓和这些场景,将常见的惯用法问题 固定 下来,就像这一个。

ES6 的解决方案,箭头函数,引入了一种称为“词法 this”的行为。

var obj = {
    count: 0,
    cool: function coolFn() {
        if (this.count < 1) {
            setTimeout( () => { // 箭头函数能好用?
                this.count++;
                console.log( "awesome?" );
            }, 100 );
        }
    }
};

obj.cool(); // awesome?

简单的解释是,当箭头函数遇到它们的this绑定时,它们的行为与一般的函数根本不同。它们摒弃了this绑定的所有一般规则,而是将它们的立即外围词法作用域作为this的值,无论它是什么。

于是,在这个代码段中,箭头函数不会以不可预知的方式丢掉this绑定,它只是“继承”cool()函数的this绑定。

虽然这使代码更短,但在我看来,箭头函数只不过是将一个开发者们常犯的错误固化成了语言的语法,这混淆了“this 绑定”规则与“词法作用域”规则。

换一种说法:为什么要使用this风格的编码形式来招惹麻烦和繁冗?只要通过将它与词法作用域混合把它剔除掉就好。对于给定的一段代码只采纳一种方式或另一种看起来才是自然的,而不是在同一段代码中将它们混在一起。

注意: 源自箭头函数的另一个非议是,它们是匿名的,不是命名的。参见第三章来了解为什么匿名函数不如命名函数理想的原因。

在我看来,这个“问题”的更恰当的解决方式是,正确地使用并接受this机制。

var obj = {
    count: 0,
    cool: function coolFn() {
        if (this.count < 1) {
            setTimeout( function timer(){
                this.count++; // `this` 因为 `bind(..)` 所以安全
                console.log( "more awesome" );
            }.bind( this ), 100 ); // 看,`bind()`!
        }
    }
};

obj.cool(); // more awesome

不管你是偏好箭头函数的新的词法 this 行为,还是偏好经得起考验的bind(),重要的是要注意箭头函数 仅仅是关于可以少打一些“function”。

它们拥有一种我们应当学习并理解的,有意的行为上的不同,而且如果我们这样选择,就可以利用它们。

现在我们完全理解了词法作用域(和闭包!),理解词法 this 应该是小菜一碟!

你不懂 JS:入门与进阶

来源:你不懂 JS:入门与进阶

你不懂 JS:入门与进阶 第一章:进入编程

欢迎来到 你不懂 JSYDKJS)系列。

入门与进阶 是一个对几种编程基本概念的介绍 —— 当然我们是特别倾向于 JavaScript(经常略称为 JS)的 —— 以及如何看待与理解本系列的其他书目。特别是如果你刚刚接触编程和/或 JavaScript,这本书将简要地探索你需要什么来 入门与进阶

这本书从很高的角度来解释编程的基本原则开始。它基本上假定你是在没有或很少的编程经验的情况下开始阅读 YDKJS 的,而且你期待这些书可以透过 JavaScript 的镜头帮助你开启一条理解编程的道路。

第一章应当作为一个快速的概览来阅读,它讲述为了 进入编程 你将想要多加学习和实践的东西。有许多其他精彩的编程介绍资源可以帮你在这个话题上走得更远,而且我鼓励你学习它们来作为这一章的补充。

一旦你对一般的编程基础感到适应了,第二章将指引你熟悉 JavaScript 风格的编程。第二章介绍了 JavaScript 是什么,但是同样的,它不是一个全面的指引 —— 那是其他 YDKJS 书目的任务!

如果你已经相当熟悉 JavaScript,那么就首先看一下第三章作为 YDKJS 内容的简要一瞥,然后一头扎进去吧!

代码

让我们从头开始。

一个程序,经常被称为 源代码 或者只是 代码,是一组告诉计算机要执行什么任务的特殊指令。代码通常保存在文本文件中,虽然你也可以使用 JavaScript 在一个浏览器的开发者控制台中直接键入代码 —— 我们一会儿就会讲解。

合法的格式与指令的组合规则被称为一种 计算机语言,有时被称作它的 语法,这和英语教你如何拼写单词,和如何使用单词与标点创建合法的句子差不多是相同的。

语句

在一门计算机语言中,一组单词,数字,和执行一种具体任务的操作符构成了一个 语句。在 JavaScript 中,一个语句可能看起来像下面这样:

a = b * 2;

字符ab被称为 变量(参见“变量”),它们就像简单和盒子,你可以把任何东西存储在其中。在程序中,变量持有将被程序使用的值(比如数字42)。可以认为它们就是值本身的标志占位符。

相比之下,2本身只是一个值,称为一个 字面值,因为它没有被存入一个变量,是独立的。

字符=*操作符(见“操作符”) —— 它们使用值和变量实施动作,比如赋值和数学乘法。

在 JavaScript 中大多数语句都以末尾的分号(;)结束。

语句a = b * 2;告诉计算机,大致上,去取得当前存储在变量b中的值,将这个值乘以2,然后将结果存回到另一个我们称为a变量里面。

程序只是许多这样的语句的集合,它们一起描述为了执行你的程序的意图所要采取的所有步骤。

表达式

语句是由一个或多个 表达式 组成的。一个表达式是一个引用,指向变量或值,或者一组用操作符组合的变量和值。

例如:

a = b * 2;

这个语句中有四个表达式:

  • 2是一个 字面量表达式
  • b是一个 变量表达式,它意味着取出它的当前值
  • b * 2是一个 算数表达式,它意味着执行乘法
  • a = b * 2是一个 赋值表达式,它意味着将表达式b * 2的结果赋值给变量a(稍后有更多关于赋值的内容)

一个独立的普通表达式也被称为一个 表达式语句,比如下面的:

b * 2;

这种风格的表达式语句不是很常见也没什么用,因为一般来说它不会对程序的运行有任何影响 —— 它将取得b的值并乘以2,但是之后不会对结果做任何事情。

一种更常见的表达式语句是 调用表达式 语句(见“函数”),因为整个语句本身是一个函数调用表达式:

alert( a );

执行一个程序

这些程序语句的集合如何告诉计算机要做什么?这个程序需要被 执行,也称为 运行这个程序

在开发者们阅读与编写时,像a = b * 2这样的语句很有帮助,但是它实际上不是计算机可以直接理解的形式。所以一个计算机上的特殊工具(不是一个 解释器 就是一个 编译器)被用于将你编写的代码翻译为计算机可以理解的命令。

对于某些计算机语言,这种命令的翻译经常是在每次程序运行时从上向下,一行接一行完成的,这通常成为代码的 解释

对于另一些语言,这种翻译是提前完成的,成为代码的 编译,所以当程序稍后 运行 时,实际上运行的东西已经是编译好,随时可以运行的计算机指令了。

JavaScript 通常被断言为是 解释型 的,因为你的 JavaScript 源代码在它每次运行时都被处理。但这并不是完全准确的。JavaScript 引擎实际上在即时地 编译 程序然后立即运行编译好的代码。

注意: 更多关于 JavaScript 编译的信息,参见本系列的 作用域与闭包 的前两章。

亲自尝试

这一章将用简单的代码段来介绍每一个编程概念,它们都是用 JavaScript 写的(当然!)。

有一件事情怎么强调都不过分:在你通读本章时 —— 而且你可能需要花时间读好几遍 —— 你应当通过自己编写代码来实践这些概念中的每一个。最简单的方法就是打开你手边的浏览器(Firefox,Chrome,IE,等等)的开发者工具控制台。

提示: 一般来说,你可以使用快捷键或者菜单选项来启动开发者控制台。更多关于启动和使用你最喜欢的浏览器的控制台的细节,参见“精通开发者工具控制台”([blog.teamtreehouse.com/mastering-developer-tools-console)。要在控制台中一次键入多行,可以使用``](http://blog.teamtreehouse.com/mastering-developer-tools-console)%E3%80%82%E8%A6%81%E5%9C%A8%E6%8E%A7%E5%88%B6%E5%8F%B0%E4%B8%AD%E4%B8%80%E6%AC%A1%E9%94%AE%E5%85%A5%E5%A4%9A%E8%A1%8C%EF%BC%8C%E5%8F%AF%E4%BB%A5%E4%BD%BF%E7%94%A8%60)<shift> + <enter>来移动到下一行。一旦你敲击<enter>,控制台将运行你刚刚键入的任何东西。

让我们熟悉一下在控制台中运行代码的过程。首先,我建议你在浏览器中打开一个新的标签页。我喜欢在地址栏中键入about:blank来这么做。然后,确认你的开发者控制台是打开的,就像我们刚刚提到的那样。

现在,键入如下代码看看它是怎么运行的:

a = 21;

b = a * 2;

console.log( b );

在 Chrome 的控制台中键入前面的代码应该会产生如下的东西:


fig1.png

继续,试试吧。学习编程的最佳方式就是开始编码!

输出

在前一个代码段中,我们使用了console.log(..)。让我们简单地看看这一行代码在做什么。

你也许已经猜到了,它正是我们如何在开发者控制台中打印文本(也就是向用户 输出)的方法。这个语句有两个性质,我们应当解释一下。

首先,log( b )部分被称为一个函数调用(见“函数”)。这里发生的事情是,我们将变量b交给这个函数,它向变量b要来它的值,并在控制台中打印。

第二,console.部分是一个对象引用,这个对象就是找到log(..)函数的地方。我们会在第二章中详细讲解对象和它们的属性。

另一种创建你可以看到的输出的方式是运行alert(..)语句。例如:

alert( b );

如果你运行它,你会注意到它不会打印输出到控制台,而是显示一个内容为变量b的“OK”弹出框。但是,一般来说与使用alert(..)相比,使用console.log(..)会使学习编码和在控制台运行你的程序更简单一些,因为你可以一次输出许多值,而不必干扰浏览器的界面。

在这本书中,我们将使用console.log(..)来输出。

输入

虽然我们在讨论输出,你也许还想知道 输入(例如,从用户那里获得信息)。

对于 HTML 网页来说,输入发生的最常见的方式是向用户显示一个他们可以键入的 form 元素,然后使用 JS 将这些值读入你程序的变量中。

但是为了单纯的学习和展示的目的 —— 也就是你在这本书中将通篇看到的 —— 有一个获取输入的更简单的方法。使用prompt(..)函数:

age = prompt( "Please tell me your age:" );

console.log( age );

正如你可能已经猜到的,你传递给prompt(..)的消息 —— 在这个例子中,"Please tell me your age:" —— 被打印在弹出框中。

它应当和下面的东西很相似:


fig2.png

一旦你点击“OK”提交输入的文本,你将会看到你输入的值被存储在变量age中,然后我们使用console.log(..)把它 输出


fig3.png

为了让我们在学习基本编程概念时使事情保持简单,本书中的例子不要求输入。但是现在你已经看到了如何使用prompt(..),如果你想挑战一下自己,你可以试着在探索这些例子时使用输入。

操作符

操作符是我们如何在变量和值上实施操作的方式。我们已经见到了两种 JavaScript 操作符,=*

*操作符实施数学乘法。够简单的,对吧?

=操作符用于 赋值 —— 我们首先计算= 右手边 的值(源值)然后将它放进我们在 左手边 指定的变量中(目标变量)。

警告: 对于指定赋值,这看起来像是一种奇怪的倒置。与a = 42不同,一些人喜欢把顺序反转过来,于是源值在左而目标变量在右,就像42 -> a(这不是合法的 JavaScript!)。不幸的是,a = 42顺序的形式,和与其相似的变种,在现代编程语言中是十分流行的。如果它让你觉得不自然,那么就花些时间在脑中演练这个顺序并习惯它。

考虑如下代码:

a = 2;
b = a + 1;

这里,我们将值2赋值给变量a。然后,我们取得变量a的值(还是2),把它加1得到值3,然后将这个值存储到变量b中。

虽然在技术上说var不是一个操作符,但是你将在每一个程序中都需要这个关键字,因为它是你 声明(也就是 创建)变量(见“变量”)的主要方式。

你应当总是在使用变量前用名称声明它。但是对于每个 作用域(见“作用域”)你只需要声明变量一次;它可以根据需要使用任意多次。例如:

var a = 20;

a = a + 1;
a = a * 2;

console.log( a );    // 42

这里是一些在 JavaScript 中最常见的操作符:

  • 赋值:比如a = 2中的=

  • 数学:+(加法),-(减法),*(乘法),和/(除法),比如a * 3

  • 复合赋值:+=-=*=,和/=都是复合操作符,它们组合了数学操作和赋值,比如a += 2(与a = a + 2相同)。

  • 递增/递减:++(递增),--(递减),比如a++(和a = a + 1很相似)。

  • 对象属性访问:比如console.log().

    对象是一种值,它可以在被称为属性的,被具体命名的位置上持有其他的值。obj.a意味着一个称为obj的对象值有一个名为a的属性。属性可以用obj["a"]这种替代的方式访问。参见第二章。

  • 等价性:==(宽松等价),===(严格等价),!=(宽松不等价),!==(严格不等价),比如a == b

    参见“值与类型”和第二章。

  • 比较:<(小于),>(大于),<=(小于或宽松等价),>=(大于或宽松等价),比如a <= b

    参见“值与类型”和第二章。

  • 逻辑:&&(与),||(或),比如a || b它选择ab中的一个。

    这些操作符用于表达复合的条件(见“条件”),比如如果a或者b成立。

注意: 更多细节,以及在此没有提到的其他操作符,可以参见 Mozilla 开发者网络(MDN)的“表达式与操作符”(developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Expressions_and_Operators)。%E3%80%82)

值与类型

如果你问一个手机店的店员一种特定手机的价格,而他们说“九十九块九毛九”(即,$99.99),他们给了你一个实际的美元数字来表示你需要花多少钱才能买到它。如果你想两部这种手机,你可以很容易地心算这个值的两倍来得到你需要花费的$199.98。

如果同一个店员拿起另一部相似的手机说它是“免费的”(也许在用手比划引号),那么他们就不是在给你一个数字,而是你的花费($0.00)的另一种表达形式 —— “免费”这个词。

当你稍后问到这个手机是否带充电器时,回答可能仅仅是“是”或者“不”。

以同样的方式,当你在程序中表达一个值时,你根据你打算对这些值做什么来选择不同的表达形式。

在编程术语中值的这些不同的表达形式称为 类型。JavaScript 中对这些所谓的 基本类型 值都有内建的类型:

  • 但你需要做数学计算时,你需要一个number
  • 当你需要在屏幕上打印一个值时,你需要一个string(一个或多个字符,单词,句子)。
  • 当你需要在你的程序中做决定时,你需要一个booleantruefalse)。

在源代码中直接包含的值称为 字面量string字面量被双引号"..."或单引号('...')包围 —— 唯一的区别是风格上的偏好。numberboolean字面量用它们本身来表示(即,42true,等等)。

考虑如下代码:

"I am a string";
'I am also a string';

42;

true;
false;

string/number/boolean值的类型以外,编程语言通常会提供 数组对象函数 等更多的类型。我们会在本章和下一章中讲解更多关于值和类型的内容。

类型间转换

如果你有一个number但需要将它打印在屏幕上,那么你就需要将这个值转换为一个string,在 JavaScript 中这种转换称为“强制转换”。类似地,如果某些人在一个电商网页的 form 中输入一系列数字,那么它是一个string,但是如果你需要使用这个值去做数学运算,那么你就需要将它 强制转换 为一个number

为了在 类型 之间强制转换,JavaScript 提供了几种不同的工具。例如:

var a = "42";
var b = Number( a );

console.log( a );    // "42"
console.log( b );    // 42

使用上面展示的Number(..)(一个内建函数)是一种从任意其他类型到number类型的 明确的 强制转换。这应当是相当直白的。

但是一个具有争议的话题是,当你试着比较两个还不是相同类型的值时发生的事情,它需要 隐含的 强制转换。

当比较字符串"99.99"和数字99.99时,大多数人同意它们是等价的。但是他们不完全相同,不是吗?它们是相同的值的两种不同表现形式,两个不同的 类型。你可以说它们是“宽松地等价”的,不是吗?

为了在这些常见情况下帮助你,JavaScript 有时会启动 隐含的 强制转换来把值转换为匹配的类型。

所以如果你使用==宽松等价操作符来进行"99.99" == 99.99比较,JavaScript 会将左手边的"99.99"转换为它的number等价物99.99。所以比较就变成了99.99 == 99.99,这当然是成立的。

虽然隐含强制转换是为了帮助你而设计,但是它也可能把你搞糊涂,如果你没有花时间去学习控制它行为的规则。大多数开发者从没有这么做,所以常见的感觉是隐含的强制转换是令人困惑的,并且会产生意外的 bug 危害程序,因此应当避免使用。有时它甚至被称为这种语言中的设计缺陷。

然而,隐含强制转换是一种 可以被学习 的机制,而且是一种 应当 被所有想要认真对待 JavaScript 编程的人学习的机制。一旦你学习了这些规则,它不仅是消除了困惑,而且它实际上是你的程序变得更好!这种努力是值得的。

注意: 关于强制转换的更多信息,参见本书第二章和本系列 类型与文法 的第四章。

代码注释

手机店店员可能会写下一些笔记,记下新出的手机的特性或者他们公司推出的新套餐。这些笔记仅仅是给店员使用的 —— 他们不是给顾客读的。不管怎样,通过记录下为什么和如何告诉顾客他应当说的东西,这些笔记帮助店员更好的工作。

关于编写代码你要学的最重要的课程之一,就是它不仅仅是写给计算机的。代码的每一个字节都和写给编译器一样,也是写给开发者的。

你的计算机只关心机器码,一系列源自 编译 的 0 和 1。你几乎可以写出无限多种可以产生相同 0 和 1 序列的代码。所以你对如何编写程序作出的决定很重要 —— 不仅是对你,也对你的团队中的其他成员,甚至是你未来的自己。

你不仅应当努力去编写可以正确工作的程序,而且应当努力编写检视起来有道理的程序。你可以通过给变量(见“变量”)和函数(见“函数”)起一个好名字在这条路上走很远。

但另外一个重要的部分是代码注释。它们纯粹是为了向人类解释一些事情而在你的程序中插入的一点儿文本。解释器/编译器将总是忽略这些注释。

关于什么是良好注释的代码有许多意见;我们不能真正地定义绝对统一的规则。但是一些意见和指导是十分有用的:

  • 没有注释的代码是次优的。
  • 过多的注释(比如,每行都有注释)可能是代码编写的很烂的标志。
  • 注释应当解释 为什么,而不是 是什么。它们可以选择性地解释 如何做,如果代码特别令人困惑的话。

在 JavaScript 中,有两种可能的注释类型:单行注释和多行注释

考虑如下代码:

// 这是一个单行注释

/* 而这是
       一个多行
             注释。
                      */

如果你想在一个语句的正上方,或者甚至是在行的末尾加一个注释,//单行注释是很合适的。这一行上//之后的所有东西都将被视为注释(因此被编译器忽略),一直到行的末尾。在单行注释内部可以出现的内容没有限制。

考虑:

var a = 42;        // 生命的意义是 42

如果你想在注释中用好几行来解释一些事情,/* .. */多行注释就很合适。

这是多行注释的一个常见用法:

/* 使用下面的值是因为
   它回答了
   全宇宙中所有的问题。 */
var a = 42;

它还可以出现在一行中的任意位置,甚至是一行的中间,因为*/终结了它。例如:

var a = /* 随机值 */ 42;

console.log( a );    // 42

在多行注释中唯一不能出现的就是*/,因为这将干扰注释的结尾。

你绝对会希望通过养成注释代码的习惯来开始学习编程。在本书剩余的部分中,你将看到我使用注释来解释事情,请也在你自己的实践中这么做。相信我,所有阅读你的代码的人都会感谢你!

变量

大多数有用的程序都需要在程序运行整个过程中,追踪由于你的程序所意图的任务被调用的底层不同的操作而发生的值的变化。

要这样做的最简单的方法是将一个值赋予一个符号容器,称为一个 变量 —— 因为在这个容器中的值可以根据需要不时 变化 而得名。

在某些编程语言中,你可以声明一个变量(容器)来持有特定类型的值,比如numberstring。因为防止了意外的类型转换,静态类型,也被称为 类型强制,通常被认为是对程序正确性有好处的。

另一些语言在值上强调类型而非在变量上。弱类型,也被称为 动态类型,允许变量在任意时刻持有任意类型的值。因为它允许一个变量在程序逻辑流程中代表一个值,而不论这个值在任意给定的时刻是什么类型,所以它被认为是对程序灵活性有好处的。

JavaScript 使用的是后者,动态类型,这意味着变量可以持有任意 类型 的值而没有任何 类型 强制约束。

正如我们刚才提到的,我们使用var语句来声明一个变量 —— 注意在这种声明中没有其他的 类型 信息。考虑这段简单的代码:

var amount = 99.99;

amount = amount * 2;

console.log( amount );        // 199.98

// 将 `amount` 转换为一个字符串,
// 并在开头加一个 "$"
amount = "$" + String( amount );

console.log( amount );        // "$199.98"

变量amount开始时持有数字99.99,然后持有amount * 2number结果,也就是199.98

第一个console.log(..)命令不得不 隐含地 将这个number值强制转换为一个string才能够打印出来。

然后语句amount = "$" + String(amount) 明确地 将值199.98强制转换为一个string并且在开头加入一个"$"字符。这时,amount现在就持有这个string$199.98,所以第二个console.log(..)语句无需强制转换就可以把它打印出来。

JavaScript 开发者将会注意到为值99.99199.98,和"$199.98"都使用变量amount的灵活性。静态类型的拥护者们将偏好于使用一个分离的变量,比如amountStr来持有这个值最后的"$199.98"表达形式,因为它是一个不同的类型。

不管哪种方式,你将会注意到amount持有一个在程序运行过程中不断变化的值,这展示了变量的主要目地:管理程序 状态

换句话说,在你程序运行的过程中 状态 追踪着值的改变。

变量的另一种常见用法是将值的设定集中化。当你为一个在程序中通篇不打算改变的值声明了一个变量时,它更一般地被称为 常量

你经常会在程序的顶部声明这些 常量,这样提供了一种方便:如果你需要改变一个值时你可以到唯一的地方去寻找。根据惯例,用做常量的 JavaScript 变量通常是大写的,在多个单词之间使用下划线_连接。

这里是一个呆萌的例子:

var TAX_RATE = 0.08;    // 8% sales tax

var amount = 99.99;

amount = amount * 2;

amount = amount + (amount * TAX_RATE);

console.log( amount );                // 215.9784
console.log( amount.toFixed( 2 ) );    // "215.98"

注意: console.log(..)是一个函数log(..)作为一个在值console上的对象属性被访问,与此类似,这里的toFixed(..)是一个可以在值number上被访问的函数。JavaScript number不会被自动地格式化为美元 —— 引擎不知道你的意图,而且也没有通货类型。toFixed(..)让我们指明四舍五入到小数点后多少位,而且它如我们需要的那样产生一个string

变量TAX_RATE只是因为惯例才是一个 常量 —— 在这个程序中没有什么特殊的东西可以防止它被改变。但是如果这座城市将它的消费税增至 9%,我们仍然可以很容地通过在一个地方将TAX_RATE被赋予的值改为0.09来更新我们的程序,而不是在程序通篇中寻找许多值0.08出现的地方然后更新它们全部。

在写作本书时,最新版本的 JavaScript(通常称为“ES6”)引入了一个声明常量的新方法,用const代替var:

// 在 ES6 中:
const TAX_RATE = 0.08;

var amount = 99.99;

// ..

常量就像带有不变的值的变量一样有用,常量还防止在初始设置之后的某些地方意外地改变它的值。如果你试着在第一个声明之后给TAX_RATE赋予一个不同的值,你的程序将会拒绝这个改变(而且在 Strict 模式下,会产生一个错误 —— 见第二章的“Strict 模式”)。

顺带一提,这种防止编程错误的“保护”与静态类型的类型强制很类似,所以你可以看到为什么在其他语言中的静态类型很吸引人。

注意: 更多关于如何在你程序的变量中使用不同的值,参见本系列的 类型与文法

块儿

在你买你的新手机时,手机店店员必须走过一系列步骤才能完成结算。

相似地,在代码中我们经常需要将一系列语句一起分为一组,这就是我们常说的 块儿。在 JavaScript 中,一个块儿被定义为包围在一个大括号{ .. }中的一个或多个语句。考虑如下代码:

var amount = 99.99;

// 一个普通的块儿
{
    amount = amount * 2;
    console.log( amount );    // 199.98
}

这种独立的{ .. }块儿是合法的,但是在 JS 程序中并不常见。一般来说,块儿是添附在一些其他的控制语句后面的,比如一个if语句(见“条件”)或者一个循环(见“循环”)。例如:

var amount = 99.99;

// 数值够大吗?
if (amount > 10) {            // <-- 添附在`if`上的块儿
    amount = amount * 2;
    console.log( amount );    // 199.98
}

我们将在下一节讲解if语句,但是如你所见,{ .. }块儿带着它的两个语句被添附在if (amount > 10)后面;块儿中的语句将会仅在条件成立时被处理。

注意: 与其他大多数语句不同(比如console.log(amount);),一个块儿语句与不需要分号(;)来终结它。

条件

“你想来一个额外的屏幕贴膜吗?只要$9.99。” 热心的手机店店员请你做个决定。而你也许需要首先咨询一下钱包或银行帐号的 状态 才能回答这个问题。但很明显,这只是一个简单的“是与否”的问题。

在我们的程序中有好几种方式可以表达 条件(也就是决定)。

最常见的一个就是if语句。实质上,你在说,“如果 这个条件成立,做后面的……”。例如:

var bank_balance = 302.13;
var amount = 99.99;

if (amount < bank_balance) {
    console.log( "I want to buy this phone!" );
}

if语句在括号( )之间需要一个表达式,它不是被视作true就是被视作false。在这个程序中,我们提供了表达式amount < bank_balance,它确实会根据变量bank_balance中的值被求值为truefalse

如果条件不成立,你甚至可以提供一个另外的选择,称为else子句。考虑下面的代码:

const ACCESSORY_PRICE = 9.99;

var bank_balance = 302.13;
var amount = 99.99;

amount = amount * 2;

// 我们买得起配件吗?
if ( amount < bank_balance ) {
    console.log( "I'll take the accessory!" );
    amount = amount + ACCESSORY_PRICE;
}
// 否则:
else {
    console.log( "No, thanks." );
}

在这里,如果amount < bank_balancetrue,我们将打印出"I'll take the accessory!"并在我们的变量amount上加9.99。否则,else子句说我们将礼貌地回应"No, thanks.",并保持amount不变。

正如我们在早先的“值与类型”中讨论的,一个还不是所期望类型的值经常会被强制转换为那种类型。if语句期待一个boolean,但如果你传给它某些还不是boolean的东西,强制转换就会发生。

JavaScript 定义了一组特定的被认为是“falsy”的值,因为在强制转换为boolean时,它们将变为false —— 这些值包括0""。任何不再这个falsy列表中的值都自动是“truthy” —— 当强制转换为boolean时它们变为true。truthy 值包括99.99"free"这样的东西。更多信息参见第二章的“Truthy 与 Falsy”。

除了if 条件 还以其他形式存在。例如,switch语句可以被用作一系列if..else语句的缩写(见第二章)。循环(见“循环”)使用一个 条件 来决定循环是否应当继续或停止。

注意: 关于在 条件 的测试表达式中可能发生的隐含强制转换的更深层的信息,参见本系列的 类型与文法 的第四章。

循环

在繁忙的时候,有一张排队单,上面记载着需要和手机店店员谈话的顾客。虽然排队单上还有许多人,但是她只需要持续服务下一位顾客就好了。

重复一组动作直到特定的条件失败 —— 换句话说,仅在条件成立时重复 —— 就是程序循环的工作;循环可以有不同的形式,但是它们都符合这种基本行为。

一个循环包含测试条件和一个块儿(通常是{ .. })。每次循环块儿执行,都称为一次 迭代

例如,while循环和do..while循环形式就说明了这种概念 —— 重复一块儿语句直到一个条件不再求值得true

while (numOfCustomers > 0) {
    console.log( "How may I help you?" );

    // 服务顾客……

    numOfCustomers = numOfCustomers - 1;
}

// 与

do {
    console.log( "How may I help you?" );

    // 服务顾客……

    numOfCustomers = numOfCustomers - 1;
} while (numOfCustomers > 0);

这些循环之间唯一的实际区别是,条件是在第一次迭代之前(while)还是之后(do..while)被测试。

在这两种形式中,如果条件测试得false,那么下一次迭代就不会运行。这意味着如果条件初始时就是false,那么while循环就永远不会运行,但是一个do..while循环将仅运行一次。

有时你会为了计数一组特定的数字来进行循环,比如从09(十个数)。你可以通过设定一个值为0的循环迭代变量,比如i,并在每次迭代时将它递增1

警告: 由于种种历史原因,编程语言几乎总是用从零开始的方式来计数的,这意味着计数开始于0而不是1。如果你不熟悉这种思维模式,一开始它可能十分令人困惑。为了更适应它,花些时间练习从0开始数数吧!

条件在每次迭代时都会被测试,好像在循环内部有一个隐含的if语句一样。

你可以使用 JavaScript 的break语句来停止一个循环。另外,我们可以看到如果没有break机制,就会极其容易地创造一个永远运行的循环。

让我们展示一下:

var i = 0;

// 一个 `while..true` 循环将会永远运行,对吧?
while (true) {
    // 停止循环?
    if ((i <= 9) === false) {
        break;
    }

    console.log( i );
    i = i + 1;
}
// 0 1 2 3 4 5 6 7 8 9

警告: 这未必是你想在你的循环中使用的实际形式。它是仅为了说明的目的才出现在这里的。

虽然一个while(或do..while)可以手动完成任务,但是为了同样的目的,还有一种称为for循环的语法形式:

for (var i = 0; i <= 9; i = i + 1) {
    console.log( i );
}
// 0 1 2 3 4 5 6 7 8 9

如你所见,对于这两种循环形式来说,前 10 次迭代(i的值从09)的条件i <= 9都是true,而且一旦i值为10就变为false

for循环有三个子句:初始化子句(var i=0),条件测试子句(i <= 9),和更新子句(i = i + 1)。所以如果你想要使用循环迭代来计数,for是一个更紧凑而且更易理解和编写的形式。

还有一些意在迭代特定的值的特殊循环形式,比如迭代一个对象的属性(见第二章),它隐含的测试条件是所有的属性是否都被处理过了。无论循环是何种形式,“循环直到条件失败”的概念是它们共有的。

函数

手机店的店员可能不会拿着一个计算器到处走,用它来搞清税费和最终的购物款。这是一个她需要定义一次然后一遍又一遍地重用的任务。很有可能的是,公司有一个带有内建这些“功能”的收银机(电脑,平板电脑,等等)。

相似地,几乎可以肯定你的程序想要将代码的任务分割成可以重用的片段,而不是频繁地多次重复自己。这么做的方法是定义一个function

一个函数一般来说是一段被命名的代码,它可以使用名称来被“调用”,而每次调用它内部的代码就会运行。考虑如下代码:

function printAmount() {
    console.log( amount.toFixed( 2 ) );
}

var amount = 99.99;

printAmount(); // "99.99"

amount = amount * 2;

printAmount(); // "199.98"

函数可以选择性地接收参数值(也就是参数)—— 你传入的值。而且它们还可以选择性地返回一个值。

function printAmount(amt) {
    console.log( amt.toFixed( 2 ) );
}

function formatAmount() {
    return "$" + amount.toFixed( 2 );
}

var amount = 99.99;

printAmount( amount * 2 );        // "199.98"

amount = formatAmount();
console.log( amount );            // "$99.99"

函数printAmount(..)接收一个参数,我们称之为amt。函数formatAmount()返回一个值。当然,你也可以在同一个函数中组合这两种技术。

函数经常被用于你打算多次调用的代码,但它们对于仅将有关联的代码组织在一个命名的集合中也很有用,即便你只打算调用它们一次。

考虑如下代码:

const TAX_RATE = 0.08;

function calculateFinalPurchaseAmount(amt) {
    // 计算带有税费的新费用
    amt = amt + (amt * TAX_RATE);

    // 返回新费用
    return amt;
}

var amount = 99.99;

amount = calculateFinalPurchaseAmount( amount );

console.log( amount.toFixed( 2 ) );        // "107.99"

虽然calculateFinalPurchaseAmount(..)只被调用了一次,但是将它的行为组织进一个分离的带名称的函数,让使用它逻辑的代码(amount = calculateFinal...语句)更干净。如果函数中拥有更多的语句,这种好处将会更加明显。

作用域

如果你向手机店的店员询问一款她们店里没有的手机,那么她就不能卖给你你想要的。她只能访问她们店库房里的手机。你不得不到另外一家店里去看看能不能找到你想要的手机。

编程对这种概念有一个术语:作用域(技术上讲称为 词法作用域)。在 JavaScript 中,每个函数都有自己的作用域。作用域基本上就是变量的集合,也是如何使用名称访问这些变量的规则。只有在这个函数内部的代码才能访问这个函数 作用域内 的变量。

在同一个作用域内变量名必须是唯一的 —— 不能有两个不同的变量a并排出现。但是相同的变量名a可以出现在不同的作用域中。

function one() {
    // 这个 `a` 仅属于函数 `one()`
    var a = 1;
    console.log( a );
}

function two() {
    // 这个 `a` 仅属于函数 `two()`
    var a = 2;
    console.log( a );
}

one();        // 1
two();        // 2

另外,一个作用域可以嵌套在另一个作用域中,就像生日 Party 上的小丑在一个气球的里面吹另一个气球一样。如果一个作用域嵌套在另一个中,那么在内部作用域中的代码就可以访问这两个作用域中的变量。

考虑如下代码:

function outer() {
    var a = 1;

    function inner() {
        var b = 2;

        // 我们可以在这里同时访问 `a` 和 `b`
        console.log( a + b );    // 3
    }

    inner();

    // 我们在这里只能访问 `a`
    console.log( a );            // 1
}

outer();

词法作用域规则说,在一个作用域中的代码既可以访问这个作用域中的变量,又可以访问任何在它外面的作用域的变量。

所以,在函数inner()内部的代码可以同时访问变量ab,但是仅在outer()中的代码只能访问a —— 它不能访问b因为这个变量仅存在于inner()内部。

回忆一下先前的这个代码段:

const TAX_RATE = 0.08;

function calculateFinalPurchaseAmount(amt) {
    // 计算带有税费的新费用
    amt = amt + (amt * TAX_RATE);

    // 返回新费用
    return amt;
}

因为词法作用域,常数TAX_RATE(变量)可以从calculateFinalPurchaseAmount(..)函数中访问,即便它没有被传入这个函数。

注意: 关于词法作用域的更多信息,参见本系列的 作用域与闭包 的前三章。

练习

在编程的学习中绝对没有什么可以替代练习。我写的再好也不可能使你成为一个程序员。

带着这样的意识,让我们试着练习一下我们在本章学到的一些概念。我将给出“需求”,而你首先试着实现它。然后参考下面的代码清单来看看我是怎么处理它的。

  • 写一个程序来计算你购买手机的总价。你将不停地购买手机直到你的银行账户上的钱都用光(提示:循环!)。你还将为每个手机购买配件,只要你的花费低于你心理预算。
  • 在你计算完购买总价之后,加入税费,然后用合适的格式打印出计算好的购买总价。
  • 最后,将总价与你银行账户上的余额作比较,来看看那你是否买的起。
  • 你应当为“税率”,“手机价格”,“配件价格”和“花费预算”设置一些常数,也为你的“银行账户余额”设置一个变量。
  • 你应当为税费的计算和价格的格式化 —— 使用一个“$”并四舍五入到小数点后两位 —— 定义函数。
  • 加分挑战: 试着在这个程序中利用输入,也许是使用在前面的“输入”中讲过的prompt(..)。比如,你可能会提示用户输入它们的银行账户余额。发挥创造力好好玩儿吧!

好的,去吧。试试看。在你自己实践过之前不要偷看我的代码清单!

注意: 因为这是一本 JavaScript 书,很明显我将使用 JavaScript 解决这个联系。但是目前你可使用其他的语言,如果你感觉更适应的话。

对于这个练习,这是我的 JavaScript 解决方案:

const SPENDING_THRESHOLD = 200;
const TAX_RATE = 0.08;
const PHONE_PRICE = 99.99;
const ACCESSORY_PRICE = 9.99;

var bank_balance = 303.91;
var amount = 0;

function calculateTax(amount) {
    return amount * TAX_RATE;
}

function formatAmount(amount) {
    return "$" + amount.toFixed( 2 );
}

// 只要你还有钱就不停地买手机
while (amount < bank_balance) {
    // 买个新手机
    amount = amount + PHONE_PRICE;

    // 还买得起配件吗?
    if (amount < SPENDING_THRESHOLD) {
        amount = amount + ACCESSORY_PRICE;
    }
}

// 也别忘了给政府交钱
amount = amount + calculateTax( amount );

console.log(
    "Your purchase: " + formatAmount( amount )
);
// Your purchase: $334.76

// 你买的起吗?
if (amount > bank_balance) {
    console.log(
        "You can't afford this purchase. :("
    );
}
// 你买不起 :(

注意: 运行这个 JavaScript 程序的最简单的方法是将它键入到你手边的浏览器的开发者控制台中。

你做的怎么样?看了我的代码之后,现在再试一次也没什么不好。而且你可以改变某些常数来看看使用不同的值时这个程序运行的如何。

复习

学习编程不一定是个复杂而且巨大的过程。你只需要在脑中装进几个基本的概念。

它们就像构建块儿。要建一座高塔,你就要从堆砌构建块儿开始。编程也一样。这里是一些编程中必不可少的构建块儿:

  • 你需要 操作符 来在值上实施动作。
  • 你需要值和 类型 来试试不同种类的动作,比如在number上做数学,或者使用string输出。
  • 你需要 变量 在你程序执行的过程中存储数据(也就是 状态)。
  • 你需要 条件,比如if语句来做决定。
  • 你需要 循环 来重复任务,直到一个条件不再成立。
  • 你需要 函数 来将你的代码组织为有逻辑的和可复用的块儿。

代码注释是一种编写更好可读性代码的有效方法,它使你的代码更易理解,维护,而且如果稍后出现问题的话更易修改。

最后,不要忽视练习的力量。学习写代码的最好方法就是写代码。

现在,我很高兴看到你在学习编码的道路上走得很好!保持下去。不要忘了看看其他编程初学者的资源(书,博客,在线教学,等等)。这一章和这本书是一个很好的开始,但它们只是一个简要的介绍。

下一章将会复习许多本章中的概念,但是是从更加专门于 JavaScript 的视角,这将突出将在本系列的剩余部分将要深度剖析的大多数主要话题。

你不懂 JS:入门与进阶 第二章:进入 JavaScript

在前一章中,我介绍了编程的基本构建块儿,比如变量,循环,条件,和函数。当然,所有被展示的代码都是 JavaScript。但是在这一章中,为了作为一个 JS 开发者入门和进阶,我们想要特别集中于那些你需要知道的关于 JavaScript 的事情。

我们将在本章中介绍好几个概念,它们将会在后续的 YDKJS 丛书中全面地探索。你可以将这一章看作是这个系列的其他书目中将要详细讲解的话题的一个概览。

特别是如果你刚刚接触 JavaScript,那么你应当希望花相当一段时间来多次复习这里的概念和代码示例。任何好的基础都是一砖一瓦积累起来的,所以不要指望你会在第一遍通读后就立即理解了全部内容。

你深入学习 JavaScript 的旅途从这里开始。

注意: 正如我在第一章中说过的,在你通读这一章的同时,你绝对应该亲自尝试这里所有的代码。要注意的是,这里的有些代码假定最新版本的 JavaScript(通常称为“ES6”,ECMAScript 的第六个版本 —— ECMAScript 是 JS 语言规范的官方名称)中引入的功能是存在的。如果你碰巧在使用一个老版本的,前 ES6 时代的浏览器,这些代码可能不好用。应当使用一个更新版本的现代浏览器(比如 Chrome,Firefox,或者 IE)。

值与类型

正如我们在第一章中宣称的,JavaScript 拥有带类型的值,没有带类型的变量。下面是可用的内建类型:

  • string
  • number
  • boolean
  • nullundefined
  • object
  • symbol (ES6 新增类型)

JavaScript 提供了一个typeof操作符,它可以检查一个值并告诉你它的类型是什么:

var a;
typeof a;                // "undefined"

a = "hello world";
typeof a;                // "string"

a = 42;
typeof a;                // "number"

a = true;
typeof a;                // "boolean"

a = null;
typeof a;                // "object" -- 奇怪的 bug

a = undefined;
typeof a;                // "undefined"

a = { b: "c" };
typeof a;                // "object"

来自typeof的返回值总是六个(ES6 中是七个!)字符串值之一。也就是,typeof "abc"返回"string",不是string

注意在这个代码段中变量a是如何持有每种不同类型的值的,而且尽管表面上看起来很像,但是typeof a并不是在询问“a的类型”,而是“当前a中的值的类型”。在 JavaScript 中只有值拥有类型;变量只是这些值的简单容器。

typeof null是一个有趣的例子,因为当你期望它返回"null"时,它错误地返回了"object"

警告: 这是 JS 中一直存在的一个 bug,但是看起来它永远都不会被修复了。在网络上有太多的代码依存于这个 bug,因此修复它将会导致更多的 bug!

另外,注意a = undefined。我们明确地将a设置为值undefined,但是在行为上这与一个还没有被设定值的变量没有区别,比如在这个代码段顶部的var a;。一个变量可以用好几种不同的方式得到这样的“undefined”值状态,包括没有返回值的函数和使用void操作符。

对象

object类型指的是一种复合值,你可以在它上面设定属性(带名称的位置),每个属性持有各自的任意类型的值。它也许是 JavaScript 中最有用的类型之一。

var obj = {
    a: "hello world",
    b: 42,
    c: true
};

obj.a;        // "hello world"
obj.b;        // 42
obj.c;        // true

obj["a"];    // "hello world"
obj["b"];    // 42
obj["c"];    // true

可视化地考虑这个obj值可能会有所帮助:


fig4.png

属性既可以使用 点号标记法(例如,obj.a) 访问,也可以使用 方括号标记法(例如,obj["a"]) 访问。点号标记法更短而且一般来说更易于阅读,因此在可能的情况下它都是首选。

如果你有一个名称中含有特殊字符的属性名称,方括号标记法就很有用,比如obj["hello world!"] —— 当通过方括号标记法访问时,这样的属性经常被称为 [ ]标记法要求一个变量(下一节讲解)或者一个string 字面量(它需要包装进" .. "' .. ')。

当然,如果你想访问一个属性/键,但是它的名称被存储在另一个变量中时,方括号标记法也很有用。例如:

var obj = {
    a: "hello world",
    b: 42
};

var b = "a";

obj[b];            // "hello world"
obj["b"];        // 42

注意: 更多关于 JavaScript 的object的信息,请参见本系列的 this 与对象原型,特别是第三章。

在 JavaScript 程序中有另外两种你将会经常打交道的值类型:数组函数。但与其说它们是内建类型,这些类型应当被认为更像是子类型 —— object类型的特化版本。

数组

一个数组是一个object,它不使用特殊的带名称的属性/键持有(任意类型的)值,而是使用数字索引的位置。例如:

var arr = [
    "hello world",
    42,
    true
];

arr[0];            // "hello world"
arr[1];            // 42
arr[2];            // true
arr.length;        // 3

typeof arr;        // "object"

注意: 从零开始计数的语言,比如 JS,在数组中使用0作为第一个元素的索引。

可视化地考虑arr很能会有所帮助:


fig5.png

因为数组是一种特殊的对象(正如typeof所暗示的),所以它们可以拥有属性,包括一个可以自动被更新的length属性。

理论上你可以使用你自己的命名属性将一个数组用作一个普通对象,或者你可以使用一个object但是给它类似于数组的数字属性(01,等等)。然而,这么做一般被认为是分别误用了这两种类型。

最好且最自然的方法是为数字定位的值使用数组,而为命名属性使用object

函数

另一个你将在 JS 程序中到处使用的object子类型是函数:

function foo() {
    return 42;
}

foo.bar = "hello world";

typeof foo;            // "function"
typeof foo();        // "number"
typeof foo.bar;        // "string"

同样地,函数也是object的子类型 —— typeof返回"function",这暗示着"function"是一种主要类型 —— 因此也可以拥有属性,但是你一般仅会在有限情况下才使用函数对象属性(比如foo.bar)。

注意: 更多关于 JS 的值和它们的类型的信息,参见本系列的 类型与文法 的前两章。

内建类型的方法

我们刚刚讨论的内建类型和子类型拥有十分强大和有用的行为,它们作为属性和方法暴露出来。

例如:

var a = "hello world";
var b = 3.14159;

a.length;                // 11
a.toUpperCase();        // "HELLO WORLD"
b.toFixed(4);            // "3.1416"

使调用a.toUpperCase()成为可能的原因,要比这个值上存在这个方法的说法复杂一些。

简而言之,有一个StringS大写)对象包装器形式,通常被称为“原生类型”,与string基本类型配成一对儿;正是这个对象包装器的原型上定义了toUpperCase()方法。

当你通过引用一个属性或方法(例如,前一个代码段中的a.toUpperCase())将一个像"hello world"这样的基本类型值当做一个object来使用时,JS 自动地将这个值“封箱”为它对应的对象包装器(这个操作是隐藏在幕后的)。

一个string值可以被包装为一个String对象,一个number可以被包装为一个Number对象,而一个boolean可以被包装为一个Boolean对象。在大多数情况下,你不担心或者直接使用这些值的对象包装器形式 —— 在所有实际情况中首选基本类型值形式,而 JavaScript 会帮你搞定剩下的一切。

注意: 关于 JS 原生类型和“封箱”的更多信息,参见本系列的 类型与文法 的第三章。要更好地理解对象原型,参见本系列的 this 与对象原型 的第五章。

值的比较

在你的 JS 程序中你将需要进行两种主要的值的比较:等价不等价。任何比较的结果都是严格的boolean值(truefalse),无论被比较的值的类型是什么。

强制转换

在第一章中我们简单地谈了一下强制转换,我们在此回顾它。

在 JavaScript 中强制转换有两种形式:明确的隐含的。明确的强制转换比较简单,因为你可以在代码中明显地看到一个类型转换到另一个类型将会发生,而隐含的强制转换更像是另外一些操作的不明显的副作用引发的类型转换。

你可能听到过像“强制转换是邪恶的”这样情绪化的观点,这是因为一个清楚的事实 —— 强制转换在某些地方会产生一些令人吃惊的结果。也许没有什么能比当一个语言吓到开发者时更能唤起他们的沮丧心情了。

强制转换并不邪恶,它也不一定是令人吃惊的。事实上,你使用类型强制转换构建的绝大部分情况是十分合理和可理解的,而且它甚至可以用来 增强 你代码的可读性。但我们不会在这个话题上过度深入 —— 本系列的 类型与文法 的第四章将会进行全面讲解。

这是一个 明确 强制转换的例子:

var a = "42";

var b = Number( a );

a;                // "42"
b;                // 42 -- 数字!

而这是一个 隐含 强制转换的例子:

var a = "42";

var b = a * 1;    // 这里 "42" 被隐含地强制转换为 42

a;                // "42"
b;                // 42 -- 数字!

Truthy 与 Falsy

在第一章中,我们简要地提到了值的“truthy”和“falsy”性质:当一个非boolean值被强制转换为一个boolean时,它是变成true还是false

在 JavaScript 中“falsy”的明确列表如下:

  • "" (空字符串)
  • 0, -0, NaN (非法的number
  • null, undefined
  • false

任何不在这个“falsy”列表中的值都是“truthy”。这是其中的一些例子:

  • "hello"
  • 42
  • true
  • [ ], [ 1, "2", 3 ] (数组)
  • { }, { a: 42 } (对象)
  • function foo() { .. } (函数)

重要的是要记住,一个非boolean值仅在实际上被强制转换为一个boolean时才遵循这个“truthy”/“falsy”强制转换。把你搞糊涂并不困难 —— 当一个场景看起来像是将一个值强制转换为boolean,可其实它不是。

等价性

有四种等价性操作符:=====!=,和!==!形式当然是与它们相对应操作符平行的“不等”版本;不等(non-equality) 不应当与 不等价性(inequality) 相混淆。

=====之间的不同通常被描述为,==检查值的等价性而===检查值和类型两者的等价性。然而,这是不准确的。描述它们的合理方式是,==在允许强制转换的条件下检查值的等价性,而===是在不允许强制转换的条件下检查值的等价性;因此===常被称为“严格等价”。

考虑这个隐含强制转换,它在==宽松等价性比较中允许,而===严格等价性比较中不允许:

var a = "42";
var b = 42;

a == b;            // true
a === b;        // false

a == b的比较中,JS 注意到类型不匹配,于是它经过一系列有顺序的步骤将一个值或者它们两者强制转换为一个不同的类型,直到类型匹配为止,然后就可以检查一个简单的值等价性。

如果你仔细想一想,通过强制转换a == b可以有两种方式给出true。这个比较要么最终成为42 == 42,要么成为"42" == "42"。那么是哪一种呢?

答案:"42"变成42,于是比较成为42 == 42。在一个这样简单的例子中,只要最终结果是一样的,处理的过程走哪一条路看起来并不重要。但在一些更复杂的情况下,这不仅对比较的最终结果很重要,而且对你 如何 得到这个结果也很重要。

a === b产生false,因为强制转换是不允许的,所以简单值的比较很明显将会失败。许多开发者感觉===更可靠,所以他们提倡一直使用这种形式而远离==。我认为这种观点是非常短视的。我相信==是一种可以改进程序的强大工具,如果你花时间去学习它的工作方式

我们不会详细地讲解强制转换在==比较中是如何工作的。它的大部分都是相当合理的,但是有一些重要的极端用例要小心。你可以阅读 ES5 语言规范的 11.9.3 部分(www.ecma-international.org/ecma-262/5.1/)来了解确切的规则,而且与围绕这种机制的所有负面炒作比起来,你会对这它是多么的直白而感到吃惊。

为了将这许多细节归纳为一个简单的包装,并帮助你在各种情况下判断是否使用=====,这是我的简单规则:

  • 如果一个比较的两个值之一可能是truefalse值,避免==而使用===
  • 如果一个比较的两个值之一可能是这些具体的值(0"",或[] —— 空数组),避免==而使用===
  • 所有 其他情况下,你使用==是安全的。它不仅安全,而且在许多情况下它可以简化你的代码并改善可读性。

这些规则归纳出来的东西要求你严谨地考虑你的代码:什么样的值可能通过这个被比较等价性的变量。如果你可以确定这些值,那么==就是安全的,使用它!如果你不能确定这些值,就使用===。就这么简单。

!=不等价形式对应于==,而!==形式对应于===。我们刚刚讨论的所有规则和注意点对这些非等价比较都是平行适用的。

如果你在比较两个非基本类型值,比如object(包括functionarray),那么你应当特别小心=====的比较规则。因为这些值实际上是通过引用持有的,=====比较都将简单地检查这个引用是否相同,而不是它们底层的值。

例如,array默认情况下会通过使用逗号(,)连接所有值来被强制转换为string。你可能认为两个内容相同的array将是==相等的,但它们不是:

var a = [1,2,3];
var b = [1,2,3];
var c = "1,2,3";

a == c;        // true
b == c;        // true
a == b;        // false

注意: 更多关于==等价性比较规则的信息,参见 ES5 语言规范(11.9.3 部分),和本系列的 类型与文法 的第四章;更多关于值和引用的信息,参见它的第二章。

不等价性

<><=,和>=操作符用于不等价性比较,在语言规范中被称为“关系比较”。一般来说它们将与number这样的可比较有序值一起使用。3 < 4是很容易理解的。

但是 JavaScriptstring值也可进行不等价性比较,它使用典型的字母顺序规则("bar" < "foo")。

那么强制转换呢?与==比较相似的规则(虽然不是完全相同!)也适用于不等价操作符。要注意的是,没有像===严格等价操作符那样不允许强制转换的“严格不等价”操作符。

考虑如下代码:

var a = 41;
var b = "42";
var c = "43";

a < b;        // true
b < c;        // true

这里发生了什么?在 ES5 语言规范的 11.8.5 部分中,它说如果<比较的两个值都是string,就像b < c,那么这个比较将会以字典顺序(也就是像字典中字母的排列顺序)进行。但如果两个值之一不是string,就像a < b,那么两个值就将被强制转换成number,并进行一般的数字比较。

在可能不同类型的值之间进行比较时,你可能遇到的最大的坑 —— 记住,没有“严格不等价”可用 —— 是其中一个值不能转换为合法的数字,例如:

var a = 42;
var b = "foo";

a < b;        // false
a > b;        // false
a == b;        // false

等一下,这三个比较怎么可能都是false?因为在<>的比较中,值b被强制转换为了“非法的数字值”,而且语言规范说NaN既不大于其他值,也不小于其他值。

==比较失败于不同的原因。如果a == b被解释为42 == NaN或者"42" == "foo"都会失败 —— 正如我们前面讲过的,这里是前一种情况。

注意: 关于不等价比较规则的更多信息,参见 ES5 语言规范的 11.8.5 部分,和本系列的 类型与文法 第四章。

变量

在 JavaScript 中,变量名(包括函数名)必须是合法的 标识符(identifiers)。当你考虑非传统意义上的字符时,比如 Unicode,标识符中合法字符的严格和完整的规则就有点儿复杂。如果你仅考虑典型的 ASCII 字母数字的字符,那么这个规则还是很简单的。

一个标识符必须以a-zA-Z$,或_开头。它可以包含任意这些字符外加数字0-9

一般来说,变量标识符的规则也通用适用于属性名称。然而,有一些不能用作变量名,但是可以用作属性名的单词。这些单词被称为“保留字(reserved words)”,包括 JS 关键字(forinif,等等)和nulltruefalse

注意: 更多关于保留字的信息,参见本系列的 类型与文法 的附录 A。

函数作用域

你使用var关键字声明的变量将属于当前的函数作用域,如果声明位于任何函数外部的顶层,它就属于全局作用域。

提升

无论var出现在一个作用域内部的何处,这个声明都被认为是属于整个作用域,而且在作用域的所有位置都是可以访问的。

这种行为称为 提升,比喻一个var声明在概念上 被移动 到了包含它的作用域的顶端。技术上讲,这个过程通过代码的编译方式进行解释更准确,但是我们先暂且跳过那些细节。

考虑如下代码:

var a = 2;

foo();                    // 可以工作, 因为 `foo()` 声明被“提升”了

function foo() {
    a = 3;

    console.log( a );    // 3

    var a;                // 声明被“提升”到了 `foo()` 的顶端
}

console.log( a );    // 2

警告: 在一个作用域中依靠变量提升来在var声明出现之前使用一个变量是不常见的,也不是个好主意;它可能相当使人困惑。而使用被提升的函数声明要常见得多,也更为人所接受,就像我们在foo()正式声明之前就调用它一样。

嵌套的作用域

当你声明了一个变量时,它就在这个作用域内的任何地方都是可用的,包括任何下层/内部作用域。例如:

function foo() {
    var a = 1;

    function bar() {
        var b = 2;

        function baz() {
            var c = 3;

            console.log( a, b, c );    // 1 2 3
        }

        baz();
        console.log( a, b );        // 1 2
    }

    bar();
    console.log( a );                // 1
}

foo();

注意cbar()的内部是不可用的,因为它是仅在内部的baz()作用域中被声明的,并且b因为同样的原因在foo()内是不可用的。

如果你试着在一个作用域内访问一个不可用的变量的值,你就会得到一个被抛出的ReferenceError。如果你试着为一个还没有被声明的变量赋值,那么根据“strict 模式”的状态,你会要么得到一个在顶层全局作用域中创建的变量(不好!),要么得到一个错误。让我们看一下:

function foo() {
    a = 1;    // `a` 没有被正式声明
}

foo();
a;            // 1 -- 噢,自动全局变量 :(

这是一种非常差劲儿的做法。别这么干!总是给你的变量进行正式声明。

除了在函数级别为变量创建声明,ES6 允许你使用let关键字声明属于个别块儿(一个{ .. })的变量。除了一些微妙的细节,作用域规则将大致上与我们刚刚看到的函数相同:

function foo() {
    var a = 1;

    if (a >= 1) {
        let b = 2;

        while (b < 5) {
            let c = b * 2;
            b++;

            console.log( a + c );
        }
    }
}

foo();
// 5 7 9

因为使用了let而非varb将仅属于if语句而不是整个foo()函数的作用域。相似地,c仅属于while循环。对于以更加细粒度的方式管理你的变量作用域来说,块儿作用域是非常有用的,它将使你的代码随着时间的推移更加易于维护。

注意: 关于作用域的更多信息,参见本系列的 作用域与闭包。更多关于let块儿作用域的信息,参见本系列的 ES6 与未来

条件

除了我们在第一章中简要介绍过的if语句,JavaScript 还提供了几种其他值得我们一看的条件机制。

有时你可能发现自己在像这样写一系列的if..else..if语句:

if (a == 2) {
    // 做一些事情
}
else if (a == 10) {
    // 做另一些事请
}
else if (a == 42) {
    // 又是另外一些事情
}
else {
    // 这里是备用方案
}

这种结构好用,但有一点儿繁冗,因为你需要为每一种情况都指明a的测试。这里有另一种选项,switch语句:

switch (a) {
    case 2:
        // 做一些事情
        break;
    case 10:
        // 做另一些事请
        break;
    case 42:
        // 又是另外一些事情
        break;
    default:
        // 这里是备用方案
}

如果你想仅让一个case中的语句运行,break是很重要的。如果你在一个case中省略了break,并且这个case成立或运行,那么程序的执行将会不管下一个case语句是否成立而继续执行它。这种所谓的“掉落”有时是有用/期望的:

switch (a) {
    case 2:
    case 10:
        // 一些很酷的事情
        break;
    case 42:
        // 另一些事情
        break;
    default:
        // 备用方案
}

这里,如果a210,它就会执行“一些很酷的事情”的代码语句。

在 JavaScript 中的另一种条件形式是“条件操作符”,经常被称为“三元操作符”。它像是一个单独的if..else语句的更简洁的形式,比如:

var a = 42;

var b = (a > 41) ? "hello" : "world";

// 与此相似:

// if (a > 41) {
//    b = "hello";
// }
// else {
//    b = "world";
// }

如果测试表达式(这里是a > 41)求值为true,那么就会得到第一个子句("hello"),否则得到第二个子句("world"),而且无论结果为何都会被赋值给b

条件操作符不一定非要用于赋值,但是这绝对是最常见的用法。

注意: 关于测试条件和switch? :的其他模式的更多信息,参见本系列的 类型与文法

Strict 模式

ES5 在语言中加入了一个“strict 模式”,它收紧了一些特定行为的规则。一般来说,这些限制被视为使代码符合一组更安全和更合理的指导方针。另外,坚持 strict 模式一般会使你的代码对引擎有更强的可优化性。strict 模式对代码有很大的好处,你应当在你所有的程序中使用它。

根据你摆放 strict 模式注解的位置,你可以为一个单独的函数,或者是整个一个文件切换到 strict 模式:

function foo() {
 "use strict";

    // 这部分代码是 strict 模式的

    function bar() {
        // 这部分代码是 strict 模式的
    }
}

// 这部分代码不是 strict 模式的

将它与这个相比:

"use strict";

function foo() {
    // 这部分代码是 strict 模式的

    function bar() {
        // 这部分代码是 strict 模式的
    }
}

// 这部分代码是 strict 模式的

使用 strict 模式的一个关键不同(改善!)是,它不允许因为省略了var而进行隐含的自动全局变量声明:

function foo() {
 "use strict";    // 打开 strict 模式
    a = 1;            // 缺少`var`,ReferenceError
}

foo();

如果你在代码中打开 strict 模式,并且得到错误,或者代码开始变得有 bug,这可能会诱使你避免使用 strict 模式。但是纵容这种直觉不是一个好主意。如果 strict 模式在你的程序中导致了问题,那么这标志着在你的代码中几乎可以肯定有应该修改的东西。

strict 模式不仅将你的代码保持在更安全的道路上,也不仅将使你的代码可优化性更强,它还代表着这种语言未来的方向。对于你来说,现在就开始习惯于 strict 模式要比一直回避它容易得多 —— 以后再进行这种转变只会更难!

注意: 关于 strict 模式的更多信息,参见本系列的 类型与文法 的第五章。

函数作为值

至此,我们已经将函数作为 JavaScript 中主要的 作用域 机制讨论过了。你可以回想一下典型的function声明语法是这样的:

function foo() {
    // ..
}

虽然从这种语法中看起来不明显,foo基本上是一个位于外围作用域的变量,它给了被声明的function一个引用。也就是说,function本身是一个值,就像42[1,2,3]一样。

这可能听起来像是一个奇怪的概念,所以花点儿时间仔细考虑一下。你不仅可以向一个function传递一个值(参数值),而且 一个函数本身可以是一个值,它能够赋值给变量,传递给其他函数,或者从其它函数中返回。

因此,一个函数值应当被认为是一个表达式,与任何其他的值或表达式很相似。

考虑如下代码:

var foo = function() {
    // ..
};

var x = function bar(){
    // ..
};

第一个被赋值给变量foo的函数表达式称为 匿名 函数表达式,因为它没有“名称”。

第二个函数表达式是 命名的bar),它还被赋值给变量x作为它的引用。命名函数表达式 一般来说更理想,虽然 匿名函数表达式 仍然极其常见。

更多信息参见本系列的 作用域与闭包

立即被调用的函数表达式(IIFE)

在前一个代码段中,哪一个函数表达式都没有被执行 —— 除非我们使用了foo()x()

有另一种执行函数表达式的方法,它通常被称为一个 立即被调用的函数表达式 (IIFE):

(function IIFE(){
    console.log( "Hello!" );
})();
// "Hello!"

围绕在函数表达式(function IIFE(){ .. })外部的( .. )只是一个微妙的 JS 文法,我们需要它来防止函数表达式被看作一个普通的函数声明。

在表达式末尾的最后的() —— })();这一行 —— 才是实际立即执行它前面的函数表达式的东西。

这看起来可能很奇怪,但它不像第一眼看上去那么陌生。考虑这里的fooIIFE之间的相似性:

function foo() { .. }

// `foo` 是函数引用表达式,然后用`()`执行它
foo();

// `IIFE` 是函数表达式,然后用`()`执行它
(function IIFE(){ .. })();

如你所见,在执行它的()之前列出(function IIFE(){ .. }),与在执行它的()之前定义foo实质上是相同的;在这两种情况下,函数引用都使用立即在它后面的()执行。

因为 IIFE 只是一个函数,而函数可以创建变量 作用域,以这样的风格使用一个 IIFE 经常被用于定义变量,而这些变量将不会影响围绕在 IIFE 外面的代码:

var a = 42;

(function IIFE(){
    var a = 10;
    console.log( a );    // 10
})();

console.log( a );        // 42

IIFE 还可以有返回值:

var x = (function IIFE(){
    return 42;
})();

x;    // 42

42从被执行的命名为IIFE的函数中return,然后被赋值给x

闭包

闭包 是 JavaScript 中最重要,却又经常最少为人知的概念之一。我不会在这里涵盖更深的细节,你可以参照本系列的 作用域与闭包。但我想说几件关于它的事情,以便你了解它的一般概念。它将是你的 JS 技术结构中最重要的技术之一。

你可以认为闭包是这样一种方法:即使函数已经完成了运行,它依然可以“记住”并持续访问函数的作用域。

考虑如下代码:

function makeAdder(x) {
    // 参数 `x` 是一个内部变量

    // 内部函数 `add()` 使用 `x`,所以它对 `x` 拥有一个“闭包”
    function add(y) {
        return y + x;
    };

    return add;
}

每次调用外部的makeAdder(..)所返回的对内部add(..)函数的引用可以记住被传入makeAdder(..)x值。现在,让我们使用makeAdder(..)

// `plusOne` 得到一个指向内部函数 `add(..)` 的引用,
// `add()` 函数拥有对外部 `makeAdder(..)` 的参数 `x`
// 的闭包
var plusOne = makeAdder( 1 );

// `plusTen` 得到一个指向内部函数 `add(..)` 的引用,
// `add()` 函数拥有对外部 `makeAdder(..)` 的参数 `x`
// 的闭包
var plusTen = makeAdder( 10 );

plusOne( 3 );        // 4  <-- 1 + 3
plusOne( 41 );        // 42 <-- 1 + 41

plusTen( 13 );        // 23 <-- 10 + 13

这段代码的工作方式是:

  1. 当我们调用makeAdder(1)时,我们得到一个指向它内部的add(..)的引用,它记住了x1。我们称这个函数引用为plusOne(..)
  2. 当我们调用makeAdder(10)时,我们得到了另一个指向它内部的add(..)引用,它记住了x10。我们称这个函数引用为plusTen(..)
  3. 当我们调用plusOne(3)时,它在3(它内部的y)上加1(被x记住的),于是我们得到结果4
  4. 当我们调用plusTen(13)时,它在13(它内部的y)上加10(被x记住的),于是我们得到结果23

如果这看起里很奇怪和令人困惑,不要担心 —— 它确实是的!要完全理解它需要很多的练习。

但是相信我,一旦你理解了它,它就是编程中最强大最有用的技术之一。让你的大脑在闭包中煎熬一会是绝对值得的。在下一节中,我们将进一步实践闭包。

模块

在 JavaScript 中闭包最常见的用法就是模块模式。模块让你定义对外面世界不可见的私有实现细节(变量,函数),和对外面可访问的公有 API。

考虑如下代码:

function User(){
    var username, password;

    function doLogin(user,pw) {
        username = user;
        password = pw;

        // 做登录的工作
    }

    var publicAPI = {
        login: doLogin
    };

    return publicAPI;
}

// 创建一个 `User` 模块的实例
var fred = User();

fred.login( "fred", "12Battery34!" );

函数User()作为一个外部作用域持有变量usernamepassword,以及内部doLogin()函数;它们都是User模块内部的私有细节,是不能从外部世界访问的。

警告: 我们在这里没有调用new User(),这是有意为之的,虽然对大多数读者来说那可能更常见。User()只是一个函数,不是一个要被初始化的对象,所以它只是被一般地调用了。使用new将是不合适的,而且实际上会浪费资源。

执行User()创建了User模块的一个 实例 —— 一个全新的作用域会被创建,而每个内部变量/函数的一个全新的拷贝也因此而被创建。我们将这个实例赋值给fred。如果我们再次运行User(),我们将会得到一个与fred完全分离的新的实例。

内部的doLogin()函数在usernamepassword上拥有闭包,这意味着即便User()函数已经完成了运行,它依然持有对它们的访问权。

publicAPI是一个带有一个属性/方法的对象,login是一个指向内部doLogin()函数的引用。当我们从User()中返回publicAPI时,它就变成了我们称为fred的实例。

在这个时候,外部的User()函数已经完成了执行。一般说来,你会认为像usernamepassword这样的内部变量将会消失。但是在这里它们不会,因为在login()函数里有一个闭包使它们继续存活。

这就是为什么我们可以调用fred.login(..) —— 和调用内部的doLogin(..)一样 —— 而且它依然可以访问内部变量usernamepassword

这样对闭包和模块模式的简单一瞥,你很有可能还是有点儿糊涂。没关系!要把它装进你的大脑确实需要花些功夫。

以此为起点,关于更多深入细节的探索可以去读本系列的 作用域与闭包

this 标识符

在 JavaScript 中另一个经常被误解的概念是this标识符。同样,在本系列的 this 与对象原型 中有好几章关于它的内容,所以在这里我们只简要的介绍一下概念。

虽然this可能经常看起来是与“面向对象模式”有关的,但在 JS 中this是一个不同的概念。

如果一个函数在它内部拥有一个this引用,那么这个this引用通常指向一个object。但是指向哪一个object要看这个函数是如何被调用的。

重要的是要理解this 不是 指函数本身,这是最常见的误解。

这是一个快速的说明:

function foo() {
    console.log( this.bar );
}

var bar = "global";

var obj1 = {
    bar: "obj1",
    foo: foo
};

var obj2 = {
    bar: "obj2"
};

// --------

foo();                // "global"
obj1.foo();            // "obj1"
foo.call( obj2 );    // "obj2"
new foo();            // undefined

关于this如何被设置有四个规则,它们被展示在这个代码段的最后四行中:

  1. foo()最终在非 strict 模式中将this设置为全局对象 —— 在 strict 模式中,this将会是undefined而且你会在访问bar属性时得到一个错误 —— 所以this.bar的值是global
  2. obj1.foo()this设置为对象obj1
  3. foo.call(obj2)this设置为对象obj2
  4. new foo()this设置为一个新的空对象。

底线:要搞清楚this指向什么,你必须检视当前的函数是如何被调用的。它将是我们刚刚看到的四种中的一种,而这将会回答this是什么。

注意: 关于this的更多信息,参见本系列的 this 与对象原型 的第一和第二章。

原型

JavaScript 中的原型机制十分复杂。我们在这里近仅仅扫它一眼。要了解关于它的所有细节,你需要花相当的时间来学习本系列的 this 与对象原型 的第四到六章。

当你引用一个对象上的属性时,如果这个属性不存在,JavaScript 将会自动地使用这个对象的内部原型引用来寻找另外一个对象,在它上面查询你想要的属性。你可以认为它几乎是在属性缺失时的备用对象。

从一个对象到它备用对象的内部原型引用链接发生在这个对象被创建的时候。说明它的最简单的方法是使用称为Object.create(..)的内建工具。

考虑如下代码:

var foo = {
    a: 42
};

// 创建 `bar` 并将它链接到 `foo`
var bar = Object.create( foo );

bar.b = "hello world";

bar.b;        // "hello world"
bar.a;        // 42 <-- 委托到 `foo`

将对象foobar以及它们的关系可视化也许会有所帮助:


fig6.png

属性a实际上不存在于对象bar上,但是因为bar被原型链接到foo,JavaScript 自动地退到对象foo上去寻找a,而且在这里找到了它。

这种链接看起来是语言的一种奇怪的特性。这种特性最常被使用的方式 —— 我会争辩说这是一种滥用 —— 是用来模拟/模仿“类”机制的“继承”。

使用原型的更自然的方式是一种称为“行为委托”的模式,在这种模式中你有意地将你的被链接的对象设计为可以从一个委托到另一个的部分所需的行为中。

注意: 更多关于原型和行为委托的信息,参见本系列的 this 与对象原型 的第四到六章。

旧的与新的

以我们已经介绍过的 JS 特性,和将在这个系列的其他部分中讲解的相当一部分特性都是新近增加的,不一定在老版本的浏览器中可用。事实上,语言规范中的一些最新特性甚至在任何稳定的浏览中都没有被实现。

那么,你拿这些新东西怎么办?你只能等上几年或者十几年直到老版本浏览器归于尘土?

这确实是许多人认为的情况,但是它不是 JS 健康的进步方式。

有两种主要的技术可以将新的 JavaScript 特性“带到”老版本的浏览器中:填补和转译。

填补

“填补(Polyfilling)”是一个认为发明的词(由 Remy Sharp 创造)(remysharp.com/2010/10/08/what-is-a-polyfill)。它是指拿来一个新特性的定义并制造一段行为等价的代码,但是这段代码可以运行在老版本的 JS 环境中。

例如,ES6 定义了一个称为Number.isNaN(..)的工具,来为检查NaN值提供一种准确无误的方法,同时废弃原来的isNaN(..)工具。这个工具可以很容易填补,因此你可开始在你的代码中使用它,而不管最终用户是否在一个 ES6 浏览器中。

考虑如下代码:

if (!Number.isNaN) {
    Number.isNaN = function isNaN(x) {
        return x !== x;
    };
}

if语句决定着在这个工具已经存在的 ES6 环境中不再进行填补。如果它还不存在,我们就定义Number.isNaN(..)

注意: 我们在这里做的检查利用了NaN值的怪异之处,即它们是整个语言中唯一与自己不相等的值。所以NaN是唯一可能使x !== xtrue的值。

并不是所有的新特性都可以完全填补。有时一种特性的大部分行为可以被填补,但是仍然存在一些小的偏差。在实现你自己的添补时你应当非常非常小心,来确保你尽可能严格地遵循语言规范。

或者更好地,使用一组你信任的,经受过检验的添补,比如那些由 ES5-Shim(github.com/es-shims/es5-shim)和 ES6-Shim(https://github.com/es-shims/es6-shim)提供的。

转译

没有任何办法可以添补语言中新增加的语法。在老版本的 JS 引擎中新的语法将因为不可识别/不合法而抛出一个错误。

所以更好的选择是使用一个工具将你的新版本代码转换为等价的老版本代码。这个处理通常被称为“转译(transpiling)”,表示转换 + 编译。

实质上,你的源代码是使用新的语法形式编写的,但是你向浏览器部署的是转译过的旧语法形式。你一般会将转译器插入到你的构建过程中,与你的代码 linter 和代码压缩器类似。

你可能想知道为什么要麻烦地使用新语法编写程序又将它转译为老版本代码 —— 为什么不直接编写老版本代码呢?

关于转译你应当注意几个重要的原因:

  • 在语言中新加入的语法是为了使你的代码更具可读性和维护性而设计的。老版本的等价物经常会绕多得多的圈子。你应当首选编写新的和干净的语法,不仅为你自己,也为了开发团队的其他的成员。
  • 如果你仅为老版本浏览器转译,而给最新的浏览器提供新语法,那么你就可以利用浏览器对新语法进行的性能优化。这也让浏览器制造商有更多真实世界的代码来测试它们的实现和优化方法。
  • 提早使用新语法可以允许它在真实世界中被测试得更加健壮,这给 JavaScript 协会(TC39)提供了更早的反馈。如果问题被发现的足够早,他们就可以在那些语言设计错误变得无法挽回之前改变/修改它。

这是一个转译的简单例子。ES6 增加了一个称为“默认参数值”的新特性。它看起来像是这样:

function foo(a = 2) {
    console.log( a );
}

foo();        // 2
foo( 42 );    // 42

简单,对吧?也很有用!但是这种新语法在前 ES6 引擎中是不合法的。那么转译器将会对这段代码做什么才能使它在老版本环境中运行呢?

function foo() {
    var a = arguments[0] !== (void 0) ? arguments[0] : 2;
    console.log( a );
}

如你所见,它检查arguments[0]值是否是void 0(也就是undefined),而且如果是,就提供默认值2;否则,它就赋值被传递的任何东西。

除了可以现在就在老版本浏览器中使用更好的语法以外,观察转译后的代码实际上更清晰地解释了意图中的行为。

仅从 ES6 版本的代码看来,你可能还不理解undefined是唯一不能作为参数默认值的明确传递的值,但是转译后的代码使这一点清楚的多。

关于转译要强调的最后一个细节是,现在它们应当被认为是 JS 开发的生态系统和过程中的标准部分。JS 将继续以比以前快得多的速度进化,所以每几个月就会有新语法和新特性被加入进来。

如果你默认地使用一个转译器,那么你将总是可以在你发现新语法有用时,立即开始使用它,而不必为了让今天的浏览器被淘汰而等上好几年。

有好几个了不起的转译器供你选择。这是一些在本书写作时存在的好选择:

非 JavaScript

至此,我们讨论过的所有东西都限于 JS 语言本身。现实是大多数 JS 程序都是在浏览器这样的环境中运行并与之互动的。你在你的代码中编写的很大一部分东西,严格地说,不是直接由 JavaScript 控制的。这听起来可能有点奇怪。

你将会遇到的最常见的非 JavaScript 程序是 DOM API。例如:

var el = document.getElementById( "foo" );

当你的代码运行在一个浏览器中时,变量document作为一个全局变量存在。它不是由 JS 引擎提供的,也不为 JavaScript 语言规范所控制。它采取了某种与普通 JSobject极其相似的形式,但它不是真正的object。它是一种特殊的object,经常被称为“宿主对象”。

另外,document上的getElementById(..)方法看起来像一个普通的 JS 函数,但它只是一个微微暴露出来的接口,指向由浏览器 DOM 提供的内建方法。在一些(新一代的)浏览器中,这一层可能也是由 JS 实现的,但是传统的 DOM 和它的行为是由像 C/C++这样的语言实现的。

另一个例子是输入/输出(I/O)。

大家最喜爱的alert(..)在用户的浏览器窗口中弹出一个消息框。alert(..)是由浏览器提供给你的 JS 程序的,而不是 JS 引擎本身。你进行的调用将消息发送给浏览器内部,它来处理消息框的绘制与显示。

console.log()也一样;你的浏览器提供这样的机制并将它们挂在开发者工具中。

这本书,和整个这个系列,聚焦于 JavaScript 这种语言。这就是为什么你看不到任何涵盖这些非 JavaScript 机制的重要内容。不管怎样,你需要小心它们,因为它们将在你写的每一个 JS 程序中存在!

复习

学习 JavaScript 风格编程的第一步是对它的核心机制有一个基本的了解,比如值,类型,函数闭包,this,和原型。

当然,这些话题中的每一个都会衍生出比你在这里见到的多得多的内容,这也是为什么它们在这个系列剩下的部分中拥有自己的章节和书目。在你对本章中的概念和代码示例感到相当适应之后,这个系列的其他部分正等着你真正地深入挖掘和了解这门语言。

这本书的最后一章将会对这个系列的每一卷的内容,以及它们所涵盖的我们在这里还没有探索过的概念,进行简单地总结。

你不懂 JS:入门与进阶 第三章:进入 YDKJS

这个系列丛书到底是为了什么?简单地说,它的目的是认真地学习 JavaScript 的所有部分,不仅是这门语言的某些人称之为“好的部分”的子集,也不仅是让你在工作中搞定任务所需的最小部分的知识。

其他语言中,认真的开发者总是希望努力学习他们主要使用的语言的大部分或全部,但是 JS 开发者由于通常不太学习这门语言而在人群中显得很扎眼。这不是一件好事,而且我们也不应当继续将之视为常态。

你不懂 JSYDKJS)系列的立场是与学习 JS 的通常方式形成鲜明的对比,而且与你将会读到的其他 JS 书籍不同。它挑战你超越自己的舒适区,对每一个你遇到的行为问一个更深入的“为什么”。你准备好接受挑战了吗?

我将用这最后一章的篇幅来简要地总结一下这个系列其他书目的内容,和如何在 YDKJS 的基础上最有效地建立学习 JS 的基础。

作用域与闭包

也许你需要快速接受的基础之一,就是在 JavaScript 中变量的作用域是如何工作的。关于作用域仅有传闻中的模糊 观念 是不够的。

作用域与闭包 从揭穿常见的误解开始:JS 是“解释型语言”因此是不被编译的。不对。

JS 引擎在你的代码执行的前一刻(有时是在执行期间!)编译它。所以我们首先深入了解编译器处理我们代码的方式,以此来理解它如何找到并处理变量和函数的声明。沿着这条道路,我们将见到 JS 变量作用域管理的特有隐喻,“提升”。

对“词法作用域”的极其重要的理解,是我们在这本书最后一章探索闭包时所需的基石。闭包也许是 JS 所有的概念中最重要的一个,但如果你没有首先牢牢把握住作用域的工作方式,那么闭包将很可能依然不在你的掌握之中。

闭包的一个重要应用是模块模式,正如我们在本书第二章中简要介绍过的那样。模块模式也许是 JavaScript 的所有代码组织模式中最流行的一种;深刻理解它应当是你的首要任务之一。

this 与对象原型

也许关于 JavaScript 传播得最广泛和持久的谬误之一是认为this关键字指代它所出现的函数。可怕的错误。

this关键字是根据函数如何被执行而动态绑定的,而事实上有四种简单的规则可以用来理解和完全决定this绑定。

this密切相关的是对象原型属性,它是一种属性的查询链,与查询词法作用域变量的方式相似。但是原型中包含的是另一个关于 JS 的巨大谬误:模拟(山寨)类和继承(所谓的“原型继承”)的想法。

不幸的是,渴望将类和继承的设计模式思想带入 JavaScript 只是你能做的最差劲儿的事情,因为虽然语法可能欺骗你,使你认为有类这样的东西存在,但实际上原型机制在行为上是根本相反的。

目前的问题是,是忽略这种错位并假装你实现的是“继承”更好,还是学习并接纳对象原型系统实际的工作方式更恰当。后者被称为“行为委托”更合适。

这不光是语法上的偏好问题。委托是一种完全不同的,更强大的设计模式,其中的原因之一就是它取代了使用类和继承进行设计的需要。但是对于以谈论 JavaScript 的一生为主题的几乎所有的其他博客,书籍,和论坛来说,这些断言绝对是打脸的。

我对委托和继承做出的宣言不是源于对语言和其语法的厌恶,而是来自于渴望看到这门语言的真实力量被正确地利用,渴望看到无尽的困惑与沮丧被一扫而光。

但是我举出的关于原型和委托的例子可要比我在这里乱说的东西复杂得多。如果你准备好重新思考你认为你所了解的关于 JavaScript“类”和“继承”的一切,我给你一个机会来“服用红色的药丸”,并且看一看本系列的 this 与对象原型 的第四到六章。

类型与文法

这个系列的第三本书主要集中于解决另一个极具争议的话题:类型强制转换。也许没有什么话题能比你谈论隐含的强制转换造成的困惑更能使 JS 开发者感到沮丧了。

到目前为止,惯例的智慧说隐含强制转换是这门语言的“坏的部分”,并且应当不计一切避免它。事实上,有些人已经到了将它称为语言设计的“缺陷”的地步了。确实存在这么一些工具,它们的全部工作就是扫描你的代码,并在你进行任何强制转换,甚至是做有些像强制转换的事情时报警。

但是强制转换真的如此令人困惑,如此的坏,如此的不可信,以至于只要你使用它,你的代码从一开始就灭亡了吗?

我说不。在第一到三章中建立了对类型和值真正的工作方式的理解后,第四章参与了这个辩论,并从强制转换的角落和缝隙全面地讲解它的工作方式。我们将看到强制转换的哪一部分真的令人惊讶,而且如果花时间去学习,哪一部分实际上完全是合理的。

但我不仅仅要说强制转换是合理的和可以学习的,我断言强制转换是一种 你应当在代码中使用的 极其有用而且完全被低估的工具。我要说在合理使用的情况下,强制转换不仅可以工作,而且会使你的代码更好。所有唱反调的和怀疑的人当然会嘲笑这样的立场,但我相信它是让你玩儿好 JS 游戏的主要按键之一。

你是想继续人云亦云,还是想将所有的臆测放在一边,用一个全新的视角观察强制转换?这个系列的 类型与文法 将会强制转换你的想法。

异步与性能

这个系列的前三本书聚焦于这门语言的核心技术,但是第四本书稍稍开出一个分支来探讨在这门语言技术之上的管理异步编程的模式。异步不仅对于性能和我们的应用程序很关键,而且它日渐成为改进可写性和可维护性的关键因素。

这本书从搞清楚许多令人困惑的术语和概念开始,比如“异步”,“并行”和“并发”。而且深入讲解了这些东西如何适用和不适用于 JS。

然后我们继续检视作为开启异步的主要方法:回调。但我们很快就会看到,对于现代异步编程的需求来说,单靠回调自身是远远不够的。我们将找出仅使用回调编码的两种主要的不足之处:控制反转(IoC)信任丢失和缺乏线性的可推理性。

为了解决这两种主要的不足,ES6 引入了两种新的机制(实际上也是模式):promise 和 generator。

Prmise 是一个“未来值”的一种与时间无关的包装,它让你推理并组合这些未来值而不必关心它们是否已经准备好。另外,它们通过将回调沿着一个可信赖和可组装的 promise 机制传递,有效地解决了 IoC 信任问题。

Generator 给 JS 函数引入了一种新的执行模式,generator 可以在yield点被暂停而稍后异步地被继续。这种“暂停-继续”的能力让 generator 在幕后异步地被处理,使看起来同步,顺序执行的代码成为可能。如此,我们就解决了回调的非线性,非本地跳转的困惑,并因此使我们的异步代码看起来是更容易推理的同步代码。

但是,是 promise 与 generator 的组合给了我们 JavaScript 中最有效的异步代码模式。事实上,在即将到来的 ES7 与之后的版本中,大多数精巧的异步性肯定会建立在这个基础之上。为了认真地在一个异步的世界中高效地编程,你将需要对 promise 与 generator 的组合十分适应。

如果 promise 和 generator 是关于表达一些模式,这些模式让你的程序更加并发地运行,而因此在更短的时间内完成更多的处理,那么 JS 在性能优化上就拥有许多其他的方面值得探索。

第五章钻研的话题是使用 Web Worker 的程序并行性和使用 SIMD 的数据并行性,以及像 ASM.js 这样的底层优化技术。第六章从正确的基准分析技术的角度来观察性能优化,包括什么样的性能值得关心而什么应当忽略。

高效地编写 JavaScript 意味着编写的代码可以突破这种限制壁垒:在范围广泛的浏览器和其他环境中动态运行。这需要我们进行更多复杂的详细计划与努力,才能使一个程序从“可以工作”到“工作得很好”。

给你编写合理且高效的 JavaScript 代码所需的全部工具与技能,异步与性能 就是为此而设计的。

ES6 与未来

至此,无论你感觉自己已经将 JavaScript 掌握的多么好,现实是 JavaScript 从来没有停止过进化,而且进化的频率正在飞快地增长。这个事实几乎就是本系列精神的含义,拥抱我们永远不会完全 懂得 的 JS 的所有部分,因为只要你掌握了它的全部,就会有你需要学习的新的东西到来。

这本书专注于这门语言在中短期的发展前景,不仅是像 ES6 这样 已知的 东西,还包括在未来 可能的 东西。

虽然这个系列的所有书目采纳的是在编写它们时 JavaScript 的状态,也就是 ES6 正在被接纳的半途中,但是这个系列更主要地集中于 ES5。现在我们想要将注意力转移到 ES6,ES7,和……

因为在编写本书时 ES6 已经近于完成,ES6 与未来 首先将 ES6 中确定的东西分割为几个关键的范畴,包括新的语法,新的数据结构(集合),和新的处理能力以及 API。我们将在各种细节的层面讲解这些新的 ES6 特性中的每一个,包括复习我们在本系列的其他书目中遇到过的细节。

这是一些值得一读的激动人心的 ES6 特性:解构,参数默认值,symbol,简洁方法,计算属性,箭头函数,块儿作用域,promise,generator,iterator,模块,代理,weakmap,以及很多,很多别的东西!呼,ES6 真是不容小觑!

这本书的第一部分是一张路线图,为了对你将要在以后几年中编写和探索的新改进的 JavaScript 做好准备,它指明了你需要学习的所有东西。

这本书稍后的部分将注意力转向简要地介绍一些我们将在近未来可能看到的 JavaScript 的新东西。在这里最重要的是,要理解在后 ES6 时代,JS 很可能将会一个特性一个特性地进化,而不是一个版本一个版本地进化,这意味着我们将在比你想象的早得多的时候,看到这些近未来的到来。

JavaScript 的未来是光明的。这不正是我们开始学习它好时机吗!?

复习

YDKJS 系列投身于这样的命题:所有的 JS 开发者都可以,也应该学习这门伟大语言的每一部分。没有任何个人意见,没有任何框架的设想,没有任何项目的期限可以作为你从没有学习和深入理解 JavaScript 的借口。

我们聚焦这门语言中的每一个重要领域,为之专著一本很短但是内容非常稠密的书,来全面地探索它的 —— 你也许认为自己知道但可能并不全面 —— 所有部分。

“你不懂 JS”不是一种批评或羞辱。它是我们所有人,包括我自己,都必须正视的一种现实。学习 JavaScript 不是一个最终目标,而是一个过程。我们还不懂 JavaScript。但是我们会的!

你不懂 JS:ES6 与未来

来源:你不懂 JS:ES6 与未来

你不懂 JS:ES6 与未来 第一章:ES?现在与未来

在你一头扎进这本书之前,你应当可以熟练地使用(在本书写作时)最近版本的 JavaScript,也就是通常所说的 ES5(技术上讲是 ES 5.1)。这里,我们打算好好谈谈即将到来的 ES6,同时放眼未来去看看 JS 将会如何继续进化。

如果你还在 JavaScript 上寻找信心,我强烈推荐你首先读一读本系列的其他书目:

  • 入门与进阶:你是编程和 JS 的新手吗?这就是你在开启学习的旅程前需要查看的路线图。
  • 作用域与闭包:你知道 JS 的词法作用域是基于编译器(不是解释器!)语义的吗?你能解释闭包是如何成为词法作用域和函数作为值的直接结果的吗?
  • this 与对象原型:你能复述this绑定的四个简单规则吗?你有没有曾经在 JS 中对付着去山寨“类”,而不是采取更简单的“行为委托”设计模式?你听说过 链接到其他对象的对象 (OOLO)吗?
  • 类型与文法:你知道 JS 中的内建类型吗?更重要的是,你知道如何在类型之间正确且安全地使用强制转换吗?你对 JS 文法/语法的微妙之处感到有多习惯?
  • 异步与性能:你还在使用回调管理你的异步处理吗?你能解释 promise 是为什么/如何解决了“回调地狱”的吗?你知道如何使用 generator 来改进异步代码的易读性吗?到底是什么构成了 JS 程序和独立操作的成熟优化?

如果你已经读过了这些书目而且对它们涵盖的内容感到十分轻松,那么现在是时候让我们深入 JS 的进化过程来探索所有即将到来的以及未来会发生的改变了。

与 ES5 不同,ES6 不仅仅是向语言添加的一组不算太多的新 API。它包含大量的新的语法形式,其中的一些你可能会花上相当一段时间才能适应。还有几种新的组织形式和为各种数据类型添加的新 API。

对这门语言来说 ES6 十分激进。就算你认为你懂得 ES5 的 JS,ES6 也满是 你还不懂的 新东西,所以做好准备!这本书探索所有你需要迅速掌握的 ES6 主要主题,并且窥见一下那些你应当注意的正在步入正轨的未来特性。

警告: 这本书中的所有代码都假定运行在 ES6+的环境中。在写作本书时,浏览器和 JS 环境(比如 Node.js)对 ES6 的支持相当不同,因此你的感觉可能将会不同。

版本

JavaScript 标准在官方上被称为“ECMAScript”(缩写为“ES”),而且直到最近才刚刚完全采用顺序数字来标记版本(例如,“5”代表“第五版”)。

最早的版本,ES1 和 ES2,并不广为人知也没有大范围地被实现。ES3 是 JavaScript 第一次广泛传播的基准线,并且构成了像 IE6-8 和更早的 Android 2.x 移动浏览器的 JavaScript 标准。由于一些超出我们讨论范围的政治原因,命运多舛的 ES4 从未问世。

在 2009 年,ES5 正式定稿(在 2011 年出现了 ES5.1),它在浏览器的现代革新和爆发性增长(比如 Firefox,Chrome,Opera,Safari,和其他许多)中广泛传播,并作为 JS 标准稳定下来。

预计下一个版本的 JS(从 2013 年到 2014 年和之后的 2015 年中的内容),在人们的讨论中显然地经常被称为 ES6。

然而,在 ES6 规范的晚些时候,有建议提及未来的版本号也许会切换到编年制,比如用 ES2016(也叫 ES7)来指代在 2016 年末之前被定稿的任何版本。有些人对此持否定意见,但是相对于后来的 ES2015 来说,ES6 将很可能继续维持它占统治地位的影响力。可是,ES2016 事实上可能标志了新的编年制。

还可以看到,JS 进化的频度即使与一年一度的定版相比都要快得多。只要一个想法开始标准化讨论的进程,浏览器就开始为这种特性建造原型,而且早期的采用者就开始在代码中进行实验。

通常在一个特性被盖上官方承认的印章以前,由于这些早期的引擎/工具的原型它实际上已经被标准化了。所以也可以认为未来的 JS 版本将是一个特性一个特性的更新,而非一组主要特性的随意集合的更新(就像现在),也不是一年一年的更新(就像可能将变成的那样)。

简而言之,版本号不再那么重要了,JavaScript 开始变得更像一个常青的,活的标准。应对它的最佳方法是,举例来说,不再将你的代码库认为是“基于 ES6”的,而是考虑它支持的一个个特性。

转译

由于特性的快速进化,给开发者们造成了一个糟糕的问题,他们强烈地渴望立即使用新特性,而同时被被现实打脸 —— 他们的网站/app 需要支持那些不支持这些特性的老版本浏览器。

在整个行业中 ES5 的方式似乎已经无力回天了,它典型的思维模式是,代码库等待几乎所有的前 ES5 环境从它们的支持谱系中除名之后才开始采用 ES5。结果呢,许多人最近(在本书写作时)才开始采用strict模式这样的东西,而它早在五年前就在 ES5 中定稿了。

对于 JS 生态系统的未来来说,等待和落后于语言规范那么多年被广泛地认为是一种有害的方式。所有负责推动语言演进的人都渴望这样的事情;只要新的特性和模式以规范的形式稳定下来,并且浏览器有机会实现它们,开发者就开始基于这些新的特性和模式进行编码。

那么我们如何解决这个看起来似乎矛盾的问题?答案是工具,特别是一种称为 转译(transpiling) 的技术(转换+编译)。大致上,它的想法是使用一种特殊的工具将你的 ES6 代码转换为可以在 ES5 环境中工作的等价物(或近似物!)。

例如,考虑属性定义缩写(见第二章的“对象字面扩展”)。这是 ES6 的形式:

var foo = [1,2,3];

var obj = {
    foo        // 意思是 `foo: foo`
};

obj.foo;    // [1,2,3]

这(大致)是它如何被转译:

var foo = [1,2,3];

var obj = {
    foo: foo
};

obj.foo;    // [1,2,3]

这是一个微小但令人高兴的转换,它让我们在一个对象字面声明中将foo: foo缩写为foo,如果名称相同的话。

转译器为你实施这些变形,这个过程通常是构建工作流的一个步骤 —— 与你进行 linting,压缩,和其他类似操作相似。

填补(Shims/Polyfills)

不是所有的 ES6 新特性都需要转译器。填补(也叫 shims)是一种模式,在可能的情况下,它为一个新环境的行为定义一个可以在旧环境中运行的等价行为。语法是不能填补的,但是 API 经常是可以的。

例如,Object.is(..)是一个用来检查两个值严格等价性的新工具,它不带有===对于NaN-0值的那种微妙的例外。Object.is(..)的填补相当简单:

if (!Object.is) {
    Object.is = function(v1, v2) {
        // 测试 `-0`
        if (v1 === 0 && v2 === 0) {
            return 1 / v1 === 1 / v2;
        }
        // 测试 `NaN`
        if (v1 !== v1) {
            return v2 !== v2;
        }
        // 其他的一切情况
        return v1 === v2;
    };
}

提示:注意外部的if语句守护性地包围着填补的内容。这是一个重要的细节,它意味着这个代码段仅仅是为这个 API 还未定义的老环境而定义的后备行为;你想要覆盖既存 API 的情况是非常少见的。

有一个被称为“ES6 Shim”(github.com/paulmillr/es6-shim/)的了不起的 ES6 填补集合,你绝对应该将它采纳为任何新 JS 项目的标准组成部分!

看起来 JS 将会继续一往无前的进化下去,同时浏览器也会持续地小步迭代以支持新特性,而不是大块大块地更新。所以跟上时代的最佳策略就是在你的代码库中引入填补,并在你的构建流程中引入一个转译器步骤,现在就开始习惯新的现实。

如果你决定维持现状,等待不支持新特性的所有浏览器都消失才开始使用新特性,那么你将总是落后于时代。你将可悲地错过所有新发明的设计 —— 而它们使编写 JavaScript 更有效,更高效,而且更健壮。

复习

ES6(有些人可能会称它为 ES2015)在本书写作时刚刚定稿,它包含许多你需要学习的新东西!

但更重要的是,它将你的思维模式与 JavaScript 新的进化方式相接轨。不是仅仅为了等待某些官方文档投票通过而耗上许多年,就像以前许多人做的那样。

现在,JavaScript 特性一准备好就会在浏览器中实现,由你来决定是否现在就搭上早班车,还是去玩儿代价不菲的追车游戏。

不管未来的 JavaScript 采用什么样的标签,它都将会以比以前快得多的速度前进。为了使你位于在这门语言前进方向上的最前列,转译和填补是不可或缺的工具。

如果说对于 JavaScript 的新现实有什么重要的事情需要理解,那就是所有的 JS 开发者都被强烈地恳求从落后的一端移动到领先的一段。而学习 ES6 就是这一切的开端!

你不懂 JS:ES6 与未来 第二章:语法(上)

如果你曾经或多或少地写过 JS,那么你很可能对它的语法感到十分熟悉。当然有一些奇怪之处,但是总体来讲这是一种与其他语言有很多相似之处的,相当合理而且直接的语法。

然而,ES6 增加了好几种需要费些功夫才能习惯的新语法形式。在这一章中,我们将遍历它们来看看葫芦里到底卖的什么药。

提示: 在写作本书时,这本书中所讨论的特性中的一些已经被各种浏览器(Firefox,Chrome,等等)实现了,但是有一些仅仅被实现了一部分,而另一些根本就没实现。如果直接尝试这些例子,你的体验可能会夹杂着三种情况。如果是这样,就使用转译器尝试吧,这些特性中的大多数都被那些工具涵盖了。ES6Fiddle(www.es6fiddle.net/)是一个了不起的尝试 ES6 的游乐场,简单易用,它是一个 Babel 转译器的在线 REPL(http://babeljs.io/repl/)。

块儿作用域声明

你可能知道在 JavaScript 中变量作用域的基本单位总是function。如果你需要创建一个作用域的块儿,除了普通的函数声明以外最流行的方法就是使用立即被调用的函数表达式(IIFE)。例如:

var a = 2;

(function IIFE(){
    var a = 3;
    console.log( a );    // 3
})();

console.log( a );        // 2

let声明

但是,现在我们可以创建绑定到任意的块儿上的声明了,它(勿庸置疑地)称为 块儿作用域。这意味着一对{ .. }就是我们用来创建一个作用域所需要的全部。var总是声明附着在外围函数(或者全局,如果在顶层的话)上的变量,取而代之的是,使用let

var a = 2;

{
    let a = 3;
    console.log( a );    // 3
}

console.log( a );        // 2

迄今为止,在 JS 中使用独立的{ .. }块儿不是很常见,也不是惯用模式,但它总是合法的。而且那些来自拥有 块儿作用域 的语言的开发者将很容易认出这种模式。

我相信使用一个专门的{ .. }块儿是创建块儿作用域变量的最佳方法。但是,你应该总是将let声明放在块儿的最顶端。如果你有多于一个的声明,我推荐只使用一个let

从文体上说,我甚至喜欢将let放在与开放的{的同一行中,以便更清楚地表示这个块儿的目的仅仅是为了这些变量声明作用域。

{    let a = 2, b, c;
    // ..
}

它现在看起来很奇怪,而且不大可能与其他大多数 ES6 文献中推荐的文法吻合。但我的疯狂是有原因的。

这是另一种实验性的(不是标准化的)let声明形式,称为let块儿,看起来就像这样:

let (a = 2, b, c) {
    // ..
}

我称这种形式为 明确的 块儿作用域,而与var相似的let声明形式更像是 隐含的,因为它在某种意义上劫持了它所处的{ .. }。一般来说开发者们认为 明确的 机制要比 隐含的 机制更好一些,我主张这种情况就是这样的情况之一。

如果你比较前面两个形式的代码段,它们非常相似,而且我个人认为两种形式都有资格在文体上称为 明确的 块儿作用域。不幸的是,两者中最 明确的 let (..) { .. }形式没有被 ES6 所采用。它可能会在后 ES6 时代被重新提起,但我想目前为止前者是我们的最佳选择。

为了增强对let ..声明的 隐含 性质的理解,考虑一下这些用法:

let a = 2;

if (a > 1) {
    let b = a * 3;
    console.log( b );        // 6

    for (let i = a; i <= b; i++) {
        let j = i + 10;
        console.log( j );
    }
    // 12 13 14 15 16

    let c = a + b;
    console.log( c );        // 8
}

不要回头去看这个代码段,小测验:哪些变量仅存在于if语句内部?哪些变量仅存在于for循环内部?

答案:if语句包含块儿作用域变量bc,而for循环包含块儿作用域变量ij

你有任何迟疑吗?i没有被加入外围的if语句的作用域让你惊讶吗?思维上的停顿和疑问 —— 我称之为“思维税” —— 不仅源自于let机制对我们来说是新东西,还因为它是 隐含的

还有一个灾难是let c = ..声明出现在作用域中太过靠下的地方。传统的被var声明的变量,无论它们出现在何处,都会被附着在整个外围的函数作用域中;与此不同的是,let声明附着在块儿作用域,而且在它们出现在块儿中之前是不会被初始化的。

在一个let ..声明/初始化之前访问一个用let声明的变量会导致一个错误,而对于var声明来说这个顺序无关紧要(除了文体上的区别)。

考虑如下代码:

{
    console.log( a );    // undefined
    console.log( b );    // ReferenceError!

    var a;
    let b;
}

警告: 这个由于过早访问被let声明的引用而引起的ReferenceError在技术上称为一个 临时死区(Temporal Dead Zone —— TDZ) 错误 —— 你在访问一个已经被声明但还没被初始化的变量。这将不是我们唯一能够见到 TDZ 错误的地方 —— 在 ES6 中它们会在几种地方意外地发生。另外,注意“初始化”并不要求在你的代码中明确地赋一个值,比如let b;是完全合法的。一个在声明时没有被赋值的变量被认为已经被赋予了undefined值,所以let b;let b = undefined;是一样的。无论是否明确赋值,在let b语句运行之前你都不能访问b

最后一个坑:对于 TDZ 变量和未声明的(或声明的!)变量,typeof的行为是不同的。例如:

{
    // `a` 没有被声明
    if (typeof a === "undefined") {
        console.log( "cool" );
    }

    // `b` 被声明了,但位于它的 TDZ 中
    if (typeof b === "undefined") {        // ReferenceError!
        // ..
    }

    // ..

    let b;
}

a没有被声明,所以typeof是检查它是否存在的唯一安全的方法。但是typeof b抛出了 TDZ 错误,因为在代码下面很远的地方偶然出现了一个let b声明。噢。

现在你应当清楚为什么我坚持认为所有的let声明都应该位于它们作用域的顶部了。这完全避免了偶然过早访问的错误。当你观察一个块儿,或任何块儿的开始部分时,它还更 明确 地指出这个块儿中含有什么变量。

你的块儿(if语句,while循环,等等)不一定要与作用域行为共享它们原有的行为。

这种明确性要由你负责,由你用毅力来维护,它将为你省去许多重构时的头疼和后续的麻烦。

注意: 更多关于let和块儿作用域的信息,参见本系列的 作用域与闭包 的第三章。

let + for

我偏好 明确 形式的let声明块儿,但对此的唯一例外是出现在for循环头部的let。这里的原因看起来很微妙,但我相信它是更重要的 ES6 特性中的一个。

考虑如下代码:

var funcs = [];

for (let i = 0; i < 5; i++) {
    funcs.push( function(){
        console.log( i );
    } );
}

funcs[3]();        // 3

for头部中的let i不仅是为for循环本身声明了一个i,而且它为循环的每一次迭代都重新声明了一个新的i。这意味着在循环迭代内部创建的闭包都分别引用着那些在每次迭代中创建的变量,正如你期望的那样。

如果你尝试在这段相同代码的for循环头部使用var i,那么你会得到5而不是3,因为在被引用的外部作用域中只有一个i,而不是为每次迭代的函数都有一个i被引用。

你也可以稍稍繁冗地实现相同的东西:

var funcs = [];

for (var i = 0; i < 5; i++) {
    let j = i;
    funcs.push( function(){
        console.log( j );
    } );
}

funcs[3]();        // 3

在这里,我们强制地为每次迭代都创建一个新的j,然后闭包以相同的方式工作。我喜欢前一种形式;那种额外的特殊能力正是我支持for(let .. ) ..形式的原因。可能有人会争论说它有点儿 隐晦,但是对我的口味来说,它足够 明确 了,也足够有用。

letfor..infor..of(参见“for..of循环”)循环中也以形同的方式工作。

const声明

还有另一种需要考虑的块儿作用域声明:const,它创建 常量

到底什么是一个常量?它是一个在初始值被设定后就成为只读的变量。考虑如下代码:

{
    const a = 2;
    console.log( a );    // 2

    a = 3;                // TypeError!
}

变量持有的值一旦在声明时被设定就不允许你改变了。一个const声明必须拥有一个明确的初始化。如果想要一个持有undefined值的 常量,你必须声明const a = undefined来得到它。

常量不是一个作用于值本身的制约,而是作用于变量对这个值的赋值。换句话说,值不会因为const而冻结或不可变,只是它的赋值被冻结了。如果这个值是一个复杂值,比如对象或数组,那么这个值的内容仍然是可以被修改的:

{
    const a = [1,2,3];
    a.push( 4 );
    console.log( a );        // [1,2,3,4]

    a = 42;                    // TypeError!
}

变量a实际上没有持有一个恒定的数组;而是持有一个指向数组的恒定的引用。数组本身可以自由变化。

警告: 将一个对象或数组作为常量赋值意味着这个值在常量的词法作用域消失以前是不能够被垃圾回收的,因为指向这个值的引用是永远不能解除的。这可能是你期望的,但如果不是你就要小心!

实质上,const声明强制实行了我们许多年来在代码中用文体来表明的东西:我们声明一个名称全由大写字母组成的变量并赋予它某些字面值,我们小心照看它以使它永不改变。var赋值没有强制性,但是现在const赋值上有了,它可以帮你发现不经意的改变。

const可以 被用于forfor..in,和for..of循环(参见“for..of循环”)的变量声明。然而,如果有任何重新赋值的企图,一个错误就会被抛出,例如在for循环中常见的i++子句。

const用还是不用

有些流传的猜测认为在特定的场景下,与letvar相比一个const可能会被 JS 引擎进行更多的优化。理论上,引擎可以更容易地知道变量的值/类型将永远不会改变,所以它可以免除一些可能的追踪工作。

无论const在这方面是否真的有帮助,还是这仅仅是我们的幻想和直觉,你要做的更重要的决定是你是否打算使用常量的行为。记住:源代码扮演的一个最重要的角色是为了明确地交流你的意图是什么,不仅是与你自己,而且还是与未来的你和其他的代码协作者。

一些开发者喜欢在一开始将每个变量都声明为一个const,然后当它的值在代码中有必要发生变化的时候将声明放松至一个let。这是一个有趣的角度,但是不清楚这是否真正能够改善代码的可读性或可推理性。

就像许多人认为的那样,它不是一种真正的 保护,因为任何后来的想要改变一个const值的开发者都可以盲目地将声明从const改为let。它至多是防止意外的改变。但是同样地,除了我们的直觉和感觉以外,似乎没有客观和明确的标准可以衡量什么构成了“意外”或预防措施。这与类型强制上的思维模式类似。

我的建议:为了避免潜在的令人糊涂的代码,仅将const用于那些你有意地并且明显地标识为不会改变的变量。换言之,不要为了代码行为而 依靠 const,而是在为了意图可以被清楚地表明时,将它作为一个表明意图的工具。

块儿作用域的函数

从 ES6 开始,发生在块儿内部的函数声明现在被明确规定属于那个块儿的作用域。在 ES6 之前,语言规范没有要求这一点,但是许多实现不管怎样都是这么做的。所以现在语言规范和现实吻合了。

考虑如下代码:

{
    foo();                    // 好用!

    function foo() {
        // ..
    }
}

foo();                        // ReferenceError

函数foo()是在{ .. }块儿内部被声明的,由于 ES6 的原因它是属于那里的块儿作用域的。所以在那个块儿的外部是不可用的。但是还要注意它在块儿里面被“提升”了,这与早先提到的遭受 TDZ 错误陷阱的let声明是相反的。

如果你以前曾经写过这样的代码,并依赖于老旧的非块儿作用域行为的话,那么函数声明的块儿作用域可能是一个问题:

if (something) {
    function foo() {
        console.log( "1" );
    }
}
else {
    function foo() {
        console.log( "2" );
    }
}

foo();        // ??

在前 ES6 环境下,无论something的值是什么foo()都将会打印"2",因为两个函数声明被提升到了块儿的顶端,而且总是第二个有效。

在 ES6 中,最后一行将抛出一个ReferenceError

扩散/剩余

ES6 引入了一个新的...操作符,根据你在何处以及如何使用它,它一般被称作 扩散(spread)剩余(rest) 操作符。让我们看一看:

function foo(x,y,z) {
    console.log( x, y, z );
}

foo( ...[1,2,3] );                // 1 2 3

...在一个数组(实际上,是我们将在第三章中讲解的任何的 可迭代 对象)前面被使用时,它就将数组“扩散”为它的个别的值。

通常你将会在前面所展示的那样的代码段中看到这种用法,它将一个数组扩散为函数调用的一组参数。在这种用法中,...扮演了apply(..)方法的简约语法替代品,在前 ES6 中我们经常这样使用apply(..)

foo.apply( null, [1,2,3] );        // 1 2 3

...也可以在其他上下文环境中被用于扩散/展开一个值,比如在另一个数组声明内部:

var a = [2,3,4];
var b = [ 1, ...a, 5 ];

console.log( b );                    // [1,2,3,4,5]

在这种用法中,...取代了concat(..),它在这里的行为就像[1].concat( a, [5] )

另一种...的用法常见于一种实质上相反的操作;与将值散开不同,...将一组值 收集 到一个数组中。

function foo(x, y, ...z) {
    console.log( x, y, z );
}

foo( 1, 2, 3, 4, 5 );            // 1 2 [3,4,5]

这个代码段中的...z实质上是在说:“将 剩余的 参数值(如果有的话)收集到一个称为z的数组中。” 因为x被赋值为1,而y被赋值为2,所以剩余的参数值34,和5被收集进了z

当然,如果你没有任何命名参数,...会收集所有的参数值:

function foo(...args) {
    console.log( args );
}

foo( 1, 2, 3, 4, 5);            // [1,2,3,4,5]

注意:foo(..)函数声明中的...args经常因为你向其中收集参数的剩余部分而被称为“剩余参数”。我喜欢使用“收集”这个词,因为它描述了它做什么而不是它包含什么。

这种用法最棒的地方是,它为被废弃了很久的arguments数组 —— 实际上它不是一个真正的数组,而是一个类数组对象 —— 提供了一种非常稳健的替代方案。因为args(无论你叫它什么 —— 许多人喜欢叫它r或者rest)是一个真正的数组,我们可以摆脱许多愚蠢的前 ES6 技巧,我们曾经通过这些技巧尽全力去使arguments变成我们可以视之为数组的东西。

考虑如下代码:

// 使用新的 ES6 方式
function foo(...args) {
    // `args`已经是一个真正的数组了

    // 丢弃`args`中的第一个元素
    args.shift();

    // 将`args`的所有内容作为参数值传给`console.log(..)`
    console.log( ...args );
}

// 使用老旧的前 ES6 方式
function bar() {
    // 将`arguments`转换为一个真正的数组
    var args = Array.prototype.slice.call( arguments );

    // 在末尾添加一些元素
    args.push( 4, 5 );

    // 过滤掉所有奇数
    args = args.filter( function(v){
        return v % 2 == 0;
    } );

    // 将`args`的所有内容作为参数值传给`foo(..)`
    foo.apply( null, args );
}

bar( 0, 1, 2, 3 );                    // 2 4

在函数foo(..)声明中的...args收集参数值,而在console.log(..)调用中的...args将它们扩散开。这个例子很好地展示了...操作符平行但相反的用途。

除了在函数声明中...的用法以外,还有另一种...被用于收集值的情况,我们将在本章稍后的“太多,太少,正合适”一节中检视它。

默认参数值

也许在 JavaScript 中最常见的惯用法之一就是为函数参数设置默认值。我们多年来一直使用的方法应当看起来很熟悉:

function foo(x,y) {
    x = x || 11;
    y = y || 31;

    console.log( x + y );
}

foo();                // 42
foo( 5, 6 );        // 11
foo( 5 );            // 36
foo( null, 6 );        // 17

当然,如果你曾经用过这种模式,你就会知道它既有用又有点儿危险,例如如果你需要能够为其中一个参数传入一个可能被认为是 falsy 的值。考虑下面的代码:

foo( 0, 42 );        // 53 <-- 噢,不是 42

为什么?因为0是 falsy,因此x || 11的结果为11,而不是直接被传入的0

为了填这个坑,一些人会像这样更加啰嗦地编写检查:

function foo(x,y) {
    x = (x !== undefined) ? x : 11;
    y = (y !== undefined) ? y : 31;

    console.log( x + y );
}

foo( 0, 42 );            // 42
foo( undefined, 6 );    // 17

当然,这意味着除了undefined以外的任何值都可以直接传入。然而,undefined将被假定是这样一种信号,“我没有传入这个值。” 除非你实际需要能够传入undefined,它就工作的很好。

在那样的情况下,你可以通过测试参数值是否没有出现在arguments数组中,来看它是否实际上被省略了,也许是像这样:

function foo(x,y) {
    x = (0 in arguments) ? x : 11;
    y = (1 in arguments) ? y : 31;

    console.log( x + y );
}

foo( 5 );                // 36
foo( 5, undefined );    // NaN

但是在没有能力传入意味着“我省略了这个参数值”的任何种类的值(连undefined也不行)的情况下,你如何才能省略第一个参数值x呢?

foo(,5)很诱人,但它不是合法的语法。foo.apply(null,[,5])看起来应该可以实现这个技巧,但是apply(..)的奇怪之处意味着这组参数值将被视为[undefined,5],显然它没有被省略。

如果你深入调查下去,你将发现你只能通过简单地传入比“期望的”参数值个数少的参数值来省略末尾的参数值,但是你不能省略在参数值列表中间或者开头的参数值。这就是不可能。

这里有一个施用于 JavaScript 设计的重要原则需要记住:undefined意味着 缺失。也就是,在undefined缺失 之间没有区别,至少是就函数参数值而言。

注意: 容易令人糊涂的是,JS 中有其他的地方不适用这种特殊的设计原则,比如带有空值槽的数组。更多信息参见本系列的 类型与文法

带着所有这些认识,现在我们可以检视在 ES6 中新增的一种有用的好语法,来简化对丢失的参数值进行默认值的赋值。

function foo(x = 11, y = 31) {
    console.log( x + y );
}

foo();                    // 42
foo( 5, 6 );            // 11
foo( 0, 42 );            // 42

foo( 5 );                // 36
foo( 5, undefined );    // 36 <-- `undefined`是缺失
foo( 5, null );            // 5  <-- null 强制转换为`0`

foo( undefined, 6 );    // 17 <-- `undefined`是缺失
foo( null, 6 );            // 6  <-- null 强制转换为`0`

注意这些结果,和它们如何暗示了与前面的方式的微妙区别和相似之处。

与常见得多的x || 11惯用法相比,在一个函数声明中的x = 11更像x !== undefined ? x : 11,所以在将你的前 ES6 代码转换为这种 ES6 默认参数值语法时要多加小心。

注意: 一个剩余/收集参数(参见“扩散/剩余”)不能拥有默认值。所以,虽然function foo(...vals=[1,2,3]) {看起来是一种迷人的能力,但它不是合法的语法。有必要的话你需要继续手动实施那种逻辑。

默认值表达式

函数默认值可以比像31这样的简单值复杂得多;它们可以是任何合法的表达式,甚至是函数调用:

function bar(val) {
    console.log( "bar called!" );
    return y + val;
}

function foo(x = y + 3, z = bar( x )) {
    console.log( x, z );
}

var y = 5;
foo();                                // "bar called"
                                    // 8 13
foo( 10 );                            // "bar called"
                                    // 10 15
y = 6;
foo( undefined, 10 );                // 9 10

如你所见,默认值表达式是被懒惰地求值的,这意味着他们仅在被需要时运行 —— 也就是,当一个参数的参数值被省略或者为undefined

这是一个微妙的细节,但是在一个函数声明中的正式参数是在它们自己的作用域中的(将它想象为一个仅仅围绕在函数声明的(..)外面的一个作用域气泡),不是在函数体的作用域中。这意味着在一个默认值表达式中的标识符引用会在首先在正式参数的作用域中查找标识符,然后再查找一个外部作用域。更多信息参见本系列的 作用域与闭包

考虑如下代码:

var w = 1, z = 2;

function foo( x = w + 1, y = x + 1, z = z + 1 ) {
    console.log( x, y, z );
}

foo();                    // ReferenceError

在默认值表达式w + 1中的w在正式参数作用域中查找w,但没有找到,所以外部作用域的w被使用了。接下来,在默认值表达式x + 1中的x在正式参数的作用域中找到了x,而且走运的是x已经被初始化了,所以对y的赋值工作的很好。

然而,z + 1中的z找到了一个在那个时刻还没有被初始化的参数变量z,所以它绝不会试着在外部作用域中寻找z

正如我们在本章早先的“let声明”一节中提到过的那样,ES6 拥有一个 TDZ,它会防止一个变量在它还没有被初始化的状态下被访问。因此,z + 1默认值表达式抛出一个 TDZReferenceError错误。

虽然对于代码的清晰度来说不见得是一个好主意,一个默认值表达式甚至可以是一个内联的函数表达式调用 —— 通常被称为一个立即被调用的函数表达式(IIFE):

function foo( x =
    (function(v){ return v + 11; })( 31 )
) {
    console.log( x );
}

foo();            // 42

一个 IIFE(或者任何其他被执行的内联函数表达式)作为默认值表示来说很合适是非常少见的。如果你发现自己试图这么做,那么就退一步再考虑一下!

警告: 如果一个 IIFE 试图访问标识符x,而且还没有声明自己的x,那么这也将是一个 TDZ 错误,就像我们刚才讨论的一样。

前一个代码段的默认值表达式是一个 IIFE,这是因为它是通过(31)在内联时立即被执行。如果我们去掉这一部分,赋予x的默认值将会仅仅是一个函数的引用,也许像一个默认的回调。可能有一些情况这种模式将十分有用,比如:

function ajax(url, cb = function(){}) {
    // ..
}

ajax( "http://some.url.1" );

这种情况下,我们实质上想在没有其他值被指定时,让默认的cb是一个没有操作的空函数。这个函数表达式只是一个函数引用,不是一个调用它自己(在它末尾没有调用的())以达成自己目的的函数。

从 JS 的早些年开始,就有一个少为人知但是十分有用的奇怪之处可供我们使用:Function.prototype本身就是一个没有操作的空函数。这样,这个声明可以是cb = Function.prototype而省去内联函数表达式的创建。

解构

ES6 引入了一个称为 解构 的新语法特性,如果你将它考虑为 结构化赋值 那么它令人困惑的程度可能会小一些。为了理解它的含义,考虑如下代码:

function foo() {
    return [1,2,3];
}

var tmp = foo(),
    a = tmp[0], b = tmp[1], c = tmp[2];

console.log( a, b, c );                // 1 2 3

如你所见,我们创建了一个手动赋值:从foo()返回的数组中的值到个别的变量ab,和c,而且这么做我们就(不幸地)需要tmp变量。

相似地,我们也可以用对象这么做:

function bar() {
    return {
        x: 4,
        y: 5,
        z: 6
    };
}

var tmp = bar(),
    x = tmp.x, y = tmp.y, z = tmp.z;

console.log( x, y, z );                // 4 5 6

属性值tmp.x被赋值给变量xtmp.yytmp.zz也一样。

从一个数组中取得索引的值,或从一个对象中取得属性并手动赋值可以被认为是 结构化赋值。ES6 为 解构 增加了一种专门的语法,具体地称为 数组解构对象结构。这种语法消灭了前一个代码段中对变量tmp的需要,使它们更加干净。考虑如下代码:

var [ a, b, c ] = foo();
var { x: x, y: y, z: z } = bar();

console.log( a, b, c );                // 1 2 3
console.log( x, y, z );                // 4 5 6

你很可能更加习惯于看到像[a,b,c]这样的东西出现在一个=赋值的右手边的语法,即作为要被赋予的值。

解构对称地翻转了这个模式,所以在=赋值左手边的[a,b,c]被看作是为了将右手边的数组拆解为分离的变量赋值的某种“模式”。

类似地,{ x: x, y: y, z: z }指明了一种“模式”把来自于bar()的对象拆解为分离的变量赋值。

对象属性赋值模式

让我们深入前一个代码段中的{ x: x, .. }语法。如果属性名与你想要声明的变量名一致,你实际上可以缩写这个语法:

var { x, y, z } = bar();

console.log( x, y, z );                // 4 5 6

很酷,对吧?

{ x, .. }是省略了x:部分还是省略了: x部分?当我们使用这种缩写语法时,我们实际上省略了x:部分。这看起来可能不是一个重要的细节,但是一会儿你就会了解它的重要性。

如果你能写缩写形式,那为什么你还要写出更长的形式呢?因为更长的形式事实上允许你将一个属性赋值给一个不同的变量名称,这有时很有用:

var { x: bam, y: baz, z: bap } = bar();

console.log( bam, baz, bap );        // 4 5 6
console.log( x, y, z );                // ReferenceError

关于这种对象结构形式有一个微妙但超级重要的怪异之处需要理解。为了展示为什么它可能是一个你需要注意的坑,让我们考虑一下普通对象字面量的“模式”是如何被指定的:

var X = 10, Y = 20;

var o = { a: X, b: Y };

console.log( o.a, o.b );            // 10 20

{ a: X, b: Y }中,我们知道a是对象属性,而X是被赋值给它的源值。换句话说,它的语义模式是目标: 源,或者更明显地,属性别名: 值。我们能直观地明白这一点,因为它和=赋值是一样的,而它的模式就是目标 = 源

然而,当你使用对象解构赋值时 —— 也就是,将看起来像是对象字面量的{ .. }语法放在=操作符的左手边 —— 你反转了这个目标: 源的模式。

回想一下:

var { x: bam, y: baz, z: bap } = bar();

这里面对称的模式是源: 目标(或者值: 属性别名)。x: bam意味着属性x是源值而ban是被赋值的目标变量。换句话说,对象字面量是target <-- source,而对象解构赋值是source --> target。看到它是如何反转的了吗?

有另外一种考虑这种语法的方式,可能有助于缓和这种困惑。考虑如下代码:

var aa = 10, bb = 20;

var o = { x: aa, y: bb };
var     { x: AA, y: BB } = o;

console.log( AA, BB );                // 10 20

{ x: aa, y: bb }这一行中,xy代表对象属性。在{ x: AA, y: BB }这一行,xy 代表对象属性。

还记得刚才我是如何断言{ x, .. }省去了x:部分的吗?在这两行中,如果你在代码段中擦掉x:y:部分,仅留下aa, bbAA, BB,它的效果 —— 从概念上讲,实际上不能 —— 将是从aa赋值到AA和从bb赋值到BB

所以,这种平行性也许有助于解释为什么对于这种 ES6 特性,语法模式被故意地反转了。

注意: 对于解构赋值来说我更喜欢它的语法是{ AA: x , BB: y },因为那样的话可以在两种用法中一致地使用我们更熟悉的target: source模式。唉,我已经被迫训练自己的大脑去习惯这种反转了,就像一些读者也不得不去做的那样。

不仅是声明

至此,我们一直将解构赋值与var声明(当然,它们也可以使用letconst)一起使用,但是解构是一种一般意义上的赋值操作,不仅是一种声明。

考虑如下代码:

var a, b, c, x, y, z;

[a,b,c] = foo();
( { x, y, z } = bar() );

console.log( a, b, c );                // 1 2 3
console.log( x, y, z );                // 4 5 6

变量可以是已经被定义好的,然后解构仅仅负责赋值,正如我们已经看到的那样。

注意: 特别对于对象解构形式来说,当我们省略了var/let/const声明符时,就必须将整个赋值表达式包含在()中,因为如果不这样做的话左手边作为语句第一个元素的{ .. }将被视为一个语句块儿而不是一个对象。

事实上,变量表达式(ay,等等)不必是一个变量标识符。任何合法的赋值表达式都是允许的。例如:

var o = {};

[o.a, o.b, o.c] = foo();
( { x: o.x, y: o.y, z: o.z } = bar() );

console.log( o.a, o.b, o.c );        // 1 2 3
console.log( o.x, o.y, o.z );        // 4 5 6

你甚至可以在解构中使用计算型属性名。考虑如下代码:

var which = "x",
    o = {};

( { [which]: o[which] } = bar() );

console.log( o.x );                    // 4

[which]:的部分是计算型属性名,它的结果是x —— 将从当前的对象中拆解出来作为赋值的源头的属性。o[which]的部分只是一个普通的对象键引用,作为赋值的目标来说它与o.x是等价的。

你可以使用普通的赋值来创建对象映射/变形,例如:

var o1 = { a: 1, b: 2, c: 3 },
    o2 = {};

( { a: o2.x, b: o2.y, c: o2.z } = o1 );

console.log( o2.x, o2.y, o2.z );    // 1 2 3

或者你可以将对象映射进一个数组,例如:

var o1 = { a: 1, b: 2, c: 3 },
    a2 = [];

( { a: a2[0], b: a2[1], c: a2[2] } = o1 );

console.log( a2 );                    // [1,2,3]

或者用另一种方式:

var a1 = [ 1, 2, 3 ],
    o2 = {};

[ o2.a, o2.b, o2.c ] = a1;

console.log( o2.a, o2.b, o2.c );    // 1 2 3

或者你可以将一个数组重排到另一个数组中:

var a1 = [ 1, 2, 3 ],
    a2 = [];

[ a2[2], a2[0], a2[1] ] = a1;

console.log( a2 );                    // [2,3,1]

你甚至可以不使用临时变量来解决传统的“交换两个变量”的问题:

var x = 10, y = 20;

[ y, x ] = [ x, y ];

console.log( x, y );                // 20 10

警告: 小心:你不应该将声明和赋值混在一起,除非你想要所有的赋值表达式 被视为声明。否则,你会得到一个语法错误。这就是为什么在刚才的例子中我必须将var a2 = [][ a2[0], .. ] = ..解构赋值分开做。尝试var [ a2[0], .. ] = ..没有任何意义,因为a2[0]不是一个合法的声明标识符;很显然它也不能隐含地创建一个var a2 = []声明来使用。

重复赋值

对象解构形式允许源属性(持有任意值的类型)被罗列多次。例如:

var { a: X, a: Y } = { a: 1 };

X;    // 1
Y;    // 1

这意味着你既可以解构一个子对象/数组属性,也可以捕获这个子对象/数组的值本身。考虑如下代码:

var { a: { x: X, x: Y }, a } = { a: { x: 1 } };

X;    // 1
Y;    // 1
a;    // { x: 1 }

( { a: X, a: Y, a: [ Z ] } = { a: [ 1 ] } );

X.push( 2 );
Y[0] = 10;

X;    // [10,2]
Y;    // [10,2]
Z;    // 1

关于解构有一句话要提醒:像我们到目前为止的讨论中做的那样,将所有的解构赋值都罗列在单独一行中的方式可能很诱人。然而,一个好得多的主意是使用恰当的缩进将解构赋值的模式分散在多行中 —— 和你在 JSON 或对象字面量中做的事非常相似 —— 为了可读性。

// 很难读懂:
var { a: { b: [ c, d ], e: { f } }, g } = obj;

// 好一些:
var {
    a: {
        b: [ c, d ],
        e: { f }
    },
    g
} = obj;

记住:解构的目的不仅是为了少打些字,更多是为了声明可读性

解构赋值表达式

带有对象或数组解构的赋值表达式的完成值是右手边完整的对象/数组值。考虑如下代码:

var o = { a:1, b:2, c:3 },
    a, b, c, p;

p = { a, b, c } = o;

console.log( a, b, c );            // 1 2 3
p === o;                        // true

在前面的代码段中,p被赋值为对象o的引用,而不是ab,或c的值。数组解构也是一样:

var o = [1,2,3],
    a, b, c, p;

p = [ a, b, c ] = o;

console.log( a, b, c );            // 1 2 3
p === o;                        // true

通过将这个对象/数组作为完成值传递下去,你可将解构赋值表达式链接在一起:

var o = { a:1, b:2, c:3 },
    p = [4,5,6],
    a, b, c, x, y, z;

( {a} = {b,c} = o );
[x,y] = [z] = p;

console.log( a, b, c );            // 1 2 3
console.log( x, y, z );            // 4 5 4

太多,太少,正合适

对于数组解构赋值和对象解构赋值两者来说,你不必分配所有出现的值。例如:

var [,b] = foo();
var { x, z } = bar();

console.log( b, x, z );                // 2 4 6

foo()返回的值13被丢弃了,从bar()返回的值5也是。

相似地,如果你试着分配比你正在解构/拆解的值要多的值时,它们会如你所想的那样安静地退回到undefined

var [,,c,d] = foo();
var { w, z } = bar();

console.log( c, z );                // 3 6
console.log( d, w );                // undefined undefined

这种行为平行地遵循早先提到的“undefined意味着缺失”原则。

我们在本章早先检视了...操作符,并看到了它有时可以用于将一个数组值扩散为它的分离值,而有时它可以被用于相反的操作:将一组值收集进一个数组。

除了在函数声明中的收集/剩余用法以外,...可以在解构赋值中实施相同的行为。为了展示这一点,让我们回想一下本章早先的一个代码段:

var a = [2,3,4];
var b = [ 1, ...a, 5 ];

console.log( b );                    // [1,2,3,4,5]

我们在这里看到因为...a出现在数组[ .. ]中值的位置,所以它将a扩散开。如果...a出现一个数组解构的位置,它会实施收集行为:

var a = [2,3,4];
var [ b, ...c ] = a;

console.log( b, c );                // 2 [3,4]

解构赋值var [ .. ] = a为了将a赋值给在[ .. ]中描述的模式而将它扩散开。第一部分的名称b对应a中的第一个值(2)。然后...c将剩余的值(34)收集到一个称为c的数组中。

注意: 我们已经看到...是如何与数组一起工作的,但是对象呢?那不是一个 ES6 特性,但是参看第八章中关于一种可能的“ES6 之后”的特性的讨论,它可以让...扩散或者收集对象。

默认值赋值

两种形式的解构都可以为赋值提供默认值选项,它使用和早先讨论过的默认函数参数值相似的=语法。

考虑如下代码:

var [ a = 3, b = 6, c = 9, d = 12 ] = foo();
var { x = 5, y = 10, z = 15, w = 20 } = bar();

console.log( a, b, c, d );            // 1 2 3 12
console.log( x, y, z, w );            // 4 5 6 20

你可以将默认值赋值与前面讲过的赋值表达式语法组合在一起。例如:

var { x, y, z, w: WW = 20 } = bar();

console.log( x, y, z, WW );            // 4 5 6 20

如果你在一个解构中使用一个对象或者数组作为默认值,那么要小心不要把自己(或者读你的代码的其他开发者)搞糊涂了。你可能会创建一些非常难理解的代码:

var x = 200, y = 300, z = 100;
var o1 = { x: { y: 42 }, z: { y: z } };

( { y: x = { y: y } } = o1 );
( { z: y = { y: z } } = o1 );
( { x: z = { y: x } } = o1 );

你能从这个代码段中看出xyz最终是什么值吗?花点儿时间好好考虑一下,我能想象你的样子。我会终结这个悬念:

console.log( x.y, y.y, z.y );        // 300 100 42

这里的要点是:解构很棒也可以很有用,但是如果使用得不明智,它也是一把可以伤人(某人的大脑)的利剑。

嵌套解构

如果你正在解构的值拥有嵌套的对象或数组,你也可以解构这些嵌套的值:

var a1 = [ 1, [2, 3, 4], 5 ];
var o1 = { x: { y: { z: 6 } } };

var [ a, [ b, c, d ], e ] = a1;
var { x: { y: { z: w } } } = o1;

console.log( a, b, c, d, e );        // 1 2 3 4 5
console.log( w );                    // 6

嵌套的解构可以是一种将对象名称空间扁平化的简单方法。例如:

var App = {
    model: {
        User: function(){ .. }
    }
};

// 取代:
// var User = App.model.User;

var { model: { User } } = App;

参数解构

你能在下面的代码段中发现赋值吗?

function foo(x) {
    console.log( x );
}

foo( 42 );

其中的赋值有点儿被隐藏的感觉:当foo(42)被执行时42(参数值)被赋值给x(参数)。如果参数/参数值对是一种赋值,那么按常理说它是一个可以被解构的赋值,对吧?当然!

考虑参数的数组解构:

function foo( [ x, y ] ) {
    console.log( x, y );
}

foo( [ 1, 2 ] );                    // 1 2
foo( [ 1 ] );                        // 1 undefined
foo( [] );                            // undefined undefined

参数也可以进行对象解构:

function foo( { x, y } ) {
    console.log( x, y );
}

foo( { y: 1, x: 2 } );                // 2 1
foo( { y: 42 } );                    // undefined 42
foo( {} );                            // undefined undefined

这种技术是命名参数值(一个长期以来被渴求的 JS 特性!)的一种近似解法:对象上的属性映射到被解构的同名参数上。这也意味着我们免费地(在任何位置)得到了可选参数,如你所见,省去“参数”x可以如我们期望的那样工作。

当然,先前讨论过的所有解构的种类对于参数解构来说都是可用的,包括嵌套解构,默认值,和其他。解构也可以和其他 ES6 函数参数功能很好地混合在一起,比如默认参数值和剩余/收集参数。

考虑这些快速的示例(当然这没有穷尽所有可能的种类):

function f1([ x=2, y=3, z ]) { .. }
function f2([ x, y, ...z], w) { .. }
function f3([ x, y, ...z], ...w) { .. }

function f4({ x: X, y }) { .. }
function f5({ x: X = 10, y = 20 }) { .. }
function f6({ x = 10 } = {}, { y } = { y: 10 }) { .. }

为了展示一下,让我们从这个代码段中取一个例子来检视:

function f3([ x, y, ...z], ...w) {
    console.log( x, y, z, w );
}

f3( [] );                            // undefined undefined [] []
f3( [1,2,3,4], 5, 6 );                // 1 2 [3,4] [5,6]

这里使用了两个...操作符,他们都是将值收集到数组中(zw),虽然...z是从第一个数组参数值的剩余值中收集,而...w是从第一个之后的剩余主参数值中收集的。

解构默认值 + 参数默认值

有一个微妙的地方你应当注意要特别小心 —— 解构默认值与函数参数默认值的行为之间的不同。例如:

function f6({ x = 10 } = {}, { y } = { y: 10 }) {
    console.log( x, y );
}

f6();                                // 10 10

首先,看起来我们用两种不同的方法为参数xy都声明了默认值10。然而,这两种不同的方式会在特定的情况下表现出不同的行为,而且这种区别极其微妙。

考虑如下代码:

f6( {}, {} );                        // 10 undefined

等等,为什么会这样?十分清楚,如果在第一个参数值的对象中没有一个同名属性被传递,那么命名参数x将默认为10

yundefined是怎么回事儿?值{ y: 10 }是一个作为函数参数默认值的对象,不是结构默认值。因此,它仅在第二个参数根本没有被传递,或者undefined被传递时生效,

在前面的代码段中,我们传递了第二个参数({}),所以默认值{ y: 10 }不被使用,而解构{ y }会针对被传入的空对象值{}发生。

现在,将{ y } = { y: 10 }{ x = 10 } = {}比较一下。

对于x的使用形式来说,如果第一个函数参数值被省略或者是undefined,会默认地使用空对象{}。然后,不管在第一个参数值的位置上是什么值 —— 要么是默认的{},要么是你传入的 —— 都会被{ x = 10 }解构,它会检查属性x是否被找到,如果没有找到(或者是undefined),默认值10会被设置到命名参数x上。

深呼吸。回过头去把最后几段多读几遍。让我们用代码复习一下:

function f6({ x = 10 } = {}, { y } = { y: 10 }) {
    console.log( x, y );
}

f6();                                // 10 10
f6( undefined, undefined );            // 10 10
f6( {}, undefined );                // 10 10

f6( {}, {} );                        // 10 undefined
f6( undefined, {} );                // 10 undefined

f6( { x: 2 }, { y: 3 } );            // 2 3

一般来说,与参数y的默认行为比起来,参数x的默认行为可能看起来更可取也更合理。因此,理解{ x = 10 } = {}形式与{ y } = { y: 10 }形式为何与如何不同是很重要的。

如果这仍然有点儿模糊,回头再把它读一遍,并亲自把它玩弄一番。未来的你将会感谢你花了时间把这种非常微妙的,晦涩的细节的坑搞明白。

嵌套默认值:解构与重构

虽然一开始可能很难掌握,但是为一个嵌套的对象的属性设置默认值产生了一种有趣的惯用法:将对象解构与一种我成为 重构 的东西一起使用。

考虑在一个嵌套的对象结构中的一组默认值,就像下面这样:

// 摘自:http://es-discourse.com/t/partial-default-arguments/120/7

var defaults = {
    options: {
        remove: true,
        enable: false,
        instance: {}
    },
    log: {
        warn: true,
        error: true
    }
};

现在,我们假定你有一个称为config的对象,它有一些这其中的值,但也许不全有,而且你想要将所有的默认值设置到这个对象的缺失点上,但不覆盖已经存在的特定设置:

var config = {
    options: {
        remove: false,
        instance: null
    }
};

你当然可以手动这样做,就像你可能曾经做过的那样:

config.options = config.options || {};
config.options.remove = (config.options.remove !== undefined) ?
    config.options.remove : defaults.options.remove;
config.options.enable = (config.options.enable !== undefined) ?
    config.options.enable : defaults.options.enable;
...

讨厌。

另一些人可能喜欢用覆盖赋值的方式来完成这个任务。你可能会被 ES6 的Object.assign(..)工具(见第六章)所吸引,来首先克隆defaults中的属性然后使用从config中克隆的属性覆盖它,像这样:

config = Object.assign( {}, defaults, config );

这看起来好多了,是吧?但是这里有一个重大问题!Object.assign(..)是浅拷贝,这意味着当它拷贝defaults.options时,它仅仅拷贝这个对象的引用,而不是深度克隆这个对象的属性到一个config.options对象。Object.assign(..)需要在你的对象树的每一层中实施才能得到你期望的深度克隆。

注意: 许多 JS 工具库/框架都为对象的深度克隆提供它们自己的选项,但是那些方式和它们的坑超出了我们在这里的讨论范围。

那么让我们检视一下 ES6 的带有默认值的对象解构能否帮到我们:

config.options = config.options || {};
config.log = config.log || {};
({
    options: {
        remove: config.options.remove = defaults.options.remove,
        enable: config.options.enable = defaults.options.enable,
        instance: config.options.instance = defaults.options.instance
    } = {},
    log: {
        warn: config.log.warn = defaults.log.warn,
        error: config.log.error = defaults.log.error
    } = {}
} = config);

不像Object.assign(..)的虚假诺言(因为它只是浅拷贝)那么好,但是我想它要比手动的方式强多了。虽然它仍然很不幸地带有冗余和重复。

前面的代码段的方式可以工作,因为我黑进了结构和默认机制来为我做属性的=== undefined检查和赋值的决定。这里的技巧是,我解构了config(看看在代码段末尾的= config),但是我将所有解构出来的值又立即赋值回config,带着config.options.enable赋值引用。

但还是太多了。让我们看看能否做得更好。

下面的技巧在你知道你正在解构的所有属性的名称都是唯一的情况下工作得最好。但即使不是这样的情况你也仍然可以使用它,只是没有那么好 —— 你将不得不分阶段解构,或者创建独一无二的本地变量作为临时的别名。

如果我们将所有的属性完全解构为顶层变量,那么我们就可以立即重构来重组原本的嵌套对象解构。

但是所有那些游荡在外的临时变量将会污染作用域。所以,让我们通过一个普通的{ }包围块儿来使用块儿作用域(参见本章早先的“块儿作用域声明”)。

// 将`defaults`混入`config`
{
    // 解构(使用默认值赋值)
    let {
        options: {
            remove = defaults.options.remove,
            enable = defaults.options.enable,
            instance = defaults.options.instance
        } = {},
        log: {
            warn = defaults.log.warn,
            error = defaults.log.error
        } = {}
    } = config;

    // 重构
    config = {
        options: { remove, enable, instance },
        log: { warn, error }
    };
}

这看起来好多了,是吧?

注意: 你也可以使用箭头 IIFE 来代替一般的{ }块儿和let声明来达到圈占作用域的目的。你的解构赋值/默认值将位于参数列表中,而你的重构将位于函数体的return语句中。

在重构部分的{ warn, error }语法可能是你初次见到;它称为“简约属性”,我们将在下一节讲解它!

你不懂 JS:ES6 与未来 第二章:语法(中)

对象字面量扩展

ES6 给不起眼儿的{ .. }对象字面量增加了几个重要的便利扩展。

简约属性

你一定很熟悉用这种形式的对象字面量声明:

var x = 2, y = 3,
    o = {
        x: x,
        y: y
    };

如果到处说x: x总是让你感到繁冗,那么有个好消息。如果你需要定义一个名称和词法标识符一致的属性,你可以将它从x: x缩写为x。考虑如下代码:

var x = 2, y = 3,
    o = {
        x,
        y
    };

简约方法

本着与我们刚刚检视的简约属性相同的精神,添附在对象字面量属性上的函数也有一种便利简约形式。

以前的方式:

var o = {
    x: function(){
        // ..
    },
    y: function(){
        // ..
    }
}

而在 ES6 中:

var o = {
    x() {
        // ..
    },
    y() {
        // ..
    }
}

警告: 虽然x() { .. }看起来只是x: function(){ .. }的缩写,但是简约方法有一种特殊行为,是它们对应的老方式所不具有的;确切地说,是允许super(参见本章稍后的“对象super”)的使用。

Generator(见第四章)也有一种简约方法形式:

var o = {
    *foo() { .. }
};

简约匿名

虽然这种便利缩写十分诱人,但是这其中有一个微妙的坑要小心。为了展示这一点,让我们检视一下如下的前 ES6 代码,你可能会试着使用简约方法来重构它:

function runSomething(o) {
    var x = Math.random(),
        y = Math.random();

    return o.something( x, y );
}

runSomething( {
    something: function something(x,y) {
        if (x > y) {
            // 使用相互对调的`x`和`y`来递归地调用
            return something( y, x );
        }

        return y - x;
    }
} );

这段蠢代码只是生成两个随机数,然后用大的减去小的。但这里重要的不是它做的是什么,而是它是如何被定义的。让我把焦点放在对象字面量和函数定义上,就像我们在这里看到的:

runSomething( {
    something: function something(x,y) {
        // ..
    }
} );

为什么我们同时说something:function something?这不是冗余吗?实际上,不是,它们俩被用于不同的目的。属性something让我们能够调用o.something(..),有点儿像它的公有名称。但是第二个something是一个词法名称,使这个函数可以为了递归而从内部引用它自己。

你能看出来为什么return something(y,x)这一行需要名称something来引用这个函数吗?因为这里没有对象的词法名称,要是有的话我们就可以说return o.something(y,x)或者其他类似的东西。

当一个对象字面量的确拥有一个标识符名称时,这其实是一个很常见的做法,比如:

var controller = {
    makeRequest: function(..){
        // ..
        controller.makeRequest(..);
    }
};

这是个好主意吗?也许是,也许不是。你在假设名称controller将总是指向目标对象。但它也很可能不是 —— 函数makeRequest(..)不能控制外部的代码,因此不能强制你的假设一定成立。这可能会回过头来咬到你。

另一些人喜欢使用this定义这样的东西:

var controller = {
    makeRequest: function(..){
        // ..
        this.makeRequest(..);
    }
};

这看起来不错,而且如果你总是用controller.makeRequest(..)来调用方法的话它就应该能工作。但现在你有一个this绑定的坑,如果你做这样的事情的话:

btn.addEventListener( "click", controller.makeRequest, false );

当然,你可以通过传递controller.makeRequest.bind(controller)作为绑定到事件上的处理器引用来解决这个问题。但是这很讨厌 —— 它不是很吸引人。

或者要是你的内部this.makeRequest(..)调用需要从一个嵌套的函数内发起呢?你会有另一个this绑定灾难,人们经常使用var self = this这种用黑科技解决,就像:

var controller = {
    makeRequest: function(..){
        var self = this;

        btn.addEventListener( "click", function(){
            // ..
            self.makeRequest(..);
        }, false );
    }
};

更讨厌。

注意: 更多关于this绑定规则和陷阱的信息,参见本系列的 this 与对象原型 的第一到二章。

好了,这些与简约方法有什么关系?回想一下我们的something(..)方法定义:

runSomething( {
    something: function something(x,y) {
        // ..
    }
} );

在这里的第二个something提供了一个超级便利的词法标识符,它总是指向函数自己,给了我们一个可用于递归,事件绑定/解除等等的完美引用 —— 不用乱搞this或者使用不可靠的对象引用。

太好了!

那么,现在我们试着将函数引用重构为这种 ES6 解约方法的形式:

runSomething( {
    something(x,y) {
        if (x > y) {
            return something( y, x );
        }

        return y - x;
    }
} );

第一眼看上去不错,除了这个代码将会坏掉。return something(..)调用经不会找到something标识符,所以你会得到一个ReferenceError。噢,但为什么?

上面的 ES6 代码段将会被翻译为:

runSomething( {
    something: function(x,y){
        if (x > y) {
            return something( y, x );
        }

        return y - x;
    }
} );

仔细看。你看出问题了吗?简约方法定义暗指something: function(x,y)。看到我们依靠的第二个something是如何被省略的了吗?换句话说,简约方法暗指匿名函数表达式。

对,讨厌。

注意: 你可能认为在这里=>箭头函数是一个好的解决方案。但是它们也同样不够,因为它们也是匿名函数表达式。我们将在本章稍后的“箭头函数”中讲解它们。

一个部分地补偿了这一点的消息是,我们的简约函数something(x,y)将不会是完全匿名的。参见第七章的“函数名”来了解 ES6 函数名称的推断规则。这不会在递归中帮到我们,但是它至少在调试时有用处。

那么我们怎样总结简约方法?它们简短又甜蜜,而且很方便。但是你应当仅在你永远不需要将它们用于递归或事件绑定/解除时使用它们。否则,就坚持使用你的老式something: function something(..)方法定义。

你的很多方法都将可能从简约方法定义中受益,这是个非常好的消息!只要小心几处未命名的灾难就好。

ES5 Getter/Setter

技术上讲,ES5 定义了 getter/setter 字面形式,但是看起来它们没有被太多地使用,这主要是由于缺乏转译器来处理这种新的语法(其实,它是 ES5 中加入的唯一的主要新语法)。所以虽然它不是一个 ES6 的新特性,我们也将简单地复习一下这种形式,因为它可能会随着 ES6 的向前发展而变得有用得多。

考虑如下代码:

var o = {
    __id: 10,
    get id() { return this.__id++; },
    set id(v) { this.__id = v; }
}

o.id;            // 10
o.id;            // 11
o.id = 20;
o.id;            // 20

// 而:
o.__id;            // 21
o.__id;            // 还是 —— 21!

这些 getter 和 setter 字面形式也可以出现在类中;参见第三章。

警告: 可能不太明显,但是 setter 字面量必须恰好有一个被声明的参数;省略它或罗列其他的参数都是不合法的语法。这个单独的必须参数 可以 使用解构和默认值(例如,set id({ id: v = 0 }) { .. }),但是收集/剩余...是不允许的(set id(...v) { .. })。

计算型属性名

你可能曾经遇到过像下面的代码段那样的情况,你的一个或多个属性名来自于某种表达式,因此你不能将它们放在对象字面量中:

var prefix = "user_";

var o = {
    baz: function(..){ .. }
};

o[ prefix + "foo" ] = function(..){ .. };
o[ prefix + "bar" ] = function(..){ .. };
..

ES6 为对象字面定义增加了一种语法,它允许你指定一个应当被计算的表达式,其结果就是被赋值属性名。考虑如下代码:

var prefix = "user_";

var o = {
    baz: function(..){ .. },
    [ prefix + "foo" ]: function(..){ .. },
    [ prefix + "bar" ]: function(..){ .. }
    ..
};

任何合法的表达式都可以出现在位于对象字面定义的属性名位置的[ .. ]内部。

很有可能,计算型属性名最经常与Symbol(我们将在本章稍后的“Symbol”中讲解)一起使用,比如:

var o = {
    [Symbol.toStringTag]: "really cool thing",
    ..
};

Symbol.toStringTag是一个特殊的内建值,我们使用[ .. ]语法求值得到,所以我们可以将值"really cool thing"赋值给这个特殊的属性名。

计算型属性名还可以作为简约方法或简约 generator 的名称出现:

var o = {
    ["f" + "oo"]() { .. }    // 计算型简约方法
    *["b" + "ar"]() { .. }    // 计算型简约 generator
};

设置[[Prototype]]

我们不会在这里讲解原型的细节,所以关于它的更多信息,参见本系列的 this 与对象原型

有时候在你声明对象字面量的同时给它的[[Prototype]]赋值很有用。下面的代码在一段时期内曾经是许多 JS 引擎的一种非标准扩展,但是在 ES6 中得到了标准化:

var o1 = {
    // ..
};

var o2 = {
    __proto__: o1,
    // ..
};

o2是用一个对象字面量声明的,但它也被[[Prototype]]链接到了o1。这里的__proto__属性名还可以是一个字符串"__proto__",但是要注意它 不能 是一个计算型属性名的结果(参见前一节)。

客气点儿说,__proto__是有争议的。在 ES6 中,它看起来是一个最终被很勉强地标准化了的,几十年前的自主扩展功能。实际上,它属于 ES6 的“Annex B”,这一部分罗列了 JS 感觉它仅仅为了兼容性的原因,而不得不标准化的东西。

警告: 虽然我勉强赞同在一个对象字面定义中将__proto__作为一个键,但我绝对不赞同在对象属性形式中使用它,就像o.__proto__。这种形式既是一个 getter 也是一个 setter(同样也是为了兼容性的原因),但绝对存在更好的选择。更多信息参见本系列的 this 与对象原型

对于给一个既存的对象设置[[Prototype]],你可以使用 ES6 的工具Object.setPrototypeOf(..)。考虑如下代码:

var o1 = {
    // ..
};

var o2 = {
    // ..
};

Object.setPrototypeOf( o2, o1 );

注意: 我们将在第六章中再次讨论Object。“Object.setPrototypeOf(..)静态函数”提供了关于Object.setPrototypeOf(..)的额外细节。另外参见“Object.assign(..)静态函数”来了解另一种将o2原型关联到o1的形式。

对象super

super通常被认为是仅与类有关。然而,由于 JS 对象仅有原型而没有类的性质,super是同样有效的,而且在普通对象的简约方法中行为几乎一样。

考虑如下代码:

var o1 = {
    foo() {
        console.log( "o1:foo" );
    }
};

var o2 = {
    foo() {
        super.foo();
        console.log( "o2:foo" );
    }
};

Object.setPrototypeOf( o2, o1 );

o2.foo();        // o1:foo
                // o2:foo

警告: super仅在简约方法中允许使用,而不允许在普通的函数表达式属性中。而且它还仅允许使用super.XXX形式(属性/方法访问),而不是super()形式。

在方法o2.foo()中的super引用被静态地锁定在了o2,而且明确地说是o2[[Prototype]]。这里的super基本上是Object.getPrototypeOf(o2) —— 显然被解析为o1 —— 这就是他如何找到并调用o1.foo()的。

关于super的完整细节,参见第三章的“类”。

模板字面量

在这一节的最开始,我将不得不呼唤这个 ES6 特性的极其……误导人的名称,这要看在你的经验中 模板(template) 一词的含义是什么。

许多开发者认为模板是一段可复用的,可重绘的文本,就像大多数模板引擎(Mustache,Handlebars,等等)提供的能力那样。ES6 中使用的 模板 一词暗示着相似的东西,就像一种声明可以被重绘的内联模板字面量的方法。然而,这根本不是考虑这个特性的正确方式。

所以,在我们继续之前,我把它重命名为它本应被称呼的名字:插值型字符串字面量(或者略称为 插值型字面量)。

你已经十分清楚地知道了如何使用"'分隔符来声明字符串字面量,而且你还知道它们不是(像有些语言中拥有的)内容将被解析为插值表达式的 智能字符串

但是,ES6 引入了一种新型的字符串字面量,使用反引号`作为分隔符。这些字符串字面量允许嵌入基本的字符串插值表达式,之后这些表达式自动地被解析和求值。

这是老式的前 ES6 方式:

var name = "Kyle";

var greeting = "Hello " + name + "!";

console.log( greeting );            // "Hello Kyle!"
console.log( typeof greeting );        // "string"

现在,考虑这种新的 ES6 方式:

var name = "Kyle";

var greeting = `Hello ${name}!`;

console.log( greeting );            // "Hello Kyle!"
console.log( typeof greeting );        // "string"

如你所见,我们在一系列被翻译为字符串字面量的字符周围使用了..,但是${..}形式中的任何表达式都将立即内联地被解析和求值。称呼这样的解析和求值的高大上名词就是 插值(interpolation)(比模板要准确多了)。

被插值的字符串字面量表达式的结果只是一个老式的普通字符串,赋值给变量greeting

警告: typeof greeting == "string"展示了为什么不将这些实体考虑为特殊的模板值很重要,因为你不能将这种字面量的未求值形式赋值给某些东西并复用它。..字符串字面量在某种意义上更像是 IIFE,因为它自动内联地被求值。..字符串字面量的结果只不过是一个简单的字符串。

插值型字符串字面量的一个真正的好处是他们允许被分割为多行:

var text =
`Now is the time for all good men
to come to the aid of their
country!`;

console.log( text );
// Now is the time for all good men
// to come to the aid of their
// country!

在插值型字符串字面量中的换行将会被保留在字符串值中。

除非在字面量值中作为明确的转义序列出现,回车字符\r(编码点U+000D)的值或者回车+换行序列\r\n(编码点U+000DU+000A)的值都会被泛化为一个换行字符\n(编码点U+000A)。但不要担心;这种泛化很少见而且很可能仅会在你将文本拷贝粘贴到 JS 文件中时才会发生。

插值表达式

在一个插值型字符串字面量中,任何合法的表达式都被允许出现在${..}内部,包括函数调用,内联函数表达式调用,甚至是另一个插值型字符串字面量!

考虑如下代码:

function upper(s) {
    return s.toUpperCase();
}

var who = "reader";

var text =
`A very ${upper( "warm" )} welcome
to all of you ${upper( `${who}s` )}!`;

console.log( text );
// A very WARM welcome
// to all of you READERS!

当我们组合变量who与字符串s时, 相对于who + "s",这里的内部插值型字符串字面量${who}s更方便一些。有些情况下嵌套的插值型字符串字面量是有用的,但是如果你发现自己做这样的事情太频繁,或者发现你自己嵌套了好几层时,你就要小心一些。

如果确实有这样情况,你的字符串你值生产过程很可能可以从某些抽象中获益。

警告: 作为一个忠告,使用这样的新发现的力量时要非常小心你代码的可读性。就像默认值表达式和解构赋值表达式一样,仅仅因为你 做某些事情,并不意味着你 应该 做这些事情。在使用新的 ES6 技巧时千万不要做过了头,使你的代码比你或者你的其他队友聪明。

表达式作用域

关于作用域的一个快速提醒是它用于解析表达式中的变量时。我早先提到过一个插值型字符串字面量与 IIFE 有些相像,事实上这也可以考虑为作用域行为的一种解释。

考虑如下代码:

function foo(str) {
    var name = "foo";
    console.log( str );
}

function bar() {
    var name = "bar";
    foo( `Hello from ${name}!` );
}

var name = "global";

bar();                    // "Hello from bar!"

在函数bar()内部,字符串字面量..被表达的那一刻,可供它查找的作用域发现变量的name的值为"bar"。既不是全局的name也不是foo(..)name。换句话说,一个插值型字符串字面量在它出现的地方是词法作用域的,而不是任何方式的动态作用域。

标签型模板字面量

再次为了合理性而重命名这个特性:标签型字符串字面量

老实说,这是一个 ES6 提供的更酷的特性。它可能看起来有点儿奇怪,而且也许一开始看起来一般不那么实用。但一旦你花些时间在它上面,标签型字符串字面量的用处可能会令你惊讶。

例如:

function foo(strings, ...values) {
    console.log( strings );
    console.log( values );
}

var desc = "awesome";

foo`Everything is ${desc}!`;
// [ "Everything is ", "!"]
// [ "awesome" ]

让我们花点儿时间考虑一下前面的代码段中发生了什么。首先,跳出来的最刺眼的东西就是fooEverything...;。它看起来不像是任何我们曾经见过的东西。不是吗?

它实质上是一种不需要( .. )的特殊函数调用。标签 —— 在字符串字面量..之前的foo部分 —— 是一个应当被调用的函数的值。实际上,它可以是返回函数的任何表达式,甚至是一个返回另一个函数的函数调用,就像:

function bar() {
    return function foo(strings, ...values) {
        console.log( strings );
        console.log( values );
    }
}

var desc = "awesome";

bar()`Everything is ${desc}!`;
// [ "Everything is ", "!"]
// [ "awesome" ]

但是当作为一个字符串字面量的标签时,函数foo(..)被传入了什么?

第一个参数值 —— 我们称它为strings —— 是一个所有普通字符串的数组(所有被插值的表达式之间的东西)。我们在strings数组中得到两个值:"Everything is ""!"

之后为了我们示例的方便,我们使用...收集/剩余操作符(见本章早先的“扩散/剩余”部分)将所有后续的参数值收集到一个称为values的数组中,虽说你本来当然可以把它们留作参数strings后面单独的命名参数。

被收集进我们的values数组中的参数值,就是在字符串字面量中发现的,已经被求过值的插值表达式的结果。所以在我们的例子中values里唯一的元素显然就是awesome

你可以将这两个数组考虑为:在values中的值原本是你拼接在stings的值之间的分隔符,而且如果你将所有的东西连接在一起,你就会得到完整的插值字符串值。

一个标签型字符串字面量像是一个在插值表达式被求值之后,但是在最终的字符串被编译之前的处理步骤,允许你在从字面量中产生字符串的过程中进行更多的控制。

一般来说,一个字符串字面连标签函数(在前面的代码段中是foo(..))应当计算一个恰当的字符串值并返回它,所以你可以使用标签型字符串字面量作为一个未打标签的字符串字面量来使用:

function tag(strings, ...values) {
    return strings.reduce( function(s,v,idx){
        return s + (idx > 0 ? values[idx-1] : "") + v;
    }, "" );
}

var desc = "awesome";

var text = tag`Everything is ${desc}!`;

console.log( text );            // Everything is awesome!

在这个代码段中,tag(..)是一个直通操作,因为它不实施任何特殊的修改,而只是使用reduce(..)来循环遍历,并像一个未打标签的字符串字面量一样,将stringsvalues拼接/穿插在一起。

那么实际的用法是什么?有许多高级的用法超出了我们要在这里讨论的范围。但这里有一个格式化美元数字的简单想法(有些像基本的本地化):

function dollabillsyall(strings, ...values) {
    return strings.reduce( function(s,v,idx){
        if (idx > 0) {
            if (typeof values[idx-1] == "number") {
                // 看,也使用插值性字符串字面量!
                s += `$${values[idx-1].toFixed( 2 )}`;
            }
            else {
                s += values[idx-1];
            }
        }

        return s + v;
    }, "" );
}

var amt1 = 11.99,
    amt2 = amt1 * 1.08,
    name = "Kyle";

var text = dollabillsyall
`Thanks for your purchase, ${name}! Your
product cost was ${amt1}, which with tax
comes out to ${amt2}.`

console.log( text );
// Thanks for your purchase, Kyle! Your
// product cost was $11.99, which with tax
// comes out to $12.95.

如果在values数组中遇到一个number值,我们就在它前面放一个"$"并用toFixed(2)将它格式化为小数点后两位有效。否则,我们就不碰这个值而让它直通过去。

原始字符串

在前一个代码段中,我们的标签函数接受的第一个参数值称为strings,是一个数组。但是有一点儿额外的数据被包含了进来:所有字符串的原始未处理版本。你可以使用.raw属性访问这些原始字符串值,就像这样:

function showraw(strings, ...values) {
    console.log( strings );
    console.log( strings.raw );
}

showraw`Hello\nWorld`;
// [ "Hello
// World" ]
// [ "Hello\nWorld" ]

原始版本的值保留了原始的转义序列\n(``和n是两个分离的字符),但处理过的版本认为它是一个单独的换行符。但是,早先提到的行终结符泛化操作,是对两个值都实施的。

ES6 带来了一个内建函数,它可以用做字符串字面量的标签:String.raw(..)。它简单地直通strings值的原始版本:

console.log( `Hello\nWorld` );
// Hello
// World

console.log( String.raw`Hello\nWorld` );
// Hello\nWorld

String.raw`Hello\nWorld`.length;
// 12

字符串字面量标签的其他用法包括国际化,本地化,和许多其他的特殊处理。

箭头函数

我们在本章早先接触了函数中this绑定的复杂性,而且在本系列的 this 与对象原型 中也以相当的篇幅讲解过。理解普通函数中基于this的编程带来的挫折是很重要的,因为这是 ES6 的新=>箭头函数的主要动机。

作为与普通函数的比较,我们首先来展示一下箭头函数看起来什么样:

function foo(x,y) {
    return x + y;
}

// 对比

var foo = (x,y) => x + y;

箭头函数的定义由一个参数列表(零个或多个参数,如果参数不是只有一个,需要有一个( .. )包围这些参数)组成,紧跟着是一个=>符号,然后是一个函数体。

所以,在前面的代码段中,箭头函数只是(x,y) => x + y这一部分,而这个函数的引用刚好被赋值给了变量foo

函数体仅在含有多于一个表达式,或者由一个非表达式语句组成时才需要用{ .. }括起来。如果仅含有一个表达式,而且你省略了外围的{ .. },那么在这个表达式前面就会有一个隐含的return,就像前面的代码段中展示的那样。

这里是一些其他种类的箭头函数:

var f1 = () => 12;
var f2 = x => x * 2;
var f3 = (x,y) => {
    var z = x * 2 + y;
    y++;
    x *= 3;
    return (x + y + z) / 2;
};

箭头函数 总是 函数表达式;不存在箭头函数声明。而且很明显它们都是匿名函数表达式 —— 它们没有可以用于递归或者事件绑定/解除的命名引用 —— 但在第七章的“函数名”中将会讲解为了调试的目的而存在的 ES6 函数名接口规则。

注意: 普通函数参数的所有功能对于箭头函数都是可用的,包括默认值,解构,剩余参数,等等。

箭头函数拥有漂亮,简短的语法,这使得它们在表面上看起来对于编写简洁代码很有吸引力。确实,几乎所有关于 ES6 的文献(除了这个系列中的书目)看起来都立即将箭头函数仅仅认作“新函数”。

这说明在关于箭头函数的讨论中,几乎所有的例子都是简短的单语句工具,比如那些作为回调传递给各种工具的箭头函数。例如:

var a = [1,2,3,4,5];

a = a.map( v => v * 2 );

console.log( a );                // [2,4,6,8,10]

在这些情况下,你的内联函数表达式很适合这种在一个单独语句中快速计算并返回结果的模式,对于更繁冗的function关键字和语法来说箭头函数确实看起来是一个很吸人,而且轻量的替代品。

大多数人看着这样简洁的例子都倾向于发出“哦……!啊……!”的感叹,就像我想象中你刚刚做的那样!

然而我要警示你的是,在我看来,使用箭头函数的语法代替普通的,多语句函数,特别是那些可以被自然地表达为函数声明的函数,是某种误用。

回忆本章早前的字符串字面量标签函数dollabillsyall(..) —— 让我们将它改为使用=>语法:

var dollabillsyall = (strings, ...values) =>
    strings.reduce( (s,v,idx) => {
        if (idx > 0) {
            if (typeof values[idx-1] == "number") {
                // look, also using interpolated
                // string literals!
                s += `$${values[idx-1].toFixed( 2 )}`;
            }
            else {
                s += values[idx-1];
            }
        }

        return s + v;
    }, "" );

在这个例子中,我做的唯一修改是删除了functionreturn,和一些{ .. },然后插入了=>和一个var。这是对代码可读性的重大改进吗?呵呵。

实际上我会争论,缺少return和外部的{ .. }在某种程度上模糊了这样的事实:reduce(..)调用是函数dollabillsyall(..)中唯一的语句,而且它的结果是这个调用的预期结果。另外,那些受过训练而习惯于在代码中搜索function关键字来寻找作用域边界的眼睛,现在需要搜索=>标志,在密集的代码中这绝对会更加困难。

虽然不是一个硬性规则,但是我要说从=>箭头函数转换得来的可读性,与被转换的函数长度成反比。函数越长,=>能帮的忙越少;函数越短,=>的闪光之处就越多。

我觉得这样做更明智也更合理:在你需要短的内联函数表达式的地方采用=>,但保持你的一般长度的主函数原封不动。

不只是简短的语法,而是this

曾经集中在=>上的大多数注意力都是它通过在你的代码中除去functionreturn,和{ .. }来节省那些宝贵的击键。

但是至此我们一直忽略了一个重要的细节。我在这一节最开始的时候说过,=>函数与this绑定行为密切相关。事实上,=>箭头函数 主要的设计目的 就是以一种特定的方式改变this的行为,解决在this敏感的编码中的一个痛点。

节省击键是掩人耳目的东西,至多是一个误导人的配角。

让我们重温本章早前的另一个例子:

var controller = {
    makeRequest: function(..){
        var self = this;

        btn.addEventListener( "click", function(){
            // ..
            self.makeRequest(..);
        }, false );
    }
};

我们使用了黑科技var self = this,然后引用了self.makeRequest(..),因为在我们传递给addEventListener(..)的回调函数内部,this绑定将与makeRequest(..)本身中的this绑定不同。换句话说,因为this绑定是动态的,我们通过self变量退回到了可预测的词法作用域。

在这其中我们终于可以看到=>箭头函数主要的设计特性了。在箭头函数内部,this绑定不是动态的,而是词法的。在前一个代码段中,如果我们在回调里使用一个箭头函数,this将会不出所料地成为我们希望它成为的东西。

考虑如下代码:

var controller = {
    makeRequest: function(..){
        btn.addEventListener( "click", () => {
            // ..
            this.makeRequest(..);
        }, false );
    }
};

前面代码段的箭头函数中的词法this现在指向的值与外围的makeRequest(..)函数相同。换句话说,=>var self = this的语法上的替代品。

var self = this(或者,另一种选择是,.bind(this)调用)通常可以帮忙的情况下,=>箭头函数是一个基于相同原则的很好的替代操作。听起来很棒,是吧?

没那么简单。

如果=>取代var self = this.bind(this)可以工作,那么猜猜=>用于一个 不需要 var self = this就能工作的this敏感的函数会发生么?你可能会猜到它将会把事情搞砸。没错。

考虑如下代码:

var controller = {
    makeRequest: (..) => {
        // ..
        this.helper(..);
    },
    helper: (..) => {
        // ..
    }
};

controller.makeRequest(..);

虽然我们以controller.makeRequest(..)的方式进行了调用,但是this.helper引用失败了,因为这里的this没有像平常那样指向controller。那么它指向哪里?它通过词法继承了外围的作用域中的this。在前面的代码段中,它是全局作用域,this指向了全局作用域。呃。

除了词法的this以外,箭头函数还拥有词法的arguments —— 它们没有自己的arguments数组,而是从它们的上层继承下来 —— 同样还有词法的supernew.target(参见第三章的“类”)。

所以,关于=>在什么情况下合适或不合适,我们现在可以推论出一组更加微妙的规则:

  • 如果你有一个简短的,单语句内联函数表达式,它唯一的语句是某个计算后的值的return语句,并且 这个函数没有在它内部制造一个this引用,并且 没有自引用(递归,事件绑定/解除),并且 你合理地预期这个函数绝不会变得需要this引用或自引用,那么你就可能安全地将它重构为一个=>箭头函数。
  • 如果你有一个内部函数表达式,它依赖于外围函数的var self = this黑科技或者.bind(this)调用来确保正确的this绑定,那么这个内部函数表达式就可能安全地变为一个=>箭头函数。
  • 如果你有一个内部函数表达式,它依赖于外围函数的类似于var args = Array.prototype.slice.call(arguments)这样的东西来制造一个arguments的词法拷贝,那么这个内部函数就可能安全地变为一个=>箭头函数。
  • 对于其他的所有东西 —— 普通函数声明,较长的多语句函数表达式,需要词法名称标识符进行自引用(递归等)的函数,和任何其他不符合前述性质的函数 —— 你就可能应当避免=>函数语法。

底线:=>thisarguments,和super的词法绑定有关。它们是 ES6 为了修正一些常见的问题而被有意设计的特性,而不是为了修正 bug,怪异的代码,或者错误。

不要相信任何说=>主要是,或者几乎是,为了减少几下击键的炒作。无论你是省下还是浪费了这几下击键,你都应当确切地知道你打入的每个字母是为了做什么。

提示: 如果你有一个函数,由于上述各种清楚的原因而不适合成为一个=>箭头函数,但同时它又被声明为一个对象字面量的一部分,那么回想一下本章早先的“简约方法”,它有简短函数语法的另一种选择。

对于如何/为何选用一个箭头函数,如果你喜欢一个可视化的决策图的话:

for..of Loops

伴随着我们熟知的 JavaScriptforfor..in循环,ES6 增加了一个for..of循环,它循环遍历一组由一个 迭代器(iterator) 产生的值。

你使用for..of循环遍历的值必须是一个 可迭代对象(iterable),或者它必须是一个可以被强制转换/封箱(参见本系列的 类型与文法)为一个可迭代对象的值。一个可迭代对象只不过是一个可以生成迭代器的对象,然后由循环使用这个迭代器。

让我们比较for..offor..in来展示它们的区别:

var a = ["a","b","c","d","e"];

for (var idx in a) {
    console.log( idx );
}
// 0 1 2 3 4

for (var val of a) {
    console.log( val );
}
// "a" "b" "c" "d" "e"

如你所见,for..in循环遍历数组a中的键/索引,而for.of循环遍历a中的值。

这是前面代码段中for..of的前 ES6 版本:

var a = ["a","b","c","d","e"],
    k = Object.keys( a );

for (var val, i = 0; i < k.length; i++) {
    val = a[ k[i] ];
    console.log( val );
}
// "a" "b" "c" "d" "e"

而这是一个 ES6 版本的非for..of等价物,它同时展示了手动迭代一个迭代器(见第三章的“迭代器”):

var a = ["a","b","c","d","e"];

for (var val, ret, it = a[Symbol.iterator]();
    (ret = it.next()) && !ret.done;
) {
    val = ret.value;
    console.log( val );
}
// "a" "b" "c" "d" "e"

在幕后,for..of循环向可迭代对象要来一个迭代器(使用内建的Symbol.iterator;参见第七章的“通用 Symbols”),然后反复调用这个迭代器并将它产生的值赋值给循环迭代的变量。

在 JavaScript 标准的内建值中,默认为可迭代对象的(或提供可迭代能力的)有:

  • 数组
  • 字符串
  • Generators(见第三章)
  • 集合/类型化数组(见第五章)

警告: 普通对象默认是不适用于for..of循环的。因为他们没有默认的迭代器,这是有意为之的,不是一个错误。但是,我们不会进一步探究这其中微妙的原因。在第三章的“迭代器”中,我们将看到如何为我们自己的对象定义迭代器,这允许for..of遍历任何对象来得到我们定义的一组值。

这是如何遍历一个基本类型的字符串中的字符:

for (var c of "hello") {
    console.log( c );
}
// "h" "e" "l" "l" "o"

基本类型字符串"hello"被强制转换/封箱为等价的String对象包装器,它是默认就是一个可迭代对象。

for (XYZ of ABC)..中,XYZ子句既可以是一个赋值表达式也可以是一个声明,这与forfor..in中相同的子句一模一样。所以你可以做这样的事情:

var o = {};

for (o.a of [1,2,3]) {
    console.log( o.a );
}
// 1 2 3

for ({x: o.a} of [ {x: 1}, {x: 2}, {x: 3} ]) {
  console.log( o.a );
}
// 1 2 3

与其他的循环一样,使用breakcontinuereturn(如果是在一个函数中),以及抛出异常,for..of循环可以被提前终止。在任何这些情况下,迭代器的return(..)函数(如果存在的话)都会被自动调用,以便让迭代器进行必要的清理工作。

注意: 可迭代对象与迭代器的完整内容参见第三章的“迭代器”。

正则表达式

让我们承认吧:长久以来在 JS 中正则表达式都没怎么改变过。所以一件很棒的事情是,在 ES6 中它们终于学会了一些新招数。我们将在这里简要地讲解一下新增的功能,但是正则表达式整体的话题是如此厚重,以至于如果你需要复习一下的话你需要找一些关于它的专门章节/书籍(有许多!)。

Unicode 标志

我们将在本章稍后的“Unicode”一节中讲解关于 Unicode 的更多细节。在此,我们将仅仅简要地看一下 ES6+正则表达式的新u标志,它使这个正则表达式的 Unicode 匹配成为可能。

JavaScript 字符串通常被解释为 16 位字符的序列,它们对应于 基本多文种平面(Basic Multilingual Plane (BMP)) (en.wikipedia.org/wiki/Plane_%28Unicode%29)中的字符。但是有许多 UTF-16 字符在这个范围以外,而且字符串可能含有这些多字节字符。%E4%B8%AD%E7%9A%84%E5%AD%97%E7%AC%A6%E3%80%82%E4%BD%86%E6%98%AF%E6%9C%89%E8%AE%B8%E5%A4%9AUTF-16%E5%AD%97%E7%AC%A6%E5%9C%A8%E8%BF%99%E4%B8%AA%E8%8C%83%E5%9B%B4%E4%BB%A5%E5%A4%96%EF%BC%8C%E8%80%8C%E4%B8%94%E5%AD%97%E7%AC%A6%E4%B8%B2%E5%8F%AF%E8%83%BD%E5%90%AB%E6%9C%89%E8%BF%99%E4%BA%9B%E5%A4%9A%E5%AD%97%E8%8A%82%E5%AD%97%E7%AC%A6%E3%80%82)

在 ES6 之前,正则表达式只能基于 BMP 字符进行匹配,这意味着在匹配时那些扩展字符被看作是两个分离的字符。这通常不理想。

所以,在 ES6 中,u标志告诉正则表达式使用 Unicode(UTF-16)字符的解释方式来处理字符串,这样一来一个扩展的字符将作为一个单独的实体被匹配。

警告: 尽管名字的暗示是这样,但是“UTF-16”并不严格地意味着 16 位。现代的 Unicode 使用 21 位,而且像 UTF-8 和 UTF-16 这样的标准大体上是指有多少位用于表示一个字符。

一个例子(直接从 ES6 语言规范中拿来的): 𝄞 (G 大调音乐符号)是 Unicode 代码点 U+1D11E(0x1D11E)。

如果这个字符出现在一个正则表达式范例中(比如/𝄞/),标准的 BMP 解释方式将认为它是需要被匹配的两个字符(0xD834 和 0xDD1E)。但是 ES6 新的 Unicode 敏感模式意味着/𝄞/u(或者 Unicode 的转义形式/\u{1D11E}/u)将会把"𝄞"作为一个单独的字符在一个字符串中进行匹配。

你可能想知道为什么这很重要。在非 Unicode 的 BMP 模式下,这个正则表达式范例被看作两个分离的字符,但它仍然可以在一个含有"𝄞"字符的字符串中找到匹配,如果你试一下就会看到:

/𝄞/.test( "𝄞-clef" );            // true
```js

重要的是匹配的长度。例如:

/^.-clef/ .test( "𝄞-clef" ); // false
/^.-clef/u.test( "𝄞-clef" ); // true


这个范例中的`^.-clef`说要在普通的`"-clef"`文本前面只匹配一个单独的字符。在标准的 BMP 模式下,这个匹配会失败(因为是两个字符),但是在 Unicode 模式标志位`u`打开的情况下,这个匹配会成功(一个字符)。

另外一个重要的注意点是,`u`使像`+`和`*`这样的量词实施于作为一个单独字符的整个 Unicode 代码点,而不仅仅是字符的 *低端替代符*(也就是符号最右边的一半)。对于出现在字符类中的 Unicode 字符也是一样,比如`/[💩-💫]/u`。

**注意:** 还有许多关于`u`在正则表达式中行为的细节,对此 Mathias Bynens([`twitter.com/mathias)撰写了大量的作品(https://mathiasbynens.be/notes/es6-unicode-regex)。`](https://twitter.com/mathias)%E6%92%B0%E5%86%99%E4%BA%86%E5%A4%A7%E9%87%8F%E7%9A%84%E4%BD%9C%E5%93%81(https://mathiasbynens.be/notes/es6-unicode-regex)%E3%80%82)

### 粘性标志

另一个加入 ES6 正则表达式的模式标志是`y`,它经常被称为“粘性模式(sticky mode)”。*粘性* 实质上意味着正则表达式在它开始时有一个虚拟的锚点,这个锚点使正则表达式仅以自己的`lastIndex`属性所指示的位置为起点进行匹配。

为了展示一下,让我们考虑两个正则表达式,第一个没有使用粘性模式而第二个有:

```js
var re1 = /foo/,
    str = "++foo++";

re1.lastIndex;            // 0
re1.test( str );        // true
re1.lastIndex;            // 0 —— 没有更新

re1.lastIndex = 4;
re1.test( str );        // true —— `lastIndex`被忽略了
re1.lastIndex;            // 4 —— 没有更新

关于这个代码段可以观察到三件事:

  • test(..)根本不在意lastIndex的值,而总是从输入字符串的开始实施它的匹配。
  • 因为我们的模式没有输入的起始锚点^,所以对"foo"的搜索可以在整个字符串上自由向前移动。
  • lastIndex没有被test(..)更新。

现在,让我们试一下粘性模式的正则表达式:

var re2 = /foo/y,        // <-- 注意粘性标志`y`
    str = "++foo++";

re2.lastIndex;            // 0
re2.test( str );        // false —— 在`0`没有找到“foo”
re2.lastIndex;            // 0

re2.lastIndex = 2;
re2.test( str );        // true
re2.lastIndex;            // 5 —— 在前一次匹配后更新了

re2.test( str );        // false
re2.lastIndex;            // 0 —— 在前一次匹配失败后重置

于是关于粘性模式我们可以观察到一些新的事实:

  • test(..)str中使用lastIndex作为唯一精确的位置来进行匹配。在寻找匹配时不会发生向前的移动 —— 匹配要么出现在lastIndex的位置,要么就不存在。
  • 如果发生了一个匹配,test(..)就更新lastIndex使它指向紧随匹配之后的那个字符。如果匹配失败,test(..)就将lastIndex重置为0

没有使用^固定在输入起点的普通非粘性范例可以自由地在字符串中向前移动来搜索匹配。但是粘性模式制约这个范例仅在lastIndex的位置进行匹配。

正如我在这一节开始时提到过的,另一种考虑的方式是,y暗示着一个虚拟的锚点,它位于正好相对于(也就是制约着匹配的起始位置)lastIndex位置的范例的开头。

警告: 在关于这个话题的以前的文献中,这种行为曾经被声称为y像是在范例中暗示着一个^(输入的起始)锚点。这是不准确的。我们将在稍后的“锚定粘性”中讲解更多细节。

粘性定位

对反复匹配使用y可能看起来是一种奇怪的限制,因为匹配没有向前移动的能力,你不得不手动保证lastIndex恰好位于正确的位置上。

这是一种可能的场景:如果你知道你关心的匹配总是会出现在一个数字(例如,01020,等等)倍数的位置。那么你就可以只构建一个受限的范例来匹配你关心的东西,然后在每次匹配那些固定位置之前手动设置lastIndex

考虑如下代码:

var re = /f../y,
    str = "foo       far       fad";

str.match( re );        // ["foo"]

re.lastIndex = 10;
str.match( re );        // ["far"]

re.lastIndex = 20;
str.match( re );        // ["fad"]

然而,如果你正在解析一个没有像这样被格式化为固定位置的字符串,在每次匹配之前搞清楚为lastIndex设置什么东西的做法可能会难以维系。

这里有一个微妙之处要考虑。y要求lastIndex位于发生匹配的准确位置。但它不严格要求 来手动设置lastIndex

取而代之的是,你可以用这样的方式构建你的正则表达式:它们在每次主匹配中都捕获你所关心的东西的前后所有内容,直到你想要进行下一次匹配的东西为止。

因为lastIndex将被设置为一个匹配末尾之后的下一个字符,所以如果你已经匹配了到那个位置的所有东西,lastIndex将总是位于下次y范例开始的正确位置。

警告: 如果你不能像这样足够范例化地预知输入字符串的结构,这种技术可能不合适,而且你可能不应使用y

拥有结构化的字符串输入,可能是y能够在一个字符串上由始至终地进行反复匹配的最实际场景。考虑如下代码:

var re = /\d+\.\s(.*?)(?:\s|$)/y
    str = "1\. foo 2\. bar 3\. baz";

str.match( re );        // [ "1\. foo ", "foo" ]

re.lastIndex;            // 7 —— 正确位置!
str.match( re );        // [ "2\. bar ", "bar" ]

re.lastIndex;            // 14 —— 正确位置!
str.match( re );        // ["3\. baz", "baz"]

这能够工作是因为我事先知道输入字符串的结构:总是有一个像"1\. "这样的数字的前缀出现在期望的匹配("foo",等等)之前,而且它后面要么是一个空格,要么就是字符串的末尾($锚点)。所以我构建的正则表达式在每次主匹配中捕获了所有这一切,然后我使用一个匹配分组( )使我真正关心的东西被方便地分离出来。

在第一次匹配("1\. foo ")之后,lastIndex7,它已经是开始下一次匹配"2\. bar "所需的位置了,如此类推。

如果你要使用粘性模式y进行反复匹配,那么你就可能想要像我们刚刚展示的那样寻找一个机会自动地定位lastIndex

粘性对比全局

一些读者可能意识到,你可以使用全局匹配标志位gexec(..)方法来模拟某些像lastIndex相对匹配的东西,就像这样:

var re = /o+./g,        // <-- 看,`g`!
    str = "foot book more";

re.exec( str );            // ["oot"]
re.lastIndex;            // 4

re.exec( str );            // ["ook"]
re.lastIndex;            // 9

re.exec( str );            // ["or"]
re.lastIndex;            // 13

re.exec( str );            // null —— 没有更多的匹配了!
re.lastIndex;            // 0 —— 现在重新开始!

虽然使用exec(..)g范例确实从lastIndex的当前值开始它们的匹配,而且也在每次匹配(或失败)之后更新lastIndex,但这与y的行为不是相同的东西。

注意前面代码段中被第二个exec(..)调用匹配并找到的"ook",被定位在位置6,即便在这个时候lastIndex4(前一次匹配的末尾)。为什么?因为正如我们前面讲过的,非粘性匹配可以在它们的匹配过程中自由地向前移动。一个粘性模式表达式在这里将会失败,因为它不允许向前移动。

除了也许不被期望的向前移动的匹配行为以外,使用g代替y的另一个缺点是,g改变了一些匹配方法的行为,比如str.match(re)

考虑如下代码:

var re = /o+./g,        // <-- 看,`g`!
    str = "foot book more";

str.match( re );        // ["oot","ook","or"]

看到所有的匹配是如何一次性地被返回的吗?有时这没问题,但有时这不是你想要的。

test(..)match(..)这样的工具一起使用,粘性标志位y将给你一次一个的推进式的匹配。只要保证每次匹配时lastIndex总是在正确的位置上就行!

锚定粘性

正如我们早先被警告过的,将粘性模式认为是暗含着一个以^开头的范例是不准确的。在正则表达式中锚点^拥有独特的含义,它 没有 被粘性模式改变。^总是 一个指向输入起点的锚点,而且 以任何方式相对于lastIndex

在这个问题上,除了糟糕/不准确的文档,一个在 Firefox 中进行的老旧的前 ES6 粘性模式实验不幸地加深了这种困惑,它确实 曾经 使^相对于lastIndex,所以这种行为曾经存在了许多年。

ES6 选择不这么做。^在一个范例中绝对且唯一地意味着输入的起点。

这样的后果是,一个像/^foo/y这样的范例将总是仅在一个字符串的开头找到"foo"匹配,如果它被允许在那里匹配的话。如果lastIndex不是0,匹配就会失败。考虑如下代码:

var re = /^foo/y,
    str = "foo";

re.test( str );            // true
re.test( str );            // false
re.lastIndex;            // 0 —— 失败之后被重置

re.lastIndex = 1;
re.test( str );            // false —— 由于定位而失败
re.lastIndex;            // 0 —— 失败之后被重置

底线:y^lastIndex > 0是一种不兼容的组合,它将总是导致失败的匹配。

注意: 虽然y不会以任何方式改变^的含义,但是多行模式m,这样^就意味着输入的起点 或者 一个换行之后的文本的起点。所以,如果你在一个范例中组合使用ym,你会在一个字符串中发现多个开始于^的匹配。但是要记住:因为它的粘性y,将不得不在后续的每次匹配时确保lastIndex被置于正确的换行的位置(可能是通过匹配到行的末尾),否者后续的匹配将不会执行。

正则表达式flags

在 ES6 之前,如果你想要检查一个正则表达式来看看它被施用了什么标志位,你需要将它们 —— 讽刺的是,可能是使用另一个正则表达式 —— 从source属性的内容中解析出来,就像这样:

var re = /foo/ig;

re.toString();            // "/foo/ig"

var flags = re.toString().match( /\/([gim]*)$/ )[1];

flags;                    // "ig"

在 ES6 中,你现在可以直接得到这些值,使用新的flags属性:

var re = /foo/ig;

re.flags;                // "gi"

虽然是个细小的地方,但是 ES6 规范要求表达式的标志位以"gimuy"的顺序罗列,无论原本的范例中是以什么顺序指定的。这就是出现/ig"gi"的区别的原因。

是的,标志位被指定和罗列的顺序无所谓。

ES6 的另一个调整是,如果你向构造器RegExp(..)传递一个既存的正则表达式,它现在是flags敏感的:

var re1 = /foo*/y;
re1.source;                            // "foo*"
re1.flags;                            // "y"

var re2 = new RegExp( re1 );
re2.source;                            // "foo*"
re2.flags;                            // "y"

var re3 = new RegExp( re1, "ig" );
re3.source;                            // "foo*"
re3.flags;                            // "gi"

在 ES6 之前,构造re3将抛出一个错误,但是在 ES6 中你可以在复制时覆盖标志位。

你不懂 JS:ES6 与未来 第二章:语法(下)

数字字面量扩展

在 ES5 之前,数字字面量看起来就像下面的东西 —— 八进制形式没有被官方指定,唯一被允许的是各种浏览器已经实质上达成一致的一种扩展:

var dec = 42,
    oct = 052,
    hex = 0x2a;

注意: 虽然你用不同的进制来指定一个数字,但是数字的数学值才是被存储的东西,而且默认的输出解释方式总是 10 进制的。前面代码段中的三个变量都在它们当中存储了值42

为了进一步说明052是一种非标准形式扩展,考虑如下代码:

Number( "42" );                // 42
Number( "052" );            // 52
Number( "0x2a" );            // 42

ES5 继续允许这种浏览器扩展的八进制形式(包括这样的不一致性),除了在 strict 模式下,八进制字面量(052)是不允许的。做出这种限制的主要原因是,许多开发者似乎习惯于下意识地为了将代码对齐而在十进制的数字前面前缀0,然后遭遇他们完全改变了数字的值的意外!

ES6 延续了除十进制数字之外的数字字面量可以被表示的遗留的改变/种类。现在有了一种官方的八进制形式,一种改进了的十六进制形式,和一种全新的二进制形式。由于 Web 兼容性的原因,在非 strict 模式下老式的八进制形式052将继续是合法的,但其实应当永远不再被使用了。

这些是新的 ES6 数字字面形式:

var dec = 42,
    oct = 0o52,            // or `0O52` :(
    hex = 0x2a,            // or `0X2a` :/
    bin = 0b101010;        // or `0B101010` :/

唯一允许的小数形式是十进制的。八进制,十六进制,和二进制都是整数形式。

而且所有这些形式的字符串表达形式都是可以被强制转换/变换为它们的数字等价物的:

Number( "42" );            // 42
Number( "0o52" );        // 42
Number( "0x2a" );        // 42
Number( "0b101010" );    // 42

虽然严格来说不是 ES6 新增的,但一个鲜为人知的事实是你其实可以做反方向的转换(好吧,某种意义上的):

var a = 42;

a.toString();            // "42" —— 也可使用`a.toString( 10 )`
a.toString( 8 );        // "52"
a.toString( 16 );        // "2a"
a.toString( 2 );        // "101010"

事实上,以这种方你可以用从236的任何进制表达一个数字,虽然你会使用标准进制 —— 2,8,10,和 16 ——之外的情况非常少见。

Unicode

我只能说这一节不是一个穷尽了“关于 Unicode 你想知道的一切”的资料。我想讲解的是,你需要知道在 ES6 中对 Unicode 改变了什么,但是我们不会比这深入太多。Mathias Bynens (twitter.com/mathias) 大量且出色地撰写/讲解了关于 JS 和 Unicode (参见 mathiasbynens.be/notes/javascript-unicodefluentconf.com/javascript-html-2015/public/content/2015/02/18-javascript-loves-unicode)。%E3%80%82)

0x00000xFFFF范围内的 Unicode 字符包含了所有的标准印刷字符(以各种语言),它们都是你可能看到过和互动过的。这组字符被称为 基本多文种平面(Basic Multilingual Plane (BMP))。BMP 甚至包含像这个酷雪人一样的有趣字符: ☃ (U+2603)。

在这个 BMP 集合之外还有许多扩展的 Unicode 字符,它们的范围一直到0x10FFFF。这些符号经常被称为 星形(astral) 符号,这正是 BMP 之外的字符的 16 组 平面 (也就是,分层/分组)的名称。星形符号的例子包括𝄞 (U+1D11E)和💩 (U+1F4A9)。

在 ES6 之前,JavaScript 字符串可以使用 Unicode 转义来指定 Unicode 字符,例如:

var snowman = "\u2603";
console.log( snowman );            // "☃"

然而,\uXXXXUnicode 转义仅支持四个十六进制字符,所以用这种方式表示你只能表示 BMP 集合中的字符。要在 ES6 以前使用 Unicode 转义表示一个星形字符,你需要使用一个 代理对(surrogate pair) —— 基本上是两个经特殊计算的 Unicode 转义字符放在一起,被 JS 解释为一个单独星形字符:

var gclef = "\uD834\uDD1E";
console.log( gclef );            // "𝄞"

在 ES6 中,我们现在有了一种 Unicode 转义的新形式(在字符串和正则表达式中),称为 Unicode 代码点转义

var gclef = "\u{1D11E}";
console.log( gclef );            // "𝄞"

如你所见,它的区别是出现在转义序列中的{ },它允许转义序列中包含任意数量的十六进制字符。因为你只需要六个就可以表示在 Unicode 中可能的最高代码点(也就是,0x10FFFF),所以这是足够的。

Unicode 敏感的字符串操作

在默认情况下,JavaScript 字符串操作和方法对字符串值中的星形符号是不敏感的。所以,它们独立地处理每个 BMP 字符,即便是可以组成一个单独字符的两半代理。考虑如下代码:

var snowman = "☃";
snowman.length;                    // 1

var gclef = "𝄞";
gclef.length;                    // 2

那么,我们如何才能正确地计算这样的字符串的长度呢?在这种场景下,下面的技巧可以工作:

var gclef = "𝄞";

[...gclef].length;                // 1
Array.from( gclef ).length;        // 1

回想一下本章早先的“for..of循环”一节,ES6 字符串拥有内建的迭代器。这个迭代器恰好是 Unicode 敏感的,这意味着它将自动地把一个星形符号作为一个单独的值输出。我们在一个数组字面量上使用扩散操作符...,利用它创建了一个字符串符号的数组。然后我们只需检查这个结果数组的长度。ES6 的Array.from(..)基本上与[...XYZ]做的事情相同,不过我们将在第六章中讲解这个工具的细节。

警告: 应当注意的是,相对地讲,与理论上经过优化的原生工具/属性将做的事情比起来,仅仅为了得到一个字符串的长度就构建并耗尽一个迭代器在性能上的代价是高昂的。

不幸的是,完整的答案并不简单或直接。除了代理对(字符串迭代器可以搞定的),一些特殊的 Unicode 代码点有其他特殊的行为,解释起来非常困难。例如,有一组代码点可以修改前一个相邻的字符,称为 组合变音符号(Combining Diacritical Marks)

考虑这两个数组的输出:

console.log( s1 );                // "é"
console.log( s2 );                // "é"

它们看起来一样,但它们不是!这是我们如何创建s1s2的:

var s1 = "\xE9",
    s2 = "e\u0301";

你可能猜到了,我们前面的length技巧对s2不管用:

[...s1].length;                    // 1
[...s2].length;                    // 2

那么我们能做什么?在这种情况下,我们可以使用 ES6 的String#normalize(..)工具,在查询这个值的长度前对它实施一个 Unicode 正规化操作

var s1 = "\xE9",
    s2 = "e\u0301";

s1.normalize().length;            // 1
s2.normalize().length;            // 1

s1 === s2;                        // false
s1 === s2.normalize();            // true

实质上,normalize(..)接受一个"e\u0301"这样的序列,并把它正规化为\xE9。正规化甚至可以组合多个相邻的组合符号,如果存在适合他们组合的 Unicode 字符的话:

var s1 = "o\u0302\u0300",
    s2 = s1.normalize(),
    s3 = "ồ";

s1.length;                        // 3
s2.length;                        // 1
s3.length;                        // 1

s2 === s3;                        // true

不幸的是,这里的正规化也不完美。如果你有多个组合符号在修改一个字符,你可能不会得到你所期望的长度计数,因为一个被独立定义的,可以表示所有这些符号组合的正规化字符可能不存在。例如:

var s1 = "e\u0301\u0330";

console.log( s1 );                // "ḛ́"

s1.normalize().length;            // 2

你越深入这个兔子洞,你就越能理解要得到一个“长度”的精确定义是很困难的。我们在视觉上看到的作为一个单独字符绘制的东西 —— 更精确地说,它称为一个 字形 —— 在程序处理的意义上不总是严格地关联到一个单独的“字符”上。

提示: 如果你就是想看看这个兔子洞有多深,看看“字形群集边界(Grapheme Cluster Boundaries)”算法(www.Unicode.org/reports/tr29/#Grapheme_Cluster_Boundaries)。%E3%80%82)

字符定位

与长度的复杂性相似,“在位置 2 上的字符是什么?”,这么问的意思究竟是什么?前 ES6 的原生答案来自charAt(..),它不会遵守一个星形字符的原子性,也不会考虑组合符号。

考虑如下代码:

var s1 = "abc\u0301d",
    s2 = "ab\u0107d",
    s3 = "ab\u{1d49e}d";

console.log( s1 );                // "abćd"
console.log( s2 );                // "abćd"
console.log( s3 );                // "ab𝒞d"

s1.charAt( 2 );                    // "c"
s2.charAt( 2 );                    // "ć"
s3.charAt( 2 );                    // "" <-- 不可打印的代理字符
s3.charAt( 3 );                    // "" <-- 不可打印的代理字符

那么,ES6 会给我们 Unicode 敏感版本的charAt(..)吗?不幸的是,不。在本书写作时,在后 ES6 的考虑之中有一个这样的工具的提案。

但是使用我们在前一节探索的东西(当然也带着它的限制!),我们可以黑一个 ES6 的答案:

var s1 = "abc\u0301d",
 s2 = "ab\u0107d",
 s3 = "ab\u{1d49e}d";

[...s1.normalize()][2];            // "ć"
[...s2.normalize()][2];            // "ć"
[...s3.normalize()][2];            // "𝒞"

警告: 提醒一个早先的警告:在每次你想得到一个单独的字符时构建并耗尽一个迭代器……在性能上不是很理想。对此,希望我们很快能在后 ES6 时代得到一个内建的,优化过的工具。

那么charCodeAt(..)工具的 Unicode 敏感版本呢?ES6 给了我们codePointAt(..)

var s1 = "abc\u0301d",
    s2 = "ab\u0107d",
    s3 = "ab\u{1d49e}d";

s1.normalize().codePointAt( 2 ).toString( 16 );
// "107"

s2.normalize().codePointAt( 2 ).toString( 16 );
// "107"

s3.normalize().codePointAt( 2 ).toString( 16 );
// "1d49e"

那么从另一个方向呢?String.fromCharCode(..)的 Unicode 敏感版本是 ES6 的String.fromCodePoint(..)

String.fromCodePoint( 0x107 );        // "ć"

String.fromCodePoint( 0x1d49e );    // "𝒞"

那么等一下,我们能组合String.fromCodePoint(..)codePointAt(..)来得到一个刚才的 Unicode 敏感charAt(..)的更好版本吗?是的!

var s1 = "abc\u0301d",
    s2 = "ab\u0107d",
    s3 = "ab\u{1d49e}d";

String.fromCodePoint( s1.normalize().codePointAt( 2 ) );
// "ć"

String.fromCodePoint( s2.normalize().codePointAt( 2 ) );
// "ć"

String.fromCodePoint( s3.normalize().codePointAt( 2 ) );
// "𝒞"

还有好几个字符串方法我们没有在这里讲解,包括toUpperCase()toLowerCase()substring(..)indexOf(..)slice(..),以及其他十几个。它们中没有任何一个为了完全支持 Unicode 而被改变或增强过,所以在处理含有星形符号的字符串是,你应当非常小心 —— 可能干脆回避它们!

还有几个字符串方法为了它们的行为而使用正则表达式,比如replace(..)match(..)。值得庆幸的是,ES6 为正则表达式带来了 Unicode 支持,正如我们在本章早前的“Unicode 标志”中讲解过的那样。

好了,就是这些!有了我们刚刚讲过的各种附加功能,JavaScript 的 Unicode 字符串支持要比前 ES6 时代好太多了(虽然还不完美)。

Unicode 标识符名称

Unicode 还可以被用于标识符名称(变量,属性,等等)。在 ES6 之前,你可以通过 Unicode 转义这么做,比如:

var \u03A9 = 42;

// 等同于:var Ω = 42;

在 ES6 中,你还可以使用前面讲过的代码点转义语法:

var \u{2B400} = 42;

// 等同于:var 𫐀 = 42;

关于究竟哪些 Unicode 字符被允许使用,有一组复杂的规则。另外,有些字符只要不是标识符名称的第一个字符就允许使用。

注意: 关于所有这些细节,Mathias Bynens 写了一篇了不起的文章 (mathiasbynens.be/notes/javascript-identifiers-es6)。%E3%80%82)

很少有理由,或者是为了学术上的目的,才会在标识符名称中使用这样不寻常的字符。你通常不会因为依靠这些深奥的功能编写代码而感到舒服。

Symbol

在 ES6 中,长久以来首次,有一个新的基本类型被加入到了 JavaScript:symbol。但是,与其他的基本类型不同,symbol 没有字面形式。

这是你如何创建一个 symbol:

var sym = Symbol( "some optional description" );

typeof sym;        // "symbol"

一些要注意的事情是:

  • 你不能也不应该将newSymbol(..)一起使用。它不是一个构造器,你也不是在产生一个对象。
  • 被传入Symbol(..)的参数是可选的。如果传入的话,它应当是一个字符串,为 symbol 的目的给出一个友好的描述。
  • typeof的输出是一个新的值("symbol"),这是识别一个 symbol 的主要方法。

如果描述被提供的话,它仅仅用于 symbol 的字符串化表示:

sym.toString();        // "Symbol(some optional description)"

与基本字符串值如何不是String的实例的原理很相似,symbol 也不是Symbol的实例。如果,由于某些原因,你想要为一个 symbol 值构建一个封箱的包装器对像,你可以做如下的事情:

sym instanceof Symbol;        // false

var symObj = Object( sym );
symObj instanceof Symbol;    // true

symObj.valueOf() === sym;    // true

注意: 在这个代码段中的symObjsym是可以互换使用的;两种形式可以在 symbol 被用到的地方使用。没有太多的理由要使用封箱的包装对象形式(symObj),而不用基本类型形式(sym)。和其他基本类型的建议相似,使用sym而非symObj可能是最好的。

一个 symbol 本身的内部值 —— 称为它的name —— 被隐藏在代码之外而不能取得。你可以认为这个 symbol 的值是一个自动生成的,(在你的应用程序中)独一无二的字符串值。

但如果这个值是隐藏且不可取得的,那么拥有一个 symbol 还有什么意义?

一个 symbol 的主要意义是创建一个不会和其他任何值冲突的类字符串值。所以,举例来说,可以考虑将一个 symbol 用做表示一个事件的名称的值:

const EVT_LOGIN = Symbol( "event.login" );

然后你可以在一个使用像"event.login"这样的一般字符串字面量的地方使用EVT_LOGIN

evthub.listen( EVT_LOGIN, function(data){
    // ..
} );

其中的好处是,EVT_LOGIN持有一个不能被其他任何值所(有意或无意地)重复的值,所以在哪个事件被分发或处理的问题上不可能存在任何含糊。

注意: 在前面的代码段的幕后,几乎可以肯定地认为evthub工具使用了EVT_LOGIN参数值的 symbol 值作为某个跟踪事件处理器的内部对象的属性/键。如果evthub需要将 symbol 值作为一个真实的字符串使用,那么它将需要使用String(..)或者toString(..)进行明确强制转换,因为 symbol 的隐含字符串强制转换是不允许的。

你可能会将一个 symbol 直接用做一个对象中的属性名/键,如此作为一个你想将之用于隐藏或元属性的特殊属性。重要的是,要知道虽然你试图这样对待它,但是它 实际上 并不是隐藏或不可接触的属性。

考虑这个实现了 单例 模式行为的模块 —— 也就是,它仅允许自己被创建一次:

const INSTANCE = Symbol( "instance" );

function HappyFace() {
    if (HappyFace[INSTANCE]) return HappyFace[INSTANCE];

    function smile() { .. }

    return HappyFace[INSTANCE] = {
        smile: smile
    };
}

var me = HappyFace(),
    you = HappyFace();

me === you;            // true

这里的 symbol 值INSTANCE是一个被静态地存储在HappyFace()函数对象上的特殊的,几乎是隐藏的,类元属性。

替代性地,它本可以是一个像__instance这样的普通属性,而且其行为将会是一模一样的。symbol 的使用仅仅增强了程序元编程的风格,将这个INSTANCE属性与其他普通的属性间保持隔离。

Symbol 注册表

在前面几个例子中使用 symbol 的一个微小的缺点是,变量EVT_LOGININSTANCE不得不存储在外部作用域中(甚至也许是全局作用域),或者用某种方法存储在一个可用的公共位置,这样代码所有需要使用这些 symbol 的部分都可以访问它们。

为了辅助组织访问这些 symbol 的代码,你可以使用 全局 symbol 注册表 来创建 symbol。例如:

const EVT_LOGIN = Symbol.for( "event.login" );

console.log( EVT_LOGIN );        // Symbol(event.login)

和:

function HappyFace() {
    const INSTANCE = Symbol.for( "instance" );

    if (HappyFace[INSTANCE]) return HappyFace[INSTANCE];

    // ..

    return HappyFace[INSTANCE] = { .. };
}

Symbol.for(..)查询全局 symbol 注册表来查看一个 symbol 是否已经使用被提供的说明文本存储过了,如果有就返回它。如果没有,就创建一个并返回。换句话说,全局 symbol 注册表通过描述文本将 symbol 值看作它们本身的单例。

但这也意味着只要使用匹配的描述名,你的应用程序的任何部分都可以使用Symbol.for(..)从注册表中取得 symbol。

讽刺的是,基本上 symbol 的本意是在你的应用程序中取代 魔法字符串 的使用(被赋予了特殊意义的随意的字符串值)。但是你正是在全局 symbol 注册表中使用 魔法 描述字符串值来唯一识别/定位它们的!

为了避免意外的冲突,你可能想使你的 symbol 描述十分独特。这么做的一个简单的方法是在它们之中包含前缀/环境/名称空间的信息。

例如,考虑一个像下面这样的工具:

function extractValues(str) {
    var key = Symbol.for( "extractValues.parse" ),
        re = extractValues[key] ||
            /[^=&]+?=([^&]+?)(?=&|$)/g,
        values = [], match;

    while (match = re.exec( str )) {
        values.push( match[1] );
    }

    return values;
}

我们使用魔法字符串值"extractValues.parse",因为在注册表中的其他任何 symbol 都不太可能与这个描述相冲突。

如果这个工具的一个用户想要覆盖这个解析用的正则表达式,他们也可以使用 symbol 注册表:

extractValues[Symbol.for( "extractValues.parse" )] =
    /..some pattern../g;

extractValues( "..some string.." );

除了 symbol 注册表在全局地存储这些值上提供的协助以外,我们在这里看到的一切其实都可以通过将魔法字符串"extractValues.parse"作为一个键,而不是一个 symbol,来做到。这其中在元编程的层次上的改进要多于在函数层次上的改进。

你可能偶然会使用一个已经被存储在注册表中的 symbol 值来查询它底层存储了什么描述文本(键)。例如,因为你无法传递 symbol 值本身,你可能需要通知你的应用程序的另一个部分如何在注册表中定位一个 symbol。

你可以使用Symbol.keyFor(..)取得一个被注册的 symbol 描述文本(键):

var s = Symbol.for( "something cool" );

var desc = Symbol.keyFor( s );
console.log( desc );            // "something cool"

// 再次从注册表取得 symbol
var s2 = Symbol.for( desc );

s2 === s;                        // true

Symbols 作为对象属性

如果一个 symbol 被用作一个对象的属性/键,它会被以一种特殊的方式存储,以至这个属性不会出现在这个对象属性的普通枚举中:

var o = {
    foo: 42,
    [ Symbol( "bar" ) ]: "hello world",
    baz: true
};

Object.getOwnPropertyNames( o );    // [ "foo","baz" ]

要取得对象的 symbol 属性:

Object.getOwnPropertySymbols( o );    // [ Symbol(bar) ]

这表明一个属性 symbol 实际上不是隐藏的或不可访问的,因为你总是可以在Object.getOwnPropertySymbols(..)的列表中看到它。

内建 Symbols

ES6 带来了好几种预定义的内建 symbol,它们暴露了在 JavaScript 对象值上的各种元行为。然而,正如人们所预料的那样,这些 symbol 没有 没被注册到全局 symbol 注册表中。

取而代之的是,它们作为属性被存储到了Symbol函数对象中。例如,在本章早先的“for..of”一节中,我们介绍了值Symbol.iterator

var a = [1,2,3];

a[Symbol.iterator];            // native function

语言规范使用@@前缀注释指代内建的 symbol,最常见的几个是:@@iterator@@toStringTag@@toPrimitive。还定义了几个其他的 symbol,虽然他们可能不那么频繁地被使用。

注意: 关于这些内建 symbol 如何被用于元编程的详细信息,参见第七章的“通用 Symbol”。

复习

ES6 给 JavaScript 增加了一堆新的语法形式,有好多东西要学!

这些东西中的大多数都是为了缓解常见编程惯用法中的痛点而设计的,比如为函数参数设置默认值和将“剩余”的参数收集到一个数组中。解构是一个强大的工具,用来更简约地表达从数组或嵌套对象的赋值。

虽然像箭头函数=>这样的特性看起来也都是关于更简短更好看的语法,但是它们实际上拥有非常特殊的行为,你应当在恰当的情况下有意地使用它们。

扩展的 Unicode 支持,新的正则表达式技巧,和新的symbol基本类型充实了 ES6 语法的发展演变。

你不懂 JS:ES6 与未来 第三章:组织(上)

编写 JS 代码是一回事儿,而合理地组织它是另一回事儿。利用常见的组织和重用模式在很大程度上改善了你代码的可读性和可理解性。记住:代码在与其他开发者交流上起的作用,与在给计算机喂指令上起的作用同样重要。

ES6 拥有几种重要的特性可以显著改善这些模式,包括:迭代器,generator,模块,和类。

迭代器

迭代器(iterator) 是一种结构化的模式,用于从一个信息源中以一次一个的方式抽取信息。这种模式在程序设计中存在很久了。而且不可否认的是,不知从什么时候起 JS 开发者们就已经特别地设计并实现了迭代器,所以它根本不是什么新的话题。

ES6 所做的是,为迭代器引入了一个隐含的标准化接口。许多在 JavaScript 中内建的数据结构现在都会暴露一个实现了这个标准的迭代器。而且你也可以构建自己的遵循同样标准的迭代器,来使互用性最大化。

迭代器是一种消费数据的方法,它是组织有顺序的,相继的,基于抽取的。

举个例子,你可能实现一个工具,它在每次被请求时产生一个新的唯一的标识符。或者你可能循环一个固定的列表以轮流的方式产生一系列无限多的值。或者你可以在一个数据库查询的结果上添加一个迭代器来一次抽取一行结果。

虽然在 JS 中它们不经常以这样的方式被使用,但是迭代器还可以认为是每次控制行为中的一个步骤。这会在考虑 generator 时得到相当清楚的展示(参见本章稍后的“Generator”),虽然你当然可以不使用 generator 而做同样的事。

接口

在本书写作的时候,ES6 的 25.1.1.2 部分 (people.mozilla.org/~jorendorff/es6-draft.html#sec-iterator-interface) 详述了Iterator接口,它有如下的要求:

Iterator [必须]
    next() {method}: 取得下一个 IteratorResult

有两个可选成员,有些迭代器用它们进行了扩展:

Iterator [可选]
    return() {method}: 停止迭代并返回 IteratorResult
    throw() {method}: 通知错误并返回 IteratorResult

接口IteratorResult被规定为:

IteratorResult
    value {property}: 当前的迭代值或最终的返回值
        (如果它的值为`undefined`,是可选的)
    done {property}: 布尔值,指示完成的状态

注意: 我称这些接口是隐含的,不是因为它们没有在语言规范中被明确地被说出来 —— 它们被说出来了!—— 而是因为它们没有作为可以直接访问的对象暴露给代码。在 ES6 中,JavaScript 不支持任何“接口”的概念,所以在你自己的代码中遵循它们纯粹是惯例上的。但是,不论 JS 在何处需要一个迭代器 —— 例如在一个for..of循环中 —— 你提供的东西必须遵循这些接口,否则代码就会失败。

还有一个Iterable接口,它描述了一定能够产生迭代器的对象:

Iterable
    @@iterator() {method}: 产生一个迭代器

如果你回忆一下第二章的“内建 Symbol”,@@iterator是一种特殊的内建 symbol,表示可以为对象产生迭代器的方法。

IteratorResult

IteratorResult接口规定从任何迭代器操作的返回值都是这样形式的对象:

{ value: .. , done: true / false }

内建迭代器将总是返回这种形式的值,当然,更多的属性也允许出现在这个返回值中,如果有必要的话。

例如,一个自定义的迭代器可能会在结果对象中加入额外的元数据(比如,数据是从哪里来的,取得它花了多久,缓存过期的时间长度,下次请求的恰当频率,等等)。

注意: 从技术上讲,在值为undefined的情况下,value是可选的,它将会被认为是不存在或者是没有被设置。因为不管它是表示的就是这个值还是完全不存在,访问res.value都将会产生undefined,所以这个属性的存在/不存在更大程度上是一个实现或者优化(或两者)的细节,而非一个功能上的问题。

next()迭代

让我们来看一个数组,它是一个可迭代对象,可以生成一个迭代器来消费它的值:

var arr = [1,2,3];

var it = arr[Symbol.iterator]();

it.next();        // { value: 1, done: false }
it.next();        // { value: 2, done: false }
it.next();        // { value: 3, done: false }

it.next();        // { value: undefined, done: true }

每一次定位在Symbol.iterator上的方法在值arr上被调用时,它都将生成一个全新的迭代器。大多数的数据结构都会这么做,包括所有内建在 JS 中的数据结构。

然而,像事件队列这样的结构也许只能生成一个单独的迭代器(单例模式)。或者某种结构可能在同一时间内只允许存在一个唯一的迭代器,要求当前的迭代器必须完成,才能创建一个新的。

前一个代码段中的it迭代器不会再你得到值3时报告done: true。你必须再次调用next(),实质上越过数组末尾的值,才能得到完成信号done: true。在这一节稍后会清楚地讲解这种设计方式的原因,但是它通常被认为是一种最佳实践。

基本类型的字符串值也默认地是可迭代对象:

var greeting = "hello world";

var it = greeting[Symbol.iterator]();

it.next();        // { value: "h", done: false }
it.next();        // { value: "e", done: false }
..

注意: 从技术上讲,这个基本类型值本身不是可迭代对象,但多亏了“封箱”,"hello world"被强制转换为它的String对象包装形式, 才是一个可迭代对象。更多信息参见本系列的 类型与文法

ES6 还包括几种新的数据结构,称为集合(参见第五章)。这些集合不仅本身就是可迭代对象,而且它们还提供 API 方法来生成一个迭代器,例如:

var m = new Map();
m.set( "foo", 42 );
m.set( { cool: true }, "hello world" );

var it1 = m[Symbol.iterator]();
var it2 = m.entries();

it1.next();        // { value: [ "foo", 42 ], done: false }
it2.next();        // { value: [ "foo", 42 ], done: false }
..

一个迭代器的next(..)方法能够可选地接受一个或多个参数。大多数内建的迭代器不会实施这种能力,虽然一个 generator 的迭代器绝对会这么做(参见本章稍后的“Generator”)。

根据一般的惯例,包括所有的内建迭代器,在一个已经被耗尽的迭代器上调用next(..)不是一个错误,而是简单地持续返回结果{ value: undefined, done: true }

可选的return(..)throw(..)

在迭代器接口上的可选方法 —— return(..)throw(..) —— 在大多数内建的迭代器上都没有被实现。但是,它们在 generator 的上下文环境中绝对有某些含义,所以更具体的信息可以参看“Generator”。

return(..)被定义为向一个迭代器发送一个信号,告知它消费者代码已经完成而且不会再从它那里抽取更多的值。这个信号可以用于通知生产者(应答next(..)调用的迭代器)去实施一些可能的清理作业,比如释放/关闭网络,数据库,或者文件引用资源。

如果一个迭代器拥有return(..),而且发生了可以自动被解释为非正常或者提前终止消费迭代器的任何情况,return(..)就将会被自动调用。你也可以手动调用return(..)

return(..)将会像next(..)一样返回一个IteratorResult对象。一般来说,你向return(..)发送的可选值将会在这个IteratorResult中作为value发送回来,虽然在一些微妙的情况下这可能不成立。

throw(..)被用于向一个迭代器发送一个异常/错误信号,与return(..)隐含的完成信号相比,它可能会被迭代器用于不同的目的。它不一定像return(..)一样暗示着迭代器的完全停止。

例如,在 generator 迭代器中,throw(..)实际上会将一个被抛出的异常注射到 generator 暂停的执行环境中,这个异常可以用try..catch捕获。一个未捕获的throw(..)异常将会导致 generator 的迭代器异常中止。

注意: 根据一般的惯例,在return(..)throw(..)被调用之后,一个迭代器就不应该在产生任何结果了。

迭代器循环

正如我们在第二章的“for..of”一节中讲解的,ES6 的for..of循环可以直接消费一个规范的可迭代对象。

如果一个迭代器也是一个可迭代对象,那么它就可以直接与for..of循环一起使用。通过给予迭代器一个简单地返回它自身的Symbol.iterator方法,你就可以使它成为一个可迭代对象:

var it = {
 // 使迭代器`it`成为一个可迭代对象
 [Symbol.iterator]() { return this; },

 next() { .. },
 ..
};

it[Symbol.iterator]() === it;        // true

现在我们就可以用一个for..of循环来消费迭代器it了:

for (var v of it) {
    console.log( v );
}

为了完全理解这样的循环如何工作,回忆下第二章中的for..of循环的for等价物:

for (var v, res; (res = it.next()) && !res.done; ) {
    v = res.value;
    console.log( v );
}

如果你仔细观察,你会发现it.next()是在每次迭代之前被调用的,然后res.done才被查询。如果res.donetrue,那么这个表达式将会求值为false于是这次迭代不会发生。

回忆一下之前我们建议说,迭代器一般不应与最终预期的值一起返回done: true。现在你知道为什么了。

如果一个迭代器返回了{ done: true, value: 42 }for..of循环将完全扔掉值42。因此,假定你的迭代器可能会被for..of循环或它的for等价物这样的模式消费的话,你可能应当等到你已经返回了所有相关的迭代值之后才返回done: true来表示完成。

警告: 当然,你可以有意地将你的迭代器设计为将某些相关的valuedone: true同时返回。但除非你将此情况在文档中记录下来,否则不要这么做,因为这样会隐含地强制你的迭代器消费者使用一种,与我们刚才描述的for..of或它的手动等价物不同的模式来进行迭代。

自定义迭代器

除了标准的内建迭代器,你还可以制造你自己的迭代器!所有使它们可以与 ES6 消费设施(例如,for..of循环和...操作符)进行互动的代价就是遵循恰当的接口。

让我们试着构建一个迭代器,它能够以斐波那契(Fibonacci)数列的形式产生无限多的数字序列:

var Fib = {
    [Symbol.iterator]() {
        var n1 = 1, n2 = 1;

        return {
            // 使迭代器成为一个可迭代对象
            [Symbol.iterator]() { return this; },

            next() {
                var current = n2;
                n2 = n1;
                n1 = n1 + current;
                return { value: current, done: false };
            },

            return(v) {
                console.log(
                    "Fibonacci sequence abandoned."
                );
                return { value: v, done: true };
            }
        };
    }
};

for (var v of Fib) {
    console.log( v );

    if (v > 50) break;
}
// 1 1 2 3 5 8 13 21 34 55
// Fibonacci sequence abandoned.

警告: 如果我们没有插入break条件,这个for..of循环将会永远运行下去,这回破坏你的程序,因此可能不是我们想要的!

方法Fib[Symbol.iterator]()在被调用时返回带有next()return(..)方法的迭代器对象。它的状态通过变量n1n2维护在闭包中。

接下来让我们考虑一个迭代器,它被设计为执行一系列(也叫队列)动作,一次一个:

var tasks = {
    [Symbol.iterator]() {
        var steps = this.actions.slice();

        return {
            // 使迭代器成为一个可迭代对象
            [Symbol.iterator]() { return this; },

            next(...args) {
                if (steps.length > 0) {
                    let res = steps.shift()( ...args );
                    return { value: res, done: false };
                }
                else {
                    return { done: true }
                }
            },

            return(v) {
                steps.length = 0;
                return { value: v, done: true };
            }
        };
    },
    actions: []
};

tasks上的迭代器步过在数组属性actions中找到的函数,并每次执行它们中的一个,并传入你传递给next(..)的任何参数值,并在标准的IteratorResult对象中向你返回任何它返回的东西。

这是我们如何使用这个tasks队列:

tasks.actions.push(
    function step1(x){
        console.log( "step 1:", x );
        return x * 2;
    },
    function step2(x,y){
        console.log( "step 2:", x, y );
        return x + (y * 2);
    },
    function step3(x,y,z){
        console.log( "step 3:", x, y, z );
        return (x * y) + z;
    }
);

var it = tasks[Symbol.iterator]();

it.next( 10 );            // step 1: 10
                        // { value:   20, done: false }

it.next( 20, 50 );        // step 2: 20 50
                        // { value:  120, done: false }

it.next( 20, 50, 120 );    // step 3: 20 50 120
                        // { value: 1120, done: false }

it.next();                // { done: true }

这种特别的用法证实了迭代器可以是一种具有组织功能的模式,不仅仅是数据。这也联系着我们在下一节关于 generator 将要看到的东西。

你甚至可以更有创意一些,在一块数据上定义一个表示元操作的迭代器。例如,我们可以为默认从 0 开始递增至(或递减至,对于负数来说)指定数字的一组数字定义一个迭代器。

考虑如下代码:

if (!Number.prototype[Symbol.iterator]) {
    Object.defineProperty(
        Number.prototype,
        Symbol.iterator,
        {
            writable: true,
            configurable: true,
            enumerable: false,
            value: function iterator(){
                var i, inc, done = false, top = +this;

                // 正向迭代还是负向迭代?
                inc = 1 * (top < 0 ? -1 : 1);

                return {
                    // 使迭代器本身成为一个可迭代对象!
                    [Symbol.iterator](){ return this; },

                    next() {
                        if (!done) {
                            // 最初的迭代总是 0
                            if (i == null) {
                                i = 0;
                            }
                            // 正向迭代
                            else if (top >= 0) {
                                i = Math.min(top,i + inc);
                            }
                            // 负向迭代
                            else {
                                i = Math.max(top,i + inc);
                            }

                            // 这次迭代之后就完了?
                            if (i == top) done = true;

                            return { value: i, done: false };
                        }
                        else {
                            return { done: true };
                        }
                    }
                };
            }
        }
    );
}

现在,这种创意给了我们什么技巧?

for (var i of 3) {
    console.log( i );
}
// 0 1 2 3

[...-3];                // [0,-1,-2,-3]

这是一些有趣的技巧,虽然其实际用途有些值得商榷。但是再一次,有人可能想知道为什么 ES6 没有提供如此微小但讨喜的特性呢?

如果我连这样的提醒都没给过你,那就是我的疏忽:像我在前面的代码段中做的那样扩展原生原型,是一件你需要小心并了解潜在的危害后才应该做的事情。

在这样的情况下,你与其他代码或者未来的 JS 特性发生冲突的可能性非常低。但是要小心微小的可能性。并在文档中为后人详细记录下你在做什么。

注意: 如果你想知道更多细节,我在这篇文章(blog.getify.com/iterating-es6-numbers/) 中详细论述了这种特别的技术。而且这段评论(blog.getify.com/iterating-es6-numbers/comment-page-1/#comment-535294)甚至为制造一个字符串字符范围提出了一个相似的技巧。%E7%94%9A%E8%87%B3%E4%B8%BA%E5%88%B6%E9%80%A0%E4%B8%80%E4%B8%AA%E5%AD%97%E7%AC%A6%E4%B8%B2%E5%AD%97%E7%AC%A6%E8%8C%83%E5%9B%B4%E6%8F%90%E5%87%BA%E4%BA%86%E4%B8%80%E4%B8%AA%E7%9B%B8%E4%BC%BC%E7%9A%84%E6%8A%80%E5%B7%A7%E3%80%82)

消费迭代器

我们已经看到了使用for..of循环来一个元素一个元素地消费一个迭代器。但是还有一些其他的 ES6 结构可以消费迭代器。

让我们考虑一下附着这个数组上的迭代器(虽然任何我们选择的迭代器都将拥有如下的行为):

var a = [1,2,3,4,5];

扩散操作符...将完全耗尽一个迭代器。考虑如下代码:

function foo(x,y,z,w,p) {
    console.log( x + y + z + w + p );
}

foo( ...a );            // 15

...还可以在一个数组内部扩散一个迭代器:

var b = [ 0, ...a, 6 ];
b;                        // [0,1,2,3,4,5,6]

数组解构(参见第二章的“解构”)可以部分地或者完全地(如果与一个...剩余/收集操作符一起使用)消费一个迭代器:

var it = a[Symbol.iterator]();

var [x,y] = it;            // 仅从`it`中取前两个元素
var [z, ...w] = it;        // 取第三个,然后一次取得剩下所有的

// `it`被完全耗尽了吗?是的
it.next();                // { value: undefined, done: true }

x;                        // 1
y;                        // 2
z;                        // 3
w;                        // [4,5]

Generator

所有的函数都会运行至完成,对吧?换句话说,一旦一个函数开始运行,在它完成之前没有任何东西能够打断它。

至少对于到目前为止的 JavaScript 的整个历史来说是这样的。在 ES6 中,引入了一个有些异乎寻常的新形式的函数,称为 generator。一个 generator 可以在运行期间暂停它自己,还可以立即或者稍后继续运行。所以显然它没有普通函数那样的运行至完成的保证。

另外,在运行期间的每次暂停/继续轮回都是一个双向消息传递的好机会,generator 可以在这里返回一个值,而使它继续的控制端代码可以发回一个值。

就像前一节中的迭代器一样,有种方式可以考虑 generator 是什么,或者说它对什么最有用。对此没有一个正确的答案,但我们将试着从几个角度考虑。

注意: 关于 generator 的更多信息参见本系列的 异步与性能,还可以参见本书的第四章。

语法

generator 函数使用这种新语法声明:

function *foo() {
    // ..
}

*的位置在功能上无关紧要。同样的声明还可以写做以下的任意一种:

function *foo() { .. }
function* foo() { .. }
function * foo() { .. }
function*foo() { .. }
..

这里 唯一 的区别就是风格的偏好。大多数其他的文献似乎喜欢function* foo(..) { .. }。我喜欢function *foo(..) { .. },所以这就是我将在本书剩余部分中表示它们的方法。

我这样做的理由实质上纯粹是为了教学。在这本书中,当我引用一个 generator 函数时,我将使用*foo(..),与普通函数的foo(..)相对。我发现*foo(..)function *foo(..) { .. }*的位置更加吻合。

另外,就像我们在第二章的简约方法中看到的,在对象字面量中有一种简约 generator 形式:

var a = {
    *foo() { .. }
};

我要说在简约 generator 中,*foo() { .. }要比* foo() { .. }更自然。这进一步表明了为何使用*foo()匹配一致性。

一致性使理解与学习更轻松。

执行一个 Generator

虽然一个 generator 使用*进行声明,但是你依然可以像一个普通函数那样执行它:

foo();

你依然可以传给它参数值,就像:

function *foo(x,y) {
    // ..
}

foo( 5, 10 );

主要区别在于,执行一个 generator,比如foo(5,10),并不实际运行 generator 中的代码。取而代之的是,它生成一个迭代器来控制 generator 执行它的代码。

我们将在稍后的“迭代器控制”中回到这个话题,但是简要地说:

function *foo() {
    // ..
}

var it = foo();

// 要开始/推进`*foo()`,调用
// `it.next(..)`

yield

Generator 还有一个你可以在它们内部使用的新关键字,用来表示暂停点:yield。考虑如下代码:

function *foo() {
    var x = 10;
    var y = 20;

    yield;

    var z = x + y;
}

在这个*foo()generator 中,前两行的操作将会在开始时运行,然后yield将会暂停这个 generator。如果这个 generator 被继续,*foo()的最后一行将运行。在一个 generator 中yield可以出现任意多次(或者,在技术上讲,根本不出现!)。

你甚至可以在一个循环内部放置yield,它可以表示一个重复的暂停点。事实上,一个永不完成的循环就意味着一个永不完成的 generator,这是完全合法的,而且有时候完全是你需要的。

yield不只是一个暂停点。它是在暂停 generator 时发送出一个值的表达式。这里是一个位于 generator 中的while..true循环,它每次迭代时yield出一个新的随机数:

function *foo() {
    while (true) {
        yield Math.random();
    }
}

yield ..表达式不仅发送一个值 —— 不带值的yieldyield undefined相同 —— 它还接收(也就是,被替换为)最终的继续值。考虑如下代码:

function *foo() {
    var x = yield 10;
    console.log( x );
}

这个 generator 在暂停它自己时将首先yield出值10。当你继续这个 generator 时 —— 使用我们先前提到的it.next(..) —— 无论你使用什么值继续它,这个值都将替换/完成整个表达式yield 10,这意味着这个值将被赋值给变量x

一个yield..表达式可以出现在任意普通表达式可能出现的地方。例如:

function *foo() {
    var arr = [ yield 1, yield 2, yield 3 ];
    console.log( arr, yield 4 );
}

这里的*foo()有四个yield ..表达式。其中每个yield都会导致 generator 暂停以等待一个继续值,这个继续值稍后被用于各个表达式环境中。

yield在技术上讲不是一个操作符,虽然像yield 1这样使用时看起来确实很像。因为yield可以像var x = yield这样完全通过自己被使用,所以将它认为是一个操作符有时令人困惑。

从技术上讲,yield ..a = 3这样的赋值表达式拥有相同的“表达式优先级” —— 概念上和操作符优先级很相似。这意味着yield ..基本上可以出现在任何a = 3可以合法出现的地方。

让我们展示一下这种对称性:

var a, b;

a = 3;                    // 合法
b = 2 + a = 3;            // 不合法
b = 2 + (a = 3);        // 合法

yield 3;                // 合法
a = 2 + yield 3;        // 不合法
a = 2 + (yield 3);        // 合法

注意: 如果你好好考虑一下,认为一个yield ..表达式与一个赋值表达式的行为相似在概念上有些道理。当一个被暂停的 generator 被继续时,它就以一种与被这个继续值“赋值”区别不大的方式,被这个值完成/替换。

要点:如果你需要yield ..出现在a = 3这样的赋值本不被允许出现的位置,那么它就需要被包在一个( )中。

因为yield关键字的优先级很低,几乎任何出现在yield ..之后的表达式都会在被yield发送之前首先被计算。只有扩散操作符...和逗号操作符,拥有更低的优先级,这意味着他们会在yield已经被求值之后才会被处理。

所以正如带有多个操作符的普通语句一样,存在另一个可能需要( )来覆盖(提升)yield的低优先级的情况,就像这些表达式之间的区别:

yield 2 + 3;            // 与`yield (2 + 3)`相同

(yield 2) + 3;            // 首先`yield 2`,然后`+ 3`

=赋值一样,yield也是“右结合性”的,这意味着多个接连出现的yield表达式被视为从右到左被( .. )分组。所以,yield yield yield 3将被视为yield (yield (yield 3))。像((yield) yield) yield 3这样的“左结合性”解释没有意义。

和其他操作符一样,yield与其他操作符或yield组合时为了使你的意图没有歧义,使用( .. )分组是一个好主意,即使这不是严格要求的。

注意: 更多关于操作符优先级和结合性的信息,参见本系列的 类型与文法

yield *

*使一个function声明成为一个function *generator 声明的方式一样,一个*使yield成为一个机制非常不同的yield *,称为 yield 委托。从文法上讲,yield *..的行为与yield ..相同,就像在前一节讨论过的那样。

yield * ..需要一个可迭代对象;然后它调用这个可迭代对象的迭代器,并将它自己的宿主 generator 的控制权委托给那个迭代器,直到它被耗尽。考虑如下代码:

function *foo() {
    yield *[1,2,3];
}

注意: 与 generator 声明中*的位置(早先讨论过)一样,在yield *表达式中的*的位置在风格上由你来决定。大多数其他文献偏好yield* ..,但是我喜欢yield *..,理由和我们已经讨论过的相同。

[1,2,3]产生一个将会步过它的值的迭代器,所以 generator*foo()将会在被消费时产生这些值。另一种说明这种行为的方式是,yield 委托到了另一个 generator:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

function *bar() {
    yield *foo();
}

*bar()调用*foo()产生的迭代器通过yield *受到委托,意味着无论*foo()产生什么值都会被*bar()产生。

yield ..中表达式的完成值来自于使用it.next(..)继续 generator,而yield *..表达式的完成值来自于受到委托的迭代器的返回值(如果有的话)。

内建的迭代器一般没有返回值,正如我们在本章早先的“迭代器循环”一节的末尾讲过的。但是如果你定义你自己的迭代器(或者 generator),你就可以将它设计为return一个值,yield *..将会捕获它:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
    return 4;
}

function *bar() {
    var x = yield *foo();
    console.log( "x:", x );
}

for (var v of bar()) {
    console.log( v );
}
// 1 2 3
// x: { value: 4, done: true }

虽然值12,和3*foo()中被yield出来,然后从*bar()中被yield出来,但是从*foo()中返回的值4是表达式yield *foo()的完成值,然后它被赋值给x

因为yield *可以调用另一个 generator(通过委托到它的迭代器的方式),它还可以通过调用自己来实施某种 generator 递归:

function *foo(x) {
    if (x < 3) {
        x = yield *foo( x + 1 );
    }
    return x * 2;
}

foo( 1 );

取得foo(1)的结果并调用迭代器的next()来使它运行它的递归步骤,结果将是24。第一次*foo()运行时x拥有值1,它是x < 3x + 1被递归地传递到*foo(..),所以之后的x2。再一次递归调用导致x3

现在,因为x < 3失败了,递归停止,而且return 3 * 26给回前一个调用的yeild *..表达式,它被赋值给x。另一个return 6 * 2返回12给前一个调用的x。最终12 * 2,即24,从 generator*foo(..)运行的完成中被返回。

迭代器控制

早先,我们简要地介绍了 generator 是由迭代器控制的概念。现在让我们完整地深入这个话题。

回忆一下前一节的递归*for(..)。这是我们如何运行它:

function *foo(x) {
    if (x < 3) {
        x = yield *foo( x + 1 );
    }
    return x * 2;
}

var it = foo( 1 );
it.next();                // { value: 24, done: true }

在这种情况下,generator 并没有真正暂停过,因为这里没有yield ..表达式。而yield *只是通过递归调用保持当前的迭代步骤继续运行下去。所以,仅仅对迭代器的next()函数进行一次调用就完全地运行了 generator。

现在让我们考虑一个有多个步骤并且因此有多个产生值的 generator:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

我们已经知道我们可以是使用一个for..of循环来消费一个迭代器,即便它是一个附着在*foo()这样的 generator 上:

for (var v of foo()) {
    console.log( v );
}
// 1 2 3

注意: for..of循环需要一个可迭代对象。一个 generator 函数引用(比如foo)本身不是一个可迭代对象;你必须使用foo()来执行它以得到迭代器(它也是一个可迭代对象,正如我们在本章早先讲解过的)。理论上你可以使用一个实质上仅仅执行return this()Symbol.iterator函数来扩展GeneratorPrototype(所有 generator 函数的原型)。这将使foo引用本身成为一个可迭代对象,也就意味着for (var v of foo) { .. }(注意在foo上没有())将可以工作。

让我们手动迭代这个 generator:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

var it = foo();

it.next();                // { value: 1, done: false }
it.next();                // { value: 2, done: false }
it.next();                // { value: 3, done: false }

it.next();                // { value: undefined, done: true }

如果你仔细观察,这里有三个yield语句和四个next()调用。这可能看起来像是一个奇怪的不匹配。事实上,假定所有的东西都被求值并且 generator 完全运行至完成的话,next()调用将总是比yield表达式多一个。

但是如果你相反的角度观察(从里向外而不是从外向里),yieldnext()之间的匹配就显得更有道理。

回忆一下,yield ..表达式将被你用于继续 generator 的值完成。这意味着你传递给next(..)的参数值将完成任何当前暂停中等待完成的yield ..表达式。

让我们这样展示一下这种视角:

function *foo() {
    var x = yield 1;
    var y = yield 2;
    var z = yield 3;
    console.log( x, y, z );
}

在这个代码段中,每个yield ..都送出一个值(123),但更直接的是,它暂停了 generator 来等待一个值。换句话说,它就像在问这样一个问题,“我应当在这里用什么值?我会在这里等你告诉我。”

现在,这是我们如何控制*foo()来启动它:

var it = foo();

it.next();                // { value: 1, done: false }

这第一个next()调用从 generator 初始的暂停状态启动了它,并运行至第一个yield。在你调用第一个next()的那一刻,并没有yield ..表达式等待完成。如果你给第一个next()调用传递一个值,目前它会被扔掉,因为没有yield等着接受这样的一个值。

注意: 一个“ES6 之后”时间表中的早期提案 允许你在 generator 内部通过一个分离的元属性(见第七章)来访问一个被传入初始next(..)调用的值。

现在,让我们回答那个未解的问题,“我应当给x赋什么值?” 我们将通过给 下一个 next(..)调用发送一个值来回答:

it.next( "foo" );        // { value: 2, done: false }

现在,x将拥有值"foo",但我们也问了一个新的问题,“我应当给y赋什么值?”

it.next( "bar" );        // { value: 3, done: false }

答案给出了,另一个问题被提出了。最终答案:

it.next( "baz" );        // "foo" "bar" "baz"
                        // { value: undefined, done: true }

现在,每一个yield ..的“问题”是如何被 下一个 next(..)调用回答的,所以我们观察到的那个“额外的”next()调用总是使一切开始的那一个。

让我们把这些步骤放在一起:

var it = foo();

// 启动 generator
it.next();                // { value: 1, done: false }

// 回答第一个问题
it.next( "foo" );        // { value: 2, done: false }

// 回答第二个问题
it.next( "bar" );        // { value: 3, done: false }

// 回答第三个问题
it.next( "baz" );        // "foo" "bar" "baz"
                        // { value: undefined, done: true }

在生成器的每次迭代都简单地为消费者生成一个值的情况下,你可认为一个 generator 是一个值的生成器。

但是在更一般的意义上,也许将 generator 认为是一个受控制的,累进的代码执行过程更恰当,与早先“自定义迭代器”一节中的tasks队列的例子非常相像。

注意: 这种视角正是我们将如何在第四章中重温 generator 的动力。特别是,next(..)没有理由一定要在前一个next(..)完成之后立即被调用。虽然 generator 的内部执行环境被暂停了,程序的其他部分仍然没有被阻塞,这包括控制 generator 什么时候被继续的异步动作能力。

提前完成

正如我们在本章早先讲过的,连接到一个 generator 的迭代器支持可选的return(..)throw(..)方法。它们俩都有立即中止一个暂停的的 generator 的效果。

考虑如下代码:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

var it = foo();

it.next();                // { value: 1, done: false }

it.return( 42 );        // { value: 42, done: true }

it.next();                // { value: undefined, done: true }

return(x)有点像强制一个return x就在那个时刻被处理,这样你就立即得到这个指定的值。一旦一个 generator 完成,无论是正常地还是像展示的那样提前地,它就不再处理任何代码或返回任何值了。

return(..)除了可以手动调用,它还在迭代的最后被任何 ES6 中消费迭代器的结构自动调用,比如for..of循环和...扩散操作符。

这种能力的目的是,在控制端的代码不再继续迭代 generator 时它可以收到通知,这样它就可能做一些清理工作(释放资源,复位状态,等等)。与普通函数的清理模式完全相同,达成这个目的的主要方法是使用一个finally子句:

function *foo() {
    try {
        yield 1;
        yield 2;
        yield 3;
    }
    finally {
        console.log( "cleanup!" );
    }
}

for (var v of foo()) {
    console.log( v );
}
// 1 2 3
// cleanup!

var it = foo();

it.next();                // { value: 1, done: false }
it.return( 42 );        // cleanup!
                        // { value: 42, done: true }

警告: 不要把yield语句放在finally子句内部!它是有效和合法的,但这确实是一个可怕的主意。它在某种意义上推迟了return(..)调用的完成,因为在finally子句中的任何yield ..表达式都被遵循来暂停和发送消息;你不会像期望的那样立即得到一个完成的 generator。基本上没有任何好的理由去选择这种疯狂的 坏的部分,所以避免这么做!

前一个代码段除了展示return(..)如何在中止 generator 的同时触发finally子句,它还展示了一个 generator 在每次被调用时都产生一个全新的迭代器。事实上,你可以并发地使用连接到相同 generator 的多个迭代器:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

var it1 = foo();
it1.next();                // { value: 1, done: false }
it1.next();                // { value: 2, done: false }

var it2 = foo();
it2.next();                // { value: 1, done: false }

it1.next();                // { value: 3, done: false }

it2.next();                // { value: 2, done: false }
it2.next();                // { value: 3, done: false }

it2.next();                // { value: undefined, done: true }
it1.next();                // { value: undefined, done: true }

提前中止

你可以调用throw(..)来代替return(..)调用。就像return(x)实质上在 generator 当前的暂停点上注入了一个return x一样,调用throw(x)实质上就像在暂停点上注入了一个throw x

除了处理异常的行为(我们在下一节讲解这对try子句意味着什么),throw(..)产生相同的提前完成 —— 在 generator 当前的暂停点中止它的运行。例如:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

var it = foo();

it.next();                // { value: 1, done: false }

try {
    it.throw( "Oops!" );
}
catch (err) {
    console.log( err );    // Exception: Oops!
}

it.next();                // { value: undefined, done: true }

因为throw(..)基本上注入了一个throw ..来替换 generator 的yield 1这一行,而且没有东西处理这个异常,它立即传播回外面的调用端代码,调用端代码使用了一个try..catch来处理了它。

return(..)不同的是,迭代器的throw(..)方法绝不会被自动调用。

当然,虽然没有在前面的代码段中展示,但如果当你调用throw(..)时有一个try..finally子句等在 generator 内部的话,这个finally子句将会在异常被传播回调用端代码之前有机会运行。

错误处理

正如我们已经得到的提示,generator 中的错误处理可以使用try..catch表达,它在上行和下行两个方向都可以工作。

function *foo() {
    try {
        yield 1;
    }
    catch (err) {
        console.log( err );
    }

    yield 2;

    throw "Hello!";
}

var it = foo();

it.next();                // { value: 1, done: false }

try {
    it.throw( "Hi!" );    // Hi!
                        // { value: 2, done: false }
    it.next();

    console.log( "never gets here" );
}
catch (err) {
    console.log( err );    // Hello!
}

错误也可以通过yield *委托在两个方向上传播:

function *foo() {
    try {
        yield 1;
    }
    catch (err) {
        console.log( err );
    }

    yield 2;

    throw "foo: e2";
}

function *bar() {
    try {
        yield *foo();

        console.log( "never gets here" );
    }
    catch (err) {
        console.log( err );
    }
}

var it = bar();

try {
    it.next();            // { value: 1, done: false }

    it.throw( "e1" );    // e1
                        // { value: 2, done: false }

    it.next();            // foo: e2
                        // { value: undefined, done: true }
}
catch (err) {
    console.log( "never gets here" );
}

it.next();                // { value: undefined, done: true }

*foo()调用yield 1时,值1原封不动地穿过了*bar(),就像我们已经看到过的那样。

但这个代码段最有趣的部分是,当*foo()调用throw "foo: e2"时,这个错误传播到了*bar()并立即被*bar()try..catch块儿捕获。错误没有像值1那样穿过*bar()

然后*bar()catcherr普通地输出("foo: e2")之后*bar()就正常结束了,这就是为什么迭代器结果{ value: undefined, done: true }it.next()中返回。

如果*bar()没有用try..catch环绕着yield *..表达式,那么错误将理所当然地一直传播出来,而且在它传播的路径上依然会完成(中止)*bar()

转译一个 Generator

有可能在 ES6 之前的环境中表达 generator 的能力吗?事实上是可以的,而且有好几种了不起的工具在这么做,包括最著名的 Facebook 的 Regenerator 工具 (facebook.github.io/regenerator/)。%E3%80%82)

但为了更好地理解 generator,让我们试着手动转换一下。基本上讲,我们将制造一个简单的基于闭包的状态机。

我们将使原本的 generator 非常简单:

function *foo() {
    var x = yield 42;
    console.log( x );
}

开始之前,我们将需要一个我们能够执行的称为foo()的函数,它需要返回一个迭代器:

function foo() {
    // ..

    return {
        next: function(v) {
            // ..
        }

        // 我们将省略`return(..)`和`throw(..)`
    };
}

现在,我们需要一些内部变量来持续跟踪我们的“generator”的逻辑走到了哪一个步骤。我们称它为state。我们将有三种状态:起始状态的0,等待完成yield表达式的1,和 generator 完成的2

每次next(..)被调用时,我们需要处理下一个步骤,然后递增state。为了方便,我们将每个步骤放在一个switch语句的case子句中,并且我们将它放在一个next(..)可以调用的称为nextState(..)的内部函数中。另外,因为x是一个横跨整个“generator”作用域的变量,所以它需要存活在nextState(..)函数的外部。

这是将它们放在一起(很明显,为了使概念的展示更清晰,它经过了某些简化):

function foo() {
    function nextState(v) {
        switch (state) {
            case 0:
                state++;

                // `yield`表达式
                return 42;
            case 1:
                state++;

                // `yield`表达式完成了
                x = v;
                console.log( x );

                // 隐含的`return`
                return undefined;

            // 无需处理状态`2`
        }
    }

    var state = 0, x;

    return {
        next: function(v) {
            var ret = nextState( v );

            return { value: ret, done: (state == 2) };
        }

        // 我们将省略`return(..)`和`throw(..)`
    };
}

最后,让我们测试一下我们的前 ES6“generator”:

var it = foo();

it.next();                // { value: 42, done: false }

it.next( 10 );            // 10
                        // { value: undefined, done: true }

不赖吧?希望这个练习能在你的脑中巩固这个概念:generator 实际上只是状态机逻辑的简单语法。这使它们可以广泛地应用。

Generator 的使用

我们现在非常深入地理解了 generator 如何工作,那么,它们在什么地方有用?

我们已经看过了两种主要模式:

  • 生产一系列值: 这种用法可以很简单(例如,随机字符串或者递增的数字),或者它也可以表达更加结构化的数据访问(例如,迭代一个数据库查询结果的所有行)。

    这两种方式中,我们使用迭代器来控制 generator,这样就可以为每次next(..)调用执行一些逻辑。在数据解构上的普通迭代器只不过生成值而没有任何控制逻辑。

  • 串行执行的任务队列: 这种用法经常用来表达一个算法中步骤的流程控制,其中每一步都要求从某些外部数据源取得数据。对每块儿数据的请求可能会立即满足,或者可能会异步延迟地满足。

    从 generator 内部代码的角度来看,在yield的地方,同步或异步的细节是完全不透明的。另外,这些细节被有意地抽象出去,如此就不会让这样的实现细节把各个步骤间自然的,顺序的表达搞得模糊不清。抽象还意味着实现可以被替换/重构,而根本不用碰 generator 中的代码。

当根据这些用法观察 generator 时,它们的含义要比仅仅是手动状态机的一种不同或更好的语法多多了。它们是一种用于组织和控制有序地生产与消费数据的强大工具。

你不懂 JS:ES6 与未来 第三章:组织(下)

模块

我觉得这样说并不夸张:在所有的 JavaScript 代码组织模式中最重要的就是,而且一直是,模块。对于我自己来说,而且我认为对广大典型的技术社区来说,模块模式驱动着绝大多数代码。

过去的方式

传统的模块模式基于一个外部函数,它带有内部变量和函数,以及一个被返回的“公有 API”。这个“公有 API”带有对内部变量和功能拥有闭包的方法。它经常这样表达:

function Hello(name) {
    function greeting() {
        console.log( "Hello " + name + "!" );
    }

    // 公有 API
    return {
        greeting: greeting
    };
}

var me = Hello( "Kyle" );
me.greeting();            // Hello Kyle!

这个Hello(..)模块通过被后续调用可以产生多个实例。有时,一个模块为了作为一个单例(也就是,只需要一个实例)而只被调用一次,这样的情况下常见的是一种前面代码段的变种,使用 IIFE:

var me = (function Hello(name){
    function greeting() {
        console.log( "Hello " + name + "!" );
    }

    // 公有 API
    return {
        greeting: greeting
    };
})( "Kyle" );

me.greeting();            // Hello Kyle!

这种模式是经受过检验的。它也足够灵活,以至于在许多不同的场景下可以有大量的各种变化。

其中一种最常见的是异步模块定义(AMD),另一种是统一模块定义(UMD)。我们不会在这里涵盖这些特定的模式和技术,但是它们在网上的许多地方有大量的讲解。

向前迈进

在 ES6 中,我们不再需要依赖外围函数和闭包来为我们提供模块支持了。ES6 模块拥有头等语法上和功能上的支持。

在我们接触这些具体语法之前,重要的是要理解 ES6 模块与你以前曾经用过的模块比较起来,在概念上的一些相当显著的不同之处:

  • ES6 使用基于文件的模块,这意味着一个模块一个文件。目前,没有标准的方法将多个模块组合到一个文件中。

    这意味着如果你要直接把 ES6 模块加载到一个浏览器 web 应用中的话,你将个别地加载它们,不是像常见的那样为了性能优化而作为一个单独文件中的一个巨大的包加载。

    预计同时期到来的 HTTP/2 将会大幅缓和这种性能上的顾虑,因为它工作在一个持续的套接字连接上,因而可以用并行的,互相交错的方式非常高效地加载许多小文件。

  • 一个 ES6 模块的 API 是静态的。这就是说,你在模块的公有 API 上静态地定义所有被导出的顶层内容,而这些内容导出之后不能被修改。

    有些用法习惯于能够提供动态 API 定义,它的方法可以根据运行时的条件被增加/删除/替换。这些用法要么必须改变以适应 ES6 静态 API,要么它们就不得不将属性/方法的动态修改限制在一个内层对象中。

  • ES6 模块都是单例。也就是,模块只有一个维持它状态的实例。每次你将这个模块导入到另一个模块时,你得到的都是一个指向中央实例的引用。如果你想要能够产生多个模块实例,你的模块将需要提供某种工厂来这么做。

  • 你在模块的公有 API 上暴露的属性和方法不是值和引用的普通赋值。它们是在你内部模块定义中的标识符的实际绑定(几乎就是指针)。

    在前 ES6 的模块中,如果你将一个持有像数字或者字符串这样基本类型的属性放在你的共有 API 中,那么这个属性是通过值拷贝赋值的,任何对相应内部变量的更新都将是分离的,不会影响在 API 对象上的共有拷贝。

    在 ES6 中,导出一个本地私有变量,即便它当前持有一个基本类型的字符串/数字/等等,导出的都是这个变量的一个绑定。如果这个模块改变了这个变量的值,外部导入的绑定就会解析为那个新的值。

  • 导入一个模块和静态地请求它被加载是同一件事情(如果它还没被加载的话)。如果你在浏览器中,这意味着通过网络的阻塞加载。如果你在服务器中,它是一个通过文件系统的阻塞加载。

    但是,不要对它在性能的影响上惊慌。因为 ES6 模块是静态定义的,导入的请求可以被静态地扫描,并提前加载,甚至是在你使用这个模块之前。

    ES6 并没有实际规定或操纵这些加载请求如何工作的机制。有一个模块加载器的分离概念,它让每一个宿主环境(浏览器,Node.js,等等)为该环境提供合适的默认加载器。一个模块的导入使用一个字符串值来表示从哪里去取得模块(URL,文件路径,等等),但是这个值在你的程序中是不透明的,它仅对加载器自身有意义。

    如果你想要比默认加载器提供的更细致的控制能力,你可以定义你自己的加载器 —— 默认加载器基本上不提供任何控制,它对于你的程序代码是完全隐藏的。

如你所见,ES6 模块将通过封装,控制共有 API,以及应用依赖导入来服务于所有的代码组织需求。但是它们用一种非常特别的方式来这样做,这可能与你已经使用多年的模块方式十分接近,也肯能差得很远。

CommonJS

有一种相似,但不是完全兼容的模块语法,称为 CommonJS,那些使用 Node.js 生态系统的人很熟悉它。

不太委婉地说,从长久看来,ES6 模块实质上将要取代所有先前的模块格式与标准,即便是 CommonJS,因为它们是建立在语言的语法支持上的。如果除了普遍性以外没有其他原因,迟早 ES6 将不可避免地作为更好的方式胜出。

但是,要达到那一天我们还有相当长的路要走。在服务器端的 JavaScript 世界中差不多有成百上千的 CommonJS 风格模块,而在浏览器的世界里各种格式标准的模块(UMD,AMD,临时性的模块方案)数量还要多十倍。这要花许多年过渡才能取得任何显著的进展。

在这个过渡期间,模块转译器/转换器将是绝对必要的。你可能刚刚适应了这种新的现实。不论你是使用正规的模块,AMD,UMD,CommonJS,或者 ES6,这些工具都不得不解析并转换为适合你代码运行环境的格式。

对于 Node.js,这可能意味着(目前)转换的目标是 CommonJS。对于浏览器来说,可能是 UMD 或者 AMD。除了在接下来的几年中随着这些工具的成熟和最佳实践的出现而发生的许多变化。

从现在起,我能对模块的提出的最佳建议是:不管你曾经由于强烈的爱好而虔诚地追随哪一种格式,都要培养对理解 ES6 模块的欣赏能力,并让你对其他模块模式的倾向性渐渐消失掉。它们就是 JS 中模块的未来,即便现实有些偏差。

新的方式

使用 ES6 模块的两个主要的新关键字是importexport。在语法上有许多微妙的地方,那么让我们深入地看看。

警告: 一个容易忽视的重要细节:importexport都必须总是出现在它们分别被使用之处的顶层作用域。例如,你不能把importexport放在一个if条件内部;它们必须出现在所有块儿和函数的外部。

exportAPI 成员

export关键字要么放在一个声明的前面,要么就与一组特殊的要被导出的绑定一起用作一个操作符。考虑如下代码:

export function foo() {
    // ..
}

export var awesome = 42;

var bar = [1,2,3];
export { bar };

表达相同导出的另一种方法:

function foo() {
    // ..
}

var awesome = 42;
var bar = [1,2,3];

export { foo, awesome, bar };

这些都称为 命名导出,因为你实际上导出的是变量/函数/等等其他的名称绑定。

任何你没有使用export标记 的东西将在模块作用域的内部保持私有。也就是说,虽然有些像var bar = ..的东西看起来像是在顶层全局作用域中声明的,但是这个顶层作用域实际上是模块本身;在模块中没有全局作用域。

注意: 模块确实依然可以访问挂在它外面的window和所有的“全局”,只是不作为顶层词法作用域而已。但是,你真的应该在你的模块中尽可能地远离全局。

你还可以在命名导出期间“重命名”(也叫别名)一个模块成员:

function foo() { .. }

export { foo as bar };

当这个模块被导入时,只有成员名称bar可以用于导入;foo在模块内部保持隐藏。

模块导出不像你习以为常的=赋值操作符那样,仅仅是值或引用的普通赋值。实际上,当你导出某些东西时,你导出了一个对那个东西(变量等)的一个绑定(有些像指针)。

在你的模块内部,如果你改变一个你已经被导出绑定的变量的值,即使它已经被导入了(见下一节),这个被导入的绑定也将解析为当前的(更新后的)值。

考虑如下代码:

var awesome = 42;
export { awesome };

// 稍后
awesome = 100;

当这个模块被导入时,无论它是在awesome = 100设定的之前还是之后,一旦这个赋值发生,被导入的绑定都将被解析为值100,不是42

这是因为,这个绑定实质上是一个指向变量awesome本身的一个引用,或指针,而不是它的值的一个拷贝。ES6 模块绑定引入了一个对于 JS 来说几乎是史无前例的概念。

虽然你显然可以在一个模块定义的内部多次使用export,但是 ES6 绝对偏向于一个模块只有一个单独导出的方式,这称为 默认导出。用 TC39 协会的一些成员的话说,如果你遵循这个模式你就可以“获得更简单的import语法作为奖励”,如果你不遵循你就会反过来得到更繁冗的语法作为“惩罚”。

一个默认导出将一个特定的导出绑定设置为在这个模块被导入时的默认绑定。这个绑定的名称是字面上的default。正如你即将看到的,在导入模块绑定时你还可以重命名它们,你经常会对默认导出这么做。

每个模块定义只能有一个default。我们将在下一节中讲解import,你将看到如果模块拥有默认导入时import语法如何变得更简洁。

默认导出语法有一个微妙的细节你应当多加注意。比较这两个代码段:

function foo(..) {
    // ..
}

export default foo;

和这一个:

function foo(..) {
    // ..
}

export { foo as default };

在第一个代码段中,你导出的是那一个函数表达式在那一刻的值的绑定,不是 标识符foo的绑定。换句话说,export default ..接收一个表达式。如果你稍后在你的模块内部赋给foo一个不同的值,这个模块导入将依然表示原本被导出的函数,而不是那个新的值。

顺带一提,第一个代码段还可以写做:

export default function foo(..) {
    // ..
}

警告: 虽然技术上讲这里的function foo..部分是一个函数表达式,但是对于模块内部作用域来说,它被视为一个函数声明,因为名称foo被绑定在模块的顶层作用域(经常称为“提升”)。对export default var foo = ..也是如此。然而,虽然你 可以 export var foo = ..,但是一个令人沮丧的不一致是,你目前还不能export default bar foo = ..(或者letconst)。在写作本书时,为了保持一致性,已经开始了在后 ES6 不久的时期增加这种能力的讨论。

再次回想一下第二个代码段:

function foo(..) {
    // ..
}

export { foo as default };

这种版本的模块导出中,默认导出的绑定实际上是标识符foo而不是它的值,所以你会得到先前描述过的绑定行为(也就是,如果你稍后改变foo的值,在导入一端看到的值也会被更新)。

要非常小心这种默认导出语法的微妙区别,特别是在你的逻辑需要导出的值要被更新时。如果你永远不打算更新一个默认导出的值,export default ..就没问题。如果你确实打算更新这个值,你必须使用export { .. as default }。无论哪种情况,都要确保注释你的代码以解释你的意图!

因为一个模块只能有一个default,这可能会诱使你将你的模块设计为默认导出一个带有你所有 API 方法的对象,就像这样:

export default {
    foo() { .. },
    bar() { .. },
    ..
};

这种模式看起来十分接近于许多开发者构建它们的前 ES6 模块时曾经用过的模式,所以它看起来像是一种十分自然的方式。不幸的是,它有一些缺陷并且不为官方所鼓励使用。

特别是,JS 引擎不能静态地分析一个普通对象的内容,这意味着它不能为静态import性能进行一些优化。使每个成员独立地并明确地导出的好处是,引擎 可以 进行静态分析和性能优化。

如果你的 API 已经有多于一个的成员,这些原则 —— 一个模块一个默认导出,和所有 API 成员作为被命名的导出 —— 看起来是冲突的,不是吗?但是你 可以 有一个单独的默认导出并且有其他的被命名导出;它们不是互相排斥的。

所以,取代这种(不被鼓励使用的)模式:

export default function foo() { .. }

foo.bar = function() { .. };
foo.baz = function() { .. };

你可以这样做:

export default function foo() { .. }

export function bar() { .. }
export function baz() { .. }

注意: 在前面这个代码段中,我为标记为default的函数使用了名称foo。但是,这个名称foo为了导出的目的而被忽略掉了 —— default才是实际上被导出的名称。当你导入这个默认绑定时,你可以叫它任何你想用的名字,就像你将在下一节中看到的。

或者,一些人喜欢:

function foo() { .. }
function bar() { .. }
function baz() { .. }

export { foo as default, bar, baz, .. };

混合默认和被命名导出的效果将在稍后我们讲解import时更加清晰。但它实质上意味着最简洁的默认导入形式将仅仅取回foo()函数。用户可以额外地手动罗列barbaz作为命名导入,如果他们想用它们的话。

你可能能够想象,如果你的模块有许多命名导出绑定,那么对于模块的消费者来说将有多么乏味。有一个通配符导入形式,你可以在一个名称空间对象中导入一个模块的所有导出,但是没有办法用通配符导入到顶层绑定。

要重申的是,ES6 模块机制被有意设计为不鼓励带有许多导出的模块;相对而言,它被期望成为一种更困难一些的,作为某种社会工程的方式,以鼓励对大型/复杂模块设计有利的简单模块设计。

我将可能推荐你不要将默认导出与命名导出混在一起,特别是当你有一个大型 API,并且将它重构为分离的模块是不现实或不希望的时候。在这种情况下,就都使用命名导出,并在文档中记录你的模块的消费者可能应当使用import * as ..(名称空间导入,在下一节中讨论)方式来将整个 API 一次性地带到一个单独的名称空间中。

我们早先提到过这一点,但让我们回过头来更详细地讨论一下。除了导出一个表达式的值的绑定的export default ...形式,所有其他的导出形式都导出本地标识符的绑定。对于这些绑定,如果你在导出之后改变一个模块内部变量的值,外部被导入的绑定将可以访问这个被更新的值:

var foo = 42;
export { foo as default };

export var bar = "hello world";

foo = 10;
bar = "cool";

当你导出这个模块时,defaultbar导出将会绑定到本地变量foobar,这意味着它们将反映被更新的值10"cool"。在被导出时的值是无关紧要的。在被导入时的值是无关紧要的。这些绑定是实时的链接,所以唯一重要的是当你访问这个绑定时它当前的值是什么。

警告: 双向绑定是不允许的。如果你从一个模块中导入一个foo,并试图改变你导入的变量foo的值,一个错误就会被抛出!我们将在下一节重新回到这个问题。

你还可以重新导出另一个模块的导出,比如:

export { foo, bar } from "baz";
export { foo as FOO, bar as BAR } from "baz";
export * from "baz";

这些形式都与首先从"baz"模块导入然后为了从你的模块中到处而明确地罗列它的成员相似。然而,在这些形式中,模块"baz"的成员从没有被导入到你的模块的本地作用域;某种程度上,它们原封不动地穿了过去。

importAPI 成员

要导入一个模块,你将不出意料地使用import语句。就像export有几种微妙的变化一样,import也有,所以你要花相当多的时间来考虑下面的问题,并试验你的选择。

如果你想要导入一个模块的 API 中的特定命名成员到你的顶层作用域,使用这种语法:

import { foo, bar, baz } from "foo";

警告: 这里的{ .. }语法可能看起来像一个对象字面量,甚至是像一个对象解构语法。但是,它的形式仅对模块而言是特殊的,所以不要将它与其他地方的{ .. }模式搞混了。

字符串"foo"称为一个 模块指示符。因为它的全部目的在于可以静态分析的语法,所以模块指示符必须是一个字符串字面量;它不能是一个持有字符串值的变量。

从你的 ES6 代码和 JS 引擎本身的角度来看,这个字符串字面量的内容是完全不透明和没有意义的。模块加载器将会把这个字符串翻译为一个在何处寻找被期望的模块的指令,不是作为一个 URL 路径就是一个本地文件系统路径。

被罗列的标识符foobarbaz必须匹配在模块的 API 上的命名导出(这里将会发生静态分析和错误断言)。它们在你当前的作用域中被绑定为顶层标识符。

import { foo } from "foo";

foo();

你可以重命名被导入的绑定标识符,就像:

import { foo as theFooFunc } from "foo";

theFooFunc();

如果这个模块仅有一个你想要导入并绑定到一个标识符的默认导出,你可以为这个绑定选择性地跳过外围的{ .. }语法。在这种首选情况下import会得到最好的最简洁的import语法形式:

import foo from "foo";

// 或者:
import { default as foo } from "foo";

注意: 正如我们在前一节中讲解过的,一个模块的export中的default关键字指定了一个名称实际上为default的命名导出,正如在第二个更加繁冗的语法中展示的那样。在这个例子中,从defaultfoo的重命名在后者的语法中是明确的,并且与前者隐含地重命名是完全相同的。

如果模块有这样的定义,你还可以与其他的命名导出一起导入一个默认导出。回忆一下先前的这个模块定义:

export default function foo() { .. }

export function bar() { .. }
export function baz() { .. }

要引入这个模块的默认导出和它的两个命名导出:

import FOOFN, { bar, baz as BAZ } from "foo";

FOOFN();
bar();
BAZ();

ES6 的模块哲学强烈推荐的方式是,你只从一个模块中导入你需要的特定的绑定。如果一个模块提供 10 个 API 方法,但是你只需它们中的两个,有些人认为带入整套 API 绑定是一种浪费。

一个好处是,除了代码变得更加明确,收窄导入使得静态分析和错误检测(例如,不小心使用了错误的绑定名称)变得更加健壮。

当然,这只是受 ES6 设计哲学影响的标准观点;没有什么东西要求我们坚持这种方式。

许多开发者可能很快指出这样的方式更令人厌烦,每次你发现自己需要一个模块中的其他某些东西时,它要求你经常地重新找到并更新你的import语句。它的代价是牺牲便利性。

以这种观点看,首选方式可能是将模块中的所有东西都导入到一个单独的名称空间中,而不是将每个个别的成员直接导入到作用域中。幸运的是,import语句拥有一个变种语法可以支持这种风格的模块使用,它被称为 名称空间导入

考虑一个被这样导出的"foo"模块:

export function bar() { .. }
export var x = 42;
export function baz() { .. }

你可以将整个 API 导入到一个单独的模块名称空间绑定中:

import * as foo from "foo";

foo.bar();
foo.x;            // 42
foo.baz();

注意: * as ..子句要求使用*通配符。换句话说,你不能做像import { bar, x } as foo from "foo"这样的事情来将 API 的一部分绑定到foo名称空间。我会很喜欢这样的东西,但是对 ES6 的名称空间导入来说,要么全有要么全无。

如果你正在使用* as ..导入的模块拥有一个默认导出,它会在指定的名称空间中被命名为default。你可以在这个名称空间绑定的外面,作为一个顶层标识符额外地命名这个默认导出。考虑一个被这样导出的"world"模块:

export default function foo() { .. }
export function bar() { .. }
export function baz() { .. }

和这个import

import foofn, * as hello from "world";

foofn();
hello.default();
hello.bar();
hello.baz();

虽然这个语法是合法的,但是它可能令人困惑:这个模块的一个方法(那个默认导出)被绑定到你作用域的顶层,然而其他的命名导出(而且之中之一称为default)作为一个不同名称(hello)的标识符名称空间的属性被绑定。

正如我早先提到的,我的建议是避免这样设计你的模块导出,以降低你模块的用户受困于这些奇异之处的可能性。

所有被导入的绑定都是不可变和/或只读的。考虑前面的导入;所有这些后续的赋值尝试都将抛出TypeError:

import foofn, * as hello from "world";

foofn = 42;            // (运行时)TypeError!
hello.default = 42;    // (运行时)TypeError!
hello.bar = 42;        // (运行时)TypeError!
hello.baz = 42;        // (运行时)TypeError!

回忆早先在“export API 成员”一节中,我们谈到barbaz绑定是如何被绑定到"world"模块内部的实际标识符上的。它意味着如果模块改变那些值,hello.barhello.baz将引用更新后的值。

但是你的本地导入绑定的不可变/只读的性质强制你不能从被导入的绑定一方改变他们,不然就会发生TypeError。这很重要,因为如果没有这种保护,你的修改将会最终影响所有其他该模块的消费者(记住:单例),这可能会产生一些非常令人吃惊的副作用!

另外,虽然一个模块 可以 从内部改变它的 API 成员,但你应当对有意地以这种风格设计你的模块非常谨慎。ES6 模块 被预计 是静态的,所以背离这个原则应当是不常见的,而且应当在文档中被非常小心和详细地记录下来。

警告: 存在一些这样的模块设计思想,你实际上打算允许一个消费者改变你的 API 上的一个属性的值,或者模块的 API 被设计为可以通过向 API 的名称空间中添加“插件”来“扩展”。但正如我们刚刚断言的,ES6 模块 API 应当被认为并设计为静态的和不可变的,这强烈地约束和不鼓励那些其他的模块设计模式。你可以通过导出一个普通对象 —— 它理所当然是可以随意改变的 —— 来绕过这些限制。但是在选择这条路之前要三思而后行。

作为一个import的结果发生的声明将被“提升”(参见本系列的 作用域与闭包)。考虑如下代码:

foo();

import { foo } from "foo";

foo()可以运行是因为import ..语句的静态解析不仅在编译时搞清了foo是什么,它还将这个声明“提升”到模块作用域的顶部,如此使它在模块中通篇都是可用的。

最后,最基本的import形式看起来像这样:

import "foo";

这种形式实际上不会将模块的任何绑定导入到你的作用域中。它加载(如果还没被加载过),编译(如果还没被编译过),并对"foo"模块求值(如果还没被运行过)。

一般来说,这种导入可能不会特别有用。可能会有一些模块的定义拥有副作用(比如向window/全局对象赋值)的特殊情况。你还可以将import "foo"用作稍后可能需要的模块的预加载。

模块循环依赖

A 导入 B。B 导入 A。这将如何工作?

我要立即声明,一般来说我会避免使用刻意的循环依赖来设计系统。话虽如此,我也认识到人们这么做是有原因的,而且它可以解决一些艰难的设计问题。

让我们考虑一下 ES6 如何处理这种情况。首先,模块"A"

import bar from "B";

export default function foo(x) {
    if (x > 10) return bar( x - 1 );
    return x * 2;
}

现在,是模块"B"

import foo from "A";

export default function bar(y) {
    if (y > 5) return foo( y / 2 );
    return y * 3;
}

这两个函数,foo(..)bar(..),如果它们在相同的作用域中就会像标准的函数声明那样工作,因为声明被“提升”至整个作用域,而因此与它们的编写顺序无关,它们互相是可用的。

在模块中,你的声明在完全不同的作用域中,所以 ES6 必须做一些额外的工作以使这些循环引用工作起来。

在大致的概念上,这就是循环的import依赖如何被验证和解析的:

  • 如果模块"A"被首先加载,第一步将是扫描文件并分析所有的导出,这样就可以为导入注册所有可用的绑定。然后它处理import .. from "B",这指示它需要去取得"B"

  • 一旦引擎加载了"B",它会做同样的导出绑定分析。当它看到import .. from "A"时,它知道"A"的 API 已经准备好了,所以它可以验证这个import为合法的。现在它知道了"B"的 API,它也可以验证在模块"A"中等待的import .. from "B"了。

实质上,这种相互导入,连同对两个import语句合法性的静态验证,虚拟地组合了两个分离的模块作用域(通过绑定),因此foo(..)可以调用bar(..)或相反。这与我们在相同的作用域中声明是对称的。

现在然我们试着一起使用这两个模块。首先,我们将试用foo(..)

import foo from "foo";
foo( 25 );                // 11

或者我们可以试用bar(..)

import bar from "bar";
bar( 25 );                // 11.5

foo(25)调用bar(25)被执行的时刻,所有模块的所有分析/编译都已经完成了。这意味着foo(..)内部地直接知道bar(..),而且bar(..)内部地直接知道foo(..)

如果所有我们需要的仅是与foo(..)互动,那么我们只需要导入"foo"模块。bar(..)"bar"模块也同理。

当然,如果我们想,我们 可以 导入并使用它们两个:

import foo from "foo";
import bar from "bar";

foo( 25 );                // 11
bar( 25 );                // 11.5

import语句的静态加载语义意味着通过import互相依赖对方的"foo""bar"将确保在它们运行前被加载,解析,和编译。所以它们的循环依赖是被静态地解析的,而且将会如你所愿地工作。

模块加载

我们在“模块”这一节的最开始声称,import语句使用了一个由宿主环境(浏览器,Node.js,等等)提供的分离的机制,来实际地将模块指示符字符串解析为一些对寻找和加载所期望模块的有用的指令。这种机制就是系统 模块加载器

由环境提供的默认模块加载器,如果是在浏览器中将会把模块指示符解释为一个 URL,如果是在服务器端(一般地)将会解释为一个本地文件系统路径,比如 Node.js。它的默认行为是假定被加载的文件是以 ES6 标准的模块格式编写的。

另外,与当下脚本程序被加载的方式相似,你将可以通过一个 HTML 标签将一个模块加载到浏览器中。在本书写作时,这个标签将会是<script type="module">还是<module>还不完全清楚。ES6 没有控制这个决定,但是在相应的标准化机构中的讨论早已随着 ES6 开始了。

无论这个标签看起来什么样,你可以确信它的内部将会使用默认加载器(或者一个你预先指定好的加载器,就像我们将在下一节中讨论的)。

就像你将在标记中使用的标签一样,ES6 没有规定模块加载器本身。它是一个分离的,目前由 WHATWG 浏览器标准化小组控制的平行的标准。(whatwg.github.io/loader/)

在本书写作时,接下来的讨论反映了它的 API 设计的一个早期版本,和一些可能将要改变的东西。

加载模块之外的模块

一个与模块加载器直接交互的用法,是当一个非模块需要加载一个模块时。考虑如下代码:

// 在浏览器中通过`<script>`加载的普通 script,
// `import`在这里是不合法的

Reflect.Loader.import( "foo" ) // 返回一个`"foo"`的 promise
.then( function(foo){
    foo.bar();
} );

工具Reflect.Loader.import(..)将整个模块导入到命名参数中(作为一个名称空间),就像我们早先讨论过的import * as foo ..名称空间导入。

注意: Reflect.Loader.import(..)返回一个 promise,它在模块准备好时被完成。要导入多个模块的话,你可以使用Promise.all([ .. ])将多个Reflect.Loader.import(..)的 promise 组合起来。有关 Promise 的更多信息,参见第四章的“Promise”。

你还可以在一个真正的模块中使用Reflect.Loader.import(..)来动态地/条件性地加载一个模块,这是import自身无法做到的。例如,你可能在一个特性测试表明某个 ES7+特性没有被当前的引擎所定义的情况下,选择性地加载一个含有此特性的填补的模块。

由于性能的原因,你将想要尽量避免动态加载,因为它阻碍了 JS 引擎从它的静态分析中提前获取的能力。

自定义加载

直接与模块加载器交互的另外一种用法是,你想要通过配置或者甚至是重定义来定制它的行为。

在本书写作时,有一个被开发好的模块加载器 API 的填补(github.com/ModuleLoader/es6-module-loader)。虽然关于它的细节非常匮乏,而且很可能改变,但是我们可以通过它来探索最终可能固定下来的东西是什么。%E3%80%82%E8%99%BD%E7%84%B6%E5%85%B3%E4%BA%8E%E5%AE%83%E7%9A%84%E7%BB%86%E8%8A%82%E9%9D%9E%E5%B8%B8%E5%8C%AE%E4%B9%8F%EF%BC%8C%E8%80%8C%E4%B8%94%E5%BE%88%E5%8F%AF%E8%83%BD%E6%94%B9%E5%8F%98%EF%BC%8C%E4%BD%86%E6%98%AF%E6%88%91%E4%BB%AC%E5%8F%AF%E4%BB%A5%E9%80%9A%E8%BF%87%E5%AE%83%E6%9D%A5%E6%8E%A2%E7%B4%A2%E6%9C%80%E7%BB%88%E5%8F%AF%E8%83%BD%E5%9B%BA%E5%AE%9A%E4%B8%8B%E6%9D%A5%E7%9A%84%E4%B8%9C%E8%A5%BF%E6%98%AF%E4%BB%80%E4%B9%88%E3%80%82)

Reflect.Loader.import(..)调用可能会支持第二个参数,它指定各种选项来定制导入/加载任务。例如:

Reflect.Loader.import( "foo", { address: "/path/to/foo.js" } )
.then( function(foo){
    // ..
} )

还有一种预期是,会为一个自定义内容提供某种机制来将之挂钩到模块加载的处理过程中,就在翻译/转译可能发生的加载之后,但是在引擎编译这个模块之前。

例如,你可能会加载某些还不是 ES6 兼容的模块格式的东西(例如,CoffeeScript,TypeScript,CommonJS,AMD)。你的翻译步骤可能会为了后面的引擎处理而将它转换为 ES6 兼容的模块。

几乎从 JavaScript 的最开始的那时候起,语法和开发模式都曾努力(读作:挣扎地)地戴上一个支持面向类的开发的假面具。伴随着newinstanceof和一个.constructor属性,谁能不认为 JS 在它的原型系统的某个地方藏着类机制呢?

当然,JS 的“类”与经典的类完全不同。其区别有很好的文档记录,所以在此我不会在这一点上花更多力气。

注意: 要学习更多关于在 JS 中假冒“类”的模式,以及另一种称为“委托”的原型的视角,参见本系列的 this 与对象原型 的后半部分。

class

虽然 JS 的原型机制与传统的类的工作方式不同,但是这并不能阻挡一种强烈的潮流 —— 要求这门语言扩展它的语法糖以便将“类”表达得更像真正的类。让我们进入 ES6class关键字和它相关的机制。

这个特性是一个具有高度争议、旷日持久的争论的结果,而且代表了几种对关于如何处理 JS 类的强烈反对意见的妥协的一小部分。大多数希望 JS 拥有完整的类机制的开发者将会发现新语法的一些部分十分吸引人,但是也会发现一些重要的部分仍然缺失了。但不要担心,TC39 已经致力于另外的特性,以求在后 ES6 时代中增强类机制。

新的 ES6 类机制的核心是class关键字,它标识了一个 ,其内容定义了一个函数的原型的成员。考虑如下代码:

class Foo {
    constructor(a,b) {
        this.x = a;
        this.y = b;
    }

    gimmeXY() {
        return this.x * this.y;
    }
}

一些要注意的事情:

  • class Foo 暗示着创建一个(特殊的)名为Foo的函数,与你在前 ES6 中所做的非常相似。
  • constructor(..)表示了这个Foo(..)函数的签名,和它的函数体内容。
  • 类方法同样使用对象字面量中可以使用的“简约方法”语法,正如在第二章中讨论过的。这也包括在本章早先讨论过的简约 generator,以及 ES5 的 getter/setter 语法。但是,类方法是不可枚举的而对象方法默认是可枚举的。
  • 与对象字面量不同的是,在一个class内容的部分没有逗号分隔各个成员!事实上,这甚至是不允许的。

前一个代码段的class语法定义可以大致认为和这个前 ES6 等价物相同,对于那些以前做过原型风格代码的人来说可能十分熟悉它:

function Foo(a,b) {
    this.x = a;
    this.y = b;
}

Foo.prototype.gimmeXY = function() {
    return this.x * this.y;
}

不管是前 ES6 形式还是新的 ES6class形式,这个“类”现在可以被实例化并如你所想地使用了:

var f = new Foo( 5, 15 );

f.x;                        // 5
f.y;                        // 15
f.gimmeXY();                // 75

注意!虽然class Foo看起来很像function Foo(),但是有一些重要的区别:

  • class Foo的一个Foo(..)调用 必须new一起使用,因为前 ES6 的Foo.call( obj )方式 不能 工作。
  • 虽然function Foo会被“提升”(参见本系列的 作用域与闭包),但是class Foo不会;extends ..指定的表达式不能被“提升”。所以,在你能够实例化一个class之前必须先声明它。
  • 在顶层全局作用域中的class Foo在这个作用域中创建了一个词法标识符Foo,但与此不同的是function Foo不会创建一个同名的全局对象属性。

已经建立的instanceof操作仍然可以与 ES6 的类一起工作,因为class只是创建了一个同名的构造器函数。然而,ES6 引入了一个定制instanceof如何工作的方法,使用Symbol.hasInstance(参见第七章的“通用 Symbol”)。

我发现另一种更方便地考虑class的方法是,将它作为一个用来自动填充proptotype对象的 。可选的是,如果使用extends(参见下一节)的话它还能连接[[Prototype]]关系。

其实一个 ES6class本身不是一个实体,而是一个元概念,它包裹在其他具体实体上,例如函数和属性,并将它们绑在一起。

提示: 除了这种声明的形式,一个class还可以是一个表达式,就像:var x = class Y { .. }。这主要用于将类的定义(技术上说,是构造器本身)作为函数参数值传递,或者将它赋值给一个对象属性。

extendssuper

ES6 的类还有一种语法糖,用于在两个函数原型之间建立[[Prototype]]委托链 —— 通常被错误地标记为“继承”或者令人困惑地标记为“原型继承” —— 使用我们熟悉的面向类的术语extends

class Bar extends Foo {
    constructor(a,b,c) {
        super( a, b );
        this.z = c;
    }

    gimmeXYZ() {
        return super.gimmeXY() * this.z;
    }
}

var b = new Bar( 5, 15, 25 );

b.x;                        // 5
b.y;                        // 15
b.z;                        // 25
b.gimmeXYZ();                // 1875

一个有重要意义的新增物是super,它实际上在前 ES6 中不是直接可能的东西(不付出一些不幸的黑科技的代价的话)。在构造器中,super自动指向“父构造器”,这在前一个例子中是Foo(..)。在方法中,它指向“父对象”,如此你就可以访问它上面的属性/方法,比如super.gimmeXY()

Bar extends Foo理所当然地意味着将Bar.prototype[[Prototype]]链接到Foo.prototype。所以,在gimmeXYZ()这样的方法中的super特被地意味着Foo.prototype,而当super用在Bar构造器中时意味着Foo

注意: super不仅限于class声明。它也可以在对象字面量中工作,其方式在很大程度上与我们在此讨论的相同。更多信息参见第二章中的“对象super”。

super的坑

注意到super的行为根据它出现的位置不同而不同是很重要的。公平地说,大多数时候这不是一个问题。但是如果你背离一个狭窄的规范,令人诧异的事情就会等着你。

可能会有这样的情况,你想在构造器中引用Foo.prototype,比如直接访问它的属性/方法之一。然而,在构造器中的super不能这样被使用;super.prototype将不会工作。super(..)大致上意味着调用new Foo(..),但它实际上不是一个可用的对Foo本身的引用。

与此对称的是,你可能想要在一个非构造器方法中引用Foo(..)函数。super.constructor将会指向Foo(..)函数,但是要小心这个函数 只能new一起被调用。new super.constructor(..)将是合法的,但是在大多数情况下它都不是很有用, 因为你不能使这个调用使用或引用当前的this对象环境,而这很可能是你想要的。

另外,super看起来可能就像this一样是被函数的环境所驱动的 —— 也就是说,它们都是被动态绑定的。但是,super不像this那样是动态的。当声明时一个构造器或者方法在它内部使用一个super引用时(在class的内容部分),这个super是被静态地绑定到这个指定的类阶层中的,而且不能被覆盖(至少是在 ES6 中)。

这意味着什么?这意味着如果你习惯于从一个“类”中拿来一个方法并通过覆盖它的this,比如使用call(..)或者apply(..),来为另一个类而“借用”它的话,那么当你借用的方法中有一个super时,将很有可能发生令你诧异的事情。考虑这个类阶层:

class ParentA {
    constructor() { this.id = "a"; }
    foo() { console.log( "ParentA:", this.id ); }
}

class ParentB {
    constructor() { this.id = "b"; }
    foo() { console.log( "ParentB:", this.id ); }
}

class ChildA extends ParentA {
    foo() {
        super.foo();
        console.log( "ChildA:", this.id );
    }
}

class ChildB extends ParentB {
    foo() {
        super.foo();
        console.log( "ChildB:", this.id );
    }
}

var a = new ChildA();
a.foo();                    // ParentA: a
                            // ChildA: a
var b = new ChildB();        // ParentB: b
b.foo();                    // ChildB: b

在前面这个代码段中一切看起来都相当自然和在意料之中。但是,如果你试着借来b.foo()并在a的上下文中使用它的话 —— 通过动态this绑定的力量,这样的借用十分常见而且以许多不同的方式被使用,包括最明显的 mixin —— 你可能会发现这个结果出奇地难看:

// 在`a`的上下文环境中借用`b.foo()`
b.foo.call( a );            // ParentB: a
                            // ChildB: a

如你所见,引用this.id被动态地重绑定所以在两种情况下都报告: a而不是: b。但是b.foo()super.foo()引用没有被动态重绑定,所以它依然报告ParentB而不是期望的ParentA

因为b.foo()引用super,所以它被静态地绑定到了ChildB/ParentB阶层而不能被用于ChildA/ParentA阶层。在 ES6 中没有办法解决这个限制。

如果你有一个不带移花接木的静态类阶层,那么super的工作方式看起来很直观。但公平地说,实施带有this的编码的一个主要好处正是这种灵活性。简单地说,class + super要求你避免使用这样的技术。

你能在对象设计上作出的选择归结为两个:使用这些静态的阶层 —— classextends,和super将十分不错 —— 要么放弃所有“山寨”类的企图,而接受动态且灵活的,没有类的对象和[[Prototype]]委托(参见本系列的 this 与对象原型)。

子类构造器

对类或子类来说构造器不是必需的;如果构造器被省略,这两种情况下都会有一个默认构造器顶替上来。但是,对于一个直接的类和一个被扩展的类来说,顶替上来的默认构造器是不同的。

特别地,默认的子类构造器自动地调用父构造器,并且传递所有参数值。换句话说,你可以认为默认的子类构造器有些像这样:

constructor(...args) {
    super(...args);
}

这是一个需要注意的重要细节。不是所有支持类的语言的子类构造器都会自动地调用父构造器。C++会,但 Java 不会。更重要的是,在前 ES6 的类中,这样的自动“父构造器”调用不会发生。如果你曾经依赖于这样的调用 不会 发生,按么当你将代码转换为 ES6class时就要小心。

ES6 子类构造器的另一个也许令人吃惊的偏差/限制是:在一个子类的构造器中,在super(..)被调用之前你不能访问this。其中的原因十分微妙和复杂,但是可以归结为是父构造器在实际上创建/初始化你的实例的this。前 ES6 中,它相反地工作;this对象被“子类构造器”创建,然后你使用这个“子类”的this上下文环境调用“父构造器”。

让我们展示一下。这是前 ES6 版本:

function Foo() {
    this.a = 1;
}

function Bar() {
    this.b = 2;
    Foo.call( this );
}

// `Bar` “扩展” `Foo`
Bar.prototype = Object.create( Foo.prototype );

但是这个 ES6 等价物不允许:

class Foo {
    constructor() { this.a = 1; }
}

class Bar extends Foo {
    constructor() {
        this.b = 2;            // 在`super()`之前不允许
        super();            // 可以通过调换这两个语句修正
    }
}

在这种情况下,修改很简单。只要在子类Bar的构造器中调换两个语句的位置就行了。但是,如果你曾经依赖于前 ES6 可以跳过“父构造器”调用的话,就要小心这不再被允许了。

extend原生类型

新的classextend设计中最值得被欢呼的好处之一,就是(终于!)能够为内建原生类型,比如Array,创建子类。考虑如下代码:

class MyCoolArray extends Array {
    first() { return this[0]; }
    last() { return this[this.length - 1]; }
}

var a = new MyCoolArray( 1, 2, 3 );

a.length;                    // 3
a;                            // [1,2,3]

a.first();                    // 1
a.last();                    // 3

在 ES6 之前,可以使用手动的对象创建并将它链接到Array.prototype来制造一个Array的“子类”的山寨版,但它仅能部分地工作。它缺失了一个真正数组的特殊行为,比如自动地更新length属性。ES6 子类应该可以如我们盼望的那样使用“继承”与增强的行为来完整地工作!

另一个常见的前 ES6“子类”的限制与Error对象有关,在创建自定义的错误“子类”时。当纯粹的Error被创建时,它们自动地捕获特殊的stack信息,包括错误被创建的行号和文件。前 ES6 的自定义错误“子类”没有这样的特殊行为,这严重地限制了它们的用处。

ES6 前来拯救:

class Oops extends Error {
    constructor(reason) {
        super(reason);
        this.oops = reason;
    }
}

// 稍后:
var ouch = new Oops( "I messed up!" );
throw ouch;

前面代码段的ouch自定义错误对象将会向任何其他的纯粹错误对象那样动作,包括捕获stack。这是一个巨大的改进!

new.target

ES6 引入了一个称为 元属性 的新概念(见第七章),用new.target的形式表示。

如果这看起来很奇怪,是的;将一个带有.的关键字与一个属性名配成一对,对 JS 来说绝对是不同寻常的模式。

new.target是一个在所有函数中可用的“魔法”值,虽然在普通的函数中它总是undefined。在任意的构造器中,new.target总是指向new实际直接调用的构造器,即便这个构造器是在一个父类中,而且是通过一个在子构造器中的super(..)调用被委托的。

class Foo {
    constructor() {
        console.log( "Foo: ", new.target.name );
    }
}

class Bar extends Foo {
    constructor() {
        super();
        console.log( "Bar: ", new.target.name );
    }
    baz() {
        console.log( "baz: ", new.target );
    }
}

var a = new Foo();
// Foo: Foo

var b = new Bar();
// Foo: Bar   <-- 遵照`new`的调用点
// Bar: Bar

b.baz();
// baz: undefined

new.target元属性在类构造器中没有太多作用,除了访问一个静态属性/方法(见下一节)。

如果new.targetundefined,那么你就知道这个函数不是用new调用的。然后你就可以强制一个new调用,如果有必要的话。

static

当一个子类Bar扩展一个父类Foo时,我们已经观察到Bar.prototype[[Prototype]]链接到Foo.prototype。但是额外地,Bar()[[Prototype]]链接到Foo()。这部分可能就没有那么明显了。

但是,在你为一个类声明static方法(不只是属性)时它就十分有用,因为这些静态方法被直接添加到这个类的函数对象上,不是函数对象的prototype对象上。考虑如下代码:

class Foo {
    static cool() { console.log( "cool" ); }
    wow() { console.log( "wow" ); }
}

class Bar extends Foo {
    static awesome() {
        super.cool();
        console.log( "awesome" );
    }
    neat() {
        super.wow();
        console.log( "neat" );
    }
}

Foo.cool();                    // "cool"
Bar.cool();                    // "cool"
Bar.awesome();                // "cool"
                            // "awesome"

var b = new Bar();
b.neat();                    // "wow"
                            // "neat"

b.awesome;                    // undefined
b.cool;                        // undefined

小心不要被搞糊涂,认为static成员是在类的原型链上的。它们实际上存在与函数构造器中间的一个双重/平行链条上。

Symbol.species构造器 Getter

一个static可以十分有用的地方是为一个衍生(子)类设置Symbol.speciesgetter(在语言规范内部称为@@species)。这种能力允许一个子类通知一个父类应当使用什么样的构造器 —— 当不打算使用子类的构造器本身时 —— 如果有任何父类方法需要产生新的实例的话。

举个例子,在Array上的许多方法都创建并返回一个新的Array实例。如果你从Array定义一个衍生的类,但你想让这些方法实际上继续产生Array实例,而非从你的衍生类中产生实例,那么这就可以工作:

class MyCoolArray extends Array {
    // 强制`species`为父类构造器
    static get [Symbol.species]() { return Array; }
}

var a = new MyCoolArray( 1, 2, 3 ),
    b = a.map( function(v){ return v * 2; } );

b instanceof MyCoolArray;    // false
b instanceof Array;            // true

为了展示一个父类方法如何可以有些像Array#map(..)所做的那样,使用一个子类型声明,考虑如下代码:

class Foo {
    // 将`species`推迟到衍生的构造器中
    static get [Symbol.species]() { return this; }
    spawn() {
        return new this.constructor[Symbol.species]();
    }
}

class Bar extends Foo {
    // 强制`species`为父类构造器
    static get [Symbol.species]() { return Foo; }
}

var a = new Foo();
var b = a.spawn();
b instanceof Foo;                    // true

var x = new Bar();
var y = x.spawn();
y instanceof Bar;                    // false
y instanceof Foo;                    // true

父类的Symbol.species使用return this来推迟到任意的衍生类,就像你通常期望的那样。然后Bar手动地声明Foo被用于这样的实例创建。当然,一个衍生的类依然可以使用new this.constructor(..)生成它本身的实例。

复习

ES6 引入了几个在代码组织上提供帮助的新特性:

  • 迭代器提供了对数据和操作的序列化访问。它们可以被for..of...这样的新语言特性消费。
  • Generator 是由一个迭代器控制的能够在本地暂停/继续的函数。它们可以被用于程序化地(并且是互动地,通过yield/next(..)消息传递) 生成 通过迭代器被消费的值。
  • 模块允许实现的细节的私有封装带有一个公开导出的 API。模块定义是基于文件的,单例的实例,并且在编译时静态地解析。
  • 类为基于原型的编码提供了更干净的语法。super的到来也解决了在[[Prototype]]链中进行相对引用的刁钻问题。

在你考虑通过采纳 ES6 来改进你的 JS 项目体系结构时,这些新工具应当是你的第一站。

你不懂 JS:ES6 与未来 第四章:异步流程控制

如果你写过任何数量相当的 JavaScript,这就不是什么秘密:异步编程是一种必须的技能。管理异步的主要机制曾经是函数回调。

然而,ES6 增加了一种新特性:Promise,来帮助你解决仅使用回调来管理异步的重大缺陷。另外,我们可以重温 generator(前一章中提到的)来看看一种将两者组合的模式,它是 JavaScript 中异步流程控制编程向前迈出的重要一步。

Promises

让我们辨明一些误解:Promise 不是回调的替代品。Promise 提供了一种可信的中介机制 —— 也就是,在你的调用代码和将要执行任务的异步代码之间 —— 来管理回调。

另一种考虑 Promise 的方式是作为一种事件监听器,你可以在它上面注册监听一个通知你任务何时完成的事件。它是一个仅被触发一次的时间,但不管怎样可以被看作是一个事件。

Promise 可以被链接在一起,它们可以是一系列顺序的、异步完成的步骤。与all(..)方法(用经典的术语将,叫“门”)和race(..)方法(用经典的术语将,叫“闩”)这样的高级抽象一起,promise 链可以提供一种异步流程控制的机制。

还有另外一种概念化 Promise 的方式是,将它看作一个 未来值,一个与时间无关的值的容器。无论底层的值是否是最终值,这种容器都可以被同样地推理。观测一个 Promise 的解析会在这个值准备好的时候将它抽取出来。换言之,一个 Promise 被认为是一个同步函数返回值的异步版本。

一个 Promise 只可能拥有两种解析结果:完成或拒绝,并带有一个可选的信号值。如果一个 Promise 被完成,这个最终值称为一个完成值。如果它被拒绝,这个最终值称为理由(也就是“拒绝的理由”)。Promise 只可能被解析(完成或拒绝)一次。任何其他的完成或拒绝的尝试都会被简单地忽略,一旦一个 Promise 被解析,它就成为一个不可被改变的值(immutable)。

显然,有几种不同的方式可以来考虑一个 Promise 是什么。没有一个角度就它自身来说是完全充分的,但是每一个角度都提供了整体的一个方面。这其中的要点是,它们为仅使用回调的异步提供了一个重大的改进,也就是它们提供了顺序、可预测性、以及可信性。

创建与使用 Promises

要构建一个 promise 实例,可以使用Promise(..)构造器:

var p = new Promise( function pr(resolve,reject){
    // ..
} );

Promise(..)构造器接收一个单独的函数(pr(..)),它被立即调用并以参数值的形式收到两个控制函数,通常被命名为resolve(..)reject(..)。它们被这样使用:

  • 如果你调用reject(..),promise 就会被拒绝,而且如果有任何值被传入reject(..),它就会被设置为拒绝的理由。
  • 如果你不使用参数值,或任何非 promise 值调用resolve(..),promise 就会被完成。
  • 如果你调用resolve(..)并传入另一个 promise,这个 promise 就会简单地采用 —— 要么立即要么最终地 —— 这个被传入的 promise 的状态(不是完成就是拒绝)。

这里是你通常如何使用一个 promise 来重构一个依赖于回调的函数调用。假定你始于使用一个ajax(..)工具,它期预期要调用一个错误优先风格的回调:

function ajax(url,cb) {
    // 发起请求,最终调用 `cb(..)`
}

// ..

ajax( "http://some.url.1", function handler(err,contents){
    if (err) {
        // 处理 ajax 错误
    }
    else {
        // 处理成功的`contents`
    }
} );

你可以将它转换为:

function ajax(url) {
    return new Promise( function pr(resolve,reject){
        // 发起请求,最终不是调用 `resolve(..)` 就是调用 `reject(..)`
    } );
}

// ..

ajax( "http://some.url.1" )
.then(
    function fulfilled(contents){
        // 处理成功的 `contents`
    },
    function rejected(reason){
        // 处理 ajax 的错误 reason
    }
);

Promise 拥有一个方法then(..),它接收一个或两个回调函数。第一个函数(如果存在的话)被看作是 promise 被成功地完成时要调用的处理器。第二个函数(如果存在的话)被看作是 promise 被明确拒绝时,或者任何错误/异常在解析的过程中被捕捉到时要调用的处理器。

如果这两个参数值之一被省略或者不是一个合法的函数 —— 通常你会用null来代替 —— 那么一个占位用的默认等价物就会被使用。默认的成功回调将传递它的完成值,而默认的错误回调将传播它的拒绝理由。

调用then(null,handleRejection)的缩写是catch(handleRejection)

then(..)catch(..)两者都自动地构建并返回另一个 promise 实例,它被链接在原本的 promise 上,接收原本的 promise 的解析结果 —— (实际被调用的)完成或拒绝处理器返回的任何值。考虑如下代码:

ajax( "http://some.url.1" )
.then(
    function fulfilled(contents){
        return contents.toUpperCase();
    },
    function rejected(reason){
        return "DEFAULT VALUE";
    }
)
.then( function fulfilled(data){
    // 处理来自于原本的 promise 的处理器中的数据
} );

在这个代码段中,我们要么从fulfilled(..)返回一个立即值,要么从rejected(..)返回一个立即值,然后在下一个事件周期中这个立即值被第二个then(..)fulfilled(..)接收。如果我们返回一个新的 promise,那么这个新 promise 就会作为解析结果被纳入与采用:

ajax( "http://some.url.1" )
.then(
    function fulfilled(contents){
        return ajax(
            "http://some.url.2?v=" + contents
        );
    },
    function rejected(reason){
        return ajax(
            "http://backup.url.3?err=" + reason
        );
    }
)
.then( function fulfilled(contents){
    // `contents` 来自于任意一个后续的 `ajax(..)` 调用
} );

要注意的是,在第一个fulfilled(..)中的一个异常(或者 promise 拒绝)将 不会 导致第一个rejected(..)被调用,因为这个处理仅会应答第一个原始的 promise 的解析。取代它的是,第二个then(..)调用所针对的第二个 promise,将会收到这个拒绝。

在上面的代码段中,我们没有监听这个拒绝,这意味着它会为了未来的观察而被静静地保持下来。如果你永远不通过调用then(..)catch(..)来观察它,那么它将会成为未处理的。有些浏览器的开发者控制台可能会探测到这些未处理的拒绝并报告它们,但是这不是有可靠保证的;你应当总是观察 promise 拒绝。

注意: 这只是 Promise 理论和行为的简要概览。要进行更加深入的探索,参见本系列的 异步与性能 的第三章。

Thenables

Promise 是Promise(..)构造器的纯粹实例。然而,还存在称为 thenable 的类 promise 对象,它通常可以与 Promise 机制协作。

任何带有then(..)函数的对象(或函数)都被认为是一个 thenable。任何 Promise 机制可以接受与采用一个纯粹的 promise 的状态的地方,都可以处理一个 thenable。

Thenable 基本上是一个一般化的标签,标识着任何由除了Promise(..)构造器之外的其他系统创建的类 promise 值。从这个角度上讲,一个 thenable 没有一个纯粹的 Promise 那么可信。例如,考虑这个行为异常的 thenable:

var th = {
    then: function thener( fulfilled ) {
        // 永远会每 100ms 调用一次`fulfilled(..)`
        setInterval( fulfilled, 100 );
    }
};

如果你收到这个 thenable 并使用th.then(..)将它链接,你可能会惊讶地发现你的完成处理器被反复地调用,而普通的 Promise 本应该仅仅被解析一次。

一般来说,如果你从某些其他系统收到一个声称是 promise 或 thenable 的东西,你不应当盲目地相信它。在下一节中,我们将会看到一个 ES6 Promise 的工具,它可以帮助解决信任的问题。

但是为了进一步理解这个问题的危险,让我们考虑一下,在 任何 一段代码中的 任何 对象,只要曾经被定义为拥有一个称为then(..)的方法就都潜在地会被误认为是一个 thenable —— 当然,如果和 Promise 一起使用的话 —— 无论这个东西是否有意与 Promise 风格的异步编码有一丝关联。

在 ES6 之前,对于称为then(..)的方法从来没有任何特别的保留措施,正如你能想象的那样,在 Promise 出现在雷达屏幕上之前就至少有那么几种情况,它已经被选择为方法的名称了。最有可能用错 thenable 的情况就是使用then(..)的异步库不是严格兼容 Promise 的 —— 在市面上有好几种。

这份重担将由你来肩负:防止那些将被误认为一个 thenable 的值被直接用于 Promise 机制。

Promise API

PromiseAPI 还为处理 Promise 提供了一些静态方法。

Promise.resolve(..)创建一个被解析为传入的值的 promise。让我们将它的工作方式与更手动的方法比较一下:

var p1 = Promise.resolve( 42 );

var p2 = new Promise( function pr(resolve){
    resolve( 42 );
} );

p1p2将拥有完全相同的行为。使用一个 promise 进行解析也一样:

var theP = ajax( .. );

var p1 = Promise.resolve( theP );

var p2 = new Promise( function pr(resolve){
    resolve( theP );
} );

提示: Promise.resolve(..)就是前一节提出的 thenable 信任问题的解决方案。任何你还不确定是一个可信 promise 的值 —— 它甚至可能是一个立即值 —— 都可以通过传入Promise.resolve(..)来进行规范化。如果这个值已经是一个可识别的 promise 或 thenable,它的状态/解析结果将简单地被采用,将错误行为与你隔绝开。如果相反它是一个立即值,那么它将会被“包装”进一个纯粹的 promise,以此将它的行为规范化为异步的。

Promise.reject(..)创建一个立即被拒绝的 promise,与它的Promise(..)构造器对等品一样:

var p1 = Promise.reject( "Oops" );

var p2 = new Promise( function pr(resolve,reject){
    reject( "Oops" );
} );

虽然resolve(..)Promise.resolve(..)可以接收一个 promise 并采用它的状态/解析结果,但是reject(..)Promise.reject(..)不会区分它们收到什么样的值。所以,如果你使用一个 promise 或 thenable 进行拒绝,这个 promise/thenable 本身将会被设置为拒绝的理由,而不是它底层的值。

Promise.all([ .. ])接收一个或多个值(例如,立即值,promise,thenable)的数组。它返回一个 promise,这个 promise 会在所有的值完成时完成,或者在这些值中第一个被拒绝的值出现时被立即拒绝。

使用这些值/promises:

var p1 = Promise.resolve( 42 );
var p2 = new Promise( function pr(resolve){
    setTimeout( function(){
        resolve( 43 );
    }, 100 );
} );
var v3 = 44;
var p4 = new Promise( function pr(resolve,reject){
    setTimeout( function(){
        reject( "Oops" );
    }, 10 );
} );

让我们考虑一下使用这些值的组合,Promise.all([ .. ])如何工作:

Promise.all( [p1,p2,v3] )
.then( function fulfilled(vals){
    console.log( vals );            // [42,43,44]
} );

Promise.all( [p1,p2,v3,p4] )
.then(
    function fulfilled(vals){
        // 永远不会跑到这里
    },
    function rejected(reason){
        console.log( reason );        // Oops
    }
);

Promise.all([ .. ])等待所有的值完成(或第一个拒绝),而Promise.race([ .. ])仅会等待第一个完成或拒绝。考虑如下代码:

// 注意:为了避免时间的问题误导你,
// 重建所有的测试值!

Promise.race( [p2,p1,v3] )
.then( function fulfilled(val){
    console.log( val );                // 42
} );

Promise.race( [p2,p4] )
.then(
    function fulfilled(val){
        // 永远不会跑到这里
    },
    function rejected(reason){
        console.log( reason );        // Oops
    }
);

警告: 虽然 Promise.all([])将会立即完成(没有任何值),但是 Promise.race([])将会被永远挂起。这是一个奇怪的不一致,我建议你应当永远不要使用空数组调用这些方法。

Generators + Promises

将一系列 promise 在一个链条中表达来代表你程序的异步流程控制是 可能 的。考虑如如下代码:

step1()
.then(
    step2,
    step1Failed
)
.then(
    function step3(msg) {
        return Promise.all( [
            step3a( msg ),
            step3b( msg ),
            step3c( msg )
        ] )
    }
)
.then(step4);

但是对于表达异步流程控制来说有更好的选项,而且在代码风格上可能比长长的 promise 链更理想。我们可以使用在第三章中学到的 generator 来表达我们的异步流程控制。

要识别一个重要的模式:一个 generator 可以 yield 出一个 promise,然后这个 promise 可以使用它的完成值来推进 generator。

考虑前一个代码段,使用 generator 来表达:

function *main() {

    try {
        var ret = yield step1();
    }
    catch (err) {
        ret = yield step1Failed( err );
    }

    ret = yield step2( ret );

    // step 3
    ret = yield Promise.all( [
        step3a( ret ),
        step3b( ret ),
        step3c( ret )
    ] );

    yield step4( ret );
}

从表面上看,这个代码段要比前一个 promise 链等价物要更繁冗。但是它提供了更加吸引人的 —— 而且重要的是,更加容易理解和阅读的 —— 看起来同步的代码风格(“return”值的=赋值操作,等等),对于try..catch错误处理可以跨越那些隐藏的异步边界使用来说就更是这样。

为什么我们要与 generator 一起使用 Promise?不用 Promise 进行异步 generator 编码当然是可能的。

Promise 是一个可信的系统,它将普通的回调和 thunk 中发生的控制倒转(参见本系列的 异步与性能)反转回来。所以组合 Promise 的可信性与 generator 中代码的同步性有效地解决了回调的主要缺陷。另外,像Promise.all([ .. ])这样的工具是一个非常美好、干净的方式 —— 在一个 generator 的一个yield步骤中表达并发。

那么这种魔法是如何工作的?我们需要一个可以运行我们 generator 的 运行器(runner),接收一个被yield出来的 promise 并连接它,让它要么使用成功的完成推进 generator,要么使用拒绝的理由向 generator 抛出异常。

许多具备异步能力的工具/库都有这样的“运行器”;例如,Q.spawn(..)和我的 asynquence 中的runner(..)插件。这里有一个独立的运行器来展示这种处理如何工作:

function run(gen) {
    var args = [].slice.call( arguments, 1), it;

    it = gen.apply( this, args );

    return Promise.resolve()
        .then( function handleNext(value){
            var next = it.next( value );

            return (function handleResult(next){
                if (next.done) {
                    return next.value;
                }
                else {
                    return Promise.resolve( next.value )
                        .then(
                            handleNext,
                            function handleErr(err) {
                                return Promise.resolve(
                                    it.throw( err )
                                )
                                .then( handleResult );
                            }
                        );
                }
            })( next );
        } );
}

注意: 这个工具的更丰富注释的版本,参见本系列的 异步与性能。另外,由各种异步库提供的这种运行工具通常要比我们在这里展示的东西更强大。例如,asynquence 的runner(..)可以处理被yield的 promise、序列、thunk、以及(非 promise 的)间接值,给你终极的灵活性。

于是现在运行早先代码段中的*main()就像这样容易:

run( main )
.then(
    function fulfilled(){
        // `*main()` 成功地完成了
    },
    function rejected(reason){
        // 噢,什么东西搞错了
    }
);

实质上,在你程序中的任何拥有多于两个异步步骤的流程控制逻辑的地方,你就可以 而且应当 使用一个由运行工具驱动的 promise-yielding generator 来以一种同步的风格表达流程控制。这样做将产生更易于理解和维护的代码。

这种“让出一个 promise 推进 generator”的模式将会如此常见和如此强大,以至于 ES6 之后的下一个版本的 JavaScript 几乎可以确定将会引入一中新的函数类型,它无需运行工具就可以自动地执行。我们将在第八章中讲解async function(正如它们期望被称呼的那样)。

复习

随着 JavaScript 在它被广泛采用过程中的日益成熟与成长,异步编程越发地成为关注的中心。对于这些异步任务来说回调并不完全够用,而且在更精巧的需求面前全面崩塌了。

可喜的是,ES6 增加了 Promise 来解决回调的主要缺陷之一:在可预测的行为上缺乏可信性。Promise 代表一个潜在异步任务的未来完成值,跨越同步和异步的边界将行为进行了规范化。

但是,Promise 与 generator 的组合才完全揭示了这样做的好处:将我们的异步流程控制代码重新安排,将难看的回调浆糊(也叫“地狱”)弱化并抽象出去。

目前,我们可以在各种异步库的运行器的帮助下管理这些交互,但是 JavaScript 最终将会使用一种专门的独立语法来支持这种交互模式!

你不懂 JS:ES6 与未来 第五章:集合

结构化的集合与数据访问对于任何 JS 程序来说都是一个关键组成部分。从这门语言的最开始到现在,数组和对象一直都是我们创建数据结构的主要机制。当然,许多更高级的数据结构作为用户方的库都曾建立在这些之上。

到了 ES6,最有用(而且优化性能的!)的数据结构抽象中的一些已经作为这门语言的原生组件被加入了进来。

我们将通过检视 类型化数组(TypedArrays) 来开始这一章,技术上讲它与几年前的 ES5 是同一时期的产物,但是仅仅作为 WebGL 的同伴被标准化了,而不是作为 JavaScript 自身的一部分。到了 ES6,这些东西已经被语言规范直接采纳了,这给予了它们头等的地位。

Map 就像对象(键/值对),但是与仅能使用一个字符串作为键不同的是,你可以使用任何值 —— 即使是另一个对象或 map!Set 与数组很相似(值的列表),但是这些值都是唯一的;如果你添加一个重复的值,它会被忽略。还有与之相对应的 weak 结构(与内存/垃圾回收有关联):WeakMap 和 WeakSet。

类型化数组(TypedArrays)

正如我们在本系列的 类型与文法 中讲到过的,JS 确实拥有一组内建类型,比如numberstring。看到一个称为“类型化的数组”的特性,可能会诱使你推测它意味着一个特定类型的值的数组,比如一个仅含字符串的数组。

然而,类型化数组其实更多的是关于使用类似数组的语义(索引访问,等等)提供对二进制数据的结构化访问。名称中的“类型”指的是在大量二进制位(比特桶)的类型之上覆盖的“视图”,它实质上是一个映射,控制着这些二进制位是否应当被看作 8 位有符号整数的数组,还是被看作 16 位有符号整数的数组,等等。

你怎样才能构建这样的比特桶呢?它被称为一个“缓冲(buffer)”,而你可以用ArrayBuffer(..)构造器直接地构建它:

var buf = new ArrayBuffer( 32 );
buf.byteLength;                            // 32

现在buf是一个长度为 32 字节(256 比特)的二进制缓冲,它被预初始化为全0。除了检查它的byteLength属性,一个缓冲本身不会允许你进行任何操作。

提示: 有几种 web 平台特性都使用或返回缓冲,比如FileReader#readAsArrayBuffer(..)XMLHttpRequest#send(..),和ImageData(canvas 数据)。

但是在这个数组缓冲的上面,你可以平铺一层“视图”,它就是用类型化数组的形式表现的。考虑如下代码:

var arr = new Uint16Array( buf );
arr.length;                            // 16

arr是一个 256 位的buf缓冲在 16 位无符号整数的类型化数组的映射,意味着你得到 16 个元素。

字节顺序

明白一个事实非常重要:arr是使用 JS 所运行的平台的字节顺序设定(大端法或小端法)被映射的。如果二进制数据是由一种字节顺序创建,但是在一个拥有相反字节数序的平台被解释时,这就可能是个问题。

字节顺序指的是一个多字节数字的低位字节(8 个比特位的集合) —— 比如我们在早先的代码段中创建的 16 位无符号整数 —— 是在这个数字的字节序列的左边还是右边。

举个例子,让我们想象一下用 16 位来表示的 10 进制的数字3085。如果你只有一个 16 位数字的容器,无论字节顺序怎样它都将以二进制表示为0000110000001101(十六进制的0c0d)。

但是如果3085使用两个 8 位数字来表示的话,字节顺序就像会极大地影响它在内存中的存储:

  • 0000110000001101 / 0c0d (大端法)
  • 0000110100001100 / 0d0c (小端法)

如果你从一个小端法系统中收到表示为00001101000011003085,但是在一个大端法系统中为它上面铺一层视图,那么你将会看到值3340(10 进制)和0d0c(16 进制)。

如今在 web 上最常见的表现形式是小端法,但是绝对存在一些与此不同的浏览器。你明白一块二进制数据的生产者和消费者的字节顺序是十分重要的。

在 MDN 上有一种快速的方法测试你的 JavaScript 的字节顺序:

var littleEndian = (function() {
    var buffer = new ArrayBuffer( 2 );
    new DataView( buffer ).setInt16( 0, 256, true );
    return new Int16Array( buffer )[0] === 256;
})();

littleEndian将是truefalse;对大多数浏览器来说,它应当返回true。这个测试使用DataView(..),它允许更底层,更精细地控制如何从你平铺在缓冲上的视图中访问二进制位。前面代码段中的setInt16(..)方法的第三个参数告诉DataView,对于这个操作你想使用什么字节顺序。

警告: 不要将一个数组缓冲中底层的二进制存储的字节顺序与一个数字在 JS 程序中被暴露时如何被表示搞混。举例来说,(3085).toString(2)返回"110000001101",它被假定前面有四个"0"因而是大端法表现形式。事实上,这个表现形式是基于一个单独的 16 位视图的,而不是两个 8 位字节的视图。上面的DataView测试是确定你的 JS 环境的字节顺序的最佳方法。

多视图

一个单独的缓冲可以连接多个视图,例如:

var buf = new ArrayBuffer( 2 );

var view8 = new Uint8Array( buf );
var view16 = new Uint16Array( buf );

view16[0] = 3085;
view8[0];                        // 13
view8[1];                        // 12

view8[0].toString( 16 );        // "d"
view8[1].toString( 16 );        // "c"

// 调换(好像字节顺序一样!)
var tmp = view8[0];
view8[0] = view8[1];
view8[1] = tmp;

view16[0];                        // 3340

类型化数组的构造器拥有多种签名。目前我们展示过的只是向它们传递一个既存的缓冲。然而,这种形式还接受两个额外的参数:byteOffsetlength。换句话讲,你可以从0以外的位置开始类型化数组视图,也可以使它的长度小于整个缓冲的长度。

如果二进制数据的缓冲包含规格不一的大小/位置,这种技术可能十分有用。

例如,考虑一个这样的二进制缓冲:在开头拥有一个 2 字节数字(也叫做“字”),紧跟着两个 1 字节数字,然后跟着一个 32 位浮点数。这是你如何在同一个缓冲,偏移量,和长度上使用多视图来访问数据:

var first = new Uint16Array( buf, 0, 2 )[0],
    second = new Uint8Array( buf, 2, 1 )[0],
    third = new Uint8Array( buf, 3, 1 )[0],
    fourth = new Float32Array( buf, 4, 4 )[0];

类型化数组构造器

除了前一节我们检视的(buffer,[offset, [length]])形式之外,类型化数组的构造器还支持这些形式:

  • [constructor](length):在一个长度为length字节的缓冲上创建一个新视图
  • [constructor](typedArr):创建一个新视图和缓冲,并拷贝typedArr视图中的内容
  • [constructor](obj):创建一个新视图和缓冲,并迭代类数组或对象obj来拷贝它的内容

在 ES6 中可以使用下面的类型化数组构造器:

  • Int8Array(8 位有符号整数),Uint8Array(8 位无符号整数)
    • Uint8ClampedArray(8 位无符号整数,每个值都被卡在0 - 255范围内)
  • Int16Array(16 位有符号整数),Uint16Array(16 位无符号整数)
  • Int32Array(32 位有符号整数),Uint32Array(32 位无符号整数)
  • Float32Array(32 位浮点数,IEEE-754)
  • Float64Array(64 位浮点数,IEEE-754)

类型化数组构造器的实例基本上和原生的普通数组是一样的。一些区别包括它有一个固定的长度并且值都是同种“类型”。

但是,它们共享绝大多数相同的prototype方法。这样一来,你很可能将会像普通数组那样使用它们而不必进行转换。

例如:

var a = new Int32Array( 3 );
a[0] = 10;
a[1] = 20;
a[2] = 30;

a.map( function(v){
    console.log( v );
} );
// 10 20 30

a.join( "-" );
// "10-20-30"

警告: 你不能对类型化数组使用没有意义的特定Array.prototype方法,比如修改器(splice(..)push(..),等等)和concat(..)

要小心,在类型化数组中的元素被限制在它被声明的位长度中。如果你有一个Uint8Array并试着向它的一个元素赋予某些大于 8 为的值,那么这个值将被截断以保持在相应的位长度中。

这可能造成一些问题,例如,如果你试着对一个类型化数组中的所有值求平方。考虑如下代码:

var a = new Uint8Array( 3 );
a[0] = 10;
a[1] = 20;
a[2] = 30;

var b = a.map( function(v){
    return v * v;
} );

b;                // [100, 144, 132]

在被平方后,值2030的结果会位溢出。要绕过这样的限制,你可以使用TypedArray#from(..)函数:

var a = new Uint8Array( 3 );
a[0] = 10;
a[1] = 20;
a[2] = 30;

var b = Uint16Array.from( a, function(v){
    return v * v;
} );

b;                // [100, 400, 900]

关于被类型化数组所共享的Array.from(..)函数的更多信息,参见第六章的“Array.from(..)静态方法”一节。特别地,“映射”一节讲解了作为第二个参数值被接受的映射函数。

一个值得考虑的有趣行为是,类型化数组像普通数组一样有一个sort(..)方法,但是这个方法默认是数字排序比较而不是将值强制转换为字符串进行字典顺序比较。例如:

var a = [ 10, 1, 2, ];
a.sort();                                // [1,10,2]

var b = new Uint8Array( [ 10, 1, 2 ] );
b.sort();                                // [1,2,10]

就像Array#sort(..)一样,TypedArray#sort(..)接收一个可选的比较函数作为参数值,它们的工作方式完全一样。

Maps

如果你对 JS 经验丰富,那么你一定知道对象是创建无序键/值对数据结构的主要机制,这也被称为 map。然而,将对象作为 map 的主要缺陷是不能使用一个非字符串值作为键。

例如,考虑如下代码:

var m = {};

var x = { id: 1 },
    y = { id: 2 };

m[x] = "foo";
m[y] = "bar";

m[x];                            // "bar"
m[y];                            // "bar"

这里发生了什么?xy这两个对象都被字符串化为"[object Object]",所以只有这一个键被设置为m

一些人通过在一个值的数组旁边同时维护一个平行的非字符串键的数组实现了山寨的 map,比如:

var keys = [], vals = [];

var x = { id: 1 },
    y = { id: 2 };

keys.push( x );
vals.push( "foo" );

keys.push( y );
vals.push( "bar" );

keys[0] === x;                    // true
vals[0];                        // "foo"

keys[1] === y;                    // true
vals[1];                        // "bar"

当然,你不会想亲自管理这些平行数组,所以你可能会定义一个数据解构,使它内部带有自动管理的方法。除了你不得不自己做这些工作,主要的缺陷是访问的时间复杂度不再是 O(1),而是 O(n)。

但在 ES6 中,不再需要这么做了!使用Map(..)就好:

var m = new Map();

var x = { id: 1 },
    y = { id: 2 };

m.set( x, "foo" );
m.set( y, "bar" );

m.get( x );                        // "foo"
m.get( y );                        // "bar"

唯一的缺点是你不能使用[]方括号访问语法来设置或取得值。但是get(..)set(..)可以完美地取代这种语法。

要从一个 map 中删除一个元素,不要使用delete操作符,而是使用delete(..)方法:

m.set( x, "foo" );
m.set( y, "bar" );

m.delete( y );

使用clear()你可清空整个 map 的内容。要得到 map 的长度(也就是,键的数量),使用size属性(不是length)。

m.set( x, "foo" );
m.set( y, "bar" );
m.size;                            // 2

m.clear();
m.size;                            // 0

Map(..)的构造器还可以接受一个可迭代对象(参见第三章的“迭代器”),它必须产生一个数组的列表,每个数组的第一个元素是键,第二元素是值。这种用于迭代的格式与entries()方法产生的格式是一样的,entries()方法将在下一节中讲解。这使得制造一个 map 的拷贝十分简单:

var m2 = new Map( m.entries() );

// 等同于:
var m2 = new Map( m );

因为一个 map 实例是一个可迭代对象,而且它的默认迭代器与entries()相同,第二种稍短的形式更理想。

当然,你可以在Map(..)构造器形式中手动指定一个 entries 列表:

var x = { id: 1 },
    y = { id: 2 };

var m = new Map( [
    [ x, "foo" ],
    [ y, "bar" ]
] );

m.get( x );                        // "foo"
m.get( y );                        // "bar"

Map 值

要从一个 map 得到值的列表,使用values(..),它返回一个迭代器。在第二和第三章,我们讲解了几种序列化(像一个数组那样)处理一个迭代器的方法,比如...扩散操作符和for..of循环。另外,第六章的“Arrays”将会详细讲解Array.from(..)方法。考虑如下代码:

var m = new Map();

var x = { id: 1 },
    y = { id: 2 };

m.set( x, "foo" );
m.set( y, "bar" );

var vals = [ ...m.values() ];

vals;                            // ["foo","bar"]
Array.from( m.values() );        // ["foo","bar"]

就像在前一节中讨论过的,你可以使用entries()(或者默认的 map 迭代器)迭代一个 map 的记录。考虑如下代码:

var m = new Map();

var x = { id: 1 },
 y = { id: 2 };

m.set( x, "foo" );
m.set( y, "bar" );

var vals = [ ...m.entries() ];

vals[0][0] === x;                // true
vals[0][1];                        // "foo"

vals[1][0] === y;                // true
vals[1][1];                        // "bar"

Map 键

要得到键的列表,使用keys(),它返回一个 map 中键的迭代器:

var m = new Map();

var x = { id: 1 },
    y = { id: 2 };

m.set( x, "foo" );
m.set( y, "bar" );

var keys = [ ...m.keys() ];

keys[0] === x;                    // true
keys[1] === y;                    // true

要判定一个 map 中是否拥有一个给定的键,使用has(..)

var m = new Map();

var x = { id: 1 },
    y = { id: 2 };

m.set( x, "foo" );

m.has( x );                        // true
m.has( y );                        // false

实质上 map 让你将一些额外的信息(值)与一个对象(键)相关联,而不用实际上将这些信息放在对象本身中。

虽然在一个 map 中你可以使用任意种类的值作为键,但是你经常使用的将是对象,就像字符串和其他在普通对象中可以合法地作为键的基本类型。换句话说,你可能将想要继续使用普通对象,除非一些或全部的键需要是对象,在那种情况下 map 更合适。

警告: 如果你使用一个对象作为一个 map 键,而且这个对象稍后为了能够被垃圾回收器(GC)回收它占用的内存而被丢弃(解除所有的引用),那么 map 本身将依然持有它的记录。你需要从 map 中移除这个记录来使它能够被垃圾回收。在下一节中,我们将看到对于作为对象键和 GC 来说更好的选择 —— WeakMaps。

WeakMaps

WeakMap 是 map 的一个变种,它们的大多数外部行为是相同的,而在底层内存分配(明确地说是它的 GC)如何工作上有区别。

WeakMap(仅)接收对象作为键。这些对象被 持有,这意味着如果对象本身被垃圾回收掉了,那么在 WeakMap 中的记录也会被移除。这是观察不到的,因为一个对象可以被垃圾回收的唯一方法是不再有指向它的引用 —— 一旦不再有指向它的引用,你就没有对象引用可以用来检查它是否存在于这个 WeakMap 中。

除此以外,WeakMap 的 API 是相似的,虽然限制更多:

var m = new WeakMap();

var x = { id: 1 },
    y = { id: 2 };

m.set( x, "foo" );

m.has( x );                        // true
m.has( y );                        // false

WeakMap 没有size属性和clear()方法,它们也不对它们的键,值和记录暴露任何迭代器。所以即便你解除了x引用,它将会因 GC 从m中移除它的记录,也没有办法确定这一事实。你只能相信 JavaScript 会这么做!

就像 map 一样,WeakMap 让你将信息与一个对象软关联。如果你不能完全控制这个对象,比如 DOM 元素,它们就特别有用。如果你用做 map 键的对象可以被删除并且应当在被删除时成为 GC 的回收对象,那么一个 WeakMap 就是更合适的选项。

要注意的是 WeakMap 只弱持有它的 ,而不是它的值。考虑如下代码:

var m = new WeakMap();

var x = { id: 1 },
    y = { id: 2 },
    z = { id: 3 },
    w = { id: 4 };

m.set( x, y );

x = null;                        // { id: 1 } 是可以 GC 的
y = null;                        // 由于 { id: 1 } 是可以 GC 的,因此 { id: 2 } 也可以

m.set( z, w );

w = null;                        // { id: 4 } 不可以 GC

因此,我认为 WeakMap 被命名为“WeakKeyMap”更好。

Sets

一个 set 是一个集合,其中的值都是唯一的(重复的会被忽略)。

set 的 API 与 map 很相似。add(..)方法(有点讽刺地)取代了set(..),而且没有get(..)方法。

考虑如下代码:

var s = new Set();

var x = { id: 1 },
    y = { id: 2 };

s.add( x );
s.add( y );
s.add( x );

s.size;                            // 2

s.delete( y );
s.size;                            // 1

s.clear();
s.size;                            // 0

Set(..)构造器形式与Map(..)相似,它可以接收一个可迭代对象,比如另一个 set 或者一个值的数组。但是,与Map(..)期待一个 记录 的列表(键/值数组的数组)不同的是,Set(..)期待一个 的列表(值的数组):

var x = { id: 1 },
    y = { id: 2 };

var s = new Set( [x,y] );

一个 set 不需要get(..),因为你不会从一个 set 中取得值,而是使用has(..)测试一个值是否存在:

var s = new Set();

var x = { id: 1 },
    y = { id: 2 };

s.add( x );

s.has( x );                        // true
s.has( y );                        // false

注意: has(..)中的比较算法与Object.is(..)(见第六章)几乎完全相同,除了-00被视为相同而非不同。

Set 迭代器

set 和 map 一样拥有相同的迭代器方法。set 的行为有所不同,但是与 map 的迭代器的行为是对称的。考虑如下代码:

var s = new Set();

var x = { id: 1 },
 y = { id: 2 };

s.add( x ).add( y );

var keys = [ ...s.keys() ],
 vals = [ ...s.values() ],
 entries = [ ...s.entries() ];

keys[0] === x;
keys[1] === y;

vals[0] === x;
vals[1] === y;

entries[0][0] === x;
entries[0][1] === x;
entries[1][0] === y;
entries[1][1] === y;

keys()values()迭代器都会给出 set 中唯一值的列表。entries()迭代器给出记录数组的列表,记录数组中的两个元素都是唯一的 set 值。一个 set 的默认迭代器是它的values()迭代器。

一个 set 天生的唯一性是它最有用的性质。例如:

var s = new Set( [1,2,3,4,"1",2,4,"5"] ),
    uniques = [ ...s ];

uniques;                        // [1,2,3,4,"1","5"]

set 的唯一性不允许强制转换,所以1"1"被认为是不同的值。

WeakSets

一个 WeakMap 弱持有它的键(但强持有它的值),而一个 WeakSet 弱持有它的值(不存在真正的键)。

var s = new WeakSet();

var x = { id: 1 },
    y = { id: 2 };

s.add( x );
s.add( y );

x = null;                        // `x` 可以 GC
y = null;                        // `y` 可以 GC

警告: WeakSet 的值必须是对象,在 set 中被允许的基本类型值是不行的。

复习

ES6 定义了几种有用的集合,它们使得处理解构化的数据更加高效和有效。

类型化数组提供了二进制数据缓冲的“视图”,它使用各种整数类型对齐,比如 8 位无符号整数和 32 位浮点数。二进制数据的数组访问使得操作更加容易表达和维护,它可以让你更简单地处理如视频,音频,canvas 数据等复杂的数组。

Map 是键-值对集合,它的键可以是对象而非只可以是字符串/基本类型。Set 是(任何类型的)唯一值的列表。

WeakMap 是键(对象)被弱持有的 map,所以如果它是最后一个指向这个对象的引用,GC 就可以自由地回收这个记录。WeakSet 是值被弱持有的 set,所以同样地,如果它是最后一个指向这个对象的引用,GC 就可以移除这个记录。

你不懂 JS:ES6 与未来 第六章:新增 API

从值的转换到数学计算,ES6 给各种内建原生类型和对象增加了许多静态属性和方法来辅助这些常见任务。另外,一些原生类型的实例通过各种新的原型方法获得了新的能力。

注意: 大多数这些特性都可以被忠实地填补。我们不会在这里深入这样的细节,但是关于兼容标准的 shim/填补,你可以看一下“ES6 Shim”(github.com/paulmillr/es6-shim/)。%E3%80%82)

Array

在 JS 中被各种用户库扩展得最多的特性之一就是数组类型。ES6 在数组上增加许多静态的和原型(实例)的帮助功能应当并不令人惊讶。

Array.of(..) 静态函数

Array(..)的构造器有一个尽人皆知的坑:如果仅有一个参数值被传递,而且这个参数值是一个数字的话,它并不会制造一个含有一个带有该数值元素的数组,而是构建一个长度等于这个数字的空数组。这种操作造成了不幸的和怪异的“空值槽”行为,而这正是 JS 数组为人诟病的地方。

Array.of(..)作为数组首选的函数型构造器取代了Array(..),因为Array.of(..)没有那种单数字参数值的情况。考虑如下代码:

var a = Array( 3 );
a.length;                        // 3
a[0];                            // undefined

var b = Array.of( 3 );
b.length;                        // 1
b[0];                            // 3

var c = Array.of( 1, 2, 3 );
c.length;                        // 3
c;                                // [1,2,3]

在什么样的环境下,你才会想要是使用Array.of(..)来创建一个数组,而不是使用像c = [1,2,3]这样的字面语法呢?有两种可能的情况。

如果你有一个回调,传递给它的参数值本应当被包装在一个数组中时,Array.of(..)就完美地符合条件。这可能不是那么常见,但是它可以为你的痒处挠上一把。

另一种场景是如果你扩展Array构成它的子类,而且希望能够在一个你的子类的实例中创建和初始化元素,比如:

class MyCoolArray extends Array {
    sum() {
        return this.reduce( function reducer(acc,curr){
            return acc + curr;
        }, 0 );
    }
}

var x = new MyCoolArray( 3 );
x.length;                        // 3 -- 噢!
x.sum();                        // 0 -- 噢!

var y = [3];                    // Array,不是 MyCoolArray
y.length;                        // 1
y.sum();                        // `sum` is not a function

var z = MyCoolArray.of( 3 );
z.length;                        // 1
z.sum();                        // 3

你不能(简单地)只创建一个MyCoolArray的构造器,让它覆盖Array父构造器的行为,因为这个父构造器对于实际创建一个规范的数组值(初始化this)是必要的。在MyCoolArray子类上“被继承”的静态of(..)方法提供了一个不错的解决方案。

Array.from(..) 静态函数

在 JavaScript 中一个“类数组对象”是一个拥有length属性的对象,这个属性明确地带有 0 或更高的整数值。

在 JS 中处理这些值出了名地让人沮丧;将它们变形为真正的数组曾经是十分常见的做法,这样各种Array.property方法(map(..)indexOf(..)等等)才能与它一起使用。这种处理通常看起来像:

// 类数组对象
var arrLike = {
    length: 3,
    0: "foo",
    1: "bar"
};

var arr = Array.prototype.slice.call( arrLike );

另一种slice(..)经常被使用的常见任务是,复制一个真正的数组:

var arr2 = arr.slice();

在这两种情况下,新的 ES6Array.from(..)方法是一种更易懂而且更优雅的方式 —— 也不那么冗长:

var arr = Array.from( arrLike );

var arrCopy = Array.from( arr );

Array.from(..)会查看第一个参数值是否是一个可迭代对象(参见第三章的“迭代器”),如果是,它就使用迭代器来产生值,并将这些值“拷贝”到将要被返回的数组中。因为真正的数组拥有一个可以产生这些值的迭代器,所以这个迭代器会被自动地使用。

但是如果你传递一个类数组对象作为Array.from(..)的第一个参数值,它的行为基本上是和slice()(不带参数值的!)或apply()相同的,它简单地循环所有的值,访问从0开始到length值的由数字命名的属性。

考虑如下代码:

var arrLike = {
    length: 4,
    2: "foo"
};

Array.from( arrLike );
// [ undefined, undefined, "foo", undefined ]

因为在arrLike上不存在位置01,和3,所以对这些值槽中的每一个,结果都是undefined值。

你也可以这样产生类似的结果:

var emptySlotsArr = [];
emptySlotsArr.length = 4;
emptySlotsArr[2] = "foo";

Array.from( emptySlotsArr );
// [ undefined, undefined, "foo", undefined ]

避免空值槽

前面的代码段中,在emptySlotsArrArray.from(..)调用的结果有一个微妙但重要的不同。Array.from(..)从不产生空值槽。

在 ES6 之前,如果你想要制造一个被初始化为在每个值槽中使用实际undefined值(不是空值槽!)的特定长数组,你不得不做一些额外的工作:

var a = Array( 4 );                                // 四个空值槽!

var b = Array.apply( null, { length: 4 } );        // 四个 `undefined` 值

但现在Array.from(..)使这件事简单了些:

var c = Array.from( { length: 4 } );            // 四个 `undefined` 值

警告: 使用一个像前面代码段中的a那样的空值槽数组可以与一些数组函数工作,但是另一些函数会忽略空值槽(比如map(..)等)。你永远不应该刻意地使用空值槽,因为它几乎肯定会在你的程序中导致奇怪/不可预料的行为。

映射

Array.from(..)工具还有另外一个绝技。第二个参数值,如果被提供的话,是一个映射函数(和普通的Array#map(..)几乎相同),它在将每个源值映射/变形为返回的目标值时调用。考虑如下代码:

var arrLike = {
    length: 4,
    2: "foo"
};

Array.from( arrLike, function mapper(val,idx){
    if (typeof val == "string") {
        return val.toUpperCase();
    }
    else {
        return idx;
    }
} );
// [ 0, 1, "FOO", 3 ]

注意: 就像其他接收回调的数组方法一样,Array.from(..)接收可选的第三个参数值,它将被指定为作为第二个参数传递的回调的this绑定。否则,this将是undefined

一个使用Array.from(..)将一个 8 位值数组翻译为 16 位值数组的例子,参见第五章的“类型化数组”。

创建 Arrays 和子类型

在前面几节中,我们讨论了Array.of(..)Array.from(..),它们都用与构造器相似的方法创建一个新数组。但是在子类中它们会怎么做?它们是创建基本Array的实例,还是创建衍生的子类的实例?

class MyCoolArray extends Array {
    ..
}

MyCoolArray.from( [1, 2] ) instanceof MyCoolArray;    // true

Array.from(
    MyCoolArray.from( [1, 2] )
) instanceof MyCoolArray;                            // false

of(..)from(..)都使用它们被访问时的构造器来构建数组。所以如果你使用基本的Array.of(..)你将得到Array实例,但如果你使用MyCoolArray.of(..),你将得到一个MyCoolArray实例。

在第三章的“类”中,我们讲解了在所有内建类(比如Array)中定义好的@@species设定,它被用于任何创建新实例的原型方法。slice(..)是一个很棒的例子:

var x = new MyCoolArray( 1, 2, 3 );

x.slice( 1 ) instanceof MyCoolArray;                // true

一般来说,这种默认行为将可能是你想要的,但是正如我们在第三章中讨论过的,如果你想的话你 可以 覆盖它:

class MyCoolArray extends Array {
    // 强制 `species` 为父类构造器
    static get [Symbol.species]() { return Array; }
}

var x = new MyCoolArray( 1, 2, 3 );

x.slice( 1 ) instanceof MyCoolArray;                // false
x.slice( 1 ) instanceof Array;                        // true

要注意的是,@@species设定仅适用于原型方法,比如slice(..)of(..)from(..)不使用它;它们俩都只使用this绑定(哪个构造器被用于发起引用)。考虑如下代码:

class MyCoolArray extends Array {
    // 强制 `species` 为父类构造器
    static get [Symbol.species]() { return Array; }
}

var x = new MyCoolArray( 1, 2, 3 );

MyCoolArray.from( x ) instanceof MyCoolArray;        // true
MyCoolArray.of( [2, 3] ) instanceof MyCoolArray;    // true

copyWithin(..) 原型方法

Array#copyWithin(..)是一个对所有数组可用的新修改器方法(包括类型化数组;参加第五章)。copyWithin(..)将数组的一部分拷贝到同一个数组的其他位置,覆盖之前存在在那里的任何东西。

它的参数值是 目标(要被拷贝到的索引位置),开始(拷贝开始的索引位置(含)),和可选的 结束(拷贝结束的索引位置(不含))。如果这些参数值中存在任何负数,那么它们就被认为是相对于数组的末尾。

考虑如下代码:

[1,2,3,4,5].copyWithin( 3, 0 );            // [1,2,3,1,2]

[1,2,3,4,5].copyWithin( 3, 0, 1 );        // [1,2,3,1,5]

[1,2,3,4,5].copyWithin( 0, -2 );        // [4,5,3,4,5]

[1,2,3,4,5].copyWithin( 0, -2, -1 );    // [4,2,3,4,5]

copyWithin(..)方法不会扩张数组的长度,就像前面代码段中的第一个例子展示的。当到达数组的末尾时拷贝就会停止。

与你可能想象的不同,拷贝的顺序并不总是从左到右的。如果起始位置与目标为重叠的话,它有可能造成已经被拷贝过的值被重复拷贝,这大概不是你期望的行为。

所以在这种情况下,算法内部通过相反的拷贝顺序来避免这个坑。考虑如下代码:

[1,2,3,4,5].copyWithin( 2, 1 );        // ???

如果算法是严格的从左到右,那么2应当被拷贝来覆盖3,然后这个被拷贝的2应当被拷贝来覆盖4,然后这个被拷贝的2应当被拷贝来覆盖5,而你最终会得到[1,2,2,2,2]

与此不同的是,拷贝算法把方向反转过来,拷贝4来覆盖5,然后拷贝3来覆盖4,然后拷贝2来覆盖3,而最后的结果是[1,2,2,3,4]。就期待的结果而言这可能更“正确”,但是如果你仅以单纯的从左到右的方式考虑拷贝算法的话,它就可能让人糊涂。

fill(..) 原型方法

ES6 中的Array#fill(..)方法原生地支持使用一个指定的值来完全地(或部分地)填充一个既存的数组:

var a = Array( 4 ).fill( undefined );
a;
// [undefined,undefined,undefined,undefined]

fill(..)可选地接收 开始结束 参数,它们指示要被填充的数组的一部分,比如:

var a = [ null, null, null, null ].fill( 42, 1, 3 );

a;                                    // [null,42,42,null]

find(..) 原型方法

一般来说,在一个数组中搜索一个值的最常见方法曾经是indexOf(..)方法,如果值被找到的话它返回值的位置索引,没有找到的话返回-1

var a = [1,2,3,4,5];

(a.indexOf( 3 ) != -1);                // true
(a.indexOf( 7 ) != -1);                // false

(a.indexOf( "2" ) != -1);            // false

indexOf(..)比较要求一个严格===匹配,所以搜索"2"找不到值2,反之亦然。没有办法覆盖indexOf(..)的匹配算法。不得不手动与值-1进行比较也很不幸/不优雅。

提示: 一个使用~操作符来绕过难看的-1的有趣(而且争议性地令人糊涂)技术,参见本系列的 类型与文法

从 ES5 开始,控制匹配逻辑的最常见的迂回方法是some(..)。它的工作方式是为每一个元素调用一个回调函数,直到这些调用中的一个返回true/truthy 值,然后它就会停止。因为是由你来定义这个回调函数,所以你就拥有了如何做出匹配的完全控制权:

var a = [1,2,3,4,5];

a.some( function matcher(v){
    return v == "2";
} );                                // true

a.some( function matcher(v){
    return v == 7;
} );                                // false

但这种方式的缺陷是你只能使用true/false来指示是否找到了合适的匹配值,而不是实际被匹配的值。

ES6 的find(..)解决了这个问题。它的工作方式基本上与some(..)相同,除了一旦回调返回一个true/truthy 值,实际的数组值就会被返回:

var a = [1,2,3,4,5];

a.find( function matcher(v){
    return v == "2";
} );                                // 2

a.find( function matcher(v){
    return v == 7;                    // undefined
});

使用一个自定义的matcher(..)函数还允许你与对象这样的复杂值进行匹配:

var points = [
    { x: 10, y: 20 },
    { x: 20, y: 30 },
    { x: 30, y: 40 },
    { x: 40, y: 50 },
    { x: 50, y: 60 }
];

points.find( function matcher(point) {
    return (
        point.x % 3 == 0 &&
        point.y % 4 == 0
    );
} );                                // { x: 30, y: 40 }

注意: 和其他接收回调的数组方法一样,find(..)接收一个可选的第二参数。如果它被设置了的话,就将被指定为作为第一个参数传递的回调的this绑定。否则,this将是undefined

findIndex(..) 原型方法

虽然前一节展示了some(..)如何在一个数组检索给出一个 Boolean 结果,和find(..)如何从数组检索中给出匹配的值,但是还有一种需求是寻找匹配的值的位置索引。

indexOf(..)可以完成这个任务,但是没有办法控制它的匹配逻辑;它总是使用===严格等价。所以 ES6 的findIndex(..)才是答案:

var points = [
    { x: 10, y: 20 },
    { x: 20, y: 30 },
    { x: 30, y: 40 },
    { x: 40, y: 50 },
    { x: 50, y: 60 }
];

points.findIndex( function matcher(point) {
    return (
        point.x % 3 == 0 &&
        point.y % 4 == 0
    );
} );                                // 2

points.findIndex( function matcher(point) {
    return (
        point.x % 6 == 0 &&
        point.y % 7 == 0
    );
} );                                // -1

不要使用findIndex(..) != -1(在indexOf(..)中经常这么干)来从检索中取得一个 boolean,因为some(..)已经给出了你想要的true/false了。而且也不要用a[ a.findIndex(..) ]来取得一个匹配的值,因为这是find(..)完成的任务。最后,如果你需要严格匹配的索引,就使用indexOf(..),如果你需要一个更加定制化的匹配,就使用findIndex(..)

注意: 和其他接收回调的数组方法一样,find(..)接收一个可选的第二参数。如果它被设置了的话,就将被指定为作为第一个参数传递的回调的this绑定。否则,this将是undefined

entries(), values(), keys() 原型方法

在第三章中,我们展示了数据结构如何通过一个迭代器来提供一种模拟逐个值的迭代。然后我们在第五章探索新的 ES6 集合(Map,Set,等)如何为了产生不同种类的迭代器而提供几种方法时阐述了这种方式。

因为Array并不是 ES6 的新东西,所以它可能不被认为是一个传统意义上的“集合”,但是在它提供了相同的迭代器方法:entries()values(),和keys()的意义上,它是的。考虑如下代码:

var a = [1,2,3];

[...a.values()];                    // [1,2,3]
[...a.keys()];                        // [0,1,2]
[...a.entries()];                    // [ [0,1], [1,2], [2,3] ]

[...a[Symbol.iterator]()];            // [1,2,3]

就像Set一样,默认的Array迭代器与values()放回的东西相同。

在本章早先的“避免空值槽”一节中,我们展示了Array.from(..)如何将一个数组中的空值槽看作带有undefined的存在值槽。其实际的原因是,在底层数组迭代器就是以这种方式动作的:

var a = [];
a.length = 3;
a[1] = 2;

[...a.values()];        // [undefined,2,undefined]
[...a.keys()];            // [0,1,2]
[...a.entries()];        // [ [0,undefined], [1,2], [2,undefined] ]

Object

几个额外的静态帮助方法已经被加入Object。从传统意义上讲,这种种类的函数是关注于对象值的行为/能力的。

但是,从 ES6 开始,Object静态函数还用于任意种类的通用全局 API —— 那些还没有更自然地存在于其他的某些位置的 API(例如,Array.from(..))。

Object.is(..) 静态函数

Object.is(..)静态函数进行值的比较,它的风格甚至要比===比较还要严格。

Object(..)调用底层的SameValue算法(ES6 语言规范,第 7.2.9 节)。SameValue算法基本上与===严格等价比较算法相同(ES6 语言规范,第 7.2.13 节),但是带有两个重要的例外。

考虑如下代码:

var x = NaN, y = 0, z = -0;

x === x;                            // false
y === z;                            // true

Object.is( x, x );                    // true
Object.is( y, z );                    // false

你应当为严格等价性比较继续使用===Object.is(..)不应当被认为是这个操作符的替代品。但是,在你想要严格地识别NaN-0值的情况下,Object.is(..)是现在的首选方式。

注意: ES6 还增加了一个Number.isNaN(..)工具(在本章稍后讨论),它可能是一个稍稍方便一些的测试;比起Object.is(x, NaN)你可能更偏好Number.isNaN(x)。你 可以 使用笨拙的x == 0 && 1 / x === -Infinity来准确地测试-0,但在这种情况下Object.is(x,-0)要好得多。

Object.getOwnPropertySymbols(..) 静态函数

第二章中的“Symbol”一节讨论了 ES6 中的新 Symbol 基本值类型。

Symbol 可能将是在对象上最经常被使用的特殊(元)属性。所以引入了Object.getOwnPropertySymbols(..),它仅取回直接存在于对象上的 symbol 属性:

var o = {
    foo: 42,
    [ Symbol( "bar" ) ]: "hello world",
    baz: true
};

Object.getOwnPropertySymbols( o );    // [ Symbol(bar) ]

Object.setPrototypeOf(..) 静态函数

还是在第二章中,我们提到了Object.setPrototypeOf(..)工具,它为了 行为委托 的目的(意料之中地)设置一个对象的[[Prototype]](参见本系列的 this 与对象原型)。考虑如下代码:

var o1 = {
    foo() { console.log( "foo" ); }
};
var o2 = {
    // .. o2 的定义 ..
};

Object.setPrototypeOf( o2, o1 );

// 委托至 `o1.foo()`
o2.foo();                            // foo

另一种方式:

var o1 = {
    foo() { console.log( "foo" ); }
};

var o2 = Object.setPrototypeOf( {
    // .. o2 的定义 ..
}, o1 );

// 委托至 `o1.foo()`
o2.foo();                            // foo

在前面两个代码段中,o2o1之间的关系都出现在o2定义的末尾。更常见的是,o2o1之间的关系在o2定义的上面被指定,就像在类中,而且在对象字面量的__proto__中也是这样(参见第二章的“设置[[Prototype]]”)。

警告: 正如展示的那样,在对象创建之后立即设置[[Prototype]]是合理的。但是在很久之后才改变它一般不是一个好主意,而且经常会导致困惑而非清晰。

Object.assign(..) 静态函数

许多 JavaScript 库/框架都提供将一个对象的属性拷贝/混合到另一个对象中的工具(例如,jQuery 的extend(..))。在这些不同的工具中存在着各种微妙的区别,比如一个拥有undefined值的属性是否被忽略。

ES6 增加了Object.assign(..),它是这些算法的一个简化版本。第一个参数是 目标对象 而所有其他的参数是 源对象,它们会按照罗列的顺序被处理。对每一个源对象,它自己的(也就是,不是“继承的”)可枚举键,包括 symbol,将会好像通过普通=赋值那样拷贝。Object.assign(..)返回目标对象。

考虑这种对象构成:

var target = {},
    o1 = { a: 1 }, o2 = { b: 2 },
    o3 = { c: 3 }, o4 = { d: 4 };

// 设置只读属性
Object.defineProperty( o3, "e", {
    value: 5,
    enumerable: true,
    writable: false,
    configurable: false
} );

// 设置不可枚举属性
Object.defineProperty( o3, "f", {
    value: 6,
    enumerable: false
} );

o3[ Symbol( "g" ) ] = 7;

// 设置不可枚举 symbol
Object.defineProperty( o3, Symbol( "h" ), {
    value: 8,
    enumerable: false
} );

Object.setPrototypeOf( o3, o4 );

仅有属性abce,和Symbol("g")将被拷贝到target

Object.assign( target, o1, o2, o3 );

target.a;                            // 1
target.b;                            // 2
target.c;                            // 3

Object.getOwnPropertyDescriptor( target, "e" );
// { value: 5, writable: true, enumerable: true,
//   configurable: true }

Object.getOwnPropertySymbols( target );
// [Symbol("g")]

属性df,和Symbol("h")在拷贝中被忽略了;非枚举属性和非自身属性将会被排除在赋值之外。另外,e作为一个普通属性赋值被拷贝,而不是作为一个只读属性被复制。

在早先一节中,我们展示了使用setPrototypeOf(..)来在对象o2o1之间建立一个[[Prototype]]关系。这是利用Object.assign(..)的另外一种形式:

var o1 = {
    foo() { console.log( "foo" ); }
};

var o2 = Object.assign(
    Object.create( o1 ),
    {
        // .. o2 的定义 ..
    }
);

// 委托至 `o1.foo()`
o2.foo();                            // foo

注意: Object.create(..)是一个 ES5 标准工具,它创建一个[[Prototype]]链接好的空对象。更多信息参见本系列的 this 与对象原型

Math

ES6 增加了几种新的数学工具,它们协助或填补了常见操作的空白。所有这些操作都可以被手动计算,但是它们中的大多数现在都被原生地定义,这样 JS 引擎就可以优化计算的性能,或者进行与手动计算比起来小数精度更高的计算。

与直接的开发者相比,asm.js/转译的 JS 代码(参见本系列的 异步与性能)更可能是这些工具的使用者。

三角函数:

  • cosh(..) - 双曲余弦
  • acosh(..) - 双曲反余弦
  • sinh(..) - 双曲正弦
  • asinh(..) - 双曲反正弦
  • tanh(..) - 双曲正切
  • atanh(..) - 双曲反正切
  • hypot(..) - 平方和的平方根(也就是,广义勾股定理)

算数函数:

  • cbrt(..) - 立方根
  • clz32(..) - 计数 32 位二进制表达中前缀的零
  • expm1(..) - 与exp(x) - 1相同
  • log2(..) - 二进制对数(以 2 为底的对数)
  • log10(..) - 以 10 为底的对数
  • log1p(..) - 与log(x + 1)相同
  • imul(..) - 两个数字的 32 为整数乘法

元函数:

  • sign(..) - 返回数字的符号
  • trunc(..) - 仅返回一个数字的整数部分
  • fround(..) - 舍入到最接近的 32 位(单精度)浮点数值

Number

重要的是,为了你的程序能够正常工作,它必须准确地处理数字。ES6 增加了一些额外的属性和函数来辅助常见的数字操作。

两个在Number上新增的功能只是既存全局函数的引用:Number.parseInt(..)Number.parseFloat(..)

静态属性

ES6 以静态属性的形式增加了一些有用的数字常数:

  • Number.EPSILON - 在任意两个数字之间的最小值:2^-52(关于为了应对浮点算数运算不精确的问题而将这个值用做容差的讲解,参见本系列的 类型与文法 的第二章)
  • Number.MAX_SAFE_INTEGER - 可以用一个 JS 数字值明确且“安全地”表示的最大整数:2⁵³ - 1
  • Number.MIN_SAFE_INTEGER - 可以用一个 JS 数字值明确且“安全地”表示的最小整数:-(2⁵³ - 1)(-2)⁵³ + 1.

注意: 关于“安全”整数的更多信息,参见本系列的 类型与文法 的第二章。

Number.isNaN(..) 静态函数

标准的全局isNaN(..)工具从一开始就坏掉了,因为不仅对实际的NaN值返回true,而且对不是数字的东西也返回true。其原因是它会将参数值强制转换为数字类型(这可能失败而导致一个 NaN)。ES6 增加了一个修复过的工具Number.isNaN(..),它可以正确工作:

var a = NaN, b = "NaN", c = 42;

isNaN( a );                            // true
isNaN( b );                            // true —— 噢!
isNaN( c );                            // false

Number.isNaN( a );                    // true
Number.isNaN( b );                    // false —— 修好了!
Number.isNaN( c );                    // false

Number.isFinite(..) 静态函数

看到像isFinite(..)这样的函数名会诱使人们认为它单纯地意味着“不是无限”。但这不十分正确。这个新的 ES6 工具有更多的微妙之处。考虑如下代码:

var a = NaN, b = Infinity, c = 42;

Number.isFinite( a );                // false
Number.isFinite( b );                // false

Number.isFinite( c );                // true

标准的全局isFinite(..)会强制转换它收到的参数值,但是Number.isFinite(..)会省略强制转换的行为:

var a = "42";

isFinite( a );                        // true
Number.isFinite( a );                // false

你可能依然偏好强制转换,这时使用全局isFinite(..)是一个合法的选择。或者,并且可能是更明智的选择,你可以使用Number.isFinite(+x),它在将x传递前明确地将它强制转换为数字(参见本系列的 类型与文法 的第四章)。

整数相关的静态函数

JavaScript 数字值总是浮点数(IEEE-754)。所以判定一个数字是否是“整数”的概念与检查它的类型无关,因为 JS 没有这样的区分。

取而代之的是,你需要检查这个值是否拥有非零的小数部分。这样做的最简单的方法通常是:

x === Math.floor( x );

ES6 增加了一个Number.isInteger(..)帮助工具,它可以潜在地判定这种性质,而且效率稍微高一些:

Number.isInteger( 4 );                // true
Number.isInteger( 4.2 );            // false

注意: 在 JavaScript 中,44.4.0,或4.0000之间没有区别。它们都将被认为是一个“整数”,因此都会从Number.isInteger(..)中给出true

另外,Number.isInteger(..)过滤了一些明显的非整数值,它们在x === Math.floor(x)中可能会被混淆:

Number.isInteger( NaN );            // false
Number.isInteger( Infinity );        // false

有时候处理“整数”是信息的重点,它可以简化特定的算法。由于为了仅留下整数而进行过滤,JS 代码本身不会运行得更快,但是当仅有整数被使用时引擎可以采取几种优化技术(例如,asm.js)。

因为Number.isInteger(..)NanInfinity值的处理,定义一个isFloat(..)工具并不像!Number.isInteger(..)一样简单。你需要这么做:

function isFloat(x) {
    return Number.isFinite( x ) && !Number.isInteger( x );
}

isFloat( 4.2 );                        // true
isFloat( 4 );                        // false

isFloat( NaN );                        // false
isFloat( Infinity );                // false

注意: 这看起来可能很奇怪,但是无穷即不应当被认为是整数也不应当被认为是浮点数。

ES6 还定义了一个Number.isSafeInteger(..)工具,它检查一个值以确保它是一个整数并且在Number.MIN_SAFE_INTEGER-Number.MAX_SAFE_INTEGER的范围内(包含两端)。

var x = Math.pow( 2, 53 ),
    y = Math.pow( -2, 53 );

Number.isSafeInteger( x - 1 );        // true
Number.isSafeInteger( y + 1 );        // true

Number.isSafeInteger( x );            // false
Number.isSafeInteger( y );            // false

String

在 ES6 之前字符串就已经拥有好几种帮助函数了,但是有更多的内容被加入了进来。

Unicode 函数

在第二章的“Unicode 敏感的字符串操作”中详细讨论了String.fromCodePoint(..)String#codePointAt(..)String#normalize(..)。它们被用来改进 JS 字符串值对 Unicode 的支持。

String.fromCodePoint( 0x1d49e );            // "𝒞"

"ab𝒞d".codePointAt( 2 ).toString( 16 );        // "1d49e"

normalize(..)字符串原型方法用来进行 Unicode 规范化,它将字符与相邻的“组合标志”进行组合,或者将组合好的字符拆开。

一般来说,规范化不会对字符串的内容产生视觉上的影响,但是会改变字符串的内容,这可能会影响length属性报告的结果,以及用位置访问字符的行为:

var s1 = "e\u0301";
s1.length;                            // 2

var s2 = s1.normalize();
s2.length;                            // 1
s2 === "\xE9";                        // true

normalize(..)接受一个可选参数值,它用于指定使用的规范化形式。这个参数值必须是下面四个值中的一个:"NFC"(默认),"NFD""NFKC",或者"NFKD"

注意: 规范化形式和它们在字符串上的效果超出了我们要在这里讨论的范围。更多细节参见“Unicode 规范化形式”(www.unicode.org/reports/tr15/)。%E3%80%82)

String.raw(..) 静态函数

String.raw(..)工具被作为一个内建的标签函数来与字符串字面模板(参见第二章)一起使用,取得不带有任何转译序列处理的未加工的字符串值。

这个函数几乎永远不会被手动调用,但是将与被标记的模板字面量一起使用:

var str = "bc";

String.raw`\ta${str}d\xE9`;
// "\tabcd\xE9", not "    abcdé"

在结果字符串中,``和t是分离的未被加工过的字符,而不是一个转译字符序列\t。这对 Unicode 转译序列也是一样。

repeat(..) 原型函数

在 Python 和 Ruby 那样的语言中,你可以这样重复一个字符串:

"foo" * 3;                            // "foofoofoo"

在 JS 中这不能工作,因为*乘法是仅对数字定义的,因此"foo"会被强制转换为NaN数字。

但是,ES6 定义了一个字符串原型方法repeat(..)来完成这个任务:

"foo".repeat( 3 );                    // "foofoofoo"

字符串检验函数

作为对 ES6 以前的String#indexOf(..)String#lastIndexOf(..)的补充,增加了三个新的搜索/检验函数:startsWith(..)endsWidth(..),和includes(..)

var palindrome = "step on no pets";

palindrome.startsWith( "step on" );    // true
palindrome.startsWith( "on", 5 );    // true

palindrome.endsWith( "no pets" );    // true
palindrome.endsWith( "no", 10 );    // true

palindrome.includes( "on" );        // true
palindrome.includes( "on", 6 );        // false

对于所有这些字符串搜索/检验方法,如果你查询一个空字符串"",那么它将要么在字符串的开头被找到,要么就在字符串的末尾被找到。

警告: 这些方法默认不接受正则表达式作为检索字符串。关于关闭实施在第一个参数值上的isRegExp检查的信息,参见第七章的“正则表达式 Symbol”。

复习

ES6 在各种内建原生对象上增加了许多额外的 API 帮助函数:

  • Array增加了of(..)from(..)之类的静态函数,以及copyWithin(..)fill(..)之类的原型函数。
  • Object增加了is(..)assign(..)之类的静态函数。
  • Math增加了acosh(..)clz32(..)之类的静态函数。
  • Number增加了Number.EPSILON之类的静态属性,以及Number.isFinite(..)之类的静态函数。
  • String增加了String.fromCodePoint(..)String.raw(..)之类的静态函数,以及repeat(..)includes(..)之类的原型函数。

这些新增函数中的绝大多数都可以被填补(参见 ES6 Shim),它们都是受常见的 JS 库/框架中的工具启发的。

你不懂 JS:ES6 与未来 第七章:元编程

元编程是针对程序本身的行为进行操作的编程。换句话说,它是为你程序的编程而进行的编程。是的,很拗口,对吧?

例如,如果你为了调查对象a和另一个对象b之间的关系 —— 它们是被[[Prototype]]链接的吗? —— 而使用a.isPrototypeOf(b),这通常称为自省,就是一种形式的元编程。宏(JS 中还没有) —— 代码在编译时修改自己 —— 是元编程的另一个明显的例子。使用for..in循环枚举一个对象的键,或者检查一个对象是否是一个“类构造器”的 实例,是另一些常见的元编程任务。

元编程关注以下的一点或几点:代码检视自己,代码修改自己,或者代码修改默认的语言行为而使其他代码受影响。

元编程的目标是利用语言自身的内在能力使你其他部分的代码更具描述性,表现力,和/或灵活性。由于元编程的 的性质,要给它一个更精确的定义有些困难。理解元编程的最佳方法是通过代码来观察它。

ES6 在 JS 已经拥有的东西上,增加了几种新的元编程形式/特性。

函数名

有一些情况,你的代码想要检视自己并询问某个函数的名称是什么。如果你询问一个函数的名称,答案会有些令人诧异地模糊。考虑如下代码:

function daz() {
    // ..
}

var obj = {
    foo: function() {
        // ..
    },
    bar: function baz() {
        // ..
    },
    bam: daz,
    zim() {
        // ..
    }
};

在这前一个代码段中,“obj.foo()的名字是什么?”有些微妙。是"foo""",还是undefined?那么obj.bar()呢 —— 是"bar"还是"baz"obj.bam()称为"bam"还是"daz"obj.zim()呢?

另外,作为回调被传递的函数呢?就像:

function foo(cb) {
    // 这里的 `cb()` 的名字是什么?
}

foo( function(){
    // 我是匿名的!
} );

在程序中函数可以被好几种方法所表达,而函数的“名字”应当是什么并不总是那么清晰和明确。

更重要的是,我们需要区别函数的“名字”是指它的name属性 —— 是的,函数有一个叫做name的属性 —— 还是指它词法绑定的名称,比如在function bar() { .. }中的bar

词法绑定名称是你将在递归之类的东西中所使用的:

function foo(i) {
    if (i < 10) return foo( i * 2 );
    return i;
}

name属性是你为了元编程而使用的,所以它才是我们在这里的讨论中所关注的。

产生这种用困惑是因为,在默认情况下一个函数的词法名称(如果有的话)也会被设置为它的name属性。实际上,ES5(和以前的)语言规范中并没有官方要求这种行为。name属性的设置是一种非标准,但依然相当可靠的行为。在 ES6 中,它已经被标准化。

提示: 如果一个函数的name被赋值,它通常是在开发者工具的栈轨迹中使用的名称。

推断

但如果函数没有词法名称,name属性会怎么样呢?

现在在 ES6 中,有一个推断规则可以判定一个合理的name属性值来赋予一个函数,即使它没有词法名称可用。

考虑如下代码:

var abc = function() {
    // ..
};

abc.name;                // "abc"

如果我们给了这个函数一个词法名称,比如abc = function def() { .. },那么name属性将理所当然地是"def"。但是由于缺少词法名称,直观上名称"abc"看起来很合适。

这里是在 ES6 中将会(或不会)进行名称推断的其他形式:

(function(){ .. });                    // name:
(function*(){ .. });                // name:
window.foo = function(){ .. };        // name:

class Awesome {
    constructor() { .. }            // name: Awesome
    funny() { .. }                    // name: funny
}

var c = class Awesome { .. };        // name: Awesome

var o = {
    foo() { .. },                    // name: foo
    *bar() { .. },                    // name: bar
    baz: () => { .. },                // name: baz
    bam: function(){ .. },            // name: bam
    get qux() { .. },                // name: get qux
    set fuz() { .. },                // name: set fuz
    ["b" + "iz"]:
        function(){ .. },            // name: biz
    [Symbol( "buz" )]:
        function(){ .. }            // name: [buz]
};

var x = o.foo.bind( o );            // name: bound foo
(function(){ .. }).bind( o );        // name: bound

export default function() { .. }    // name: default

var y = new Function();                // name: anonymous
var GeneratorFunction =
    function*(){}.__proto__.constructor;
var z = new GeneratorFunction();    // name: anonymous

name属性默认是不可写的,但它是可配置的,这意味着如果有需要,你可以使用Object.defineProperty(..)来手动改变它。

元属性

在第三章的“new.target”一节中,我们引入了一个 ES6 的新概念:元属性。正如这个名称所暗示的,元属性意在以一种属性访问的形式提供特殊的元信息,而这在以前是不可能的。

new.target的情况下,关键字new作为一个属性访问的上下文环境。显然new本身不是一个对象,这使得这种能力很特殊。然而,当new.target被用于一个构造器调用(一个使用new调用的函数/方法)内部时,new变成了一个虚拟上下文环境,如此new.target就可以指代这个new调用的目标构造器。

这是一个元编程操作的典型例子,因为它的意图是从一个构造器调用内部判定原来的new的目标是什么,这一般是为了自省(检查类型/结构)或者静态属性访问。

举例来说,你可能想根据一个构造器是被直接调用,还是通过一个子类进行调用,来使它有不同的行为:

class Parent {
    constructor() {
        if (new.target === Parent) {
            console.log( "Parent instantiated" );
        }
        else {
            console.log( "A child instantiated" );
        }
    }
}

class Child extends Parent {}

var a = new Parent();
// Parent instantiated

var b = new Child();
// A child instantiated

这里有一个微妙的地方,在Parent类定义内部的constructor()实际上被给予了这个类的词法名称(Parent),即便语法暗示着这个类是一个与构造器分离的不同实体。

警告: 与所有的元编程技术一样,要小心不要创建太过聪明的代码,而使未来的你或其他维护你代码的人很难理解。小心使用这些技巧。

通用 Symbol

在第二章中的“Symbol”一节中,我们讲解了新的 ES6 基本类型symbol。除了你可以在你自己的程序中定义的 symbol 以外,JS 预定义了几种内建 symbol,被称为 通用(Well Known) Symbols(WKS)。

定义这些 symbol 值主要是为了向你的 JS 程序暴露特殊的元属性来给你更多 JS 行为的控制权。

我们将简要介绍每一个 symbol 并讨论它们的目的。

Symbol.iterator

在第二和第三章中,我们介绍并使用了@@iteratorsymbol,它被自动地用于...扩散和for..of循环。我们还在第五章中看到了在新的 ES6 集合中定义的@@iterator

Symbol.iterator表示在任意一个对象上的特殊位置(属性),语言机制自动地在这里寻找一个方法,这个方法将构建一个用于消费对象值的迭代器对象。许多对象都带有一个默认的Symbol.iterator

然而,我们可以通过设置Symbol.iterator属性来为任意对象定义我们自己的迭代器逻辑,即便它是覆盖默认迭代器的。这里的元编程观点是,我们在定义 JS 的其他部分(明确地说,是操作符和循环结构)在处理我们所定义的对象值时所使用的行为。

考虑如下代码:

var arr = [4,5,6,7,8,9];

for (var v of arr) {
    console.log( v );
}
// 4 5 6 7 8 9

// 定义一个仅在奇数索引处产生值的迭代器
arr[Symbol.iterator] = function*() {
    var idx = 1;
    do {
        yield this[idx];
    } while ((idx += 2) < this.length);
};

for (var v of arr) {
    console.log( v );
}
// 5 7 9

Symbol.toStringTagSymbol.hasInstance

最常见的元编程任务之一,就是在一个值上进行自省来找出它是什么 种类 的,者经常用来决定它们上面适于实施什么操作。对于对象,最常见的两个自省技术是toString()instanceof

考虑如下代码:

function Foo() {}

var a = new Foo();

a.toString();                // [object Object]
a instanceof Foo;            // true

在 ES6 中,你可以控制这些操作的行为:

function Foo(greeting) {
    this.greeting = greeting;
}

Foo.prototype[Symbol.toStringTag] = "Foo";

Object.defineProperty( Foo, Symbol.hasInstance, {
    value: function(inst) {
        return inst.greeting == "hello";
    }
} );

var a = new Foo( "hello" ),
    b = new Foo( "world" );

b[Symbol.toStringTag] = "cool";

a.toString();                // [object Foo]
String( b );                // [object cool]

a instanceof Foo;            // true
b instanceof Foo;            // false

在原型(或实例本身)上的@@toStringTagsymbol 指定一个用于[object ___]字符串化的字符串值。

@@hasInstancesymbol 是一个在构造器函数上的方法,它接收一个实例对象值并让你通过放回truefalse来决定这个值是否应当被认为是一个实例。

注意: 要在一个函数上设置@@hasInstance,你必须使用Object.defineProperty(..),因为在Function.prototype上默认的那一个是writable: false。更多信息参见本系列的 this 与对象原型

Symbol.species

在第三章的“类”中,我们介绍了@@speciessymbol,它控制一个类内建的生成新实例的方法使用哪一个构造器。

最常见的例子是,在子类化Array并且想要定义slice(..)之类被继承的方法应当使用哪一个构造器时。默认地,在一个Array的子类实例上调用的slice(..)将产生这个子类的实例,坦白地说这正是你经常希望的。

但是,你可以通过覆盖一个类的默认@@species定义来进行元编程:

class Cool {
    // 将 `@@species` 倒推至被衍生的构造器
    static get [Symbol.species]() { return this; }

    again() {
        return new this.constructor[Symbol.species]();
    }
}

class Fun extends Cool {}

class Awesome extends Cool {
    // 将 `@@species` 强制为父类构造器
    static get [Symbol.species]() { return Cool; }
}

var a = new Fun(),
    b = new Awesome(),
    c = a.again(),
    d = b.again();

c instanceof Fun;            // true
d instanceof Awesome;        // false
d instanceof Cool;            // true

就像在前面的代码段中的Cool的定义展示的那样,在内建的原生构造器上的Symbol.species设定默认为return this。它在用户自己的类上没有默认值,但也像展示的那样,这种行为很容易模拟。

如果你需要定义生成新实例的方法,使用new this.constructorSymbol.species的元编程模式,而不要用手写的new this.constructor(..)或者new XYZ(..)。如此衍生的类就能够自定义Symbol.species来控制哪一个构造器来制造这些实例。

Symbol.toPrimitive

在本系列的 类型与文法 一书中,我们讨论了ToPrimitive抽象强制转换操作,它在对象为了某些操作(例如==比较或者+加法)而必须被强制转换为一个基本类型值时被使用。在 ES6 以前,没有办法控制这个行为。

在 ES6 中,在任意对象值上作为属性的@@toPrimitivesymbol 都可以通过指定一个方法来自定义这个ToPrimitive强制转换。

考虑如下代码:

var arr = [1,2,3,4,5];

arr + 10;                // 1,2,3,4,510

arr[Symbol.toPrimitive] = function(hint) {
    if (hint == "default" || hint == "number") {
        // 所有数字的和
        return this.reduce( function(acc,curr){
            return acc + curr;
        }, 0 );
    }
};

arr + 10;                // 25

Symbol.toPrimitive方法将根据调用ToPrimitive的操作期望何种类型,而被提供一个值为"string""number",或"default"(这应当被解释为"number")的 提示(hint)。在前一个代码段中,+加法操作没有提示("default"将被传递)。一个*乘法操作将提示"number",而一个String(arr)将提示"string"

警告: ==操作符将在一个对象上不使用任何提来示调用ToPrimitive操作 —— 如果存在@@toPrimitive方法的话,将使用"default"被调用 —— 如果另一个被比较的值不是一个对象。但是,如果两个被比较的值都是对象,==的行为与===是完全相同的,也就是引用本身将被直接比较。这种情况下,@@toPrimitive根本不会被调用。关于强制转换和抽象操作的更多信息,参见本系列的 类型与文法

正则表达式 Symbols

对于正则表达式对象,有四种通用 symbols 可以被覆盖,它们控制着这些正则表达式在四个相应的同名String.prototype函数中如何被使用:

  • @@match:一个正则表达式的Symbol.match值是使用被给定的正则表达式来匹配一个字符串值的全部或部分的方法。如果你为String.prototype.match(..)传递一个正则表达式做范例匹配,它就会被使用。

    匹配的默认算法写在 ES6 语言规范的第 21.2.5.6 部分(people.mozilla.org/~jorendorff/es6-draft.html#sec-regexp.prototype-@@match)。你可以覆盖这个默认算法并提供额外的正则表达式特性,比如后顾断言。%E3%80%82%E4%BD%A0%E5%8F%AF%E4%BB%A5%E8%A6%86%E7%9B%96%E8%BF%99%E4%B8%AA%E9%BB%98%E8%AE%A4%E7%AE%97%E6%B3%95%E5%B9%B6%E6%8F%90%E4%BE%9B%E9%A2%9D%E5%A4%96%E7%9A%84%E6%AD%A3%E5%88%99%E8%A1%A8%E8%BE%BE%E5%BC%8F%E7%89%B9%E6%80%A7%EF%BC%8C%E6%AF%94%E5%A6%82%E5%90%8E%E9%A1%BE%E6%96%AD%E8%A8%80%E3%80%82)

    Symbol.match还被用于isRegExp抽象操作(参见第六章的“字符串检测函数”中的注意部分)来判定一个对象是否意在被用作正则表达式。为了使一个这样的对象不被看作是正则表达式,可以将Symbol.match的值设置为false(或 falsy 的东西)强制这个检查失败。

  • @@replace:一个正则表达式的Symbol.replace值是被String.prototype.replace(..)使用的方法,来替换一个字符串里面出现的一个或所有字符序列,这些字符序列匹配给出的正则表达式范例。

    替换的默认算法写在 ES6 语言规范的第 21.2.5.8 部分(people.mozilla.org/~jorendorff/es6-draft.html#sec-regexp.prototype-@@replace)。%E3%80%82)

    一个覆盖默认算法的很酷的用法是提供额外的replacer可选参数值,比如通过用连续的替换值消费可迭代对象来支持"abaca".replace(/a/g,[1,2,3])产生"1b2c3"

  • @@search:一个正则表达式的Symbol.search值是被String.prototype.search(..)使用的方法,来在一个字符串中检索一个匹配给定正则表达式的子字符串。

    检索的默认算法写在 ES6 语言规范的第 21.2.5.9 部分(people.mozilla.org/~jorendorff/es6-draft.html#sec-regexp.prototype-@@search)。%E3%80%82)

  • @@split:一个正则表达式的Symbol.split值是被String.prototype.split(..)使用的方法,来将一个字符串在分隔符匹配给定正则表达式的位置分割为子字符串。

    分割的默认算法写在 ES6 语言规范的第 21.2.5.11 部分(people.mozilla.org/~jorendorff/es6-draft.html#sec-regexp.prototype-@@split)。%E3%80%82)

覆盖内建的正则表达式算法不是为心脏脆弱的人准备的!JS 带有高度优化的正则表达式引擎,所以你自己的用户代码将很可能慢得多。这种类型的元编程很精巧和强大,但是应当仅用于确实必要或有好处的情况下。

Symbol.isConcatSpreadable

@@isConcatSpreadablesymbol 可以作为一个布尔属性(Symbol.isConcatSpreadable)在任意对象上(比如一个数组或其他的可迭代对象)定义,来指示当它被传递给一个数组concat(..)时是否应当被 扩散

考虑如下代码:

var a = [1,2,3],
    b = [4,5,6];

b[Symbol.isConcatSpreadable] = false;

[].concat( a, b );        // [1,2,3,[4,5,6]]

Symbol.unscopables

@@unscopablessymbol 可以作为一个对象属性(Symbol.unscopables)在任意对象上定义,来指示在一个with语句中哪一个属性可以和不可以作为此法变量被暴露。

考虑如下代码:

var o = { a:1, b:2, c:3 },
    a = 10, b = 20, c = 30;

o[Symbol.unscopables] = {
    a: false,
    b: true,
    c: false
};

with (o) {
    console.log( a, b, c );        // 1 20 3
}

一个在@@unscopables对象中的true指示这个属性应当是 非作用域(unscopable) 的,因此会从此法作用域变量中被过滤掉。false意味着它可以被包含在此法作用域变量中。

警告: with语句在strict模式下是完全禁用的,而且因此应当被认为是在语言中被废弃的。不要使用它。更多信息参见本系列的 作用域与闭包。因为应当避免with,所以这个@@unscopablessymbol 也是无意义的。

代理

在 ES6 中被加入的最明显的元编程特性之一就是proxy特性。

一个代理是一种由你创建的特殊的对象,它“包”着另一个普通的对象 —— 或者说挡在这个普通对象的前面。你可以在代理对象上注册特殊的处理器(也叫 机关(traps)),当对这个代理实施各种操作时被调用。这些处理器除了将操作 传送 到原本的目标/被包装的对象上之外,还有机会运行额外的逻辑。

一个这样的 机关 处理器的例子是,你可以在一个代理上定义一个拦截[[Get]]操作的get —— 它在当你试图访问一个对象上的属性时运行。考虑如下代码:

var obj = { a: 1 },
    handlers = {
        get(target,key,context) {
            // 注意:target === obj,
            // context === pobj
            console.log( "accessing: ", key );
            return Reflect.get(
                target, key, context
            );
        }
    },
    pobj = new Proxy( obj, handlers );

obj.a;
// 1

pobj.a;
// accessing: a
// 1

我们将一个get(..)处理器作为 处理器 对象的命名方法声明(Proxy(..)的第二个参数值),它接收一个指向 目标 对象的引用(obj),属性的 名称("a"),和self/接受者/代理本身(pobj)。

在追踪语句console.log(..)之后,我们通过Reflect.get(..)将操作“转送”到obj。我们将在下一节详细讲解ReflectAPI,但要注意的是每个可用的代理机关都有一个相应的同名Reflect函数。

这些映射是故意对称的。每个代理处理器在各自的元编程任务实施时进行拦截,而每个Reflect工具将各自的元编程任务在一个对象上实施。每个代理处理器都有一个自动调用相应Reflect工具的默认定义。几乎可以肯定你将总是一前一后地使用ProxyReflect

这里的列表是你可以在一个代理上为一个 目标 对象/函数定义的处理器,以及它们如何/何时被触发:

  • get(..):通过[[Get]],在代理上访问一个属性(Reflect.get(..).属性操作符或[ .. ]属性操作符)
  • set(..):通过[[Set]],在代理对象上设置一个属性(Reflect.set(..)=赋值操作符,或者解构赋值 —— 如果目标是一个对象属性的话)
  • deleteProperty(..):通过[[Delete]],在代理对象上删除一个属性 (Reflect.deleteProperty(..)delete)
  • apply(..)(如果 目标 是一个函数):通过[[Call]],代理作为一个普通函数/方法被调用(Reflect.apply(..)call(..)apply(..),或者(..)调用操作符)
  • construct(..)(如果 目标 是一个构造函数):通过[[Construct]]代理作为一个构造器函数被调用(Reflect.construct(..)new
  • getOwnPropertyDescriptor(..):通过[[GetOwnProperty]],从代理取得一个属性的描述符(Object.getOwnPropertyDescriptor(..)Reflect.getOwnPropertyDescriptor(..)
  • defineProperty(..):通过[[DefineOwnProperty]],在代理上设置一个属性描述符(Object.defineProperty(..)Reflect.defineProperty(..)
  • getPrototypeOf(..):通过[[GetPrototypeOf]],取得代理的[[Prototype]]Object.getPrototypeOf(..)Reflect.getPrototypeOf(..)__proto__, Object#isPrototypeOf(..),或instanceof
  • setPrototypeOf(..):通过[[SetPrototypeOf]],设置代理的[[Prototype]]Object.setPrototypeOf(..)Reflect.setPrototypeOf(..),或__proto__
  • preventExtensions(..):通过[[PreventExtensions]]使代理成为不可扩展的(Object.preventExtensions(..)Reflect.preventExtensions(..)
  • isExtensible(..):通过[[IsExtensible]],检测代理的可扩展性(Object.isExtensible(..)Reflect.isExtensible(..)
  • ownKeys(..):通过[[OwnPropertyKeys]],取得一组代理的直属属性和/或直属 symbol 属性(Object.keys(..)Object.getOwnPropertyNames(..)Object.getOwnSymbolProperties(..)Reflect.ownKeys(..),或JSON.stringify(..)
  • enumerate(..):通过[[Enumerate]],为代理的可枚举直属属性及“继承”属性请求一个迭代器(Reflect.enumerate(..)for..in
  • has(..):通过[[HasProperty]],检测代理是否拥有一个直属属性或“继承”属性(Reflect.has(..)Object#hasOwnProperty(..),或"prop" in obj

提示: 关于每个这些元编程任务的更多信息,参见本章稍后的“Reflect API”一节。

关于将会触发各种机关的动作,除了在前面列表中记载的以外,一些机关还会由另一个机关的默认动作间接地触发。举例来说:

var handlers = {
        getOwnPropertyDescriptor(target,prop) {
            console.log(
                "getOwnPropertyDescriptor"
            );
            return Object.getOwnPropertyDescriptor(
                target, prop
            );
        },
        defineProperty(target,prop,desc){
            console.log( "defineProperty" );
            return Object.defineProperty(
                target, prop, desc
            );
        }
    },
    proxy = new Proxy( {}, handlers );

proxy.a = 2;
// getOwnPropertyDescriptor
// defineProperty

在设置一个属性值时(不管是新添加还是更新),getOwnPropertyDescriptor(..)defineProperty(..)处理器被默认的set(..)处理器触发。如果你还定义了你自己的set(..)处理器,你或许对context(不是target!)进行了将会触发这些代理机关的相应调用。

代理的限制

这些元编程处理器拦截了你可以对一个对象进行的范围很广泛的一组基础操作。但是,有一些操作不能(至少是还不能)被用于拦截。

例如,从pobj代理到obj目标,这些操作全都没有被拦截和转送:

var obj = { a:1, b:2 },
    handlers = { .. },
    pobj = new Proxy( obj, handlers );

typeof obj;
String( obj );
obj + "";
obj == pobj;
obj === pobj

也许在未来,更多这些语言中的底层基础操作都将是可拦截的,那将给我们更多力量来从 JavaScript 自身扩展它。

警告: 对于代理处理器的使用来说存在某些 不变量 —— 它们的行为不能被覆盖。例如,isExtensible(..)处理器的结果总是被强制转换为一个boolean。这些不变量限制了一些你可以使用代理来自定义行为的能力,但是它们这样做只是为了防止你创建奇怪和不寻常(或不合逻辑)的行为。这些不变量的条件十分复杂,所以我们就不再这里全面阐述了,但是这篇博文(www.2ality.com/2014/12/es6-proxies.html#invariants)很好地讲解了它们。%E5%BE%88%E5%A5%BD%E5%9C%B0%E8%AE%B2%E8%A7%A3%E4%BA%86%E5%AE%83%E4%BB%AC%E3%80%82)

可撤销的代理

一个一般的代理总是包装着目标对象,而且在创建之后就不能修改了 —— 只要保持着一个指向这个代理的引用,代理的机制就将维持下去。但是,可能会有一些情况你想要创建一个这样的代理:在你想要停止它作为代理时可以被停用。解决方案就是创建一个 可撤销代理

var obj = { a: 1 },
    handlers = {
        get(target,key,context) {
            // 注意:target === obj,
            // context === pobj
            console.log( "accessing: ", key );
            return target[key];
        }
    },
    { proxy: pobj, revoke: prevoke } =
        Proxy.revocable( obj, handlers );

pobj.a;
// accessing: a
// 1

// 稍后:
prevoke();

pobj.a;
// TypeError

一个可撤销代理是由Proxy.revocable(..)创建的,它是一个普通的函数,不是一个像Proxy(..)那样的构造器。此外,它接收同样的两个参数值:目标处理器

new Proxy(..)不同的是,Proxy.revocable(..)的返回值不是代理本身。取而代之的是,它返回一个带有 proxyrevoke 两个属性的对象 —— 我们使用了对象解构(参见第二章的“解构”)来将这些属性分别赋值给变量pobjprevoke

一旦可撤销代理被撤销,任何访问它的企图(触发它的任何机关)都将抛出TypeError

一个使用可撤销代理的例子可能是,将一个代理交给另一个存在于你应用中、并管理你模型中的数据的团体,而不是给它们一个指向正式模型对象本身的引用。如果你的模型对象改变了或者被替换掉了,你希望废除这个你交出去的代理,以便于其他的团体能够(通过错误!)知道要请求一个更新过的模型引用。

使用代理

这些代理处理器带来的元编程的好处应当是显而易见的。我们可以全面地拦截(而因此覆盖)对象的行为,这意味着我们可以用一些非常强大的方式将对象行为扩展至 JS 核心之外。我们将看几个模式的例子来探索这些可能性。

代理前置,代理后置

正如我们早先提到过的,你通常将一个代理考虑为一个目标对象的“包装”。在这种意义上,代理就变成了代码接口所针对的主要对象,而实际的目标对象则保持被隐藏/被保护的状态。

你可能这么做是因为你希望将对象传递到某个你不能完全“信任”的地方去,如此你需要在它的访问权上强制实施一些特殊的规则,而不是传递这个对象本身。

考虑如下代码:

var messages = [],
    handlers = {
        get(target,key) {
            // 是字符串值吗?
            if (typeof target[key] == "string") {
                // 过滤掉标点符号
                return target[key]
                    .replace( /[^\w]/g, "" );
            }

            // 让其余的东西通过
            return target[key];
        },
        set(target,key,val) {
            // 仅设置唯一的小写字符串
            if (typeof val == "string") {
                val = val.toLowerCase();
                if (target.indexOf( val ) == -1) {
                    target.push(
                        val.toLowerCase()
                    );
                }
            }
            return true;
        }
    },
    messages_proxy =
        new Proxy( messages, handlers );

// 在别处:
messages_proxy.push(
    "heLLo...", 42, "wOrlD!!", "WoRld!!"
);

messages_proxy.forEach( function(val){
    console.log(val);
} );
// hello world

messages.forEach( function(val){
    console.log(val);
} );
// hello... world!!

我称此为 代理前置 设计,因为我们首先(主要、完全地)与代理进行互动。

我们在与messages_proxy的互动上强制实施了一些特殊规则,这些规则不会强制实施在messages本身上。我们仅在值是一个不重复的字符串时才将它添加为元素;我们还将这个值变为小写。当从messages_proxy取得值时,我们过滤掉字符串中所有的标点符号。

另一种方式是,我们可以完全反转这个模式,让目标与代理交互而不是让代理与目标交互。这样,代码其实只与主对象交互。达成这种后备方案的最简单的方法是,让代理对象存在于主对象的[[Prototype]]链中。

考虑如下代码:

var handlers = {
        get(target,key,context) {
            return function() {
                context.speak(key + "!");
            };
        }
    },
    catchall = new Proxy( {}, handlers ),
    greeter = {
        speak(who = "someone") {
            console.log( "hello", who );
        }
    };

// 让 `catchall` 成为 `greeter` 的后备方法
Object.setPrototypeOf( greeter, catchall );

greeter.speak();                // hello someone
greeter.speak( "world" );        // hello world

greeter.everyone();                // hello everyone!

我们直接与greeter而非catchall进行交互。当我们调用speak(..)时,它在greeter上被找到并直接使用。但当我们试图访问everyone()这样的方法时,这个函数并不存在于greeter

默认的对象属性行为是向上检查[[Prototype]]链(参见本系列的 this 与对象原型),所以catchall被询问有没有一个everyone属性。然后代理的get()处理器被调用并返回一个函数,这个函数使用被访问的属性名("everyone")调用speak(..)

我称这种模式为 代理后置,因为代理仅被用作最后一道防线。

"No Such Property/Method"

一个关于 JS 的常见的抱怨是,在你试着访问或设置一个对象上还不存在的属性时,默认情况下对象不是非常具有防御性。你可能希望为一个对象预定义所有这些属性/方法,而且在后续使用不存在的属性名时抛出一个错误。

我们可以使用一个代理来达成这种想法,既可以使用 代理前置 也可以 代理后置 设计。我们将两者都考虑一下。

var obj = {
        a: 1,
        foo() {
            console.log( "a:", this.a );
        }
    },
    handlers = {
        get(target,key,context) {
            if (Reflect.has( target, key )) {
                return Reflect.get(
                    target, key, context
                );
            }
            else {
                throw "No such property/method!";
            }
        },
        set(target,key,val,context) {
            if (Reflect.has( target, key )) {
                return Reflect.set(
                    target, key, val, context
                );
            }
            else {
                throw "No such property/method!";
            }
        }
    },
    pobj = new Proxy( obj, handlers );

pobj.a = 3;
pobj.foo();            // a: 3

pobj.b = 4;            // Error: No such property/method!
pobj.bar();            // Error: No such property/method!

对于get(..)set(..)两者,我们仅在目标对象的属性已经存在时才转送操作;否则抛出错误。代理对象应当是进行交互的主对象,因为它拦截这些操作来提供保护。

现在,让我们考虑一下反过来的 代理后置 设计:

var handlers = {
        get() {
            throw "No such property/method!";
        },
        set() {
            throw "No such property/method!";
        }
    },
    pobj = new Proxy( {}, handlers ),
    obj = {
        a: 1,
        foo() {
            console.log( "a:", this.a );
        }
    };

// 让 `pobj` 称为 `obj` 的后备
Object.setPrototypeOf( obj, pobj );

obj.a = 3;
obj.foo();            // a: 3

obj.b = 4;            // Error: No such property/method!
obj.bar();            // Error: No such property/method!

在处理器如何定义的角度上,这里的 代理后置 设计相当简单。与拦截[[Get]][[Set]]操作并仅在目标属性存在时转送它们不同,我们依赖于这样一个事实:不管[[Get]]还是[[Set]]到达了我们的pobj后备对象,这个动作已经遍历了整个[[Prototype]]链并且没有找到匹配的属性。在这时我们可以自由地、无条件地抛出错误。很酷,对吧?

代理黑入 [[Prototype]]

[[Get]]操作是[[Prototype]]机制被调用的主要渠道。当一个属性不能在直接对象上找到时,[[Get]]会自动将操作交给[[Prototype]]对象。

这意味着你可以使用一个代理的get(..)机关来模拟或扩展这个[[Prototype]]机制的概念。

我们将考虑的第一种黑科技是创建两个通过[[Prototype]]循环链接的对象(或者说,至少看起来是这样!)。你不能实际创建一个真正循环的[[Prototype]]链,因为引擎将会抛出一个错误。但是代理可以假冒它!

考虑如下代码:

var handlers = {
        get(target,key,context) {
            if (Reflect.has( target, key )) {
                return Reflect.get(
                    target, key, context
                );
            }
            // 假冒循环的 `[[Prototype]]`
            else {
                return Reflect.get(
                    target[
                        Symbol.for( "[[Prototype]]" )
                    ],
                    key,
                    context
                );
            }
        }
    },
    obj1 = new Proxy(
        {
            name: "obj-1",
            foo() {
                console.log( "foo:", this.name );
            }
        },
        handlers
    ),
    obj2 = Object.assign(
        Object.create( obj1 ),
        {
            name: "obj-2",
            bar() {
                console.log( "bar:", this.name );
                this.foo();
            }
        }
    );

// 假冒循环的 `[[Prototype]]` 链
obj1[ Symbol.for( "[[Prototype]]" ) ] = obj2;

obj1.bar();
// bar: obj-1 <-- 通过代理假冒 [[Prototype]]
// foo: obj-1 <-- `this` 上下文环境依然被保留

obj2.foo();
// foo: obj-2 <-- 通过 [[Prototype]]

注意: 为了让事情简单一些,在这个例子中我们没有代理/转送[[Set]]。要完整地模拟[[Prototype]]兼容,你会想要实现一个set(..)处理器,它在[[Prototype]]链上检索一个匹配得属性并遵循它的描述符的行为(例如,set,可写性)。参见本系列的 this 与对象原型

在前面的代码段中,obj2凭借Object.create(..)语句[[Prototype]]链接到obj1。但是要创建反向(循环)的链接,我们在obj1的 symbol 位置Symbol.for("[[Prototype]]")(参见第二章的“Symbol”)上创建了一个属性。这个 symbol 可能看起来有些特别/魔幻,但它不是的。它只是允许我使用一个被方便地命名的属性,这个属性在语义上看来是与我进行的任务有关联的。

然后,代理的get(..)处理器首先检查一个被请求的key是否存在于代理上。如果每个有,操作就被手动地交给存储在targetSymbol.for("[[Prototype]]")位置中的对象引用。

这种模式的一个重要优点是,在obj1obj2之间建立循环关系几乎没有入侵它们的定义。虽然前面的代码段为了简短而将所有的步骤交织在一起,但是如果你仔细观察,代理处理器的逻辑完全是范用的(不具体地知道obj1obj2)。所以,这段逻辑可以抽出到一个简单的将它们连在一起的帮助函数中,例如setCircularPrototypeOf(..)。我们将此作为一个练习留给读者。

现在我们看到了如何使用get(..)来模拟一个[[Prototype]]链接,但让我们将这种黑科技推动的远一些。与其制造一个循环[[Prototype]],搞一个多重[[Prototype]]链接(也就是“多重继承”)怎么样?这看起来相当直白:

var obj1 = {
        name: "obj-1",
        foo() {
            console.log( "obj1.foo:", this.name );
        },
    },
    obj2 = {
        name: "obj-2",
        foo() {
            console.log( "obj2.foo:", this.name );
        },
        bar() {
            console.log( "obj2.bar:", this.name );
        }
    },
    handlers = {
        get(target,key,context) {
            if (Reflect.has( target, key )) {
                return Reflect.get(
                    target, key, context
                );
            }
            // 假冒多重 `[[Prototype]]`
            else {
                for (var P of target[
                    Symbol.for( "[[Prototype]]" )
                ]) {
                    if (Reflect.has( P, key )) {
                        return Reflect.get(
                            P, key, context
                        );
                    }
                }
            }
        }
    },
    obj3 = new Proxy(
        {
            name: "obj-3",
            baz() {
                this.foo();
                this.bar();
            }
        },
        handlers
    );

// 假冒多重 `[[Prototype]]` 链接
obj3[ Symbol.for( "[[Prototype]]" ) ] = [
    obj1, obj2
];

obj3.baz();
// obj1.foo: obj-3
// obj2.bar: obj-3

注意: 正如在前面的循环[[Prototype]]例子后的注意中提到的,我们没有实现set(..)处理器,但对于一个将[[Set]]模拟为普通[[Prototype]]行为的解决方案来说,它将是必要的。

obj3被设置为多重委托到obj1obj2。在obj2.baz()中,this.foo()调用最终成为从obj1中抽出foo()(先到先得,虽然还有一个在obj2上的foo())。如果我们将连接重新排列为obj2, obj1,那么obj2.foo()将被找到并使用。

同理,this.bar()调用没有在obj1上找到bar(),所以它退而检查obj2,这里找到了一个匹配。

obj1obj2代表obj3的两个平行的[[Prototype]]链。obj1和/或obj2自身可以拥有委托至其他对象的普通[[Prototype]],或者自身也可以是多重委托的代理(就像obj3一样)。

正如先前的循环[[Prototype]]的例子一样,obj1obj2obj3的定义几乎完全与处理多重委托的范用代理逻辑相分离。定义一个setPrototypesOf(..)(注意那个“s”!)这样的工具将是小菜一碟,它接收一个主对象和一组模拟多重[[Prototype]]链接用的对象。同样,我们将此作为练习留给读者。

希望在这种种例子之后代理的力量现在变得明朗了。代理使得许多强大的元编程任务成为可能。

Reflect API

Reflect对象是一个普通对象(就像Math),不是其他内建原生类型那样的函数/构造器。

它持有对应于你可以控制的各种元编程任务的静态函数。这些函数与代理可以定义的处理器方法(机关)一一对应。

这些函数中的一些看起来与在Object上的同名函数很相似:

  • Reflect.getOwnPropertyDescriptor(..)
  • Reflect.defineProperty(..)
  • Reflect.getPrototypeOf(..)
  • Reflect.setPrototypeOf(..)
  • Reflect.preventExtensions(..)
  • Reflect.isExtensible(..)

这些工具一般与它们的Object.*对等物的行为相同。但一个区别是,Object.*对等物在它们的第一个参数值(目标对象)还不是对象的情况下,试图将它强制转换为一个对象。Reflect.*方法在同样的情况下仅简单地抛出一个错误。

一个对象的键可以使用这些工具访问/检测:

  • Reflect.ownKeys(..):返回一个所有直属(不是“继承的”)键的列表,正如被 Object.getOwnPropertyNames(..)Object.getOwnPropertySymbols(..)返回的那样。关于键的顺序问题,参见“属性枚举顺序”一节。
  • Reflect.enumerate(..):返回一个产生所有(直属和“继承的”)非 symbol、可枚举的键的迭代器(参见本系列的 this 与对象原型)。 实质上,这组键与在for..in循环中被处理的那一组键是相同的。关于键的顺序问题,参见“属性枚举顺序”一节。
  • Reflect.has(..):实质上与用于检查一个属性是否存在于一个对象或它的[[Prototype]]链上的in操作符相同。例如,Reflect.has(o,"foo")实质上实施"foo" in o

函数调用和构造器调用可以使用这些工具手动地实施,与普通的语法(例如,(..)new)分开:

  • Reflect.apply(..):例如,Reflect.apply(foo,thisObj,[42,"bar"])使用thisObj作为foo(..)函数的this来调用它,并传入参数值42"bar"
  • Reflect.construct(..):例如,Reflect.construct(foo,[42,"bar"])实质上调用new foo(42,"bar")

对象属性访问,设置,和删除可以使用这些工具手动实施:

  • Reflect.get(..):例如,Reflect.get(o,"foo")会取得o.foo
  • Reflect.set(..):例如,Reflect.set(o,"foo",42)实质上实施o.foo = 42
  • Reflect.deleteProperty(..):例如,Reflect.deleteProperty(o,"foo")实质上实施delete o.foo

Reflect的元编程能力给了你可以模拟各种语法特性的程序化等价物,暴露以前隐藏着的抽象操作。例如,你可以使用这些能力来扩展 领域特定语言(DSL)的特性和 API。

属性顺序

在 ES6 之前,罗列一个对象的键/属性的顺序没有在语言规范中定义,而是依赖于具体实现的。一般来说,大多数引擎会以创建的顺序来罗列它们,虽然开发者们已经被强烈建议永远不要依仗这种顺序。

在 ES6 中,罗列直属属性的属性是由[[OwnPropertyKeys]]算法定义的(ES6 语言规范,9.1.12 部分),它产生所有直属属性(字符串或 symbol),不论其可枚举性。这种顺序仅对Reflect.ownKeys(..)有保证()。

这个顺序是:

  1. 首先,以数字上升的顺序,枚举所有数字索引的直属属性。
  2. 然后,以创建顺序枚举剩下的直属字符串属性名。
  3. 最后,以创建顺序枚举直属 symbol 属性。

考虑如下代码:

var o = {};

o[Symbol("c")] = "yay";
o[2] = true;
o[1] = true;
o.b = "awesome";
o.a = "cool";

Reflect.ownKeys( o );                // [1,2,"b","a",Symbol(c)]
Object.getOwnPropertyNames( o );    // [1,2,"b","a"]
Object.getOwnPropertySymbols( o );    // [Symbol(c)]

另一方面,[[Enumeration]]算法(ES6 语言规范,9.1.11 部分)从目标对象和它的[[Prototype]]链中仅产生可枚举属性。它被用于Reflect.enumerate(..)for..in。可观察到的顺序是依赖于具体实现的,语言规范没有控制它。

相比之下,Object.keys(..)调用[[OwnPropertyKeys]]算法来得到一个所有直属属性的列表。但是,它过滤掉了不可枚举属性,然后特别为了JSON.stringify(..)for..in而将这个列表重排,以匹配遗留的、依赖于具体实现的行为。所以通过扩展,这个顺序 Reflect.enumerate(..)的顺序像吻合。

换言之,所有四种机制(Reflect.enumerate(..)Object.keys(..)for..in,和JSON.stringify(..))都同样将与依赖于具体实现的顺序像吻合,虽然技术上它们是以不同的方式达到的同样的效果。

具体实现可以将这四种机制与[[OwnPropertyKeys]]的顺序相吻合,但不是必须的。无论如何,你将很可能从它们的行为中观察到以下的排序:

var o = { a: 1, b: 2 };
var p = Object.create( o );
p.c = 3;
p.d = 4;

for (var prop of Reflect.enumerate( p )) {
    console.log( prop );
}
// c d a b

for (var prop in p) {
    console.log( prop );
}
// c d a b

JSON.stringify( p );
// {"c":3,"d":4}

Object.keys( p );
// ["c","d"]

这一切可以归纳为:在 ES6 中,根据语言规范Reflect.ownKeys(..)Object.getOwnPropertyNames(..),和Object.getOwnPropertySymbols(..)保证都有可预见和可靠的顺序。所以依赖于这种顺序来建造代码是安全的。

Reflect.enumerate(..)Object.keys(..),和for..in (扩展一下的话还有JSON.stringification(..))继续互相共享一个可观察的顺序,就像它们往常一样。但这个顺序不一定与Reflect.ownKeys(..)的相同。在使用它们依赖于具体实现的顺序时依然应当小心。

特性测试

什么是特性测试?它是一种由你运行来判定一个特性是否可用的测试。有些时候,这种测试不仅是为了判定存在性,还是为判定对特定行为的适应性 —— 特性可能存在但有 bug。

这是一种元编程技术 —— 测试你程序将要运行的环境然后判定你的程序应当如何动作。

在 JS 中特性测试最常见的用法是检测一个 API 的存在性,而且如果它不存在,就定义一个填补(见第一章)。例如:

if (!Number.isNaN) {
    Number.isNaN = function(x) {
        return x !== x;
    };
}

在这个代码段中的if语句就是一个元编程:我们探测我们的程序和它的运行时环境,来判定我们是否和如何进行后续处理。

但是如何测试一个涉及新语法的特性呢?

你可能会尝试这样的东西:

try {
 a = () => {};
    ARROW_FUNCS_ENABLED = true;
}
catch (err) {
    ARROW_FUNCS_ENABLED = false;
}

不幸的是,这不能工作,因为我们的 JS 程序是要被编译的。因此,如果引擎还没有支持 ES6 箭头函数的话,它就会在() => {}语法的地方熄火。你程序中的语法错误会阻止它的运行,进而阻止你程序根据特性是否被支持而进行后续的不同相应。

为了围绕语法相关的特性进行特性测试的元编程,我们需要一个方法将测试与我们程序将要通过的初始编译步骤隔离开。举例来说,如果我们能够将进行测试的代码存储在一个字符串中,之后 JS 引擎默认地将不会尝试编译这个字符串中的内容,直到我们要求它这么做。

你的思路是不是跳到了使用eval(..)

别这么着急。看看本系列的 作用域与闭包 来了解一下为什么eval(..)是一个坏主意。但是有另外一个缺陷较少的选项:Function(..)构造器。

考虑如下代码:

try {
    new Function( "( () => {} )" );
    ARROW_FUNCS_ENABLED = true;
}
catch (err) {
    ARROW_FUNCS_ENABLED = false;
}

好了,现在我们判定一个像箭头函数这样的特性是否 被当前的引擎所编译来进行元编程。你可能会想知道,我们要用这种信息做什么?

检查 API 的存在性,并定义后备的 API 填补,对于特性检测成功或失败来说都是一条明确的道路。但是对于从ARROW_FUNCS_ENABLEDtrue还是false中得到的信息来说,我们能对它做什么呢?

因为如果引擎不支持一种特性,它的语法就不能出现在一个文件中,所以你不能在这个文件中定义使用这种语法的函数。

你所能做的是,使用测试来判定你应当加载哪一组 JS 文件。例如,如果在你的 JS 应用程序中的启动装置中有一组这样的特性测试,那么它就可以测试环境来判定你的 ES6 代码是否可以直接加载运行,或者你是否需要加载一个代码的转译版本(参见第一章)。

这种技术称为 分割投递

事实表明,你使用 ES6 编写的 JS 程序有时可以在 ES6+浏览器中完全“原生地”运行,但是另一些时候需要在前 ES6 浏览器中运行转译版本。如果你总是加载并使用转译代码,即便是在新的 ES6 兼容环境中,至少是有些情况下你运行的也是次优的代码。这并不理想。

分割投递更加复杂和精巧,但对于你编写的代码和你的程序所必须在其中运行的浏览器支持的特性之间,它代表一种更加成熟和健壮的桥接方式。

FeatureTests.io

为所有的 ES6+语法以及语义行为定义特性测试,是一项你可能不想自己解决的艰巨任务。因为这些测试要求动态编译(new Function(..)),这会产生不幸的性能损耗。

另外,在每次你的应用运行时都执行这些测试可能是一种浪费,因为平均来说一个用户的浏览器在几周之内至多只会更新一次,而即使是这样,新特性也不一定会在每次更新中都出现。

最终,管理一个对你特定代码库进行的特性测试列表 —— 你的程序将很少用到 ES6 的全部 —— 是很容易失控而且易错的。

featuretests.io”的“特性测试服务”为这种挫折提供了解决方案。

你可以将这个服务的库加载到你的页面中,而它会加载最新的测试定义并运行所有的特性测试。在可能的情况下,它将使用 Web Worker 的后台处理中这样做,以降低性能上的开销。它还会使用 LocalStorage 持久化来缓存测试的结果 —— 以一种可以被所有你访问的使用这个服务的站点所共享的方式,这将及大地降低测试需要在每个浏览器实例上运行的频度。

你可以在每一个用户的浏览器上进行运行时特性测试,而且你可以使用这些测试结果动态地向用户传递最适合他们环境的代码(不多也不少)。

另外,这个服务还提供工具和 API 来扫描你的文件以判定你需要什么特性,这样你就能够完全自动化你的分割投递构建过程。

对 ES6 的所有以及未来的部分进行特性测试,以确保对于任何给定的环境都只有最佳的代码会被加载和运行 —— FeatureTests.io 使这成为可能。

尾部调用优化(TCO)

通常来说,当从一个函数内部发起对另一个函数的调用时,就会分配一个 栈帧 来分离地管理这另一个函数调用的变量/状态。这种分配不仅花费一些处理时间,还会消耗一些额外的内存。

一个调用栈链从一个函数到另一个再到另一个,通常至多拥有 10-15 跳。在这些场景下,内存使用不太可能是某种实际问题。

然而,当你考虑递归编程(一个函数频繁地调用自己) —— 或者使用两个或更多的函数相互调用而构成相互递归 —— 调用栈就可能轻易地到达上百,上千,或更多层的深度。如果内存的使用无限制地增长下去,你可能看到了它将导致的问题。

JavaScript 引擎不得不设置一个随意的限度来防止这样的编程技术耗尽浏览器或设备的内存。这就是为什么我们会在到达这个限度时得到令人沮丧的“RangeError: Maximum call stack size exceeded”。

警告: 调用栈深度的限制是不由语言规范控制的。它是依赖于具体实现的,而且将会根据浏览器和设备不同而不同。你绝不应该带着可精确观察到的限度的强烈臆想进行编码,因为它们还很可能在每个版本中变化。

一种称为 尾部调用 的特定函数调用模式,可以以一种避免额外的栈帧分配的方法进行优化。如果额外的分配可以被避免,那么就没有理由随意地限制调用栈的深度,这样引擎就可以让它们没有边界地运行下去。

一个尾部调用是一个带有函数调用的return语句,除了返回它的值,函数调用之后没有任何事情需要发生。

这种优化只能在strict模式下进行。又一个你总是应该用strict编写所有代码的理由!

这个函数调用 不是 在尾部:

"use strict";

function foo(x) {
    return x * 2;
}

function bar(x) {
    // 不是一个尾部调用
    return 1 + foo( x );
}

bar( 10 );                // 21

foo(x)调用完成后必须进行1 + ..,所以那个bar(..)调用的状态需要被保留。

但是下面的代码段中展示的foo(..)bar(..)都是位于尾部,因为它们都是在自身代码路径上(除了return以外)发生的最后一件事:

"use strict";

function foo(x) {
    return x * 2;
}

function bar(x) {
    x = x + 1;
    if (x > 10) {
        return foo( x );
    }
    else {
        return bar( x + 1 );
    }
}

bar( 5 );                // 24
bar( 15 );                // 32

在这个程序中,bar(..)明显是递归,但foo(..)只是一个普通的函数调用。这两个函数调用都位于 恰当的尾部位置x + 1bar(..)调用之前被求值,而且不论这个调用何时完成,所有将要放生的只有return

这些形式的恰当尾部调用(Proper Tail Calls —— PTC)是可以被优化的 —— 称为尾部调用优化(TCO)—— 于是额外的栈帧分配是不必要的。与为下一个函数调用创建新的栈帧不同,引擎会重用既存的栈帧。这能够工作是因为一个函数不需要保留任何当前状态 —— 在 PTC 之后的状态下不会发生任何事情。

TCO 意味着调用栈可以有多深实际上是没有限度的。这种技巧稍稍改进了一般程序中的普通函数调用,但更重要的是它打开了一扇大门:可以使用递归表达程序,即使它的调用栈深度有成千上万层。

我们不再局限于单纯地在理论上考虑用递归解决问题了,而是可以在真实的 JavaScript 程序中使用它!

作为 ES6,所有的 PTC 都应该是可以以这种方式优化的,不论递归与否。

重写尾部调用

然而,障碍是只有 PTC 是可以被优化的;非 PTC 理所当然地依然可以工作,但是将造成往常那样的栈帧分配。如果你希望优化机制启动,就必须小心地使用 PTC 构造你的函数。

如果你有一个没有用 PTC 编写的函数,你可能会发现你需要手动地重新安排你的代码,使它成为合法的 TCO。

考虑如下代码:

"use strict";

function foo(x) {
    if (x <= 1) return 1;
    return (x / 2) + foo( x - 1 );
}

foo( 123456 );            // RangeError

foo(x-1)的调用不是一个 PTC,因为在return之前它的结果必须被加上(x / 2)

但是,要使这段代码在一个 ES6 引擎中是合法的 TCO,我们可以像下面这样重写它:

"use strict";

var foo = (function(){
    function _foo(acc,x) {
        if (x <= 1) return acc;
        return _foo( (x / 2) + acc, x - 1 );
    }

    return function(x) {
        return _foo( 1, x );
    };
})();

foo( 123456 );            // 3810376848.5

如果你在一个实现了 TCO 的 ES6 引擎中运行前面这个代码段,你将会如展示的那样得到答案3810376848.5。然而,它仍然会在非 TCO 引擎中因为RangeError而失败。

非 TCO 优化

有另一种技术可以重写代码,让调用栈不随每次调用增长。

一个这样的技术称为 蹦床,它相当于让每一部分结果表示为一个函数,这个函数要么返回另一个部分结果函数,要么返回最终结果。然后你就可以简单地循环直到你不再收到一个函数,这时你就得到了结果。考虑如下代码:

"use strict";

function trampoline( res ) {
    while (typeof res == "function") {
        res = res();
    }
    return res;
}

var foo = (function(){
    function _foo(acc,x) {
        if (x <= 1) return acc;
        return function partial(){
            return _foo( (x / 2) + acc, x - 1 );
        };
    }

    return function(x) {
        return trampoline( _foo( 1, x ) );
    };
})();

foo( 123456 );            // 3810376848.5

这种返工需要一些最低限度的改变来将递归抽出到trampoline(..)中的循环中:

  1. 首先,我们将return _foo ..这一行包装进函数表达式return partial() {..
  2. 然后我们将_foo(1,x)包装进trampoline(..)调用。

这种技术之所以不受调用栈限制的影响,是因为每个内部的partial(..)函数都只是返回到trampoline(..)while循环中,这个循环运行它然后再一次循环迭代。换言之,partial(..)并不递归地调用它自己,它只是返回另一个函数。栈的深度维持不变,所以它需要运行多久就可以运行多久。

蹦床表达的是,内部的partial()函数使用在变量xacc上的闭包来保持迭代与迭代之间的状态。它的优势是循环的逻辑可以被抽出到一个可重用的trampoline(..)工具函数中,许多库都提供这个工具的各种版本。你可以使用不同的蹦床算法在你的程序中重用trampoline(..)多次。

当然,如果你真的想要深度优化(于是可复用性不予考虑),你可以摒弃闭包状态,并将对acc的状态追踪,与一个循环一起内联到一个函数的作用域内。这种技术通常称为 递归展开

"use strict";

function foo(x) {
    var acc = 1;
    while (x > 1) {
        acc = (x / 2) + acc;
        x = x - 1;
    }
    return acc;
}

foo( 123456 );            // 3810376848.5

算法的这种表达形式很容易阅读,而且很可能是在我们探索过的各种形式中性能最好的(严格地说)一个。很明显它看起来是一个胜利者,而且你可能会想知道为什么你曾尝试其他的方式。

这些是为什么你可能不想总是手动地展开递归的原因:

  • 与为了复用而将弹簧(循环)逻辑抽出去相比,我们内联了它。这在仅有一个这样的例子需要考虑时工作的很好,但只要你在程序中有五六个或更多这样的东西时,你将很可能想要一些可复用性来将让事情更简短、更易管理一些。

  • 这里的例子为了展示不同的形式而被故意地搞得很简单。在现实中,递归算法有着更多的复杂性,比如相互递归(有多于一个的函数调用它自己)。

    你在这条路上走得越远,展开 优化就变得越复杂和越依靠手动。你很快就会失去所有可读性的认知价值。递归,甚至是 PTC 形式的递归的主要优点是,它保留了算法的可读性,并将性能优化的任务交给引擎。

如果你使用 PTC 编写你的算法,ES6 引擎将会实施 TCO 来使你的代码运行在一个定长深度的栈中(通过重用栈帧)。你将在得到递归的可读性的同时,也得到性能上的大部分好处与无限的运行长度。

元?

TCO 与元编程有什么关系?

正如我们在早先的“特性测试”一节中讲过的,你可以在运行时判定一个引擎支持什么特性。这也包括 TCO,虽然判定的过程相当粗暴。考虑如下代码:

"use strict";

try {
    (function foo(x){
        if (x < 5E5) return foo( x + 1 );
    })( 1 );

    TCO_ENABLED = true;
}
catch (err) {
    TCO_ENABLED = false;
}

在一个非 TCO 引擎中,递归循环最终将会失败,抛出一个被try..catch捕获的异常。否则循环将由 TCO 轻易地完成。

讨厌,对吧?

但是围绕着 TCO 特性进行的元编程(或者,没有它)如何给我们的代码带来好处?简单的答案是你可以使用这样的特性测试来决定加载一个你的应用程序的使用递归的版本,还是一个被转换/转译为不需要递归的版本。

自我调整的代码

但这里有另外一种看待这个问题的方式:

"use strict";

function foo(x) {
    function _foo() {
        if (x > 1) {
            acc = acc + (x / 2);
            x = x - 1;
            return _foo();
        }
    }

    var acc = 1;

    while (x > 1) {
        try {
            _foo();
        }
        catch (err) { }
    }

    return acc;
}

foo( 123456 );            // 3810376848.5

这个算法试图尽可能多地使用递归来工作,但是通过作用域中的变量xacc来跟踪这个进程。如果整个问题可以通过递归没有错误地解决,很好。如果引擎在某一点终止了递归,我们简单地使用try..catch捕捉它,然后从我们离开的地方再试一次。

我认为这是一种形式的元编程,因为你在运行时期间探测着引擎是否能(递归地)完成任务的能力,并绕过了任何可能制约你的(非 TCO 的)引擎的限制。

一眼(或者是两眼!)看上去,我打赌这段代码要比以前的版本难看许多。它运行起来还相当地慢一些(在一个非 TCO 环境中长时间运行的情况下)。

它主要的优势是,除了在非 TCO 引擎中也能完成任意栈大小的任务外,这种对递归栈限制的“解法”要比前面展示的蹦床和手动展开技术灵活得多。

实质上,这种情况下的_foo()实际上是任意递归任务,甚至是相互递归的某种替身。剩下的内容是应当对任何算法都可以工作的模板代码。

唯一的“技巧”是为了能够在达到递归限制的事件发生时继续运行,递归的状态必须保存在递归函数外部的作用域变量中。我们是通过将xacc留在_foo()函数外面这样做的,而不是像早先那样将它们作为参数值传递给_foo()

几乎所有的递归算法都可以采用这种方法工作。这意味着它是在你的程序中,进行最小的重写就能利用 TCO 递归的最广泛的可行方法。

这种方式仍然使用一个 PTC,意味着这段代码将会 渐进增强:从在一个老版浏览器中使用许多次循环(递归批处理)来运行,到在一个 ES6+环境中完全利用 TCO 递归。我觉得这相当酷!

复习

元编程是当你将程序的逻辑转向关注它自身(或者它的运行时环境)时进行的编程,要么为了调查它自己的结构,要么为了修改它。元编程的主要价值是扩展语言的普通机制来提供额外的能力。

在 ES6 以前,JavaScript 已经有了相当的元编程能力,但是 ES6 使用了几个新特性及大地提高了它的地位。

从对匿名函数的函数名推断,到告诉你一个构造器是如何被调用的元属性,你可以前所未有地在程序运行期间来调查它的结构。通用 Symbols 允许你覆盖固有的行为,比如将一个对象转换为一个基本类型值的强制转换。代理可以拦截并自定义各种在对象上的底层操作,而且Reflect提供了模拟它们的工具。

特性测试,即便是对尾部调用优化这样微妙的语法行为,将元编程的焦点从你的程序提升到 JS 引擎的能力本身。通过更多地了解环境可以做什么,你的程序可以在运行时将它们自己调整到最佳状态。

你应该进行元编程吗?我的建议是:先集中学习这门语言的核心机制是如何工作的。一旦你完全懂得了 JS 本身可以做什么,就是开始利用这些强大的元编程能力将这门语言向前推进的时候了!

你不懂 JS:ES6 与未来 第八章:ES6 以后

在本书写作的时候,ES6(ECMAScript 2015)的最终草案即将为了 ECMA 的批准而进行最终的官方投票。但即便是在 ES6 已经被最终定稿的时候,TC39 协会已经在为了 ES7/2016 和将来的特性进行努力的工作。

正如我们在第一章中讨论过的,预计 JS 进化的节奏将会从好几年升级一次加速到每年进行一次官方的版本升级(因此采用编年命名法)。这将会彻底改变 JS 开发者学习与跟上这门语言脚步的方式。

但更重要的是,协会实际上将会一个特性一个特性地进行工作。只要一种特性的规范被定义完成,而且通过在几种浏览器中的实验性实现打通了关节,那么这种特性就会被认为足够稳定并可以开始使用了。我们都被强烈鼓励一旦特性准备好就立即采用它,而不是等待什么官方标准投票。如果你还没学过 ES6,现在上船的日子已经过了!

在本书写作时,一个未来特性提案的列表和它们的状态可以在这里看到(github.com/tc39/ecma262#current-proposals)。%E3%80%82)

在所有我们支持的浏览器实现这些新特性之前,转译器和填补是我们如何桥接它们的方法。Babel,Traceur,和其他几种主流转译器已经支持了一些最可能稳定下来的 ES6 之后的特性。

认识到这一点,是时候看一看它们之中的一些了。让我们开始吧!

警告: 这些特性都处于开发的各种阶段。虽然它们很可能确定下来,而且将与本章的内容看起来相似,但还是要抱着更多质疑的态度看待本章的内容。这一章将会在本书未来的版本中随着这些(和其他的!)特性的确定而演化。

async function

我们在第四章的“Generators + Promises”中提到过,generatoryield一个 promise 给一个类似运行器的工具,它会在 promise 完成时推进 generator —— 有一个提案是要为这种模式提供直接的语法支持。让我们简要看一下这个被提出的特性,它称为async function

回想一下第四章中的这个 generator 的例子:

run( function *main() {
    var ret = yield step1();

    try {
        ret = yield step2( ret );
    }
    catch (err) {
        ret = yield step2Failed( err );
    }

    ret = yield Promise.all([
        step3a( ret ),
        step3b( ret ),
        step3c( ret )
    ]);

    yield step4( ret );
} )
.then(
    function fulfilled(){
        // `*main()` 成功地完成了
    },
    function rejected(reason){
        // 噢,什么东西搞错了
    }
);

被提案的async function语法可以无需run(..)工具就表达相同的流程控制逻辑,因为 JS 将会自动地知道如何寻找 promise 来等待和推进。考虑如下代码:

async function main() {
    var ret = await step1();

    try {
        ret = await step2( ret );
    }
    catch (err) {
        ret = await step2Failed( err );
    }

    ret = await Promise.all( [
        step3a( ret ),
        step3b( ret ),
        step3c( ret )
    ] );

    await step4( ret );
}

main()
.then(
    function fulfilled(){
        // `main()` 成功地完成了
    },
    function rejected(reason){
        // 噢,什么东西搞错了
    }
);

取代function *main() { ..声明的,是我们使用async function main() { ..形式声明。而取代yield一个 promise 的,是我们await这个 promise。运行main()函数的调用实际上返回一个我们可以直接监听的 promise。这与我们从一个run(main)调用中拿回一个 promise 是等价的。

你看到对称性了吗?async function实质上是 generators + promises + run(..)模式的语法糖;它们在底层的操作是相同的!

如果你是一个 C#开发者而且这种async/await看起来很熟悉,那是因为这种特性就是直接由 C#的特性启发的。看到语言提供一致性是一件好事!

Babel、Traceur 以及其他转译器已经对当前的async function状态有了早期支持,所以你已经可以使用它们了。但是,在下一节的“警告”中,我们将看到为什么你也许还不应该上这艘船。

注意: 还有一个async function*的提案,它应当被称为“异步 generator”。你可以在同一段代码中使用yieldawait两者,甚至是在同一个语句中组合这两个操作:x = await yield y。“异步 generator”提案看起来更具变化 —— 也就是说,它返回一个没有还没有完全被计算好的值。一些人觉得它应当是一个 可监听对象(observable),有些像是一个迭代器和 promise 的组合。就目前来说,我们不会进一步探讨这个话题,但是会继续关注它的演变。

警告

关于async function的一个未解的争论点是,因为它仅返回一个 promise,所以没有办法从外部 撤销 一个当前正在运行的async function实例。如果这个异步操作是资源密集型的,而且你想在自己确定不需要它的结果时能立即释放资源,这可能是一个问题。

举例来说:

async function request(url) {
    var resp = await (
        new Promise( function(resolve,reject){
            var xhr = new XMLHttpRequest();
            xhr.open( "GET", url );
            xhr.onreadystatechange = function(){
                if (xhr.readyState == 4) {
                    if (xhr.status == 200) {
                        resolve( xhr );
                    }
                    else {
                        reject( xhr.statusText );
                    }
                }
            };
            xhr.send();
        } )
    );

    return resp.responseText;
}

var pr = request( "http://some.url.1" );

pr.then(
    function fulfilled(responseText){
        // ajax 成功
    },
    function rejected(reason){
        // 噢,什么东西搞错了
    }
);

我构想的request(..)有点儿像最近被提案要包含进 web 平台的fetch(..)工具。我们关心的是,例如,如果你想要用pr值以某种方法指示撤销一个长时间运行的 Ajax 请求会怎么样?

Promise 是不可撤销的(在本书写作时)。在我和其他许多人看来,它们就不应该是可以被撤销的(参见本系列的 异步与性能)。而且即使一个 proimse 确实拥有一个cancel()方法,那么一定意味着调用pr.cancel()应当真的沿着 promise 链一路传播一个撤销信号到async function吗?

对于这个争论的几种可能的解决方案已经浮出水面:

  • async function将根本不能被撤销(现状)
  • 一个“撤销存根”可以在调用时传递给一个异步函数
  • 将返回值改变为一个新增的可撤销 promsie 类型
  • 将返回值改变为非 promise 的其他东西(比如,可监听对象,或带有 promise 和撤销能力的控制存根)

在本书写作时,async function返回普通的 promise,所以完全改变返回值不太可能。但是现在下定论还是为时过早了。让我们持续关注这个讨论吧。

Object.observe(..)

前端 web 开发的圣杯之一就是数据绑定 —— 监听一个数据对象的更新并同步这个数据的 DOM 表现形式。大多数 JS 框架都为这些类型的操作提供某种机制。

在 ES6 后期,我们似乎很有可能看到这门语言通过一个称为Object.observe(..)的工具,对此提供直接的支持。实质上,它的思想是你可以建立监听器来监听一个对象的变化,并在一个变化发生的任何时候调用一个回调。例如,你可相应地更新 DOM。

你可以监听六种类型的变化:

  • add
  • update
  • delete
  • reconfigure
  • setPrototype
  • preventExtensions

默认情况下,你将会收到所有这些类型的变化的通知,但是你可以将它们过滤为你关心的那一些。

考虑如下代码:

var obj = { a: 1, b: 2 };

Object.observe(
    obj,
    function(changes){
        for (var change of changes) {
            console.log( change );
        }
    },
    [ "add", "update", "delete" ]
);

obj.c = 3;
// { name: "c", object: obj, type: "add" }

obj.a = 42;
// { name: "a", object: obj, type: "update", oldValue: 1 }

delete obj.b;
// { name: "b", object: obj, type: "delete", oldValue: 2 }

除了主要的"add""update"、和"delete"变化类型:

  • "reconfigure"变化事件在对象的一个属性通过Object.defineProperty(..)而重新配置时触发,比如改变它的writable属性。更多信息参见本系列的 this 与对象原型

  • "preventExtensions"变化事件在对象通过Object.preventExtensions(..)被设置为不可扩展时触发。

    因为Object.seal(..)Object.freeze(..)两者都暗示着Object.preventExtensions(..),所以它们也将触发相应的变化事件。另外,"reconfigure"变化事件也会为对象上的每个属性被触发。

  • "setPrototype"变化事件在一个对象的[[Prototype]]被改变时触发,不论是使用__proto__setter,还是使用Object.setPrototypeOf(..)设置它。

注意,这些变化事件在会在变化发生后立即触发。不要将它们与代理(见第七章)搞混,代理是可以在动作发生之前拦截它们的。对象监听让你在变化(或一组变化)发生之后进行应答。

自定义变化事件

除了六种内建的变化事件类型,你还可以监听并触发自定义变化事件。

考虑如下代码:

function observer(changes){
    for (var change of changes) {
        if (change.type == "recalc") {
            change.object.c =
                change.object.oldValue +
                change.object.a +
                change.object.b;
        }
    }
}

function changeObj(a,b) {
    var notifier = Object.getNotifier( obj );

    obj.a = a * 2;
    obj.b = b * 3;

    // queue up change events into a set
    notifier.notify( {
        type: "recalc",
        name: "c",
        oldValue: obj.c
    } );
}

var obj = { a: 1, b: 2, c: 3 };

Object.observe(
    obj,
    observer,
    ["recalc"]
);

changeObj( 3, 11 );

obj.a;            // 12
obj.b;            // 30
obj.c;            // 3

变化的集合("recalc"自定义事件)为了投递给监听器而被排队,但还没被投递,这就是为什么obj.c依然是3

默认情况下,这些变化将在当前事件轮询(参见本系列的 异步与性能)的末尾被投递。如果你想要立即投递它们,使用Object.deliverChangeRecords(observer)。一旦这些变化投递完成,你就可以观察到obj.c如预期地更新为:

obj.c;            // 42

在前面的例子中,我们使用变化完成事件的记录调用了notifier.notify(..)。将变化事件的记录进行排队的一种替代形式是使用performChange(..),它把事件的类型与事件记录的属性(通过一个函数回调)分割开来。考虑如下代码:

notifier.performChange( "recalc", function(){
    return {
        name: "c",
        // `this` 是被监听的对象
        oldValue: this.c
    };
} );

在特定的环境下,这种关注点分离可能与你的使用模式匹配的更干净。

中止监听

正如普通的事件监听器一样,你可能希望停止监听一个对象的变化事件。为此,你可以使用Object.unobserve(..)

举例来说:

var obj = { a: 1, b: 2 };

Object.observe( obj, function observer(changes) {
    for (var change of changes) {
        if (change.type == "setPrototype") {
            Object.unobserve(
                change.object, observer
            );
            break;
        }
    }
} );

在这个小例子中,我们监听变化事件直到我们看到"setPrototype"事件到来,那时我们就不再监听任何变化事件了。

指数操作符

为了使 JavaScript 以与Math.pow(..)相同的方式进行指数运算,有一个操作符被提出了。考虑如下代码:

var a = 2;

a ** 4;            // Math.pow( a, 4 ) == 16

a **= 3;        // a = Math.pow( a, 3 )
a;                // 8

注意: **实质上在 Python、Ruby、Perl、和其他语言中都与此相同。

对象属性与 ...

正如我们在第二章的“太多,太少,正合适”一节中看到的,...操作符在扩散或收集一个数组上的工作方式是显而易见的。但对象会怎么样?

这样的特性在 ES6 中被考虑过,但是被推迟到 ES6 之后(也就是“ES7”或者“ES2016”或者……)了。这是它在“ES6 以后”的时代中可能的工作方式:

var o1 = { a: 1, b: 2 },
    o2 = { c: 3 },
    o3 = { ...o1, ...o2, d: 4 };

console.log( o3.a, o3.b, o3.c, o3.d );
// 1 2 3 4

...操作符也可能被用于将一个对象的被解构属性收集到另一个对象:

var o1 = { b: 2, c: 3, d: 4 };
var { b, ...o2 } = o1;

console.log( b, o2.c, o2.d );        // 2 3 4

这里,...o2将被解构的cd属性重新收集到一个o2对象中(与o1不同,o2没有b属性)。

重申一下,这些只是正在考虑之中的 ES6 之后的提案。但是如果它们能被确定下来就太酷了。

Array#includes(..)

JS 开发者需要执行的极其常见的一个任务就是在一个值的数组中搜索一个值。完成这项任务的方式曾经总是:

var vals = [ "foo", "bar", 42, "baz" ];

if (vals.indexOf( 42 ) >= 0) {
    // 找到了!
}

进行>= 0检查是因为indexOf(..)在找到结果时返回一个0或更大的数字值,或者在没找到结果时返回-1。换句话说,我们在一个布尔值的上下文环境中使用了一个返回索引的函数。而由于-1是 truthy 而非 falsy,所以我们不得不手动进行检查。

在本系列的 类型与文法 中,我探索了另一种我稍稍偏好的模式:

var vals = [ "foo", "bar", 42, "baz" ];

if (~vals.indexOf( 42 )) {
    // 找到了!
}

这里的~操作符使indexOf(..)的返回值与一个值的范围相一致,这个范围可以恰当地强制转换为布尔型。也就是,-1产生0(falsy),而其余的东西产生非零值(truthy),而这正是我们判定是否找到值的依据。

虽然我觉得这是一种改进,但有另一些人强烈反对。然而,没有人会质疑indexOf(..)的检索逻辑是完美的。例如,在数组中查找NaN值会失败。

于是一个提案浮出了水面并得到了大量的支持 —— 增加一个真正的返回布尔值的数组检索方法,称为includes(..)

var vals = [ "foo", "bar", 42, "baz" ];

if (vals.includes( 42 )) {
    // 找到了!
}

注意: Array#includes(..)使用了将会找到NaN值的匹配逻辑,但将不会区分-00(参见本系列的 类型与文法)。如果你在自己的程序中不关心-0值,那么它很可能正是你希望的。如果你 确实 关心-0,那么你就需要实现你自己的检索逻辑,很可能是使用Object.is(..)工具(见六章)。

SIMD

我们在本系列的 异步与性能 中详细讲解了一个指令,多个数据(SIMD),但因为它是未来 JS 中下一个很可能被确定下来的特性,所以这里简要地提一下。

SIMD API 暴露了各种底层(CPU)指令,它们可以同时操作一个以上的数字值。例如,你可以指定两个拥有 4 个或 8 个数字的 向量,然后一次性分别相乘所有元素(数据并行机制!)。

考虑如下代码:

var v1 = SIMD.float32x4( 3.14159, 21.0, 32.3, 55.55 );
var v2 = SIMD.float32x4( 2.1, 3.2, 4.3, 5.4 );

SIMD.float32x4.mul( v1, v2 );
// [ 6.597339, 67.2, 138.89, 299.97 ]

SIMD 将会引入mul(..)(乘法)之外的几种其他操作,比如sub()div()abs()neg()sqrt()、以及其他许多。

并行数学操作对下一代的高性能 JS 应用程序至关重要。

WebAssembly (WASM)

在本书的第一版将近完成的时候,Brendan Eich 突然宣布了一个有可能对 JavaScript 未来的道路产生重大冲击的公告:WebAssembly(WASM)。我们不能在这里详细地探讨 WASM,因为在本书写作时这个话题为时过早了。但如果不简要地提上一句,这本书就不够完整。

JS 语言在近期(和近未来的)设计的改变上所承受的最大压力之一,就是渴望它能够成为从其他语言(比如 C/C++,ClojureScript,等等)转译/交叉编译来的、合适的目标语言。显然,作为 JavaScript 运行的代码性能是一个主要问题。

正如在本系列的 异步与性能 中讨论过的,几年前一组在 Mozilla 的开发者给 JavaScript 引入了一个称为 ASM.js 的想法。AMS.js 是一个合法 JS 的子集,它大幅地制约了使代码难于被 JS 引擎优化的特定行为。其结果就是兼容 AMS.js 的代码在一个支持 ASM 的引擎上可以显著地快速运行,几乎可以与优化过的原生 C 语言的等价物相媲美。许多观点认为,对于那些将要由 JavaScript 编写的渴求性能的应用程序来说,ASM.js 很可能将是它们的基干。

换言之,在浏览器中条条大路通过 JavaScript 通向运行的代码。

直到 WASM 公告之前,是这样的。WASM 提供了另一条路线,让其他语言不必非得首先通过 JavaScript 就能将浏览器的运行时环境作为运行的目标。实质上,如果 WASM 启用,JS 引擎将会生长出额外的能力 —— 执行可以被视为有些与字节码相似的二进制代码(就像在 JVM 上运行的那些东西)。

WASM 提出了一种高度压缩的代码 AST(语法树)的二进制表示格式,它可以继而像 JS 引擎以及它的基础结构直接发出指令,无需被 JS 解析,甚至无需按照 JS 的规则动作。像 C 或 C++这样的语言可以直接被编译为 WASM 格式而非 ASM.js,并且由于跳过 JS 解析而得到额外的速度优势。

短期内,WASM 与 AMS.js、JS 不相上下。但是最终,人们预期 WASM 将会生长出新的能力,那将超过 JS 能做的任何事情。例如,让 JS 演化出像线程这样的根本特性 —— 一个肯定会对 JS 生态系统造成重大冲击的改变 —— 作为一个 WASM 未来的扩展更有希望,也会缓解改变 JS 的压力。

事实上,这张新的路线图为许多语言服务于 web 运行时开启了新的道路。对于 web 平台来说,这真是一个激动人心的新路线!

它对 JS 意味着什么?JS 将会变得无关紧要或者“死去”吗?绝对不是。ASM.js 在接下来的几年中很可能看不到太多未来,但 JS 在数量上的绝对优势将它安全地锚定在 web 平台中。

WASM 的拥护者们说,它的成功意味着 JS 的设计将会被保护起来,远离那些最终会迫使它超过自己合理性的临界点的压力。人们估计 WASM 将会成为应用程序中高性能部分的首选目标语言,这些部分曾用各种各样不同的语言编写过。

有趣的是,JavaScript 是未来不太可能以 WASM 为目标的语言之一。可能有一些未来的改变会切出 JS 的一部分,而使这一部分更适于以 WASM 作为目标,但是这件事情看起来优先级不高。

虽然 JS 很可能与 WASM 没什么关联,但 JS 代码和 WASM 代码将能够以最重要的方式进行交互,就像当下的模块互动一样自然。你可以想象,调用一个foo()之类的 JS 函数而使它实际上调用一个同名 WASM 函数,它具备远离你其余 JS 的制约而运行的能力。

至少是在可预见的未来,当下以 JS 编写的东西可能将继续总是由 JS 编写。转译为 JS 的东西将可能最终至少考虑以 WASM 为目标。对于那些需要极致性能,而且在抽象的层面上没有余地的东西,最有可能的选择是找一种合适的非 JS 语言编写,然后以 WASM 为目标语言。

这个转变很有可能将会很慢,会花上许多年成形。WASM 在所有的主流浏览器上固定下来可能最快也要花几年。同时,WASM 项目(github.com/WebAssembly)有一个早期填补,来为它的基本原则展示概念证明。%E6%9C%89%E4%B8%80%E4%B8%AA%E6%97%A9%E6%9C%9F%E5%A1%AB%E8%A1%A5%EF%BC%8C%E6%9D%A5%E4%B8%BA%E5%AE%83%E7%9A%84%E5%9F%BA%E6%9C%AC%E5%8E%9F%E5%88%99%E5%B1%95%E7%A4%BA%E6%A6%82%E5%BF%B5%E8%AF%81%E6%98%8E%E3%80%82)

但随着时间的推移,也随着 WASM 学到新的非 JS 技巧,不难想象一些当前是 JS 的东西被重构为以 WASM 作为目标的语言。例如,框架中性能敏感的部分,游戏引擎,和其他被深度使用的工具都很可能从这样的转变中获益。在 web 应用程序中使用这些工具的开发者们并不会在使用或整合上注意到太多不同,但确实会自动地利用这些性能和能力。

可以确定的是,随着 WASM 变得越来越真实,它对 JavaScript 设计路线的影响就越来越多。这可能是开发者们应当关注的最重要的“ES6 以后”的话题。

复习

如果这个系列的其他书目实质上提出了这个挑战,“你(可能)不懂 JS(不想自己想象的那么懂)”,那么这本书就是在说,“你不再懂 JS 了”。这本书讲解了在 ES6 中加入到语言里的一大堆新东西。它是一个新语言特性的精彩集合,也是将永远改进我们 JS 程序的范例。

但 JS 不是到 ES6 就完了!还早得很呢。已经有好几个“ES6 之后”的特性处于开发的各个阶段。在这一章中,我们简要地看了一些最有可能很快会被固定在 JS 中的候选特性。

async function是建立在 generators + promises 模式(见第四章)上的强大语法糖。Object.observe(..)为监听对象变化事件增加了直接原生的支持,它对实现数据绑定至关重要。**指数作符,针对对象属性的...,以及Array#includes(..)都是对现存机制的简单而有用的改进。最后,SIMD 将高性能 JS 的演化带入一个新纪元。

听起来很俗套,但 JS 的未来是非常光明的!这个系列,以及这本书的挑战,现在是各位读者的职责了。你还在等什么?是时候开始学习和探索了!

posted @ 2025-11-22 09:02  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报