js中的类型转换,其实没那么难!!!

lxf2023-03-10 18:45:01

开篇

  • 类型转换的问题一直存在我们的身边,无论是在日常开发中,还是在面试里,类型转化你的操作都是必不可少的,时不时就需要我们主动进行强制类型转换或者隐式类型转换。
  • 类型转换发生在静态类型语言的编译阶段,而强制类型转换则发生在动态类型语言的运行时。
  • JavaScript 中,通常将它们统称为强制类型转换,当然,你也可以理解为 隐式强制类型转换显示强制类型转换,二者的区别显而易见,我们能够从代码中看出哪些地方是显示强制类型转换,而隐式强制类型转换则不那么明显,通常是某些操作产生的副作用,例如:
var a = 77;
var b = a + ""; // 隐式强制类型转换
var c = String(a); // 显示类型转换

toString

  • toString() 方法返回一个表示该对象字符串。该方法旨在重写(自定义)派生类对象的类型转换的逻辑。
  • 该方法由 字符串转换 优先调用,所有继承自 Object.prototype 的对象都继承 toString() 方法。

valueOf

  • OBjectvalueOf() 方法将 this 值转换为一个对象。此方法旨在用于自定义类型转换的逻辑时,重写派生类。
  • 该方法由 数值转换原始值转换 优先调用,但是 字符串转换 会优先调用 toString(),并且 toString(),并且 toString() 非常有可能返回一个字符串类型,所以 valueOf() 在这种情况下通常不会被调用。

Symbol.toPrimitive

  • Symbol.toPrimitive 是内置的 symbol 属性,其指定了一种接受首选类型并返回对象原始值表示的方法。它被所有的 强类型转换制 算法优先调用。
  • 在 Symbol.toPrimitive 属性(用作函数值)的帮助下,对象可以转换为一个原始值。该函数被调用时,会被传递一个字符串参数 hint,表示要转换到的原始值的预期类型。hint 参数的取值是 "number""string" 和 "default" 中的任意一个。
  • 以下示例描述了 Symbol.toPrimitive 属性如何修改从对象转换的原始值:
const object = {
  [Symbol.toPrimitive](type) {
    if (type === "number") return 77;
    if (type === "string") return "string优先调用这里";
    if (type === "default") return "default";
  },
  valueOf() {
    return 22;
  },
  toString() {
    return 33;
  },
};

console.log(String(object)); // string优先调用这里 type 参数值是 "number"
console.log(Number(object)); // 77  type 参数值是 "string"
console.log(object + ""); // default type 参数值是 "default"

JSON字符串化

  • 工具函数 JSON.stringify(...) 在将 JSON 对象序列化为字符串时也用到了 Tostring
  • JSON 字符串化和 toString() 的效果基本相同,只不过序列化的结果总是字符串:
console.log(JSON.stringify(77)); // '77'
console.log(JSON.stringify("77")); // '77'
console.log(JSON.stringify(null)); // 'null'
console.log(JSON.stringify(undefined)); // undefined
  • JSON.stringify(...) 在对象中遇到 undefinedfunction()symbol时会自动将其忽略,在数组中则会返回 null,例如:
console.log(JSON.stringify(undefined)); // undefined
console.log(JSON.stringify(function () {})); // undefined
console.log(JSON.stringify(class C {})); // undefined
console.log(JSON.stringify([1, undefined, function () {}, 4])); // [1,null,null,4]
console.log(JSON.stringify({ a: 2, b() {} })); // "{"a":2}"
console.log(JSON.stringify({ x: undefined, y: Object, z: Symbol("") })); // '{}'
  • 如果对象中定义了 toJSON() 方法,JSON 字符串化时会首先调用该方法,然后用它的返回值进行序列化。
  • 如果要对含有非法 JSON 值的对象做字符串化,或者对象中的某些值无法被序列化时,或需要定义 toJSON() 方法返回一个安全的 JSON 值,例如:
var obj = {};

var a = {
  b: 77,
  c: obj,
  d: function () {},
};

// 在 a 中创建一个循环引用
obj.cycle = a;

// 循环引用在这里会产生错误
JSON.stringify(a);

js中的类型转换,其实没那么难!!!

  • 对象包含循环引用的对象执行 JSON.stringify(...) 会出错。
var obj = {
  foo: 11,
  toJSON() {
    return { b: 77 };
  },
};

console.log(JSON.stringify(obj)); // "{"b":77}"
  • toJSON() 返回的是一个适当的值,可以是任何类型,然后再有 JSON.stringify(...) 对其进行字符串化。也就是说,toJSON(),应该返回一个能够被字符串化的安全的 JSON 值,而不是返回一个 JSON 字符串,例如:
var a = {
  value: [1, 2, 3],
  toJSON: function () {
    return this.value.slice(1);
  },
};

console.log(JSON.stringify(a)); // "[2,3]"

var b = {
  value: [1, 2, 3],
  toJSON: function () {
    return "[" + this.value.slice(1).join() + "]";
  },
};

console.log(JSON.stringify(b)); // ""[2,3]""
  • 这里第二个函数是对 toJSON 返回的字符串做字符串化,而非数组本身。
  • 我们可以向 JSON.stringify(...) 传递一个可选参数 replacer,它可以是数组或者函数,用来指定对象序列化过程中哪些属性应该被处理,哪些应该被排除,和 toJSON()很像。
  • 如果 replacer 是一个数组,那么它必须是一个字符串数组,其中包含序列化要处理的对象的属性名称,除此之外其他的属性则被忽略。
  • 如果 replacer 是一个函数,它会对对象本身调用一次,然后对对象中的每个属性各调用一次,每次传递两个参数,键和值。如果要忽略某个键就返回 undefined,否则返回指定的值:
var a = {
  foo: 77,
  bar: "moment",
  baz: [1, 2, 3],
};

console.log(JSON.stringify(a, ["bar", "baz"])); // "{"bar":"moment","baz":[1,2,3]}"

console.log(
  JSON.stringify(a, function (key, val) {
    if (key !== "foo") return val;
  })
); // "{"bar":"moment","baz":[1,2,3]}"
  • JSON.stringify(...) 并不是强制类型转换,因为它涉及 ToSting 强制类型转换,具体表现在以下两点:
  1. 字符串数字布尔值nullJSON.stringify(...) 规则于 ToString基本相同。
  2. 如果传递给 JSON.stringify(...) 对象中定义了 toJSON() 方法,那么该方法会在字符串化前调用,以便将对象转换为安全的 JSON 值。

ToNUmber

  • 有时我们需要将非数字值当做数组来使用,比如数字运算。为此 es5规范 给我们定义了抽象操作 ToNumber,其基本语法ToNUmber(argument),它在调用时执行以下步骤:
  1. 如果 argument 是数字类型,直接返回 argument;
  2. 如果 argumentSymbolBigint,则抛出一个 TypeError(类型错误) 错误;
  3. 如果 argumentundefined,返回 NAN;
  4. 如果 argumentnullfalse,则返回 +0;
  5. 如果 argumenttrue,则返回 1;
  6. 如果 argument 是一个 string 类型,则返回一个 StringToNumber(argument) 方法,如果 argument 不是只包含数字的字符串,例如 "1,2",那么 Number 函数会将其转为 `NAN;
  • 如果传入的是对象(包括数组),会首先被转换为相对应的基本类型,如果返回的是非数字的基本类型值,则再遵循以上 规则6 将其强制转为数字。
  • 为了将帝乡转换为相对应的基本类型子,抽象操作 ToPrimitive会首先通过内部操作 [[DefaultValue]](hint),如果 hint 参数是 number,会检查该值是否有 valueOf() 方法,如果存在,设定 value 是调用 valueOf()的内部 [[Call]]方法的返回结果,如果 value是原始值,则直接返回,就使用该值进行强制类型转换。如果没有 valueOf()方法就使用 toString()方法,也是跟 valueOf()方法相似,如果返回值存在,就对该值进行强制类型转换。
  • 如果 valueOf()toString()均不返回基本类型值,会产生 TypeError 错误。
  • 从 ES5 开始,使用 OBject.create(null)创建的对象 [[Prototype]] 属性为 null,所以也就没有 valueOf()toString()方法,因此无法进行强制类型转换。
var a = {
  valueOf: function () {
    return "66";
  },
  toString() {
    return "77";
  },
};

var b = {
  toString() {
    return "77";
  },
};

var c = [2, 3];
c.toString = function () {
  return this.join("");
};

console.log(Number(a)); // 66
console.log(Number(b)); // 77
console.log(Number(c)); // 23
console.log(Number("")); // 0
console.log(Number([])); // 0
console.log(Number([1, 2])); // NAN
console.log(Number(["a"])); // NAN
console.log(Number("12ab")); // NAN
  • 在上面的例子中,数字的强制类型转换会优先调用 valueof() 方法,随后是 toString() 方法。
  • c 变量中修改了数组的原型方法 toString(),使其返回一个字符串 "23",然后对其强制类型转换。
  • 对数组进行强制类型转换,会先调用数组的 toString()方法,[] 会变成 "",[2,3] 会变成 "2,3",所以遵循规则,空字符串会返回 0,而如果不是纯数字的字符串会返回 NAN

ToBolean

  • JavaScript 中有两个关键词 truefalse,分别代表布尔类型中的真和假。
  • ES规范 中定义了抽象操作 ToBOlean,该抽象操作接收一个参数 argument,并且遵循以下规则:
  1. 如果 argument是一个 Completion Record 类型,并且是一个 abrupt completion,直接返回 argument,否则返回 ToBolean(argument.[[value]]);
  2. 如果 argument是一个 undefined 类型,返回 false;
  3. 如果 argument是一个 Null 类型,返回 false;
  4. 如果 argument是一个 Boolean 类型,直接返回 argument;
  5. 如果 argument是一个 Number 类型,并且是 +0,-0或者NAN,返回 false,否则返回true;
  6. 如果 argument是一个 String 类型,并且是一个空字符串(字符串长度为0),返回false,否则返回true;
  7. 如果 argument是一个 Symbol 类型,返回 true;
  8. 如果 argument是一个 Object 类型,包括 []{}function(){},都返回 true(注意,这里还有一个意外,请看规则9);
  9. 如果 argument是一个 Object 类型,并且 argument 拥有 [[IsHTMLDDA]]内部插槽,返回 false,例如 document.all;
console.log(Boolean(undefined)); // false
console.log(Boolean(null)); // false
console.log(Boolean(true)); // true
console.log(Boolean(+0)); // false
console.log(Boolean(-0)); // false
console.log(Boolean(NaN)); // false
console.log(Boolean(777));
console.log(Boolean("")); // false
console.log(Boolean("1")); // true
console.log(Boolean('""')); // true  长度不为0
console.log(Boolean(Symbol())); // // true
console.log(Boolean(function () {})); // // true
console.log(Boolean({})); // true
console.log(Boolean([])); // true
console.log(Boolean(document.all)); // false

Completion Record

  • Completion Record(完成记录) 是一种特殊的 Record,用于表示流程运行带特定步骤时的运行结果。例如我们控制台在输入 var a = 2; 得到的结果却是 undefined。普通语句执行后会得到 [[type]] 值为 normalCompletion Record,所以普通语句执行完成之后就继续执行下一条,而只有表示式语句才会有 [[value]] 值,var 语句执行得到的是一个 [[vaue]] 值为Completion Record
  • ES5规范 中可运行任何步骤、语句都会显示或隐式地返回一个 Completion Record,它具有特定的三个字段,如下图:

js中的类型转换,其实没那么难!!!

  1. [[Type]] 字段,即当前 Completion Record 的类型,它的值是 normalreturnthrowbreak 或者 continue 五选一,表示这个 Completion Record 是通过什么样的语句、步骤而生成的;
  2. 如果 [[Type]]normalreturnthrow 三者中的一个,那么这个 Completion Record[[Value]] 会记录是生成正常值或者抛出的异常值,否则就是 empty;
  3. 如果 [[Type]]breakcontinue,那么 [[Target]] 就包含控制流要转移的目标 label,类似于 goto 语句。
  • Completion Record[[Type]]normal 时,我们就成这个 Completion Record 是 normal completion 正常完成,否则,就称之为 abrupt completion 中断式完成

显示强制类型转换

  • 显示强制类型转换是那些显而易见的类型转换,很多类型转换都属于此类。

字符串和数字之间的显式转换

  • 字符串和数字之间的转换是通过 String(...)Number(...) 这两个内建函数来实现的,但是它们前面没有 new 关键字,并不创建封装对象。例如:
var a = 77;
console.log(String(a)); // "77"

var b = "3.14";
console.log(Number(b)); // 3,14
  • 除了 String(...)Number(...) 意外,还有其他方法可以实现字符串和数字之间的显式转换:
var a = 77;
console.log(a.toString()); // "77"

var b = "3.14";
console.log(+b); // 3,14
  • a.toString() 是显式的,不过其中设计隐式转换。因为 77 根本没有 tostring() 的方法或者说 toString() 对这些基本类型不适用,所以JavaScript 引擎会自动为 77 创建一个封装对象,然后对该对象调用 toString()。这里显示转换中含有隐式转换, a.toString() 实际上是执行的以下代码:
var a = 77;
var aa = new String(a);
console.log(aa.toString()); // "77"
  • 在上例中 +b+ 运算符的一元形式(即只有一个操作数)。 + 运算符显示地将 b 转换为数字,而非数字加法运算,也不是字符串拼接。
  • 一元操作符的其他操作,例如:
var a = "3.14";
var b = 3.14 + +a;
console.log(b); // 6.28
console.log(1 + - + + + - + 1); // 2
  • 那么我们继续看看下面你的例子:
console.log(+[]); // 0
console.log(+["1"]); // 1
console.log(+["1", "2", "3"]); // NaN
console.log(+{});// NaN
  • 上面的例子中 +[],首先调用 valueOf() 方法,但是 valueOf() 不存在,调用 toString() 方法,返回 "",得到结果,然后对其调用 ToNumber() 方法,"" 对应的返回值是 0,所以返回 0,下面两个同样的道理。而 {} 调用 toString() 方法返回的是 [object Object],对其调用 ToNumber() 方法,返回的结果是 NaN

显式解析数字字符串

  • 解析字符串中的数字和将字符串强制类型转换为数字返回的结果都是数字。但是解析和转换两者之间还是有明显的差别的。例如:
var a = "77";
var b = "77px";

console.log(Number(a)); // 7
console.log(parseInt(a)); // 7

console.log(Number(b)); // Nan
console.log(parseInt(b)); // 7
  • 解析运行字符串中含有非数字字符,解析从左侧到右的顺序,如果遇到非数字字符就停止。而转换不允许出现非数字字符,否则会失败并返回 NaN
  • 再看一个例子:
console.log(parseInt(1/0, 19)); // 18
  • 咦,这怎么输出的18,难道不是 NaN吗,这一定有问题,不行,我要重启电脑看看,再次打开电脑,发现依然是 18,在开机的过程中我已经想到了答案了,且听我一一道来:parseInt(1/0, 19) 实际上是 parseInt("Infinity", 19)。第一个字符是 "I",以19为基数时值为18。第二个字符 "n"不是一个有效的数字字符,解析到此为止,所以输出的是 15
  • 现在一些看起来奇奇怪怪的但实际上解释的通的例子:
console.log(parseInt(0.000008)); // 0 (0 来自于"0.000008")
console.log(parseInt(0.0000008)); // 0 (0 来自于"8e-7")
console.log(parseInt(false, 16)); // 250 ("fa" 来自于 "false")
console.log(parseInt(parseInt, 16)); // 15 ("f" 来自于 "function")

console.log(parseInt("0x10")); // 16
console.log(parseInt("103", 2)); // 2 (3无效)
  • parseInt() 如果传入一个对象,如果该对象含有 toString() 方法,则直接隐式调用用该方法,和前面说到的一样,把该方法返回值作为 parseInt() 的参数传递:
var obj = {
  a: 1,
  toString() {
    return this.a;
  },
  valueOf() {
    return 2;
  },
  toJSON() {
    return 3;
  },
};

console.log(parseInt(obj)); //  1

显示转换为布尔值

  • 与前面的 String(...)Number(...)一样,Boolean(...) 是显示的 ToBoolean 强制类型转换,虽然 Boolean(...) 是显示的,但并不常用。
  • 一元运算符 ! 显式地将值强制类型转换为布尔值。但是它同时还将真值反转为假值(或者将假值反转为真值)。所以显式强制类型转换为布尔值最常用的方法是 !!,因为第二个 ! 会将结果反转回原值:
var a = "0";
var b = [];
var c = {};

var d = "";
var e = 0;
var f = null;

var g;

console.log(!!a); // true
console.log(!!b); // true
console.log(!!c); // true
console.log(!!d); // false
console.log(!!e); // false
console.log(!!f); // false
console.log(!!g); // false

隐式强制类型转换

  • 隐式强制类型转换指的是那些隐藏的强制类型转换,副作用也不是很明显。换句话说,你自己不觉得不够明显的强制类型转换都可以算作隐式强制类型转换。
  • 显示强制类型转换旨在让代码更加清晰易读,而隐式强制类型转换看起来就像是它的对立面,会让代码变得更晦涩。

字符串和数字之间的隐式强制类型转换

  • 通过重载, + 运算符既能用于数字加法,也能用于字符串拼接。JavaScript 怎样来判断我们要执行的是哪个操作?例如:
var a = "77";
var b = "0";

var c = 77;
var d = 0;

console.log(a + b); // 770
console.log(c + d); // 77
  • 这里为什么会得到 "770"77 两个不同的结果呢?通常的理解是,因为某一个或者两个操作数都是字符串,所以 + 执行的是字符串拼接操作。这样解释只对了一般,实际情况要复杂的多,例如:
var a = [1, 2];
var b = [3, 4];

console.log(a + b); // "1,23,4"
  • ab 都不是字符串,但是它们都被强制转换为字符串然后进行拼接,原因是什么呢?

js中的类型转换,其实没那么难!!!

  • 根据上图 ES5规范 ,我们来个简单的总结,主要有以下规则:
  1. 把第一个表达式 AdditiveExpression 的值赋值给左引用 lref;
  2. 使用 GetValue(lref) 方法获取左引用 lref 的计算结果,并赋值给左值 lval
  3. 使用 ReturnIfAbrupt(lval) 返回运算结,如果 lvalabrupt completion,则直接返回,如果是一个 Completion Record,那么则通过该记录的内部 [[value]] 获取生成的是正常值还是抛出的异常值,否则就是empty,empty 一般都为 undefined;
  4. 右边的也是相同的步骤;
  5. 使用 ToPrimitive(lval)获取左值 lval 的原始类型,并将其赋值给左原生值 lprim;
  6. 使用 ToPrimitive(lval)获取左值 rval 的原始类型,并将其赋值给右原生值 rprim;
  7. 如果操作符左边或者操作符右边其中一个是 String 类型,则把另外一个非 String 类型转换为 String 类型,再进行字符串拼接。如果另外一个也是 String 类型,则直接进行字符串拼接;
  8. ToNumber(lprim) 的结果赋值给左数字 lnum;
  9. ToNumber(rprim) 的结果赋值给左数字 rnum;
  10. 返回左数字 lnum 和 右数字 rnum 相加的数值;
  • 然鹅,加法操作还有以下规范:
  1. 如果其中一个操作数是 NaN,则返回结果便是 NaN;
  2. 两个 Infinity 相加还是 Infinity;
  3. 两个符号相反的 Infinity 相加是 NaN;
  4. Infinity 和一个有限值相加还是 Infinity;
  5. 两个 -0 的和是 -0,两个 +0 或两个符号相反的 0 的和为 +0
  6. 零和非零有限值之和等于非零操作数。
console.log(null + 1); // 1 Number(null) === 0
console.log(undefined + 1); // NaN  Number(undefined)=== NaN
console.log(NaN + 1); // NaN
console.log(-Infinity + Infinity); // Infinity
console.log(Infinity + Infinity); // Infinity
console.log(9999999 + Infinity); // Infinity
console.log(-0 + 0); // 0
console.log(-0 + -0); // -0
  • 在上面的数组相加的例子中,因为数组并没有 valueOf() 方法,于是转而调用 toString()。因此上例中的两个数组变成了 "1,2""3,4"+ 操作符将它们进行了拼接,所以便返回了 "1,23,4"
  • 我们再来看看下面的例子:
var obj = {
  valueOf() {
    return 7;
  },
  toString() {
    return "7";
  },
};

var foo = {
  valueOf() {
    return 7;
  },
  toString() {
    return "7";
  },
};

var bar = {
  toString() {
    return "7";
  },
};

console.log(foo + obj); // 14
console.log([1] + foo); // "17"
console.log(foo + bar); // 7
  • 通过代码输出可以得出结论,两个对象相加,如果对象内部有 valueOf() 方法,会优先调用该方法,否则调用 toString() 方法。剩下的继续遵循上面讲解的规则。

  • 我们再看看下面的那几对奇葩:

console.log([] + {});// [object Object]
console.log({} + []); // [object Object]
  • 通过查看输出,两者的结果一致,按照规范,它们是这样执行的:[] 调用 toString() 方法,返回 "",而 {} 调用 toString() 方法,返回的是 [object Object],两者进行字符串拼接,于是有 [object Object] 这样的输出。
  • 但是这两个卧龙凤雏在浏览器上输出便产生了不同的结果,具体看下图:

js中的类型转换,其实没那么难!!!

  • 这是因为 {} 被当成了一个独立的代码快运行了,所以 {} + [] 变成了 +[],所以结果就变成了 0
  • 但是,对其加上一个括号,它又变回原样了。

js中的类型转换,其实没那么难!!!

  • 最后一个例子:
console.log(new Date(2022, 11, 20) + 777); // Tue Dec 20 2022 00:00:00 GMT+0800 (中国标准时间)777
  • 在这里 Date 是一个特例,如果其中一个操作数是对象,则对象则会遵循对象到原始值的转换规则,也就是首先调用 valueOf(),日期对象直接调用 toString 方法转成字符串,其它对象先调用 valueOf() 方法,所以该结果进行的是字符串拼接。

  • + 操作符讲完了,接下来就看看 - 操作符,但是这个没 + 操作符那么复杂,这个很简单,继续看规范。

js中的类型转换,其实没那么难!!!

  • + 操作符不同的是, - 操作符默认都是把左右值优先转成 number 类型的,具体解释请看下面的代码:
console.log([1, 3] - 2); // Nan   Number([1, 3]) - 2
console.log(null - 1); // -1   Number(null) - 1
console.log(undefined - 1); // NaN    Number(undefined) - 1
console.log(77 - "7"); // 70  77 - Number("7")
console.log([3] - [1]); // 2  Number([3]) - Number([1])
console.log(new Date() - 1); // 1668914470081   new Date().valueOf() - 1
  • 好了,突然看了一些字数,好像已经很多了,那么就分成两篇吧,这个大概要到星期二或者星期三才能完成,再继续写就要挂科了。

js中的类型转换,其实没那么难!!!

参考文献

  • ES5规范
  • ECMA标准
  • ECMA标准
  • MDN