解决数组的问题
在本章开始时,我解释了为何在 ES6 之前开发者无法准确模拟 JS 数组的行为。而代理与反射接口则允许你创建这样一种对象:在属性被添加或删除时,它的行为与内置数组类型的行为相同。为了刷新你的记忆,这里有个例子展示了代理所要模拟的行为:
let colors = ["red", "green", "blue"];
console.log(colors.length); // 3
colors[3] = "black";
console.log(colors.length); // 4
console.log(colors[3]); // "black"
colors.length = 2;
console.log(colors.length); // 2
console.log(colors[3]); // undefined
console.log(colors[2]); // undefined
console.log(colors[1]); // "green"
这个例子可以体现出两个特别重要的行为特性:
-
当 colors[3] 被赋值时,length 属性被自动增加到 4;
-
当 length 属性被设置为 2 时,数组的最后两个元素被自动移除了。
当想要重现内置数组的工作方式时,仅需模拟这两个行为即可。接下来的几小节将会介绍如何正确地将一个对象模拟为数组。
检测数组的索引
必须始终牢记:对于数组来说,为整数属性赋值是一种特殊情况,不同于对非整数的键的处理。在如何判断一个属性键是否为数组的索引方面,ES6 规范给出了指南:
对于名为 P 的一个字符串属性名称来说,当且仅当 ToString(ToUint32(P)) 等于 P、并且 ToUint32(P) 不等于 232 - 1 时,它才能被用作数组的索引。 |
这个操作可以用下述的 JS 代码来实现:
function toUint32(value) {
return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
}
function isArrayIndex(key) {
let numericKey = toUint32(key);
return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
}
toUint32() 函数使用规范中描述的算法,将给定值转换为一个无符号的 32 位整数。isArrayIndex() 函数首先将键值转换为一个 uint32 数,并执行了比较操作来判断该键是否能够作为数组的索引。借助这两个工具函数,你就可以开始实现一个对象来模拟内置数组。
在添加新元素时增加长度属性
你可能已经注意到:数组上述两个特殊行为都依赖于对属性的赋值,这就意味着你只需要使用 set 代理陷阱来达成这两个行为。首先,下面的例子实现了第一个行为,即:当一个大于 length - 1 的数组索引被使用时,length 属性需要被增加。
function toUint32(value) {
return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
}
function isArrayIndex(key) {
let numericKey = toUint32(key);
return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
}
function createMyArray(length=0) {
return new Proxy({ length }, {
set(trapTarget, key, value) {
let currentLength = Reflect.get(trapTarget, "length");
// the special case
if (isArrayIndex(key)) {
let numericKey = Number(key);
if (numericKey >= currentLength) {
Reflect.set(trapTarget, "length", numericKey + 1);
}
}
// always do this regardless of key type
return Reflect.set(trapTarget, key, value);
}
});
}
let colors = createMyArray(3);
console.log(colors.length); // 3
colors[0] = "red";
colors[1] = "green";
colors[2] = "blue";
console.log(colors.length); // 3
colors[3] = "black";
console.log(colors.length); // 4
console.log(colors[3]); // "black"
这个例子使用了 set 代理陷阱对数组索引的设置操作进行拦截。若该键能够作为数组索引,由于传入的键值始终都是字符串,那么就需要将其转换为一个数值;接下来, 如果该数值大于或等于当前的 length 属性值,那么要把 length 属性值增加到比该数值多 1(如在索引位置 3 设置一个项,则 length 属性必须是 4); 最后通过 Reflect.set() 来调用属性的默认设置操作,以便让对应属性接收到指定的值。
使用值为 3 的 length 参数调用 createMyArray() 函数,初始化了一个定制数组,接下来立刻将三个项添加到该数组内。数组的 length 属性一直保持为 3 ,直到 "black" 值被赋值到索引 3 的位置,此时 length 属性就变成了 4。
这样就成功模拟了第一个行为,该继续处理第二个行为了。
在减少长度属性时移除元素
仅当数组索引值大于或等于 length 属性值时,所需模拟的第一个数组行为才会被使用。而相反的,在将 length 属性值设置得比之前更小的时候,才需要使用第二个行为并移除数组的元素。此时不仅需要修改 length 属性的值,还需要移除所有不应再保留的元素。例如,若数组的 length 属性从 4 被设置为 2,则位置 2 与位置 3 的项就需要被移除。你可以像处理第一个行为那样,在 set 代理陷阱中完成这个操作。下面再次使用了前一段代码,并增加了 createMyArray 方法:
function toUint32(value) {
return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
}
function isArrayIndex(key) {
let numericKey = toUint32(key);
return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
}
function createMyArray(length=0) {
return new Proxy({ length }, {
set(trapTarget, key, value) {
let currentLength = Reflect.get(trapTarget, "length");
// the special case
if (isArrayIndex(key)) {
let numericKey = Number(key);
if (numericKey >= currentLength) {
Reflect.set(trapTarget, "length", numericKey + 1);
}
} else if (key === "length") {
if (value < currentLength) {
for (let index = currentLength - 1; index >= value; index--) {
Reflect.deleteProperty(trapTarget, index);
}
}
}
// always do this regardless of key type
return Reflect.set(trapTarget, key, value);
}
});
}
let colors = createMyArray(3);
console.log(colors.length); // 3
colors[0] = "red";
colors[1] = "green";
colors[2] = "blue";
colors[3] = "black";
console.log(colors.length); // 4
colors.length = 2;
console.log(colors.length); // 2
console.log(colors[3]); // undefined
console.log(colors[2]); // undefined
console.log(colors[1]); // "green"
console.log(colors[0]); // "red"
此代码中的 set 陷阱函数会检查键的值是否为 "length",以便正确地调整对象的剩余项。如果是,则使用 Reflect.get() 方法来获取当前的长度值,并与新值作比较。如果新值小于当前值,将会使用一个 for 循环来删除对象上所有不应再被保留的属性,该循环从当前数组长度(currentLength)的位置向前删除每个属性, 直到触及新的数组长度(value)为止。
该例子先向 colors 对象中添加了四个颜色,再将其 length 属性设置为 2,结果移除了位置 2 与位置 3 的项,这样在试图访问这两个项的时候就会得到 undefined。而位置 0 与位置 1的项仍然可被访问。
两个行为都实现之后,你就可以轻易创建一个对象来模拟内置数组的行为。然而使用函数来做这些事并不可取,最好将其封装为一个类,因此下一步就是使用类来实现这些功能。
实现 MyArray 类
创建一个使用代理的类的最简单方式,就是照常定义一个类但从构造器中返回一个代理。这种方式下,该类被实例化时返回的对象就是代理,而不是该类的实例(实例即构造器内部的 this 值)。代理会像实例一样被返回,而实例此时就变成了该代理的目标对象。实例将会是完全私有的,无法被直接访问,不过你可以使用代理去间接访问它。
这里有一个从类构造器返回代理的简单范例:
class Thing {
constructor() {
return new Proxy(this, {});
}
}
let myThing = new Thing();
console.log(myThing instanceof Thing); // true
在这个例子中,Thing 类从它的构造器中返回了一个代理,该代理的目标对象是构造器被调用时其内部的 this。这意味着虽然 myThing 对象是调用 Thing 构造器创建的,但它实际上是一个代理对象。由于此代理将行为直接传递给它的目标对象,因而 myThing 仍然可以被认定为 Thing 类的一个实例,并且让代理在使用 Thing 类时完全透明。
知道了这些,使用代理来创建一个定制的数组类就相当简单了。它的实现代码与 “在减少长度属性时移除元素” 那个小节的代码非常接近,使用了相同的代理代码,但这次是在类的构造器中使用它。这里有个完整的范例:
function toUint32(value) {
return Math.floor(Math.abs(Number(value))) % Math.pow(2, 32);
}
function isArrayIndex(key) {
let numericKey = toUint32(key);
return String(numericKey) == key && numericKey < (Math.pow(2, 32) - 1);
}
class MyArray {
constructor(length=0) {
this.length = length;
return new Proxy(this, {
set(trapTarget, key, value) {
let currentLength = Reflect.get(trapTarget, "length");
// the special case
if (isArrayIndex(key)) {
let numericKey = Number(key);
if (numericKey >= currentLength) {
Reflect.set(trapTarget, "length", numericKey + 1);
}
} else if (key === "length") {
if (value < currentLength) {
for (let index = currentLength - 1; index >= value; index--) {
Reflect.deleteProperty(trapTarget, index);
}
}
}
// always do this regardless of key type
return Reflect.set(trapTarget, key, value);
}
});
}
}
let colors = new MyArray(3);
console.log(colors instanceof MyArray); // true
console.log(colors.length); // 3
colors[0] = "red";
colors[1] = "green";
colors[2] = "blue";
colors[3] = "black";
console.log(colors.length); // 4
colors.length = 2;
console.log(colors.length); // 2
console.log(colors[3]); // undefined
console.log(colors[2]); // undefined
console.log(colors[1]); // "green"
console.log(colors[0]); // "red"
这段代码创建了一个 MyArray 类,并从构造器中返回了一个代理。在构造器中, 添加了 length 属性(使用传入的值进行初始化,或者在值未提供的情况下使用默认的 0),然后创建并返回了一个代理。这让 colors 变量看起来就像是 MyArray 类的一个实例,并且实现了数组的两个关键行为。
虽然从类构造器中返回一个代理是很容易的,但这意味着每个实例都会创建一个新的代理。不过你可以将代理对象作为原型使用,这样就可以在所有实例上共享一个代理。