探究 JS 教科书般使人困惑的语言设计缺陷 “null == 0 为 false 但 null <= 0 为 true”
宽松相等听起来有点耳熟,如果换成 == 大家秒懂,相对应的是严格相等 ===,这有啥难的?
莫非是想写前端面试 “八股文”...

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

写这篇文章是因为工作中遇到了这个问题,后端莫名其妙下发了 { price: null } ,竟然走进了 price <= 0 分支,当时觉得有点意外。
if (price <= 0) {
// 显示弹窗
}
此时,应该能想到 null 强制类型转换为数字是 0,null <= 0 也合理。但我顺便控制台输入了 null == 0,这下一发不可收拾,发现不能逻辑自洽了。

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

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

既然推导逻辑理解起来费劲,那就死记硬背 null <= 0 为 true 这个结论不就行了。
然后被我老大杰哥犀利地指出,去看《你不知道的JavaScript(中卷)》这本书 4.5 和 4.6。 这本书是我去年早读(早到公司 10:00 前的自由时间)的唯二的两本前端专业书之一,我却对和这个问题的关联毫无印象,当时看到这章枯燥乏味的规则算法,顿时没了兴趣,就走马观花翻过去了。
可以不懂,但是用的时候得有个印象,知道去哪找。就着这个实际问题,有必要认真再看一遍,留下点深刻印象。毕竟老大都指点了,突破不了,职业天花板也就止步于此了。

回到问题根源,null <= 0 为 true 是怎么计算的?
查询 ES5 规范 11.8.3 <= 算法,简单说就是 x <= y 等价于 !(y < x)。

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


看起来内容多且枯燥,简单说就是先 GetValue 进行表达式运算,再 ToPrimitive 转换为原始值,接着再比较,只有两种可能,要么是数字比较,要么字符串比较,数字就是比大小,字符串就是挨个比较 Unicode 码。
!(0 < null) 对应 !(0 < ToNumber(null)),即 !(0 < 0),返回 true,即证。
简单小结一下:null <= 0 => !(0 < null) => !(0 < ToNumber(null)) => !(0 < 0) => !false => true。
回头看,懂了,但好像哪里又不对。冷静下来细想下,问题出在先入为主的 “我以为的” 和枯燥乏味的 “实际上的” 不一样...

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

问题有二:
-
表格第一行,
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(先转换再比较)。

如果待比较的值类型相同,== 和 === 等价;
如果不同, 会发生隐式强制类型转换,会将其中之一或两者都转换为相同的类型后再进行比较。
[美] Kyle Simpson 《你不知道的JavaScript(中卷)》
不同类型的值进行 == 比较,会先发生隐私强制类型转换为相同类型后再比较。 这将是本篇文章继续阐述的逻辑基础。
举几个常见的坑:
-
'42' == true和'42' == false均为false,串符串'42'既不等于true,也不等于false。(一个值既非真值也非假值 ???) -
null == undefined为 true,但null == ''、undefined == ''、null == 0、undefined == 0、null == false和undefined == false均为false。

'' == false为true。

[42] == 42为true。

[] == !\[]为true。

'' == [null]为true。

NaN == NaN为false。NaN 是 number 类型。

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

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

问题 1: '42' == true 和 '42' == false 均为 false。
这里需要停一下,“我以为的” '42' == true 是字符串 '42' 先强制转换为布尔值再比较,'42' == true 等价于 ToBoolean('42') == true,即 true == true 返回 true,但实际结果是 false。
“实际上的”是 ES5 11.8.5 抽象相等比较算法定义(抽象相等和宽松相等一个意思,便于理解文中用更易于理解的宽松相等表述)。

*抽象相等比较算法图*
实际上的,根据抽象相等比较算法第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) { }同理。

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

- 推荐写法
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 == undefined 为 true,但 null == ''、undefined == ''、null == 0、undefined == 0、null == false 和 undefined == false 均为 false。
在回答这个问题前,先要搞清楚 JS 有哪些类型。 简单说共8种,即7种原始类型(string、number、bigint、boolean、undefined、symbol、null)和1种对象类型(object)。

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

接下来,根据类型和宽松相等算法按图索骥。
null == undefined为true。null和undefined是不同原始类型,根据抽象相等比较算法第2条“如果x为null且y为undefined,则返回true”,这个相当于规则就是这么定义的,没有逻辑,记住就行。null == ''、undefined == ''、null == 0、undefined == 0为false。同理根据抽象相等比较算法第10条(没有匹配上前9条)返回false。null == false为false。根据抽象相等比较算法第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: '' == false 为 true。
'' 和 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转换为1,false转换为0。• 空字符串或仅包含空格的字符串转换为
0。mdn web docs - JavaScript 标准内置对象 Number
即 ToNumber('') == 0 等价于 0 == 0,返回 true,即证。
简单小结一下:'' == false => '' == ToNumber(false) => '' == 0 => ToNumber('') == 0 => 0 == 0 => true。
问题 4: [42] == 42 为 true。
[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)返回对应自定义原始值。
下面是一个最直观的例子。

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

只是试显然不够,查了下Symbol.toPrimitive文档,摘要如下。
对象将依次调用它的
[@@toPrimitive]()(将default作为hint值)、valueOf()和toString()方法,将其转换为原始值。⑴
Array都没有[@@toPrimitive]()方法。⑵
Array从Object.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、-0、null、false、NaN、undefined和''。
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'。

让我们来看下定义:
Array.prototype.toString(): 数组的toString方法实际上在内部调用了join()方法来拼接数组并返回一个包含所有数组元素的字符串,元素之间用逗号分隔。
Array.prototype.join(): 所有数组元素被转换成字符串并连接到一个字符串中。如果一个元素是undefined或null,它将被转换为空字符串,而不是字符串'undefined'或'null'。mdn web docs - JavaScript 标准内置对象 - Array
书接上文,'' == ToPrimitive([null]) 即 '' == '',返回 true,即证。
简单小结一下:'' == [null] => '' == ToPrimitive([null]) => '' == '' => true。
**问题 7: ** NaN == NaN 为 false。
让我们来看下定义:
NaN(“Not a Number”)是一个特殊种类的数值,当算术运算的结果不表示数值时,通常会遇到它。它也是 JavaScript 中唯一不等于自身的值。
mdn web docs - JavaScript 数据类型数据结构
定义里面就说了不等于自身,即证。
简单小结一下:NaN == NaN => false(规则直接定义)。
行文到此,推导逻辑讲完了。但光说不练等于零,根据抽象相等比较算法实现一遍加深理解。

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

// 格式化 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:'' == false 为 true。");
abstractEqualityCase('', false);
console.log("\n问题 4:[42] == 42 为 true。");
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);
运行如下,跑测试用例自测通过。

总结一下:
-
最好不用
==,如果用到了,切记,当类型不一致时,是个逐步强制类型转换的过程。 如果有布尔类型,布尔类型先转为数字类型再递归,不是和我们以为的先数字转布尔。 如果有字符类型,字符类型先转为数字类型再递归。 另外就是几个固定规则(null == undefined为true,但null 或 undefined == '' 或 0 或 false 均为 false),具体可以跑我的代码看看,我已经做到了规则运算+逻辑推导。 -
x <= y等价于!(y < x),不等价于x < y || x == y,两者计算结果大相径庭(门外的小路和门内的庭院,比喻彼此相差很远,大不相同)。 -
不要使用
if(x == true){ }或if(x === true){ },直接if(x){ }就够了,避免画蛇添足。 -
[null]转字符串是'',null转字符串是'null',这和我们以为的也不一样。
回到起点,为什么 “null == 0 为 false 但 null <= 0 为 true” ?
答案是,null == 0 对应宽松相等算法规则就是 false。null <= 0 对应小于等于算法,转换为 !(null > 0),而不是 (null < 0 || null == 0),这两种结果不一样。
总之,null <= 0 是另一套规则,而不是简单拆分成 null < 0 和 null == 0 叠加。
对我来讲,最大的收获还是以点带面把看似简单的宽松相等和比较算法串联了一遍,巩固了其中几个模棱两可的知识点,并且根据 ES5 语言规范自己实现了一遍,还挺有意思。
是时候玩会 JavaScript == 扫雷小游戏放松一下了。我的答案是全对,正确标记了全部 22 个不严格相等值,但不知道为啥是 77% 正确。

转载自:https://juejin.cn/post/7353561211512913929