1. 一个线上事故开场:三行代码里藏着多少判等陷阱
先讲一个我真实经历过的数据丢失事故,正好能把这个话题的所有关键点都串起来。
当时我在做一个配置管理后台,前端接了一个接口,返回用户对某项配置的修改结果。接口语义设计得其实很清晰:如果用户没动过这项配置,后端返回null;如果用户主动清空了配置,返回空字符串'';如果整个字段缺失,说明请求里根本没带这个字段,对应undefined。
业务逻辑是:只有用户确实有修改时才调用更新接口。
最初代码写得很朴素:
if (data != null) { updateConfig(data); }这个判断其实是对的。data != null在 JavaScript 里是官方推荐的"同时判断null和undefined"的写法,null和undefined都不会进入更新逻辑,而空字符串''和数字0都会正常走进去。
但接手的人后来觉得!=这个写法看着别扭,容易被误读成"不等于 null",于是在一次重构里把它改成了:
if (data !== null) { updateConfig(data); }就是这一个等号的差异,引发了线上故障。当字段缺失、data是undefined时,data !== null结果是true,于是进入了updateConfig。而updateConfig内部只处理字符串,拿到undefined后走了一遍JSON.stringify,字段直接被丢弃。最终表现是:用户确实提交过修改,但配置没有更新,浏览器控制台里没有任何报错,后端也没有日志。
这种"失效但不报错"的 bug 最难排查。我们花了整个下午,最后在 diff 里看到了这一行改动:!=变成了!==。
从那天起,我决定把所有关于相等判断的细节彻底搞清楚。因为这种问题不是少写一个等号这么简单,它背后牵扯的是 JavaScript 的抽象相等比较、严格相等比较、Object.is,以及null、undefined、未声明变量这三套概念的底层差异。任何一个环节理解不到位,都会写出看起来没问题、一上线就爆炸的代码。
这篇文章想做的,就是把这些基础但关键的知识点一次性讲透。我不打算只罗列规则,我会把每一项规则背后的原理、推理过程、实际应用场景,以及我在项目里踩过的坑都拿出来说。既适合刚入门的新手建立完整认知,也适合写了几年 JavaScript 但没仔细抠过这些细节的开发者查漏补缺。
2. == 的隐式转换机制与安全使用边界
2.1 抽象相等比较的完整转化规则
==在 ECMAScript 规范里的正式名称叫 Abstract Equality Comparison,从名字就能看出来,它做的不是"比较"而是"比较 + 转换"。规范定义了一套完整的类型转换流程,我可以把核心规则总结成一张表:
| 左侧类型 | 右侧类型 | 转换规则 |
|---|---|---|
| Null | Undefined | 直接返回true |
| Undefined | Null | 直接返回true |
| Number | String | 字符串先转数字,再比较 |
| Boolean | 任意类型 | 布尔先转数字(true→ 1,false→ 0),再按新类型比较 |
| Number/String | Object | 对象先转原始值,再比较 |
| 其他不同类型组合 | - | 返回false |
注意第一条,null == undefined是true,这是==最特殊的规则,它俩只互相相等,不与任何其他值相等(除了互相之外)。举个容易误导的例子:null == 0是false,null == ''也是false,正因为这样,value == null才能安全地同时判断null和undefined。
还有一条要注意:NaN和自身用==比较也是false,NaN在 JavaScript 里是唯一一个不等于自己的值。
我把几个高频结果列出来,你可以先做一遍心理预测再对答案:
console.log(null == undefined); // true console.log(null == 0); // false console.log('1' == 1); // true console.log(true == 1); // true console.log(false == 0); // true console.log('0' == false); // true console.log([] == ''); // true console.log([] == false); // true console.log([1] == 1); // true console.log([1, 2] == '1,2'); // true'0' == false这一条最坑。false先转数字变成0,'0'再转数字也变成0,所以结果是true。但如果你把'0'当成表单里用户输入的字符串"0",把false当成布尔开关的关闭状态,这俩在业务上根本八竿子打不着,程序却告诉你它们相等。
2.2 ToPrimitive 在对象比较时的工作过程
对象和原始值用==比较时,对象要走一遍ToPrimitive转换。这个转换过程是:先尝试调用对象的valueOf(),如果返回值是原始值,就用它;如果不是原始值,再尝试toString(),如果返回的是原始值,就用它;如果两个方法返回的都不是原始值,就直接抛TypeError。
普通对象和数组的行为不太一样。数组的valueOf()返回数组本身(不是原始值),于是走toString(),得到元素用逗号拼接的字符串;普通对象的valueOf()也返回对象本身,toString()得到'[object Object]'。
所以[] == ''的结果是true,推导过程是这样的:
[] 是对象,先 ToPrimitive [].valueOf() // 返回 [],还是对象,继续 [].toString() // 返回 '',是原始值,就用它 '' == '' // 同类型,直接比较 // true再推导一个经典面试题:
console.log([] == ![]); // true![]先做布尔运算,空数组是真值,取反得到false。此时比较变成了[] == false。右侧是布尔,先转数字:false→0。然后左侧数组走ToPrimitive变成''。最后'' == 0,字符串转数字:''→0。于是0 == 0,结果是true。
这就是为什么我不建议任何人依赖这种隐式转换来写业务逻辑。每一层转换都是可以推理的,但推理链太长,真出问题的时候,阅读代码的人很难在脑子里把这串过程完整走完。
2.3 哪些 == 用法在真实项目里是安全的
说到这你可能觉得==全是坑。但其实业界对==是有共识的:它不是不能用的"坏东西",而是要明确它的安全边界。
安全和好用的场景只有一个:value == null。这个写法等价于value === null || value === undefined,用来判断一个值是否为"空值",非常简洁。Lodash 里的isNil函数,内部实现其实就是:
function isNil(value) { return value == null; }Vue 3 源码里也大量出现类似判断。你去看shared工具包的源码,isObject函数写的是:
const isObject = (val) => val !== null && typeof val === 'object';这里的val !== null不能换成val != null,因为后者会把undefined也过滤掉,而undefined在类型判断里本来就不属于 object,没必要通过!=额外排除。
实际项目中,我建议团队规范可以写成:只用== null判断空值,除此之外一律用===或Object.is。ESLint 的eqeqeq规则也允许配置成smart模式,专门放行== null这种写法。既保持了代码风格统一,又留了必要的通道。
3. === 与 Object.is 的分岔路:NaN 和 0 的两大例外
3.1 === 的算法流程与两大例外
===的实现逻辑简单很多:先看类型,类型不一致直接返回false,类型一致再比较值。它不做任何隐式转换,所以'1' === 1是false,null === undefined也是false。
正因为简单,很多人会以为===就是"完全相等"的代名词。但严格相等比较里有两个例外,恰恰说明它没那么"严格"。
第一个例外是NaN。NaN === NaN返回false。这其实可以理解:NaN的语义是"不是一个数字",既然不是一个具体的数字,它和谁都不相等,包括自己。但随之而来的问题是,如果你需要判断一个值是不是NaN,不能直接用===。
判断NaN有几种方式。全局函数isNaN会先做一次隐式转换,isNaN('abc')返回true,这个行为在大多数场景下不是我们想要的。ES6 之后用Number.isNaN(value)更安全,它不会对入参做类型转换,只有类型为number且值为NaN时才返回true。
第二个例外是+0和-0。JS 里存在负零这个概念,1 / -0得到的是-Infinity。+0 === -0返回true,但在某些科学计算中,+0和-0是有本质区别的。解决方案是使用Object.is,它会把这俩区分开。
3.2 Object.is 与 SameValue 算法
Object.is是 ES6 新增的静态方法,它实现的是 ECMAScript 规范里的SameValue算法。它和===的行为基本一致,只改了两点:
Object.is(NaN, NaN)返回trueObject.is(+0, -0)返回false
这个设计初看有点"反直觉":一个语义上更严格的比较,却认为NaN和自身相等;一个语义上"宽松"的比较,却认为+0和-0不等。但如果你从数学语义去理解就通了:NaN在上面的场景里代表"同一个无效计算的结果",SameValue想表达的核心是"这两个值是不是同一个值",而不是"它们在数学上是否等价"。
SameValue算法在规范内部用得非常多。举例来说,Object.defineProperty在更新属性描述符时,如果新属性和旧属性对应位置的值在SameValue意义下相等,且其他描述符属性都没有变化,那么这次调用可以视为无操作,不触发重新定义。假设一个对象有一个值为NaN的属性,你给它重新赋NaN,如果用===判断新旧值,会得出"不相等"的结论,从而误判为属性被修改了;用Object.is判断则能正确识别出"没有变化"。
除了SameValue,规范里还有一个SameValueZero算法,它和SameValue的唯一区别是:+0和-0被视为相等(和===一样)。数组的includes方法、Map和Set中的键比较用的都是SameValueZero。这也解释了为什么[NaN].includes(NaN)会返回true,而[NaN].indexOf(NaN)会返回-1——因为indexOf用的是严格相等比较,NaN === NaN是false。
3.3 一张表看清三种比较方式的边界差异
用一张表来对照三种等值判断在关键边界值上的表现,这是我最常拿出来给团队培训的图:
| 比较表达式 | == | === | Object.is |
|---|---|---|---|
null == undefined | true | false | false |
0 == '' | true | false | false |
'1' == 1 | true | false | false |
true == 1 | true | false | false |
NaN == NaN | false | false | true |
+0 == -0 | true | true | false |
[] == '' | true | false | false |
如果你的代码里出现NaN和±0相关的比较,建议默认使用Object.is,它更符合人的直觉。如果只是常规业务逻辑,用===就够了。对于null和undefined的"两者皆可"判断,== null是最简洁的写法,这个我在前面已经交代过了。
4. null、undefined、undeclared:三种空态的本质辨析
4.1 概念定位:显式空值、声明未赋值、完全未声明
很多人分不清null和undefined,更分不清undeclared。这三个概念其实处在不同的维度上,我用一句话给它们做了定位:
null是"有变量,但没有值",是开发者显式赋予的空值。undefined是"有变量,但没赋过值",是运行时默认的未初始化状态。undeclared是"压根没有这个变量",连声明这一步都不存在。
具体来说,null是语言层面预留的空值对象,它表示一个对象引用为空。当后端接口返回null,表示"这里什么都没有",但它仍然是接口四要素里一个明确的值。null参与数字运算时会自动变成0,参与字符串拼接时会变成字符串"null",但在typeof这里,历史遗留了一个著名的 bug:typeof null === 'object'。这个 bug 源于 JavaScript 第一版里值类型标签的设计,后续规范为了兼容性一直保留至今,所以你不能用typeof来区分null和对象。
undefined的产生场景特别多:声明变量但没赋值、访问对象不存在的属性、函数没有返回值、函数形参没有被传入、数组越界访问,这些都是undefined。它在JSON.stringify中的行为也和null不同:对象属性值为undefined时,整个属性会被丢弃;值为null时,属性保留且序列化为null。
undeclared严格来说不是一个"值",它是标识符解析失败的状态。直接访问一个未声明的变量会抛出ReferenceError: xxx is not defined。这个报错信息很容易把人误导到undefined上去,实际上它说的是"标识符未声明"。
4.2 typeof 的安全机制与真实的判定陷阱
在区分这三种状态时,typeof有一个非常重要的安全机制:对未声明变量执行typeof不会抛错,而是返回"undefined"。
// 假设 foo 从未声明 console.log(typeof foo); // 'undefined' console.log(foo); // ReferenceError: foo is not defined这个特性非常实用。比如在浏览器环境里安全地检测某个全局 API 是否存在,标准写法就是:
if (typeof IntersectionObserver !== 'undefined') { // 使用 IntersectionObserver }这里不能直接写if (IntersectionObserver),因为如果这个 API 在当前浏览器不存在,直接访问变量名就会抛ReferenceError,整个脚本就中断了。
还有一个隐蔽的坑:typeof的返回值一共有七个字符串,分别是"undefined"、"object"、"boolean"、"number"、"string"、"function"、"symbol"、"bigint"。注意,没有"null"这个返回值。所以用typeof判断 null 是做不到的,只能靠=== null或者value == null。
检测一个变量是否已声明,但又不是undeclared,推荐这样:
function isDeclared(variableName) { // 通过全局对象访问,而不是直接访问变量名 const value = globalThis[variableName]; return value !== undefined || variableName in globalThis; }这个函数里用的variableName in globalThis是必要的。因为如果一个全局变量已经被显式赋值为undefined,直接读globalThis[variableName]得到undefined,但如果用in运算符,它的声明还是存在的。
4.3 三种空态在相等比较中的完整表现
把null、undefined、以及"未声明"摆到一起看它们在不同比较中的表现,思路会清晰很多。
// 声明但不赋值 let a; // 显式赋 null let b = null; // c 未声明,但这里不能写任何访问 c 的表达式,因为会直接抛错先看已声明但未赋值的a和已赋null的b的所有比较:
a == null // true,undefined == null a === null // false a === undefined // true b == null // true b === null // true b === undefined // false typeof a // 'undefined' typeof b // 'object'再看未声明变量的情况。你不能直接写c == null或c === undefined,因为c这个标识符一出现就会抛ReferenceError。唯一的例外是typeof c,它安全返回'undefined'。
所以真实项目中,判断一个属性是否存在的正确姿势取决于"属性属于谁"。如果属性是某个已经存在的对象上的属性,用obj.prop === undefined或'prop' in obj都可以;如果是一个从未声明的全局变量,则必须用typeof。
我还想强调一个常见误区:在非严格模式下,给一个未声明的变量赋值不会抛错,而是会创建一个全局变量。这类隐式全局变量是历史包袱,在 ES5 严格模式下这种行为已经被禁止了。代码里如果要使用变量,一定要先声明。
5. 工程实践中的判空策略与典型报错排查
5.1 不同业务语义下该选哪种判空写法
说完了理论,落到工程实践。我总结了几个高频业务场景对应的判空写法,直接给出结论:
| 业务语义 | 推荐的写法 | 备注 |
|---|---|---|
字段不能为null或undefined,否则判空 | value == null | 最推荐,语义明确 |
只排除null,保留undefined走另一分支 | value === null | 适合有显式空值的接口 |
判断字段是否存在(不排除值为undefined) | value !== undefined | 但要先确保对象本身存在 |
| 判断一个全局 API 是否存在 | typeof window.xxx !== 'undefined' | 对 undeclared 安全 |
判断是否为NaN | Number.isNaN(value) | 不要用isNaN,它做了隐式转换 |
| 判断是否为合法的非空字符串 | typeof value === 'string' && value.length > 0 | 比!!value语义更明确 |
有一个经典误用是if (!value)。这个条件判断的是"假值",它会把0、''、false、NaN、null、undefined全部囊括进去。如果你的本意是"字段是不是空",这种写法通常没问题;但如果你的本意是"排除null和undefined"但不想排除0和'',那就必须用value == null。
另一个常见误用是只判断value === undefined而不判断null。在很多后端返回的数据里,字段缺失和字段为null是两种含义:缺失表示没提交过,null表示显示置空。如果代码只排除了一种,另一种就会悄无声息地漏过去。这就是我在开篇讲的那个事故的本质。
5.2 可选链与空值合并运算符的正确打开方式
ES2020 带来了两个和判空相关的语法糖:可选链?.和空值合并??,它们能大幅减少显式判空代码的量。
可选链在访问多层嵌套属性时特别有用。以前我们写:
const list = res && res.data && res.data.list;现在已经可以直接写:
const list = res?.data?.list;如果res是null或undefined,整个表达式返回undefined,不会抛错。注意,可选链只对null和undefined生效,如果res是0或false,它依然会尝试访问属性并报错。不过0和false本来也不该作为需要深入的对象存在。
空值合并运算符??的语义非常精确:只有左侧是null或undefined时才返回右侧。
const value = config.timeout ?? 3000;当config.timeout是0时,上面这行返回0,而不是3000。这是??和||最关键的区别:
const value1 = config.timeout || 3000; // timeout 为 0 时得到 3000,可能是 bug const value2 = config.timeout ?? 3000; // timeout 为 0 时得到 0我见过不止一次:因为用||设置默认值,导致某个本应允许为0的配置被默默替换成默认值。这种 bug 特别隐蔽,因为0在业务上往往是有效值,程序流程不会中断,但结果就是不对。
?.和??可以配合使用,比如:
const count = res?.data?.count ?? 0;如果接口数据链路中任何一环为null/undefined,count安全地变成0。
5.3 我看过的几个典型报错与排查思路
现在常见的运行时错误里,有一大类都可以归结到本文的话题上。
第一种是Cannot read properties of undefined (reading 'xxx')。这个报错的意思是:你访问了user.name,但user是undefined。最常见的原因是接口返回结构和预期不一致,或者上一个环节对空值做了JSON.stringify把字段丢了。排查思路是,先在访问属性之前把整条链路打出来,确认哪一环变成了undefined,然后决定在那一环做兜底。
第二种是null is not an object (evaluating 'xxx')。报错信息里多了一个"object"的说法,其实主要是 Safari 对访问null属性的报错文案。语义和第一种类似,只是对象为null。这种情况下,判断条件要区分到底是想排除null还是排除null + undefined。
第三种是xxx is not defined。这是在访问一个从未声明的变量。我在热词里看到有人搜use of undeclared identifier、undefined identifier错误,这类错误在 C/C++ 里叫法不一样,但本质相同:编译器或运行时在作用域链上找不到这个标识符。JS 里最常见的原因是变量拼写错误、把var声明写在了某个作用域之外,或者在没有引入某个全局库的情况下直接使用它的全局变量名。
第四种是Cannot read properties of null (reading 'xxx')。排除方法和前两种类似,但要记住null和undefined可能来自不同环节:后端可能显式返回null,而前端某个代码路径里忘了初始化变量则产生undefined。定位时先看报错对象到底是谁。
我目前的排查套路是固定的:先看报错堆栈定位到具体行,然后在该行打断点,查看是哪个对象出了问题。一般的链式调用res.data.list,我会在控制台里分别打印res、res.data,逐步缩窄范围。找到谁为空的之后,再根据业务语义决定是在上游做默认值兜底,还是在下游做判空保护,还是在接口层做数据清洗。个人经验是,判空逻辑尽量靠近数据入口,也就是在拿到接口返回值后立刻处理,不要散落在各个业务组件里,否则后面维护的人根本不知道哪些字段已是清洗后的。
最后再分享一个我自己用的习惯:数组和对象的默认值一定要在声明时就给好。比如把list初始化为空数组,而不是undefined;把配置对象初始化为{}而不是直接访问。很多运行时报错,其实只是因为变量初始化这一步偷了懒。养成这个习惯之后,你会发现Cannot read properties of undefined这类报错会少掉一大半。