0%

JavaScript 权威指南 14:元编程

如果说常规编程是写代码去操作数据,那么元编程就是写代码去操作其他代码。在像 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
2
3
4
5
6
7
8
9
10
11
> Object.getOwnPropertyDescriptor({x:1}, "x")
{ value: 1, writable: true, enumerable: true, configurable: true }

> const random = { get octet() { return Math.floor(Math.random()*256); } };
> Object.getOwnPropertyDescriptor(random, "octet")
{
get: [Function: get octet],
set: undefined,
enumerable: true,
configurable: true
}

要设置属性的特性或者要创建一个具有指定特性的属性,可以调用 Object.defineProperty() 方法:传入要修改的对象、要创建或修改的属性的名字,以及属性描述符对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let o = {};

Object.defineProperty(o, "x", {
value: 1,
writable: true,
enumerable: false,
configurable: true,
}
)

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

Object.defineProperty(o, "x", {writable: false});
o.x = 2;
console.log(o.x); // => 1

// 这个属性依然是可配置的,因此可以通过修改它的 value 特性来改变该属性的值
Object.defineProperty(o, "x", {value: 2});
console.log(o.x); // => 2

Object.defineProperty(o, "x", {get: function() {return 0;}});
console.log(o.x); // => 0

传给 Object.defineProperty() 的属性描述符不一定 4 个特性都包含:

  • 如果是创建新属性,那么省略的特性会取得 false 或 undefined 值
  • 如果是修改已有的属性,那么省略的特性就不会被修改

这个方法只修改已经存在的自有属性,或者创建新的自有属性,不会修改继承的属性。如果想一次性创建或修改多个属性,可以使用 Object.defineProperties()

  • 第一个参数是要修改的对象
  • 第二个参数也是一个对象,该对象将要创建或修改的属性的名称映射到这些属性的属性描述符
1
2
3
> let p = Object.defineProperties({}, {x: {value:1, enumerable: true}, y: {value:2, enumerable: true}})
> p
{ x: 1, y: 2 }

之前介绍过 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
Object.defineProperty(Object, "assignDescriptors", {
writable: true,
enumerable: false,
configurable: true,
value: function(target, ...sources) {
for (let source of sources) {
for (let name of Object.getOwnPropertyNames(source)) {
let desc = Object.getOwnPropertyDescriptor(source, name);
Object.defineProperty(target, name, desc);
}

for (let symbol of Object.getOwnPropertySymbols(source)) {
let desc = Object.getOwnPropertyDescriptor(source, symbol);
Object.defineProperty(target, symbol, desc);
}
}

return target;
}
});

let o = {c: 1, get count() { return this.c++; }};
let p = Object.assign({}, o);
let q = Object.assignDescriptors({}, o);

console.log(p.count); // => 1
console.log(p.count); // => 1
console.log(q.count); // => 2
console.log(q.count); // => 3

对象的可扩展能力

对象的可扩展 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
2
3
4
5
6
> Object.getPrototypeOf({}) === Object.prototype
true
> Object.getPrototypeOf([]) === Array.prototype
true
> Object.getPrototypeOf(function(){}) === Function.prototype
true

要确定一个对象是不是另一个对象的原型(或原型链中的一环)​,可以使用 isPrototypeOf() 方法:

1
2
3
4
5
6
7
8
> let p = {x: 1};
> let o = Object.create(p)
> p.isPrototypeOf(o)
true
> Object.prototype.isPrototypeOf(p)
true
> Object.prototype.isPrototypeOf(o)
true

对象的 prototype 特性在它创建时会被设定,且通常保持不变。不过,可以使用Object.setPrototypeOf()修改对象的原型:

1
2
3
4
5
6
7
> let o = {x: 1}
> let p = {y: 2}
> o.y
undefined
> Object.setPrototypeOf(o, p)
> o.y
2

JavaScript 的一些早期浏览器实现通过 __proto__(前后各有两个下划线)属性暴露了对象的 prototype 特性。这个属性很早以前就已经被废弃了,但网上仍然有很多已有代码依赖 __proto__。ECMAScript 标准为此也要求所有浏览器的 JavaScript 实现都必须支持它(尽管标准并未要求,但 Node 也支持它)​。

在现代 JavaScript 中,__proto__ 是可读且可写的,你可以使用它代替 Object.getPrototypeOf()Object.setPrototypeOf()(但是不推荐):

1
2
3
4
5
6
7
> o.__proto__
{ y: 2 }
> o.__proto__ = {z: 3}
> o.y
undefined
> o.z
3

公认符号

Symbol 类型是在 ES6 中添加到 JavaScript 中的。之所以增加这个新类型,主要是为了便于扩展 JavaScript 语言,同时又不会破坏对已有代码的向后兼容性。

Symbol.iterator 是最为人熟知的 公认符号(well-known symbol)。所谓 公认符号​,其实就是 Symbol() 工厂函数的一组属性,也就是一组符号值。通过这些符号值,我们可以控制 JavaScript 对象和类的某些底层行为。

Symbol.iterator 和 Symbol.asyncIterator

Symbol.iteratorSymbol.asyncIterator 符号可以让对象或类把自己变成可迭代对象和异步可迭代对象,之前已经详细介绍过。

Symbol.hasInstance

之前介绍过 instanceof 运算符,其右侧必须是一个构造函数,而表达式 o instanceof f 在求值时会在 o 的原型链中查找 f.prototype 的值。在 ES6 及之后的版本中,Symbol.hasInstance 提供了一个替代选择:

  • instanceof 的右侧是一个有 Symbol.hasInstance 方法的对象,那么就会以左侧的值作为参数来调用这个方法并返回这个方法的值,返回值会被转换为布尔值,变成 intanceof 操作符的值

Symbol.hasInstance 意味着我们可以使用 instanceof 操作符对适当定义的伪类型对象去执行通用类型检查。

1
2
3
4
5
6
7
8
9
10
// 定义了一个作为 `类型` 的对象
let uint8 = {
[Symbol.hasInstance](x) {
return Number.isInteger(x) && x >= 0 && x <= 255;
}
}

console.log(128 instanceof uint8); // => true
console.log(256 instanceof uint8); // => false
console.log(Math.PI instanceof uint8); // => false

Symbol.toStringTag

调用一个简单 JavaScript 对象的 toString() 方法会得到字符串 [object Object]

1
2
> {}.toString()
'[object Object]'

但是如果在内置类型实例上调用 Object.prototype.toString() 函数,会得到一些有趣的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
> Object.prototype.toString.call({})
'[object Object]'
> Object.prototype.toString.call([])
'[object Array]'
> Object.prototype.toString.call(/.*/)
'[object RegExp]'
> Object.prototype.toString.call(()=>{})
'[object Function]'
> Object.prototype.toString.call("")
'[object String]'
> Object.prototype.toString.call(0)
'[object Number]'
> Object.prototype.toString.call(false)
'[object Boolean]'

使用这种 Object.prototype.toString().call() 技术检查任何 JavaScript 值,都可以从一个包含类型信息的对象中获取以其他方式无法获取的“类特性”​。

下面实现的 classof 函数比 typeof 操作符更有用,因为 typeof 操作符无法区分不同对象的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function classof(o) {
return Object.prototype.toString.call(o).slice(8, -1);
}

console.log(classof(null)); // Null
console.log(classof(undefined)); // Undefined
console.log(classof(1)); // Number
console.log(classof(10n*10n)); // BigInt
console.log(classof("")); // String
console.log(classof(false)); // Boolean
console.log(classof(Symbol())); // Symbol
console.log(classof({})); // Object
console.log(classof([])); // Array
console.log(classof(() => {})); // Function
console.log(classof(new Map())); // Map
console.log(classof(new Set())); // Set
console.log(classof(new Date())); // Date
  • 在 ES6 之前,Object.prototype.toString() 这种特殊的用法只对内置类型的实例有效。如果你对自己定义的类的实例调用 classof(),那只能得到 Object
  • 在 ES6 中,Object.prototype.toString() 会查找自己参数中有没有一个属性的符号名是 Symbol.toStringTag,如果有这样一个属性,则使用这个属性的值作为输出

这意味着如果你自己定义了一个类,那很容易可以让它适配 classof() 这样的函数:

1
2
3
4
5
6
7
8
class Range {
get [Symbol.toStringTag]() {
return "Range";
}
}

let r = new Range();
console.log(classof(r)); // 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
2
3
4
5
class EZArray extends Array {
static get [Symbol.species]() { return Array; }
get first() { return this[0]; }
get last() { return this[this.length-1]; }
}

除了 Array,像定型数组、Promise 都使用这种 物种协议 来决定某些方法的返回值是父类还是子类的实例。

Symbol.isConcatSpreadable

之前在介绍数组的 concat 方法时介绍过,数组的 concat() 方法对待自己的 this 值和它的数组参数不同于对待非数组参数。

  • 非数组参数会被简单地追加到新数组末尾
  • 但对于数组参数,this数组和参数数组都会被打平或 展开​,从而实现数组元素的拼接,而不是拼接数组参数本身
1
2
> [].concat([1,2,3], 4, 5)
[ 1, 2, 3, 4, 5 ]

在 ES6 之前,concat() 只使用 Array.isArray() 确定是否将某个值作为数组来对待。在 ES6 中,这个算法进行了一些调整:

  • 如果 concat() 的参数(或 this 值)是对象且有一个 Symbol.isConcatSpreadable 符号属性,那么就根据这个属性的布尔值来确定是否应该 展开 参数
  • 如果这个属性不存在,那么就像语言之前的版本一样使用 Array.isArray()
1
2
3
> let arrayLike = {length:1, 0:1, [Symbol.isConcatSpreadable]: true};
> [].concat(arrayLike)
[ 1 ]

模式匹配符号

之前介绍过使用 RegExp 参数 执行模式匹配操作的 String 方法。在 ES6 及之后的版本中,这些方法都统一泛化为既能够使用 RegExp 对象,也能使用任何 通过特定名称的属性定义了模式匹配行为 的对象。match()matchAll()search()replace()split() 方法都有对应的公认符号:Symbol.matchSymbol.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
2
3
4
5
6
7
8
9
10
> Object.keys(Array.prototype[Symbol.unscopables])
[
'copyWithin', 'entries',
'fill', 'find',
'findIndex', 'flat',
'flatMap', 'includes',
'keys', 'values',
'at', 'findLast',
'findLastIndex'
]

模版标签

位于反引号之间的字符串被称为 模板字面量,之前介绍过,如果一个求值为函数的表达式后面跟着一个模板字面量,那就会转换为一个函数调用,而我们称其为 标签化模板字面量。可以把定义使用标签化模板字面量的标签函数看成是元编程,因为标签化模板经常用于定义 DSL(Domain-Specific Language,领域专用语言)​。而定义新的标签函数类似于给 JavaScript 添加新语法。

标签函数并没有什么特别之处,它们就是普通的 JavaScript 函数,定义它们不涉及任何特殊语法。当函数表达式后面跟着一个模板字面量时,这个函数会被调用:

  • 第一个参数是一个字符串数组
  • 然后是 0 或多个额外参数,这些参数可以是任何类型的值。
  • 参数的个数取决于被插值到模板字面量中值的个数
  • 字符串数组的参数是以差值进行分割的(例如如果模版字面量包含一个插值,那么数组参数中会包含两个字符串,一个是插入值左侧的字符串,另一个是插入值右侧的字符串。而且这两个字符串都可能是空字符串)

如下是一个 html`` 模版的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function html(strings, ...values) {
let escaped = values.map(v => String(v)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;"));

let result = strings[0];
for (let i = 0; i < escaped.length; i++) {
result += escaped[i] + strings[i];
}

return result;
}

let operator = "<";
console.log(html`<b>x ${operator} y</b>`);

当标签函数被调用时,它的第一个参数是一个字符串数组。不过这个数组也有一个名为 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() 只对 nullundefined 参数抛出 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
2
3
4
5
6
7
8
9
10
11
12
> let  t = {x:1, y:2}
> let p = new Proxy(t, {});
> p.x
1
> delete p.y
> t.y
undefined
> p.z = 3
> t.z
3
> t
{ x: 1, z: 3 }

透明包装代理本质上就是底层目标对象,这意味着没有理由使用代理来代替包装的对象。但是,透明包装器在创建 可撤销代理 时有用。创建可撤销代理不使用 Proxy() 构造函数,而要使用 Proxy.revocable() 工厂函数。这个函数返回一个对象,其中包含代理对象和一个 revoke() 函数。一旦调用 revoke() 函数,代理立即失效:

1
2
3
4
5
6
7
> function accessTheDatabase() { return 42; }
> let {proxy, revoke} = Proxy.revocable(accessTheDatabase, {})
> proxy()
42
> revoke()
> proxy()
Uncaught TypeError: Cannot perform 'apply' on a proxy that has been revoked

可撤销代理充当了某种代码隔离的机制,而这可以在我们使用不信任的第三方库时派上用场。

如果我们给 Proxy() 构造函数传一个非空的处理器对象,那定义的就不再是一个透明包装器对象了,而是要在代理中实现自定义行为。例如如下代码为目标对象创建一个只读包装器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function readOnlyProxy(o) {
function readonly() { throw new TypeError("Readonly"); }
return new Proxy(
o,
{
set: readonly,
defineProperty: readonly,
deleteProperty: readonly,
setPrototypeOf: readonly,
}
);
}

let o = {x:1, y:2};
let p = readOnlyProxy(o);
console.log(p.x);
p.x = 2;
delete p.y;
p.z = 3;
p.__proto__ = {};

另一种使用代理的技术是为它定义处理器方法,拦截对象操作,但仍然把操作委托给目标对象。另一种使用代理的技术是为它定义处理器方法,拦截对象操作,但仍然把操作委托给目标对象。反射API的函数与处理器方法具有完全相同的签名,从而实现这种委托也很容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
function loggingProxy(o, objname) {
const handlers = {
get(target, property, receiver) {
console.log(`Handler get(${objname}, ${property.toString()})`);
let value = Reflect.get(target, property, receiver);

if (Reflect.ownKeys(target).includes(property) &&
(typeof value === "Object" || typeof value === "function")) {
return loggingProxy(value, `${objname}.${property.toString()}`);
}

return value;
},

set(target, prop, value, receiver) {
console.log(`Handler set(${objname}, ${prop.toString()}, ${value})`);
return Reflect.set(target, prop, value, receiver);
},

apply(target, receiver, args) {
console.log(`Handler ${objname}(${args})`);
return Reflect.apply(target, receiver, args);
},

construct(target, args, receiver) {
console.log(`Handler ${objname}(${args})`);
return Reflect.construct(target, args, receiver);
},
};

Reflect.ownKeys(Reflect).forEach(handlerName => {
if (!handlerName in handlers) {
handlers[handlerName] = function(target, ...args) {
console.log(`Handler ${handlerName}(${objname},${args})`);
return Reflect[handlerName](target, ...args);
}
}
});

return new Proxy(o, handlers);
}

let data = [10, 20];
let methods = {square: x => x*x };

let proxyData = loggingProxy(data, "data");
let proxyMethods = loggingProxy(methods, "methods");

// Handler get(data, map)
// Handler get(data, length)
// Handler get(data, constructor)
// Handler get(data, 0)
// Handler get(data, 1)
proxyData.map(methods.square);

// Handler get(methods, square)
// Handler methods.square(10,0,10,20)
// Handler methods.square(20,1,10,20)
data.map(proxyMethods.square);

// Handler get(data, Symbol(Symbol.iterator))
// Handler get(data, length)
// Handler get(data, 0)
// Datum 10
// Handler get(data, length)
// Handler get(data, 1)
// Datum 20
// Handler get(data, length)
for (let x of proxyData) console.log("Datum", x);

代理不变式

之前通过 readOnlyProxy 函数创建的代理对象实际上是冻结的,即修改属性值或属性特性,添加或删除属性,都会抛出异常。但是,只要目标对象没有被冻结,那么通过 Reflect.isExtensible()Reflect.getOwnPropertyDescriptor() 查询代理对象,都会告诉我们应该可以设置、添加或删除属性。也就是说,readOnlyProxy() 创建的对象处于不一致的状态。

之前说过,代理是一种没有自己的行为的对象,因为它们只负责把所有操作转发给处理器对象和目标对象。其实这么说也不全对:转发完操作后,Proxy 类会对结果执行合理性检查,以确保不违背重要的 JavaScript 不变式(invariant)。如果检查发现违背了,代理会抛出 TypeError。

如下是一个例子:

1
2
3
4
5
6
> let target = Object.preventExtensions({});
> let proxy = new Proxy(target, { isExtensible() {return true;}});
> Reflect.isExtensible(proxy);
Uncaught:
TypeError: 'isExtensible' on proxy: trap result does not reflect extensibility of proxy target (which is 'false')
at Reflect.isExtensible (<anonymous>)

在这个例子中,为一个不可扩展对象创建了代理,而它的 isExtensible() 处理器返回true,代理就会抛出 TypeError。Proxy还遵循其他一些不变式,几乎都与不可扩展的目标对象和目标对象上不可配置的属性有关。