Object to Primitive

April 10, 2020

前言

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

js
let a;
// Do something with a...
(a == 1 && a == 2 && a == 3) === true;
js
let a;
// Do something with a...
(a == 1 && a == 2 && a == 3) === true;

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

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

Proxy 对象的 get(),和 Object.defineProperty(obj, propertyName, { get: ... }) 的作用非常相似,但 Proxy 不需要指明 propertyName.

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

隐式转换

虽然从字面上看不出显式访问,但不要忽略了一个重要条件,即非严格相等运算符 ==,它会将等式两边的值做出相应的 隐式转换(又称强制类型转换) 再进行比较。

而隐式转换的最终目的,是将等式两边的值都转换为 原始值(Primitive value),例如:string、number、boolean...

js
console.log(0 == false);
// true
js
console.log(0 == false);
// true

实际上,如果操作数之一是 Boolean,则将布尔操作数转换为 1 或 0,即 Number(false) => 0,具体细则可参照 抽象相等比较算法

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

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

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

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

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

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

控制台打印出以下内容:

可以看出,Object 在隐式转换的过程中,会依次访问 Symbol(Symbol.toPrimitive)valueOftoString.

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

为了简化问题,我定义了一个普通的对象(POJO):

js
const obj = {};
"hello " + obj; // 触发隐式转换
js
const obj = {};
"hello " + obj; // 触发隐式转换

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

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

obj.valueOf() 返回本身 {},不是原始值,继续调用下一个方法。

obj.toString() 返回 "[object Object]",属于基本数据类型 string,隐式转换成功,控制台打印出 "hello [object Object]".

为什么返回 "[object Object]",可 查看此处

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

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

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

而这一切,在 ECMAScript Language Specification - 7.1.1 ToPrimitive 中早有定义,它指明了 JavaScript 引擎是如何按照步骤把 object 转换为 primitive.

这里的 @@toPrimitive 就是指 Symbol.toPrimitive.

注意这个 hint,它会随着表达式和运算符的不同而改变,从而让一个对象能转化成多种原始值。

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

Symbol.toPrimitive 没有被定义时,会继续执行 OrdinaryToPrimitive 抽象操作,同时 hint 会决定 toString()valueOf() 的先后调用顺序。

查看源码

Talk is cheap. Show me the code. —— Quote by Linus Torvalds

有规范,自然就有对应的实现,通过 source.chromium.org,你可以用 “关键词搜索” 快速定位至源码,例如搜索之前规范中出现的 “OrdinaryToPrimitive”,就能看到很多相关的代码片段。

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

c
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);
}
c
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);
}

懂的人自然懂,但犹如残疾人一般 C 语言水平的我两眼一黑,只得继续翻看其他文件。

很快发现 third_party/devtools-frontend/src/node_modules/es-to-primitive/es2015.js 文件同样包含该关键词,并且包含了 ToPrimitive 的具体 JS 实现。

js
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");
};
js
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");
};
js
module.exports = function ToPrimitive(input) {
if (isPrimitive(input)) {
return input;
}
var hint = "default";
if (arguments.length > 1) {
if (arguments[1] === String) {
hint = "string";
} else if (arguments[1] === Number) {
hint = "number";
}
}
var exoticToPrim;
if (hasSymbols) {
if (Symbol.toPrimitive) {
exoticToPrim = GetMethod(input, Symbol.toPrimitive);
} else if (isSymbol(input)) {
exoticToPrim = Symbol.prototype.valueOf;
}
}
if (typeof exoticToPrim !== "undefined") {
var result = exoticToPrim.call(input, hint);
if (isPrimitive(result)) {
return result;
}
throw new TypeError("unable to convert exotic object to primitive");
}
if (hint === "default" && (isDate(input) || isSymbol(input))) {
hint = "string";
}
return ordinaryToPrimitive(input, hint === "default" ? "number" : hint);
};
js
module.exports = function ToPrimitive(input) {
if (isPrimitive(input)) {
return input;
}
var hint = "default";
if (arguments.length > 1) {
if (arguments[1] === String) {
hint = "string";
} else if (arguments[1] === Number) {
hint = "number";
}
}
var exoticToPrim;
if (hasSymbols) {
if (Symbol.toPrimitive) {
exoticToPrim = GetMethod(input, Symbol.toPrimitive);
} else if (isSymbol(input)) {
exoticToPrim = Symbol.prototype.valueOf;
}
}
if (typeof exoticToPrim !== "undefined") {
var result = exoticToPrim.call(input, hint);
if (isPrimitive(result)) {
return result;
}
throw new TypeError("unable to convert exotic object to primitive");
}
if (hint === "default" && (isDate(input) || isSymbol(input))) {
hint = "string";
}
return ordinaryToPrimitive(input, hint === "default" ? "number" : hint);
};

我特意指出源代码,是为了证明源码是有迹可循的,它与 ECMAScript Language Specification 密不可分。

其他解法

巧用字符 · 障眼法

在庞大的字符库中,选择三个形态相似的字符 “a”,即可使等式成立。

不过,障眼法毕竟是障眼法,一旦变更为其他字体就会原形毕露。

js
let a = 1;
let= 2;
let а = 3;
if (a == 1 &&== 2 && а == 3) {
console.log("awesome!");
}
js
let a = 1;
let= 2;
let а = 3;
if (a == 1 &&== 2 && а == 3) {
console.log("awesome!");
}

巧用数组

js
let a = [1, 2, 3];
a.join = a.shift;
if (a == 1 && a == 2 && a == 3) {
console.log("awesome!");
}
js
let a = [1, 2, 3];
a.join = a.shift;
if (a == 1 && a == 2 && a == 3) {
console.log("awesome!");
}

根据 规范,数组在 ToPrimitive 时,调用Array.prototype.toString(),其内部调用了 Array.prototype.join().

Reference


B2D1 (包邦东)

Written by B2D1 (包邦东)