13基础篇隐式类型转换不同类型的数据是如何一起工作的
这一章我们来学习隐式类型转换。为什么要加上隐式
二字呢?因为隐式往往限定了具体的场景,有着明确规定的转换规则,比如加法运算。如果不加这两个字,那么比如把一个对象转换成字符串,可以是 String(obj),也可以是 obj.toString(),这是两种不一致的逻辑。
类型的隐式转换常常不被重视,很多人对具体场景的理解往往是想当然的,从而产生一些非预期的结果,甚至是运行时异常。接下来,我就列举几种常见的涉及类型转换的案例,和大家一起深究背后的逻辑。
模板字符串(Template literals)
模板字符串是这种带占位符
的字符串,并且可以声明成带换行符的多行
字符串。
var message = `Hello!
${name}
`;
既然是字符串,那么上面的占位符变量 name 自然需要转换成字符串类型。
一般的对象,如果原型链上够上溯到 Object.prototype
,那么就可以调用 toString()
实例函数。有的对象会重载这个函数,比如 Date,甚至像 Number 的 toString 还带有一个参数。如果没有重载,相当于:
Object.prototype.toString.call(obj); // [object xxx]
但是在模板字符串中,并不是调用变量的 toString
方法,这样不安全,毕竟变量可能不是广义上的对象(null、undefined),而且 toString 也可以被重载为不返回字符串类型。
这里就要用到 ECMAScript 规范定义的一个内部函数了,叫做 ToString()
。没错,规范已经盘点好了各种类型转换的需求,其他的还有 ToBoolean、ToNumber、ToObject,甚至还有一些场景化的转换,比如 ToLength、ToPropertyKey、ToIndex,还有一个相当重要的 ToPrimitive
,我们马上讲到。
言归正传,我们看 ToString(arg)
是如何工作的。
- 判断入参类型,遍历一遍所有的
Primitive
类型:- 如果是 String,显然不用转换,直接返回;
- 如果是 Symbol,
抛出异常
,这个我们在 Symbol 那一章讲过,除非调用 Symbol 实例的toString()
方法,否则不可以转换成字符串; - 如果是 Undefined,就返回 “undefined”;
- 如果是 Null,就返回 “null”;
- 如果是 Boolean,就返回 “true” 或 “false”;
- 如果是 Number 或者 BigInt,都转换成其 10 进制表示形式,这里面的细节不涉及类型转换,所以我们就不深究了,大家注意这里可能输出“NaN”、“Infinite”和科学记数法。
- 如果是非 Primitive 类型,也就是 Object,如何转换成字符串呢?答案是将参数带入到
ToPrimitive(arg, string)
。
ToPrimitive(input[, preferredType])
用来将参数转换成 Primitive
类型,即非 Object。
通常来说,使用到 ToPrimitive 的场景,都是在参数已经被判定是 Object 的条件之下。下面我们也以此为前提条件来梳理它的原理。
首先来看它的第二个参数 preferredType
,它是可选的,如果传入,只能是 “string” 或者 “number” 这两个值。想必你已经猜到了,preferredType
就是用来控制对象是偏向转换成哪种 Primitive 类型的。虽然它只能取值为数字和字符串,但并不限制 ToPrimitive 返回其他类型。
ToPrimitive
会先尝试取对象的一个方法,叫做 [Symbol.toPrimitive]
。没错,又是 Symbol 预设常量的应用场景。
这个方法存在于对象本身或者原型链都可以,像下面这两种声明方式都是允许的:
var foo = {
[Symbol.toPrimitive](hint: "default" | "number" | "string") {}
};
class Foo {
[Symbol.toPrimitive](hint: "default" | "number" | "string") {}
}
它的参数 hint
事实上就是 preferredType
:
hint = preferredType ?? "default";
因此,Symbol.toPrimitive
的引入相当于把内部方法 ToPrimitive
外包给了开发者去定义。ToString
在调用 ToPrimitive
的时候,preferredType
用的是 “string”,因此下面的 hint
就是 “string”:
var foo = {
[Symbol.toPrimitive](hint) {
switch(hint) {
case "number":
return 67;
case "string":
default:
return "foo"
}
}
};
console.log(`${foo}`); // “foo”
什么时候
hint
会是 “number” 呢?别急,马上就会讲到了。
注意,[Symbol.toPrimitive]
必须返回一个 Primitive 类型,如果不是的话,就会抛出异常。在 ToString
的场景下,该返回值还会递归传入到 ToString
,确保最终生成一个字符串。
如果对象没有 [Symbol.toPrimitive]
方法,那么就会回退到没有 ES6 之前的逻辑。在这个逻辑中,不传递 preferredType 不代表是 “default”,而是 “number”
,即 ToPrimitive 倾向于返回数字类型。
但是没有 [Symbol.toPrimitive]
的话,preferredType
还有什么用呢?有,这里就要调用另一个内部方法了,叫做 OrdinaryToPrimitive(O, preferredType)
。
在 OrdinaryToPrimitive
中,逻辑是这样的:
- 如果
preferredType
等于 “string”,那么就会尝试依次调用对象的 toString 和 valueOf 方法,如果 toString 存在就不会调用 valueOf; - 如果
preferredType
等于 “number”,那么就会尝试依次调用对象的 valueOf 和 toString 方法,如果 valueOf 存在就不会调用 toString; - 如果返回值不是 Primitive 类型,抛出异常。
是不是很有意思?都是在尝试使用 toString 和 valueOf 的返回值作为 Primitive 类型,只不过根据 preferredType
的值不同,调用顺序不同而已。
对于一般的对象来说,其 toString 和 valueOf 都会上溯到原型对象 Object.prototype
中。
也就是说,绕来绕去,ToString(O)
最终还是在调用 O.toString()
,或者 Object.prototype.toString.call(O)
。
既然还没到底,那我们就继续深挖,来看 Object.prototype.toString
逻辑:
- 如果对象定义了
Symbol.toStringtTag
属性,设为 tag,返回[object ${tag}]
; - 根据类型不同,返回
[object Undefined/Null/Array/Arguments/Function/Error/Boolean/Number/String/Date/RegExp/Object]
。
可见,又有一个 Symbol 的常量被使用,到此为止,我们才遍历完了所有 Symbol 常量的应用场景,但我相信未来 ECMAScript 规范还会继续引入新的常量。
这个方法几乎能分辨所有我们日常想要区分的类型,简直比 typeof
还好用。但是用作对象转换字符串的结果,其返回值格式还是偏死板,其中的 [object Object]
应该是很多同学都经历过的打印噩梦。
事实上,很多规范内置的对象类型,都对 toString 进行了重载,比如 Number、BigInt、Array、Error、Symbol、RegExp、Boolean、Date。因此,它们转换成字符串的时候,压根走不到 Object.prototype.toString
。
另外像 JSON、Math、Atomics、Reflect、Map、Set、Symbol、WeakMap、WeakRef、Promise 等等很多对象,还有浏览器环境的 window 和 document,也都定义了自己的 [Symbol.toStringTag]
属性,因此在 Object.prototype.toString
下也有定制化的返回结果,大家不妨试一试:
Object.prototype.toString.call(Math)
Object.prototype.toString.call(new Map())
Object.prototype.toString.call(window)
Object.prototype.toString.call(document)
相比之下,Object.prototype.valueOf
就简单多了,就返回对象自身。不过像 Date、Symbol、Number、String、Boolean 都重载了这个函数,返回的都是 Primitive 类型,比如:
new Date().valueOf() // 1686978276206
Object(Symbol('x')).valueOf() // symbol(x)
new Number(56).valueOf() // 56
new String().valueOf() // ''
new Boolean(true).valueOf() // true
到此为止,我才终于把 ToPrimitive
的完整逻辑讲完,相信绝大多数同学的感受都是看着越来越懵😳。那不妨用一张图来辅助理解吧:
再把它代入到 ToString
中,我们大概就能推理出在模板字符串
之下,想要控制一个对象的字符串表示形式,可以有这么几种方式:
- 定义
[Symbol.toPrimitive]()
方法; - 重载
toString()
方法; - 如果对象(包括原型链)没有 toString,那么定义
[Symbol.toStringTag]
也能控制,只不过只能是[object ${tag}]
的格式; - 如果对象(包括原型链)没有 toString,那么定义
valueOf
也有效。
💡 后两种情况比较少,一般需要
Object.create(null)
来断开与Object.prototype
的关系。
以上就是模板字符串中的变量隐式转换逻辑,顺带着我们也理清了在对象上调用 toString
可能会走的逻辑。那么,还有一种把变量转换成字符串的方法是利用 String 的强制转换:
String(vari)
这个情况的原理也比较简单:
- 如果入参是 Symbol 类型,单独走一个叫做
SymbolDescriptiveString()
的方法,生成类似Symbol(xxx)
的格式; - 其他类型的话,都走
ToString()
。
这个算法相当于避开了 vari.toString()
在 null、undefined 或者对象没有 toString 情况导致的报错问题,也规避了模板字符串中调用 ToString()
从而不兼容 Symbol 导致的报错问题,是最安全的一种字符串转换方法。
不过无论哪种方法,如果你自定义的 [Symbol.toPrimitive]
、[Symbol.toStringTag]
、toString
等等不够健壮,依然是可能在格式转换时报错的。
加法运算(+)
对于 JavaScript 这种动态语言来说,运行时需要处理的类型转换工作非常常见,toPrimitive
在特别多的地方都有所用。现在我们来看另一种常见的操作:加法运算(Addition Operator)。
为什么要单独讲加法,而不是减法、乘法、除法?因为其他运算一定都是数学运算,参与的变量一定都要转换成数字才可以。而加法存在歧义性,它可能是数学运算,也可能只是字符串拼接,所以最关键的是要判断什么时候是哪种操作。
假设计算 A+B
,操作如下:
首先,两者都转换 Primitive 类型,a=ToPrimitive(A)
、b=ToPrimitive(B)
,注意没有传入 preferredType
参数,如果你定义了 [Symbol.toPrimitive]
函数,参数 hint
就会是 default;如果没有定义 [Symbol.toPrimitive]
,preferredType
就默认是 number,从而会先尝试调用 valueOf,没有才会调用 toString。不妨验证一下。
下面一段可以验证调用了 ToPrimitive
,并且 hint
为 “default”:
var A = {
[Symbol.toPrimitive](hint) {
console.log(hint); // "default"
return 1;
}
};
var B = 1;
A + B; // 2
这一段代码则可证明 preferredType
默认为 “number”,进而优先调用了 valueOf
:
var A = {
valueOf() {
console.log('valueOf'); // valueOf
return 1;
},
toString() {
console.log('toString'); //
return '1';
}
};
var B = 1;
A + B; // 1
好,我们继续加法的运算,在得到了 a 和 b 这两个 Primitive 类型之后,判断它们之中有没有 String 类型,如果有,则认为这个运算属于字符串拼接,把 a 和 b 传入 ToString
。
我们可以想象这一步可能发生什么。如果 a 和 b 之间任意一个是 Symbol 类型,那么加法执行到 ToString
这一步则必报错。
如果 a 和 b 都不是 String,那么这个加法就属于数学运算,它们两个要继续转换成数字,调用内部方法 ToNumeric
。受限于篇幅,我就不在这里展开了,需要特别强调的是,数字也分成 Number 和 BigInt,它们二者是不可以相加的。
以上就是 JavaScript 中加法的主要逻辑,这么一看还算简单,现在我们看一个更复杂一点的操作。
相等判断(==)
之前提到过内部的 IsStrictlyEqual
方法,它代表的是 ===
操作符,逻辑还是很简单的,毕竟只要类型不同,就一定返回 false。
==
不然,它在比较不同类型数据的时候,是允许返回 true 的,因此要比 ===
有更多的特例。
它在规范内部由方法 IsLooselyEqual
代表,我们来分析它的原理,以 A == B
为例。
第一步,判断 A 和 B 的类型,如果相同,则转 IsStrictlyEqual(A, B)
,可见如果类型相同,==
与 ===
是等价的。
接下来,如果 A 和 B,一个是 null,一个是 undefined,那么返回 true,即 null == undefined
;如果其中一个是 document.all
,另一个是 null 或者 undefined,返回 true。在前面“如何判断变量的类型”那一章我们就讲到过 document.all
的特殊之处,在这里也有效果:document.all == null
,document.all == undefined
。
如果 A 和 B,一个是 String,一个是 Number,那么把 String 传入 toNumber()
,再和另一边共同传入 IsLooselyEqual
。也就是说,字符串和数字比较,是把字符串转换成数字,而不是把数字转换成字符串。以下代码可以作证:
15 == '0xF' // true
3 == '0b11' // true
在一个是 String、一个是 BigInt 的情况下,也是同样的操作,都是 String 被转换。
继续,如果 A 和 B 有一方是 Boolean,那么就把它转成数字,结果无非是 1 或者 0,然后继续递归用 IsLooselyEqual
比较:
1 == true
0 == false
'0x1' == true
如果 A 和 B 有一方是 Object,那么会把这个对象用 ToPrimitive
转换,再继续递归比较。注意,这里必须只有一方是 Object,如果双方都是,就会走到前面的 IsStrictlyEqual
分支去了。
var A = {
valueOf() {
return 1;
},
};
var B = 1;
console.log(A == B); // true
最后,如果双方分别是 Number 和 BigInt,那么就比较它们的数值(mathematical value)是否相等。
其他情况,都返回 false。以上就是 ==
的全部逻辑。想必大家看过后会觉得比 ToPrimitive
还要迷茫,这样做真的能覆盖所有场景吗?
我帮大家理一理这个逻辑。ECMAScript 一共定义了 Undefined、Null、Boolean、Number、BigInt、Symbol、String、Object 这 8 种类型,按理说 ==
需要兼顾到这 8 种的排列组合。但是实际上可以大大简化,我们看:
- Object 在比较的时候,总会转换成 Primitive,因此可以去掉 Object;
- Boolean 在比较的时候,总会转换成 Nummber,因此可以去掉 Boolean;
- Undefined 和 Null 只有互相比较的时候返回 true,和其他任意类型都返回 false,因此也可以去掉 Null、Undefined;
- Symbol 和任意类型比较都返回 false,因此还可以去掉 Symbol。
最后我们就剩 String、Number、BigInt 这三种类型了,而后两者还可以归结为 Numeric。String 在和 Numeric 比较的时候,总会尝试把自身转换为对方的类型,而 Numeric 内部的 Number 和 BigInt 之间的比较又会看数值。
这就是 ==
简化后的东西了,就是 String 和 Numeric 而已
。
小结
本章节我们梳理了模板字符串、加法(+)和相等判断(==)这三种操作的背后逻辑,这只是 ECMAScript 众多操作中的冰山一角。但我想表达的是,在这种动态语言中,变量的隐式类型转换应该是常态,如果你实现了一个函数,除非有类型辅助系统(如 TypeScript)的帮助,那么就不能假设入参一定就是你期望的类型,应该要学会进行自动转换。
不过,类型转换的背后也会涉及到众多的策略差异,比如 ${name}
和 String(name)
就有所不同,从而产生不一样的结果,甚至报错。
我给到大家的建议就是,牢记常见的类型转换手段,以及背后的原理,比如:
- 任意类型转换成字符串(本文已全部涉及);
- 字符串转换成数字,比如
+foo
、parseInt(foo)
、foo | 0
等。
此外,像 ToPrimitive
、ToString
这类规范内部的方法,大家也应该有所掌握,它们之间的组合往往就是一种运算、操作的核心原理。
到此为止,本小册的基础篇就结束了。熟练掌握基础篇的内容,能够让你在应付日常业务开发的活动中更有深度思考,进而能减少编写漏洞代码的数量。但是如果编写通用型模块、底层框架,那么还需要掌握更高级的内容。
接下来,就让我们开启进阶篇吧!