懂王系列(九)之彻底搞定JavaScript类型转换

518 阅读13分钟

作为一名前端小白,不知道大家是否遇到和我一样的问题。看了一道面试题的解析,当时觉得会了,可是过两天以后再看又不会了;盲目追求各种新技术,感觉什么都会点,但是一上手就不行了... 痛定思痛后,我终于认识到了问题所在,开始专注于基本功的修炼。近半年来通读了(其实是囫囵吞枣)《JavaScript高级程序设计》、《你不知道的JavaScript上、中、下》等书籍,本系列文章是我读书过程中对知识点的一些总结。喜欢的同学记得帮我点个赞😁。

懂王系列(一)之彻底搞懂JavaScript函数执行机制
懂王系列(二)之彻底搞懂JavaScript作用域
懂王系列(三)之彻底搞懂JavaScript对象
懂王系列(四)之彻底搞懂JavaScript类
懂王系列(五)之彻底搞懂JavaScript原型
懂王系列(六)之彻底搞懂JavaScript中的this
懂王系列(七)之彻底搞懂JavaScript数据类型
懂王系列(八)之彻底搞懂JavaScript语句
懂王系列(九)之彻底搞定JavaScript类型转换

将值从一种类型转换为另一种类型通常称为类型转换(type casting),这是显式的情况;隐式的情况称为强制类型转换(coercion)。

类型转换发生在静态类型语言的编译阶段,而强制类型转换则发生在动态类型语言的运行时(runtime)

1. 抽象值操作

1.1 ToString

1.1.1 普通对象

对普通对象来说,除非自行定义,否则 toString() 返回内部属性 [[Class]] 的值,如 [object Object]

1.1.2 数组对象

数组的默认 toString() 方法经过了重新定义,将所有单元字符串化以后再用 "," 连接起来:

var a = [1,2,3];
a.toString(); // "1,2,3"

1.1.3 JSON字符串化

JSON.stringify(..) 在对象中遇到 undefined、function 和 symbol 时会自动将其忽略,在数组中则会返回 null(以保证单元位置不变)。例如:

JSON.stringify(undefined); // undefined
JSON.stringify(function(){}); // undefined
JSON.stringify([1,undefined,function(){},4]); // "[1,null,null,4]"
JSON.stringify({ a:2, b:function(){} }); // "{"a":2}"

注意:对包含循环引用的对象执行 JSON.stringify(..) 会出错

如果对象中定义了 toJSON() 方法,JSON 字符串化时会首先调用该方法,然后用它的返回值来进行序列化。

// 自定义的JSON序列化
a.toJSON = function() {
  // 序列化仅包含b
  return {b: this.b};
};
JSON.stringify(a); // "{"b":42}"

toJSON() 返回的应该是一个适当的值,可以是任何类型,然后再由 JSON.stringify(..) 对其进行字符串化。也就是说,toJSON() 应该“返回一个能够被字符串化的安全的 JSON 值”,而不是“返回一个 JSON 字符串”。

JSON.stringify参数

  1. replacer
    我们可以向 JSON.stringify(..) 传递一个可选参数 replacer,它可以是数组或者函数,用来指定对象序列化过程中哪些属性应该被处理,哪些应该被排除,和 toJSON() 很像。

如果 replacer 是一个数组,那么它必须是一个字符串数组,其中包含序列化要处理的对象的属性名称,除此之外其他的属性则被忽略。

var a = { 
  b: 42,
  c: "42",
  d: [1,2,3] 
};
JSON.stringify( a, ["b","c"] ); // "{"b":42,"c":"42"}"
JSON.stringify( a, function(k,v){
  if (k !== "c") return v;
});
// "{"b":42,"d":[1,2,3]}"

如果 replacer 是函数,它的参数 k 在第一次调用时为 undefined(就是对对象本身调用的那次)。if 语句将属性 "c" 排除掉。由于字符串化是递归的,因此数组 [1,2,3] 中的每个元素都会通过参数 v 传递给 replacer,即 1、2 和 3,参数 k 是它们的索引值,即 0、1 和 2。

  1. space
    JSON.stringify 还有一个可选参数 space,用来指定输出的缩进格式。space 为正整数时是指定每一级缩进的字符数。它还可以是字符串,此时最前面的十个字符被用于每一级的缩进:
var a = { 
  b: 42,
  c: "42",
  d: [1,2,3] 
};
JSON.stringify( a, null, 3 );
// "{
  // "b": 42,
  // "c": "42",
  // "d": [
    // 1, 
    // 2,
    // 3
  // ]
// }"
JSON.stringify( a, null, "-----" );
// "{
// -----"b": 42,
// -----"c": "42",
// -----"d": [
// ----------1,
// ----------2,
// ----------3
// -----]
// }"

JSON.stringify(..) 并不是强制类型转换。在这里介绍是因为它涉及 ToString 强制类型转换,具体表现在以下两点。

(1) 字符串、数字、布尔值和 null 的 JSON.stringify(..) 规则与 ToString 基本相同。
(2) 如果传递给 JSON.stringify(..) 的对象中定义了 toJSON() 方法,那么该方法会在字符 串化前调用,以便将对象转换为安全的 JSON 值。

1.2 ToNumber

true 转换为 1,false 转换为 0。undefined 转换为 NaN,null 转换为 0。对象(包括数组)会首先被转换为相应的基本类型值,如果返回的是非数字的基本类型值,则再遵循以上规则将其强制转换为数字。

注意: 使用 Object.create(null) 创建的对象 [[Prototype]] 属性为 null,并且没有valueOf()toString() 方法,因此无法进行强制类型转换

1.3 ToBoolean

以下这些是假值:

• undefined
• null
• false
• +0、-0 和 NaN
• ""

假值的布尔强制类型转换结果为 false。

var a = []; // 空数组——是真值还是假值?
var b = {}; // 空对象——是真值还是假值?
var c = function(){}; // 空函数——是真值还是假值?
var d = Boolean(a && b && c); // true

[]{}function(){} 都不在假值列表中,因此它们都是真值。

2. 显式强制类型转换

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

除了 String(..) 和 Number(..) 以外,还有其他方法可以实现字符串和数字之间的显式转换:

var a = 42;
var b = a.toString();
var c = "3.14";
var d = +c;
b; // "42"
d; // 3.14

a.toString() 是显式的(“toString”意为“to a string”),不过其中涉及隐式转换。因为 toString() 对 42 这样的基本类型值不适用,所以 JavaScript 引擎会自动为 42 创建一个封装对象,然后对该对象调用 toString()。这里显式转换中含有隐式转换。

  1. 日期显式转换为数字
    一元运算符 + 的另一个常见用途是将日期(Date)对象强制类型转换为数字,返回结果为Unix 时间戳,以毫秒为单位
var d = new Date("Mon, 18 Aug 2014 08:53:06 CDT");
+d; // 1408369986000

我们常用下面的方法来获得当前的时间戳,例如:

var timestamp = +new Date();

JavaScript 有一处奇特的语法,即构造函数没有参数时可以不用带 ()。于是我们可能会碰到 var timestamp = +new Date; 这样的写法。这样能否提高代码可读性还存在争议,因为这仅用于 new fn(),对一般的函数调用 fn() 并不适用。

不建议对日期类型使用强制类型转换,应该使用 Date.now() 来获得当前的时间戳,使用 new Date(..).getTime() 来获得指定时间的时间戳。

  1. ~ 运算符
    ~x 大致等同于 -(x+1)。很奇怪,但相对更容易说明问题:
~42; // -(42+1) ==> -43

-(x+1) 中唯一能够得到 0(或者严格说是 -0)的 x 值是 -1。也就是说如果 x 为 -1 时,~和一些数字值在一起会返回假值 0,其他情况则返回真值。

~indexOf() 一起可以将结果强制类型转换(实际上仅仅是转换)为真 / 假值:

var a = "Hello World";
~a.indexOf( "lo" ); // -4 <-- 真值!
if (~a.indexOf( "lo" )) { // true
 // 找到匹配!
}
~a.indexOf( "ol" ); // 0 <-- 假值!
!~a.indexOf( "ol" ); // true
if (!~a.indexOf( "ol" )) { // true
 // 没有找到匹配!
}

如果 indexOf(..) 返回 -1~ 将其转换为假值 0,其他情况一律转换为真值。

2.2 显式解析数字字符串

解析和转换两者之间还是有明显的差别

var a = "42";
var b = "42px";

Number(a); // 42
parseInt(a); // 42

Number(b); // NaN
parseInt(b); // 42

解析允许字符串中含有非数字字符,解析按从左到右的顺序,如果遇到非数字字符就停止。而转换不允许出现非数字字符,否则会失败并返回 NaN。 解析和转换之间不是相互替代的关系。它们虽然类似,但各有各的用途。如果字符串右边的非数字字符不影响结果,就可以使用解析。而转换要求字符串中所有的字符都是数字,像 "42px" 这样的字符串就不行。

parseInt(..) 针对的是字符串值。向 parseInt(..) 传递数字和其他类型的参数是没有用的,比如 truefunction(){...}[1,2,3]

非字符串参数会首先被强制类型转换为字符串,依赖这样的隐式强制类型转换并非上策,应该避免向 parseInt(..) 传递非字符串参数

从 ES5 开始 parseInt(..) 默认转换为十进制数,除非另外指定。如果你的代码需要在 ES5 之前的环境运行,请记得将第二个参数设置为 10。

2.3 显示转换为布尔值

在 if(..).. 这样的布尔值上下文中,如果没有使用 Boolean(..)!!,就会自动隐式地进行 ToBoolean 转换。建议使用 Boolean(..)!! 来进行显式转换以便让代码更清晰易读。

建议使用 Boolean(a)!!a 来进行显式强制类型转换

3. 隐式强制类型转换

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

简单来说就是,如果 + 的其中一个操作数是字符串(或者通过以上步骤可以得到字符串),则执行字符串拼接;否则执行数字加法。

3.2 布尔值到数字的隐式强制类型转换

(1) if (..) 语句中的条件判断表达式。
(2) for ( .. ; .. ; .. ) 语句中的条件判断表达式(第二个)。
(3) while (..) 和 do..while(..) 循环中的条件判断表达式。
(4) ? : 中的条件判断表达式。
(5) 逻辑运算符 ||(逻辑或)和 &&(逻辑与)左边的操作数(作为条件判断表达式)。

3.3 || 和 &&

||&& 首先会对第一个操作数执行条件判断,如果其不是布尔值就先进行 ToBoolean 强制类型转换,然后再执行条件判断。

对于 || 来说,如果条件判断结果为 true 就返回第一个操作数的值,如果为false 就返回第二个操作数的值。

&& 则相反,如果条件判断结果为 true 就返回第二个操作数的值,如果为 false 就返回第一个操作数的值。

a || b;
// 大致相当于(roughly equivalent to):
a ? a : b;
a && b;
// 大致相当于(roughly equivalent to):
a ? b : a;

之所以说 a || ba ? a : b 大致相当,是因为它们返回结果虽然相同但是却有一个细微的差别。在 a ? a : b 中,如果 a 是一个复杂一些的表达式(比如有副作用的函数调用等),它有可能被执行两次(如果第一次结果为真)。而在 a || ba 只执行一次,其结果用于条件判断和返回结果(如果适用的话)。a && ba ? b : a 也是如此。

function foo() {
 console.log( a );
}
var a = 42;
a && foo(); // 42

如果条件判断未通过,a && foo() 就会悄然终止(也叫作“短路”,short circuiting),foo() 不会被调用。

为什么 a && (b || c) 这样的表达式在if 和 for 中没出过问题?

var a = 42;
var b = null;
var c = "foo";
if (a && (b || c)) {
 console.log( "yep" );
}

这里 a && (b || c) 的结果实际上是 foo 而非 true,然后再由 if 将 foo 强制类型转换为布尔值,所以最后结果为 true。现在明白了吧,这里发生了隐式强制类型转换。如果要避免隐式强制类型转换就得这样:

if (!!a && (!!b || !!c)) {
 console.log( "yep" );
}

3.4 符号的强制类型转换

ES6 允许从符号到字符串的显式强制类型转换,然而隐式强制类型转换会产生错误

var s1 = Symbol( "cool" );
String( s1 ); // "Symbol(cool)"
var s2 = Symbol( "not cool" );
s2 + ""; // TypeError

4. 宽松相等和严格相等

4.1 常规比较

  1. 字符串和数字之间的相等比较

(1) 如果 Type(x) 是数字,Type(y) 是字符串,则返回 x == ToNumber(y) 的结果。
(2) 如果 Type(x) 是字符串,Type(y) 是数字,则返回 ToNumber(x) == y 的结果。

  1. 其他类型和布尔类型之间的相等比较

(1) 如果 Type(x) 是布尔类型,则返回 ToNumber(x) == y 的结果;
(2) 如果 Type(y) 是布尔类型,则返回 x == ToNumber(y) 的结果。

  1. null 和 undefined 之间的相等比较

(1) 如果 x 为 null,y 为 undefined,则结果为 true。
(2) 如果 x 为 undefined,y 为 null,则结果为 true。

  1. 对象和非对象之间的相等比较

(1) 如果 Type(x) 是字符串或数字,Type(y) 是对象,则返回 x == ToPrimitive(y) 的结果;
(2) 如果 Type(x) 是对象,Type(y) 是字符串或数字,则返回 ToPrimitive(x) == y 的结果。

var a = "abc"; 
var b = Object(a); // 和new String( a )一样
a === b; // false 
a == b; // true

a == b 结果为 true,因为 b 通过 ToPrimitive 进行强制类型转换(也称为“拆封”,英文为 unboxed 或 unwrapped),并返回标量基本类型值 abc,与 a 相等。

但有一些值不这样,原因是 == 算法中其他优先级更高的规则。例如:

var a = null; 
var b = Object(a); // 和Object()一样
a == b; // false 
var c = undefined; 
var d = Object(c); // 和Object()一样
c == d; // false 
var e = NaN; 
var f = Object(e); // 和new Number( e )一样
e == f; // false

因为没有对应的封装对象,所以 nullundefined 不能够被封装(boxed),Object(null)Object() 均返回一个常规对象。

NaN 能够被封装为数字封装对象,但拆封之后 NaN == NaN 返回 false,因为 NaN 不等于 NaN

4.2 比较少见的情况

  1. 返回其它数字
Number.prototype.valueOf = function() {
 return 3;
};
new Number( 2 ) == 3; // true

23 都是数字基本类型值,不会调用 Number.prototype.valueOf() 方法。而 Number(2) 涉及 ToPrimitive 强制类型转换,因此会调用 valueOf()

if (a == 2 && a == 3) {
 // ..
}

// 方法1
let i = 1;
Number.prototype.valueOf = function() {
 return i++;
};
var a = new Number( 42 );
if (a== 1 && a == 2 && a == 3) {
 console.log( "Yep, this happened." );
}

// 方法2
let a = [1, 2, 3];
a.valueOf = a.shift;
// a.toString = a.shift;
if (a == 1 && a == 2 && a == 3) {
	console.log('OK');
}

// 方法3
let a = {
	i: 0,
	toString() {
		return ++this.i;
	}
};

if (a == 1 && a == 2 && a == 3) {
	console.log('OK');
}

// 方法4
let i = 0;
Object.defineProperty(window, 'a', {
	get() {
		//=>获取window.a的时候触发
		return ++i;
	},
	set() {
		//=>给window.a设置属性值的时候触发
	}
});
if (a == 1 && a == 2 && a == 3) {
	console.log('OK');
}
  1. 假值的相等比较
"0" == null; // false
"0" == undefined; // false
"0" == false; // true -- 晕!
"0" == NaN; // false
"0" == 0; // true
"0" == ""; // false
false == null; // false
false == undefined; // false
false == NaN; // false
false == 0; // true -- 晕!
false == ""; // true -- 晕!
false == []; // true -- 晕!
false == {}; // false
"" == null; // false
"" == undefined; // false
"" == NaN; // false
"" == 0; // true -- 晕!
"" == []; // true -- 晕!
"" == {}; // false
0 == null; // false
0 == undefined; // false
0 == NaN; // false
0 == []; // true -- 晕!
0 == {}; // false
  1. 极端情况
[] == ![] // true

! 运算符都做了些什么?根据 ToBoolean 规则,它会进行布尔值的显式强制类型转换(同时反转奇偶校验位)。所以 [] == ![] 变成了 [] == false。前面我们讲过 false == [],最后的结果就顺理成章了。

2 == [2]; // true
"" == [null]; // true

第一行中的 [2] 会转换为 2,然后通过 ToNumber 转换为 2。第二行中的 [null] 会直接转换为 ""

  1. 完整性检查
"0" == false; // true -- 晕!
false == 0; // true -- 晕! `
false == ""; // true -- 晕!
false == []; // true -- 晕!
"" == 0; // true -- 晕!
"" == []; // true -- 晕!
0 == []; // true -- 晕!

其中有 4 种情况涉及 == false,之前我们说过应该避免,应该不难掌握。 现在剩下 3 种:

"" == 0; // true -- 晕!
"" == []; // true -- 晕!
0 == []; // true -- 晕!

doSomething(0)doSomething([])这样的情况

function doSomething(a,b) {
 if (a == b) {
 // .. 
 }
}

doSomething("",0)doSomething([],"")也会如此。

  1. 安全运用隐式强制类型转换 我们要对 == 两边的值认真推敲,以下两个原则可以让我们有效地避免出错。

• 如果两边的值中有 true 或者 false,千万不要使用 ==。
• 如果两边的值中有 []、"" 或者 0,尽量不要使用 ==。

有一种情况下强制类型转换是绝对安全的,那就是 typeof 操作。typeof 总是返回七个字符串之一,其中没有空字符串。所以在类型检查过程中不会发生隐式强制类型转换。typeof x == "function" 是 100% 安全的,

Alex Dorey(GitHub 用户名 @dorey)在 GitHub 上制作了一张图表,列出了各种相等比较的情况,

5. 抽象关系比较

如果比较双方都是字符串,则按字母顺序来进行比较:

var a = ["42"];
var b = ["043"];
a < b; // false

ab 并没有被转换为数字,因为 ToPrimitive 返回的是字符串,所以这里比较的是 "42""043" 两个字符串,它们分别以 "4""0" 开头。因为 "0" 在字母顺序上小于 "4",所以最后结果为 false。 同理:

var a = [4, 2];
var b = [0, 4, 3];
a < b; // false

a 转换为 "4, 2"b 转换为 "0, 4, 3",同样是按字母顺序进行比较。 再比如:

var a = { b: 42 };
var b = { b: 43 };
a < b; // ??

结果还是 false,因为 a[object Object]b 也是 [object Object],所以按照字母顺序 a < b 并不成立。 下面的例子就有些奇怪了:

var a = { b: 42 };
var b = { b: 43 };
a < b; // false
a == b; // false
a > b; // false
a <= b; // true
a >= b; // true

为什么 a == b 的结果不是 true ?它们的字符串值相同(同为 "[object Object]"),按道理应该相等才对?实际上不是这样,你可以回忆一下前面讲过的对象的相等比较。

但是如果 a < ba == b 结果为 false,为什么 a <= ba >= b 的结果会是 true 呢?因为根据规范 a <= b 被处理为 b < a,然后将结果反转。因为 b < a 的结果是 false,所以 a <= b 的结果是 true

这可能与我们设想的大相径庭,即 <= 应该是小于或者等于。实际上 JavaScript 中 <= 不大于的意思(即 !(a > b),处理为 !(b < a))。同理,a >= b 处理为 b <= a

相等比较有严格相等,关系比较却没有“严格关系比较”(strict relational comparison)。也 就是说如果要避免 a < b 中发生隐式强制类型转换,我们只能确保 ab 为相同的类型,除此之外别无他法。

===== 的完整性检查一样,我们应该在必要和安全的情况下使用强制类型转换,如:42 < "43"。换句话说就是为了保证安全,应该对关系比较中的值进行显式强制类型转换:

var a = [ 42 ];
var b = "043";
a < b; // false -- 字符串比较!
Number( a ) < Number( b ); // true -- 数字比较!