Proxy 对象转 Primitive

April 10, 2020

ECMAScript

前言

本文从一道题目说起,使下列等式成立:

a == 1 && a == 2 && a == 3 === true;

这道题目有很多解法,但最为常见是通过 ES2015 的 Proxy 代理对象实现:

const a = new Proxy(
  {},
  {
    val: 1,
    get() {
      return () => this.val++;
    },
  }
);

Proxy 对象的 get(),和 Object.defineProperty(obj, propertyName, {get: ...}) 的作用非常相似,但 Proxy 不需要指明 propertyName,只通过 get() 就可以包揽所有情况。

此时,a 作为空对象 {} 的代理对象,任何尝试访问 a 对象属性的操作都会被 get() 方法拦截,但 a == 1 没有显式声明访问 a 的属性,那 get() 怎么会被执行?

隐式转换

虽然从字面上看不出显式访问,但不要忽略了一个重要条件,即非严格相等 ==,它会将等式两边的值做出相应的 隐式转换

而隐式转换的最终目的,是将等式两边的值都转换为 原始值(Primitive value),即 7 种基本类型:string,number,bigint,boolean,null,undefined,symbol。

1 == true;
// true
// 实际上,执行了 1 == Number(true)
1 === true;
// false

当执行 a == 1 时,a 作为一个 Proxy 对象,无例外地执行了隐式转换,但转换过程依旧无从得知。

但我推测:Proxy 其本质是 Object,有可能调用了原型链(prototype)上的方法。

MDN 指出,get() 方法会拦截代理对象的以下操作:

  • 访问自身属性
  • 访问原型链上的属性
  • Reflect.get()

为了验证我的想法,打印出具体的 propertyName

var p = new Proxy(
  {},
  {
    get: function(target, propertyName) {
      console.log(propertyName);
    },
  }
);
p + ""; // 同样触发了隐式转换

控制台打印出以下内容:

可以看出,object 在隐式转换为原始值的过程中,会依次调用 Symbol(Symbol.toPrimitive)valueOftoString 这 3 种 propertyName。

显然,这对应了 3 个函数的名称,即当其中任意一个函数被调用并 return 原始值时,隐式转换成功,否则抛出错误 Uncaught TypeError: Cannot convert object to primitive value

为了简化问题,定义一个普通的 object:

const obj = {};
console.log("hello " + obj); // 触发隐式转换

首先,调用 obj[Symbol.toPrimitive](),因为我没有定义该方法,所以无法返回原始值。

其次调用 valueOf(),它和 toString() 都位于 Object.prototype:

obj.valueOf() 返回本身 {},不是原始值。

obj.toString() 返回 "[object Object]",属于原始类型 String,隐式转换成功。

控制台打印出 "hello [object Object]"

回到最初的问题,在 a 进行隐式转换的第一步,即访问 a[Symbol.toPrimitive] 时,被 get() 所拦截。

get() 返回 () => this.val++,返回的函数将被调用,并且 get() 中的 this 上下文绑定在 handler 对象上。

const handler = {
  val: 1,
  get() {
    return () => this.val++;
  },
};
const a = new Proxy({}, handler);

a == 1; // true
a == 2; // true
a == 3; // true

这一系列操作可以视作间接执行了 a[Symbol.toPrimitive]()

而这一切,在 ES2015 spec 中早有定义,它指明了 JavaScript 引擎是如何按照步骤把 object 转换为 string,我已经把和文章主题不相关的部分删去了。

  1. Let exoticToPrim be GetMethod(input, @@toPrimitive).
  2. ReturnIfAbrupt(exoticToPrim). (We’ve found our string/primitive, so return it.)
  3. If hint is “string”, then

    a. Let methodNames be «”toString”, “valueOf”».

  4. Else, a. Let methodNames be «”valueOf”, “toString”».
  5. For each name in methodNames in List order, do a. Let method be Get(Object, name).

    b. ReturnIfAbrupt(method).

    c. If IsCallable(method) is true, then

    i. Let result be Call(method, Object).

    ii. ReturnIfAbrupt(result).

  6. Throw a TypeError exception.

注意这个 hint,它会随着表达式而改变,所以会影响 toString()valueOf() 的先后调用顺序。

const person = {
  [Symbol.toPrimitive](hint) {
    if (hint === "string") {
      return "I am string";
    } else if (hint === "number") {
      return 6;
    } else if (hint === "default") {
      return "I am default";
    }
  },
};
console.log(`${person}`);
console.log(person + "");
console.log(person * 1);

查看源码

强烈建议大家可以去看 Chrome source code,你可以用关键词搜索 Chrome 抛出的错误信息,来快速查找相关部分的源码,例如输入 “Cannot convert object to primitive value”

我特意指出源代码的主要原因,是因为它与 ES 规范非常相似。

查看 v8/src/objects/js-objects.cc/ 目录下的 OrdinaryToPrimitive:

MaybeHandle<Object> JSReceiver::OrdinaryToPrimitive(
    Handle<JSReceiver> receiver, OrdinaryToPrimitiveHint hint) {
  Isolate* const isolate = receiver->GetIsolate();
  Handle<String> method_names[2];
  switch (hint) {
    case OrdinaryToPrimitiveHint::kNumber:
      method_names[0] = isolate->factory()->valueOf_string();
      method_names[1] = isolate->factory()->toString_string();
      break;
    case OrdinaryToPrimitiveHint::kString:
      method_names[0] = isolate->factory()->toString_string();
      method_names[1] = isolate->factory()->valueOf_string();
      break;
  }
  for (Handle<String> name : method_names) {
    Handle<Object> method;
    ASSIGN_RETURN_ON_EXCEPTION(isolate, method,
                               JSReceiver::GetProperty(isolate, receiver, name),
                               Object);
    if (method->IsCallable()) {
      Handle<Object> result;
      ASSIGN_RETURN_ON_EXCEPTION(
          isolate, result,
          Execution::Call(isolate, method, receiver, 0, nullptr), Object);
      if (result->IsPrimitive()) return result;
    }
  }
  THROW_NEW_ERROR(isolate,
                  NewTypeError(MessageTemplate::kCannotConvertToPrimitive),
                  Object);
}

作为 JS 开发者,这段代码确实比较难读懂。

不过我们可以借助 thirdparty/devtools-frontend/src/nodemodules/object.values/node_modules/es-to-primitive/es2015.js 目录下的 devtools 代码,以一种熟悉的方式了解该过程。

var ordinaryToPrimitive = function OrdinaryToPrimitive(O, hint) {
  if (typeof O === "undefined" || O === null) {
    throw new TypeError("Cannot call method on " + O);
  }
  if (typeof hint !== "string" || (hint !== "number" && hint !== "string")) {
    throw new TypeError('hint must be "string" or "number"');
  }
  var methodNames =
    hint === "string" ? ["toString", "valueOf"] : ["valueOf", "toString"];
  var method, result, i;
  for (i = 0; i < methodNames.length; ++i) {
    method = O[methodNames[i]];
    if (isCallable(method)) {
      result = method.call(O);
      if (isPrimitive(result)) {
        return result;
      }
    }
  }
  throw new TypeError("No default value");
};

参考资料:


Written by B2D1(包邦东)