为什么 ES2015 中代理到 Map 对象不起作用?

16
我正在通过 Google Chrome 版本 57.0.2987.133 运行以下脚本:

var loggingProxyHandler = {
    "get" : function(targetObj, propName, receiverProxy) {
        let ret = Reflect.get(targetObj, propName, receiverProxy);
        console.log("get("+propName.toString()+"="+ret+")");
        return ret;
     },

     "set" : function(targetObj, propName, propValue, receiverProxy) {
         console.log("set("+propName.toString()+"="+propValue+")");
         return Reflect.set(targetObj, propName, propValue, receiverProxy);
     }
};

function onRunTest()
{
    let m1 = new Map();
    let p1 = new Proxy(m1, loggingProxyHandler);
    p1.set("a", "aval");   // Exception thrown from here
}

onRunTest();
NOTE: Requires a browser supporting ES2015's Proxy

当运行时,我看到处理程序的get陷阱被调用以返回Map的set函数,然后我收到以下错误:
"Uncaught TypeError: Method Map.prototype.set called on incompatible receiver [object Object]"
at Proxy.set (native)
...

我尝试从loggingProxyHandler中删除陷阱函数(使其成为空对象),但仍然收到相同的错误。

我的理解是,代理对象应该能够为所有本地ES5和ES2015 JavaScript对象生成。在相同的代理处理程序下,数组似乎工作得很好。 我是否误解了规范?
我的代码是否缺少某些内容? Chrome中是否存在已知的错误?(我进行了搜索,没有找到关于此主题的Chrome缺陷。)


1
听起来你实际想要做的是覆盖(拦截)setget调用,而不是通过代理路由所有属性访问 - Bergi
为了明确起见:不要使用Proxy来拦截超出正常Object语义的奇特行为。请改用子类化。 - user6445533
2
为了更清晰地表达:有一些完全合理的情况,你可能需要最终代理一个映射,特别是当你不拥有原始对象图,但希望观察任何更改时。 - theMayer
2个回答

33
你收到此错误的原因是代理在 p1.set() 方法调用中没有参与(除了使用 get 拦截来检索函数引用)。因此,一旦函数引用被检索,它就会使用 this 设置为代理 p1,而不是 m1 map —— 这是 Mapset 方法不喜欢的。
如果你真的想拦截 Map 上的所有属性访问调用,你可以通过绑定从 get 返回的任何函数引用来修复它(请参见 *** 行):

const loggingProxyHandler = {
    get(target, name/*, receiver*/) {
        let ret = Reflect.get(target, name);
        console.log(`get(${name}=${ret})`);
        if (typeof ret === "function") {    // ***
          ret = ret.bind(target);           // ***
        }                                   // ***
        return ret;
     },

     set(target, name, value/*, receiver*/) {
         console.log(`set(${name}=${value})`);
         return Reflect.set(target, name, value);
     }
};

function onRunTest() {
    const m1 = new Map();
    const p1 = new Proxy(m1, loggingProxyHandler);
    p1.set("a", "aval");
    console.log(p1.get("a")); // "aval"
    console.log(p1.size);     // 1
}

onRunTest();
NOTE: Requires a browser supporting ES2015's Proxy

注意调用Reflect.getReflect.set时,我们不传递接收器(实际上,在这些函数中根本没有使用receiver参数,因此我已经将其注释掉)。这意味着它们将使用目标本身作为接收器,如果属性是访问器(如Mapsize属性)并且需要将其this设置为实际实例(如Mapsize所需),则需要这样做。

但是,如果您只想拦截Map#getMap#set,则根本不需要代理。可以:

  1. 创建一个Map子类并实例化该子类。不过,这要求您控制Map实例的创建。

  2. 创建一个继承自Map实例的新对象,并覆盖getset;您不必控制原始Map的创建。

  3. 替换Map实例上的setget方法为自己的版本。

以下是#1:

class MyMap extends Map {
  set(...args) {
    console.log("set called");
    return super.set(...args);
  }
  get(...args) {
    console.log("get called");
    return super.get(...args);
  }
}

const m1 = new MyMap();
m1.set("a", "aval");
console.log(m1.get("a"));

#2:

const m1 = new Map();
const p1 = Object.create(m1, {
  set: {
    value: function(...args) {
      console.log("set called");
      return m1.set(...args);
    }
  },
  get: {
    value: function(...args) {
      console.log("get called");
      return m1.get(...args);
    }
  }
});

p1.set("a", "aval");
console.log(p1.get("a"));

#3:

const m1 = new Map();
const m1set = m1.set; // Yes, we know these are `Map.prototype.set` and
const m1get = m1.get; // `get`, but in the generic case, we don't necessarily
m1.set = function(...args) {
  console.log("set called");
  return m1set.apply(m1, args);
};
m1.get = function(...args) {
  console.log("get called");
  return m1get.apply(m1, args);
}

m1.set("a", "aval");
console.log(m1.get("a"));


看起来如果我想在这里实现某种程度的AOP,我需要使用子类机制。但是访问Map的size属性会有问题,因为它不是通过方法调用实现的。当我添加了推荐的绑定后,确实避开了异常,但是一旦函数通过get陷阱绑定到targetObj上,set陷阱将不会被调用,从而使代理失效。我想,如果我想追求代理,我可以尝试生成一个全新的函数,绑定到targetObject上,但通过闭包调用代理处理程序陷阱。 - Rand
@Rand:是的。没有理由调用set陷阱,因为没有任何东西在设置属性。我不明白为什么访问size会有问题:属性是继承的,只需访问它即可。 - T.J. Crowder
@T.J.Crowder 你说“我不明白为什么访问size会有问题:属性是继承的,只需访问它。” 我也不明白,但确实存在问题。在Chrome 59上,你的第一个示例可以工作,但如果我添加console.log(p1.size),它会给出Uncaught TypeError: Method Map.prototype.size called on incompatible receiver [object Object]。有什么想法吗?你的示例是我见过的最接近工作的Map代理。 - Don Hatch
嗯,显然我可以通过在第一个示例中的Reflect.get()调用之前插入if (name === 'size') { return targetObj.size; }来使代理完全可用。不知道为什么,但这太好了!我认为它将解决我正在处理的另一个问题:http://stackoverflow.com/questions/43801605/ - Don Hatch
@DonHatch 这似乎表明 .size 属性是一个 getter,它在 this 方面的作用类似于一个函数。如果我没弄错的话,另一种方法是从描述符中获取 getter 并调用它:Object.getOwnPropertyDescriptor(target, 'size').get.call(target),这应该可以解决问题,这与 target.size 的效果是相同的。 :) - trusktr
显示剩余2条评论

10
让我补充一些内容。
许多内置对象,例如MapSetDatePromise和其他对象都使用所谓的内部插槽(internal slots)
这些类似于属性但是为内部规范目的而保留。例如,Map在内部插槽[[MapData]]中存储项。内置方法直接访问它们,而不是通过[[Get]]/[[Set]]内部方法。因此Proxy不能拦截。
例如:

let map = new Map();

let proxy = new Proxy(map, {});

proxy.set('name', 'Pravin'); // Error

在内部,Map 将所有数据存储在其 [[MapData]] 内部槽中。而代理并没有这样的槽。内置方法 Map.prototype.set 方法尝试访问内部属性 this.[[MapData]],但因为 this=proxy,所以无法在代理中找到它,只能失败。

有一种方法可以修复它:

let map = new Map();
let proxy = new Proxy(map,{
    get(target,prop,receiver){
        let value = Reflect.get(...arguments);
        return typeof value === 'function'?value.bind(target):value;
    }
});
proxy.set('name','Pravin');
console.log(proxy.get('name')); //Pravin (works!)

现在它运行良好,因为get陷阱将函数属性(例如map.set)绑定到目标对象(map)本身。 因此,在proxy.set(...)内部的this值将不是proxy,而是原始map。 因此,当set的内部实现尝试访问this.[[MapData]]内部插槽时,它将成功。

谢谢您的回答。您如何判断一个变量是否具有这些内部插槽之一? - Andrew
1
@Andrew 在 ECMAScript 引擎中,每个对象都与一组内部方法相关联,这些方法定义了其运行时行为。这些内部方法不是 ECMAScript 语言的一部分。它们仅出于说明目的而由本规范定义。但是,在 ECMAScript 实现中的每个对象必须按照与其相关联的内部方法所指定的方式进行操作。实现这一点的确切方式由实现确定。 - Pravin Divraniya
1
内部插槽对应于与对象相关联并由各种ECMAScript规范算法使用的内部状态。内部插槽不是对象属性,也不会被继承。根据特定的内部插槽规范,这种状态可能由任何ECMAScript语言类型或特定的ECMAScript规范类型值组成。 - Pravin Divraniya
2
“内部插槽”是一种实现细节。它们不是对象属性。JavaScript程序员不需要知道内部插槽,但对于解释一些JavaScript行为非常有用。希望这可以帮到你。 - Pravin Divraniya

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接