使用 apply 与 construct 陷阱函数的函数代理

在所有的代理陷阱中,只有 apply 与 construct 要求代理目标对象必须是一个函数。回忆一下第三章的内容,函数拥有两个内部方法:,前者会在函数被直接调用时执行,而后者会在函数被使用 new 运算符调用时执行。apply 与 construct 陷阱函数对应着这两个内部方法,并允许你对其进行重写。当不使用 new 去调用一个函数时,apply 陷阱函数会接收到下列三个参数(Reflect.apply() 也会接收这些参数) :

  1. trapTarget:被执行的函数(即代理的目标对象);

  2. thisArg:调用过程中函数内部的 this 值;

  3. argumentsList:被传递给函数的参数数组。

当使用 new 去执行函数时,construct 陷阱函数会被调用并接收到下列两个参数:

  1. trapTarget:被执行的函数(即代理的目标对象);

  2. argumentsList:被传递给函数的参数数组。

Reflect.construct() 方法同样会接收到这两个参数,还会收到可选的第三参数 newTarget,如果提供了此参数,则它就指定了函数内部的 new.target 值。

apply 与 construct 陷阱函数结合起来就完全控制了任意的代理目标对象函数的行为。为了模拟函数的默认行为,你可以这么做:

let target = function() { return 42 },
    proxy = new Proxy(target, {
        apply: function(trapTarget, thisArg, argumentList) {
            return Reflect.apply(trapTarget, thisArg, argumentList);
        },
        construct: function(trapTarget, argumentList) {
            return Reflect.construct(trapTarget, argumentList);
        }
    });

// a proxy with a function as its target looks like a function
console.log(typeof proxy);                  // "function"

console.log(proxy());                       // 42

var instance = new proxy();
console.log(instance instanceof proxy);     // true
console.log(instance instanceof target);    // true

本例中的函数会返回一个数值 42。该函数的代理使用了 apply 与 construct 陷阱函数来将对应行为分别委托给 Reflect.apply() 与 Reflect.construct() 方法。最终结果是代理函数就像目标函数一样工作,包括使用 typeof 会将其检测为函数,并且使用 new 运算符调用会产生一个实例对象 instance。instance 对象会被同时判定为 proxy 与 target 对象的实例,是因为 instanceof 运算符使用了原型链来进行推断,而原型链查找并没有受到这个代理的影响,因此 proxy 对象与 target 对象对于 JS 引擎来说就有同一个原型。

验证函数的参数

apply 与 construct 陷阱函数在函数的执行方式上开启了很多的可能性。例如, 假设你想要保证所有参数都是某个特定类型的,可使用 apply 陷阱函数来进行验证:

// adds together all arguments
function sum(...values) {
    return values.reduce((previous, current) => previous + current, 0);
}

let sumProxy = new Proxy(sum, {
        apply: function(trapTarget, thisArg, argumentList) {

            argumentList.forEach((arg) => {
                if (typeof arg !== "number") {
                    throw new TypeError("All arguments must be numbers.");
                }
            });

            return Reflect.apply(trapTarget, thisArg, argumentList);
        },
        construct: function(trapTarget, argumentList) {
            throw new TypeError("This function can't be called with new.");
        }
    });

console.log(sumProxy(1, 2, 3, 4));          // 10

// throws error
console.log(sumProxy(1, "2", 3, 4));

// also throws error
let result = new sumProxy();

此例使用了 apply 陷阱函数来确保所有的参数都是数值。sum() 函数会将所有传递进来的参数值相加,如果传入参数的值不是数值类型,该函数仍然会尝试加法操作, 这样可能会导致意外的结果。此代码通过将 sum() 函数封装在 sumProxy() 代理中,在函数运行之前拦截了函数调用,以保证每个参数都是数值。出于安全的考虑,这段代码使用 construct 陷阱抛出错误,以确保该函数不会被使用 new 运算符调用。

相反的,你也可以限制函数必须使用 new 运算符调用,同时确保它的参数都是数值:

function Numbers(...values) {
    this.values = values;
}

let NumbersProxy = new Proxy(Numbers, {

        apply: function(trapTarget, thisArg, argumentList) {
            throw new TypeError("This function must be called with new.");
        },

        construct: function(trapTarget, argumentList) {
            argumentList.forEach((arg) => {
                if (typeof arg !== "number") {
                    throw new TypeError("All arguments must be numbers.");
                }
            });

            return Reflect.construct(trapTarget, argumentList);
        }
    });

let instance = new NumbersProxy(1, 2, 3, 4);
console.log(instance.values);               // [1,2,3,4]

// throws error
NumbersProxy(1, 2, 3, 4);

此代码中的 apply 陷阱函数会抛出错误,而 construct 陷阱函数则使用了 Reflect.construct() 方法来验证输入并返回一个新的实例。当然,你也可以不必使用代理,而是用 new.target 来完成相同的功能。

调用构造器而无须使用 new

第三章曾介绍了 new.target 元属性,在使用 new 运算符调用函数时,这个属性就是对该函数的一个引用。这意味着你可以使用 new.target 来判断函数被调用时是否使用了 new,就像这样:

function Numbers(...values) {

    if (typeof new.target === "undefined") {
        throw new TypeError("This function must be called with new.");
    }

    this.values = values;
}

let instance = new Numbers(1, 2, 3, 4);
console.log(instance.values);               // [1,2,3,4]

// throws error
Numbers(1, 2, 3, 4);

这个例子在不使用 new 来调用 Numbers 函数的情况下抛出了错误,与 “验证函数的参数” 那个小节的例子效果一致,但并没有使用代理。相对于使用代理,这种写法更简单,并且若只想阻止不使用 new 来调用函数的行为,这种写法也更胜一筹。然而有时你所要修改其行为的函数是你所无法控制的,此时使用代理就有意义了。

假设 Numbers 函数是硬编码的,无法被修改,已知该代码依赖于 new.target, 而你想要在调用函数时避免这个检查。在 “必须使用 new ” 这一限制已经确定的情况下,你可以使用 apply 陷阱函数来规避它:

function Numbers(...values) {

    if (typeof new.target === "undefined") {
        throw new TypeError("This function must be called with new.");
    }

    this.values = values;
}


let NumbersProxy = new Proxy(Numbers, {
        apply: function(trapTarget, thisArg, argumentsList) {
            return Reflect.construct(trapTarget, argumentsList);
        }
    });


let instance = NumbersProxy(1, 2, 3, 4);
console.log(instance.values);               // [1,2,3,4]

NumbersProxy 函数允许你调用 Numbers 而无须使用 new,并且让这种调用的效果与使用了 new 的情况保持一致。为此,apply 陷阱函数使用传给自身的参数去对 Reflect.construct() 方法进行了调用,于是 Numbers 内部的 new.target 就被设置为 Numbers,从而避免抛出错误。尽管这只是修改 new.target 的一个简单例子,但你还可以做得更加直接。

重写抽象基础类的构造器

你可以进一步指定 Reflect.construct() 的第三个参数,用于给 new.target 赋值。当函数把 new.target 与已知值进行比较的时候,例如在创建一个抽象基础类的构造器的场合下(参阅第九章),这么做会很有帮助。在抽象基础类的构造器中,new.target 被要求不能是构造器自身,正如这个例子:

class AbstractNumbers {

    constructor(...values) {
        if (new.target === AbstractNumbers) {
            throw new TypeError("This function must be inherited from.");
        }

        this.values = values;
    }
}

class Numbers extends AbstractNumbers {}

let instance = new Numbers(1, 2, 3, 4);
console.log(instance.values);           // [1,2,3,4]

// throws error
new AbstractNumbers(1, 2, 3, 4);

当 new AbstractNumbers() 被调用时,new.target 等于 AbstractNumbers,从而抛出了错误;而调用 new Numbers() 能正常工作,因为此时 new.target 等于 Numbers。你可以使用代理手动指定 new.target 从而绕过这个限制:

class AbstractNumbers {

    constructor(...values) {
        if (new.target === AbstractNumbers) {
            throw new TypeError("This function must be inherited from.");
        }

        this.values = values;
    }
}

let AbstractNumbersProxy = new Proxy(AbstractNumbers, {
        construct: function(trapTarget, argumentList) {
            return Reflect.construct(trapTarget, argumentList, function() {});
        }
    });


let instance = new AbstractNumbersProxy(1, 2, 3, 4);
console.log(instance.values);               // [1,2,3,4]

AbstractNumbersProxy 使用 construct 陷阱函数拦截了对于 new AbstractNumbersProxy() 方法的调用,这样陷阱函数就将一个空函数作为第三个参数传递给了 Reflect.construct() 方法,让这个空函数成为构造器内部的 new.target。由于此时 new.target 的值并不等于 AbstractNumbers, 就不会抛出错误,构造器可以执行完成。

可被调用的类构造器

第九章说明了构造器必须始终使用 new 来调用,原因是类构造器的内部方法 被明确要求抛出错误。然而代理可以拦截对于 方法的调用,意味着你可以借助代理有效创建一个可被调用的类构造器。例如,如果想让类构造器在缺少 new 的情况下能够工作,你可以使用 apply 陷阱函数来创建一个新实例。这里有个例子:

class Person {
    constructor(name) {
        this.name = name;
    }
}

let PersonProxy = new Proxy(Person, {
        apply: function(trapTarget, thisArg, argumentList) {
            return new trapTarget(...argumentList);
        }
    });


let me = PersonProxy("Nicholas");
console.log(me.name);                   // "Nicholas"
console.log(me instanceof Person);      // true
console.log(me instanceof PersonProxy); // true

PersonProxy 对象是 Person 类构造器的一个代理。类构造器实际上也是函数, 因此在使用代理时它的行为就像函数一样。apply 陷阱函数重写了默认的行为,返回 trapTarget(这里等于 Person)的一个实例, 此代码使用 trapTarget 以保证通用性,避免了手动指定特定的类。此处还使用了扩展运算符,将 argumentList 展开并传递给 trapTarget 方法。在没有使用 new 的情况下调用 PersonProxy(),获得了 Person 的一个新实例;而若你试图不使用 new 去调用 Person(),构造器仍然会抛出错误。创建一个可被调用的类构造器,是只有使用代理才能做到的。