探讨 JavaScript 中 Coercion (类型转换) 的隐式和显式规则,特别是涉及 ToPrimitive, ToString, ToNumber 等内部操作的转换逻辑。

早上好,各位! 今天咱们聊聊 JavaScript 里那些“暗箱操作”——类型转换(Coercion)。 别害怕,这玩意听起来玄乎,其实就像魔术,你知道原理了,也就那么回事。

开场白:JavaScript 的 “七十二变”

JavaScript 就像个性格多变的演员,同一个值,在不同的场合,可以扮演不同的角色。 比如,数字 5,既可以老老实实当个数字,也可以摇身一变成字符串 "5"。 这就是类型转换在搞鬼。 类型转换分两种:

  • 显式类型转换 (Explicit Coercion): 你主动要求它变。 比如 String(5)Number("42")
  • 隐式类型转换 (Implicit Coercion): JavaScript 偷偷摸摸地帮你变。 比如 5 + "5"if (0)

今天咱们重点聊聊这“偷偷摸摸”的隐式类型转换,因为这才是 Bug 的温床,也是面试官最爱挖坑的地方。

第一幕: ToPrimitive – 类型转换的幕后推手

所有类型转换,最终都要落到原始类型(primitive types)上。JavaScript 有七种原始类型:

  • String
  • Number
  • Boolean
  • Null
  • Undefined
  • Symbol (ES6 新增)
  • BigInt (ES2020 新增)

所以,当 JavaScript 遇到一个非原始类型(比如对象、数组),需要把它转换成原始类型时,就会调用一个内部操作:ToPrimitive

ToPrimitive 的算法大致如下:

  1. 检查值是否已经是原始类型: 如果是,直接返回。
  2. 否则,调用 valueOf() 方法: 如果 valueOf() 返回原始类型,返回结果。
  3. 否则,调用 toString() 方法: 如果 toString() 返回原始类型,返回结果。
  4. 如果以上两个方法都没返回原始类型,报错。 (TypeError)

但是,这个过程会受到一个 "hint" 的影响,这个 "hint" 告诉 ToPrimitive 期望转换成什么类型的原始值:

  • Number: 期望转换成数字类型。
  • String: 期望转换成字符串类型。
  • Default: 没指定,通常发生在 == 比较和 + 运算符中。

这个 "hint" 会影响 valueOf()toString() 的调用顺序。

举个栗子:

let obj = {
  valueOf: function() {
    console.log("valueOf called");
    return {}; // 返回非原始值
  },
  toString: function() {
    console.log("toString called");
    return "hello"; // 返回字符串
  }
};

console.log(String(obj)); // hint 是 String, 先 toString
// 输出:
// toString called
// hello

console.log(Number(obj)); // hint 是 Number, 先 valueOf
// 输出:
// valueOf called
// toString called
// NaN

console.log(obj + ""); // hint 是 Default, 先 valueOf (Date 对象除外)
// 输出:
// valueOf called
// toString called
// [object Object]

总结一下:

Hint valueOf 是否优先调用 备注
Number 除非对象重写了 valueOf 方法,否则会继续调用 toString。 如果 valueOftoString 都没返回原始值,则抛出 TypeError。
String 先尝试 toString,如果返回原始值,就用这个值。 否则,调用 valueOf,如果返回原始值,就用这个值。 如果 valueOftoString 都没返回原始值,则抛出 TypeError。
Default 大部分情况是 除了 Date 对象,其他对象都先调用 valueOfDate 对象在 Default hint 下会先调用 toString+ 运算符是 Default hint 的典型例子。

第二幕: ToString – 如何变成字符串?

ToString 负责把各种值转换成字符串。 规则如下:

类型 转换结果
Undefined "undefined"
Null "null"
Boolean true -> "true"false -> "false"
Number 按照数字字面量规则
Symbol 抛出 TypeError
BigInt 按照数字字面量规则
Object 调用 ToPrimitive,hint 是 String

举个栗子:

console.log(String(undefined)); // "undefined"
console.log(String(null)); // "null"
console.log(String(true)); // "true"
console.log(String(42)); // "42"

let sym = Symbol("foo");
//console.log(String(sym)); // TypeError: Cannot convert a Symbol value to a string

let obj = { name: "Alice" };
console.log(String(obj)); // "[object Object]"  (因为默认的 toString 方法返回这个)

第三幕: ToNumber – 如何变成数字?

ToNumber 负责把各种值转换成数字。 规则如下:

类型 转换结果
Undefined NaN
Null 0
Boolean true -> 1false -> 0
Number 不变
String 如果是数字字面量,转换为数字;否则 NaN
Symbol 抛出 TypeError
BigInt 如果是数字字面量,转换为数字;否则抛出 TypeError
Object 调用 ToPrimitive,hint 是 Number

举个栗子:

console.log(Number(undefined)); // NaN
console.log(Number(null)); // 0
console.log(Number(true)); // 1
console.log(Number(false)); // 0
console.log(Number("42")); // 42
console.log(Number("hello")); // NaN

let sym = Symbol("foo");
//console.log(Number(sym)); // TypeError: Cannot convert a Symbol value to a number

let obj = { valueOf: () => 5 };
console.log(Number(obj)); // 5

第四幕: ToBoolean – 如何变成布尔值?

ToBoolean 负责把各种值转换成布尔值。 规则很简单:

  • Falsy 值: false, 0, "", NaN, null, undefined, -0, +0。 这些值会被转换为 false
  • Truthy 值: 除了 Falsy 值以外的所有值,都会被转换为 true

举个栗子:

console.log(Boolean(0)); // false
console.log(Boolean("")); // false
console.log(Boolean(NaN)); // false
console.log(Boolean(null)); // false
console.log(Boolean(undefined)); // false

console.log(Boolean(1)); // true
console.log(Boolean("hello")); // true
console.log(Boolean({})); // true
console.log(Boolean([])); // true  (空数组也是 Truthy 值!)

重点来了:隐式类型转换的重灾区

理解了 ToPrimitiveToStringToNumberToBoolean,我们就来看看隐式类型转换的重灾区:

  1. + 运算符:

    • 如果 + 运算符的操作数中有一个是字符串,那么执行字符串拼接。
    • 否则,都转换为数字进行加法运算。
    console.log(1 + "1"); // "11" (字符串拼接)
    console.log(1 + 1); // 2 (数字加法)
    console.log(1 + true); // 2 (true 转换为 1)
    console.log(1 + null); // 1 (null 转换为 0)
    console.log(1 + undefined); // NaN (undefined 转换为 NaN)
    
    console.log([] + []); // ""  (空数组转换为空字符串)
    console.log([] + {}); // "[object Object]"
    console.log({} + []); // 浏览器环境输出"[object Object]0",Node.js环境抛出错误
    console.log({} + {}); // "[object Object][object Object]" (在某些浏览器中,可能解释为代码块,导致结果不同)

    注意 {} + [] 这种情况。 在浏览器中,{} 会被解释为一个空的代码块,所以实际上执行的是 + [],结果是 0,然后和 "[object Object]" 相加。 在 Node.js 中,{} 被当做一个空对象,会抛出错误。

  2. == 运算符:

    == 运算符的类型转换规则非常复杂,是面试官最喜欢考察的知识点。 简单来说,它会尝试将两边的操作数转换为相同的类型,然后再进行比较。

    • 如果类型相同,直接比较。
    • 如果类型不同:
      • null == undefined // true
      • 如果一个是数字,一个是字符串,将字符串转换为数字再比较。
      • 如果一个是布尔值,将其转换为数字再比较 (true -> 1, false -> 0)。
      • 如果一个是对象,另一个是数字或字符串,则使用 ToPrimitive 将对象转换为原始值再比较。
    console.log(null == undefined); // true
    console.log(0 == ""); // true (字符串 "" 转换为 0)
    console.log(1 == true); // true (true 转换为 1)
    console.log("1" == true); // true (true 转换为 1, "1" 转换为 1)
    console.log([] == false); // true ([] 转换为 "", "" 转换为 0, false 转换为 0)
    console.log([] == ![]); // true (![] 是 false,[] == false)
    
    console.log(2 == [2]); // true, [2] -> "2" -> 2
    console.log("0" == false); // true, false -> 0
    console.log(0 == false); // true
    
    console.log(false == "false"); // false, "false" -> NaN
    console.log(null == false); // false, null 只能和 undefined 相等
    console.log(undefined == false); // false, undefined 只能和 null 相等

    永远记住: 尽量避免使用 ==,使用 === (严格相等) 可以避免很多不必要的类型转换。 === 不会进行类型转换,只有类型和值都相等时,才会返回 true

  3. 关系运算符 (>, <, >=, <=):

    • 如果操作数都是字符串,按照 Unicode 码点进行比较。
    • 否则,都转换为数字进行比较。
    console.log("2" > 1); // true ("2" 转换为 2)
    console.log("abc" > "abd"); // false (按照 Unicode 码点比较)
    console.log("abc" > 1); // false ("abc" 转换为 NaN, NaN 和任何数字比较都返回 false)
    console.log(NaN > 1); // false
    console.log(NaN < 1); // false
    console.log(NaN == 1); // false
    console.log(NaN != 1); // true
  4. 逻辑运算符 (&&, ||, !):

    • &&|| 不会进行类型转换,它们返回的是操作数本身的值。 但是,它们会利用 ToBoolean 判断操作数的 Truthy/Falsy。
    • ! 会将操作数转换为布尔值,然后取反。
    console.log(1 && 2); // 2 (1 是 Truthy, 返回第二个操作数)
    console.log(0 && 2); // 0 (0 是 Falsy, 返回第一个操作数)
    console.log(1 || 2); // 1 (1 是 Truthy, 返回第一个操作数)
    console.log(0 || 2); // 2 (0 是 Falsy, 返回第二个操作数)
    console.log(!0); // true (0 是 Falsy, 取反后是 true)
    console.log(!""); // true ("" 是 Falsy, 取反后是 true)

第五幕:避免掉坑指南

  1. 使用 === 代替 == 这是最有效的避免类型转换陷阱的方法。

  2. 明确类型: 在进行运算之前,明确变量的类型,可以使用 typeof 运算符进行检查。

  3. 显式转换: 如果需要类型转换,尽量使用显式转换,例如 Number(), String(), Boolean()

  4. 注意 NaN NaN 和任何值比较(包括自身)都返回 false。 使用 isNaN() 函数判断一个值是否是 NaN。 (注意: isNaN() 也会进行类型转换,建议使用 Number.isNaN(),它不会进行类型转换。)

  5. 小心处理对象: 当对象参与运算时,要清楚 valueOf()toString() 方法的行为。

总结陈词: 掌握类型转换,驾驭 JavaScript

类型转换是 JavaScript 的一个重要特性,也是一个容易出错的地方。 理解了类型转换的原理,就可以避免很多 Bug,写出更健壮的代码。 记住,清晰的代码胜过任何花哨的技巧。 尽量让你的代码表达出明确的意图,减少隐式类型转换带来的歧义。

希望今天的讲座能帮助大家更好地理解 JavaScript 的类型转换。 祝大家编程愉快!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注