跳到主要内容

元编程

如果说常规编程是写代码去操作数据,那么 元编程 就是写代码去操作其他代码。

属性的特性

数据属性 的 4 个特性为 value、writable、enumerable、configurable :

  • value(值)指定属性的值

  • writable(可写)指定是否可以设置属性的值;

  • enumerable(可枚举)指定是否可以在 for-in 循环及 Object.keys() 等方法中枚举该属性;

  • configurable(可配置)指定是否可以删除属性,或修改属性特性。

访问器属性 没有值,但是有获取/设置方法,因此它的 4 个特性为 get、set、enumerable、configurable 。

用于查询和设置属性特性的 JS 方法是使用一个属性描述符( property descriptor )对象,这个对象包含属性的 4 个特性。其中 writable、enumerable、configurable 为布尔值,get、set 为函数值。

可以用 Object.getOwnPropertyDescriptor() 获取(自有)属性的属性描述符:

// 返回 {value: 1, writable: true, enumerable: true, configurable: true}
Object.getOwnPropertyDescriptor({ x: 1 }, "x")

// 对于不存在的或是继承来的属性都会返回 undefined
Object.getOwnPropertyDescriptor({}, "x") // => undefined
Object.getOwnPropertyDescriptor({}, "toString") // => undefined

可以用 Object.defineProperty() 方法来创建或修改属性:

let o = {}

// 创建一个不可枚举的属性 "x" ,值为 1 :
Object.defineProperty(o, "x", {
value: 1,
writable: true,
enumerable: false,
configurable: true
})

o.x // => 1
Object.keys(o) // => []

// 修改 "x" 属性,设为只读:
Object.defineProperty(o, "x", { writable: false })
o.x = 2 // 静默失败或者严格模式下抛出 TypeError
o.x // => 1

// 但是 "x" 依然是可配置的,即可以这样修改它的值:
Object.defineProperty(o, "x", { value: 2 })
o.x // => 2

// 也可以把 "x" 修改为访问器属性:
Object.defineProperty(o, "x", { get() { return 0 } })
o.x // => 0

使用 Object.defineProperty() 不需要完整传入 4 个特性,它们有自己的默认值:对于布尔值为 false ,其余则为 undefined

一次性处理多个属性可以用 Object.defineProperties()

let p = Object.defineProperties({}, {
x: { value: 1 },
y: { value: 1, enumerable: true },
r: { get() { return 0 } }
})

p.r // => 0

Object.defineProperty()Object.defineProperties() 均返回修改后的对象。如果创建或修改属性的行为是不被允许的,这两个方法均会抛出 TypeError(即便是非严格模式)。以下是详细的行为规则:

  • 如果对象不可扩展,则可以修改但不可添加属性;

  • 如果属性不可配置,则不能修改 enumerable、configurable 特性;

  • 如果数据属性不可配置,则不能修改为访问器属性;

  • 如果访问器属性不可配置,则不能修改 get、set 方法,且不能修改为数据属性;

  • 如果数据属性不可配置,则其 writable 特性只能由 true 改为 false

  • 如果数据不可配置不可写,则不能修改 value 。但是如果可配置不可写,则可以修改 value 。

Object.create() 方法中,除了传入一个作为原型的对象,还可以传入第二个可选参数,该参数与 Object.defineProperties() 的第二个参数一样,传入一组属性描述符。

Object.assign() 只会复制源对象可枚举的属性,且会使用属性的 get 方法,而不会复制属性的特性。比如对于一个访问器属性,则会获取其 get 方法的返回值,而不是 get 方法本身。

想要写一个能够复制属性描述符的函数可见书 P344 。

对象的可扩展能力

对象的可扩展( extensible )特性控制是否可以为对象添加新属性。

普通 JS 对象默认是可扩展的,或者可以用 Object.isExtensible() 进行查询 。可以用 Object.preventExtensions() 让对象不可扩展,在这种情况下给对象添加属性,则会静默失败或者严格模式下抛出 TypeError 。修改不可扩展对象的原型指向始终会抛出 TypeError 。

把对象修改为不可扩展的操作是 不可逆 的,但不可扩展只影响对象本身,对象的原型对象不会被影响。

还有一些 Object 类方法来跟深层次地控制对象:

  • Object.seal() 除了设置对象为不可扩展,也不能删除已有属性,同时所有属性会被设为不可配置。这种情况下可写的属性依旧可写。被 "封存" 的对象没有办法解封,但可以通过 Object.isSealed() 查询对象是否被封存。

  • Object.freeze() 会在 Object.seal() 的基础上将属性设置为只读(但如果是访问器属性且有 set 方法,则不会影响其调用)。该方法通常用于避免用户代码修改对象。可以用 Object.isFrozen() 查询对象是否被冻结。

Object.preventExtensions()Object.seal()Object.freeze() 均会返回被修改的对象。

prototype 特性

对象会有个 prototype 特性(一般浏览器会设为 __proto__ 属性),指定对象从哪里继承属性。该特性会在对象被创建时设定,使用对象字面量会用 Object.prototype 作为原型,而用 new 创建的对象会用构造函数的 prototype 属性作为原型,使用 Object.create() 创建的对象则会以传入的第一个参数作为原型。

查询对象的原型可以用 Object.getPrototypeOf()

Object.getPrototypeOf({})         // => Object.prototype
Object.getPrototypeOf([]) // => Array.prototype
Object.getPrototypeOf(() => {}) // => Function.prototype

查询一个对象是否为另一个对象的原型,可以用 Object 的 isPrototypeOf() 方法:

let p = { x: 1 }
let o = Object.create(p)
p.isPrototypeOf(o) // => true
Object.prototype.isPrototypeOf(o) // => true

isPrototypeOf() 方法与 instanceof 操作符类似。

也可以用 Object.setPrototypeOf(o, p) 方法来修改对象的原型。它会将 o 的原型指向 p 。实际中很少场景会用到这个方法。

由于历史原因,ES 标准要求浏览器通过 __proto__ 属性来暴露 prototype 特性,Node 也同样支持。但在实际开发中应当避免对 __proto__ 属性产生依赖。

公认符号

公认符号,即为 Symbol 的一些静态属性,用来定义一些 JS 对象或类的底层行为。

Symbol.iterator & Symbol.asyncIterator

这两个符号可以让对象或类把自己变为可迭代对象和异步可迭代对象。

Symbol.hasInstance

在使用 instanceof 操作符时,右侧应当是一个构造函数。在 o instanceof f 求值时会在 o 的原型链中查找 f.prototype 的值。

但在 ES6 及后中,instanceof 会先检查右值对象是否有 [Symbol.hasInstance] 方法。如果有,则会调用该方法,并将左值作为参数传入,返回该方法的返回值:

let uint8 = {
[Symbol.hasInstance](x) {
return Number.isInteger(x) && x >= 0 && x <= 255
}
}

128 instanceof uint8 // => true
256 instanceof uint8 // => false
Math.PI instanceof uint8 // => false

Symbol.toStringTag

调用一个简单的 JS 对象,或者自定义类的对象的 toString() 方法,都会得到 "[object Object]" 。而调用内置类型的对象则会有具体的类型名:

Object.prototype.toString.call([])      // => "[object Array]"
Object.prototype.toString.call(/./) // => "[object RegExp]"
Object.prototype.toString.call(false) // => "[object Boolean]"

我们可以写一个简单的获取类型名的函数:

function classof(obj) {
return Object.prototype.toString.call(obj).slice(8, -1)
}

classof(null) // => "Null"
classof(10n) // => "BigInt"

在 ES6 及后中,Object.prototype.toString() 会先查找 [Symbol.toStringTag] 属性,如果有则使用它进行返回。这意味着自定义的类可以指定其在 toString() 中的行为:

class SomeType {
get [Symbol.toStringTag]() { return "SomeType" }
}

Object.prototype.toString.call(new SomeType()) // => '[object SomeType]'

Symbol.toPrimitive

在 ES6 中,Symbol.toPrimitive 允许我们覆盖对象的转换行为,即 toString()valueOf() 方法。该符号方法会传入一个字符串参数,参数值可能为:

  • "string" 希望转为一个字符串;

  • "number" 希望转为一个数值;

  • "default" 可以是字符串或者数值。

const obj = {
[Symbol.toPrimitive](hint) {
return hint === "number" ? 42 : "42"
}
};

-obj // => -42
obj + "12" // => '4212'

Symbol.species

见书上 P349-351 ,或 Symbol.species - MDN

Symbol.isConcatSpreadable

在 ES6 前,Array 的 concat() 方法会先依据 Array.isArray() 来判断参数是否作为数组对待。ES6 及后则会判断参数是否有个 Symbol.isConcatSpreadable 属性,用该属性值来判断是否对该参数进行 "展开" 处理:

let arraylike = {
length: 2, 0: "hello", 1: "world",
[Symbol.isConcatSpreadable]: true
};

[].concat(arraylike) // => (2) ['hello', 'world']

你也可以自定义个 Array 子类,然后不希望在 concat() 方法中展开:

class NotSpreadableArray extends Array {
get [Symbol.isConcatSpreadable]() { return false }
}

[].concat(["hello", "world"]); // => (2) ['hello', 'world']
[].concat(new NotSpreadableArray("hello", "world")) // => (1) [NotSpreadableArray(2)]

Symbol.unscopables

传统的 with 语句可能会对已有类(这里特指 Array )的方法新增发生冲突。ES6 及后则修改了 with 语句行为,它会根据对象的 [Symbol.unscopables] 属性,来判断对象的属性是否放入 with 语句的作用域中:

const obj = {
p1: 42, p2: 90,
[Symbol.unscopables]: { p1: true, p2: false }
};

with (obj) {
p1 // ReferenceError: prop is not defined
p2 // => 90
}

你可以通过这个机制获取 Array 的新增方法:

Array.prototype[Symbol.unscopables]

模式匹配

与模式匹配有关的符号有:Symbol.matchSymbol.matchAllSymbol.searchSymbol.replaceSymbol.split ,它们分别对应 String 中的同名方法。

在 ES6 之前,可以用 RegExp 进行模式匹配 string.method(pattern, arg) 。而在 ES6 及后,该调用会尝试转为 pattern[symbol](string, arg) ,这意味着我们并非需要使用一个常规 RegExp 对象作为模式,只要是一个拥有对应公认符号方法的对象即可。

例子可见书 P352-353 ,更多用法在 Symbol - MDN 中查找对应的链接。

模板标签

如果一个求值为函数的表达式后面跟着一个模板字面量,则会转换为一个函数调用,称为 "标签化模板字面量" 。可以把该语法看成是元编程,有点类似于给 JS 添加新语法。

标签化模板字面量已经被很多前端 JS 包采用了。例如,graphql-tag 库可以使用 gql`` 在 JS 代码中对 GraphQL 进行查询;在 emotion 库中支持 css`` 在 JS 中嵌入 CSS 样式。

其中,标签函数的第一个参数为模板字面量中被插值分割的字符串,其余参数则为插值,返回值则不限于为字符串:

function resolve(strings, ...values) {
return { strings, values }
}

let name = "Talaxy"
let result = resolve`Hello, ${name}!`

result.strings // => (2) ['Hello, ', '!', raw: Array(2)]
result.strings.raw // => (2) ['Hello, ', '!']
result.values // => (1) ['Talaxy']

Reflect API

ES6 中新增了 Reflect 对象。它不能作为构造函数调用,但提供很多函数(就像 Math 对象),这些函数可以模拟一些核心语言语法的行为。

Reflect 的大部分功能其实可以由已有语法及 API 代替,但重点在于 Reflect 的函数是与 Proxy 对象中的处理器对象的方法一一对应的。

具体的 Reflect 使用见书 P356-359 ,或 Reflect - MDN

Proxy 对象

ES6 及后新增了 Proxy 类,它是 JS 中最强大的元编程特性,可以通过它来操纵拦截 JS 对象的基础行为。

创建代理对象时,需要指定目标对象和处理器对象:

let proxy = new Proxy(target, handler)

handler 即为处理器对象,可以在该对象上定义一些 Reflect 上已有的方法。比如可以定义 set 方法,这样在给代理对象赋值时,会去尝试调用处理器对象上的 set 方法:

let target = { x: 1 }
let proxy = new Proxy(target, {
set(obj, name, value) {
console.log(`set ${name} to ${value}.`)
obj[name] = value
}
})

proxy.x // => 1
proxy.x = 2 // 打印 "set x to 2."
proxy.x // => 2
target.x // => 2

下面这个例子可以创建一个只读的代理对象:

function readOnlyProxy(obj) {
function denied() { throw new TypeError("Readonly") }
return new Proxy(obj, {
set: denied,
defineProperty: denied,
deleteProperty: denied,
setPrototypeOf: denied,
})
}

let proxy = readOnlyProxy({ x: 1 })
proxy.x // => 1
proxy.x = 10 // TypeError: Readonly

还可以用 Proxy.recovable() 来创建代理,它返回一个对象,包括一个代理对象和一个撤销代理的函数:

let target = { x: 1 }
let { proxy, revoke } = Proxy.revocable(target, {})

proxy.x // => 1
revoke() // 撤销代理
proxy.x // TypeError: Cannot perform 'get' on a proxy that has been revoked

代理不变式

代理对象所表现的特性是可以与目标对象不一致的。比如,如果目标对象是可扩展的,则可以通过设置处理器对象的 isExtensible() 方法的返回值为 false ,来使代理对象表现为不可配置。

虽然可以定义这种不一致行为,但是 Proxy 类还是会对结果进行合理性检查(比如是否不可写、不可配置登),以确保不违背重要的 JS 不变式( invariant )。比如与上面例子相反,如果为一个不可扩展对象创建了代理,但处理器对象的 isExtensible() 返回值为 true ,代理则会直接抛出 TypeError :

let target = Object.preventExtensions({})
let proxy = new Proxy(target, { isExtensible() { return true } })
Reflect.isExtensible(proxy) // TypeError: 'isExtensible' on proxy: trap result does not reflect extensibility of proxy target (which is 'false')

参考

Object - MDN

模板字符串 - MDN

Reflect - MDN

Proxy - MDN

Symbol - MDN