细述 JS 各数据类型的检测与转换

172 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

前言

JS 是一门弱类型的语言,这使得 JS 相较于其他语言易入门、使用方便,在早期吸引大量的程序员,成为互联网的绝对霸主。

JS 的类型系统是松散的,所以在我们写代码期间不可避免的需要对类型加以限制。

尤其对于那些插件、工具库的开发者,无法预料他人传入函数的会是什么,所以对参数进行精确的类型检测或转换,更是保证工具能够正常运行必不可少的要求。

而且在面试当中,时不时也会考查类型转换的相关内容。

本文将给出各类型最精确的检测方法,以及各类型向其他类型转换时的默认规则与推荐用法。

类型检测

在本节,将介绍对于各类型的检测方法。

基础数据类型

JS 中有 7 种基础数据类型:

  • Undefined 未定义类型
  • Null 空对象类型
  • Boolean 布尔类型
  • Number 数字类型
  • String 字符串类型
  • Symbol 符号类型
  • BigInt 大整数类型

在检测基础类型的时候,我推荐使用 typeof 运算符,目前 js 中不存在修改 typeof 的方法,所以其获得的结果一定是可靠的。

针对于 Null,建议直接使用 ===

console.log(typeof 0) // 'number'
console.log(typeof true) // 'boolean'
console.log(typeof '') // 'string'
console.log(typeof Symbol()) // 'symbol'
console.log(typeof 1n) // 'bigint'
console.log(typeof undefined) // 'undefined'
console.log(typeof null) // 'object'
console.log(null === null) // true

引用数据类型

如果仅仅是区分基础数据类型与引用数据类型,使用 typeof 再把 null 排除就够了

const obj = {}
console.log(typeof obj === 'object' && obj != null) // true

但我们更多的时候,我们是想更细微地区分引用类型,比如:

  • Object 普通对象
  • Array 数组
  • Function 函数
  • Date 时间
  • Set 集合
  • Map 映射
  • ……

我推荐使用 Object.prototype.toString.call() 方法。

这样的写法是调用基类 Object 身上的 toString 方法,虽然所有对象都继承了基类,但很多对象都重写了自己的 toString 方法,比如 ArrayDate,通过 call 调用能直接获取该对象所对应的类型标签。

console.log([].toString()) // '' (空字符串,调用的是数组的 join 方法)
console.log(new Date().toString()) // 'Mon May 23 2022 15:47:05 GMT+0800 (中国标准时间)'

console.log(Object.prototype.toString.call({})) // '[object Object]'
console.log(Object.prototype.toString.call([])) // '[object Array]'
console.log(Object.prototype.toString.call(new Function())) // '[object Function]'
console.log(Object.prototype.toString.call(new Date())) // '[object Date]'
console.log(Object.prototype.toString.call(new Set())) // '[object Set]'
console.log(Object.prototype.toString.call(new Map())) // '[object Map]'

对于自己定义的对象或类,可以通过修改其 Symbol.toStringTag 属性,来改变 Object.prototype.toString.call 的返回值。

class Student1 {
  constructor() {}

  get [Symbol.toStringTag]() {
    return 'Student1'
  }
}

function Student2() {
  this[Symbol.toStringTag] = 'Student2'
}

const student1 = new Student1()
const student2 = new Student2()
const student3 = { [Symbol.toStringTag]: 'Student3' }

console.log(Object.prototype.toString.call(student1)) // '[object Student1]'
console.log(Object.prototype.toString.call(student2)) // '[object Student2]'
console.log(Object.prototype.toString.call(student3)) // '[object Student3]'

也正因如此,如果调用者故意隐瞒对象的类型,是没有办法检测出来的。对方甚至可以修改基础数据的类型。

const arr = []

arr[Symbol.toStringTag] = '我真的不是数组'

console.log(Object.prototype.toString.call(arr)) // '[object 我真的不是数组]'

console.log(Object.prototype.toString.call(1)) // '[object Number]'

Number.prototype[Symbol.toStringTag] = '猜猜我是什么类型'

console.log(Object.prototype.toString.call(1)) // '[object 猜猜我是什么类型]'

或许你会想到删除此属性来获取准确的类型,但对方可以轻松的阻止你对属性的删除。

const getType = (obj) => {
  let tag = obj[Symbol.toStringTag]
  delete obj[Symbol.toStringTag]
  let res = Object.prototype.toString.call(obj)
  obj[Symbol.toStringTag] = tag
  return res
}

const arr1 = []
arr1[Symbol.toStringTag] = '我真的不是数组'
console.log(getType(arr1)) // '[object Array]'

const arr2 = []
Object.defineProperty(arr2, Symbol.toStringTag, {
  get() {
    return '我真的不是数组'
  },
})
console.log(getType(arr2)) // '[object 我真的不是数组]'

总结

对于基础数据类型,可以通过 typeof 操作符准确获取其类型;对于引用数据类型,推荐使用 Object.prototype.toString.call 方法,但如果调用者刻意隐藏,我们是没办法准确获取其类型的。

类型转换

在本节,将介绍对于各类型相互转换时的默认规则,以及推荐使用的转换方法。

显示转换与隐式转换

类型转换分为显示类型转换与隐式类型转换

通过调用 String Number 等构造器实现的类型转换属于显示类型转换。

通过运算符 + - ! && 等,或在函数所需参数与传入参数类型不同时,进行的类型转换是隐式类型转换。

这两种转换方式在行为上几乎没有区别。

基础数据类型

Undefined 与 Null

其他类型无法向 undefinednull 类型转换。

undefinednull 向其他类型转换的结果如下,不过一般只会使用它们作为假值的特性,并不建议转换为布尔以外的类型。

let variate = undefined

console.log(Boolean(variate)) // false
console.log(Number(variate)) // NaN
console.log(String(variate)) // 'undefined'
console.log(Symbol(variate)) // Symbol()
console.log(BigInt(variate)) // TypeError

variate = null
console.log(Boolean(variate)) // false
console.log(Number(variate)) // 0
console.log(String(variate)) // 'null'
console.log(Symbol(variate)) // Symbol(null)
console.log(BigInt(variate)) // TypeError

Boolean

当其他类型向布尔类型转换时,只有假值(null undefined '' 0 -0 NaN 0n)会被转换为 false,其余的都为 true

当布尔类型转换为其他类型的结果如下,但一般不建议向其他类型转换。

let variate = true
console.log(Number(variate)) // 1
console.log(String(variate)) // 'true'
console.log(Symbol(variate)) // Symbol(true)
console.log(BigInt(variate)) // 1n

let variate = false
console.log(Number(variate)) // 0
console.log(String(variate)) // 'false'
console.log(Symbol(variate)) // Symbol(false)
console.log(BigInt(variate)) // 0n

Number String 与 BigInt

将数字类型与字符串类型、大整数类型相互转换的需求是很常见的,需要注意的几点:

  • 数字或大整数向字符串转换时,会默认使用 String 方法,大整数末尾的 n 不会保留。

  • 数字的数位过长时,会存在精度的缺失,而且在转换为字符串时,也可能会采用 e+ 的缩写形式,推荐使用大整数。

  • 字符串向数字转换时,隐式转换会调用 Number 方法,只要不符合数字规则,就会返回 NaN,推荐使用 parseIntparseFloat 方法。

  • 字符串或数字向大整数转换时,隐式转换会抛出异常,而显示调用 BigInt 方法必须传入一个能显示转换为整数的参数,否则也会抛出异常。

  • 字符串与数字或大整数相加时,会统一转换为字符串进行拼接。

console.log(String(10 ** 30)) // '1e+30'
console.log(String(10n ** 30n)) 
// '1000000000000000000000000000000'
console.log(String(BigInt(10 ** 30))) 
// '1000000000000000019884624838656'

console.log(Number('1a')) // NaN
console.log(Number('1.1.1')) // NaN
console.log(parseInt('1a')) // 1
console.log(parseFloat('1.1.1')) // 1.1

console.log(BigInt('1a')) // SyntaxError
console.log(BigInt(1.1)) // RangeError
console.log(1n - '1') // TypeError

Symbol

其他类型不会隐式地向符号类型转换,通过 Symbol 方法,会将内部的参数转换为字符串类型(undefined 除外),作为符号的描述。

符号向布尔类型转换为真;在向字符串隐式转换时会抛出异常,显示转换结果为 Symbol(description) 的形式;向数字或大整数类型转换时会抛出异常。

不建议转换符号类型。

let sy = Symbol('foo')

console.log(Boolean(sy)) // true
console.log(String(sy)) // 'Symbol(foo)'
console.log(sy + '') // TypeError
console.log(Number(sy)) // TypeError
console.log(BigInt(sy)) // TypeError

引用数据类型

各类型对象之间不会相互转换,主要是向基础数据类型转换,主要跟对象身上的 3 个转换方法有关,分别是 Symbol.toPrimitive valueOf toString

对象会按照一定规律尝试这些方法,直到得到了基础数据类型,如果没有得到基础数据类型,会抛出一个异常。

Symbol.toPrimitive 的优先级最高,如果对象配置了此方法,则仅会调用此方法。此方法会接受一个字符串参数,表示需要的类型(string number default)

如果对象没有 Symbol.toPrimitive 方法,会根据转换倾向,依次尝试valueOftoString,如果需要的类型是字符串,优先尝试 toString,否则会优先尝试 valueOf

let obj1 = {
  [Symbol.toPrimitive](hint) {
    console.log(hint)
    return {}
  },
  toString() {
    return ''
  },
  valueOf() {
    return 1
  },
}
console.log(String(obj1)) // 'string' TypeError
console.log(Number(obj1)) // 'number' TypeError
console.log(obj1 + '') // 'default' 'TypeError

let obj2 = {
  toString() {
    return ''
  },
  valueOf() {
    return 1
  },
}
console.log(String(obj2)) // ''
console.log(Number(obj2)) // 1
console.log(obj2 + '') // '1'

Object.create(null) + '' // TypeError

转换倾向

转换倾向是当前表达式或参数所需的类型倾向,变量会根据转换倾向,进行类型的转换。

基础数据类型会一步到位的转换至所需的类型;对象会根据转换倾向按照次序调用身上的转化方法,如果得到的基础数据与所需的类型不同,会再按照基础类型转换的规则进行转换。

转换倾向一般由以下规则决定:

  • 当传入参数与所需的类型不相符时,转换倾向为所需的参数类型;

  • 通过 [XXX] 形式访问对象属性时,转换倾向为字符串。

  • 当对象参与运算时,除了 + 运算符的倾向是 default,其余都是 number,均会优先调用 valueOf 方法;

  • 当只有基础数据类型参与运算时,仅当操作数中存在字符串且运算符为 +,转换倾向为字符串,否则均为数字。

  • 位运算的转换倾向是数字,但对象参与位运算时,操作的是地址,并不进行类型转换

  • == 两边操作数的转化比较复杂,可以看我之前这篇文章 细说 JS 中的 “==” 运算符

let obj = {
  [Symbol.toPrimitive](hint) {
    console.log(hint)
    return ''
  },
}

Symbol(obj) // 'string'
;[].slice(obj) // 'number'
;[][obj] // 'string'
obj+ '' // default
obj++ // 'number'
console.log(1 & '') // 0
console.log(1 & obj) // 1

习题

做点练习题巩固一下吧,看看这几段代码会输出什么吗

第一题

const res = 0.1 - '.1' + !-'1a' + '1e+1'
console.log(res)

第二题

const a = {
  num: 1,
  valueOf() {
    return this.num++
  },
}
const b = {
  num: 4,
  toString() {
    return this.num++
  },
}

const res = a == 1 && a == 2 && a == 3 && a == b
console.log(res)

第三题

const res = ([][[]] + [])[+!![]] + ([] + {})[!+[] + !![]]
console.log(res)

第四题

const a = {
  [Symbol.toPrimitive](hint) {
    if (hint === 'string') {
      return 'aaa'
    } else if (hint === 'number') {
      return 10
    } else {
      return hint
    }
  },
  valueOf() {
    return 100
  },
  toString() {
    return 'a'
  },
}
const b = {
  valueOf() {},
  toString() {
    return 'bbb'
  },
}
const c = {
  [Symbol.toStringTag]: 'c',
}

const res = `${a} ${a + b} ${-a + c}`
console.log(res)

第五题

console.log(123['toString'].length + 123)

解答

下面是这些题目的答案与解释

第一题

0.1 - '.1' + !-'1a' + '1e+1' 结果为 '11e+1'

我们从左往右分析

0.1 - '.1'.1 会使用 Number 转换为数字 0.1,前两项结果为 0

!-'1a',先将 1a 转换为数字 NaN,此为假植,取非得到 true,与 0 相加时转换为 1,前三项结果为 1

然后数字与字符串相加,直接拼接,结果为 '11e+1'

第二题

a == 1 && a == 2 && a == 3 && a == b 结果为 false

每次比较会对 a 进行类型转换,调用其 valueOf 方法,使它的 num 属性加 1,所以 a == 1 && a == 2 && a == 3 结果为true,但是两个对象进行比较时,不会进行类型转换,比的是地址,所以 a == b 返回 false,最终结果为 false

第三题

([][[]] + [])[+!![]] + ([] + {})[!+[] + !![]] 结果为'nb'

这个表达式有些复杂,咱们逐步将其拆解

首先根据中间的加号,拆解为前后两部分相加,前一部分为 ([][[]] + [])[+!![]],后一部分为 ([] + {})[!+[] + !![]]

  • 对于前一部分 ([][[]] + [])[+!![]],是对 [][[]] + [] 表达式的结果取它的 +!![] 属性值。

    [][[]] 又是对一个空数组取 [] 属性,[] 作为属性调用 toString 方法返回 '',空数组没有名为 ''的属性,该表达式返回 undefined,又与 [] 相加,等同于 undefined + '' 结果是 'undefined'

    对于表达式 +!![]!![] 是将空数组转换为布尔值,结果为 true,又前置 + 转换为数字,true 转换为数字结果是 1

    所以前一部分总的结果就是 'undefined'[1],字符串的第二位 'n'

  • 对于后一部分 ([] + {})[!+[] + !![]],是对 [] + {} 表达式的结果取它的 !+[] + !![] 属性值。

    [] + {} ,两对象相加,调用双方 toString 方法,等同于 '' + '[object Object]',结果为 '[object Object]'

    !+[] + !![]+[] 等同于 +'',结果为 0,取非为 true,后一部分也为 truetrue + true 结果为 2

    所以前一部分总的结果就是 '[object Object]'[2],字符串的第三位 'b'

最终结果是两部分相加 'n' + 'b',也就是 'nb'

下图详细展示了整个过程 image.png

第四题

`${a} ${a + b} ${-a + c}` 结果为 'aaa defaultundefined -10[object c]'

a 配置了 Symbol.toPrimitive 方法,所以其在类型转换时仅会调用此方法,${a} 的转换倾向为字符串,结果为 'aaa',参与加法运算时转换倾向为默认,结果为 'default',前置符号时转换倾向为数字,结果为 10

b 配置了 valueOftoString 方法,在运算时优先调用 valueOf,其返回值 undefined 为基础数据类型,在与前一项 'default' 相加组成 'defaultundefined'

c 配置了 Symbol.toStringTag 属性,在调用 Object 身上的 toString 方法返回的结果是 '[object c]',与前一项 -10 进行拼接,结果为 '-10[object c]'

第五题

123['toString'].length + 123 结果为124

对于数字 123,获取它原型上的 toString,该方法的 length 为 1,与 123 相加结果为 124。

为什么会有这个结果,可以看我的这篇 面试败笔 | 函数的length

结语

如果文中有错误或不严谨的地方,请务必给予指正,十分感谢。

内容整理不易,如果喜欢或者有所启发,希望能点赞关注,鼓励一下新人作者。