先记住这个答案
Object.prototype.toString 的返回值格式固定是 [object 标签]。标签的来源是这样的:先根据对象的内部类型得到一个默认标签,比如数组是 Array,普通对象是 Object;然后去读对象的 Symbol.toStringTag 属性,如果读到的是字符串,就用它替换默认标签。读的时候会走原型链,所以一般在类里定义一个 getter:get [Symbol.toStringTag]() { return 'Validator' },这个类的所有实例调用 toString 都会得到 [object Validator]。
Symbol.toStringTag可改类型标签- 通常在类里写成原型上的 getter
Map、Promise的标签就是靠这个属性来的- 标签可以伪造,数组都能被改成别的标签
自定义标签,以及它能改到什么程度
下面同时演示自定义类、内置对象,以及一个故意伪造的数组。
const toString = Object.prototype.toString
class Validator {
get [Symbol.toStringTag]() { return 'Validator' } // 定义在原型上
}
const v = new Validator()
console.log(toString.call(v))
console.log(String(v)) // 实例继承的 toString 就是这一个
console.log(toString.call(new Map()), toString.call(Promise.resolve()))
const arr = [1]
arr[Symbol.toStringTag] = 'NotArray'
console.log(toString.call(arr), Array.isArray(arr))
console.log(toString.call({ [Symbol.toStringTag]: 123 })) // 不是字符串,忽略
console.log(toString.call(null))查看输出与解释
[object Validator]
[object Validator]
[object Map] [object Promise]
[object NotArray] true
[object Object]
[object Null]String(v) 也变了,因为 v 没有自己的 toString,最终调用的就是 Object.prototype.toString,模板字符串 ${v} 同理。
Map 和 Promise 并没有专门的内部标签,它们的标签来自 Map.prototype[Symbol.toStringTag] 这个内置属性,值就是字符串 'Map'。JSON、Math、Symbol、ArrayBuffer、生成器对象也都是这样。在 ES6 之前,这些对象要么不存在,要么只能返回 [object Object]。
数组那一行最能说明问题:一个真正的数组,只要加上这个属性,toString 就会报出假标签,而 Array.isArray 不受影响。标签值不是字符串时会被忽略,退回默认标签。
为什么写成原型上的 getter
也可以在构造函数里写 this[Symbol.toStringTag] = 'Validator',结果一样,但每个实例都多一个自身属性。写成 getter 放在原型上只有一份,而且它不可枚举,Object.keys、JSON.stringify、展开运算符都不会碰到它,不会污染数据。
还有一个细节:用 getter 可以根据状态返回不同标签,但这样做会让调试更困惑,实际项目里一般返回固定字符串。
这对类型判断意味着什么
很多工具库用 Object.prototype.toString.call(x) === '[object Array]' 判断类型,因为它能跨 iframe,还能区分 null。有了 Symbol.toStringTag 之后,这种判断对普通对象就不再可靠了,任何对象都能把自己的标签写成 Date。判断数组要用 Array.isArray,判断其他内置类型也最好用专门的方法,或者检查内部行为,比如调用 Date.prototype.getTime.call(x) 看会不会抛错。
容易答错的地方
- 以为改了标签会影响
typeof或instanceof - 不会。
typeof v还是'object',v instanceof Validator还是true。instanceof能被定制的是另一个内置符号Symbol.hasInstance。 - 以为自定义了
toString方法也会用到这个标签 Symbol.toStringTag只被Object.prototype.toString读取。如果类里自己写了toString()方法,String(v)调的就是你写的那个,标签完全不参与。但Object.prototype.toString.call(v)仍然会用标签。- 以为数组、函数的标签改不了
- 可以改。默认标签只是兜底,只要对象上能读到字符串类型的
Symbol.toStringTag,就以它为准,数组也不例外。
面试官还会怎么问?
写成 static [Symbol.toStringTag] 行不行?
不行,至少达不到目的。static 会把属性挂到类本身上,结果是 Object.prototype.toString.call(Validator) 返回 [object Validator],而实例的原型链上找不到它,实例还是 [object Object]。要影响实例,必须定义在原型上,也就是不加 static 的 getter。
Object.prototype.toString.call(1) 是什么?
[object Number]。原始值会先被包装成对应的对象,Number.prototype 上没有 Symbol.toStringTag,用的是内部类型给的默认标签。
为什么 class 实例默认是 [object Object] 而不是类名?
因为默认标签只看内部类型,普通的类实例在内部就是普通对象,规范不会自动读取类名。想让它显示类名,就得自己定义 Symbol.toStringTag。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。