解决数组的问题

在本章开始时,我解释了为何在 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"

这个例子可以体现出两个特别重要的行为特性:

  1. 当 colors[3] 被赋值时,length 属性被自动增加到 4;

  2. 当 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 类的一个实例,并且实现了数组的两个关键行为。

虽然从类构造器中返回一个代理是很容易的,但这意味着每个实例都会创建一个新的代理。不过你可以将代理对象作为原型使用,这样就可以在所有实例上共享一个代理。