如果说常规编程是写代码去操作数据,那么元编程就是写代码去操作其他代码。在像 JavaScript 这样的动态语言中,编程与元编程之间的界限是模糊的。在更习惯于静态语言的程序员眼里,即便是迭代对象属性的 for/in
循环这个小小的能力都可能被打上 元
标签。
属性的特性
JavaScript 的属性有名字和值,但每个属性也有 3 个关联的特性,用于指定属性的行为以及你可以对它执行什么操作:
- 可写(writable)特性:指定是否可以修改属性的值
- 可枚举(enumerable)特性:指定是否可以通过
for/in
循环返回属性 - 可配置(configurable)特性:指定是否可以删除属性,以及是否可以修改属性的特性
对象字面量中定义的属性,或者通过常规赋值方式给对象定义的属性都可写、可枚举和可配置。之前说过,数据属性
有一个值 ,而 访问器属性
有一个获取方法和设置方法。我们可以把 value、get 和 set 方法都作为属性的特性来看待:
- 数据属性的 4 个特性是 value、writable、enumerable 和 configurable
- 访问器属性的 4 个特性是 get、set、enumerable 和 configurable
用于查询和设置属性特性的 JavaScript 方法使用一个被称为属性描述符(property descriptor
)的对象,这个对象用于描述属性的 4 个特性。属性描述符对象拥有与它所描述的属性的特性相同的属性名。
要获得特定对象某个属性的属性描述符,可以调用 Object.getOwnPropertyDescriptor()
。Object.getOwnPropertyDescriptor()
只对自有属性有效,对继承的属性或者不存在的属性返回 undefined。
1 | > Object.getOwnPropertyDescriptor({x:1}, "x") |
要设置属性的特性或者要创建一个具有指定特性的属性,可以调用 Object.defineProperty()
方法:传入要修改的对象、要创建或修改的属性的名字,以及属性描述符对象。
1 | let o = {}; |
传给 Object.defineProperty()
的属性描述符不一定 4 个特性都包含:
- 如果是创建新属性,那么省略的特性会取得 false 或 undefined 值
- 如果是修改已有的属性,那么省略的特性就不会被修改
这个方法只修改已经存在的自有属性,或者创建新的自有属性,不会修改继承的属性。如果想一次性创建或修改多个属性,可以使用 Object.defineProperties()
:
- 第一个参数是要修改的对象
- 第二个参数也是一个对象,该对象将要创建或修改的属性的名称映射到这些属性的属性描述符
1 | > let p = Object.defineProperties({}, {x: {value:1, enumerable: true}, y: {value:2, enumerable: true}}) |
之前介绍过 Object.create()
方法,这个方法的第一个参数是新创建对象的原型对象。这个方法也接收第二个可选的参数,该参数与 Object.defineProperties()
的第二个参数一样。给 Object.create()
传入一组属性描述符,可以为新创建的对象添加属性。
Object.defineProperty()
、Object.defineProperties()
都是返回修改后的对象。当违反如下规则时,就会抛出 TypeError
异常:
- 如果对象不可扩展,可以修改其已有属性,但不能给它添加新属性
- 如果属性不可配置,不能修改其 configurable 或 enumerable 特性
- 如果访问器属性不可配置,不能修改其获取方法或设置方法,也不能把它修改为数据属性
- 如果数据属性不可配置,不能把它修改为访问器属性
- 如果数据属性不可配置,不能把它的 writable 特性由 false 修改为true,但可以由 true 修改为 false
- 如果数据属性不可配置且不可写,则不能修改它的值。不过,如果这个属性可配置但不可写,则可以修改它的值(相当于先把它配置为可写,然后修改它的值,再把它配置为不可写)
另外需要注意,之前介绍过 Object.assign()
函数,该函数可以把一个或多个源对象的属性值复制到目标对象。Object.assign()
只复制可枚举属性和属性值,但不复制属性的特性。这意味着如果源对象有一个访问器属性,那么复制到目标对象的是获取函数的返回值,而不是获取函数本身。
如下实现了一个 Object.assign()
的变体,它能够复制全部属性描述符而不仅仅复制属性值。
1 | Object.defineProperty(Object, "assignDescriptors", { |
对象的可扩展能力
对象的可扩展 extensible
特性控制是否可以给对象添加新属性,即是否可扩展。普通 JavaScript 对象默认是可扩展的。
- 要确定一个对象是否可扩展,把它传给
Object.isExtensible()
即可 - 要让一个对象不可扩展,把它传给
Object.preventExtensions()
即可 - 修改不可扩展对象的原型始终都会抛出 TypeError
把对象修改为不可扩展是不可逆的(即无法再将其改回可扩展)。另外,调用 Object.preventExtensions()
只会影响对象本身的可扩展能力。如果给一个不可扩展对象的原型添加了新属性,则这个不可扩展对象仍然会继承这些新属性。
extensible
特性的作用是把对象 锁定
在已知状态,阻止外部篡改。对象的 extensible 特性经常需要与属性的 configurable 和 writable 特性协同发挥作用。JavaScript 定义了一些函数,可以一起设置这些特性:
Object.seal()
把对象标记为不可扩展,并把所有自有属性的 configurable 特性设置为 false。这意味着不能给对象添加新属性,也不能删除或配置已有属性。不过,可写的已有属性依然可写。可以使用Object.isSealed()
确定对象是否被封存Object.freeze()
除了让对象不可扩展、让它的属性不可配置外,该函数还会把对象的全部自有属性变成只读的(如果对象有访问器属性,且该访问器属性有设置方法,则这些属性不会受影响,仍然可以调用它们给属性赋值)。可以使用Object.isFrozen()
确定对象是否被冻结
对于 Object.seal()
和 Object.freeze()
,关键在于理解它们只影响传给自己的对象,而不会影响该对象的原型。如果你想彻底锁定一个对象,那可能也需要封存或冻结其原型链上的对象。
Object.preventExtensions()
、Object.seal()
和 Object.freeze()
全都返回传给它们的对象,这意味着可以在嵌套函数调用中使用它们。
如果你写的 JavaScript 库要把某些对象传给用户写的回调函数,为避免用户代码修改这些对象,可以使用 Object.freeze()
冻结它们。
prototype 特性
对象的 prototype 特性指定对象从哪里继承属性,这个特性非常重要,平常我们只会说 o 的原型
,而不会说 o 的 prototype 特性
。注意,一定要区分对象的 prototype 特性(即对象的原型)和对象的 prototype 属性。之前介绍过,构造函数的 prototype 属性用于指定通过该构造函数创建的对象的 prototype 特性(即指定所创建对象的原型)。
对象的 prototype 特性是在对象被创建时设定的:
- 使用对象字面量创建的对象使用 Object.prototype 作为其原型
- 使用 new 创建的对象使用构造函数的 prototype 属性的值作为其原型
- 而使用 Object.create() 创建的对象使用传给它的第一个参数(可能是 null )作为其原型
要查询任何对象的原型,都可以把该对象传给 Object.getPrototypeOf()
:
1 | > Object.getPrototypeOf({}) === Object.prototype |
要确定一个对象是不是另一个对象的原型(或原型链中的一环),可以使用 isPrototypeOf() 方法:
1 | > let p = {x: 1}; |
对象的 prototype 特性在它创建时会被设定,且通常保持不变。不过,可以使用Object.setPrototypeOf()修改对象的原型:
1 | > let o = {x: 1} |
JavaScript 的一些早期浏览器实现通过 __proto__
(前后各有两个下划线)属性暴露了对象的 prototype 特性。这个属性很早以前就已经被废弃了,但网上仍然有很多已有代码依赖 __proto__
。ECMAScript 标准为此也要求所有浏览器的 JavaScript 实现都必须支持它(尽管标准并未要求,但 Node 也支持它)。
在现代 JavaScript 中,__proto__
是可读且可写的,你可以使用它代替 Object.getPrototypeOf()
和 Object.setPrototypeOf()
(但是不推荐):
1 | > o.__proto__ |
公认符号
Symbol 类型是在 ES6 中添加到 JavaScript 中的。之所以增加这个新类型,主要是为了便于扩展 JavaScript 语言,同时又不会破坏对已有代码的向后兼容性。
Symbol.iterator
是最为人熟知的 公认符号
(well-known symbol)。所谓 公认符号
,其实就是 Symbol()
工厂函数的一组属性,也就是一组符号值。通过这些符号值,我们可以控制 JavaScript 对象和类的某些底层行为。
Symbol.iterator 和 Symbol.asyncIterator
Symbol.iterator
和 Symbol.asyncIterator
符号可以让对象或类把自己变成可迭代对象和异步可迭代对象,之前已经详细介绍过。
Symbol.hasInstance
之前介绍过 instanceof 运算符,其右侧必须是一个构造函数,而表达式 o instanceof f
在求值时会在 o 的原型链中查找 f.prototype 的值。在 ES6 及之后的版本中,Symbol.hasInstance
提供了一个替代选择:
- instanceof 的右侧是一个有
Symbol.hasInstance
方法的对象,那么就会以左侧的值作为参数来调用这个方法并返回这个方法的值,返回值会被转换为布尔值,变成 intanceof 操作符的值
Symbol.hasInstance
意味着我们可以使用 instanceof 操作符对适当定义的伪类型对象去执行通用类型检查。
1 | // 定义了一个作为 `类型` 的对象 |
Symbol.toStringTag
调用一个简单 JavaScript 对象的 toString() 方法会得到字符串 [object Object]
:
1 | > {}.toString() |
但是如果在内置类型实例上调用 Object.prototype.toString()
函数,会得到一些有趣的结果:
1 | > Object.prototype.toString.call({}) |
使用这种 Object.prototype.toString().call()
技术检查任何 JavaScript 值,都可以从一个包含类型信息的对象中获取以其他方式无法获取的“类特性”。
下面实现的 classof 函数比 typeof 操作符更有用,因为 typeof 操作符无法区分不同对象的类型:
1 | function classof(o) { |
- 在 ES6 之前,
Object.prototype.toString()
这种特殊的用法只对内置类型的实例有效。如果你对自己定义的类的实例调用classof()
,那只能得到Object
- 在 ES6 中,
Object.prototype.toString()
会查找自己参数中有没有一个属性的符号名是 Symbol.toStringTag,如果有这样一个属性,则使用这个属性的值作为输出
这意味着如果你自己定义了一个类,那很容易可以让它适配 classof()
这样的函数:
1 | class Range { |
Symbol.species
在ES6之前,JavaScript 没有提供任何实际的方式去创建内置类(如Array)的子类。但在 ES6 中,我们使用 class 和 extends 关键字就可以方便地扩展任何内置类。当我们定义 Array 的子类时,会继承 map()、slice() 等方法,这些方法的特点是会返回一个数组。那这些方法应该返回 Array 的实例,还是返回子类的实例呢?
以下是实现过程:
- 在 ES6 及之后版本中,Array() 构造函数有一个名字为 Symbol.species 符号属性(注意是构造函数的属性)
- 在使用 extends 创建子类时,子类构造函数会从超类构造函数继承属性(这是除子类实例继承超类方法这种常规继承之外的一种继承)。这意味着 Array 的每个子类的构造函数也会继承名为
Symbol.species
的属性 - ES6 及之后版本中,map()、slice() 等方法,会调用
new this.constructor[Symbol.species]()
创建新数组 Array[Symbol.species]
是一个只读的访问器属性,其获取函数简单地返回 this。由于子类继承该属性,因此子类构造函数继承了这个获取函数,这意味着默认情况下,每个子类构造函数都是它自己的物种
。
有时候我们可能需要修改这个默认行为,例如返回常规 Array 对象。只需要重新设置子类构造函数的 名为 Symbol.species
属性即可:但由于这个继承的属性是一个只读的访问器,不能直接用赋值操作符来设置这个值。需要用到 defineProperty()
方法:
1 | Object.defineProperty(EZArray, Symbol.species, {value: Array}); |
或者直接在创建子类时,就定义自己的 Symbol.species
获取方法(注意定义为 static 函数):
1 | class EZArray extends Array { |
除了 Array,像定型数组、Promise 都使用这种 物种协议
来决定某些方法的返回值是父类还是子类的实例。
Symbol.isConcatSpreadable
之前在介绍数组的 concat 方法时介绍过,数组的 concat()
方法对待自己的 this 值和它的数组参数不同于对待非数组参数。
- 非数组参数会被简单地追加到新数组末尾
- 但对于数组参数,this数组和参数数组都会被打平或
展开
,从而实现数组元素的拼接,而不是拼接数组参数本身
1 | > [].concat([1,2,3], 4, 5) |
在 ES6 之前,concat()
只使用 Array.isArray()
确定是否将某个值作为数组来对待。在 ES6 中,这个算法进行了一些调整:
- 如果
concat()
的参数(或 this 值)是对象且有一个Symbol.isConcatSpreadable
符号属性,那么就根据这个属性的布尔值来确定是否应该展开
参数 - 如果这个属性不存在,那么就像语言之前的版本一样使用
Array.isArray()
1 | > let arrayLike = {length:1, 0:1, [Symbol.isConcatSpreadable]: true}; |
模式匹配符号
之前介绍过使用 RegExp 参数
执行模式匹配操作的 String 方法。在 ES6 及之后的版本中,这些方法都统一泛化为既能够使用 RegExp 对象,也能使用任何 通过特定名称的属性定义了模式匹配行为
的对象。match()
、matchAll()
、search()
、replace()
和 split()
方法都有对应的公认符号:Symbol.match
、Symbol.matchAll
等等。
一般来说,在像下面这样调用上面的字符串方法时:
1 | string.method(pattern, arg); |
该调用会转换为对模式对象上相应符号化命名方法的调用:
1 | pattern[Symbol.method](string, arg); |
Symbol.toPrimitive
之前介绍过,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.unscopables
Symbol.unscopables 是针对废弃的 with 语句所导致的兼容性问题而引入的一个变通方案。with 语句会取得一个对象,而在执行语句体时,就好像在相应的作用域中该对象的属性是变量一样。这样当给 Array 类添加新的方法时可能会导致兼容性问题,有可能破坏某些既有代码。
在 ES6 及之后的版本中,with 语句被稍微进行了修改。在取得对象 o 时,with 语句会计算 Object.keys(o[Symbol.unscopables]||{})
并在创建用于执行语句体的模拟作用域时,忽略名字包含在结果数组中的那些属性。
ES6 使用这个机制给 Array.prototype
添加新方法,同时又不会破坏线上已有的代码。这意味着可以通过如下方式获取最新 Array 方法的列表:
1 | > Object.keys(Array.prototype[Symbol.unscopables]) |
模版标签
位于反引号之间的字符串被称为 模板字面量
,之前介绍过,如果一个求值为函数的表达式后面跟着一个模板字面量,那就会转换为一个函数调用,而我们称其为 标签化模板字面量
。可以把定义使用标签化模板字面量的标签函数看成是元编程,因为标签化模板经常用于定义 DSL(Domain-Specific Language,领域专用语言)。而定义新的标签函数类似于给 JavaScript 添加新语法。
标签函数并没有什么特别之处,它们就是普通的 JavaScript 函数,定义它们不涉及任何特殊语法。当函数表达式后面跟着一个模板字面量时,这个函数会被调用:
- 第一个参数是一个字符串数组
- 然后是 0 或多个额外参数,这些参数可以是任何类型的值。
- 参数的个数取决于被插值到模板字面量中值的个数
- 字符串数组的参数是以差值进行分割的(例如如果模版字面量包含一个插值,那么数组参数中会包含两个字符串,一个是插入值左侧的字符串,另一个是插入值右侧的字符串。而且这两个字符串都可能是空字符串)
如下是一个 html`` 模版的示例:
1 | function html(strings, ...values) { |
当标签函数被调用时,它的第一个参数是一个字符串数组。不过这个数组也有一个名为 raw 的属性,该属性的值是另一个字符串数组,数组元素个数保持不变:
- 参数数组中包含的字符串和往常一样解释了转义序列
- 而未处理数组中包含的字符串并没有解释转义序列
反射 API
与 Math 对象类似,Reflect 对象不是类,它的属性只是定义了一组相关函数。这些添加的函数为 反射
对象及其属性定义了一套 API。Reflect 函数虽然没有提供新特性,但它们在一个命名空间下提供了一组 API 来模拟核心语言语法的行为、以及各种已经存在的 Object
函数:
-
Reflect.apply(f, o, args)
:这个函数将函数 f 作为 o 的方法进行调用,并传入 args 数组的值作为参数。相当于f.apply(o, args)
-
Reflect.construct(c, args, newTarget)
:这个函数像使用了 new 关键字一样调用构造函数 c,并传入 args 数组的元素作为参数 -
Reflect.defineProperty(o, name, descriptor)
:这个函数在对象 o 上定义一个属性,使用 name(字符串或符号)作为属性名。描述符对象 descriptor 应该定义这个属性的特性 -
Reflect.deleteProperty(o, name)
:这个函数根据指定的字符串或符号名 name 从对象 o 中删除属性。 -
Reflect.get(o, name, receiver)
:这个函数根据指定的字符串或符号名 name 返回属性的值。如果属性是一个有获取方法的访问器属性,且指定了可选的 receiver 参数,则将获取方法作为 receiver 而非 o 的方法调用。调用这个函数类似于求值o[name]
-
Reflect.set(o, name, value, receiver)
:这个函数根据指定的 name 将对象 o 的属性设置为指定的value。如果指定的属性是一个有设置方法的访问器属性,且如果指定了可选的 receiver 参数,则将设置方法作为receiver 而非 o 的方法进行调用 -
Reflect.getOwnPropertyDescriptor(o, name)
:这个函数返回描述对象 o 的 name 属性的特性的描述符对象 -
Reflect.getPrototypeOf(o)
:这个函数返回对象o的原型,如果o没有原型则返回null。如果o是原始值而非对象,则抛出TypeError- 这个函数基本等于
Object.getPrototypeOf()
,只不过Object.getPrototypeOf()
只对null
和undefined
参数抛出TypeError
,且会将其他原始值转换为相应的包装对象
- 这个函数基本等于
-
Reflect.has(o, name)
:这个函数在对象 o 有指定的属性 name(必须是字符串或符号)时返回 true -
Reflect.isExtensible(o)
:这个函数在对象 o 可扩展时返回 true,否则返回 false。如果 o 不是对象则抛出 TypeError -
Relfect.ownKeys(o)
:这个函数返回包含对象 o 属性名的数组,如果 o 不是对象则抛出TypeError -
Reflect.preventExtensions(o)
:这个函数将对象 o 的可扩展特性设置为 false,并返回表示成功 true。如果 o 不是对象则抛出 TypeError -
Reflect.setPrototypeOf(o, p)
:这个函数将对象 o 的原型设置为 p
代理对象
ES6 及之后版本中的 Proxy 类是 JavaScript 中最强大的元编程特性。使用它可以修改 JavaScript 对象的基础行为。Proxy 类则提供了一种途径,让我们能够自己实现基础操作,并创建具有普通对象无法企及能力的代理对象。
创建代理对象时,需要指定另外两个对象,即 目标对象
(target)和 处理器对象
(handlers):
1 | let proxy = new Proxy(target, handlers); |
得到的代理对象没有自己的状态或行为。每次对它执行某个操作(读属性、写属性、定义新属性、查询原型、把它作为函数调用)时,它只会把相应的操作发送给处理器对象或目标对象。代理对象支持的操作就是反射 API 定义的那些操作。对所有基础操作,代理都这样处理:
- 如果处理器对象上存在对应方法,代理就调用该方法执行相应操作
- 如果处理器对象上不存在对应方法,则代理就在目标对象上执行基础操作
这意味着代理可以从目标对象或处理器对象获得自己的行为。如果处理器对象是空的,那代理本质上就是目标对象的一个透明包装器:
1 | > let t = {x:1, y:2} |
透明包装代理本质上就是底层目标对象,这意味着没有理由使用代理来代替包装的对象。但是,透明包装器在创建 可撤销代理
时有用。创建可撤销代理不使用 Proxy()
构造函数,而要使用 Proxy.revocable()
工厂函数。这个函数返回一个对象,其中包含代理对象和一个 revoke() 函数。一旦调用 revoke()
函数,代理立即失效:
1 | > function accessTheDatabase() { return 42; } |
可撤销代理充当了某种代码隔离的机制,而这可以在我们使用不信任的第三方库时派上用场。
如果我们给 Proxy()
构造函数传一个非空的处理器对象,那定义的就不再是一个透明包装器对象了,而是要在代理中实现自定义行为。例如如下代码为目标对象创建一个只读包装器:
1 | function readOnlyProxy(o) { |
另一种使用代理的技术是为它定义处理器方法,拦截对象操作,但仍然把操作委托给目标对象。另一种使用代理的技术是为它定义处理器方法,拦截对象操作,但仍然把操作委托给目标对象。反射API的函数与处理器方法具有完全相同的签名,从而实现这种委托也很容易。
1 | function loggingProxy(o, objname) { |
代理不变式
之前通过 readOnlyProxy 函数创建的代理对象实际上是冻结的,即修改属性值或属性特性,添加或删除属性,都会抛出异常。但是,只要目标对象没有被冻结,那么通过 Reflect.isExtensible()
和 Reflect.getOwnPropertyDescriptor()
查询代理对象,都会告诉我们应该可以设置、添加或删除属性。也就是说,readOnlyProxy()
创建的对象处于不一致的状态。
之前说过,代理是一种没有自己的行为的对象,因为它们只负责把所有操作转发给处理器对象和目标对象。其实这么说也不全对:转发完操作后,Proxy 类会对结果执行合理性检查,以确保不违背重要的 JavaScript 不变式(invariant)。如果检查发现违背了,代理会抛出 TypeError。
如下是一个例子:
1 | > let target = Object.preventExtensions({}); |
在这个例子中,为一个不可扩展对象创建了代理,而它的 isExtensible()
处理器返回true,代理就会抛出 TypeError。Proxy还遵循其他一些不变式,几乎都与不可扩展的目标对象和目标对象上不可配置的属性有关。