元编程(JS 原型链)

1 属性的特性
JavaScript 的属性有名字和值,但每个属性也有 3 个关联的特性,用于指定属性的行为以及你可以对它执行什么操作。
可写(writable)特性指定是否可以修改属性的值。
可枚举(enumerable)特性指定是否可以通过 for/in 循环和 Object.keys() 方法枚举属性。
可配置(configurable)特性指定是否可以删除属性,以及是否可以修改属性的特性。
对象字面量中定义的属性,或者通过常规赋值方式给对象定义的属性都可写、可枚举和可配置。但 JavaScript 标准库中定义的很多属性并非如此。
本节讲解与查询和设置属性的特性有关的 API。这个 API 对库作者来说尤其重要,因为:
它允许库作者给原型对象添加方法,并让它们像内置方法一样不可枚举;
它允许库作者“锁住”自己的对象,定义不能修改或删除的属性。
我们在 6.10.6 节介绍过,“数据属性”有一个值,而“访问器属性”有一个获取方法和设置方法。对于本节而言,我们将把访问器属性的获取方法(get)和设置方法(set)作为属性的特性来看待。按照这个逻辑,我们甚至也会把数据属性的值(value)当成一个特性。这样我们就可以说一个属性有一个名字和 4 个特性。数据属性的 4 个特性是 value、writable、enumerable 和 configurable。访问器属性没有 value 特性或 writable 特性,它们的可写能力取决于是否存在设置方法。因此访问器属性的 4 个特性是 get、set、enumerable 和 configurable。
用于查询和设置属性特性的 JavaScript 方法使用一个被称为属性描述符(property descriptor)的对象,这个对象用于描述属性的 4 个特性。属性描述符对象拥有与它所描述的属性的特性相同的属性名。因此,数据属性的属性描述符有如下属性:value、writable、enumerable 和 configurable。而访问器属性的属性描述符没有 value 和 writable 属性,只有 get 和 set 属性。其中,writable、enumerable 和 configurable 属性是布尔值,而 get 和 set 属性是函数值。
要获得特定对象某个属性的属性描述符,可以调用 Object.getOwnPropertyDescriptor():

顾名思义,Object.getOwnPropertyDescriptor() 只对自有属性有效。要查询继承属性的特性,必须自己沿原型链上溯(可以参考 14.3 节的 Object.getPrototypeOf() 或 14.6 节的 Reflect.getPrototypeOf()[1])。
要设置属性的特性或者要创建一个具有指定特性的属性,可以调用 Object.defineProperty() 方法,传入要修改的对象、要创建或修改的属性的名字,以及属性描述符对象:

传给 Object.defineProperty() 的属性描述符不一定 4 个特性都包含。如果是创建新属性,那么省略的特性会取得 false 或 undefined 值。如果是修改已有的属性,那么省略的特性就不用修改。注意,这个方法只修改已经存在的自有属性,或者创建新的自有属性,不会修改继承的属性。也可以参考 14.6 节非常类似的 Reflect.defineProperty()。
如果想一次性创建或修改多个属性,可以使用 Object.defineProperties()。第一个参数是要修改的对象,第二个参数也是一个对象,该对象将要创建或修改的属性的名称映射到这些属性的属性描述符。例如:

这段代码操作的是一个空对象,为该对象添加了两个数据属性和一个只读的访问器属性。它依赖的事实是 Object.defineProperties()(与 Object.defineProperty() 一样)返回修改后的对象。
6.2 节介绍过 Object.create() 方法,这个方法的第一个参数是新创建对象的原型对象。这个方法也接收第二个可选的参数,该参数与 Object.defineProperties() 的第二个参数一样。给 Object.create() 传入一组属性描述符,可以为新创建的对象添加属性。
如果创建或修改属性的行为是不被允许的,Object.defineProperty() 和 Object.defineProperties() 会抛出 TypeError。比如给一个不可扩展的对象(参见 14.2 节)添加新属性。导致这两个方法抛出 TypeError 的其他原因涉及特性本身。writable 特性控制对 value 属性的修改,而 configurable 特性控制对其他特性的修改(也控制是否可以删除某个属性)。不过,这里的规则并非直观明了。比如,虽然某个属性是不可写的,但如果它是可以配置的,仍然可以修改它的值。再比如,即使某个属性是不可配置的,但仍然可以把该属性由可写修改为不可写。下面给出了全部规则,在调用 Object.defineProperty() 或 Object.defineProperties() 时如果违反这些规则就会抛出 TypeError。
如果对象不可扩展,可以修改其已有属性,但不能给它添加新属性。
如果属性不可配置,不能修改其 configurable 或 enumerable 特性。
如果访问器属性不可配置,不能修改其获取方法或设置方法,也不能把它修改为数据属性。
如果数据属性不可配置,不能把它修改为访问器属性。
如果数据属性不可配置,不能把它的 writable 特性由 false 修改为 true,但可以由 true 修改为 false。
如果数据属性不可配置且不可写,则不能修改它的值。不过,如果这个属性可配置但不可写,则可以修改它的值(相当于先把它配置为可写,然后修改它的值,再把它配置为不可写)。
6.7 节介绍了 Object.assign() 函数,该函数可以把一个或多个源对象的属性值复制到目标对象。Object.assign() 只复制可枚举属性和属性值,但不复制属性的特性。这个结果通常都是我们想要的,但是也要清楚这个结果意味着什么。比如,它意味着如果源对象有一个访问器属性,那么复制到目标对象的是获取函数的返回值,而不是获取函数本身。示例 14-1 演示了如何使用 Object.getOwnPropertyDescriptor() 和 Object.defineProperty() 创建 Object.assign() 的一个变体,让这个变体能够复制全部属性描述符而不仅仅复制属性的值。


示例 14-1:从一个对象向另一个对象复制属性及它们的特性
[1] 原文的 Reflect.getOwnPropertyDescriptor() 是错误的。
2 对象的可扩展能力
对象的可扩展(extensible)特性控制是否可以给对象添加新属性,即是否可扩展。普通 JavaScript 对象默认是可扩展的,但可以使用本节介绍的方法修改。
要确定一个对象是否可扩展,把它传给 Object.isExtensible() 即可。要让一个对象不可扩展,把它传给 Object.preventExtensions() 即可。如此,如果再给该对象添加新属性,那么在严格模式下就会抛出 TypeErrror,而在非严格模式下则会静默失败。此外,修改不可扩展对象的原型始终都会抛出 TypeError。
注意,把对象修改为不可扩展是不可逆的(即无法再将其改回可扩展)。也要注意,调用 Object.preventExtensions() 只会影响对象本身的可扩展能力。如果给一个不可扩展对象的原型添加了新属性,则这个不可扩展对象仍然会继承这些新属性。
14.6 节还介绍了两个类似的函数:Reflect.isExtensible() 和 Reflect.preventExtensions()。
这个 extensible 特性的作用是把对象“锁定”在已知状态,阻止外部篡改。对象的 extensible 特性经常需要与属性的 configurable 和 writable 特性协同发挥作用。JavaScript 为此还定义了可以一起设置这些特性的函数。
Object.seal() 类似 Object.preventExtensions(),但除了让对象不可扩展,它也会让对象的所有自有属性不可扩展。这意味着不能给对象添加新属性,也不能删除或配置已有属性。不过,可写的已有属性依然可写。没有办法“解封”已被“封存”的对象。可以使用 Object.isSealed() 确定对象是否被封存。
Object.freeze() 会更严密地“锁定”对象。除了让对象不可扩展,让它的属性不可配置,该函数还会把对象的全部自有属性变成只读的(如果对象有访问器属性,且该访问器属性有设置方法,则这些属性不会受影响,仍然可以调用它们给属性赋值)。使用 Object.isFrozen() 确定对象是否被冻结。
对于 Object.seal() 和 Object.freeze(),关键在于理解它们只影响传给自己的对象,而不会影响该对象的原型。如果你想彻底锁定一个对象,那可能也需要封存或冻结其原型链上的对象。
Object.preventExtensions()、Object.seal() 和 Object.freeze() 全都返回传给它们的对象,这意味着可以在嵌套函数调用中使用它们:

如果你写的 JavaScript 库要把某些对象传给用户写的回调函数,为避免用户代码修改这些对象,可以使用 Object.freeze() 冻结它们。这样做虽然简单方便,但也有弊端。比如被冻结的对象可能影响常规的 JavaScript 测试策略。
3 prototype 特性
对象的 prototype 特性指定对象从哪里继承属性(更多关于原型和属性继承的内容可以参见 6.2.3 节和 6.3.2 节)。由于这个特性实在太重要了,我们平时只会说“o 的原型”,而不说“o 的 prototype 特性”。但也要记住,当 prototype 以代码字体出现时,它指的是一个普通对象的属性,而不是 prototype 特性。第 9 章解释过,构造函数的 prototype 属性用于指定通过该构造函数创建的对象的 prototype 特性。
对象的 prototype 特性是在对象被创建时设定的。使用对象字面量创建的对象使用 Object.prototype 作为其原型。使用 new 创建的对象使用构造函数的 prototype 属性的值作为其原型。而使用 Object.create() 创建的对象使用传给它的第一个参数(可能是 null)作为其原型。
要查询任何对象的原型,都可以把该对象传给 Object.getPrototypeOf():

14.6 节介绍了一个非常类似的函数:Reflect.getPrototypeOf()。
要确定一个对象是不是另一个对象的原型(或原型链中的一环),可以使用 isPrototypeOf() 方法:

注意,isPrototypeOf() 的功能与 instanceof 操作符类似(参见 1.9.4 节)。
对象的 prototype 特性在它创建时会被设定,且通常保持不变。不过,可以使用 Object.setPrototypeOf() 修改对象的原型:

一般来说很少需要使用 Object.setPrototypeOf()。JavaScript 实现可能会基于对象原型固定不变的假设实现激进的优化。这意味着如果你调用过 Object.setPrototypeOf(),那么任何使用该被修改对象的代码都可能比正常情况下慢很多。
14.6 节介绍了一个非常类似的函数:Reflect.setPrototypeOf()。
JavaScript 的一些早期浏览器实现通过proto(前后各有两个下划线)属性暴露了对象的 prototype 特性。这个属性很早以前就已经被废弃了,但网上仍然有很多已有代码依赖proto。ECMAScript 标准为此也要求所有浏览器的 JavaScript 实现都必须支持它(尽管标准并未要求,但 Node 也支持它)。在现代 JavaScript 中,proto是可读且可写的,你可以(但不应该)使用它代替 Object.getPrototypeOf() 和 Object.setPrototypeOf()。proto的一个有意思的用法是通过它定义对象字面量的原型:

4 公认符号
Symbol 类型是在 ES6 中添加到 JavaScript 中的。之所以增加这个新类型,主要是为了便于扩展 JavaScript 语言,同时又不会破坏对 Web 上已有代码的向后兼容性。第 12 章介绍了一个符号的例子,通过该例子我们知道一个类只要实现“名字”为 Symbol.iterator 符号的方法,这个类就是可迭代的。
Symbol.iterator 是最为人熟知的“公认符号”(well-known symbol)。所谓“公认符号”,其实就是 Symbol() 工厂函数的一组属性,也就是一组符号值。通过这些符号值,我们可以控制 JavaScript 对象和类的某些底层行为。接下来几节将分别介绍这些公认符号及它们的用途。
4.1 Symbol.iterator 和 Symbol.asyncIterator
Symbol.iterator 和 Symbol.asyncIterator 符号可以让对象或类把自己变成可迭代对象和异步可迭代对象。第 12 章和 13.4.2 节分别详尽介绍了这两个符号。出于完整性的考虑,这里我们只提及一下。
4.2 Symbol.hasInstance
在 4.9.4 节讲述 instanceof 操作符时,我们说过其右侧必须是一个构造函数,而表达式 o instanceof f 在求值时会在 o 的原型链中查找 f.prototype 的值,这是没有问题的,但在 ES6 及之后的版本中,Symbol.hasInstance 提供了一个替代选择。在 ES6 中,如果 instanceof 的右侧是一个有[Symbol.hasInstance]方法的对象,那么就会以左侧的值作为参数来调用这个方法并返回这个方法的值,返回值会被转换为布尔值,变成 intanceof 操作符的值。当然,如果右侧的值没有[Symbol.hasInstance]方法且是一个函数,则 instanceof 操作符仍然照常行事。
Symbol.hasInstance 意味着我们可以使用 instanceof 操作符对适当定义的伪类型对象去执行通用类型检查。例如:

注意,这个例子很巧妙,但却让人困惑。因为它使用了不是类的对象,而正常情况下应该是一个类。实际上,要写一个不依赖 Symbol.hasInstance 的 isUnit8() 函数也很容易(而且对读者来说代码也更清晰)。
4.3 Symbol.toStringTag
调用一个简单 JavaScript 对象的 toString() 方法会得到字符串"[object Object]":

如果调用与内置类型实例的方法相同的 Object.prototype.toString() 函数,则会得到一些有趣的结果:

这说明,使用这种 Object.prototype.toString().call() 技术检查任何 JavaScript 值,都可以从一个包含类型信息的对象中获取以其他方式无法获取的“类特性”。下面这个 classof() 函数无论怎么说都比 typeof 操作符更有用,因为 typeof 操作符无法区分不同对象的类型:

在 ES6 之前,Object.prototype.toString() 这种特殊的用法只对内置类型的实例有效。如果你对自己定义的类的实例调用 classof(),那只能得到“Object”。而在 ES6 中,Object.prototype.toString() 会查找自己参数中有没有一个属性的符号名是 Symbol.toStringTag。如果有这样一个属性,则使用这个属性的值作为输出。这意味着如果你自己定义了一个类,那很容易可以让它适配 classof() 这样的函数:
4.4 Symbol.species
在 ES6 之前,JavaScript 没有提供任何实际的方式去创建内置类(如 Array)的子类。但在 ES6 中,我们使用 class 和 extends 关键字就可以方便地扩展任何内置类。9.5.2 节使用下面这个简单的 Array 子类演示了这一点:
Array 定义了 concat()、filter()、map()、slice() 和 splice() 方法,这些方法仍然返回数组。在创建类似 EZArray 的数组子类时也会继承这些方法,这些方法应该返回 Array 的实例,还是返回 EZArray 的实例?两种结果似乎都有其合理的一面,但 ES6 规范认为这 5 个数组方法(默认)将返回子类的实例。
以下是实现过程。
在 ES6 及之后版本中,Array() 构造函数有一个名字为 Symbol.species 符号属性(注意这个符号是构造函数的属性名。这里介绍的其他大多数公认符号都是原型对象的方法名)。
在使用 extends 创建子类时,子类构造函数会从超类构造函数继承属性(这是除子类实例继承超类方法这种常规继承之外的一种继承)。这意味着 Array 的每个子类的构造函数也会继承名为 Symbol.species 的属性(如果需要,子类也可以用同一个名字定义自有属性)。
在 ES6 及之后的版本中,map() 和 slice() 等创建并返回新数组的方法经过了一些修改。修改后它们不仅会创建一个常规的 Array,还(实际上)会调用 new this.constructorSymbol.species 创建新数组。
接下来是最有意思的部分。假设 Array[Symbol.species]仅仅是一个常规数据属性,是按如下的方式定义的:
那么子类构造函数将作为它的“物种”(species)继承 Array() 构造函数,在数组子类上调用 map() 将返回这个超类的实例而不返回子类实例。但 ES6 中实际结果并非如此。原因在于 Array[Symbol.species]是一个只读的访问器属性,其获取函数简单地返回 this。子类构造函数继承了这个获取函数,这意味着默认情况下,每个子类构造函数都是它自己的“物种”。
不过,有时候我们可能需要修改这个默认行为。如果想让 EZArray 继承的返回数组的方法都返回常规 Array 对象,只需将 EZArray[Symbol.species]设置为 Array 即可。但由于这个继承的属性是一个只读的访问器,不能直接用赋值操作符来设置这个值。此时可以使用 defineProperty():
最简单的做法其实是在一开始创建子类时就定义自己的 Symbol.species 获取方法:
增加 Symbol.species 的主要目的就是允许更灵活地创建 Array 的子类,但这个公认符号的用途并不局限于此。定型数组与 Array 类一样,也以同样的方式使用了这个符号。类似地,ArrayBuffer 的 slice() 方法也会查找 this.constructor 的 Symbol.species 属性,而不是简单地创建新 ArrayBuffer。而返回新 Promise 对象的方法(比如 then())同样也是通过这个“物种协议”来创建返回的期约。最后,(举个例子)如果某一天你会创建 Map 的子类,并且会定义返回新 Map 对象的方法,那么为这个子类的子类考虑,或许你会用到 Symbol.species。
4.5 Symbol.isConcatSpreadable
Array 的方法 concat() 是使用 Symbol.species 确定对返回的数组使用哪个构造函数的方法之一。但 concat() 也使用 Symbol.isConcatSpreadable。7.8.3 节介绍过,数组的 concat() 方法对待自己的 this 值和它的数组参数不同于对待非数组参数。换句话说,非数组参数会被简单地追加到新数组末尾,但对于数组参数,this 数组和参数数组都会被打平或“展开”,从而实现数组元素的拼接,而不是拼接数组参数本身。
在 ES6 之前,concat() 只使用 Array.isArray() 确定是否将某个值作为数组来对待。在 ES6 中,这个算法进行了一些调整:如果 concat() 的参数(或 this 值)是对象且有一个 Symbol.isConcatSpreadable 符号属性,那么就根据这个属性的布尔值来确定是否应该“展开”参数。如果这个属性不存在,那么就像语言之前的版本一样使用 Array.isArray()。
在两种情况下可能会用到这个 Symbol。
如果你创建了一个类数组对象(参见 7.9 节),并且希望把它传给 concat() 时该对象能像真正的数组一样,那可以给这个对象添加这么一个符号属性:
Array 的子类默认是可展开的,因此如果你定义了一个数组的子类,但不希望它在传给 concat() 时像数组一样,那么可以[1] 像下面这样给这个子类添加一个获取方法:
4.6 模式匹配符号
11.3.2 节记述了使用 RegExp 参数执行模式匹配操作的 String 方法。在 ES6 及之后的版本中,这些方法都统一泛化为既能够使用 RegExp 对象,也能使用任何通过具有符号名的属性定义了模式匹配行为的对象。match()、matchAll()、search()、replace() 和 split() 这些字符串方法中的任何一个,都有一个与之对应的公认符号:Symbol.match、Symbol.search,等等。
RegExp 是描述文本模式的一种通用且强大的方式,但同时它们也比较复杂,而且也不太适合模糊匹配。有了泛化之后的字符串方法,你可以使用公认的符号方法定义自己的模式类,提供自定义匹配。例如,可以使用 Intl.Collator(参见 11.7.3 节)执行字符串比较,从而在比较时忽略重音。或者,可以基于 Soundex 算法实现一个模式类,从而根据读音的近似程度匹配单词或者近似地匹配到某个给定的莱文斯坦(Levenshtein)距离。
一般来说,在像下面这样调用上面 5 个字符串方法时:
该调用会转换为对模式对象上相应符号化命名方法的调用:
以下面这个模式匹配类为例。这个类使用我们在文件系统中熟悉的*和?通配符实现了模式匹配。这种风格的模式匹配可以追溯到 Unix 操作系统诞生之初,而模式也被称为 glob[2]:
4.7 Symbol.toPrimitive
3.9.3 节解释过 JavaScript 有 3 个稍微不同的算法,用于将对象转换为原始值。大致来讲,对于预期或偏好为字符串值的转换,JavaScript 会先调用对象的 toString() 方法。如果 toString() 方法没有定义或者返回的不是原始值,还会再调用对象的 valueOf() 方法。对于偏好为数值的转换,JavaScript 会先尝试调用 valueOf() 方法,然后在 valueOf() 没有定义或者返回的不是原始值时再调用 toString()。最后,如果没有偏好,JavaScript 会让类来决定如何转换。Date 对象首先使用 toString(),其他所有类型则首先调用 valueOf()。
在 ES6 中,公认符号 Symbol.toPrimitive 允许我们覆盖这个默认的对象到原始值的转换行为,让我们完全控制自己的类实例如何转换为原始值。为此,需要定义一个名字为这个符号的方法。这个方法必须返回一个能够表示对象的原始值。这个方法在被调用时会收到一个字符串参数,用于告诉你 JavaScript 打算对你的对象做什么样的转换。
如果这个参数是"string",则表示 JavaScript 是在一个预期或偏好(但不是必需)为字符串的上下文中做这个转换。比如,把对象作为字符串插值到一个模板字面量中。
如果这个参数是"number",则表示 JavaScript 是在一个预期或偏好(但不是必需)为数值的上下文中做这个转换。在通过<或>操作符比较对象,或者使用算术操作符-或*来计算对象时属于这种情况。
如果这个参数是"default",则表示 JavaScript 做这个转换的上下文可以接受数值也可以接受字符串。在使用+、==或!=操作符时就是这样。
很多类都可以忽略这个参数,在任何情况下都返回相同的原始值。如果你希望自己类的实例可以通过<或>来比较,那么就需要给这个类定义一个[Symbol.toPrimitive]方法。
4.8 Symbol.unscopables
最后一个要介绍的公认符号不好理解,它是针对废弃的 with 语句所导致的兼容性问题而引入的一个变通方案。我们知道,with 语句会取得一个对象,而在执行语句体时,就好像在相应的作用域中该对象的属性是变量一样。但这样一来如果再给 Array 类添加新方法就会导致兼容性问题,有可能破坏某些既有代码。Symbol.unscopables 应运而生。在 ES6 及之后的版本中,with 语句被稍微进行了修改。在取得对象 o 时,with 语句会计算 Object.keys(o[Symbol.unscopables]||{}) 并在创建用于执行语句体的模拟作用域时,忽略名字包含在结果数组中的那些属性。ES6 使用这个机制给 Array.prototype 添加新方法,同时又不会破坏线上已有的代码。这意味着可以通过如下方式获取最新 Array 方法的列表:
最后一个要介绍的公认符号不好理解,它是针对废弃的 with 语句所导致的兼容性问题而引入的一个变通方案。我们知道,with 语句会取得一个对象,而在执行语句体时,就好像在相应的作用域中该对象的属性是变量一样。但这样一来如果再给 Array 类添加新方法就会导致兼容性问题,有可能破坏某些既有代码。Symbol.unscopables 应运而生。在 ES6 及之后的版本中,with 语句被稍微进行了修改。在取得对象 o 时,with 语句会计算 Object.keys(o[Symbol.unscopables]||{}) 并在创建用于执行语句体的模拟作用域时,忽略名字包含在结果数组中的那些属性。ES6 使用这个机制给 Array.prototype 添加新方法,同时又不会破坏线上已有的代码。这意味着可以通过如下方式获取最新 Array 方法的列表:
[1] 由于 V8 JavaScript 引擎的一个 bug,这段代码在 Node 13 中将无法正确运行。
[2] glob 是 global 的简写。
5 模板标签
位于反引号之间的字符串被称为“模板字面量”,我们在 3.3.4 节介绍过。如果一个求值为函数的表达式后面跟着一个模板字面量,那就会转换为一个函数调用,而我们称其为“标签化模板字面量”。可以把定义使用标签化模板字面量的标签函数看成是元编程,因为标签化模板经常用于定义 DSL(Domain-Specific Language,领域专用语言)。而定义新的标签函数类似于给 JavaScript 添加新语法。标签化模板字面量已经被很多前端 JavaScript 包采用了。GraphQL 查询语言使用 gql*** 标签函数支持在JavaScript代码中嵌入查询。而Emotion库使用***css
标签函数支持在 JavaScript 中嵌入 CSS 样式。本节讲解如何写类似这样的标签函数。
标签函数并没有什么特别之处,它们就是普通的 JavaScript 函数,定义它们不涉及任何特殊语法。当函数表达式后面跟着一个模板字面量时,这个函数会被调用。第一个参数是一个字符串数组,然后是 0 或多个额外参数,这些参数可以是任何类型的值。
参数的个数取决于被插值到模板字面量中值的个数。如果模板字面量就是一个字符串,没有插值的位置,那么标签函数在被调用时只会收到一个该字符串的数组,没有额外的参数。如果模板字面量包含一个要插入的值,那么标签函数被调用时会收到两个参数。第一个是包含两个字符串的数组,第二个是被插入的值。第一个数组中的两个字符串:一个是插入值左侧的字符串,另一个是插入值右侧的字符串。而且这两个字符串都可能是空字符串。如果模板字面量包含两个要插入的值,那么标签函数在被调用时会收到三个参数:一个包含三个字符串的数组和两个要插入的值。数组中的三个字符串(其中任何一个甚至全部都可能是空字符串)分别是第一个插入值左侧、两个插入值之间和最后一个插入值右侧的字符串。推而广之,如果模板字面量有 n 个插值,那么标签函数在被调用时会收到 n+1 个参数。第一个参数是一个 n+1 个字符串的数组,其余 n 个参数是要插入的值,顺序为它们在模板字面量中出现的顺序。
模板字面量的值始终是一个字符串。但标签化模板字面量的值则是标签函数返回的值。这个值可能是字符串,但在标签函数被用于实现 DSL 时,返回的值通常是一个非字符串数据结构或者说是对字符串进行解析之后的表示。
作为一个返回字符串的标签函数的例子,可以看看下面这个 html``模板。这个模板可以保证向 HTML 字符串中安全地插值。在使用要插入的值构建最终字符串之前,标签会先对它们进行 HTML 转义:
下面这个例子是一个不返回字符串而返回字符串解析后表示的标签函数,其中用到了 14.4.6 节定义的 Glob 模式类。由于 Glob() 构造函数只接收一个字符串参数,我们可以定义一个标签函数来创建新 Glob 对象:
我们在 3.3.4 节曾提到过一个 String.raw``标签函数,这个函数返回字符串“未处理”(raw)的形式,不会解释任何反斜杠转义序列。这个函数使用了当时还没有讨论的标签函数调用特性实现。当标签函数被调用时,我们知道它的第一个参数是一个字符串数组。不过这个数组也有一个名为 raw 的属性,该属性的值是另一个字符串数组,数组的元素个数相同。参数数组中包含的字符串已经跟往常一样解释了转义序列。而未处理数组中包含的字符串并没有解释转义序列。如果你想定义 DSL,而文法中会使用反斜杠,那么这个不起眼的特性很重要。
例如,如果我们想让 glob``标签函数支持匹配 Windows 风格路径(使用反斜杠而不是正斜杠)的模式,也不希望用户双写每个反斜杠,那可以使用 strings.raw[]取代 strings[]来重写该函数。当然,这样做的问题在于我们不能再在 glob 字面量中使用类似\u 的转义序列。
6 反射 API
与 Math 对象类似,Reflect 对象不是类,它的属性只是定义了一组相关函数。这些 ES6 添加的函数为“反射”对象及其属性定义了一套 API。这里有一个小功能:Reflect 对象在同一个命名空间里定义了一组便捷函数,这些函数可以模拟核心语言语法的行为,复制各种既有对象功能的特性。
Reflect 函数虽然没有提供新特性,但它们用一个方便的 API 筛选出了一组特性。重点在于,这组 Reflect 函数一对一地映射了我们要在 14.7 节学习的 Proxy 处理器方法。
反射 API 包括下列函数。
Reflect.apply(f, o, args)
这个函数将函数 f 作为 o 的方法进行调用(如果 o 是 null,则调用函数 f 时没有 this 值),并传入 args 数组的值作为参数。相当于 f.apply(o, args)。
Reflect.construct(c, args, newTarget)
这个函数像使用了 new 关键字一样调用构造函数 c,并传入 args 数组的元素作为参数。如果指定了可选的 newTarget 参数,则将其作为构造函数调用中 new.target 的值。如果没有指定,则 new.target 的值是 c。
Reflect.defineProperty(o, name, descriptor)
这个函数在对象 o 上定义一个属性,使用 name(字符串或符号)作为属性名。描述符对象 descriptor 应该定义这个属性的值(或获取方法、设置方法)和特性。Reflect.defineProperty() 与 Object.defineProperty() 非常类似,但在成功时返回 true,失败时返回 false(Object.defineProperty() 成功时返回 o,失败时抛出 TypeError)。
Reflect.deleteProperty(o, name)
这个函数根据指定的字符串或符号名 name 从对象 o 中删除属性。如果成功(或指定属性不存在)则返回 true,如果无法删除该属性则返回 false。调用这个函数类似于执行 delete o[name]。
Reflect.get(o, name, receiver)
这个函数根据指定的字符串或符号名 name 返回属性的值。如果属性是一个有获取方法的访问器属性,且指定了可选的 receiver 参数,则将获取方法作为 receiver 而非 o 的方法调用。调用这个函数类似于求值 o[name]。
Reflect.getOwnPropertyDescriptor(o, name)
这个函数返回描述对象 o 的 name 属性的特性的描述符对象。如果属性不存在则返回 undefined。这个函数基本等于 Object.getOwnPropertyDescriptor(),只不这个反射 API 的版本要求第一个参数必须是对象,否则会抛出 TypeError。
Reflect.getPrototypeOf(o)
这个函数返回对象 o 的原型,如果 o 没有原型则返回 null。如果 o 是原始值而非对象,则抛出 TypeError。这个函数基本等于 Object.getPrototypeOf(),只不过 Object.getPrototypeOf() 只对 null 和 undefined 参数抛出 TypeError,且会将其他原始值转换为相应的包装对象。
Reflect.has(o, name)
这个函数在对象 o 有指定的属性 name(必须是字符串或符号)时返回 true。调用这个函数类似于求值 name in o。
Reflect.isExtensible(o)
这个函数在对象 o 可扩展(参见 14.2 节)时返回 true,否则返回 false。如果 o 不是对象则抛出 TypeError。Object.isExtensible() 与这个函数类似,但在参数不是对象时只会返回 false。
Reflect.ownKeys(o)
这个函数返回包含对象 o 属性名的数组,如果 o 不是对象则抛出 TypeError。返回数组中的名字可能是字符串或符号。调用这个函数类似于调用 Object.getOwn Property-Names() 和 Object.getOwnPropertySymbols() 并将它们返回的结果组合起来。
Reflect.preventExtensions(o)
这个函数将对象 o 的可扩展特性(参见 14.2 节)设置为 false,并返回表示成功的 true。如果 o 不是对象则抛出 TypeError。Object.preventExtensions() 具有相同的效果,但返回对象 o 而不是 true,另外对非对象参数也不抛出 TypeError。
Reflect.set(o, name, value, receiver)
这个函数根据指定的 name 将对象 o 的属性设置为指定的 value。如果成功则返回 true,失败则返回 false(如属性是只读的)。如果 o 不是对象则抛出 TypeError。如果指定的属性是一个有设置方法的访问器属性,且如果指定了可选的 receiver 参数,则将设置方法作为 receiver 而非 o 的方法进行调用。调用这个函数类似于求值 o[name] = value。
Reflect.setPrototypeOf(o, p)
这个函数将对象 o 的原型设置为 p,成功返回 true,失败返回 false(如果 o 不可扩展或操作本身会导致循环原型链)。如果 o 不是对象或 p 既不是对象也不是 null 则抛出 TypeError。Object.setPrototypeOf() 与这个函数类似,但在成功时返回 o,在失败时抛出 TypeError。注意,调用这两个函数中的任何一个都可能导致代码变慢,因为它们会破坏 JavaScript 解释器的优化。
7 代理对象
ES6 及之后版本中的 Proxy 类是 JavaScript 中最强大的元编程特性。使用它可以修改 JavaScript 对象的基础行为。14.6 节介绍的反射 API 是一组函数,通过它们可以直接对 JavaScript 对象执行基础操作。而 Proxy 类则提供了一种途径,让我们能够自己实现基础操作,并创建具有普通对象无法企及能力的代理对象。
创建代理对象时,需要指定另外两个对象,即目标对象(target)和处理器对象(handlers):
得到的代理对象没有自己的状态或行为。每次对它执行某个操作(读属性、写属性、定义新属性、查询原型、把它作为函数调用)时,它只会把相应的操作发送给处理器对象或目标对象。
代理对象支持的操作就是反射 API 定义的那些操作。假设 p 是一个代理对象,我们想执行 delete p.x。而 Reflect.deleteProperty() 函数具有与 delete 操作符相同的行为。当使用 delete 操作符删除代理对象上的一个属性时,代理对象会在处理器对象上查找 deleteProperty() 方法。如果存在这个方法,代理对象就调用它。如果不存在这个方法,代理对象就在目标对象上执行属性删除操作。
对所有基础操作,代理都这样处理:如果处理器对象上存在对应方法,代理就调用该方法执行相应操作(这里方法的名字和签名与 14.6 节介绍的反射函数完全相同)。如果处理器对象上不存在对应方法,则代理就在目标对象上执行基础操作。这意味着代理可以从目标对象或处理器对象获得自己的行为。如果处理器对象是空的,那代理本质上就是目标对象的一个透明包装器:
这种透明包装代理本质上就是底层目标对象,这意味着没有理由使用代理来代替包装的对象。然而,透明包装器在创建“可撤销代理”时有用。创建可撤销代理不使用 Proxy() 构造函数,而要使用 Proxy.revocable() 工厂函数。这个函数返回一个对象,其中包含代理对象和一个 revoke() 函数。一旦调用 revoke() 函数,代理立即失效:
注意,除了演示可撤销代理,前面的代码也演示了代理既可以封装目标函数也可以封装目标对象。但这里的关键是可撤销代理充当了某种代码隔离的机制,而这可以在我们使用不信任的第三方库时派上用场。如果必须向一个不受自己控制的库传一个函数,则可以给它传一个可撤销代理,然后在使用完这个库之后撤销代理。这样可以防止第三方库持有对你函数的引用,在你不知道的时候调用它。这种防御型编程并非 JavaScript 程序特有的,但 Proxy 类让它成为可能。
如果我们给 Proxy() 构造函数传一个非空的处理器对象,那定义的就不再是一个透明包装器对象了,而是要在代理中实现自定义行为。有了恰当自定义的处理器,底层目标对象本质上就变得不相干了。
例如,在下面的代码中,我们实现了一个对象,让它看起来好像有无数个只读属性,而每个属性的值就是属性的名字:

代理对象可以从目标对象和处理器对象获得它们的行为,到目前为止我们看到的示例都只用到它们其中一个。而同时用到这两个对象的代理通常才更有用。
例如,下面的代码为目标对象创建了一个只读包装器。当代码尝试从该对象读取值时,读取操作会正常转发给目标对象。但当代码尝试修改对象或它的属性时,处理器对象的方法会抛出 TypeError。类似这样的代理在编写测试的时候有用。假设你写了一个函数,它接收一个对象参数,你希望这个函数不会以任何方式修改它收到的参数。如果你的测试接收只读的包装器对象,那么任何写入操作都会抛出异常从而导致测试失败:


另一种使用代理的技术是为它定义处理器方法,拦截对象操作,但仍然把操作委托给目标对象。反射 API(参见 14.6 节)的函数与处理器方法具有完全相同的签名,从而实现这种委托也很容易。
例如,下面这个函数返回的代理会把所有操作都委托给目标对象,只通过处理器方法打印出执行了什么操作:


前面定义的 loggingProxy() 函数创建的代理可以把使用对象的各种操作打印出来。如果你想知道某个没有文档的函数怎么使用你传给它的对象,那就创建这样一个日志代理吧。
来看下面的例子,通过日志可以看到数组迭代的真正过程:


根据第一段日志输出,我们知道 Array.map() 方法会先检查每个数组元素是否存在(因为调用了 has() 处理器),然后才真正读取元素的值(此时触发 get() 处理器)。由此可以推断,它能够区分不存在的和存在但值为 undefined 的数组元素。
第二段日志输出可以提醒我们,传给 Array.map() 的函数在被调用时会收到 3 个参数:元素的值、元素的索引和数组本身(这个日志输出有个问题:Array.toString() 方法的返回值不包含方括号,如果输出的参数列表是(10, 0, [10, 20]) 就更清楚了)。
第三段日志输出告诉我们,for/of 循环依赖于一个符号名为[Symbol.iterator]的方法。同时也表明,Array 类对这个迭代器方法的实现在每次迭代时都会检查数组长度,并没有假定数组长度在迭代过程中保持不变。
7.1 代理不变式
前面定义的 readOnlyProxy() 函数创建的代理对象实际上是冻结的,即修改属性值或属性特性,添加或删除属性,都会抛出异常。但是,只要目标对象没有被冻结,那么通过 Reflect.isExtensible() 和 Reflect.getOwnPropertyDescriptor() 查询代理对象,都会告诉我们应该可以设置、添加或删除属性。也就是说,readOnlyProxy() 创建的对象与目标对象的状态不一致。为此,可以再添加 isExtensible() 和 getOwnProperty Descriptor() 处理器来消除一致,或者也可以保留这种轻微的不一致。
代理处理器 API 允许我们定义存在重要不一致的对象,但在这种情况下,Proxy 类本身会阻止我们创建不一致得离谱的代理对象。本节一开始,我们说代理是一种没有自己的行为的对象,因为它们只负责把所有操作转发给处理器对象和目标对象。其实这么说也不全对:转发完操作后,Proxy 类会对结果执行合理性检查,以确保不违背重要的 JavaScript 不变式(invariant)。如果检查发现违背了,代理会抛出 TypeError(不让操作继续)。
举个例子,如果我们为一个不可扩展对象创建了代理,而它的 isExtensible() 处理器返回 true,代理就会抛出 TypeError:

相应地,不可扩展目标的代理就不能定义返回目标真正原型之外其他值的 getProto-typeOf() 处理器。同样,如果目标对象的某个属性不可写、不可配置,那么如果 get() 处理器返回了跟这个属性的实际值不一样的结果,Proxy 类也会抛出 TypeError:

Proxy 还遵循其他一些不变式,几乎都与不可扩展的目标对象和目标对象上不可配置的属性有关。
我的图床


































