ES5 技术设计

尽管 ES3.1 最初的目标非常保守,ES5 仍包含多项技术创新。

严格模式

ES5 严格模式直接源于 Douglas Crockford 在 JavaScript 的设计中「纠正错误和不便」的目标。其中的一些不便在当时会造成语法错误,它们在 ES5 中可以在不影响现有代码的前提下被修正。例如保留字(reserved words)既无法作为对象字面量的属性键,也无法在点号后使用。但是,仍有许多 JavaScript 的错误特性并不能被无条件修复,因为它们可能会改变现有代码的运行时行为,从而「破坏 Web」。严格模式的设想,则是使 JavaScript 开发者有机会在新代码或更新后的代码中,明确是否选择性使用(opt-in)包含了此类修复的语言方言。为此,浏览器将必须同时支持严格模式和原有非严格模式的代码。并且在理想情况下,严格模式应该能在各个独立函数的层面上选择性切换,以便现有脚本能逐步转换为使用严格模式。人们希望随着时间的流逝,严格模式能成为编写新代码的主要方言。但它该如何获得最初的采用,仍然是一个问题。有人认为要等到所有主流浏览器都实现 ES5 严格模式,可能会有相当大的延迟。而浏览器博弈论预测,如果严格模式会使脚本在某些流行的浏览器上无法使用,那么开发者将不会使用它。使严格模式符合减法原则(subtractive)可以规避这个问题。严格模式并没有向 ECMAScript 添加新特性;相反地,它删除了有问题的特性。在不支持严格模式的浏览器上运行时,无错误的严格模式代码也应该能继续按开发者的预期工作。

严格模式的一个早期问题,是该如何选择性地启用它。严格模式所具备的细粒度选择性,需要一种易于嵌入到脚本中的机制来实现,而不能利用类似 <script> 元素属性的外部手段。ES4 中考虑过提供可放置在 ECMAScript 代码内的 use 指令,以此来选择各种模式。但这样的指令会违反 ES3.1「不允许新语法」的设计准则。还有一种可能性是使用特殊形式的注释作为指令。但是 ES3.1 工作组也不愿意为任何形式的注释赋予语法上的意义,因为 JavaScript 压缩工具(minimizer)会删除注释。但 Allen Wirfs-Brock 发现,ECMAScript 中的 ExpressionStatement 语法可以将任何表达式转换成有效的语句。只要某个表达式是显式或隐式地(通过 ASI)后跟分号,那么它就可以转换成有效的语句,对仅含字符串字面量常量的表达式而言也是如此。这意味着那些诸如 "use-strict"; 的声明,在语法上也是有效的 ES3 代码。因为这行代码只是一个常量值,所以在 ES3 中对其求值也没有副作用。同时这也是一个空操作g。选择使用这样的语句作为严格模式看起来相当安全,因为任何现有的 JavaScript 代码似乎都不太可能已经利用了这样的语句形式,并且在 ES3 的实现中加载 ES5 代码时,旧版实现也都会忽略这行代码的存在。工作组采纳了这个想法,决定只要使用 "use-strict"; 形式的声明作为脚本或函数主体的第一条语句,就表示整个脚本或函数应使用严格模式下的语义来处理。

严格模式的主要目标之一,是显式捕获那些容易产生但在运行时并不明显的编码错误。严格模式中添加了如下的新运行时错误:

  • 给未声明的标识符赋值。在旧版 JavaScript 中,对输错的变量名称进行赋值,会导致在全局对象上创建属性。
  • 给只读的自有或继承属性赋值。在旧版 JavaScript 中,这种操作会静默地不生效。
  • 尝试在不可扩展的对象上创建属性。这样的对象在 ES5 之前并不存在,但为了保持一致性,在 ES5 中的严格模式之外执行此操作时,也将会静默地不生效。
  • delete 运算符应用于不可删除的属性。在旧版 JavaScript 中,这时的 delete 会返回 false
  • delete 运算符应用于变量引用会产生语法错误。在旧版 JavaScript 中,对于显式声明的变量,delete 会返回 false。如果变量引用来自与 with 语句相配合的对象,或者属于全局对象的属性,那么它在旧版 JavaScript 中将被删除。

严格模式还会移除或修改那些可能使程序更混乱、更难优化或更不安全的特性:

  • 禁用 with 语句。with 语句提供了一种变量引用的动态作用域形式,这种形式可能会造成困扰,并且不利于各实现中的优化。
  • eval 函数不能动态添加新绑定到当前作用域。
  • evalarguments 不能用作变量名或参数名。
  • 函数的 arguments 对象不与其形参相关联。作为替代,严格模式下的 arguments 对象是一个数组式(array-like)的对象,其元素是传递给函数的参数值的快照。修改其元素不会修改相应形参的值,反之亦然。
  • 严格模式下,函数的 arguments 对象没有 callee 属性。将这样的 arguments 对象传递给其他代码时,不会再隐式转移出对其上函数的调用能力81
  • 严格模式下,不允许实现在函数的 arguments 对象上提供 caller 属性。 caller 属性是 ES3 的一个非标准但已广泛实现的扩展,它允许遍历函数的调用堆栈,获取到所有的调用者函数。
  • 严格模式下,调用函数时如果没有提供 this 值,全局对象对其就不可见。

在 Douglas Crockford [2007d] 列出的错误和不便清单上,还有许多关于严格模式的特性,但它们都没有纳入 ES5 中。对于这些特性,要么是 TC39 无法就其是否不受欢迎达成一致,要么是发现该改动不符合减法原则。例如,尽管 Crockford 和其他许多人都不喜欢 JavaScript 的自动分号插入,但许多开发者都更喜欢在没有显式分号的情况下编码。再比如,将 typeof null 更改为返回非 "object" 的其他值,也不符合减法原则。

Getter,Setter 和对象元操作

从最早的 JavaScript 实现开始,内置对象和宿主对象中的某些属性就已具备一些特殊性质。而通过 JavaScript 代码所创建的对象,是无法应用它们的。例如某些属性具有只读的值,或无法使用 delete 运算符删除;内置对象和宿主对象的方法属性在由 for-in 语句枚举时会被跳过。在 ES1 中这些特殊语义的确定,是通过将 ReadOnly,DontDelete 和 DontEnum 这些标记(attribute)与规范中的对象模型相关联的方式来实现的。这些标记会通过伪代码来测试,伪代码中定义了它们所涉及的语言标记的语义。这些标记没有被具体化(reified)——在 JavaScript 中,并不存在能为「新创建或已有的」属性设置这些标记的语言特性。ES3 中添加了一个 Object.prototype.propertyIsEnumerable 方法,用于测试 DontEnum 标记是否存在。但规范中仍然没有对 ReadOnly 或 DontDelete 标记执行非破坏性测试的相应方法。类似地,有许多由浏览器 DOM 提供的宿主对象也暴露了一些属性,它们通常叫做「getter / setter 属性」。在 ES5 中,这些属性被命名为访问器属性(accessor properties),会在存取属性值时执行计算。由于缺少对这些特性的标准化支持,JavaScript 程序员既无法定义「遵守与内置或宿主对象相同约定」的库,也无法实现 polyfill 来可靠地模拟这类对象。

对这些问题的统一解决方案,构成了新版 ES5 特性中最大的一部分。这部分特性没有正式名称,它们被非正式地称为「静态对象函数82」(Static Object Functions)或「对象反射函数」(Object Reflection Functions)。Allen Wirfs-Brock [2008] 为这个特性集编写了设计原理文档,其中包含了用例与以下设计准则:

  • 干净地将元层(meta)和应用层分开。
  • 尽量降低 API 的复杂度,例如方法的数量和方法参数的复杂度。
  • 专注于命名和参数设计上的易用性。
  • 尝试复用设计中的基本元素83
  • 尽可能使程序员或语言实现能静态优化对该 API 的使用。

第一条准则不鼓励在 Object.prototype 中添加形如 propertyIsEnumerable 的新方法,这会进一步模糊元层和应用层的分离。作为替代,ES5 工作组决定把这些特性作为命名空间对象的属性,从而将它们与应用层对象分离。他们考虑添加一个名为 Reflect 的新内置全局对象作为命名空间对象,但又担心这会与现有代码的名称冲突。最终,他们决定将新函数作为 Object 构造函数的属性,而不是 Object.prototype 的属性。将对象构造函数作为命名空间是一个不错的选择,因为它是一个已经存在的全局变量,并且在当前的语言实现和以前的标准版本中,都没有在其上定义任何属性。同时,它的名称也与重新考虑对象定义的想法相契合。

下一个问题是确定 API 的形式。基于第二条准则,ES5 设计者希望避免给每个「属性标记」与「访问器属性」分别设置单独的查询与赋值函数。设计者考虑了许多方法来将这一特性合并到少量函数中。一些可能性包括使用具有 Boolean 标记(如「read-only」)的位编码的单个函数,或者具有大量位置参数(positional parameters)的单个函数。但是这两种方法的易用性都不够好。使用可选的关键字参数(keyword arguments)或许可以解决这些易用性问题,但 ES5 中缺少关键字参数。

Allen Wirfs-Brock 建议使用描述符对象(descriptor object),这种对象的属性将与各种属性标记相对应。这种描述符可以用来定义和检查属性。Wirfs-Brock 的第一份草案84展示了一种可能的 API 示例,用于向名为 obj 的对象添加属性:

  1. Object.addProperty(obj, { name: "pi", value: 3.14159, writable: false });

在示例中,描述符被编码为对象的字面量。对于描述符上没有,但又与其他属性标记相对应的属性,则会使用这里提供的默认值。还有一个设想中的 defineProperty 函数也会接受类似的描述符,可以用来更改已有属性的标记值。defineProperty 不会修改不存在于描述符属性上的标记。最后,还可以通过调用 getProperty 来获取对象上任何已有属性的完整描述符。

Mark Miller 提出了改进意见,建议让这个 defineProperty 能支持「添加新属性」和「修改现有属性」的使用场景。Miller 还建议从属性描述符中删除 name 属性,将描述符包装在一个对象中,该对象的属性名就是目标对象中受影响的属性名。这样的「属性映射表」(property map)将允许通过单次调用定义出多个属性。例如,以下操作就定义出了名为 xy 的属性:

  1. Object.defineProperties(obj, {
  2. x: { value: 0, writable: true },
  3. y: { value: 0, writable: true }
  4. });

Miller 建议移除 defineProperty,只保留 defineProperties 的形式,因为后者也很容易用于定义单个属性。但是,这种表达方式很难定义出具有计算名称(computed name)的属性。在 ES3.1 中并没有语法能将计算值放置在「对象字面量的属性名称」位置处。最后,ES3.1 既提供了能通过「将名称独立地传递为参数」来定义单个属性的 defineProperty,也提供了能通过属性映射表定义多个属性的 defineProperties。ES5 定义的整套对象反射函数如图 33 所示。

函数名行为
Object.create使用所提供的对象作为原型,创建一个新的对象。支持通过可选的属性映射表来添加属性。
Object.defineProperty基于属性描述符创建一个新属性,或更新已有属性的定义。
Object.defineProperties创建或更新属性映射表中一组属性的定义。
Object.getOwnPropertyDescriptor返回某个具名属性的描述符对象,不存在该属性时则返回 undefined
Object.getOwnPropertyNames返回包含了某对象全部自有属性名字符串的 Array
Object.getPrototypeOf返回所传入对象的原型对象。
Object.keys返回一个 Array,其中包含对象自有属性的字符串名称,这些属性均在使用 for-in 时可见。
Object.preventExtensions阻止所有向对象上添加新属性的操作。
Object.seal阻止所有向对象上添加新属性的操作,并阻止对其自有属性定义的修改。
Object.freeze封存对象,并冻结其所有自有数据属性的值。
Object.isExtensible测试是否可为对象添加新自有属性。
Object.isSealed测试某对象是否被封存。
Object.isFrozen测试某对象是否被冻结。

图 33. ES5 对象反射函数。

访问器属性也能通过可选的属性描述符来支持。除了 value 属性外,还可以使用具有 get 和(或)set 属性的描述符来定义访问器属性。例如一个用于「拦截对数据属性存取操作」的访问器属性,就可以定义为如下:

  1. Object.defineProperties(obj, {
  2. x: {
  3. set: function (value) { this.privateX = value }, // 公有访问器属性
  4. get: function () { return this.privateX }
  5. },
  6. privateX: {
  7. value: 0,
  8. writable: true
  9. } // 「私有」数据属性
  10. });

除了这种基于反射的接口外,ES3.1 还在语法上支持了用对象字面量来定义访问器属性。四种浏览器中有三种都已经实现了这种语法,因此它符合加入新语法的标准。在对象字面量中,可以通过函数来定义访问器属性,其中函数关键字 functiongetset 所替换,例如:

  1. var obj = {
  2. privateX: 0, // 一个普通的属性
  3. set x(value) { this.privateX = value }, // 访问器属性 x 的 setter
  4. get x() { return this.privateX }, // 访问器属性 x 的 getter
  5. get negX() { return -this.privateX } // 只有 getter 的访问器
  6. };

要支持这些新特性,需要扩展语言内部(最早在 ES1 中定义的)对象模型,通过对象反射 API 来部分开放它。这也为重新考虑对象模型的术语提供了契机。ES1 通过一个值和一组标记的方式来描述属性,这些标记包括 ReadOnly,DontEnum 和 DontDelete。ES1 中的标记是无状态的,它们是关联到属性的记号,以自身的存在与否来表达其含义。ES3.1 设计者则希望将这些标记作为属性描述符对象的属性。为此他们更改了内部模型,将 ES1 标记建模为与每个对象属性相关联的 Boolean 状态变量,并将属性值重新建模为另一个状态变量。而内部标记的命名约定,也更改成了与内部方法一致的双括号模式。为了支持访问器属性,内部对象模型上新添加了 [[Get]][[Set]] 标记,这些标记的值分别是在值被引用时调用的 getter 函数,以及在赋值时调用的 setter 函数(或者是表示默认函数的 undefined)。根据某个属性是否既具有 [[Value]] 标记又没有 [[Get]][[Set]] 标记,可以区分出数据属性和访问器属性。

为了支持访问器属性,需要更新 ES1 中 [[Get]][[Put]][[CanPut]] 内部方法的规范。为了支持对象反射 API 使用的属性描述符,还需要添加 [[DefineOwnProperty]][[GetOwnProperty]][[GetProperty]] 内部方法。但光有这个反射 API 还是不够。在 ES3.1 中,for-in 语句对属性键的枚举、Object.getOwnPropertyNames 方法,以及 Object.keys 函数,都仍然使用非形式化的叙述来定义语义。

设计对象反射 API 的最后一步,是为这些属性描述符对象中表示属性标记的词汇,确定出一致且可用的命名约定。尤其像 DontEnum 和 ReadOnly 之类的名称就缺乏内部一致性,这引来了对其易用性问题的关注,当它们被用作布尔值标志时更是如此。例如若将属性设为可枚举,就需要表达双重否定(将 DontEnum 设置为 false)。在 2008 年初,Neil Mix [2008b] 在与新版 ES4 有关的主题帖上建议,将 「enumerable」、「writable」和「removable」(对应 DontDelete)作为标记名会更好。Mark Miller [2008b] 对这些名称表示赞赏,并提出了一条设计准则:标记名应说明它「允许什么」而非「拒绝什么」。他还建议遵循「默认拒绝」的最佳实践来保证安全性。当定义属性时,全部所需的标记都要显式地启用。

对象反射 API 提供了 ECMAScript 早期版本中没有的新能力。它允许程序更改现有属性的标记,包括在数据属性和访问器属性之间切换。这里的一个考量在于,是否需要额外的标记来禁用此类更改。对此可能的命名包括「dynamic」、「flexible」和「fixed」。但人们担心添加这样一个额外的 Boolean 属性标记后,对现有实现可能产生的影响。如果一个语言实现没有可用的额外比特位来表示该标记,要怎么办呢?最后 ES3.1 工作组意识到,对属性标记的更改,等效于先对属性的当前标记做原子查询,再删除该属性,最后重新创建具有相同名称但标记值已修改的属性。鉴于这种等效性,可以使用单个标记来表达是否启用删除和修改。于是 DontDelete 和 removable 标记被重命名为了「configurable」85,以此来代表这一含义。Mark Miller [2010b] 绘制了 ES5 属性标记的状态图 [Harel 2007](图 34),并发布到了 ECMAScript Wiki 上。注意当 configurable 标记为 false 时,仍然可以将属性的 writable 标记从 true 更改为 false。这个反常之处的存在,是为了让安全沙箱g能更改某些内置属性,使其从「不可配置但可写」变为「不可配置且不可写」。

ES5 技术设计 - 图1

图 34. ES5 中属性的标记状态图 [Miller 2010b]。

作为在 JavaScript 应用中使用「基于原型风格的面向对象编程」范式的倡导者,Douglas Crockford 提倡使用名为 beget 的函数,来基于「显式提供的原型」创建对象。ES5 中的 Object.create 函数,实质上就是将属性映射表添加为第二个可选参数的 beget 函数,例如:

  1. var point1 = beget(protoPoint); // 用 Crockford 风格创建一个 point
  2. point1.x = 0;
  3. point1.y = 0;
  4. var point2 = Object.create(protoPoint, { // 使用 ES5 声明式风格
  5. x: { value: 0 },
  6. y: { value: 0 }
  7. });

ES3 中 Crockford 的 beget 函数与 ES5 的对比。

Allen Wirfs-Brock 曾希望 JavaScript 程序员采用声明式风格,这样语言实现就可以识别出该模式,并据此优化对象的创建。然而在实践中,有个易用性问题妨碍了这种 ES5 模式的广泛应用。这个问题出在对默认属性标记的选择上。在 JavaScript 1.0 中,通过隐式赋值创建的属性具有与此等效的属性标记:{writable:true,enumerable:true,configurable:true}。但 ES5 属性描述符所遵循的「默认拒绝」策略,意味着在使用声明式风格的 Object.create 时,所有这些标记的默认值均为 false。例子如下所示:

  1. // 以 Crockford 风格使用 Object.create
  2. var point1 = Object.create(protoPoint);
  3. point1.x = 0;
  4. point1.y = 0;
  5. // point1.x 的标记为
  6. // writable: true, enumerable: true, configurable: true
  7. // point1.y 的标记为
  8. // writable: true, enumerable: true, configurable: true
  9. // 以声明式风格使用 Object.create
  10. var point2 = Object.create(protoPoint, {
  11. x: { value: 0 },
  12. y: { value: 0 }
  13. });
  14. // point2.x 的标记为
  15. // writable: false, enumerable: false, configurable: false
  16. // point2.y 的标记为
  17. // writable: false, enumerable: false, configurable: false

要与 beget 示例的效果完全一致,使用 ES5 风格的 JavaScript 程序员就必须编写:

  1. // 通过 ES5 与例行的标记值来创建 point 实例
  2. var point2 = Object.create(protoPoint, {
  3. x: { value: 0, writable: true, enumerable: true, configurable: true },
  4. y: { value: 0, writable: true, enumerable: true, configurable: true }
  5. });

对于大多数希望使用 JavaScript(传统意义上更为宽松的)默认值的程序员而言,这种表达方式过于繁琐。在实践中,人们通常使用 Object.create 的单参数形式来创建新对象,使用 Object.defineProperties 来定义和操作对象创建后的属性,很少使用 Object.create 的双参数形式来定义新对象的属性。

对象的完整性与安全性特性

Netscape 3 所引入的 HTML <script> 元素 src 属性,使网页可以从多个 Web 服务器加载 JavaScript 代码。按最常见的说法,脚本会被加载到单个 JavaScript 执行环境中,在此它们共享同一个全局命名空间。跨站脚本也可以直接与此交互,这使得人们有条件创建 mashup 应用。跨站脚本的加载能力得到了广泛使用,并对支持基于广告的 Web 商业模式起了关键作用。但是跨站脚本既可能相互篡改与干扰,也可能如此影响原站点页面中的脚本。最后 Web 开发者们意识到,第三方脚本可能引发一些风险,比如窃取密码等用户机密数据,或者修改页面行为以欺骗用户。到 2007 年,人们发现 Web 广告代理商开始在暗中分发恶意广告。浏览器厂商开发了各种 HTML 和 HTTP 级别的特性来解决这一问题,例如内容安全策略(CSP)。但这种级别的特性并不能直接解决许多低层面的 JavaScript 漏洞 [Barth et al. 2009]。

当 Douglas Crockford [Adsafe 2007] 和 Mark Miller [Caja Project 2012; Miller et al. 2008] 参加 ES3.1 工作组时,他们都在积极开发用于支持 JavaScript 执行沙箱的技术,这些沙箱可用于安全地托管执行不受信任的第三方 JavaScript 代码。尽管 ES3.1 强势的向后兼容性需求意味着已无法消除许多已知的第三方脚本漏洞,但 Crockford 和 Miller 都力求消除那些可以在兼容前提下修补的漏洞,并继续添加新特性,以助于创建安全沙箱。在这之中,Mark Miller 对基于对象能力(object capability)[Miller 2006] 构建沙箱所需的特性尤其感兴趣。

这里最大的问题是 JavaScript 对象的可变性(mutability)。默认情况下,包括标准库对象在内的所有 JavaScript 对象,对于任意获取到了对其引用的代码而言,都是完全可变的。对象的属性和方法都可以被更改、赋值或删除。对于被直接引用的对象以及(从根级对象起)被间接引用的对象来说,情况都是这样的。尽管 ES3 中并没有方法能直接修改某个对象「到其原型对象」的引用,但是除 IE 之外的所有主流浏览器都已经实现了非标准属性 __proto__。通过该属性,可以修改对象的原型继承链。对于这种普遍存在的可变性,仅有的例外是 ES3 中带有 ReadOnly 或 DontDelete 标记的少数内置属性。

Mark Miller 和 Douglas Crockford 希望添加新能力,从而在将对象传递给不受信任的代码前,能锁定该对象的属性。这种能力可以用于保护需要暴露给沙箱的内置库对象,并让托管在沙箱内的代码能保护任何「需要被传递给不受信任的代码」的对象。通过将 DontDelete 标记重新设定为 Configurable 标记,并利用 Object.defineProperty 来使属性不可被修改与删除,语言提供了保护单个属性的基本能力。但这仍不足以防止不受信任的代码将新属性附加到传入其中的对象上。这种添加新属性的能力,使得不受信任的代码可以覆盖掉继承的行为,并有可能构建出用于泄漏私人数据的隐蔽通信渠道。在 ES5 中,这个问题是通过为每个对象关联一个名为 [[Extensible]] 的内部新状态来解决的。创建对象时,[[Extensible]] 默认被设置为 true。但如果将它设置为 false,那么新属性就无法添加到该对象上,此时语言实现也不允许提供任何用于修改对象 [[Prototype]] 的扩展。最后,一旦 [[Extensible]] 被设置为 false,就无法将其重置为 true

Object.isExtensible 函数提供了一个用于查询对象 [[Extensible]] 状态的 API。Object.preventExtensions 函数能强制将 [[Extensible]] 设置为 false。而 Object.freeze 函数能很方便地将 [[Extensible]] 连同所有属性的 [[Configurable]][[Writable]] 标记都设置为 false,使对象的直接状态完全不可变。Object.seal 函数则类似于 Object.freeze,只是它没有将 [[Writable]] 设置为 false。它能固定住对象的原型和属性集合,但仍然允许修改数据属性的值。

另一个被重点关注的问题,则是对全局对象的环境式访问(ambient access)。ECMAScript 将全局对象定义为了一个「属性位于全局作用域上」的对象,所有具名的标准库对象都作为全局对象的属性存在。并且大多数 JavaScript 的宿主环境,都会向全局对象添加特定于环境的对象与 API 函数。例如浏览器中的全局对象就和 window 对象相同,提供了对当前页面 DOM 对象与其他浏览器 API 的完全访问权限。一般而言,沙箱会限制对某些或所有全局对象属性的访问,或替代掉部分全局对象的属性。理论上,应该可以在所有沙箱代码外强制放置一个额外的词法作用域,通过设置这个作用域来实现这种效果。这种手段可以为某些全局对象属性提供替代性的绑定,或者通过提供值为 undefined 的遮盖式绑定,从而隔离这些属性。但是自 JavaScript 1.0 以来,始终有一种方法能访问到词法作用域隐藏不了的全局对象:

  1. function getGlobalObject() {
  2. // 直接调用时,this 的值是全局对象
  3. return this;
  4. }
  5. getGlobalObject().document.write("pwned");

直到 ES5 前,直接调用函数(而非限定了对象的方法调用)的行为都会传入 null 作为隐式 this 参数,并且所有函数在被调用时,都会把值为 nullthis 替换成全局对象。为了保证后向兼容性,现有代码中的这一行为是不能更改的。但 ES5 的严格模式则为新代码提供了「选择性使用新行为」的机会。在 ES5 中,严格模式下的函数永远不会用全局对象替换实际的 this 参数。沙箱可以只允许在其中运行严格模式的 JavaScript 代码,从而杜绝对全局对象的环境式访问。

在 ES5 的开发过程中,Web 上开始实际出现了如图 35 中示例的恶意攻击。ES3 规定使用对象字面量创建的对象继承自 Object.prototype,并且该对象字面量会使用 [[Put]] 内部方法,以设置新对象字面量中列出的属性。但是当使用 [[Put]] 将值分配给对象的属性时,需要查找原型继承链,来检查是否可以找到具有相同名称的属性。如果找到具有这一名称的 setter 属性,相应的 setter 函数就会执行。而如果在 Object.prototype 上设置这种 setter,那么只要尝试使用对象字面量形式创建一个与 setter 同名的属性,就都会调用到相应的 setter 函数,并为其传递该属性值。

  1. // 假设我们已经发现某页面使用对象字面量
  2. // 将一些有价值的信息存在 secret 属性中
  3. function setupToStealSecret() {
  4. // 使用 ES5 前非标准的 getter / setter API
  5. // 在原型上定义一对 getter / setter
  6. Object.prototype.__defineSetter__("secret", function (val) {
  7. this.__harmlessSoundingName__ = val; // 将值存储在其他属性上
  8. exploitTheSecret(val, this)
  9. });
  10. Object.prototype.__defineGetter__("secret", function () {
  11. // 从另一个位置获取值,不会破坏原有代码逻辑
  12. return this.__harmlessSoundingName__;
  13. });
  14. }
  15. // 当代码使用具有 secret 属性的对象字面量定义对象时,秘密就会泄漏
  16. var objectWithSecret = {
  17. secret: "password" // 这会触发继承的 setter
  18. // 可能还定义了其他属性
  19. };

图 35. 使用 JavaScript 1.5 的 __defineSetter__ 扩展的安全漏洞。通过在 Object.prototype 上定义 setter 属性,攻击者可以劫持使用对象字面量定义的特定属性的值。

对这个漏洞的修复,会产生对象字面量语义上的破坏性变更,但浏览器厂商愿意为修复这样的安全漏洞而做出改动。实际的规范更改很简单:不再使用 [[Put]] 语义来创建新对象的属性。ES5 使用了新的 [[DefineOwnProperty]] 内部方法,这个方法会始终忽略继承的属性,直接在对象上创建新的属性。

ES5 只能使 JavaScript 在安全方面前进一小步。当 ES5 的工作正在进行时,Douglas Crockford 建议在 TC39 内成立一个安全 ECMAScript(SES)工作组,其目的 [Crockford 2008d; TC39 2008b] 是探索开发一种新 ECMAScript 安全方言的可能性,这种方言不受后向兼容性的约束。SES 工作组在 2008 到 2009 年举行了四次会议,并评估了一些现有的 JavaScript 解决方案 [TC39 2008e],以实现对不可信代码的安全求值。最后,TC39 放弃了对单独的新方言做标准化的想法,但诸如对象能力模型一类的 SES 概念则极大地影响了 Harmony 的研发。Ankur Taly [2011] 等人基于形式化手段,展示了严格模式和其他 ES5 特性是如何支持「对 mashup 友好的安全 ECMAScript 子集」的。

活动对象(Activation Object)的移除

在 ES5 之前,ECMAScript 规范已经明确要求使用 ECMAScript 对象来定义 ECMAScript 语言的作用域语义。每个作用域轮廓g都由一个活动对象(AO)表示。活动对象也是普通的 ECMAScript 对象,其属性提供了变量和函数绑定,这些绑定是由与当前轮廓相对应的代码创建的。嵌套作用域被定义为一份活动对象的列表,可在其中依次搜索对某个引用的绑定。语言特点在于,引用绑定在访问「活动对象」和访问「用户程序定义出的对象属性」时,都会使用相同的属性访问语义运算符。ES1 及其后续规范指出,活动对象的概念仅用于纯粹的语言规范化,对 ECMAScript 程序而言是透明的。然而如果引擎完全符合规范,这种属性访问语义会导致出现一些边界情况下意料之外的行为。对于这些边界情况下的语义,实际实现则各有不同。

例如有一种意外情况,就是活动对象可能继承自 Object.prototype,而这是新创建对象的默认原型。这意味着 Object.prototype 的属性会被所有活动对象继承,并将作为每个活动对象的本地绑定。这会导致外部作用域中所有名称相同的绑定都被遮盖住。

对绑定的解析是动态发生的,其中会使用活动对象进行属性查找。因此只要在调用函数前,预先加入相应名称在 Object.prototype 上的绑定,任何在被调用函数中的自由引用都可以被拦截,例如:

  1. // ES1–ES3
  2. var originalArray = Array;
  3. function AltArray() {
  4. // 用于替代内置的 Array 构造器
  5. // ...
  6. }
  7. // 调用一个函数, 强制它使用 AltArray
  8. Object.prototype.Array = AltArray;
  9. somethingThatFreelyReferencesArray();
  10. delete Object.prototype.Array; // 移除可选的 Array 绑定

另一种意外情况,是 ES3 中对 try 语句的 catch 子句形参的处理。此时的形参会在新作用域中作为「使用本地词法作用域」的绑定,而这个新作用域包含了 catch 子句的语句体。使用 ECMAScript 对象来表示作用域轮廓的手段,也给这一语义带来了问题。ES5 规范 [Lakshman and Wirfs-Brock 2009, Annex D] 对该问题的描述如下:

12.4:在第 3 版中,会以类似 new Object() 的形式创建出一个对象,作为解析「传递给 try 语句 catch 子句的异常形参」名称的作用域。如果实际的异常对象是一个函数,并且它在 catch 子句中被调用,那么作用域对象将被作为函数调用的 this 值传递。而后,函数体可以在 this 值上定义新属性。并且在函数返回后,这些属性名称将成为 catch 子句作用域内可见的标识符绑定。在第 5 版中,当将异常参数作为函数调用时,将把 undefined 作为 this 的值来传递。

在 2008 年的大部分时间里,工作组打算在新版本中引入 const 声明,因为尽管语义不同,这个特性在四种浏览器中也有三种支持。计划的目的是使 const 词法作用域缩小到块级,这有望进一步对早期规范版本中遗留的作用域模型施加压力。

为了解决这些问题,Allen Wirfs-Brock 在规范层面上开发了一种新的作用域与绑定模型。这个模型并不使用 ECMAScript 对象语义来定义标识符解析机制,并且引入了环境记录(environment record)的概念。环境记录包含单个作用域轮廓中的绑定,以及一些环境(environment),每个环境都是环境记录的有序列表。环境记录为在 ECMAScript 程序中某个位置做标识符解析提供了上下文。环境记录有不同的种类,它们可用于表示全局作用域、函数作用域、块级作用域,以及 with 语句的作用域。而所有环境都开放了一个规范级的通用协议,用于对单个绑定做定义、查找和值修改。规范中对于与「声明或访问变量」和「其他种类的绑定」相关的语言特性,都需要使用通用的环境记录协议。

不过,const 声明最终推迟到了未来的 harmony 规范版本中,因为工作组意识到过早纳入 const 可能会引入一些有问题的语义,从而妨碍将来更全面的块级作用域设计。新的作用域模型仍然在 ES5 中得以应用,以解决与作用域相关的已知遗留问题,并为 ES6 中一组更全面的声明语句奠定了基础。

其他 ES5 特性

除了图 33 中列出的对象反射函数外,ES5 还添加了以下的标准内置函数、方法和属性:

  • JSON.parseJSON.stringify,它们可以在对象与其 JSON 格式字符串之间做相互转换。
  • 9 个新的 Array.prototype 方法:indexOflastIndexOfeverysomeforEachmapfilterreducereduceRight
  • 1 个新的 String.prototype 方法:trim
  • DateDate.prototype.now 方法与新扩展,用于解析和产生 ISO 8601 日期格式下的数据字符串。
  • 新的 Function.prototype 方法 bind,以及函数实例上的 name 属性。

其他各类更改和增强包括:

  • 修复 with 语句和 catch 子句形参作用域的语义。
  • 使用 [] 语法对字符串做数组式的索引。
  • 对正则表达式语法进行小幅修正。
  • 每次求值正则表达式字面量时,都需要创建一个新的 RegExp 对象。
  • 对错误的正则表达式字面量做早期错误报告。
  • 全局对象中的 undefinedNaNInfinity 属性具有只读的值。
  • 要求对于所有规范中的算法,都用 ObjectArray 等的内置初始值来替代当前值。
  • 规范附录 D 和 E 中列出的各种非规范性语义修订。