JavaScript 类型转换

357 阅读9分钟

类型转换

本章内容主要参考: winter-极客时间-重学前端 已获作者授权,如果想要看到更完整的内容请去原文。

现在介绍一个现象:类型转换。

因为 JS 是弱类型语言,所以类型转换发生非常频繁,大部分我们熟悉的运算都会先进行类型转换。大部分类型转换符合人类的直觉,但是如果我们不去理解类型转换的严格定义,很容易造成一些代码中的判断失误。

其中最为臭名昭著的是 JavaScript 中的“ == ”运算,因为试图实现跨类型的比较,它的规则复杂到几乎没人可以记住。这里我们当然也不打算讲解 == 的规则,它属于设计失误,并非语言中有价值的部分,很多实践中推荐禁止使用“ ==”,而要求程序员进行显式地类型转换后,用 === 比较。

其它运算,如加减乘除大于小于,也都会涉及类型转换。幸好的是,实际上大部分类型转换规则是非常简单的,如下表所示:

image.png

在这个里面,较为复杂的部分是 Number 和 String 之间的转换,以及对象跟基本类型之间的转换。我们分别来看一看这几种转换的规则。

StringToNumber

字符串到数字的类型转换,存在一个语法结构。类型转换支持十进制、二进制、八进制和十六进制,比如:

  • 30;
  • 0b111;
  • 0o13;
  • 0xFF。

此外,JavaScript 支持的字符串语法还包括正负号科学计数法,可以使用大写或者小写的 e 来表示:

  • 1e3;
  • -1e-2。

需要注意的是,parseInt 和 parseFloat 并不使用上面这个转换,所以支持的语法跟这里不尽相同。

在不传入第二个参数的情况下,parseInt 只支持 16 进制前缀“0x”,而且会忽略非数字字符,也不支持科学计数法。在一些古老的浏览器环境中,parseInt 还支持 0 开头的数字作为 8 进制前缀,这是很多错误的来源。所以在任何环境下,都建议传入 parseInt 的第二个参数,而 parseFloat 则直接把原字符串作为十进制来解析,它不会引入任何的其他进制。

多数情况下,Number 是比 parseInt 和 parseFloat 更好的选择。

以下内容来自 MDN

parseInt(string [, radix ]);

  • string 要被解析的值。如果参数不是一个字符串,则将其转换为字符串(使用 ToString 抽象操作)。字符串开头的空白符将会被忽略。

  • radix 可选(建议必传) 从 2 到 36,代表该进位系统的数字。例如说指定 10 就等于指定十进位。请注意,通常预设值不是 10 进位!

如果 radix 是 undefined、0或未指定的,JavaScript 会假定以下情况:

  1. 如果输入的 string 以 "0x"或 "0X"(一个0,后面是小写或大写的X)开头,那么 radix 被假定为16,字符串的其余部分被解析为十六进制数。
  2. 如果输入的 string 以 "0"(0)开头, radix 被假定为8(八进制)或10(十进制)。具体选择哪一个radix 取决于实现。ECMAScript 5 澄清了应该使用 10 (十进制),但不是所有的浏览器都支持。因此,在使用 parseInt 时,一定要指定一个 radix。
  3. 如果输入的 string 以任何其他值开头, radix 是 10 (十进制)。

如果 parseInt 遇到的字符不是指定 radix 参数中的数字,它将忽略该字符以及所有后续字符,并返回到该点为止已解析的整数值。 parseInt 将数字截断为整数值。 允许前导和尾随空格。 ——MDN


正常情况下,parseInt 的返回值是根据给定字符串解析后返回的数字,还有另一种可能的返回值是:NaN,有如下两种场景:

  1. 当遇到的非空白字符不能转化为数字时,返回 NaN,如:parseInt('321',2) 由于指定进制基数为2,字符串中的3不符合进制规则不能正常解析
  2. 传入的 radix 小于2(非0)或者大于36(请思考为什么)

总结:当传入的字符串以"0x"开头时,默认是16进制数,其他的格式都是10进制数,所以想转化一个二进制数的格式是 parsetInt('011', 2) 而不是 parsetInt('0b11', 2),同理对8进制数也是,不使用 parseInt('0o11', 8) 而是使用 parseInt('011', 8)。

下图来自标准规范中的定义,更多细节可以查看 标准中的关于 parseInt 的定义

image.png

NumberToString

在较小的范围内,数字到字符串的转换是完全符合你直觉的十进制表示。当 Number 绝对值较大或者较小时,字符串表示则是使用科学计数法表示的。这个算法细节繁多,我们从感性的角度认识,它其实就是保证了产生的字符串不会过长。具体的算法可以参考 JavaScript 的语言标准。

装箱转换

每一种基本类型 Number、String、Boolean、Symbol 在对象中都有对应的类,所谓装箱转换,正是把基本类型转换为对应的对象,它是类型转换中一种相当重要的种类。

全局的 Symbol 函数无法使用 new 来调用,但我们仍可以利用装箱机制来得到一个 Symbol 对象,我们可以利用一个函数的 call 方法来强迫产生装箱。我们定义一个函数,函数里面只有 return this,然后我们调用函数的 call 方法到一个 Symbol 类型的值上,这样就会产生一个 symbolObject。我们可以用 console.log 看一下这个东西的 type of,它的值是 object,我们使用 symbolObject instanceof 可以看到,它是 Symbol 这个类的实例,我们找它的 constructor 也是等于 Symbol 的,所以我们无论从哪个角度看,它都是 Symbol 装箱过的对象:

var symbolObject = (function(){ return this; }).call(Symbol("a"));

console.log(typeof symbolObject); //object
console.log(symbolObject instanceof Symbol); //true
console.log(symbolObject.constructor == Symbol); //true

装箱机制会频繁产生临时对象,在一些对性能要求较高的场景下,我们应该尽量避免对基本类型做装箱转换。

使用内置的 Object 函数,我们可以在 JavaScript 代码中显式调用装箱能力

var symbolObject = Object(Symbol("a"));

console.log(typeof symbolObject); //object
console.log(symbolObject instanceof Symbol); //true
console.log(symbolObject.constructor == Symbol); //true

每一类装箱对象皆有私有的 Class 属性,这些属性可以用 Object.prototype.toString 获取:

var symbolObject = Object(Symbol("a"));

console.log(Object.prototype.toString.call(symbolObject)); //[object Symbol]

在 JavaScript 中,没有任何方法可以更改私有的 Class 属性,因此 Object.prototype.toString 是可以准确识别对象对应的基本类型的方法,它比 instanceof 更加准确。但需要注意的是,call 本身会产生装箱操作,所以需要配合 typeof 来区分基本类型还是对象类型。如下所示:

const OBJECT_TYPE = 'object';
const OBJECT_FLAG = '[object ';

// 实现一个可以用来获取传入数据的私有 Class 属性的方法
const GetValueClass = function (value) {
  // 判断是否是基础类型
  if (typeof value !== OBJECT_TYPE) {
    // 基础类型则转化后输出
    let type = typeof value;
    return type.charAt(0).toUpperCase() + type.substring(1);
  }

  let classString = Object.prototype.toString.call(value);
  return classString.substring(OBJECT_FLAG.length, classString.length - 1);
};

// 实现一个 MyInstanceof 功能类似于 instanceof
const MyInstanceof = function (value, typeString) {
  // 获取私有属性
  let type = GetValueClass(value);
  // 判断私有属性的值
  return type === typeString;
};

拆箱转换


在 JavaScript 标准中,规定了 ToPrimitive 抽象函数,它是对象类型到基本类型的转换,即拆箱转换。

对象到 String 和 Number 的转换都遵循“先拆箱再转换”的规则。通过拆箱转换,把对象变成基本类型,再从基本类型转换为对应的 String 或者 Number。如果 valueOf 和 toString 都不存在或者没有返回基本类型,则会产生类型错误 TypeError。

下面演示了先调用 valueOf 再调用 toString

    var o = {
        valueOf : () => {console.log("valueOf"); return {}},
        toString : () => {console.log("toString"); return {}}
    }

    o * 2
    // valueOf
    // toString
    // TypeError

下面演示了先调用 toString 再调用 valueOf


    var o = {
        valueOf : () => {console.log("valueOf"); return {}},
        toString : () => {console.log("toString"); return {}}
    }

   	String(o)
    // toString
    // valueOf
    // TypeError

在 ES6 以后拆箱转换时如果对象部署了[Symbol.ToPrimitive] 则会调用[Symbol.ToPrimitive] 然后结束,没有[Symbol.ToPrimitive] 则会调用 valueOf 和 toString(根据不同场景有不同的调用顺序) 来获得拆箱后的基本类型


    var o = {
        valueOf : () => {console.log("valueOf"); return {}},
        toString : () => {console.log("toString"); return {}}
    }
    o[Symbol.toPrimitive] = () => {console.log("toPrimitive"); return "hello"}
    console.log(o * 2);
    // toPrimitive
    // NaN

		console.log(String(o));
		// toPrimitive
    // "hello"

		console.log(o + '');
		// toPrimitive
    // "hello"

从标准规范看拆箱转换


关于拆箱转换这部分在标准规范中定义如下image.png
通过上图规范可以看到,在进行 ToPrimitive 转换时会依赖一个可选参数 preferredType,支持 number 和 string 两种类型,如果不传则如上图 2 c 处默认是 number ,最终会导致优先调用 valueOf。感兴趣的可以直接查看原文

那么如果想要知道在转换时到底先 valueOf 还是先 toString,只要查对应的 preferredType 即可。比如当你想要知道 a + b 的真实执行顺序时,就可以去查询二进制运算的标准
image.png
首先需要验证传入数据是否可访问可获取需要借助 GetValue(规范原文在这),截图如下
image.png

然后就可以得到下一步字符串或者数字运算标准

image.png
这样就可以知道 a + b 的执行顺序是:

  1. 第一步让 a 成为表达式的左手端,b 成为表达式的右手端,然后分别校验是否可正常访问,未定义则报错,正常访问则获取值
  2. 获取到的值进入 ApplyStringOrNumericBinaryOperator 由于运算连接符 opText 是加号 “+”,满足 规范中的场景1
  3. 场景1中调用 ToPrimitive 时没有传递第二个参数 preferredType,所以默认是 number
  4. 得到结论:先执行 valueOf 再执行 toString (完结撒花✿✿ヽ(°▽°)ノ✿)

typeof


image.png

在表格中,多数项是对应的,但是请注意 object——Null 和 function——Object 是特例。