分享

JavaScript权威指南 第11章 样章

 quasiceo 2015-03-20

本文为《JavaScript权威指南(第6版)》(JavaScript: The Definitive Guide, 6th Edition)的原始译稿,特公布此章,邀请大家参与本书初译的审校活动,具体活动页面,请访问http://ued./javascript/

第11章 JavaScript 的子集和扩展

到目前为止,本书参照ECMAScript 3和ECMAScript 5中的标准规范完整的讨论了JavaScript这门语言。从现在起,本章将开始讨论JavaScript的子集和超集。其中子集的内容大部分都是出于安全考虑,只有使用这门语言的一个安全的子集编写脚本,才能让代码执行的更安全、更稳定,比如如何更安全的执行一段由不可信第三方提供的广告代码。§11.1会对这个子集作简要介绍。

ECMAScript 3标准是1999年颁布的,十年后,也就是2009年才更新到了ECMAScript 5。JavaScript的作者Brendan Eich在这十年间不断地改进这门语言(ECMAScript 标准规范是允许对其作任何扩充的),同时,伴随着Mozilla项目的推进,在Firefox 1.0、1.5、2、3和3.5版本中分别发布了JavaScript 1.5、1.6、1.7、1.8和1.8.1版本。这些JavaScript的扩展版本中的很多新特性已经融入到了ECMAScript 5中,还有很多特性依然是非标准的,但这些特性将有很大一部分会融入到ECMAScript的将来版本中。

由于Firefox是基于一个名叫Spidermonkey的JavaScript引擎(译注:Firefox的JavaScript引擎有很多种,多是我们熟悉的“猴”系列,文中所提到的Spidermonkey(用于Firefox 1.0~3.0)便是其中一种,此外还包括TraceMonkey(用于Firefox 3.5~3.6)、JaegerMonkey(用于Firefox4.0)以及最新开发的IonMonkey。有关Spidermonkey的更多信息可参照:http://en./wiki/SpiderMonkey ),因此Firefox浏览器也可以支持这些扩展特性。由Mozilla开发的另一个基于Java的JavaScript引擎Rhino(§12.1)也支持大部分扩展特性。但由于这些语言特性是非标准的,本章的内容对于那些需要调试浏览器兼容性的开发者来说可能帮助不大。我们在本章对它们作必要的讲述是基于几点考虑:

  • 它们的确很强大
  • 它们有可能在未来成为标准
  • 它们可用来写Firefox扩展插件
  • 它们可用在服务器端的JavaScript编程,只要运行环境是基于Spidermonkey或者Rhino的JavaScript引擎即可(译注:理论上,只要是Mozilla血统的JavaScript引擎的开发环境都可以支持文中提到的扩展特性。)。

在简单介绍JavaScript语言的子集之后,本章后面会开始介绍语言的扩展部分。由于这些扩展毕竟不是标准,因此这里只是一个指南形式的描述,并不像本书其他章节那样系统完整地介绍语言特性。

11.1 JavaScript的子集

大多数语言都会定义它们的子集,用以更安全地执行不可信的第三方代码。这里有一个很有趣的子集,定义这个子集的原因有些特殊。我们首先来看这个有趣的子集,然后再讨论安全的语言子集。

11.1.1 精华

Douglas Crockford曾经写过一本很薄的书《JavaScript语言精粹》(译注:这本书已由电子工业出版社出版),专门介绍JavaScript中值得发扬光大的精华部分。这个语言子集的目标是简化这门语言,规避掉语言中的怪癖、缺陷部分,使编程更轻松、程序更健壮。Douglas Crockford是这样介绍他的动机的:

大多数编程语言都有精华部分和鸡肋部分,我发现如果只使用精华部分而避免使用鸡肋的部分,我可以成为一名更好的程序员。

Crockford提炼出的子集部分不包含with和continue语句以及eval()函数。他提倡使用函数定义表达式而不是函数定义语句来定义函数。循环体和条件分支都使用花括号括起来,不允许在循环体和条件分支中只包含一条语句时省略花括号,任何语句只要不是以花括号结束都应当使用分号作结尾。

这个子集中并未包含逗号运算符、位运算符以及++和--。也不包含==和!=,因为用这两个运算符进行比较时会涉及到类型转换,这里更推荐使用===和!==。

由于JavaScript并不包含块级作用域,Crockford为我们提炼出的子集部分对var语句做了限制,var语句只能出现在函数体的顶部,并要求程序员将函数内所有的变量声明写在一个单独的var语句中。子集中禁止使用全局变量,但这个限制只是编程约定,并不是真正的语言上的限制。

Crockford写过一个在线代码质量检测工具JSLint,可以通过http:// 访问到这个它。这个工具中提供了很多选项用来增强代码的一致性检查。除了能确保代码使用了子集推荐的特性之外,JSLint工具还对编码风格做了一些强制约定,比如合理的缩进等。

Crockford的那本《JavaScript语言精粹》出版时,ECMAScript5的严格模式还没有出来,然而Crockford所提取出的JavaScript“鸡肋部分”中有很大一部分在严格模式中同样做了限制。随着ECMAScript 5标准的广泛采用,JSLint工具要求在选中“The Good Parts”选项时程序中必须包含“use strict”指令。

子集的安全性

利用“精华部分”的子集特性可以设计出更具美感的程序以及提升程序员的开发效率。这里将要讨论的是一个更大的子集,这个大子集的设计目的是为了能在一个容器或“沙箱”中更安全地运行不可信的第三方JavaScript代码。所有能破坏这个沙箱并影响全局执行环境的语言特性和API在这个安全子集中都是禁止的。每个子集都带有一个静态的检查器,可以对代码进行解析检查以确保代码是符合子集规范的。由于这个检查器的检验规则比较严格,因此有一些沙箱系统定义了范围更广、校验更松散的子集,并增加了一个代码转换的步骤,用以将针对更大子集的代码检验转换为针对更小子集的代码检验,同时在对代码的静态分析不能确保代码安全性的情况下增加了运行时的检查。

为了让JavaScript代码静态地通过安全检查,必须移除一些JavaScript特性:

  • eval()和Function()构造函数在任何安全子集里都是禁止使用的,因为它们可以执行任意代码,而且JavaScript无法对这些代码作静态分析。
  • 禁止使用this关键字,因为函数(在非严格模式中)可以通过this访问全局对象。而沙箱的一个重要目的就是阻止对全局对象的访问。
  • 禁止使用with语句,因为with语句增加了静态代码检查的难度。
  • 禁止使用某些全局变量。在客户端JavaScript中,浏览器窗口对象可以当作全局对象,但也具有双重身份(译注:这里所说的双重身份是指浏览器象除了作为普通的全局对象之外,还能通过它们去操作浏览器和DOM。),因此代码中不能有对window对象的引用。同样的,客户端document对象定义了可以用来操控整个页面内容的方法。将对document的控制权交给一段不受信任的代码会有很多隐患。安全子集中提供了两种不同的方法来处理类似document这类全局对象。第一种方法是,沙箱完全禁掉它们,并定义一组新的API用以对Web页面做有限制的访问。第二种方法,在沙箱代码所运行的“容器”内定义一个只对外提供安全的标准DOM API的“外观面板”(facade)或“document代理对象”(proxy)(译注2:“外观面板”和“代理对象”是设计模式中的两个术语,分别对应到“外观模式”和“代理模式”。更多内容请参照:http://zh./wiki/ 外观模式 和 http://zh./wiki/代理模式 )。
  • 禁止使用某些属性和方法,以免在沙箱中的代码拥有过多的权限。这些属性和方法包括arguments对象的两个属性caller和callee(甚至在某些子集中干脆禁止使用arguments对象)、函数的call()和apply()方法、以及constructor和prototype两个属性。非标准的属性也被禁止掉了,比如__proto__。一些子集将这些不安全的属性和全局对象列进黑名单,还有一些子集提供了白名单,给出了推荐使用的安全的属性和方法。
  • 静态分析可以有效地防止带有点(.)运算符的属性存取表达式去读写特殊属性。但使用方括号[]来访问属性则与此不同,因为我们无法对方括号内的字符串表达式做静态分析。基于这个原因,安全子集通常禁止使用方括号,除非方括号内是一个数字或字符串直接量。安全子集将[]替换为全局函数,通过调用全局函数来读写对象属性,这些函数会执行运行时检查以确保它们不会读写那些被禁止访问的属性。

有一些限制,比如禁止使用eval()和with语句,并不会对开发者带来额外负担,毕竟这些特性本来就很少在JavaScript编程中用到。另外一些限制规则,比如使用方括号对属性进行存取的限制则对开发造成诸多不便,这时就有代码转换器的用武之地了。比如,转换器会自动将使用方括号的代码转换为函数调用的代码,以便能够对它执行运行时检查。有了这种代码转换,我们是可以安全的使用this关键字的。当然,沙箱代码运行时的安全性检查和执行速度之间是一对矛盾,这里的代码转换只是一种权衡后的折衷方案。

有一些安全子集已经有实现了,对这个子集更详尽地介绍超出了本书的范围,这里我们只是简要地介绍一些比较重要的实现:

ADsafe

ADsafe(http:// )是第一个被正式提出的安全子集。它的提出者是Douglas Crockford(他也是《JavaScript语言精粹》的作者)。ADsate只包含静态检查,它使用JSLint(http://)作为检验工具。这个工具禁止访问大部分的全局变量,并定义了一个ADSAFE变量,它提供了一组可以安全使用的API,包括一些特殊的DOM方法。ADsafe并未广泛使用,但它作为一个颇具影响力的概念原型对其他安全子集有着深远的影响。

dojox.secure

受ADsafe的启发,Dojo toolkit(http:// )发布了一个名为dojox.secure的子集扩展(注:详情请参照http://www./blog/2008/08/01/secure-mashups-with-dojoxsecure )。和ADsafe一样,dojox.secure也是基于静态检查,静态检查受限于语言子集范围内。但它和ADsafe又不尽相同,它允许使用标准DOM API。同时,它包含一个用JavaScript实现的检查器。因此我们可以用它对不可信的第三方代码执行运行时前的动态检查。

Caja

Caja(注:Caja是西班牙语,意思是“沙盒”,Caja的详情请参照:http://code.google.com/p/google-caja/ )是Google发布的开源安全子集。Caja定义了两个语言子集。Cajita(“小沙盒”)是一个与ADsafe和dojox.secure类似的严格子集。Valija(“手提箱”或“行李箱”)则是一个范围更广的语言子集,更接近于ECMAScript 5的严格模式(不包含eval())。Caja本身也是一个编译器的名字,这个编译器可以将一段网页内容(HTML、CSS和JavaScript代码)转换为一个安全的模块,这个模块可以放心的引用至页面中而不会对其他模块产生影响。

Caja是开OpenSocial API的一部分(注:关于OpenSocial API的更多内容请参照:http://code.google.com/apis/opensocial/ ),而且已经被Yahoo率先采用。比如,在门户页面http://my.yahoo.com中就可以看到,所有的模块都遵照了Caja规范。

FBJS

FBJS是JavaScript语言的变种,这种语言被Facebook(http://)采用,用以在用户个人资料页嵌入不可信的第三方代码。FBJS依赖代码转换器来保证代码的安全性,转换器同样提供运行时检查,以避免通过this关键字去访问全局对象,并且对所有的顶层标识符进行重命名,给它们增加了一个标识模块的前缀,正是因为这种重命名,任何对全局变量以及其他模块的成员变量的存取操作都无法正常进行了。此外,任何对eval()的调用也会因为eval函数名被重新命名而无法执行。FBJS模拟实现了一个DOM API的安全子集。

Microsoft Web Sandbox

微软的Web Sandbox(http://websandbox./)定义了JavaScript的一个更宽泛的子集,包含HTML和CSS,它的代码重写规则非常激进,有效地实现了一个安全的JavaScript虚拟机,针对不安全的JavaScript顶层代码作处理。

11.2 常量和局部变量

对语言子集的讨论暂告一段落,下面开始讨论语言的扩展。在JavaScript 1.5及后续版本中可以使用const关键字来定义常量。常量可以看成不可重复赋值的变量(对常量重新赋值会失败但不报错),对常量的重复声明会报错。

const pi = 3.14;// 定义一个常量并赋值
pi = 4;//任何对这个常量的重新赋值都被忽略
const pi = 4;//重新声明常量会报错
var pi = 4;//这里也会报错

关键字const和关键字var的行为非常类似,由于JavaScript中没有块级作用域,因此常量会被提前至函数定义的顶部(参照§3.10.1)。

一直以来,JavaScript中的变量缺少块级作用域的支持被普遍认为是JavaScript的短板,JavaScript 1.7针对这个缺陷增加了关键字let。关键字const一直都是JavaScript的保留字(没有使用),因此现有的代码不必作任何改动就可以增加常量,关键字let并不是保留字,JavaScript 1.7及以后的版本才能识别,需要手动加入版本号才可以。

JavaScript版本号

本章所有提到JavaScript版本号的地方,都是指的Mozilla的语言版本,在Spidermonkey和Rhino解析器和Firefox浏览器中实现了这些语言版本。

有一些语言的扩展定义了新的关键字(比如let),为了让现有代码不破坏原有结构就能使用这些关键字,则需要手动指名新的语言版本以便使用新版本的语言扩展。如果你正在使用Spidermonkey或Rhino作为单独的解析器,可以通过命令行选项指定语言版本,或者通过调用一个内置函数version()来指定版本(手动指定的版本号是实际版本号乘以100(译注:原文是“It expects the version number times ten”,作者表述有误,应当是版本号乘以100而不是乘以10。在命令行环境中,直接执行version()可以返回当前采用的JavaScript版本号,返回值通常为150、160、170等)的数值,要想激活JavaScript1.7版本则需要传入170)。在Firefox中,则可以在script标签中指定语言的扩展版本,就像这样:<script type="application/javascript; version=1.8">

关键字let有四种使用方式:

  • 可以作为变量声明,和var一样;
  • 在for或for/in循环中,作为var的替代方案;
  • 在语句块中定义一个变量并显式指定它的作用域;
  • 定义一个在表达式内部作用域中的变量,这个变量只在表达式内可见。

示例代码:

function oddsums(n) {
    let total = 0, result=[];     // 在函数内都是有定义的
    for(let x = 1; x <= n; x++) {    // x只在循环体内有定义
     let odd = 2*x-1;    // odd只在循环体内有定以
     total += odd;
     result.push(total);
    }
    // 这里使用x或odd会报一个引用错误
    return result;
}
oddsums(5);     //  返回 [1,4,9,16,25]

我们注意到,这段代码中let还替代了for循环中的var。这时通过let创建的变量的作用域仅限于循环体、循环条件判断逻辑和自增操作表达式。同样,可以这样在for/in(以及for each,参照§11.4.1)循环中使用let:

o = {x:1,y:2};
for(let p in o) console.log(p); //输出x和y
for each(let v in o) console.log(v); // 输出 1 and 2
console.log(p)  // 引用错误:p没有定义

在声明语句中使用let和在循环初始化器中使用let,两者有着有趣的区别。对于前者来说,变量初始化表达式是在变量的作用域内计算的。但对于后者来说,变量的初始化表达式则是在变量的作用域之外计算的。当出现两个变量同名的情况时需要尤为注意:

let x = 1;
for(let x = x + 1; x < 5; x++)
    console.log(x);     // 输出 2,3,4
{                       // 开始一个新的语句块,创建了新的变量作用域
    let x = x + 1;      // x没有定义, 因此 x+1 是 NaN
    console.log(x);     // 输出 NaN
}

通过var声明的变量在它们所在的函数内始终是存在的,但代码执行到var语句时变量才被初始化。也就是说,变量是存在的(不会报引用错误),但在var语句执行之前它的值是undefined。通过let声明变量的情况与之类似,如果在let语句之前使用这个变量(与let语句在同一个块作用域内),变量是存在的,但值是undefined。

需要注意的是,在用let声明循环变量时这个问题是不存在的,语法上是不允许在初始化之前就使用这个变量的。还有一种方法可以在let声明语句之前使用变量时避免出错,就是在一个单独的let语句中(和上文所示的let声明语句不同)既中包含了变量声明也包含了对这些变量的初始化。语句里的变量和初始化操作都放在一个圆括号内,随后跟随一个花括号括起来的语句块:

let x=1, y=2;
let (x=x+1,y=x+2) { // 注意这里的写法
    console.log(x+y); // 输出 5
};
console.log(x+y); //输出3

let语句中的变量初始化表达式并不是这个语句块的一部分,并且是在作用域外部被解析的,理解这一点至关重要。在这段代码中,我们新建了一个新的变量x并赋值给它一个更大的值。

let关键字的最后一种用法是let语句写法的一个变体,其中有一个括号括起来的变量列表和初始化,紧跟着是一个表达式而不是语句块。我们把这种写法叫做let表达式,上面的代码可以写成这样:

let x=1, y=2;
console.log(let (x=x+1,y=x+2) x+y); // 输出 5

某些const和let的用法在将来很有可能被采纳进ECMAScript标准规范中。

11.3 解构赋值

pidermonkey1.7实现了一种混合式赋值,我们称之为“解构赋值”(destructuring assignment)。你可能在Python或Ruby中接触过这个概念。在解构赋值中,等号右侧是一个数组或对象(结构化的值),左侧的变量写法和右侧的数组和对象直接量写法保持格式一致。

当发生解构赋值时,右侧的数组和对象中的值就会被提取出来(解构),并赋值给左侧相应的变量名。除了用于常规的赋值操作之外,解构赋值还用于初始化用var和let声明的新的变量。

当和数组配合使用时解构赋值是一种写法简单但又及其强大的功能,特别是在函数返回一组结果的时候解构赋值就显得非常有用。然而当配合对象或者嵌套对象一起使用时,解构赋值变得更加复杂且容易搞混。下面的例子展示了简单的和复杂的解构赋值:

这里的例子是简单的解构赋值,它用到了数组:

let [x,y] = [1,2];// 等价于 let x=1,y=2
[x,y] = [x+1,y+1];//等价于 x = x+1,y=y+1
[x,y] = [y,x];  // 交换两个变量的值
console.log([x,y]);//输出 [3,2]

注意,当函数返回一组结果时,使用解构赋值将大大简化程序代码:

// 将 [x,y] 从笛卡尔(直角)坐标转换为 [r,theta] 极坐标
function polar(x,y) {
    return [Math.sqrt(x*x+y*y), Math.atan2(y,x)];
}

// 将极坐标转换为笛卡尔坐标
function cartesian(r,theta) {
    return [r*Math.cos(theta), r*Math.sin(theta)];
}

let [r,theta] = polar(1.0, 1.0);    // r=Math.sqrt(2), theta=Math.PI/4
let [x,y] = cartesian(r,theta);     // x=1.0, y=1.0

解构赋值右侧的数组所包含的元素不必和左侧的变量一一对应,左侧多余的变量会赋值undefined,而右侧多余的值则会被忽略。左侧的变量列表可以包含连续的逗号用以跳过右侧对应的值。

let [x,y] = [1]; // x = 1, y = undefined
[x,y] = [1,2,3]; // x = 1, y = 2
[,x,,y] = [1,2,3,4]; // x = 2, y = 4

JavaScript并未提供将右侧的多余的值以数组的形式赋值给左侧变量的语法。比如,在这段代码的第二行,并不能将[2,3]赋值给y。

整个解构赋值运算的返回值是右侧的整个数据结构,而不是被提取出来的某个值。因此,可以这样写“链式”解构赋值:

let first, second, all;
all = [first,second] = [1,2,3,4]; // first=1, second=2, all=[1,2,3,4]

解构赋值同样可以用于数组嵌套的情况,解构赋值的左侧应当也是同样格式的嵌套数组直接量:

let [one, [twoA, twoB]] = [1, [2,2.5], 3]; // one=1, twoA=2, twoB=2.5

解构赋值的右侧也可以是一个对象。这种情况下,解构赋值的左侧部分也应当看起来是一个对象直接量,对象中是一个名值对的列表,名值对之间用逗号分隔,列表用花括号括起来。名值对内冒号左侧是属性名称,冒号右侧是变量名称,每一个属性都会去右侧对象中查找对应的赋值,每个值(或者是undefined)都会赋值给它所对应的变量。这种解构赋值很容易被搞混,因为属性名称和变量标识符通常写成一样的。在下面这个例子中,r、g和b是属性名,red、green和blue是变量名,请不要搞混:

let transparent = {r:0.0, g:0.0, b:0.0, a:1.0}; // 一个用 RGBA 值表示的颜色
let {r:red, g:green, b:blue} = transparent; // red=0.0,green=0.0,blue=0.0

接下来的例子中,我们将Math对象的全局函数拷贝至新的变量中,用以简化三角函数相关的代码:

// 等价于 let sin=Math.sin, cos=Math.cos, tan=Math.tan
let {sin:sin, cos:cos, tan:tan} = Math;

就像嵌套数组可以用于解构赋值一样,嵌套对象也可以用于解构赋值,实际上,两种语法可以合在一起使用,可以用来描述任意的数据结构,例如:

// 一个嵌套的数据结构:一个对象中包含了数组,数组中又包含了对象
let data = {
    name: "destructuring assignment",
    type: "extension",
    impl: [{engine: "spidermonkey", version: 1.7},
     {engine: "rhino", version: 1.7}]
};

// 使用解构赋值从数据结构中提取了四个值
let ({name:feature, impl: [{engine:impl1, version:v1},{engine:impl2}]} = data) {
    console.log(feature); // 输出"destructuring assignment"
    console.log(impl1);     // 输出 "spidermonkey"
    console.log(v1);     // 输出 1.7
    console.log(impl2);    // 输出 "rhino"
}

需要注意的是,类似这种嵌套的解构赋值可能会让你的代码变得晦涩难读。然而,有一种有趣的规律可以帮助你更好地阅读这些复杂的解构赋值。思考一下最普通的赋值(给一个变量赋值)。赋值结束后,你可以将这个变量用在程序中的表达式里,这个变量的值就是你刚赋的值。在解构赋值中,左侧的部分使用了类似数组直接量或对象直接量的语法。但需要注意,在解构赋值完成后,左侧部分看起来像数组直接量或对象直接量的代码是可以作为合法的数组和对象用在代码中其他位置的,所有变量都已经有定义,因此可以直接将等号左侧的部分作为一个可用的数组或对象复制粘贴到程序的其他地方。

11.4 迭代

Mozilla的JavaScript扩展引入了一些新的迭代机制,包括for each循环和Python风格的迭代器(iterator)和生成器(generator)。下面的小节中会有一一介绍。

11.4.1 for/each 循环

for/each循环是由E4X规范(ECMAScript for XML)定义的一种新的循环语句。E4X是语言的扩展,它允许JavaScript程序中直接出现XML标签,并定义了操作XML数据的语法和API。浏览器大都没有实现E4X,但是Mozilla JavaScript 1.6(随着Firefox 1.5发布)是支持E4X的。本节我们只对for/each作讲解,并不会涉及到XML对象。关于E4X的剩余内容请参照§11.7。

for each循环和for/in循环非常类似。但for each并不是遍历对象的属性,而是对属性的值作遍历:

let o = {one: 1, two: 2, three: 3}
for(let p in o) console.log(p); // for/in: 输出 'one', 'two', 'three'
for each (let v in o) console.log(v);

注意,for/each循环并不仅仅针对数组本身的元素作遍历,它也会遍历数组中所有可枚举属性,包括继承来的可枚举的方法。因此,通常并不推荐for/each循环和数组一起使用。在ECMAScript 5 之前的 JavaScript 版本中是可以这样用的,因为自定义属性和方法不可能设置为可枚举的(对for/in循环的讨论在 §7.6中)。

11.4.2 迭代器

JavaScript 1.7为for/in循环增加了更多通用的功能。JavaScript 1.7中的循环和Python的for/in循环非常类似,它可以对任何可迭代的(iterable)对象作遍历。为了便于理解,我们首先给出一些定义。

迭代器是一个对象,这个对象允许对它的值的集合作遍历,并保持任何必要的状态以便能够跟踪到当前遍历的“位置”。

迭代器必须包含next()方法,每一次对next()调用都返回集合中的下一个值。比如下面的counter()函数返回了一个迭代器,这个迭代器每次调用next()都会返回连续递增的整数。需要注意的是,这个函数利用闭包的特性实现了计数器状态的保存:

function counter(start) {
    let nextValue = Math.round(start); // 表示迭代器状态的一个私有成员
    return { next: function() { return nextValue++; }}; // 返回迭代器对象
}
let serialNumberGenerator = counter(1000);
let sn1 = serialNumberGenerator.next();     // 1000
let sn2 = serialNumberGenerator.next();     // 1001

迭代器用于有限的集合时,当所有的值都遍历完成没有多余的值可迭代时,再调用next()方法会抛出StopIteration。StopIteration是JavaScript 1.7中的全局对象的属性。它是一个普通的对象(它自身没有属性),只是为了终结迭代的目的而保留的一个对象。注意,实际上,StopIteration并不是像TypeError()和RangeError()这样的构造函数。比如,这里实现了一个rangeIter()方法,这个方法返回了一个可以对某个范围的整数进行迭代的迭代器:

// 这个函数返回了一个迭代器,它可以对某个区间内的整数作迭代
function rangeIter(first, last) {
    let nextValue = Math.ceil(first);
    return {
     next: function() {
         if (nextValue > last) throw StopIteration;
         return nextValue++;
     }
    };
}

// 使用这个迭代器实现了一个糟糕的迭代.
let r = rangeIter(1,5); // 获得迭代器对象
while(true) {    // 在循环中使用它
    try {
     console.log(r.next());    // 调用  next() 方法
    }
    catch(e) {
     if (e == StopIteration) break; // 抛出 StopIteration 时退出循环
     else throw e;
    }
}

注意,这里的循环中使用了迭代器对象,并且手动处理了Stopiteration,这种方式非常糟糕。因此,我们并不经常直接使用迭代器对象,而是使用可迭代的对象。可迭代对象表示了一组可迭代处理的值的集合。可迭代对象必须定义一个名叫__iterator__()的方法(开始和结尾有两个下划线),用以返回这个集合的迭代器对象。

JavaScript1.7对for/in循环的功能作了扩展,可以用它来遍历可迭代对象,如果关键字in右侧的值是可迭代的,那么for/in循环会自动调用它的__iterator__()方法来获得一个迭代器对象。然后调用迭代器的next()方法,将返回值赋值给循环变量,随即执行循环体的逻辑。for/in循环自己会处理StopIteration异常,而且处理过程对开发者是不可见的。下面的代码定义了range()函数,这个函数返回一个可迭代对象(不是迭代器)用以表示某个区间内的整数。我们看到,使用迭代区间的for/in循环要比使用迭代器的while循环更加简单。

// 返回一个可迭代对象,用以表示一个区间内的数字
function range(min, max) {
    return {     // 返回一个表示这个范围的对象
     get min() { return min;     },     //区间边界是固定的
     get max() { return max; },     //并在闭包内保存起来
     includes: function (x) {     //  检测是否属于这个区间
         return min <= x && x <= max;
     },
     toString: function () {     // 以字符串形式输出这个区间
         return "[" + min + "," + max + "]";
     },
     __iterator__: function () {     // 区间内的整数都是可迭代的
         let val = Math.ceil(min); //将当前位置保存在闭包中
         return {       // 返回一个迭代器对象
             next: function () {     //返回区间内的下一个值
                 if (val > max)     //如果到达结尾
                     throw StopIteration;
                 return val++;     // 否则返回下一个值,并自增1
             }
         };
     }
    };
}
// 这里我们对这个区间中的值作迭代:
for (let i in range(1, 10)) console.log(i); // 输出1~10之间的数字

需要注意的是,我们在创建一个可迭代对象和它们的迭代器的时候,尽管必须写一个__iterator__()方法并抛出StopIteration异常,但并不需要我们去手动调用__iterator__()方法或手动处理StopIteration异常,for/in循环会为我们处理这些逻辑。如果出于某种考虑,你想从可迭代对象中获得一个迭代器对象,只需调用Iterator()函数即可(这个函数是定义在JavaScript1.7中的全局函数)。如果这个函数的参数是一个可迭代的对象,那么将返回这个对象的__iterator__()方法的调用结果,从而保持你的代码整洁干净。如果给Iterator()函数传入第二个参数,这个参数也会带入到对__iterator__()方法的调用中。

然而,引入Iterator()函数还有一个重要的目的,如果传入的对象或者数组没有定义__iterator__()方法,它会返回这个对象的一个自定义的迭代器。每次调用这个迭代器的next()方法都会返回一个包含两个值的数组,第一个数组元素是属性名,第二个是属性的值。由于这个对象是可迭代的迭代器,因此可以直接用于for/in循环,而不用去调用它的next()方法。这意味着你可以将Iteratro()函数和解构赋值一起使用,这样可以方便的对对象或数组的属性和值作遍历:

for(let [k,v] in Iterator({a:1,b:2})) // 对属性和值作迭代
    console.log(k + "=" + v); // 输出“a=1”和“b=2”

Iterator()函数返回的迭代器还有两个重要的特性。第一,它只对自有属性作遍历而忽略继承属性,通常我们希望是这个样子。第二,如果给Iterator()传入第二个参数true,返回的迭代器只对属性名作遍历,而忽略属性值。下面这段代码展示了这两种特性:

o = {x:1, y:2}  // 定义一个对象,它有两个属性
Object.prototype.z = 3; //所有的对象都继承了z
for(p in o) console.log(p);//输出”x”,“y”和“z”
for(p in Iterator(o, true)) console.log(p);//只输出 “x” 和 “y”

11.4.3 生成器

生成器是JavaScript 1.7中的特性(是从Python中借用过来的概念),这里用到了一个新的关键字yield,使用这个关键字时代码必须手动指定JavaScript的版本,就像在§11.2中提到的。关键字yield在函数内使用,用法和return类似。yield和return的区别在于,使用yield的函数“产生”一个可保持函数内部状态的值,这个值是可以恢复的。这种可恢复性使得yield成为编写迭代器的有力工具。生成器是一种强大的语言特性,但初次理解起来可能有些困难,下面我们给出一些定义。

任何使用关键字yield的函数(哪怕yield在代码逻辑中是不可达的)都称为“生成器函数”(generator function)。生成器函数通过yield返回值。这些函数中可以使用return来终止函数的执行而不带回任何返回值,但不能使用return来返回一个值。除了使用yield,对return的使用限制也使生成器函数更明显的区别于普通函数。然而和普通的函数一样,生成器函数也是通过关键字function声明,typeof运算返回“function”,并可以从Function.prototype继承属性和方法。但对生成器函数的调用却和普通函数完全不一样,不是执行生成器函数的函数体,而是返回一个生成器对象。

生成器是一个对象,用以表示生成器函数的当前执行状态。它定义了next()方法,可以恢复生成器函数的执行,直到遇到下一条yield语句为止。这时,生成器函数中的yield语句的返回值就是生成器的next()方法的返回值。如果生成器函数通过执行return语句终止,那么生成器的next()方法将抛出一个StopIteration。

只要一个对象包含可抛出Stopiteration的next()方法,它就是一个迭代器对象(注:生成器有时也被叫做“生成器迭代器”(generator iterators),用以区分创建它的生成器函数。在本章里,我们统一使用术语“生成器”来表示“生成器迭代器”,在其他参考文献中,生成器可能会同时指代生成器函数和生成器迭代器)。实际上,它们是可迭代的迭代器,也就是说,它们可以通过for/in循环进行遍历。下面的代码展示了如何简单的使用生成器函数以及对它所yield的返回值作遍历:

// 针对一个整数区间定义一个生成器函数
function range(min, max) {
    for(let i = Math.ceil(min); i <= max; i++) yield i;
}

// 调用这个生成器函数以获得一个生成器,并对它进行遍历
for(let n in range(3,8)) console.log(n); // 输出数字 3 ~ 8.

生成器函数不需要返回。实际上最典型的例子就是用生成器来生成Fibonacci数列:

// 一个用以产生一个Fibonacci数列的生成器函数
function fibonacci() {
    let x = 0, y = 1;
    while(true) {
     yield y;
     [x,y] = [y,x+y];
    }
}

// 调用生成器函数以获得一个生成器
f = fibonacci();
// 将生成器当作迭代器,输出Fibonacci数列的前10个数
for(let i = 0; i < 10; i++) console.log(f.next());

我们注意到,fibonacci()生成器函数没有返回。因此,它所产生的生成器不会抛出StopIteration。不能把这个生成器当作可迭代对象用for/in循环进行遍历,这个循环是一个无穷循环,而是把它当作一个迭代器并通过调十次用它的next()方法来实现。这段代码运行后,生成器f依然保持着生成器函数的执行状态。如果不再使用f,则可以通过调用f.close()来释放它:

f.close();

当调用了生成器对象的close()方法,和它相关的生成器函数就会终止执行,就像在函数运行挂起的位置执行了return语句。如果当前挂起位置在try语句块中,那么将首先运行finally从句的逻辑,再执行close()返回。close()没有返回值,但如果finally语句块产生了异常,这个异常则会传播给close()。

生成器经常用来处理序列化的数据,比如元素列表、多行文本、词法分析器中的单词等等。生成器可以像Unix的shell命令中的管道那样链式使用。有趣的是,这种用法中的生成器是“懒惰的‘,只有在需要的时候才会从生成器(或者生成器的管道)中“取”值,而不是一次将许多结果都计算出来。参照例11-1。

例11-1. 一个生成器管道

// 一个生成器,每次产生一行字符串s
//这里没有使用s.split(),因为这样会每次都处理整个字串,并分配成一个数组
//我们希望能更“懒”一些
function eachline(s) {
    let p;
    while((p = s.indexOf('\n')) != -1) {
     yield s.substring(0,p);
     s = s.substring(p+1);
    }
    if (s.length > 0) yield s;
}
// 一个生成器函数,对于每个可迭代的i的每个元素x,都会产生一个f(x)
function map(i, f) {
    for(let x in i) yield f(x);
}
//一个生成器函数,针对每个结果为true的f(x),为i生成一个元素
function select(i, f) {
    for(let x in i) {
     if (f(x)) yield x;
    }
}
// 准备处理这个字符串
let text = " #comment \n \n hello \nworld\n quit \n unreached \n";

// 现在创建一个生成器管道来处理它
// 首先,将文本分隔成行
let lines = eachline(text);
//然后,去掉每行行首和行尾的空格
let trimmed = map(lines, function(line) { return line.trim(); });
//最后,忽略空行和注释
let nonblank = select(trimmed, function(line) {
    return line.length > 0 && line[0] != "#"
});

//现在从管道中取出格式化号的行对其作处理
//直到遇到“quit”的行
for (let line in nonblank) {
    if (line === "quit") break;
    console.log(line);
}

生成器往往是在创建的时候初始化,传入生成器函数的值是生成器所接收的唯一输入。然而,也可以为正在执行的生成器传入更多输入。每一个生成器都有send()方法,用来重启生成器的执行,就像next()方法一样。和next()不同的是,send()可以带一个参数,这个参数的值就成为了yield表达式的值(多数生成器函数是不会接收额外的输入的,关键字yield看起来像一条语句。但实际上,yield是一个表达式,是可以有值的)。除了next()和send()之外,还有一种方法可以重启生成器的执行,即使用throw()。如果调用这个方法,yield表达式就将参数作为一个异常抛给throw(),比如下面一段代码:

// 一个生成器函数,用以从某个初始值开始计数
// 调用生成器的send()来进行增量计算
// 调用生成器的throw(‘reset’)来重置初始值
// 这里的代码只是示例,throw()的这种用法并不推荐
function counter(initial) {
    let nextValue = initial; // 定义初始值
    while(true) {
     try {
         let increment = yield nextValue; // 产生一个值并得到增量
         if (increment)      // 如果我们传入一个增量..
             nextValue += increment;     // ...使用它.
         else nextValue++;    // 否则自增 1
     }
     catch (e) {     // 如果调用了生成器的throw(),则执行这里的逻辑
         if (e==="reset")   
             nextValue = initial;
         else throw e;
     }
    }
}

let c = counter(10);            // 用10来创建生成器
console.log(c.next());          // 输出10
console.log(c.send(2));     // 输出12
console.log(c.throw("reset"));      // 输出10

11.4.4 数组推导

JavaScript 1.7中的数组推导(array comprehension)也是从Python中借用过来的一个概念。它是一种利用另外一个数组或可迭代对象来初始化数组元素的技术。数组推导的语法是基于定义元素集合的数学模型的,也就是说,表达式和从句的写法和开发者期望的不一致。但不必担心,因为花不了太多时间就可以掌握这种新式语法,一旦掌握它则威力无穷。

下面这段代码展示了数组推导的写法,这里用到了上文定义的range()函数,这段代码用以初始化一个数组,数组成员是0~100之间的偶平方数:

let evensquares = [x*x for (x in range(0,10)) if (x % 2 === 0)]

这段代码和下面这五行代码等价:

let evensquares = [];
for(x in range(0,10)) {
    if (x % 2 === 0)
    evensquares.push(x*x);
}

一般来讲,数组推导的语法如下:

[ expression for ( variable in object ) if ( condition ) ]

我们看到,数组推导中包含三个部分:

  • 一个没有循环体的for/in或for/each循环。这部分包括一个变量(或者通过解构赋值得到的多个变量),它位于关键字in的左侧,in的右侧是一个对象(这个对象可以是一个生成器、可迭代对象或一个数组)。尽管没有循环体,这个逻辑片段也能正确执行迭代,并能给给指定的变量赋值。注意,在变量之前没有关键字var和let,其实这里使用了隐式的let,在数组推导中的变量在方括号的外部是不可见的,也不会覆盖已有的同名变量。
  • 在执行遍历的对象之后,是关键字if和条件表达式conditional,目前,这个条件表达式只是用作过滤迭代的值。每次for循环产生一个值之后会执行条件表达式。如果条件表达式返回false,则跳过这个值,这个值也不会被添加至数组当中,if从句是可选的,如果省略的话,相当于给数组推导补充一条if(true)从句。
  • 在关键字for之前是expression,这个表达式可以被认为是循环体。在迭代器返回了一个值并将它赋给一个变量,且这个变量通过了conditional的校验之后,将计算这个表达式,并将表达式的计算结果插入到要创建的数组中。

下面是一些具体的例子:

data = [2,3,4, -5]; // 一个数组
squares = [x*x for each (x in data)]; // 对每个元素求平方: [4,9,16,25]
// 如果数组元素是非负数,求它的平方根
roots = [Math.sqrt(x) for each (x in data) if (x >= 0)]

// 将一个对象的属性名放入新创建的数组中
o = {a:1, b:2, f: function(){}}
let allkeys = [p for (p in o)]
let ownkeys = [p for (p in o) if (o.hasOwnProperty(p))]
let notfuncs = [k for ([k,v] in Iterator(o)) if (typeof v !== "function")]

11.4.5 生成器表达式

在JavaScript 1.8(注:本书撰稿时,Rhino还未开始支持生成器表达式)中,将数组推导中的方括号替换成圆括号,它就成了生成器表达式。生成器表达式(generator expression)和数组推导非常类似(两者在括号内的语法几乎完全一样),只是它的返回值是一个生成器对象,而不是一个数组。和数组推导相比,使用生成器表达式的好处是可以惰性求值(lazy evaluation),只有在需要的时候求值而不是每次都计算求值,这种特性可以应用于潜在的无穷序列。生成器表达式也有不足之处,生成器只支持对值的顺序存取而不是随机存取。和数组不同,生成器并没有索引,为了得到第n个值,必须遍历它之前的n-1个值。

本章前面的小节中我们写过这样一个map()函数:

function map(i, f) { // 对于每个元素i,生成器都会生成 f(x)
    for(let x in i) yield f(x);
}

有了生成器表达式,就不必这么麻烦用这个map()函数了。比如下面这段代码,定义了一个新的生成器h用以对每个x生成f(x),x由生成器g生成:

let h = (f(x) for (x in g));

实际上,例11-1中所提到的eachline(),我们可以对其进行重写,可以通过这种方式来去除空格、注释和空行:

let lines = eachline(text);
let trimmed = (l.trim() for (l in lines));
let nonblank = (l for (l in trimmed) if (l.length > 0 && l[0]!='#'));

11.5 函数简写

对于简单的函数,JavaScript 1.8(注:本书撰稿时,Rhino还未开始支持生成器表达式)引入了一种简写形式:“表达式闭包”。如果函数只包含一个表达式并返回它的值,关键字return和花括号都可以省略,并将待计算的表达式放在参数列表之后,这里有一些例子:

这只是一种简单的快捷写法,用这种形式定义的函数其实和带花括号和关键字return的函数完全一样,这种快捷写法更适用于当给函数传入另一个函数的场景,比如:

// 对数组按照数字大小顺序进行降序排列
data.sort(function(a,b) b-a);
// 定义一个函数,用以返回数组元素的平方和
let sumOfSquares = function(data)
    Array.reduce(Array.map(data, function(x) x*x), function(x,y) x+y);

11.6 多Catch 从句

在JavaScript1.5中,try/catch语句已经可以使用多catch从句了,在catch从句的参数中加入关键字if以及一个条件判断表达式:

try {
    // 这里可能会抛出多种类型的异常
    throw 1;
}
catch(e if e instanceof ReferenceError) {
    // 这里处理引用错误
}
catch(e if e === "quit") {
    // 这里处理字符串是“quit”的情况
}
catch(e if typeof e === "string") {
    // 处理其他字符串的情况
}
catch(e) {
    // 处理余下的异常情况
}
finally {
    // finally从句正常执行
}

当产生了一个异常时,程序将会尝试执行每一个catch从句。catch从句中的参数即是这个异常,执行到catch的时候会它的计算条件表达式。如果条件表达式计算结果为true,则执行当前catch从句中的逻辑,同时跳过其他的catch从句。如果catch从句中没有条件表达式,程序会假设它包含一个if true的条件,如果它之前的catch从句都没有被激活执行,那么这个catch中的逻辑一定会执行。如果所有的catch从句都包含条件,但没有一个条件是true,那么程序会向上抛出这个未捕获的异常。注意,因为catch从句中的条件表达式已经在括号内了,因此也就不必像普通的条件句一样再给他包裹一个括号了。

11.7 E4X: ECMAScript for XML

“ECMAScript for XML”简称E4X,是JavaScript的一个标准扩展(注:E4X是由ECMA-357规范定义的。可以从这里查看官方文档:http://www./publications/standards/Ecma-357.htm),它为处理XML文档定义了一系列强大的特性。Spidermonkey 1.5和Rhino 1.6已经支持E4X。由于多数浏览器厂商还未支持E4X,因此E4X被认为是一种基于Spidermonkey或Rhino引擎的服务器端技术。

E4X将XML文档(元素节点或属性)视为一个XML对象,将XML片段视为一个紧密相关的XML列表对象。本节会介绍创建和使用XML对象的一些方法。XML对象是一类全新的对象,E4X中定义了专门的语法来描述它(接下来会看到)。我们知道,除了函数之外所有标准的JavaScript对象的typeof运算结果都是“object”。正如函数和原始的JavaScript对象有所区别一样,XML对象也和原始JavaScript对象不同,对它们进行typeof运算的结果是“xml”。在客户端JavaScript中(参照第15章),XML对象和DOM(文档对象模型)对象没有任何关系,理解这一点非常重要。E4X标准也针对XML文档元素和DOM元素之间的转换作了规定,这个规定是可选的,Firefox并没有实现它们之间的转换。这也是E4X更适用于服务器端编程的原因。

本小节中我们会给出一个E4X的快速入门教程,而不会作更深入的讲解。XML对象和XML列表对象的很多方法本书中并未介绍。在参考手册部分也不会对其作讲解,如果读者希望进一步了解E4X,可以参照官方文档。

E4X只定义了很少的语言语法。最显著的当属将XML标签引入到JavaScript语言中。可以在JavaScript代码中直接书写XML标签直接量,比如:

// 创建一个XML对象
var pt =
    <periodictable>
     <element id="1"><name>Hydrogen</name></element>
     <element id="2"><name>Helium</name></element>
     <element id="3"><name>Lithium</name></element>
    </periodictable>;

// 给这个表格添加一个新元素
pt.element += <element id="4"><name>Beryllium</name></element>;

XML直接量语法中使用花括号作为变量输出,我们可以在XML中嵌入JavaScript表达式。例如,这里是另外一种创建XML元素的方法:

pt = <periodictable></periodictable>; // 创建一个新表格
var elements = ["Hydrogen", "Helium", "Lithium"]; // 待添加的元素
// 使用数组元素创建XML元素
for(var n = 0; n < elements.length; n++) {
    pt.element += <element id={n+1}><name>{elements[n]}</name></element>;
}

除了使用直接量语法,我们也可以将字符串解析成XML,下面的代码为上段代码创建的节点增加了一个新元素:

pt.element += new XML('<element id="5"><name>Boron</name></element>');

当涉及到XML片段的时候,使用XMLList()替换XML():

pt.element += new XMLList('<element id="6"><name>Carbon</name></element>' +
    '<element id="7"><name>Nitrogen</name></element>');

E4X提供了一些显而易见的语法用以访问所创建的XML文档的内容:

var elements = pt.element;      // 得到element列表
var names = pt.element.name;    //得到所有的name标签
var n = names[0];           //"Hydrogen"(氢),name的第零个标签的内容

E4X同样为操作XML对象提供了语法支持,点点(..)运算符是“后代运算符”(descendant operator),可以用它替换普通的点(.)成员访问运算符:

// 另一种得到<name>标签列表的方法
var names2 = pt..name;

E4X甚至定义了通配符运算:

// 得到所有<element>标签的所有子节点
// 这也是得到所有<name>标签的另外一种方法
names3 = pt.element.*;

E4X中使用字符@来区分属性名和标签名(从XPath中借用过来的语法)。比如,你可以这样来获得一个属性:

// “氮”的原子序数是多少
var atomicNumber = pt.element[1].@id;

可以使用通配符来获得属性名@*,

// 获得所有的<element>标签的所有属性
var atomicNums = pt.element.@*;

E4X甚至包含了一种强大且及其简洁的语法用来对列表进行过滤,过滤条件可以是任意谓词表达式:

// 对所有的element元素组成的列表进行过滤
// 过滤出那些id属性小于3的元素
var lightElements = pt.element.(@id < 3);

// 对所有的element元素组成的列表进行过滤
// 过滤出那些name以B开始的元素。
// 然后得到过滤后元素的<name>标签列表
var bElementNames = pt.element.(name.charAt(0) == 'B').name;

本章的§11.4.1中讲到for/each循环是非常有用的,但在E4X标准中对for/each循环有了新的定义,可以用for/each来遍历XML标签和属性列表。for/each和for/in循环非常类似,for/in循环用以遍历对象的属性名,for/each循环用以遍历对象的属性值:

// 输出元素周期表中的每个元素名
for each (var e in pt.element) {
    console.log(e.name);
}

// 输出每个元素的原子序数
for each (var n in pt.element.@*) console.log(n);

E4X表达式可以出现在赋值语句的左侧,可以用它来对已存在的标签和属性进行修改或添加新标签或属性:

// 修改氢元素的<element>标签,给它添加一个新属性
// 像下面这样添加一个子元素:
//
// <element id="1" symbol="H">
//   <name>Hydrogen</name>
//   <weight>1.00794</weight>
// </element>
//
pt.element[0].@symbol = "H";
pt.element[0].weight = 1.00794;

通过标准的delete运算符也可以方便地删除属性和标签:

delete pt.element[0].@symbol; // 删除一个属性
delete pt..weight;      //删除所有的<widget>标签

我们可以通过E4X所提供的语法来进行大部分的XML操作。E4X同样定义了用于调用能够XML对象的方法,例如,这里用到了insertChildBefore()方法:

pt.insertChildBefore(pt.element[1],
    <element id="1"><name>Deuterium</name></element>);

E4X中是完全支持命名空间的,它为使用XML命名空间提供了语法支持和API支持:

// 声明默认的命名空间:default xml namespace = "http://www./1999/xhtml";
// 这里是一个包含了一些svg标签的xhtml文档
d = <html>
    <body>
     This is a small red square:
     <svg xmlns="http://www./2000/svg" width="10" height="10">
         <rect x="0" y="0" width="10" height="10" fill="red"/>
     </svg>
    </body>
    </html>

// body元素和它的命名空间里的uri以及他的localName
var tagname = d.body.name();
var bodyns = tagname.uri;
var localname = tagname.localName;

// 选择<svg>元素需要多做一些工作,因为<svg>不在默认的命名空间中,
// 因此需要为svg创建一个命名空间,并使用::运算符将命名空间添加至标签名中
var svg = new Namespace('http://www./2000/svg');
var color = d..svg::rect.@fill // "red"

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多