先记住这个答案
对象参与运算时,引擎会先把它转成原始值,这一步叫 ToPrimitive,会带一个 hint 说明「这次想要什么类型」。hint 是 string 时,先调 toString,拿不到原始值再调 valueOf;hint 是 number 或 default 时,顺序反过来,先 valueOf 再 toString。所以并不存在一个固定的「先谁后谁」,要看是哪种运算触发的转换。如果对象上定义了 Symbol.toPrimitive,引擎只调它,把 hint 当参数传进去,另外两个方法都不会碰。
- 转字符串通常先试
toString - 普通对象的默认提示先试
valueOf Symbol.toPrimitive优先参与转换- 两个方法都返回对象时抛
TypeError
同一个对象,四种写法四种顺序
最直观的办法是给对象同时写上 valueOf 和 toString,每个方法被调用时打一行日志,然后换着花样去用它。
const obj = {
valueOf() { console.log('调用 valueOf'); return 42 },
toString() { console.log('调用 toString'); return 'obj' }
}
console.log('String(obj):')
console.log(String(obj)) // hint 是 string
console.log('模板字符串:')
console.log(`${obj}`) // hint 是 string
console.log("obj + '':")
console.log(obj + '') // hint 是 default
console.log('+obj:')
console.log(+obj) // hint 是 number查看输出与解释
String(obj):
调用 toString
obj
模板字符串:
调用 toString
obj
obj + '':
调用 valueOf
42
+obj:
调用 valueOf
42最容易答错的是第三个。很多人觉得「跟空字符串相加,肯定是转字符串」,但二元 + 在转换对象时还不知道另一边是不是字符串,所以传的是 default。普通对象把 default 当 number 处理,先调 valueOf 拿到 42,然后才发现另一边是字符串,再把 42 拼成 '42'。
平时感觉不到这一点,是因为普通对象继承来的 valueOf 返回对象本身,不是原始值,引擎只好接着调 toString,最后得到 '[object Object]'。只有自己写了 valueOf,顺序的差别才会露出来。
用 Symbol.toPrimitive 自定义转换
valueOf 和 toString 是「被动」的:你只能决定返回什么,没法知道这次是谁在要。Symbol.toPrimitive 会直接收到 hint,可以按场景返回不同的值。
const price = {
amount: 99,
[Symbol.toPrimitive](hint) {
console.log('hint =', hint)
if (hint === 'string') return `¥${this.amount}`
return this.amount // number 和 default 都返回数字
}
}
console.log(`${price}`) // ¥99
console.log(price * 2) // 198
console.log(price + 1) // 100
console.log(price == 99) // true查看输出与解释
hint = string
¥99
hint = number
198
hint = default
100
hint = default
true展示的时候拿到带货币符号的字符串,计算的时候拿到数字,同一个对象两种用法都顺手。== 和二元 + 一样传 default,这也印证了上一节的结论。
这个方法必须返回原始值,返回对象会直接抛 TypeError,不会再回退到 valueOf 或 toString。
返回值不是原始值时怎么回退
valueOf 和 toString 各自被调用时,只要返回的是原始值就采用;返回对象就当它没给结果,接着试下一个。两个都失败才报错。
// toString 返回对象,不算数,接着试 valueOf
const a = { toString() { return {} }, valueOf() { return 'from valueOf' } }
console.log(String(a))
// 两个都返回对象,直接报错
const b = { toString() { return {} }, valueOf() { return {} } }
try { String(b) } catch (e) { console.log(e.name) }
// 没有原型的对象,两个方法都找不到
const c = Object.create(null)
try { `${c}` } catch (e) { console.log(e.name) }查看输出与解释
from valueOf
TypeError
TypeError第三种在项目里真会遇到。Object.create(null) 常被拿来做纯字典,它没有继承任何方法,一旦被拼进日志字符串就会抛错,所以打印这类对象要用 JSON.stringify。
Date为什么不一样Date.prototype上自带一个Symbol.toPrimitive,收到 default 时按 string 处理。所以date + 1得到类似'Tue Sep 29 2026 ... 1'的字符串,而date - 0和+date走 number,得到时间戳。想拿时间戳时,写date.getTime()最清楚。
容易答错的地方
- 说「对象转换总是先
valueOf再toString」 - 这只对 number 和 default 成立。
String(obj)、模板字符串、把对象当属性名(map[obj])都是 string hint,先调toString。回答时要把 hint 说出来,否则面试官一问String(obj)就露馅。 - 以为
obj + ''和String(obj)等价 - 两者只在对象没有自定义
valueOf时结果一样。前者先调valueOf,后者先调toString。上面的例子里一个得到'42',一个得到'obj'。 - 以为
Symbol.toPrimitive返回对象会回退 - 不会。
Symbol.toPrimitive存在就只用它,返回对象直接抛TypeError。回退逻辑只发生在没有定义它、走valueOf和toString的时候。
面试官还会怎么问?
[] + {} 结果是什么,为什么?
结果是 '[object Object]'。二元 + 对两边都用 default 转换:数组的 valueOf 返回数组本身,不是原始值,于是调 toString 得到空字符串;对象同理得到 '[object Object]'。只要有一边是字符串,+ 就做拼接,所以结果是两者相连。
怎么让 a == 1 && a == 2 && a == 3 成立?
利用 == 会对对象做 default 转换、每次比较都重新调用转换方法这一点。给 a 写一个 valueOf,内部维护一个计数器,每调一次加 1 并返回。三次比较分别拿到 1、2、3,表达式就成立。换成 === 就不行,因为严格相等不做类型转换。
自定义 toString 会影响 console.log(obj) 吗?
一般不会。console.log 打印对象时由控制台自己做格式化展示,不会走 ToPrimitive。只有把对象拼进字符串,比如 console.log('值:' + obj) 或模板字符串,才会调用你的 toString。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。