目录

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) 是如何工作的。

  1. 判断入参类型,遍历一遍所有的 Primitive 类型:
    • 如果是 String,显然不用转换,直接返回;
    • 如果是 Symbol,抛出异常,这个我们在 Symbol 那一章讲过,除非调用 Symbol 实例的 toString() 方法,否则不可以转换成字符串;
    • 如果是 Undefined,就返回 “undefined”;
    • 如果是 Null,就返回 “null”;
    • 如果是 Boolean,就返回 “true” 或 “false”;
    • 如果是 Number 或者 BigInt,都转换成其 10 进制表示形式,这里面的细节不涉及类型转换,所以我们就不深究了,大家注意这里可能输出“NaN”、“Infinite”和科学记数法。
  2. 如果是非 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 中,逻辑是这样的:

  1. 如果 preferredType 等于 “string”,那么就会尝试依次调用对象的 toString 和 valueOf 方法,如果 toString 存在就不会调用 valueOf;
  2. 如果 preferredType 等于 “number”,那么就会尝试依次调用对象的 valueOf 和 toString 方法,如果 valueOf 存在就不会调用 toString;
  3. 如果返回值不是 Primitive 类型,抛出异常。

是不是很有意思?都是在尝试使用 toString 和 valueOf 的返回值作为 Primitive 类型,只不过根据 preferredType 的值不同,调用顺序不同而已。

对于一般的对象来说,其 toString 和 valueOf 都会上溯到原型对象 Object.prototype 中。

也就是说,绕来绕去,ToString(O) 最终还是在调用 O.toString(),或者 Object.prototype.toString.call(O)

既然还没到底,那我们就继续深挖,来看 Object.prototype.toString 逻辑:

  1. 如果对象定义了 Symbol.toStringtTag 属性,设为 tag,返回 [object ${tag}]
  2. 根据类型不同,返回 [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 的完整逻辑讲完,相信绝大多数同学的感受都是看着越来越懵😳。那不妨用一张图来辅助理解吧:

https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/85d31c5a1aed4a86ab3b71e98f982eb6~tplv-k3u1fbpfcp-watermark.image?

再把它代入到 ToString 中,我们大概就能推理出在模板字符串之下,想要控制一个对象的字符串表示形式,可以有这么几种方式:

  1. 定义 [Symbol.toPrimitive]() 方法;
  2. 重载 toString() 方法;
  3. 如果对象(包括原型链)没有 toString,那么定义 [Symbol.toStringTag] 也能控制,只不过只能是 [object ${tag}] 的格式;
  4. 如果对象(包括原型链)没有 toString,那么定义 valueOf 也有效。

💡 后两种情况比较少,一般需要 Object.create(null) 来断开与 Object.prototype 的关系。

以上就是模板字符串中的变量隐式转换逻辑,顺带着我们也理清了在对象上调用 toString 可能会走的逻辑。那么,还有一种把变量转换成字符串的方法是利用 String 的强制转换:

String(vari)

这个情况的原理也比较简单:

  1. 如果入参是 Symbol 类型,单独走一个叫做 SymbolDescriptiveString() 的方法,生成类似 Symbol(xxx) 的格式;
  2. 其他类型的话,都走 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 == nulldocument.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 种的排列组合。但是实际上可以大大简化,我们看:

  1. Object 在比较的时候,总会转换成 Primitive,因此可以去掉 Object;
  2. Boolean 在比较的时候,总会转换成 Nummber,因此可以去掉 Boolean;
  3. Undefined 和 Null 只有互相比较的时候返回 true,和其他任意类型都返回 false,因此也可以去掉 Null、Undefined;
  4. Symbol 和任意类型比较都返回 false,因此还可以去掉 Symbol。

最后我们就剩 String、Number、BigInt 这三种类型了,而后两者还可以归结为 Numeric。String 在和 Numeric 比较的时候,总会尝试把自身转换为对方的类型,而 Numeric 内部的 Number 和 BigInt 之间的比较又会看数值。

这就是 == 简化后的东西了,就是 String 和 Numeric 而已

小结

本章节我们梳理了模板字符串、加法(+)和相等判断(==)这三种操作的背后逻辑,这只是 ECMAScript 众多操作中的冰山一角。但我想表达的是,在这种动态语言中,变量的隐式类型转换应该是常态,如果你实现了一个函数,除非有类型辅助系统(如 TypeScript)的帮助,那么就不能假设入参一定就是你期望的类型,应该要学会进行自动转换。

不过,类型转换的背后也会涉及到众多的策略差异,比如 ${name}String(name) 就有所不同,从而产生不一样的结果,甚至报错。

我给到大家的建议就是,牢记常见的类型转换手段,以及背后的原理,比如:

  1. 任意类型转换成字符串(本文已全部涉及);
  2. 字符串转换成数字,比如 +fooparseInt(foo)foo | 0 等。

此外,像 ToPrimitiveToString 这类规范内部的方法,大家也应该有所掌握,它们之间的组合往往就是一种运算、操作的核心原理。

到此为止,本小册的基础篇就结束了。熟练掌握基础篇的内容,能够让你在应付日常业务开发的活动中更有深度思考,进而能减少编写漏洞代码的数量。但是如果编写通用型模块、底层框架,那么还需要掌握更高级的内容。

接下来,就让我们开启进阶篇吧!