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

配图源自 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''0n false
除以上这些(falsy)值之外 true

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

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

三、ToNumber

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

ToNumber Abstract Operation
参数类型 结果
Undefined NaN
Null +0
Boolean true 转换为 1false 转换为 +0
Number 直接返回,不做类型转换。
String 1. 纯数字的字符串转换为相应的数字;
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

ToString Abstract Operation
参数类型 结果
Undefined undefined
Null null
Boolean true 转换为 "true"false 转换为 "false"
Number 1. 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 错误。
BigInt 10n 转换为 "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

ToObject Abstract Operation
参数类型 结果
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 关键字创建实例对象的原因。

六、参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容