ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript相等比较与空值处理:从==到Object.is

JavaScript相等比较与空值处理:从==到Object.is 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从名字就能看出来它做的不是比较而是比较 转换。规范定义了一套完整的类型转换流程我可以把核心规则总结成一张表左侧类型右侧类型转换规则NullUndefined直接返回trueUndefinedNull直接返回trueNumberString字符串先转数字再比较Boolean任意类型布尔先转数字true→ 1false→ 0再按新类型比较Number/StringObject对象先转原始值再比较其他不同类型组合-返回false注意第一条null undefined是true这是最特殊的规则它俩只互相相等不与任何其他值相等除了互相之外。举个容易误导的例子null 0是falsenull 也是false正因为这样value null才能安全地同时判断null和undefined。还有一条要注意NaN和自身用比较也是falseNaN在 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); // true0 false这一条最坑。false先转数字变成00再转数字也变成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是falsenull 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.isnull undefinedtruefalsefalse0 truefalsefalse1 1truefalsefalsetrue 1truefalsefalseNaN NaNfalsefalsetrue0 -0truetruefalse[] truefalsefalse如果你的代码里出现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这里历史遗留了一个著名的 bugtypeof 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 // trueundefined 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适合有显式空值的接口判断字段是否存在不排除值为undefinedvalue ! undefined但要先确保对象本身存在判断一个全局 API 是否存在typeof window.xxx ! undefined对 undeclared 安全判断是否为NaNNumber.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/undefinedcount安全地变成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这类报错会少掉一大半。
返回列表