ToPrimitive:对象转基础类型的完整算法
[] + [] 等于空字符串 "",{} + [] 等于 0——这两个表达式的结果截然相反,但它们共同遵循的只是一个名为 ToPrimitive 的算法。
🔹 Level 1 · 你需要知道的
何时 JS 会把对象转为基础类型
以下场景会触发对象到基础类型的隐式转换:
// 1. 模板字符串(string hint)
const obj = { toString() { return "hello"; } };
`${obj}` // "hello" ← 调用 toString()
// 2. + 运算符(default hint,大多数情况等同于 number hint)
obj + "" // "hello[object Object]" 或视情况而定
[] + 1 // "1"(数组转字符串再拼接)
// 3. 比较运算(default hint)
obj < 2 // 先转换 obj 为数字或字符串
// 4. if 判断(ToBoolean,不经过 ToPrimitive)
if (obj) { } // 所有对象都是 truthy,不触发 ToPrimitive
// 5. 算术运算(number hint)
obj * 2 // 触发 number hint 的 ToPrimitive
obj - 1
obj / 2
重要:if 判断不触发 ToPrimitive,它使用 ToBoolean,所有对象(包括 new Boolean(false))都是 truthy。
用 Symbol.toPrimitive 自定义转换行为
Symbol.toPrimitive 是控制对象转换的最高优先级手段:
const money = {
amount: 100,
currency: "USD",
[Symbol.toPrimitive](hint) {
// hint 是 "number"、"string" 或 "default" 之一
switch(hint) {
case "number":
return this.amount; // 算术运算时返回数字
case "string":
return `${this.amount} ${this.currency}`; // 模板字符串时返回格式化
default:
return this.amount; // + 运算等场景
}
}
};
console.log(+money); // 100(number hint)
console.log(`${money}`); // "100 USD"(string hint)
console.log(money + ""); // "100"(default hint → number → 转字符串)
console.log(money > 50); // true(number hint)
valueOf() 和 toString() 的调用时机
当没有 Symbol.toPrimitive 时,JS 按优先级调用这两个方法:
const obj = {
valueOf() {
console.log("valueOf called");
return 42;
},
toString() {
console.log("toString called");
return "obj";
}
};
// number hint:先 valueOf,再 toString
+obj; // "valueOf called" → 42
obj * 2; // "valueOf called" → 84
obj - 1; // "valueOf called" → 41
// string hint:先 toString,再 valueOf
`${obj}`; // "toString called" → "obj"
String(obj); // "toString called" → "obj"
// 如果 valueOf 返回非原始值,则继续调用 toString
const obj2 = {
valueOf() { return {}; }, // 返回对象(非原始值)
toString() { return "fallback"; }
};
+obj2; // "fallback"(valueOf 返回对象,继续调 toString)
Date 对象的特殊性:Date 覆盖了 Symbol.toPrimitive,使其在 "default" hint 时走 "string" 路径(而非 "number"):
const date = new Date("2024-01-01");
date + "" // "Mon Jan 01 2024 ..." 字符串拼接
date + 1 // "Mon Jan 01 2024 ...1" ← Date 走字符串路径
date * 1 // 1704067200000 ← * 强制 number hint,走数字路径
🔸 Level 2 · 它是怎么运行的
ToPrimitive 完整决策树
ToPrimitive(input, preferredType?)
│
├─ input 是原始类型(Undefined/Null/Boolean/Number/String/Symbol/BigInt)?
│ └─ YES → 直接返回 input(已经是原始值,无需转换)
│
└─ input 是 Object?
│
├─ 检查 input[Symbol.toPrimitive] 是否存在?
│ ├─ YES → 调用 input[Symbol.toPrimitive](hint)
│ │ hint = "number" | "string" | "default"
│ │ ├─ 返回值是原始类型 → 返回该值
│ │ └─ 返回值是对象 → 抛出 TypeError
│ │
│ └─ NO → 进入 OrdinaryToPrimitive(input, hint)
│
└─ OrdinaryToPrimitive(O, hint)
│
├─ hint 是 "string"?
│ └─ methodNames = ["toString", "valueOf"]
│
├─ hint 是 "number" 或 "default"?
│ └─ methodNames = ["valueOf", "toString"]
│
└─ 遍历 methodNames:
├─ 调用 O[methodName]()
├─ 结果是原始类型 → 返回
└─ 结果是对象 → 继续下一个
所有方法都返回对象 → 抛出 TypeError
hint 的来源
不同运算符触发不同的 hint:
hint = "string":
- 模板字符串 `${obj}`
- String(obj)(显式转换)
- 属性访问符中的键(obj[key] 中 key 被转字符串)
hint = "number":
- 一元 + 运算符(+obj)
- 一元 - 运算符(-obj)
- 算术运算(* / - %)
- 比较运算(< > <= >=)
- Number(obj)(显式转换)
hint = "default":
- 二元 + 运算符(obj + something)
- == 比较(obj == primitive)
- 未指定 preferredType 时的默认值
绝大多数内置对象:
"default" 的行为与 "number" 完全相同
Date 对象例外:
"default" 的行为与 "string" 相同
内置对象的 ToPrimitive 行为
| 对象类型 | hint=number | hint=string | hint=default |
|---|---|---|---|
| Array | valueOf() → 数组 → toString() → 逗号分隔 |
toString() → 逗号分隔 |
同 number |
| Date | valueOf() → 时间戳(毫秒数) |
toString() → 日期字符串 |
同 string |
| RegExp | valueOf() → RegExp对象 → toString() → /pattern/flags |
toString() → /pattern/flags |
同 number |
| 普通对象 | valueOf() → 对象 → toString() → [object Object] |
toString() → [object Object] |
同 number |
// 数组的 ToPrimitive 演示
const arr = [1, 2, 3];
+arr // NaN("1,2,3" 转 number 是 NaN)
`${arr}` // "1,2,3"
arr + "" // "1,2,3"
// 单元素数组的特殊情况
+[42] // 42("42" 转 number 是 42)
+[] // 0("" 转 number 是 0)
// 普通对象
const obj = {};
+obj // NaN("[object Object]" 转 number 是 NaN)
obj + "" // "[object Object]"
`${obj}` // "[object Object]"
🔺 Level 3 · 规范怎么定义的
7.1.1 ToPrimitive(input, preferredType)
规范原文(ECMA-262 第 7.1.1 节):
7.1.1 ToPrimitive ( input [ , preferredType ] )
The abstract operation ToPrimitive takes argument input (an ECMAScript language value) and optional argument preferredType (string or number) and returns either a normal completion containing an ECMAScript language value or a throw completion. It converts its input argument to a non-Object type. If an object is capable of converting to more than one primitive type, it may use the optional hint preferredType to favour that type.
- If input is an Object, then a. Let exoticToPrim be ? GetMethod(input, @@toPrimitive). b. If exoticToPrim is not undefined, then i. If preferredType is not present, let hint be "default". ii. Else if preferredType is string, let hint be "string". iii. Else, let hint be "number". iv. Let result be ? Call(exoticToPrim, input, « hint »). v. If result is not an Object, return result. vi. Throw a TypeError exception. c. If preferredType is not present, let preferredType be number. d. Return ? OrdinaryToPrimitive(input, preferredType).
- Return input.
7.1.1.1 OrdinaryToPrimitive(O, hint)
7.1.1.1 OrdinaryToPrimitive ( O, hint )
- If hint is string, then a. Let methodNames be « "toString", "valueOf" ».
- Else, a. Let methodNames be « "valueOf", "toString" ».
- For each element name of methodNames, do a. Let method be ? Get(O, name). b. If IsCallable(method) is true, then i. Let result be ? Call(method, O). ii. If result is not an Object, return result.
- Throw a TypeError exception.
关键点解读:
-
规范中
@@toPrimitive就是Symbol.toPrimitive。@@是规范内表示 Well-Known Symbol 的记号。 -
注意步骤 1.b.vi:如果
Symbol.toPrimitive返回对象,直接抛出 TypeError,不会继续尝试其他方法。 -
步骤 1.c:如果没有
Symbol.toPrimitive且preferredType未指定,则默认为number。这是 "default" hint 行为类似 "number" 的原因。 -
步骤 4:如果
valueOf和toString都返回对象,最终抛出 TypeError。
Date.prototype[Symbol.toPrimitive] 的特殊实现
Date 覆盖了 Symbol.toPrimitive,规范 Section 21.4.4.44:
21.4.4.44 Date.prototype [ @@toPrimitive ] ( hint )
- Let O be the this value.
- Perform ? RequireInternalSlot(O, [[DateValue]]).
- If hint is "string" or hint is "default", then a. Let tryFirst be string.
- Else if hint is "number", then a. Let tryFirst be number.
- Else, throw a TypeError exception.
- Return ? OrdinaryToPrimitive(O, tryFirst).
关键差异:Date 在 hint 为 "default" 时走 "string" 路径(第3步),而普通对象走 "number" 路径。这就是为什么 new Date() + 1 得到字符串拼接结果。
💎 Level 4 · 边界与陷阱
陷阱1:[] + [] = "" 的完整推导
表达式:[] + []
步骤1:+ 运算符,触发对两个操作数的 ToPrimitive(hint = "default")
步骤2:对左操作数 [] 执行 ToPrimitive([], "default")
- [] 没有 Symbol.toPrimitive
- "default" hint → 转为 "number" → 调用顺序:valueOf 先,toString 后
- [].valueOf() → [] (返回数组自身,是对象,不是原始值)
- [].toString() → "" (空数组的 toString 是空字符串)
- 结果:""
步骤3:对右操作数 [] 执行 ToPrimitive([], "default")
- 同上,结果:""
步骤4:+ 运算,两个操作数 "" 和 ""
- 其中一个是字符串 → 做字符串拼接
- "" + "" = ""
最终结果:""
陷阱2:[] + {} 与 {} + [] 的解析差异
这是 JavaScript 中最著名的"奇怪行为"之一:
[] + {} // "[object Object]"
{} + [] // 0(或在某些环境是 "[object Object]")
为什么结果不同?
[] + {} 作为表达式:
[] 的 ToPrimitive → ""
{} 的 ToPrimitive → "[object Object]"
"" + "[object Object]" = "[object Object]"
{} + [] 作为语句(在控制台直接输入时):
{} 被解析为空代码块(block statement)
+ [] 是一元 + 运算符对 [] 的操作
ToNumber([]) → ToNumber("") → 0
结果:0
{} + [] 作为表达式(放在赋值右侧或括号中):
({} + []) → "[object Object]"(正确解析为对象字面量)
验证:
// 强制作为表达式求值
console.log({} + []); // "[object Object]"(被包在函数调用中)
let x = {} + []; // "[object Object]"(赋值右侧是表达式)
// 语句层面(只在直接输入到 REPL 时出现)
// {} + [] 在 Node.js REPL 中 → 0
陷阱3:new Date() + 1 为什么走字符串路径
new Date() + 1 的推导:
步骤1:+ 运算符,对 new Date() 执行 ToPrimitive(hint = "default")
步骤2:Date 有自定义的 Symbol.toPrimitive
Date[Symbol.toPrimitive]("default")
→ hint 是 "default",Date 特殊处理:走 "string" 路径
→ OrdinaryToPrimitive(date, "string")
→ 调用顺序:toString 先
→ date.toString() → "Tue Jan 01 2025 ..."(日期字符串)
步骤3:对 1 不需要转换(已是原始值)
步骤4:+ 运算,一个操作数是字符串 → 字符串拼接
"Tue Jan 01 2025 ..." + "1" = "Tue Jan 01 2025 ...1"
如果想要日期的时间戳加法:
// ✅ 强制 number hint
+new Date() + 1 // 1704067200001(时间戳+1)
new Date().valueOf() + 1 // 1704067200001
Date.now() + 1 // 1704067200001(推荐)
陷阱4:自定义 Symbol.toPrimitive 返回对象会 TypeError
这是一个容易在调试时忽略的规范行为:
const obj = {
[Symbol.toPrimitive](hint) {
return {}; // ← 返回对象!
}
};
// 触发 ToPrimitive 的任何操作都会抛 TypeError
`${obj}` // TypeError: Cannot convert object to primitive value
+obj // TypeError: Cannot convert object to primitive value
obj + "" // TypeError: Cannot convert object to primitive value
对比没有 Symbol.toPrimitive 但 valueOf 和 toString 都返回对象:
const obj2 = {
valueOf() { return {}; },
toString() { return {}; },
};
// 同样 TypeError,但错误信息稍有不同
+obj2 // TypeError: Cannot convert object to primitive value
实际业务中的陷阱:
// ❌ 错误:Symbol.toPrimitive 必须返回原始值
class Price {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
[Symbol.toPrimitive](hint) {
// 忘记了需要返回原始值
if (hint === 'number') return { value: this.amount }; // BUG!
return this.toString(); // string hint 是对的
}
}
// ✅ 正确
class Price {
[Symbol.toPrimitive](hint) {
if (hint === 'number') return this.amount; // 返回原始数值
if (hint === 'string') return `${this.amount} ${this.currency}`;
return this.amount; // default
}
}
陷阱5:ToPrimitive 与 JSON.stringify 的关系
JSON.stringify 不走 ToPrimitive,它走 toJSON 方法:
const obj = {
value: 42,
valueOf() { return 100; }, // ToPrimitive 调用这个
toString() { return "hundred"; }, // ToPrimitive 备用
toJSON() { return { val: this.value }; } // JSON.stringify 调用这个
};
+obj // 100(ToPrimitive → valueOf)
`${obj}` // "hundred"(ToPrimitive → toString,因为 valueOf 返回数字)
// 等等,这里需要重新分析:
// string hint → 先 toString → "hundred"(字符串,是原始值)→ 直接返回
JSON.stringify(obj) // '{"val":42}'(走 toJSON,完全绕过 ToPrimitive)
Date 的 toJSON 会在 JSON.stringify 时被调用:
const date = new Date("2024-01-01");
JSON.stringify(date) // '"2024-01-01T00:00:00.000Z"'(date.toJSON() 的返回值)
date.toJSON() // "2024-01-01T00:00:00.000Z"(ISO 字符串)
本章小结
-
ToPrimitive 有三种 hint:"number"(算术运算)、"string"(模板字符串、String())、"default"(二元 + 和 ==)。绝大多数对象的 "default" 行为与 "number" 相同;Date 是唯一的 "default" 走 "string" 路径的内置对象。
-
Symbol.toPrimitive 是最高优先级:只要对象定义了
Symbol.toPrimitive,ToPrimitive 就会直接调用它,完全跳过valueOf和toString。但它返回的值必须是原始类型,否则抛出 TypeError。 -
OrdinaryToPrimitive 的调用顺序取决于 hint:number hint 先调
valueOf再调toString;string hint 先调toString再调valueOf。理解这个顺序是解读[] + {}等奇怪表达式的关键。 -
[] + []为""的原因:数组的valueOf()返回自身(对象),继续调toString()得到空字符串"",两个空字符串相加仍是""。 -
{} + []的上下文敏感性:作为语句时{}被解析为空代码块,+[]成为一元运算,结果为0;作为表达式时{}是对象字面量,结果为"[object Object]"。永远不要依赖这种上下文差异,写代码时用括号明确意图。