宽松相等听起来有点耳熟,如果换成==大家秒懂,相对应的是严格相等 ===,这有啥难的?
莫非是想写前端面试 “八股文”…

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

当然不是,我这个人最讲实际,当然啥有用学啥。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

写这篇文章是因为工作中遇到了这个问题,后端莫名其妙下发了{price: null},竟然走进了price <= 0分支,当时觉得有点意外。

if(price<=0) {
    // 显示弹窗 
} 

此时,应该能想到 null 强制类型转换为数字是 0,null <= 0也合理。但我顺便控制台输入了null == 0,这下一发不可收拾,发现不能逻辑自洽了。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

有点意思,我在工作群抛出了这个问题,小伙伴们给出了一些解答,大部分也是现场搜索摘要出来的,大致是ES5 11.8.5 抽象关系比较算法的一堆僵硬计算规则,没懂。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

其中比较惊艳的是竟然还有个JavaScript== 扫雷小游戏。对自己 JavaScript 比较自信的同学可以试试,我觉得能都对的除了电脑就是大牛了,你可以测试下自己是不是牛牪(yn)犇(bn)?

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

既然推导逻辑理解起来费劲,那就死记硬背 null <= 0 为 true 这个结论不就行了。

然后被我老大杰哥犀利地指出,去看《你不知道的JavaScript(中卷)》这本书 4.5 和 4.6。
这本书是我去年早读(早到公司 10:00前的自由时间)的唯二的两本前端专业书之一,我却对和这个问题的关联毫无印象,当时看到这章枯燥乏味的规则算法,顿时没了兴趣,就走马观花翻过去了。

可以不懂,但是用的时候得有个印象,知道去哪找。就着这个实际问题,有必要认真再看一遍,留下点深刻印象。毕竟老大都指点了,突破不了,职业天花板也就止步于此了。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

回到问题根源,null <= 0 为 true是怎么计算的?

查询 ES5 规范 11.8.3 <= 算法,简单说就是x <= y等价于!(y < x)

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

null <= 0等价于!(0 < null),查下ES5 规范 11.8.1 < 算法ES5 规范 11.8.5 抽象关系比较算法

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

看起来内容多且枯燥,简单说就是先 GetValue 进行表达式运算,再 ToPrimitive 转换为原始值,接着再比较,只有两种可能,要么是数字比较,要么字符串比较,数字就是比大小,字符串就是挨个比较 Unicode 码。

!(0 < null)对应!(0 < ToNumber(null)),即!(0 < 0),返回 true,即证。

简单小结一下:null <= 0=>!(0 < null)=>!(0 <ToNumber(null))=>!(0 < 0)=>!false=>true

回头看,懂了,但好像哪里又不对。冷静下来细想下,问题出在先入为主的 “我以为的” 和枯燥乏味的 “实际上的” 不一样…

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

拿张表格比对一下 “我以为的” 和 **“实际上的” **逻辑区别。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

问题有二:

  • 表格第一行,null <= 0只有一种计算算法,不是字面意思上我以为的null <0 || null == 0逻辑组合,哪怕我们计算结果歪打正着。

  • 表格第二行,null == 0和我以为的等价于ToNumber(null)== 0也不一样,因为null == 0返回false,而ToNumber(null)== 0等价于0== 0返回true。

null == 0 为 false怎么来的?
先看看宽松相等==,有两种观点:

A. ==检查值是否相等,===检查值和类型是否相等。===比==做的事情更多,因为它还要检查值的类型。

B.==允许在相等比较中进行强制类型转换,而===不允许。==的工作量更大一些,因为如果值的类型不同还需要进行强制类型转换。

[美] Kyle Simpson 《你不知道的JavaScript(中卷)》

这道题,选 A 还是选 B?

我选的是 A(先比较再比较),正确答案是B(先转换再比较)。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

如果待比较的值类型相同,==和===等价;

如果不同, 会发生隐式强制类型转换,会将其中之一或两者都转换为相同的类型后再进行比较。

[美] Kyle Simpson 《你不知道的JavaScript(中卷)》

不同类型的值进行==比较,会先发生隐私强制类型转换为相同类型后再比较。
这将是本篇文章继续阐述的逻辑基础。

举几个常见的坑:

  1. '42'== true'42'== false 均为 false,串符串 '42' 既不等于 true,也不等于 false。(一个值既非真值也非假值 ???)

  2. null == undefined为 true,但null == ''undefined == ''null == 0undefined== 0null== falseundefined== false均为 false

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

  1. '' == falsetrue

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

  1. [42] == 42true

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

  1. [] == ![]true

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

  1. '' == [null]true

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

  1. NaN == NaNfalse。NaN 是number 类型。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

是不是一头雾水,“猜”就没“对”过!

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

急你就输了,不急,一个个来,一步步来。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

问题 1: '42'== true'42'== false均为 false

这里需要停一下,“我以为的'42'== true 是字符串'42'先强制转换为布尔值再比较,'42'==true 等价于ToBoolean('42')==true,即true==true返回 true,但实际结果是 false

实际上的”是 ES5 11.8.5 抽象相等比较算法定义(抽象相等和宽松相等一个意思,便于理解文中用更易于理解的宽松相等表述)。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

*抽象相等比较算法图*

实际上的,根据抽象相等比较算法第7条“如果 Type(y) 是布尔值,则返回比较 x == ToNumber(y) 的结果”,'42'== true 的意思是“布尔类型 true 先转换为 1,再判断'42'== 1

同理,'42'== 1 根据抽象相等比较算法第5条“如果 Type(x) 是 String 且 Type(y) 是 Number,则返回比较 ToNumber(x)== y的结果”,字符串'42'强制转换为数字 42 再比较,即42== 1,返回 false,即证。

同理,'42'== false根据抽象相等比较算法第7条“如果 Type(y)是布尔值,则返回比较 x ==ToNumber(y) 的结果”转换为'42'== 0,根据抽象相等比较算法第5条“如果 Type(x) 是 String 且 Type(y) 是 Number,则返回比较 ToNumber(x) == y的结果”转换为42== 0,返回 false,即证。

所以,千万不要使用 == true== false
如果非要,换成 === true=== false,其实 if 的条件判断会自动转布尔类型,这么做不仅多余而且还有 bug ,会被同行洞穿你的技术水平。

避坑指南:

  • 不要使用if(x == true){ },当 x 大于 1 或者 x 小于 0 时判断都不成立。if(x == false) {} 同理。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

  • 不要使用if(x=== true){},当x = 1 时判断不成立,因为严格相等不会做隐式强制类型转换,大意失荆州。if(x === false){}同理。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

  • 推荐写法 if(x) {}if(!!x) {}或者if(Boolean(x)){}均可

简单小结一下:

  • '42'== true=>'42'==ToNumber(true)=>'42'== 1 => ToNumber('42') == 1 =>42== 1=>false
  • '42'== false=>'42'==ToNumber(false)=>'42'== 0=>ToNumber('42') == 0=>42== 0=>false

问题 2: null == undefinedtrue,但null == ''undefined == ''null == 0undefined== 0null== falseundefined== false均为 false

在回答这个问题前,先要搞清楚 JS 有哪些类型。
简单说共8种,即7种原始类型(string、number、bigint、boolean、undefined、symbol、null)和1种对象类型(object)。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

另外,顺带枚举typeof 运算符返回的全量操作数类型。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

接下来,根据类型和宽松相等算法按图索骥。

  • null == undefinedtruenullundefined是不同原始类型,根据抽象相等比较算法第2条“如果xnullyundefined,则返回true”,这个相当于规则就是这么定义的,没有逻辑,记住就行。
  • null == ''undefined == ''null == 0undefined== 0false。同理根据抽象相等比较算法第10条(没有匹配上前9条)返回 false
  • null== falsefalse。根据抽象相等比较算法第7条“如果 Type(y) 是布尔值,则返回比较 x == ToNumber(y) 的结果”转换为null== 0,根据抽象相等比较算法第10条“返回 false”。undefined== false同理。

简单小结一下:

  • null == undefined=>true(规则直接定义)。

  • null == ''=>false(规则直接定义)。

  • undefined== ''=>false(规则直接定义)。

  • null == 0=>false(规则直接定义)。

  • undefined== 0=>false(规则直接定义)。

问题 3: ''==falsetrue

''false是不同类型,根据抽象相等比较算法第7条“如果 Type(y) 是布尔值,则返回比较 x == ToNumber(y) 的结果”转换为''==ToNumber(false),即''== 0
根据抽象相等比较算法第5条“如果 Type(x) 是 String 且 Type(y) 是 Number,则返回比较 ToNumber(x) == y的结果”转换为ToNumber('')== 0

这里要补充个知识点,ToNumber('')0还是NaN

这里不卖关子了,直接上答案:

undefined 转换为 NaN

null 转换为0

true 转换为 1false 转换为0

•空字符串或仅包含空格的字符串转换为 0

mdn web docs – JavaScript 标准内置对象 Number

ToNumber('')== 0等价于0== 0,返回 true,即证。

简单小结一下:''==false=>''==ToNumber(false)=>''== 0=> ToNumber('')==0=>0==0=>true

问题 4: [42] == 42true

[42]42是不同类型,根据抽象相等比较算法第9条“如果 Type(x) 是 Object 且 Type(y) 是 String 或 Number,则返回比较 ToPrimitive(x) == y 的结果”,转换为ToPrimitive([42]) == 42

ToPrimitive 是啥意思?

Symbol.toPrimitive 是内置的 symbol 属性,其指定了一种接受首选类型并返回对象原始值的表示的方法。它被所有的强类型转换制算法优先调用。

mdn web docs – JavaScript 标准内置对象 Symbol.toPrimitive

简单说就是对象可以转换为一个原始值,可以通过自定义实现 Symbol.toPrimitive,根据不同 hint 值(number、string、default)返回对应自定义原始值。

下面是一个最直观的例子。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

那数组的Symbol.toPrimitive 又是什么呢?试一下就知道。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

只是试显然不够,查了下Symbol.toPrimitive文档,摘要如下。

对象将依次调用它的[@@toPrimitive]()(将 default 作为 hint 值)、valueOf()toString()方法,将其转换为原始值。

Array 都没有[@@toPrimitive]()方法。

ArrayObject.prototype.valueOf 继承 valueOf(),其返回对象自身。因为返回值是一个对象,因此它被忽略。

⑶因此,调用 toString()方法。Array 重写了 toString 方法,在内部调用了 join()方法来拼接数组并返回一个包含所有数组元素的字符串,元素之间用逗号分隔。

mdn web docs – JavaScript 数据类型数据结构、Array.prototype.toString()

简单讲,数组对象强制类型转换为逗号拼接数组项字符串。

书接上文,ToPrimitive([42]) == 42等价于'42'== 42。根据抽象相等比较算法第5条“如果 Type(x) 是 String 且 Type(y) 是 Number,则返回比较 ToNumber(x) == y的结果”,转换为ToNumber('42')== 42,即42== 42,返回 true,即证。

简单小结一下:[42] == 42=>ToPrimitive([42]) == 42=>'42'== 42=>ToNumber('42')== 42=>42== 42=>true

问题 5: [] == ![]true

![]是布尔值,但是 true 还是 false

相信这个很多同学也会猜错!因为我就是很多同学之一。

查文档看下定义:

false 0-0nullfalseNaNundefined''

true 所有其他值,包括任何对象,[]'false'

mdn web docs – JavaScript 标准内置对象 Boolean

[] == ![]等价于[] == !true,等价于[] == false
根据抽象相等比较算法第7条“如果 Type(y) 是布尔值,则返回比较 x == ToNumber(y) 的结果”,转换为[] ==ToNumber(false),即 [] == 0
根据抽象相等比较算法第9条“如果 Type(x) 是 Object 且 Type(y) 是 String 或 Number,则返回比较 ToPrimitive(x) == y的结果”,转换为 ToPrimitive([]) == 0,即''==0
根据抽象相等比较算法第5条“如果 Type(x) 是 String 且 Type(y) 是 Number,则返回比较 ToNumber(x) == y的结果”,转换为ToNumber('') ==0,即0==0,返回 true,即证。

简单小结一下:[] == ![]=>[] == !true=>[] == false=>[] ==ToNumber(false)=>[] == 0=>ToPrimitive([]) == 0=>'' == 0=>ToNumber('') == 0=>0== 0=> true

问题 6: '' == [null]true

左侧是字符串'',右侧是数组对象[null],根据抽象相等比较算法第8条“如果 Type(x) 是 String 或 Number 并且 Type(y) 是 Object,则返回比较 x == ToPrimitive(y) 的结果”,转换为'' == ToPrimitive([null])

这里有个问题,[null]转字符串结果是'null'还是''?反正我第一反应是'null'

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

让我们来看下定义:

Array.prototype.toString() 数组的 toString 方法实际上在内部调用了 join()方法来拼接数组并返回一个包含所有数组元素的字符串,元素之间用逗号分隔。

Array.prototype.join() 所有数组元素被转换成字符串并连接到一个字符串中。如果一个元素是 undefinednull,它将被转换为空字符串,而不是字符串'undefined''null'

mdn web docs – JavaScript 标准内置对象 – Array

书接上文,'' == ToPrimitive([null])'' == '',返回 true,即证。

简单小结一下:'' == [null]=>'' ==ToPrimitive([null])=>'' == ''=>true

**问题 7: ** NaN == NaNfalse

让我们来看下定义:

NaN(“Not a Number”)是一个特殊种类的数值,当算术运算的结果不表示数值时,通常会遇到它。它也是 JavaScript 中唯一不等于自身的值。

mdn web docs – JavaScript 数据类型数据结构

定义里面就说了不等于自身,即证。

简单小结一下:NaN==NaN=>false(规则直接定义)。

行文到此,推导逻辑讲完了。但光说不练等于零,根据抽象相等比较算法实现一遍加深理解。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

贴个abstractEqualityTest.js 源码,我这边升级了一下,除了计算结果外,还打印了逻辑推导过程,还是建议大家自己根据算法要求自己写一遍。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

// 格式化 val 显示字符,string类型需要带单引号,以及显示含有 null、undefined 的数组
const formatLog = (val) => {
    // 字符串返回时带 'xxx',以区别字符串和数字类型
    if (typeof val === 'string') {
        return `'${val}'`
    } if (typeof val === 'object') {
        if (Array.isArray(val)) {
            // [null, undefined] 默认转为 '[]',需提前处理成 string 转为 '[null, undefined]'
            return `[${val.map(item => (item == null ? '' + item : item))}]`;
        }
    }
    return val;
}
/**
 * 抽象相等 x == y 算法实现
 * desc 用于记录规则转换过程信息
 */
function abstractEquality(x, y, desc) {
    // 返回 val 类型(共 8 种,string、number、bigint、boolean、undefined、symbol、null、object)
    const toType = (val) => {
        let type = typeof val;
        if (type === 'object') {
            // 考虑 typeof null = 'object' 情况
            if (val === null) {
                type = 'null';
            }
        } else if (type === 'function') {
            // 函数也是一个对象
            type = 'object';
        }
        return type;
    }
    const typeX = toType(x);
    const typeY = toType(y);
    let result = undefined;
    // console.log('abstractEquality toType', { x, y, typeX, typeY });
    if (typeX === typeY) {
        // 1. 如果 x 与 y 类型相同,等同于严格相等 x === y。
        result = (x === y);
        console.log(`${desc} => ${result} 「规则 1 转严格相等判断」`);
    } else {
        if ((typeX === 'null' && typeY === 'undefined')
            || (typeX === 'undefined' && typeY === 'null')) {
            // 2. 如果 x 类型为 null 且 y 类型为 undefined,则返回 true。
            // 3. 如果 x 类型为 undefined 且 y 类型为 null,则返回 true。
            // 这是规则,不是逻辑。
            result = true;
            console.log(`${desc} => ${result} 「约定规则 2、3」`);
        } else if (typeX === 'number' && typeY === 'string') {
            // 4. 如果 x 类型是 number 且 y 类型是 string,则将 y 强制转换为数字类型再递归比较。
            result = abstractEquality(x, +y, `${desc} => ${formatLog(x)} == ToNumber(${formatLog(y)})「规则 4」=> ${formatLog(x)} == ${formatLog(+y)}`);
        } else if (typeX === 'string' && typeY === 'number') {
            // 5. 如果 x 类型是 string 且 y 类型是 number,则将 x 强制转换为数字类型再递归比较。
            result = abstractEquality(+x, y, `${desc} => ToNumber(${formatLog(x)}) == ${formatLog(y)} 「规则 5」=> ${formatLog(+x)} == ${formatLog(y)}`);
        } else if (typeX === 'boolean') {
            // 6. 如果 x 类型是 boolean,则将 x 强制转换为数字类型再递归比较。
            result = abstractEquality(x ? 1 : 0, y, `${desc} => ToNumber(${formatLog(x)}) == ${formatLog(y)} 「规则 6」=> ${formatLog(+x)} == ${formatLog(y)}`);
        } else if (typeY === 'boolean') {
            // 7. 如果 y 类型是 boolean,则将 y 强制转换为数字类型再递归比较。
            result = abstractEquality(x, y ? 1 : 0, `${desc} => ${formatLog(x)} == ToNumber(${formatLog(y)}) 「规则 7」=> ${formatLog(x)} == ${formatLog(+y)}`);
        } else if ((typeX === 'string' || typeX === 'number') && typeY === 'object') {
            // 8. 如果 x 类型是 string 或 number 并且 y 类型是 object,则将对象 y 强制转换为原始值再递归比较。
            // 强制类型转换 [Symbol.toPrimitive](hint) {} : +y (hint 参数值是 'number'); `${obj2}` (hint 参数值是 'string'); obj2 + '' (hint 参数值是 'default')。
            result = abstractEquality(x, y + '', `${desc} => ${formatLog(x)} == ToPrimitive(${formatLog(y)}) 「规则 8」=> ${formatLog(x)} == ${formatLog(y + '')}`);
        } else if (typeX === 'object' && (typeY === 'string' || typeY === 'number')) {
            // 9. 如果 x 类型是 object 并且 y 类型是 string 或 number,则将对象 x 强制转换为原始值再递归比较。
            result = abstractEquality(x + '', y, `${desc} => ToPrimitive(${formatLog(x)}) == ${formatLog(y)} 「规则 9」=> ${formatLog(x + '')} == ${formatLog(y)}`);
        } else {
            // 10. 返回false。
            result = false;
            console.log(`${desc} => ${result} 「约定规则 10」`);
        }
    }
    return result;
}
/** 抽象相等 x == y 用例 */
function abstractEqualityCase(x, y) {
    console.log(`${formatLog(x)} == ${formatLog(y)} ${(abstractEquality(x, y, `${formatLog(x)} == ${formatLog(y)}`) ? '成立' : '不成立')}`);
}
// 测试用例
console.log("问题 1:'42' == true'42' == false 均为 false");
abstractEqualityCase('42', true);
abstractEqualityCase('42', false);
console.log("n问题 2:null == undefined 为 true,但 null == ''、undefined == ''、null == 0、undefined == 0、null == false 和 undefined == false 均为 false");
abstractEqualityCase(null, undefined);
abstractEqualityCase(null, '');
abstractEqualityCase(undefined, '');
abstractEqualityCase(null, 0);
abstractEqualityCase(undefined, 0);
abstractEqualityCase(null, false);
abstractEqualityCase(undefined, false);
console.log("n问题 3'' == falsetrue");
abstractEqualityCase('', false);
console.log("n问题 4:[42] == 42true");
abstractEqualityCase([42], 42);
console.log("n问题 5:[] == ![] 为 true");
abstractEqualityCase([], ![]);
console.log("n问题 6'' == [null] 为 true");
abstractEqualityCase('', [null]);
console.log("n问题 7:NaN == NaN 为 false");
abstractEqualityCase(NaN, NaN);

运行如下,跑测试用例自测通过。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null

总结一下:

  1. 最好不用==,如果用到了,切记,当类型不一致时,是个逐步强制类型转换的过程
    如果有布尔类型,布尔类型先转为数字类型再递归,不是和我们以为的先数字转布尔。
    如果有字符类型,字符类型先转为数字类型再递归。
    另外就是几个固定规则(null == undefinedtrue,但null 或undefined== '' 或 0 或 false均为 false),具体可以跑我的代码看看,我已经做到了规则运算+逻辑推导。

  2. x <= y等价于!(y < x),不等价于x <y || x == y,两者计算结果大相径庭(门外的小路和门内的庭院,比喻彼此相差很远,大不相同)。

  3. 不要使用if(x == true){ }if(x === true){ },直接if(x){ } 就够了,避免画蛇添足。

  4. [null]转字符串是''null转字符串是'null',这和我们以为的也不一样。

对我来讲,最大的收获还是以点带面把看似简单的宽松相等和比较算法串联了一遍,巩固了其中几个模棱两可的知识点,并且根据 ES5 语言规范自己实现了一遍,还挺有意思。

是时候玩会JavaScript== 扫雷小游戏放松一下了。我的答案是全对,正确标记了全部 22 个不严格相等值,但不知道为啥是 77% 正确。

探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null