程序员崩溃瞬间:[] == ![] 为什么是 true?💥

988 阅读6分钟

前言

这两天都在看神三元大佬的总结的JS技术博客,在(建议收藏)原生JS灵魂之问, 请问你能接得住几个?(上)中发现了一个有趣的现象,就是[] == []返回的是false,而[] == ![] 返回的却是true,我就记录下来了,万一面试就遇上了呢😎😎

问题重现

让我们先看看这个令人困惑的表达式:

console.log([] == ![]); // 输出: true

初看之下,这似乎完全不合理:一个空数组怎么会等于它的非值呢?要理解这一点,我们需要深入JavaScript的类型转换规则。

理解 == 操作符

JavaScript中的==(宽松相等)操作符会在比较前执行类型转换,这与===(严格相等)操作符不同。当比较的两个值类型不同时,==会尝试将它们转换为相同类型后再比较,而对于===来说,必须要数据和类型都一样才会返回true。

== 的类型转换规则

  1. 如果类型相同,直接比较值

  2. 如果类型不同:

(1) null 和 undefined

  • null 和 undefined 在 == 比较时相等
  • 它们与其他任何值(包括 0false'')比较时都不相等

(2) 字符串和数字比较

  • 字符串会尝试转换为数字

  • 转换规则:

    • 空字符串 '' 转换为 0
    • 纯数字字符串(如 '123')转换为对应数字(123
    • 非数字字符串(如 'abc')转换为 NaN
    • 包含数字但非纯数字的字符串(如 '123abc')也转换为 NaN

(3) 布尔值与其他类型比较

  • 布尔值会先转换为数字(true1false0
  • 转换后再按规则与其他值进行比较

(4) 对象与原始值比较

  • 对象会尝试通过 valueOf() 和 toString() 方法转换为原始值

  • 转换顺序:

    1. 先调用对象的 valueOf() 方法
    2. 如果返回的不是原始值,再调用 toString() 方法
    3. 如果仍然不是原始值,则抛出 TypeError
  • 日期对象特殊:优先调用 toString() 而非 valueOf()

(5) 其他特殊情况

  • NaN 与任何值(包括自身)比较都返回 false
  • 数字 +0 和 -0 在 == 比较时被视为相等

分解 [] == ![] 的求值过程

让我们一步步拆解这个表达式:

  1. 计算![]

    • []是一个对象(数组),在布尔上下文中所有对象都是true
    • ![]!true,结果为false

    现在表达式变为:[] == false

  2. 应用 == 规则

    • 一边是对象([]),一边是布尔值(false)
    • 根据规则,布尔值先转为数字:false0
    • 现在表达式变为:[] == 0
  3. 对象转为原始值

    • 对象在与原始值比较时,会尝试转为原始值
    • 这个过程首先调用valueOf(),如果返回的不是原始值,再调用toString()
    • 对于数组,valueOf()返回数组本身(不是原始值),所以调用toString()
    • [].toString()返回空字符串""
    • 现在表达式变为:"" == 0
  4. 字符串与数字比较

    • 字符串转为数字:""0
    • 最终比较:0 == 0,结果为true

JavaScript 的对象到原始值转换

理解对象如何转为原始值是解决这类问题的关键。JavaScript在对象到原始值的转换中遵循以下规则:

转换步骤

  1. 如果对象有[Symbol.toPrimitive]方法(一般对象都有内置的这个方法),调用它

  2. 否则,如果期望类型是"string"或默认:

    • 先调用toString(),如果返回原始值则使用
    • 否则调用valueOf(),如果返回原始值则使用
  3. 如果期望类型是"number":

    • 先调用valueOf(),如果返回原始值则使用
    • 否则调用toString(),如果返回原始值则使用
  4. 如果以上都没有返回原始值,抛出TypeError

var obj = {
    value: 3, 
    valueOf() { 
        return 4; 
    }, 
    toString() {
        return '5' 
    }, 
    [Symbol.toPrimitive]() {
        return 6 
    }
} 

console.log(obj + 1); // 输出7

这段代码定义了一个对象 obj,它重写了 valueOf()toString()[Symbol.toPrimitive]() 方法。当对象参与加法运算时,JavaScript 会优先调用 [Symbol.toPrimitive]() 方法获取原始值(返回 6),因此 obj + 1 的结果是 6 + 1 = 7。如果没有 Symbol.toPrimitive,则会依次尝试 valueOf() 和 toString()。如果不了解Symbol.toPrimitive方法的可以看Symbol.toPrimitive

如何让if(a == 1 && a == 2)返回true??

其实就是用了上面那个方法的应用

var a = {
    value: 0;
    valueOf: function() {
        this.value++;
        return this.value;
    }
};

console.log(a == 1 && a == 2)  // true

这段代码定义了一个对象 a,它有一个 value 属性初始为 0,并重写了 valueOf 方法,使得每次调用时 value 自增 1 并返回新值。当执行 a == 1 && a == 2 时,由于 == 会触发 valueOf 方法,第一次比较 a == 1 返回 truevalue 从 0 变为 1),第二次比较 a == 2 也返回 truevalue 从 1 变为 2),因此最终结果为 true

常见对象的转换结果

  • 数组:

    • [].toString() → ""
    • [1, 2, 3].toString() → "1,2,3"
    • [null].toString() → ""
    • [undefined].toString() → ""
  • 对象:

    • ({}).toString() → "[object Object]"
  • 日期:

    • new Date().toString() → 当前日期时间的字符串表示

更多令人困惑的例子

理解了这些规则后,我们可以解释更多看似奇怪的现象:

console.log([] == 0);    // true ([]→""→0)
console.log([1] == 1);   // true ([1]→"1"→1)
console.log([1,2] == "1,2"); // true
console.log({} == "[object Object]"); // true

console.log([] == []);   // false (两个不同对象)
console.log({} == {});   // false (两个不同对象)

大家可能会比较疑惑为什么[] == []{} == {} 返回的都是false,让我们继续往下看吧

虽然两个空数组 [] 看起来相同,但它们在内存中是两个不同的对象,拥有不同的引用地址。== 比较时不会递归比较数组的内容,而是直接比较引用地址,因此返回 false

  • 引用类型比较的是内存地址,而不是内容。

  • 每次声明 [] 或 {} 都会创建一个新的对象,即使内容相同,引用也不同。

  • 如果要比较数组或对象的内容,需要手动遍历或使用工具函数(如 JSON.stringify() 或 Lodash 的 _.isEqual):

    console.log(JSON.stringify([]) === JSON.stringify([])); // true(比较字符串化的内容)
    console.log(JSON.stringify({}) === JSON.stringify({})); // true
    

补充说明

  • 如果变量指向同一个对象,比较会返回 true

    const arr = [];
    console.log(arr == arr); // true(同一引用)
    

注意事项和最佳实践

  1. 避免使用 ==

    • 由于这些复杂的转换规则,大多数情况下应该使用===来避免意外行为
    • ESLint等工具可以配置为禁止使用==
  2. 明确转换

    • 如果需要类型转换,最好显式地进行:

      Number([]);        // 0
      String([]);        // ""
      Boolean([]);       // true
      
  3. 自定义对象转换

    • 可以为自定义对象实现[Symbol.toPrimitive]valueOf()toString()来控制转换行为

      const obj = {
        valueOf() { return 123; },
        toString() { return "hello"; },
        [Symbol.toPrimitive](hint) {
          return hint === 'string' ? this.toString() : this.valueOf();
        }
      };
      
  4. 注意 falsy 值

    • JavaScript中只有以下值在布尔上下文中为false

      • false
      • 0
      • ""
      • null
      • undefined
      • NaN
    • 所有对象(包括空数组、空对象)在布尔上下文中都是true

为什么设计成这样?

JavaScript的这种设计源于其早期需要处理HTML中的松散类型:

  1. 网页中的值大多是字符串:需要灵活地与数字比较
  2. 向后兼容:早期行为被保留以避免破坏现有网站
  3. 灵活性:允许快速原型开发,但牺牲了严谨性

总结

[] == ![]true的背后是JavaScript复杂的类型转换规则:

  1. ![]先转为false
  2. false转为数字0
  3. []通过toString()转为空字符串""
  4. ""转为数字0
  5. 最终比较0 == 0true

理解这些规则有助于我们:

  • 调试奇怪的比较结果
  • 编写更健壮的代码
  • 理解JavaScript的设计哲学

记住,当你不确定时,使用===而不是==,或者显式地进行类型转换,这样可以避免大多数意外行为。

思考题

试试解释以下表达式的值:

console.log([] + []);       // ?
console.log([] + {});       // ?
console.log({} + []);       // ?
console.log({} + {});       // ?
console.log([] - []);       // ?
console.log({} - {});       // ?

答案将在评论区公布!(提示:考虑运算符重载和类型转换)