细读 JS |(隐式)数据类型转换详解

发布于 2023-05-12 12:45:42 字数 16288 浏览 52 评论 0

配图源自 Freepik

在 JavaScript 的世界里,数据类型之间的转换无处不在。即使你没有主动显式地去转换,但 JavaScript 在私底下“偷偷地”帮我们做了很多类型转换的工作。那么,它们究竟是按照什么规则去转换的呢?我们试图在本文中找到答案,来解开谜底。

ECMAScript 是负责制定标准的,而 JavaScript 则是前者的一种实现。

在 ECMAScript 标准中定义一组转换抽象操作,常见的抽象操作(Abstract Operation)有 ToPrimitiveToBooleanToNumberToStringToObject 等等。

一、ToPrimitive

1. ToPrimitive

在 ECMAScript 标准中,使用 ToPrimitive 抽象操作将引用类型转换为原始类型。(详情看 #sec-7.1.1

ToPrimitive Abstract Operation

ToPrimitive(input[, PerferredType])

参数 input 是文章开头提到的 8 种数据类型的值(Undefined、Null、Boolean、String、Symbol、Number、BigInt、Object)。参数 PreferredType 是可选的,表示要转换到的原始值的预期类型,取值只能是字符串 "default"(默认)、"string""number" 之一。

ToPrimitive 操作,可概括如下:

  1. input 是 ECMAScript 语言类型的值。
  2. 如果 input 是引用类型,那么
    1. 如果没有传递 PreferredType 参数,使 hint 等于 "default"
    2. 如果参数 PreferredType 提示 String 类型,使 hint 等于 "string"
    3. 否则,参数 PreferredType 提示 Number 类型,使 hint 等于 "number"
    4. 使 exoticToPrim 等于 GetMethod(input, @@toPrimitive) 的结果(大致意思是,获取 input 对象的 @@toPrimitive 属性值,并将其赋给 exoticToPrim)。
    5. 如果 exoticToPrim 不等于 undefined(即 input 对象含 @@toPrimitive 属性),那么
      1. 使 result 等于 Call(exoticToPrim, input, « hint ») 的结果(大致意思是,执行 @@toPrimitive 方法,即 exoticToPrim(hint))。
      2. 如果 result 不是引用类型,则返回 result
      3. 否则抛出 TypeError 类型错误。
    6. 如果 hint"default",则将 hint 设为 "number"
    7. 返回 OrdinaryToPrimitive(input, hint)
  3. 返回 input(即原始类型的值直接返回)。

注意:当不带 hint 去调用 ToPrimitive 抽象操作时,通常它的行为就像 hintNumber 类型一样。但是,(派生)对象可以通过定义 @@toPrimitive 方法来替代此行为。在本规范中定义的对象里,只有 Date 对象(详情看 #sec-20.4.4.45)和 Symbol 对象(详情看 #sec-19.4.3.5)会覆盖默认的 ToPrimitive 行为。

其他:

  1. 以上提到的 @@toPrimitive 方法,即属性名称为 [Symobl.toPrimitive] 的方法。
  2. Date 对象的 @@toPrimitive 方法定义:当 hint"default" 时,会使 hint 等于 "string"。所以这也是 Date 对象转换为原始值时,会先调用 instance.toString() 方法,而不是 instance.valueOf() 方法的原因。
  3. 目前 JavaScript 的内置对象中,含有 @@toPrimitive 方法的,只有 DateSymbol 对象。

用口水话再总结一下,如下(哈哈):

  1. 如果 input 是原始类型,直接返回 input(不做转换操作)。
  2. 如果参数 PreferredType 是 String(Number)类型,那么使得 hint 等于 "string""number"),否则 hint 等于默认的 "default"
  3. 如果 input 中存在 @@toPrimitive 属性(方法),若 @@toPrimitive 方法的返回值为原始类型,则 ToPrimitive 的操作结果就是该返回值,否则抛出 TypeError 类型错误。
  4. 如果经过以上步骤之后 hint"default",则使 hint 等于 "number"
  5. 返回 OrdinaryToPrimitive(input, hint) 操作的结果。

提醒:

  1. 关于 Date 对象的 Date.prototype[@@toPrimitive] 内部方法实现,其实是将 hint"default" 的情况改为 "string",然后执行第 5 步的 OrdinaryToPrimitive 操作。(详情看 #sec-20.4.4.45

  2. 关于 Symbol 对象的 Symbol.prototype[@@toPrimitive] 内部方法实现,如果传递给该方法的是一个 Symbol 类型的值,则直接返回该值。如果该值是引用类型,且含有属性 [[SymbolData]],而且该属性值为 Symbol 类型,则返回该属性值,否则会抛出 TypeError 类型错误。(详情看 #sec-19.4.3.5

那么 OrdinaryToPrimitive 的操作是怎样的呢?我们接着往下看...

2. OrdinaryToPrimitive

详情看:#sec-7.1.11

OrdinaryToPrimitive Abstract Operation

OrdinaryToPrimitive(O, hint)

参数 O 为引用类型。参数 hint 为 String 类型,其值只能是字符串 "string""number" 之一。

(官话)OrdinaryToPrimitive 操作,可概括如下:

  1. O 是引用类型。
  2. hint 是 String 类型,且 hint 的值只能是 "string""number" 之一。
  3. 如果 hint"string",使 methodNames 等于 « "toString", "valueOf" »(其中 «» 表示规范中的 List,类似于数组)。
  4. 如果 hint"number",使 methodNames 等于 « "valueOf", "toString" »
  5. 遍历 methodNames,使 name 等于每个迭代值,并执行:
    1. 使 method 等于 Get(O, name)(即获取对象 Oname 属性,相当于获取对象的 toStringvalueOf 属性,具体执行顺序视 hint 而定)。
    2. 如果 IsCallable(method) 结果为 true,那么:
      1. 使 result 等于 Call(method, O) 结果(即调用 method() 方法)。
      2. 如果 result 为原始类型,则返回 result
  6. 抛出 TypeError 类型错误。

其中 IsCallable(argument) 操作,大致内容是:当参数 argument 为引用类型且 argument 对象包含内部属性 [[Call]] 时返回 true, 否则返回 false。话句话说,就是用于判断是否为函数。

(口水话)再总结一下:

  1. 当经过 ToPrimitive 操作,然后执行 OrdinaryToPrimitive(input, hint) 操作,那么步骤如下:
  2. 如果 hint"string",它会先调用 input.toString() 方法,
    1. toString() 结果为原始类型,则直接返回该结果。
    2. 否则,继续调用 input.valueOf() 方法,若结果为原始类型,则返回该结果,否则抛出 TypeError 类型错误。
  3. hint"number",它先调用 input.valueOf() 方法,
    1. valueOf() 结果为原始类型,则直接返回该结果。
    2. 否则,继续调用 input.toString() 方法,若结果为原始类型,则返回该结果,否则抛出 TypeError 类型错误。

到这里,已经完整地讲述了 ToPrimitive 操作的全部过程。文笔不太好,我不知道你们有没看明白,倘若仍有疑惑,请反复斟酌或直接查看 ECMAScript 标准。

3. 一些示例

  • -*/% 这四种操作符都会把符号两边的操作数先转换为数字再进行运算。
  • + 的作用可以是数值求和,也可以是字符串拼接。
    • 若符号两边操作数都是数字,则进行数字运算。
    • 若符号一边是字符串,则会把另一端转换为字符串进行拼接操作。

一元加运算符 +(unary plus)是将操作数转换为数字的最快且首选的方式,因为它不对该数字执行任何其他运算。

区分一元加运算符(+)和算术运算符(+)的方法,就是前者只有一个操作数,而后者是两个操作数。

// 运算符: x + y

// Number + Number -> 数字相加
1 + 2 // 3

// Boolean + Number -> 数字相加
true + 1 // 2

// Boolean + Boolean -> 数字相加
false + false // 0

// Number + String -> 字符串连接
5 + 'foo' // "5foo"

// String + Boolean -> 字符串连接
'foo' + false // "foofalse"

// String + String -> 字符串连接
'foo' + 'bar' // "foobar"
const obj = {
  [Symbol.toPrimitive]: hint => {
    if (hint === 'number') {
      return 1
    } else if (hint === 'string') {
      return 'string'
    } else {
      return 'default'
    }
  }
}

+obj          // 1              hint is "number"
`${obj}`      // "string"       hint is "string"
obj + ''      // "default"      hint is "default"
obj + 1       // "default1"     hint is "default"
Number(obj)   // 1              hint is "number"
String(obj)   // "string"       hint is "string"

二、ToBoolean

将一个操作数转换为布尔值,这应该是最简单的了。(详情看 #sec-7.1.2

ToBoolean Abstract Operation

所以总结下来就是:

操作数结果
undefinednullfalse+0-0NaN''0nfalse
除以上这些(falsy)值之外true

在 JavaScript 中,如果一个操作数 argument 通过 ToBoolean(argument) 操作后被转换为 true,那么这些操作数称为真值(truthy),否则为虚值(falsy)。

// 转换为 Boolean 值的两种方式
!!x
Boolean(x)

三、ToNumber

将一个操作数转换为数字值。(详情看 #sec-7.1.4

参数类型结果
UndefinedNaN
Null+0
Booleantrue 转换为 1false 转换为 +0
Number直接返回,不做类型转换。
String1. 纯数字的字符串转换为相应的数字;
2. 空字符串 '' 转为 +0
3. 否则为 NaN

其中 0x 开头的字符串被当成 16 进制。
Symbol无法转换,抛出 TypeError 错误。
BigInt无法转换,抛出 TypeError 错误。
Object两个步骤:

1. 将引用类型转化为原始值 ToPrimitive(argument, 'number')
2. 转化为原始值后,进行 ToNumber(primValue) 操作,即按上面的类型转换。

需要注意的是

  1. Number(undefined) 结果为 NaN,而 Number(null) 结果为 0
  2. 含有前导和尾随空白符(\n\r\t\v\f)的字符串,在转换为数字类型的时候空白符会被忽略。
  3. 上面也提到过,使用一元加运算符(+) 是将其他类型转换为数值的常用方式。
Number(undefined) // NaN
Number(null) // 0

'\n  123  \t' == 123 // true

+'string' // NaN
+true // 1
+[] // 0
+{} // NaN

四、ToString

将一个操作数转换为字符串类型的值。(详情看 #sec-7.1.17

参数类型结果
Undefinedundefined
Nullnull
Booleantrue 转换为 "true"false 转换为 "false"
Number1. NaN 转换为 "NaN"
2. +0-0 转换为 "0"
3. 其中 Infinity-Infinity 分别转换为 "Infinity""-Infinity"
4. 若 x 是小于 0 的负数,则返回 "-x";若 x 是大于 0 的正数,则返回 "x"
5. 其他不常用的数值,请看 #sec-6.1.6.1.20
String直接返回,不做类型转换。
Symbol无法转换,抛出 TypeError 错误。
BigInt10n 转换为 "10"
Object两个步骤:

1. 将引用类型转化为原始值 ToPrimitive(argument, 'string')
2. 转化为原始值后,进行 ToString(primValue) 操作,即按上面的类型转换。

需要注意的是,Symbol 原始值不能转换为字符串,只能将其转换成对应的包装对象,再调用 Symbol.prototype.toString() 方法。

// 下面会导致 Symbol('foo') 进行隐式转换,即 ToString(Symbol),按以上规则,是会抛出异常的
console.log(Symbol('foo') + 'bar' ) // TypeError: Cannot convert a Symbol value to a string

// Symbol('foo') 结果是 Symbol 的原始值,再调用其包装对象的属性时,会自动转化为包装对象再调用其 toString() 方法
console.log(Symbol('foo').toString() + 'bar' ) // "Symbol(foo)bar"

抛一个有趣的问题:

// 运行出错
var name = Symbol() // TypeError: Cannot convert a Symbol value to a string

// 正常运行,不会抛出错误
let name = Symbol()

// 为什么呢?

答案我不写了,感兴趣的可以自行搜索。对此有疑问的可以先看下文章:关于 var、let 的顶层对象的属性

五、ToObject

将一个操作数转换为引用类型的值。(详情看 #sec-7.1.18

参数类型结果
Undefined无法转换,抛出 TypeError 错误。
Null无法转换,抛出 TypeError 错误。
Boolean返回 new Boolean(argument)
Number返回 new Number(argument)
String返回 new String(argument)
Symbol返回 Object(Symbol(argument))
BigInt返回 Object(BigInt(argument))
Object直接返回,不做类型转换。

需要注意都是,JavaScript 内置的 SymbolBigInt 对象不能使用 new 关键字去创建实例对象,只能通过 Object() 函数来创建一个包装对象(wrapper object)。

// 错误示例
const sym = new Symbol() // TypeError: Symbol is not a constructor

// 正确示例
const sym = Symbol()
console.log(typeof sym) // "symbol"
const symObj = Object(sym)
console.log(typeof symObj) // "object"

// BigInt 同理

需要注意的是,从 ES6 开始围绕原始数据类型创建一个显式包装器对象不再被支持。但由于遗留原因,现有的原始包装器对象(如 new Booleannew Numbernew String)仍可使用。这也是 ES6+ 新增的 Symbol、BigInt 数据类型无法通过 new 关键字创建实例对象的原因。

六、参考

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

撧情箌佬

暂无简介

0 文章
0 评论
23 人气
更多

推荐作者

yili302

文章 0 评论 0

晚霞

文章 0 评论 0

LLFFCC

文章 0 评论 0

陌路黄昏

文章 0 评论 0

xiaohuihui

文章 0 评论 0

你与昨日

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文