“玄幻”的js元编程

1.元编程

元编程是指操作目标是程序本身的行为特性的编程。

元编程(Metaprogramming)是指某类计算机程序的编写,这类计算机程序编写或者操纵其他程序(或者自身)作为它们的数据,或者在运行时完成部分本应在编译时完成的工作。很多情况下与手工编写全部代码相比工作效率更高。编写元程序的语言称之为元语言,被操作的语言称之为目标语言。一门语言同时也是自身的元语言的能力称之为反射。

反射是促进元编程的一种很有价值的语言特性。把编程语言自身作为头等对象(如Lisp或Rebol)也很有用。支持泛型编程的语言也使用元编程能力。

元编程通常有两种方式起作用。一种方式是通过应用程序接口(API)来暴露运行时引擎的内部信息。另一种方法是动态执行包含编程命令的字符串。因此,“程序能编写程序”。虽然两种方法都能用,但大多数方法主要靠其中一种。

看起来很玄幻。。。

2.js 与 元编程

继续理解概念

以例子来看,比如我们想要查看对象 a 和另外一个对象 b 的关系是否是 [[Prototype]] 链接的,可以使用 a.isPrototype(b),这就是一种元编程形式,通常称为内省(introspection)。另外一个明显的元编程例子是宏(在 js 中还不支持)——代码在编译时修改自身。用for..in 循环枚举对象的键,或者检查一个对象是否是某个“类构造器”的实例,也都是常见的元编程例子。

核心

元编程关注以下一点或几点:

  • 代码查看自身
  • 代码修改自身
  • 代码修改默认语言特性,以此影响其他代码。

目的

元编程的目标是利用语言自身的内省能力使代码的其余部分更具描述性、表达性和灵活性。因为元编程的元(meta)本质,我们有点难以给出比上面提到的更精确的定义。

ES5 与 元编程

函数名称

你的代码在有些情况下可能想要了解自身,想要知道某个函数的名称是什么。

1
2
3
function test () {}

console.log(test.name); // 'test'

name 属性是用于元编程目的的,这里比较混乱,因为默认情况下函数的词法名称(如果有的话)也会被设为它的 name 属性。实际上,ES5(和之前的)规范对这一行为并没有正式要求。name 属性的设定是非标准的,但还是比较可靠的。而在 ES6 中这一点已经得到了标准化。

如果函数设定了 name 值,那么这个值通常也就是开发者工具中栈踪迹使用的名称。

默认情况下,name 属性不可写,但可配置,也就是说如果需要的话,可使用 Object.defineProperty(..) 来手动修改。

ES6 与 元编程

从ECMAScript 2015 开始,JavaScript 获得了 ProxyReflect 对象的支持,允许你拦截并定义基本语言操作的自定义行为(例如,属性查找,赋值,枚举,函数调用等)。借助这两个对象,你可以在 JavaScript 元级别进行编程。

元属性

元属性以属性访问的形式提供特殊的其他方法无法获取的元信息。

new.target 为例,关键字 new 用作属性访问的上下文。显然,new 本身并不是一个对象,因此这个功能很特殊。而在构造器调用(通过 new 触发的函数 / 方法)内部使用 new.target 时,new 成了一个虚拟上下文,使得 new.target 能够指向调用 new 的目标构造器。

这个是元编程操作的一个明显示例,因为它的目的是从构造器调用内部确定最初 new 的目标是什么,通用地说就是用于内省(检查类型 / 结构)或者静态属性访问。
举例来说,你可能需要在构造器内部根据是直接调用还是通过子类调用采取不同的动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Parent {
constructor () {
if (new.target === Parent) {
console.log('parent');
} else {
console.log('child');
}
}
}

class Child extends Parent {}

let a = new Parent(); // parent
let b = new Child(); // child

公开符号

除了在自己的程序中定义符号之外,JavaScript 预先定义了一些内置符号,称为公开符号(Well-Known Symbol,WKS)
定义这些符号主要是为了提供专门的元属性,以便把这些元属性暴露给 JavaScript 程序以获取对 JavaScript 行为更多的控制。

公开符号主要涉及到 ES6 新增的基本数据类型Symbol

Symbol.iterator

Symbol.iterator 表示任意对象上的一个专门位置(属性),语言机制自动在这个位置上寻找一个方法,这个方法构造一个迭代器来消耗这个对象的值。很多对象定义有这个符号的默认值。

然而,也可以通过定义 Symbol.iterator 属性为任意对象值定义自己的迭代器逻辑,即使这会覆盖默认的迭代器。这里的元编程特性在于我们定义了一个行为特性,供 JavaScript 其他部分(也就是运算符和循环结构)在处理定义的对象时使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let arr = [4, 5, 6, 7, 8, 9];
for (let item of arr) {
console.log(item);
}
// 4 5 6 7 8 9

// 定义一个只在奇数索引值产生值的迭代器
arr[Symbol.iterator] = function*() {
let idx = 1;
do {
yield this[idx];
} while ((idx += 2) < this.length);
}

for (let item of arr) {
console.log(item);
}
// 5 7 9

Symbol.toStringTagSymbol.hasInstance

最常见的一个元编程任务,就是在一个值上进行内省来找出它是什么种类,这通常是为了确定其上适合执行何种运算。对于对象来说,最常用的内省技术是 toString() 和 instanceof。

1
2
3
4
function Foo() {}
let a = new Foo();
a.toString(); // [object Object]
a instanceof Foo; // true

在 ES6 中,可以控制这些操作的行为特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Foo(greeting) {
this.greeting = greeting;
}
Foo.prototype[Symbol.toStringTag] = 'Foo';
Object.defineProperty( Foo, Symbol.hasInstance, {
value (inst) {
return inst.greeting == "hello";
}
});
let a = new Foo('hello'),
b = new Foo('world');
b[Symbol.toStringTag] = 'cool';
a.toString(); // [object Foo]
String(b); // [object cool]
a instanceof Foo; // true
b instanceof Foo; // false

原型(或实例本身)的 @@toStringTag 符号指定了在 [object __] 字符串化时使用的字符串值。

@@hasInstance 符号是在构造器函数上的一个方法,接受实例对象值,通过返回 truefalse 来指示这个值是否可以被认为是一个实例。

要在一个函数上设定 @@hasInstance,必须使用Object.defineProperty(..),因为 Function.prototype 上默认的那一个是 writable: false(不可写的)。

Symbol.species

符号 @@species,这个符号控制要生成新实例时,类的内置方法使用哪一个构造器。
最常见的例子是,在创建 Array 的子类并想要定义继承的方法(比如 slice(..))时使用哪一个构造器(是 Array(..) 还是自定义的子类)。默认情况下,调用 Array 子类实例上的slice(..) 会创建这个子类的新实例,坦白说这很可能就是你想要的。
但是,你可以通过覆盖一个类的默认 @@species 定义来进行元编程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Cool {
// 把@@species推迟到子类
static get [Symbol.species]() { return this; }
again() {
return new this.constructor[Symbol.species]();
}
}
class Fun extends Cool {}
class Awesome extends Cool {
// 强制指定@@species为父构造器
static get [Symbol.species]() { return Cool; }
}
let a = new Fun(),
b = new Awesome(),
c = a.again(),
d = b.again();
c instanceof Fun; // true
d instanceof Awesome; // false
d instanceof Cool; // true

就像前面代码中 Cool 的定义那样,内置原生构造器上 Symbol.species 的默认行为是return this。在用户类上没有默认值,但是就像展示的那样,这个行为特性很容易模拟。如果需要定义生成新实例的方法,使用 new this.constructorSymbol.species 模式元编程,而不要硬编码 new this.constructor(..) 或 new XYZ(..)。然后继承类就能够自定义 Symbol.species 来控制由哪个构造器产生这些实例。

Symbol.toPrimitive

抽象类型转换运算 ToPrimitive,它用在对象为了某个操作(比如比较 == 或者相加 +)必须被强制转换为一个原生类型值的时候。在 ES6 之前,没有办法控制这一行为。
而在 ES6 中,在任意对象值上作为属性的符号 @@toPrimitivesymbol 都可以通过指定一个方法来定制这个 ToPrimitive 强制转换。

1
2
3
4
5
6
7
8
9
10
11
12
let arr = [1, 2, 3, 4, 5];
arr + 10; // 1,2,3,4,510

arr[Symbol.toPrimitive] = function(hint) {
if (hint == 'default' || hint == 'number') {
// 求所有数字之和
return this.reduce( function(acc,curr){
return acc + curr;
}, 0 );
}
};
arr + 10; // 25

Symbol.toPrimitive 方法根据调用 ToPrimitive 的运算期望的类型,会提供一个提示(hint)指定 “string”、”number” 或 “default”(这应该被解释为 “number”)。在前面的代码中,加法 + 运算没有提示(传入 “default”)。而乘法 * 运算提示为 “number”,String(arr)提示为 “string”。

如果一个对象与另一个非对象值比较,== 运算符调用这个对象上的ToPrimitive 方法时不指定提示——如果有 @@toPrimitive 方法的话,调用时提示为 “default”。但是,如果比较的两个值都是对象,== 的行为和 === 一样,也就是直接比较其引用。这种情况下完全不会调用 @@toPrimitive。

正则表达式符号

对于正则表达式对象,有 4 个公开符号可以被覆盖,它们控制着这些正则表达式在 4 个对应的同名 String.prototype 函数中如何被使用。

  • @@match:正则表达式的 Symbol.match 值是一个用于利用给定的正则表达式匹配一个字符串值的部分或全部内容的方法。如果传给 String.prototype.match(..) 一个正则表达式,那么用它来进行模式匹配。
  • @@replace:正则表达式的 Symbol.replace 值是一个方法,String.prototype.replace(..) 用它来替换一个字符串内匹配给定的正则表达式模式的一个或多个字符序列。
  • @@search:正则表达式的 Symbol.search 值是一个方法,String.prototype.search(..) 用它来在另一个字符串中搜索一个匹配给定正则表达式的子串。
  • @@split:正则表达式的 Symbol.split 值是一个方法,String.prototype.split(..) 用它把字符串在匹配给定正则表达式的分隔符处
    分割为子串。

如果你不够艺高人胆大的话,就不要覆盖内置正则表达式算法了! JavaScript 的正则表达式引擎经过高度优化,所以你自己的用户代码很可能会慢上许多。这类元编程简洁强大,但是只应该在确实需要或能带来收益的时候才使用。

Symbol.isConcatSpreadable

符号 @@isConcatSpreadable 可以被定义为任意对象(比如数组或其他可迭代对象)的布尔型属性(Symbol.isConcatSpreadable),用来指示如果把它传给一个数组的 concat(..) 是否应该将其展开。

1
2
3
4
let a = [1, 2, 3],
b = [4, 5, 6];
b[Symbol.isConcatSpreadable] = false;
[].concat( a, b ); // [1,2,3,[4,5,6]]

Symbol.unscopables

符号 @@unscopables 可以被定义为任意对象的对象属性(Symbol.unscopables),用来指示使用 with 语句时哪些属性可以或不可以暴露为词法变量。

1
2
3
4
5
6
7
8
9
10
let o = { a: 1, b: 2, c: 3 },
a = 10, b = 20, c = 30;
o[Symbol.unscopables] = {
a: false,
b: true,
c: false
};
with (o) {
console.log( a, b, c ); // 1 20 3
}

@@unscopables 对象中的 true 表示这个属性应该是 unscopable 的,因此会从词法作用域变量中被过滤出去。false 表示可以将其包含到词法作用域变量中。

strict 模式下不允许 with 语句,因此应当被认为是语言的过时特性。不要使用它。

代理

代理是一种由你创建的特殊的对象,它“封装”另一个普通对象——或者说挡在这个普通对象的前面。你可以在代理对象上注册特殊的处理函数(也就是 trap),代理上执行各种操作的时候会调用这个程序。这些处理函数除了把操作转发给原始目标 / 被封装对象之外,还有机会执行额外的逻辑。

你可以在代理上定义的 trap 处理函数的一个例子是 get,当你试图访问对象属性的时候,它拦截 [[Get]] 运算。

在 ECMAScript 6 中引入的 Proxy 对象可以拦截某些操作并实现自定义行为。例如获取一个对象上的属性:

1
2
3
4
5
6
7
8
9
10
let handler = {
get: function(target, name){
return name in target ? target[name] : 42;
}
};

let p = new Proxy({}, handler);
p.a = 1;

console.log(p.a, p.b); // 1, 42

Proxy 对象定义了一个目标(这里是一个空对象)和一个实现了 get 陷阱的 handle 对象。这里,代理的对象在获取未定义的属性时不会返回 undefined,而是返回 42。

在目标对象 / 函数代理上可以定义的处理函数,以及如何 / 何时被触发。

  • get(..):通过 [[Get]],在代理上访问一个属性(Reflect.get(..)、. 属性运算符或 [ .. ] 属性运算符);
  • set(..):通过 [[Set]],在代理上设置一个属性值(Reflect.set(..)、赋值运算符 = 或目标为对象属性的解构赋值)
  • deleteProperty(..):通过 [[Delete]], 从代理对象上删除一个属性(Reflect.deleteProperty(..) 或delete)
  • apply(..):(如果目标为函数)通过 [[Call]],将代理作为普通函数 / 方 法 调 用(Reflect.apply(..)、call(..)、apply(..) 或 (..) 调用运算符)
  • construct(..):(如果目标为构造函数)通过 [[Construct]],将代理作为构造函数调用(Reflect.construct(..) 或 new)
  • getOwnPropertyDescriptor(..):通过 [[GetOwnProperty]],从代理中提取一个属性描述符(Object.getOwnPropertyDescriptor(..)或 Reflect.getOwnPropertyDescriptor(..))
  • defineProperty(..):通过 [[DefineOwnProperty]],在代理上设置一个属性描述符(Object.defineProperty(..)或 Reflect.defineProperty(..))
  • getPrototypeOf(..):通过 [[GetPrototypeOf]],得到代理的 [[Prototype]](Object.getPrototypeOf(..)、Reflect.getPrototypeOf(..)、proto、Object#isPrototypeOf(..) 或 instanceof)
  • setPrototypeOf(..):通过 [[SetPrototypeOf]],设置代理的 [[Prototype]](Object.setPrototypeOf(..)、Reflect.setPrototypeOf(..) 或 proto
  • preventExtensions(..):通过 [[PreventExtensions]],使得代理变成不可扩展的(Object.prevent Extensions(..)或 Reflect.preventExtensions(..))
  • isExtensible(..):通过 [[IsExtensible]],检测代理是否可扩展(Object.isExtensible(..) 或 Reflect.isExtensible(..))
  • ownKeys(..):通过 [[OwnPropertyKeys]],提取代理自己的属性和 / 或符号属性(Object.keys(..)、Object.getOwnPropertyNames(..)、Object.getOwnSymbolProperties(..)、Reflect.ownKeys(..) 或 JSON.stringify(..))
  • enumerate(..):通过 [[Enumerate]],取得代理拥有的和“继承来的”可枚举属性的迭代器(Reflect.enumerate(..) 或 for..in)
  • has(..):通过 [[HasProperty]],检查代理是否拥有或者“继承了”某个属性(Reflect.has(..)、Object#hasOwnProperty(..) 或 “prop” in obj)

除了上面列出的会触发各种 trap 的动作,某些 trap 是由其他 trap 的默认动作间接触发的。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let handlers = {
getOwnPropertyDescriptor(target, prop) {
console.log('getOwnPropertyDescriptor');
return Object.getOwnPropertyDescriptor(target, prop);
},
defineProperty(target, prop, desc) {
console.log( "defineProperty" );
return Object.defineProperty(target, prop, desc);
}
},
proxy = new Proxy( {}, handlers );

proxy.a = 2;
// getOwnPropertyDescriptor
// defineProperty

getOwnPropertyDescriptor(..) 和 defineProperty(..) 处理函数是在设定属性值(不管是新增的还是更新已有的)时由默认 set(..)处理函数的步骤触发的。如果你也自定义了set(..) 处理函数,那么在 context(不是 target !)上可以(也可以不)进行相应的调用,这些调用会触发这些代理 trap。

术语

在讨论代理的功能时会用到以下术语。

  • handler:包含陷阱的占位符对象。
  • traps:提供属性访问的方法。这类似于操作系统中陷阱的概念。
  • target:代理虚拟化的对象。它通常用作代理的存储后端。根据目标验证关于对象不可扩展性或不可配置属性的不变量(保持不变的语义)。
  • invariants:实现自定义操作时保持不变的语义称为不变量。如果你违反处理程序的不变量,则会抛出一个 TypeError

撤销 Proxy

Proxy.revocable() 方法被用来创建可撤销的 Proxy 对象。这意味着 proxy 可以通过 revoke 函数来撤销,并且关闭代理。此后,代理上的任意的操作都会导致TypeError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let revocable = Proxy.revocable({}, {
get: function(target, name) {
return "[[" + name + "]]";
}
});
let proxy = revocable.proxy;
console.log(proxy.foo); // "[[foo]]"

revocable.revoke();

console.log(proxy.foo); // TypeError is thrown
proxy.foo = 1 // TypeError again
delete proxy.foo; // still TypeError
typeof proxy // "object", typeof doesn't trigger any trap

它接收同样的两个参数:targethandlers。和 new Proxy(..) 不一样,Proxy.revocable(..) 的返回值不是代理本身。而是一个有两个属性——proxyrevode 的对象。

一旦可取消代理被取消,任何对它的访问(触发它的任意 trap)都会抛出 TypeError。可取消代理的一个可能应用场景是,在你的应用中把代理分发到第三方,其中管理你的模型数据,而不是给出真实模型本身的引用。如果你的模型对象改变或者被替换,就可以使分发出去的代理失效,这样第三方能够(通过错误!)知晓变化并请求更新到这个模型的引用。

代理在先

通常可以把代理看作是对目标对象的“包装”。在这种意义上,代理成为了代码交互的主要对象,而实际目标对象保持隐藏 / 被保护的状态。
你可能这么做是因为你想要把对象传入到某个无法被完全“信任”的环境,因此需要为对它的访问增强规范性,而不是把对象本身传入。

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
let messages = [],
handlers = {
get(target, key) {
// 字符串值?
if (typeof target[key] === 'string') {
// 过滤掉标点符号
return target[key].replace( /[^\w]/g, "" );
}
// 所有其他的传递下去
return target[key];
},
set(target, key, val) {
// 设定唯一字符串,改为小写
if (typeof val === 'string') {
val = val.toLowerCase();
if (target.indexOf( val ) === -1) {
target.push(val.toLowerCase());
}
}
return true;
}
},
messages_proxy = new Proxy(messages, handlers);

// 其他某处:
messages_proxy.push('heLLo...', 42, 'wOrlD!!', 'WoRld!!');

messages_proxy.forEach( function(val){
console.log(val);
});
// hello world
messages.forEach( function(val){
console.log(val);
});
// hello... world!!

通过与 messages_proxy 交互来增加某些特殊的规则,这些是 messages 本身没有的。我们只在值为字符串并且是唯一值的时候才添加这个元素;我们还将这个值变为小写。在从messages_proxy 提取值的时候,我们过滤掉了字符串中的所有标点符号。

代理在后

另外,我们也可以完全反转这个模式,让目标与代理交流,而不是代理与目标交流。这样,代码只能与主对象交互。这个回退方式的最简单实现就是把 proxy 对象放到主对象的 [[Prototype]] 链中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let handlers = {
get(target, key, context) {
return function() {
context.speak(key + "!");
};
}
},
catchall = new Proxy( {}, handlers ),
greeter = {
speak(who = "someone") {
console.log( "hello", who );
}
};

// 设定greeter回退到catchall
Object.setPrototypeOf( greeter, catchall );
greeter.speak(); // hello someone
greeter.speak( "world" ); // hello world
greeter.everyone(); // hello everyone!

这里直接与 greeter 而不是 catchall 交流。当我们调用 speak(..) 的时候,它在 greeter 上被找到并直接使用。但是当我们试图访问像 everyone() 这样的方法的时候,这个函数在 greeter 上并不存在。
默认的对象属性行为是检查 [[Prototype]] 链,所以会查看 catchall 是否有 everyone 属性。然后代理的 get() 处理函数介入并返回一个用访问的属性名(”everyone”)调用 speak(..) 的函数。
我把这个模式称为代理在后(proxy last),因为在这里代理只作为最后的保障。

“No Such Property/Method”

有一个关于 JavaScript 的常见抱怨,在你试着访问或设置一个还不存在的属性时,默认情况下对象不是非常具有防御性。你可能希望预先定义好一个对象的所有属性 / 方法之后,访问不存在的属性名时能够抛出一个错误。

我们可以通过代理实现这一点,代理在先或代理在后设计都可以。两种情况我们都考虑一下:

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
let obj = {
a: 1,
foo() {
console.log( "a:", this.a );
}
},
handlers = {
get(target, key, context) {
if (Reflect.has( target, key )) {
return Reflect.get(target, key, context);
} else {
throw "No such property/method!";
}
},
set(target, key, val, context) {
if (Reflect.has( target, key )) {
return Reflect.set(target, key, val, context);
} else {
throw "No such property/method!";
}
}
},
pobj = new Proxy( obj, handlers );

pobj.a = 3;
pobj.foo(); // a: 3
pobj.b = 4; // Error: No such property/method!
pobj.bar(); // Error: No such property/method!

对于 get(..) 和 set(..),我们都只在目标对象的属性存在的时候才转发这个操作;否则抛出错误。主对象代码应该与代理对象(pobj)交流,因为它截获这些动作以提供保护。
现在,考虑转换为代理在后设计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let handlers = {
get() {
throw "No such property/method!";
},
set() {
throw "No such property/method!";
}
},
pobj = new Proxy( {}, handlers ),
obj = {
a: 1,
foo() {
console.log( "a:", this.a );
}
};

// 设定obj回退到pobj
Object.setPrototypeOf( obj, pobj );
obj.a = 3;
obj.foo(); // a: 3
obj.b = 4; // Error: No such property/method!
obj.bar(); // Error: No such property/method!

考虑到处理函数的定义方式,这里的代理在后设计更简单一些。与截获 [[Get]][[Set]]操作并且只在目标属性存在情况下才转发不同,我们依赖于这样一个事实:如果 [[Get]][[Set]] 进入我们的 pobj 回退,此时这个动作已经遍历了整个 [[Prototype]] 链并且没有发现匹配的属性。

代理 hack [[Prototype]]

[[Prototype]] 机制运作的主要通道是 [[Get]] 运算。当直接对象中没有找到一个属性的时候,[[Get]] 会自动把这个运算转给 [[Prototype]] 对象处理。
这意味着你可以使用代理的 get(..) trap 来模拟或扩展这个 [[Prototype]] 机制的概念。
我们将考虑的第一个 hack 就是创建两个对象,通过 [[Prototype]] 连成环状(或者,至少看起来是这样!)。实际上并不能创建一个真正的 [[Prototype]] 环,因为引擎会抛出错误。但是可以用代理模拟。

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
let handlers = {
get(target, key, context) {
if (Reflect.has( target, key )) {
return Reflect.get(target, key, context);
}
// 伪环状[[Prototype]]
else {
return Reflect.get(target[Symbol.for('[[Prototype]]')], key, context);
}
}
},
obj1 = new Proxy({
name: 'obj-1',
foo() {
console.log( "foo:", this.name );
}
}, handlers),
obj2 = Object.assign(Object.create( obj1 ), {
name: "obj-2",
bar() {
console.log( "bar:", this.name );
this.foo();
}
});

// 伪环状[[Prototype]]链接
obj1[Symbol.for('[[Prototype]]')] = obj2;
obj1.bar();
// bar: obj-1 <-- 通过代理伪装[[Prototype]]
// foo: obj-1 <-- this上下文依然保留着
obj2.foo();
// foo: obj-2 <-- 通过[[Prototype]]

代理局限性

可以在对象上执行的很广泛的一组基本操作都可以通过这些元编程处理函数 trap。但有一些操作是无法(至少现在)拦截的。
比如,下面这些操作都不会 trap 并从代理 pobj 转发到目标 obj:

1
2
3
4
5
6
7
8
9
let obj = { a:1, b:2 },
handlers = { .. },
pobj = new Proxy( obj, handlers );

typeof obj;
String( obj );
obj + "";
obj == pobj;
obj === pobj

代理处理函数总会有一些不变性(invariant),亦即不能被覆盖的行为。比如,isExtensible(..) 处理函数的返回值总会被类型转换为 boolean。这些不变性限制了自定义代理行为的能力,但它们的目的只是为了防止你创建诡异或罕见(或者不一致)的行为。

反射

Reflect 是一个内置对象,它提供了可拦截 JavaScript 操作的方法。该方法和代理句柄类似,但 Reflect 方法并不是一个函数对象。

Reflect 有助于将默认操作从处理程序转发到目标。

Reflect.has() 为例,你可以将 in 运算符作为函数:

1
Reflect.has(Object, "assign"); // true

Reflect 对象是一个平凡对象(就像 Math),不像其他内置原生值一样是函数 / 构造器。
它持有对应于各种可控的元编程任务的静态函数。这些函数一对一对应着代理可以定义的处理函数方法(trap)。
这些函数中的一部分看起来和 Object 上的同名函数类似:

  • Reflect.getOwnPropertyDescriptor(..);
  • Reflect.defineProperty(..);
  • Reflect.getPrototypeOf(..);
  • Reflect.setPrototypeOf(..);
  • Reflect.preventExtensions(..);
  • Reflect.isExtensible(..)。

一般来说这些工具和 Object. 的对应工具行为方式类似。但是,有一个区别是如果第一个参数(目标对象)不是对象的话,Object.相应工具会试图把它类型转换为一个对象。而这种情况下 Reflect.* 方法只会抛出一个错误。

可以使用下面这些工具访问 / 查看一个对象键:

  • Reflect.ownKeys(..):返回所有“拥有”的(不是“继承”的)键的列表,就像 Object.getOwnPropertyNames(..) 和 Object.getOwnPropertySymbols(..)返回的一样。
  • Reflect.enumerate(..):返回一个产生所有(拥有的和“继承的”)可枚举的(enumerable)非符号键集合的迭代器。本质上说,这个键的集合和 for..in 循环处理的那个键的集合是一样的。
  • Reflect.has(..):实质上和 in 运算符一样,用于检查某个属性是否在某个对象上或者在它的[[Prototype]] 链上。比如,Reflect.has(o, “foo”)实质上就是执行 “foo” in o。

函数调用和构造器调用可以通过使用下面这些工具手动执行,与普通的语法(比如,(..)和 new)分开 :

  • Reflect.apply(..):举例来说,Reflect.apply(foo,thisObj,[42,”bar”]) 以 thisObj 作为 this 调用 foo(..)函数,传入参数 42 和 “bar”。
  • Reflect.construct(..):举例来说,Reflect.construct(foo,[42,”bar”]) 实质上就是调用 new foo(42,”bar”)。

可以使用下面这些工具来手动执行对象属性访问、设置和删除。

  • Reflect.get(..):举例来说,Reflect.get(o,”foo”) 提取 o.foo。
  • Reflect.set(..):举例来说,Reflect.set(o,”foo”,42) 实质上就是执行 o.foo = 42。
  • Reflect.deleteProperty(..):举例来说,Reflect.deleteProperty(o,”foo”) 实质上就是执行 delete o.foo。

Reflect 的元编程能力提供了模拟各种语法特性的编程等价物,把之前隐藏的抽象操作暴露出来。比如,你可以利用这些能力扩展功能和 API,以实现领域特定语言(DSL)。

更好的 apply 函数

在 ES5 中,我们通常使用 Function.prototype.apply() 方法调用一个具有给定 this 值和 arguments 数组(或类数组对象)的函数。

1
Function.prototype.apply.call(Math.floor, undefined, [1.75]);

使用 Reflect.apply,这变得不那么冗长和容易理解:

1
2
3
4
5
6
7
8
9
10
11
Reflect.apply(Math.floor, undefined, [1.75]); 
// 1;

Reflect.apply(String.fromCharCode, undefined, [104, 101, 108, 108, 111]);
// "hello"

Reflect.apply(RegExp.prototype.exec, /ab/, ['confabulation']).index;
// 4

Reflect.apply(''.charAt, 'ponies', [3]);
// "i"

检查属性定义是否成功

使用 Object.defineProperty,如果成功返回一个对象,否则抛出一个 TypeError,你将使用 try...catch 块来捕获定义属性时发生的任何错误。因为 Reflect.defineProperty 返回一个布尔值表示的成功状态,你可以在这里使用 if...else

1
2
3
4
5
if (Reflect.defineProperty(target, property, attributes)) {
// success
} else {
// failure
}

属性排序

在 ES6 之前,一个对象键 / 属性的列出顺序是依赖于具体实现,并未在规范中定义。一般来说,多数引擎按照创建的顺序进行枚举,虽然开发者们一直被强烈建议不要依赖于这个顺序。
对于 ES6 来说,拥有属性的列出顺序是由 [[OwnPropertyKeys]] 算法定义的,这个算法产生所有拥有的属性(字符串或符号),不管是否可枚举。这个顺序只对 Reflect.ownKeys(..)(以及扩展的 Object.getOwnPropertyNames(..) 和 Object.getOwnPropertySymbols(..))有保证。
其顺序为:

  • (1) 首先,按照数字上升排序,枚举所有整数索引拥有的属性;
  • (2) 然后,按照创建顺序枚举其余的拥有的字符串属性名;
  • (3) 最后,按照创建顺序枚举拥有的符号属性。
1
2
3
4
5
6
7
8
9
let o = {};
o[Symbol('c')] = 'yay';
o[2] = true;
o[1] = true;
o.b = "awesome";
o.a = "cool";
Reflect.ownKeys( o ); // [1,2,"b","a",Symbol(c)]
Object.getOwnPropertyNames( o ); // [1,2,"b","a"]
Object.getOwnPropertySymbols( o ); // [Symbol(c)]

另一方面,[[Enumerate]] 算法只从目标对象和它的 [[Prototype]]链产生可枚举属性。它用于 Reflect.enumerate(..) 和 for..in。可以观察到的顺序和具体
的实现相关,不由规范控制。
与之对比,Object.keys(..) 调用 [[OwnPropertyKeys]] 算法取得拥有的所有键的列表。但是,它会过滤掉不可枚举属性,然后把这个列表重新排序来遵循遗留的与实现相关的行为特性,特别是 JSON.stringify(..) 和 for..in。因此通过扩展,这个顺序也和 Reflect.enumerate(..) 顺序相匹配。
换句话说,所有这 4 种机制(Reflect.enumerate(..)、Object.keys(..)、for..in 和 JSON.stringify(..))都会匹配同样的与具体实现相关的排序,尽管严格上说是通过不同的路径。
把这 4 种机制与 [[OwnPropertyKeys]] 的排序匹配的具体实现是允许的,但并不是必须的。
尽管如此,你很可能会看到它们的排序特性是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let o = { a: 1, b: 2 };
let p = Object.create( o );
p.c = 3;
p.d = 4;
for (let prop of Reflect.enumerate( p )) {
console.log( prop );
}
// c d a b

for (let prop in p) {
console.log( prop );
}
// c d a b

JSON.stringify( p );
// {"c":3,"d":4}

Object.keys( p );
// ["c","d"]

相关链接