掌握 Javascript 类型转换:隐式转换救救孩子

617 阅读7分钟

在上一篇中我们聊过了 JS 类型转换的规则和我发现的一些常见书籍中关于类型转换的一些小错误,当碰到显示类型转换的时候大家可以按照这些规则去拆解出答案。但 JS 中存在一些很隐晦的隐式类型转换,这一篇就来谈下我对隐式类型转换的一些总结。

关于 JS 类型转换规则请看上一篇的内容:掌握 JS 类型转换:从规则开始

什么是隐式类型转换呢?顾名思义就是有时候你感觉不到这是类型转换但是实际上类型转换已经发生了。所以这个 "隐式" 取决于我们的理解和经验,如果你看不出来那就是隐式的。

下面按照我自己对于隐式转换的分类来逐个聊聊吧。

一元操作符 +、-

var a = '123';
var b = +a;
console.log(b); // 123

先来看看 +- 在一个类型值前面,这里会执行 ToNumber 类型转换。如果是 - 在前面的话,还会将结果的符号取反,如:-"123" 的结果是 -123。并且如果原类型是对象的话也是遵循 ToNumber 的转换规则,大家可以自己试试,这里就不再举多余的例子了。

二元操作符

接下来我们来看一下二元操作符相关的隐式转换,比如:+-&&||==等等这些。

相减 a - b

var a = '123';
var b = true;
console.log(a - b); // 122

当执行减法操作时,两个值都会先执行 ToNumber 转换,所以这个是比较简单的,当类型是对象时也是遵循同样的规则。

相加 a + b

console.log('123' + 4); // "1234"
console.log(123 + true); // 124

相加的情况有点复杂,但隐式转换的规则大家可以按照我总结的来记:

  1. 如果 + 的操作数中有对象,则执行 ToPrimitive 并且 hint 是 Number
  2. 如果 + 中有一个操作数是字符串(或通过第一步得到字符串),则执行字符串拼接(另一个操作数执行 ToString 转换),否则执行 ToNumber 转换后相加

这个相加操作的隐式转换规则看似有点麻烦,其实解析后还是很明确的。

第一步,先看操作数里面有没有对象,如果有就是执行 hint 是 Number 的 ToPrimitive 操作。大家可以回忆下上篇说的 ToPrimitive 的内容,这里要注意的是这里的 ToPrimitive 并没有将操作数强制转化为 Number 类型。因为 hint 是 Number,所以先执行 valueOf() ,如果返回了字符串那转换结果就是字符串了;如果返回的不是基本类型值才会执行 toString(),如果都没有返回基本类型值就直接抛异常了。

第二步,如果有一个操作数是字符串,那么整个结果就是字符串拼接的,否则就是强转数字加法;第二个操作数就会按这个规则进行对应的类型转换。

开头的代码说明了字符串加数字、数字加布尔值的结果按这个规则走的,下面我们来看看对象情况下的代码:

var a = Object.create(null);
a.valueOf = function() {
  return '123';
}
a.toString = function() {
  return '234';
}
console.log(a + 6); // "1236"

以上的执行结果说明了执行 ToPrimitive 并且 hint 是 Number 结论是正确的,因为 "123"valueOf 返回的。两个操作数相加的其他情况大家也可以自己试试,记住我上面的总结就完了。

a && b、a || b

在 JS 中我们都知道 &&|| 是一种"短路”写法,一般我们会用在 ifwhile 等判断语句中。这一节我们就来说说 &&|| 出现的隐式类型转换。

我们通常把 &&|| 称为逻辑操作符,但我觉得 《你不知道的 Javascript(中卷)》中有个说法很好:称它们为"选择器运算符"。看下面的代码:

var a = 666;
var b = 'abc';
var c = null;

console.log(a || b); // 666
console.log(a && b); // "abc"
console.log(a || b && c); // 666

&&|| 会对操作数执行条件判断,如果操作数不是布尔值,会先执行 ToBoolean 类型转换后再执行条件判断。最后 &&|| 会返回一个操作数的值还不是返回布尔值,所以称之为"选择器运算符"很合理。

这里有个可能很多人都不知道的情况是:在判断语句的执行上下文中,&&|| 的返回值如果不是布尔值,那么还会执行一次 ToBoolean 的隐式转换:

var a = 666;
var b = 'abc';
var c = null;

if (a && (b || c)) {
  console.log('yes');
}

如果要避免最后的隐式转换,我们应该这样写:

if (!!a && (!!b || !!c)) {
  console.log('yes');
}

a == b 和 a === b

从这里开始是 JS 中隐式转换最容易中坑的地方。。

首先我们先明确一个规则:"== 允许在相等比较中进行类型转换,而 === 不允许。"

所以如果两个值的类型不同,那么 === 的结果肯定就是 false 了,但这里要注意几个特殊情况:

  • NaN !== NaN
  • +0 === -0

ES5 规范定义了 == 为"抽象相等比较",即是说如果两个值的类型相同,就只比较值是否相等;如果类型不同,就会执行类型转换后再比较。下面我们就来看看各种情况下是如何转换的。

null == undefined

这个大家记住就完了,null == undefined // true。也就是说在 == 中 null 与 undefined 是一回事。

所以我们判断变量的值是 null 或者 undefined 就可以这样写了:if (a == null) {...}

数字和字符串的抽象相等比较

一个操作数是字符串一个是数字,则字符串会被转换为数字后再比较,即是:ToNumber(字符串) == 数字

var a = 666;
var b = '666';
console.log(a == b); // true

布尔值与其他类型的抽象相等比较

注意,这里比较容易犯错了:

var a = '66';
var b = true;
console.log(a == b); // false

虽然 '66' 是一个真值,但是这里的比较结果却不是 true,很容易掉坑里。大家记住这个规则:布尔值如果与其他类型进行抽象比较,会先用 ToNumber 将布尔值转换为数字再比较。

显然 '66' == 1 的结果当然是 false 咯。

对象与非对象的抽象相等比较

先说下规则:如果对象与非对象比较,则先执行 ToPrimitive(对象),并且 hint 参数为空;然后得到的结果再与非对象比较。

这里值得注意的是:在 ToPrimitive() 调用中如果 hint 参数为空,那么 [[DefaultValue]] 的调用行为跟 hint 是Number 时一样——先调用 valueOf() 不满足条件再调用 toString()

注意这里有个例外情况:如果对象是 Date 类型,则 [[DefaultValue]] 的调用行为跟 hint 是 String 时一样。

我们来测试一下是不是这样的:

var a = Object.create(null);
a.valueOf = function() {
  console.log('a.valueOf is invoking.');
  return 666;
};
a.toString = function() {
  console.log('a.toString is invoking.');
  return '666';
};

console.log(a == 666);
// a.valueOf is invoking.
// true

console.log(a == '456');
// a.valueOf is invoking.
// false

a.valueOf = undefined;
console.log(a == '666');
// a.toString is invoking.
// true

根据输出来看依据上面的规则来解释是 OK 的。

有一个开源项目有张图表可以方便大家去记忆 =====,点击 这里 查看。

a > b、a < b

按惯例先总结规则,情况略微复杂:

第一步:如果操作数是对象则执行 ToPrimitive(对象),并且 hint 参数为空。

第二步:

  • 如果双方出现非字符串,则对非字符串执行 ToNumber,然后再比较
  • 如果比较双方都是字符串,则按字母顺序进行比较

我们还是用代码来测试下:

var a = Object.create(null);
a.valueOf = function() {
  console.log('a.valueOf is invoking.');
  return '666';
};
a.toString = function() {
  console.log('a.toString is invoking.');
  return true;
};

console.log(a > '700');
// a.valueOf is invoking.
// false

a.valueOf = undefined;
console.log(a < 2);
// a.toString is invoking.
// true

这里注意下当测试 a < 2 时,toString() 返回了 true,然后会执行 ToNumber(true) 返回 1,最后 1 < 2 的结果就是 true。

a ≥ b,a ≤ b

最后这里也是一个比较容易中坑的地方。

根据规范 a ≤ b 会被处理为 a > b,然后将结果反转,即处理为 !(a > b);a ≥ b 同理按照 !(a < b) 处理。

我们来看个例子:

var a = { x: 666 };
var b = { x: 666 };

console.log(a >= b); // true
console.log(a <= b); // true

这里 a 和 b 都是字面量对象,valueOf() 的结果还是对象,所以转为执行 toString(),结果都是'[object Object]',当然 a < ba > b 的结果都是 false,然后取反结果就是 true 了。≤ 和 ≥ 的结果都是 true,是不是有点出乎意料呢 :)

总结

上一篇写了 JS 类型转换的规则,这一篇写了隐式转换中我总结的经验和判断法则。感觉已经差不多了,剩下的就是实践中自己去理解了,后续可能还会找一些比较坑的类型转换示例代码写一篇拆解分析。

感谢大家花时间听我比比,欢迎 star 和关注我的 JS 博客:小声比比 JavaScript

参考资料