这是一个"我明明设了 0,它却不当 0"的诡异 bug,小到只有一个运算符之差,却让我排查了好一阵。事情是这样的:我们一个功能有个超时配置 timeout,用户可以自己设。代码里,为了给那些"没设置"的情况一个兜底的默认值,我写了 const timeout = config.timeout || 3000;——意思是"用户设了就用用户的,没设就默认 3000 毫秒"。看起来天经地义、毫无问题。可上线后,有个用户反馈:他特意把超时设成了 0(在他的业务里,0 表示"不等待、立即处理"),可系统就是不按 0 来,还是按 3000 在等。我盯着配置反复看,他确实传了 0 啊,数据库里、请求里都是 0,可到了我这段代码,这个 0 就"凭空消失"、被换成了 3000。
排查到最后,真相荒诞得让我哭笑不得,问题就出在那个不起眼的 || 上——JavaScript/TypeScript 里的 || 运算符,判断的不是"有没有值",而是"真假值(truthy/falsy)";而 0,恰恰是一个"假值(falsy)"!所以 config.timeout || 3000 这句,当 config.timeout 是 0 时,|| 一看"0 是假值",就认为"这个值不行、是空的",于是用了后面的默认值 3000——它把用户精心设置的、合法的 0,当成"没值"给丢掉了。我本意是想表达"当 timeout 没被设置(是 null/undefined)时,才用默认值";可我用了 ||,它的实际行为却是"当 timeout 是任何假值(0、空字符串、false、null、undefined)时,都用默认值"——这个"假值"的范围,比我想要的"没值"大了一圈,而 0 就不幸地落在了这多出来的一圈里,被误伤了。这篇文章,就从这次"用户设的 0 被 || 吃掉"的事故讲起,把 || 和它的"专治这个坑"的兄弟 ?? 之间,那个细微却致命的区别,讲清楚。
故障现场:被 || 误伤的那些"合法的假值"
先把这个"误伤"的过程,用几个例子摆清楚:
// 反面: 用 || 给默认值, 会把"合法的假值"(0/''/false)也误当成"没值"替换掉
const timeout = config.timeout || 3000;
// config.timeout = 0 → 0是假值 → || 用了3000 ! (用户设的0被吃了, BUG!)
// config.timeout = 5000 → 用 5000 (正常)
// config.timeout = undefined → 用 3000 (这个是对的)
// 同样的坑, 在这些"合法的假值"上都会发生:
const count = input.count || 10; // 用户传 count=0 → 被换成10 (BUG)
const name = user.name || "匿名"; // 用户名是 ""(空字符串)→ 被换成"匿名"(可能是BUG)
const enabled = opt.enabled || true; // 用户设 enabled=false → 被换成true (大BUG!)
// 根源: JS 的"假值(falsy)"有一大堆, || 对它们全都"替换":
// 假值: false, 0, -0, 0n, ""(空串), null, undefined, NaN
// 而我们通常只想在 "null / undefined"(真正的"没值")时才替换
// → 0、''、false 这些"合法但假"的值, 被 || 误伤了
看明白这个"误伤"是怎么发生的了吗?核心,是 || 这个运算符的判断标准,和我们的本意之间,有一个微妙的错位。我们用 x || 默认值 时,心里想的往往是"x 没有值的时候,用默认值";可 || 的实际逻辑是"x 是假值的时候,用默认值"。而 JavaScript 里的"假值(falsy)"是一个范围相当大的集合——false、0、空字符串 ""、null、undefined、NaN 都是假值。这其中,null 和 undefined 确实是我们想要的"没值";可 0、空字符串、false 这些,却是"虽然是假值、但完全合法、有意义"的值——它们被 || 一视同仁地当成"没值"给替换掉了,这就是 bug 的来源。
这个坑的迷惑性极强,因为它"平时都对,只在边界值上出错":绝大多数时候,你的配置项、输入值都是些"正常的、真值的"数据(超时 5000、数量 10、名字"张三"),这时 x || 默认值 表现得完美无缺;只有当那个值,恰好是 0、空字符串、或 false 这些"合法的假值"时,|| 才会露出它的獠牙,把这个合法的值误伤掉。而 0、空字符串、false,偏偏又是非常常见的"边界但合法"的值——超时设 0、数量填 0、开关设 false、名字留空……这些在业务上都是完全合理的输入。所以这个坑,你平时测不出来(测的都是正常值),一上线遇到真实用户那五花八门的输入(包括那些合法的 0 和空),它就冒出来了。这种"正常值都对、只在特定边界值上静默出错"的特征,正是这类 bug 最难防、也最磨人的地方。
第一件事:理解 || 看的是"真假",而非"有没有值"
要避开这个坑,核心是把 || 这个运算符的真实语义,从你脑中那个模糊的"取默认值"印象里,精确出来:a || b 的真实逻辑是——如果 a 是"真值(truthy)",就返回 a;否则(a 是"假值 falsy"),就返回 b。它的判断依据,是 a 的"真假",而不是 a "有没有值"。而"真假"和"有没有值",在 0、''、false 这几个"合法的假值"上,是不一致的。
// || 的真实语义: 看 a 的"真假(truthy/falsy)", 不是看 a "有没有值"
a || b // a 为真值 → 返回 a; a 为假值 → 返回 b
// 关键: 哪些是"假值(falsy)"? 记住这一串(其余都是真值):
// false, 0, -0, 0n(BigInt 0), ""(空字符串), null, undefined, NaN
// 也就是说, || 会把上面这一整串, 都判定为"该用默认值":
0 || 99 // 99 (0是假值 → 被替换! 这往往不是你想要的)
"" || "默认" // "默认" (空串是假值 → 被替换)
false || true // true (false是假值 → 被替换)
null || 99 // 99 (null是假值 → 被替换, 这个通常是对的)
undefined || 99 // 99 (undefined是假值 → 被替换, 这个也对)
5000 || 99 // 5000 (5000是真值 → 保留, 正常)
"abc" || "默认" // "abc" (非空串是真值 → 保留, 正常)
关键认知是:|| 是一个"基于真假值"的运算符,它会把 JavaScript 所有的"假值"(不只是 null/undefined,还包括 0、空字符串、false、NaN)都当成"需要被默认值替换"的对象。所以,当你用 x || 默认值 来"取默认值"时,你实际上是在说:"只要 x 是任何假值(包括 0、''、false),就用默认值"——而这,通常并不是你的本意。你的本意,绝大多数时候,其实是"只有当 x '没有值'(即 null 或 undefined)时,才用默认值",而 0、''、false 这些"虽假但合法"的值,应该被原样保留。这个"本意(只在 null/undefined 时兜底)"和"|| 的实际行为(在所有假值时都兜底)"之间的差距,正是这个坑的全部根源。而 JavaScript/TypeScript 专门为"只在 null/undefined 时兜底"这个精确的本意,提供了一个运算符——??(空值合并运算符),它就是这个坑的标准解药。
第二件事:正解——用 ?? (空值合并),只在 null/undefined 时兜底
解药就是 ?? 运算符(空值合并,nullish coalescing)。它和 || 长得像、用法也像,但判断标准精确得多:a ?? b——只有当 a 是 null 或 undefined(即真正的"没值")时,才返回 b;a 是 0、空字符串、false 这些"合法的假值"时,会原样保留 a。
// 正解: 用 ?? 代替 || —— 只在 null/undefined 时才用默认值
const timeout = config.timeout ?? 3000;
// config.timeout = 0 → 0 不是 null/undefined → 保留 0 ! (BUG修复!)
// config.timeout = 5000 → 用 5000 (正常)
// config.timeout = undefined → 用 3000 (没设置, 才用默认)
// config.timeout = null → 用 3000
// 对比 || 和 ?? 在各种值上的区别:
0 || 99 // 99 (|| 把0当假值替换)
0 ?? 99 // 0 (?? 保留0, 因为0不是null/undefined) ← 这才是对的!
"" || "默认" // "默认"
"" ?? "默认" // "" (?? 保留空串)
false || true // true
false ?? true // false (?? 保留false)
null ?? 99 // 99 (?? 在null时才替换)
undefined ?? 99 // 99 (?? 在undefined时才替换)
// → ?? 精确地实现了"只在'没值'时兜底"的本意!
?? 的精妙,在于它精确地匹配了我们绝大多数时候"取默认值"的真实本意——"只有当这个值真的'不存在'(null/undefined)时,才用默认值;只要它'存在'(哪怕值是 0、''、false),就尊重它、原样保留"。它把 || 那个"误伤合法假值"的毛病,精准地修好了:用 ??,用户设的 0 就是 0、设的空字符串就是空字符串、设的 false 就是 false,只有当他"压根没设"(null/undefined)时,才会落到你的默认值上。所以,一条极其实用、几乎可以闭眼用的准则是:当你想"给一个可能为空的值设默认值"时,默认用 ?? 而不是 ||——因为你想兜底的,几乎总是"没值(null/undefined)"这个精确的情况,而不是"所有假值"这个宽泛的范围。我把 || 和 ?? 的区别画成图:
这张图的关键,就在中间那个分叉:当值是"0、空串、false 这些合法的假值"时,|| 会误伤它(替换成默认值),而 ?? 会正确地保留它。而正常真值和真正的 null/undefined 这两种情况,|| 和 ?? 表现一致——所以两者的差异,精确地、唯一地体现在"合法的假值"这个边界上。理解了这一点,你就知道:凡是那个值"可能合法地取到 0、空串、false"的场景,就必须用 ??,绝不能用 ||。
第三件事:可选链 ?. 也是同一家族,一起用更顺手
和 ?? 一起,常常出现的还有一个"亲戚"——可选链 ?.。它俩是处理"可能为空的值"的黄金搭档:?. 安全地"访问可能为空的对象的属性"(不会因为对象为空而报错),?? 给最终可能为空的结果"兜个底"。
// 可选链 ?. : 安全访问可能为null/undefined的对象的属性, 不会报错
const city = user.address.city; // 若 address 是 undefined → 直接抛错!
const city2 = user.address?.city; // 若 address 是 undefined → 返回 undefined(不报错)
// ?. 的短路: 一旦 ?. 左边是 null/undefined, 整个链表达式直接返回 undefined
// ?. 和 ?? 黄金搭档: 安全取值 + 兜底默认
const city3 = user.address?.city ?? "未知";
// address存在且有city → 用city; address或city为空 → 用"未知"
// 既不会因为中间是null报错(?.), 又给了合理的默认(??)
// 还有 ?.() 调方法、 ?.[] 访问数组/动态属性:
obj.fn?.(); // fn 存在才调用, 不存在不报错
arr?.[0]; // arr 不为空才取第0个
// 注意: ?. 短路返回的是 undefined, 配 ?? 兜底时正好契合(?? 认 undefined)
可选链 ?. 解决的是另一个高频痛点——"访问一个可能为空的对象的深层属性时,中间任何一环为空都会报错"(经典的 "Cannot read properties of undefined")。 ?. 让你能"安全地、链式地"访问:一旦 ?. 左边的东西是 null/undefined,整个链就"短路",直接返回 undefined,而不会抛错。而 ?. 和 ?? 是天作之合:?. 安全地取出"可能为空的深层值"(为空时给你 undefined),?? 紧接着给这个"可能是 undefined 的结果"兜一个合理的默认值——user.address?.city ?? "未知" 这一句,就优雅地表达了"安全地取出用户的城市,取不到就用'未知'"。这俩(以及 ?.() 调方法、?.[] 访问元素)是现代 JavaScript/TypeScript 处理"空值"的标准工具,组合起来既安全又简洁,强烈建议用它们替代那些啰嗦的 if (a && a.b && a.b.c) 层层判空、以及那个会误伤的 ||。而它们能配合得这么默契,还有一个原因:?. 短路时返回的是 undefined,而 ?? 恰好就是对 null/undefined 兜底的——两者的"空值"定义,精确地对齐了。
第四件事:?? 自己也有个小坑——不能直接和 ||/&& 混用
顺带提一个 ?? 自己的小陷阱:为了避免歧义,JavaScript 规定 ?? 不能不加括号地直接和 || 或 && 混在一起用,否则会语法报错。你得用括号把优先级说清楚。
// 坑: ?? 不能直接和 || / && 混用(语法错误!)
const x = a || b ?? c; // ✗ SyntaxError! 不允许这样混用
const y = a && b ?? c; // ✗ SyntaxError!
// 正解: 用括号明确优先级
const x2 = (a || b) ?? c; // ✓ 加括号, 意图清晰
const y2 = a || (b ?? c); // ✓ 另一种意图, 也清晰
// 为什么禁止? 因为 (a||b)??c 和 a||(b??c) 含义不同,
// 不加括号会有歧义, 所以语言干脆强制你写括号、把意图讲明白。
// 另外: ?? 和 ?. 配合时, 优先级是 OK 的, 不用括号:
const city = user.address?.city ?? "未知"; // ✓ 正常
这个小坑其实体现了一个好的语言设计:当一种写法可能产生歧义、让人误解时,与其默默地按某个规则解释(可能和程序员想的不一样),不如干脆禁止它、强制程序员用括号把意图写清楚。 ?? 和 ||/&& 混用,在不同的优先级理解下会有不同结果,所以 JavaScript 选择"禁止混用、必须加括号"——这其实是在帮你避免一个潜在的、由"优先级理解不一致"导致的 bug。所以遇到这个语法报错别烦,它是在提醒你"把意图用括号讲清楚"。顺带把 JavaScript 里那些"假值(falsy)"列个完整清单,这是理解 || 坑的根基——记住它们,你就知道 || 会在哪些值上"误伤":
| 假值(falsy) | || 会把它替换吗 | ?? 会把它替换吗 |
|---|---|---|
| false | 会(可能误伤) | 不会(保留) |
| 0 / -0 / 0n | 会(可能误伤) | 不会(保留) |
| "" 空字符串 | 会(可能误伤) | 不会(保留) |
| NaN | 会 | 不会(保留) |
| null | 会 | 会 |
| undefined | 会 | 会 |
第五件事:|| 还是 ???——按"你想兜底什么"来选
讲清了区别,实战中到底该用 || 还是 ???判断标准其实很简单:看你想"兜底"的,到底是"没值(null/undefined)",还是"所有假值(包括 0、''、false)"。大多数时候你要的是前者,用 ??;只有少数你确实想把 0、''、false 也一并视为"该替换"时,才用 ||。我把选择整理成一张表:
| 场景 | 0/''/false 算"有效值"吗 | 该用 |
|---|---|---|
| 超时/数量/价格等数值默认 | 算(0是合法值) | ?? |
| 开关/布尔配置默认 | 算(false是合法值) | ?? |
| 可空文本默认(允许空串) | 算(空串可能有意义) | ?? |
| "非空才用、空串当没填"的文本 | 不算(空串视为没填) | || 也可(刻意要这语义) |
| 明确想把所有假值都替换 | 不算 | || (但要确认是本意) |
这张表的核心结论是:绝大多数"取默认值"的场景,你真正想兜底的都是"没值(null/undefined)",所以默认就该用 ??;只有当你确实、有意识地想把"0、空串、false"也当成"该替换的空"时(比如"用户名留空就当没填、显示匿名"这种),才用 ||——而且这种时候,最好加个注释说明"这里就是要把空串也替换掉",免得后人(或你自己)误以为是个 bug。换句话说:把 ?? 当成"取默认值"的默认选择,把 || 当成"我确实想替换所有假值"的特殊选择。这个"默认 ??、特殊才 ||"的习惯,能帮你从源头避开那个"合法的 0 被吃掉"的高频坑。我那次的 bug,正是因为我把 || 当成了"取默认值"的默认写法、随手就用了它,却没意识到它会把合法的 0 也当成"空"——如果当时我的默认选择是 ??,这个坑根本就不会出现。
一张"取默认值该用 || 还是 ??"的决策图
把这次踩坑沉淀成一张图。每当你要给一个值设默认值时,照着它一秒钟选对:
这张图的判断只有一问:"0、空串、false 在这里算不算有效值?"——算(绝大多数情况),就用空值合并 ??;真不算、确实想替换,才用 || 并加注释。配上可选链 ?. 安全取深层值,这个"取默认值"的高频坑就被你彻底堵死了。
我立下的几条取默认值规矩
这次"合法的 0 被 || 吃掉"的事故后,我给自己立了几条规矩:
- 取默认值默认用 ??:给可能为空的值设默认,默认用空值合并
??(只在 null/undefined 时兜底),而非||。 - 用 || 要确认本意:确实想把 0、空串、false 也当成"空"替换时才用
||,并加注释说明这是有意为之。 - 记牢 falsy 清单:记住 false、0、''、null、undefined、NaN 都是假值,清楚
||会在它们上面全部替换。 - 深层取值用 ?.:访问可能为空对象的深层属性用可选链
?.,别用啰嗦的层层 && 判空,更别让中间为空报错。 - ?. 配 ?? 兜底:
obj?.a?.b ?? 默认是处理"可能为空的链式取值 + 默认"的标准组合。 - ?? 和 ||/&& 混用加括号:它们不能直接混用,加括号把优先级和意图写清楚。
- 边界值要专门测:涉及默认值兜底的逻辑,专门测 0、空串、false 这些"合法的假值",别只测正常值。
这几条里,第一条"默认用 ??"是最该形成肌肉记忆的。而第七条"边界值要专门测",是这次事故给我的另一个深刻教训:我这个 bug 之所以上线才暴露,正是因为我开发、测试时,用的全是"正常的值"(超时 3000、5000),从来没想过去测一个"0"——而 0,恰恰就是触发这个坑的那个边界值。很多隐蔽的 bug,都藏在那些"合法、却特殊"的边界值里:0、空、负数、极大值、刚好等于边界的值……而我们测试时,又最容易忽略它们,习惯性地只测那些"中规中矩的正常值"。所以,养成一个习惯:测试时,主动地、刻意地去测那些"边界的、特殊的、但合法的"值——0、空字符串、false、空数组、最大最小值……因为 bug,最爱藏在这些被我们的"正常值思维"所忽略的边界角落里。
写在最后:魔鬼藏在细节里,而细节值得较真
这次被一个运算符之差(|| 还是 ??)坑到的经历,虽然问题小,却给了我一个不小的触动:"魔鬼藏在细节里"——软件世界里,很多让人头疼的 bug,根源往往不是什么宏大的架构失误、复杂的算法错误,而是一个小到不能再小的细节:一个运算符用错了、一个边界没考虑到、一个字符写错了。而这些"小细节",恰恰因为它们太小、太不起眼,最容易被我们轻视、被我们"想当然"地略过,从而成为 bug 最爱的藏身之处。我那次,就是对 || 这个天天用的运算符,抱着一个模糊的"它就是取默认值嘛"的想当然,而没去较真它"到底是按什么标准取默认值"的——这个不较真的细节,就让一个合法的 0,悄悄地变成了 3000。
想通这一点,我对"细节"和"较真"这两个词,生出了一份新的敬重。我们常常觉得,工程师的水平,体现在能不能搞定那些"高大上"的难题——复杂的架构、精妙的算法、前沿的技术;可这次让我意识到,一个工程师的可靠程度,同样、甚至更多地,体现在他对"细节"的较真上——他会不会去深究一个常用运算符的精确语义?会不会在写下一个默认值时,多想一秒"这里的边界值会怎样"?会不会对那些"看起来理所当然"的小地方,也保持一份"我真的搞清楚了吗"的审慎?因为宏大的架构错误,往往在评审、设计阶段就被发现了;而恰恰是这些细微的、藏在每一行代码里的细节失误,最容易溜过所有的防线,潜伏到生产环境里,在某个特殊的边界值上,猝不及防地咬你一口。对细节的较真,看起来不那么"耀眼",却是把代码从"大概能跑"打磨到"真正可靠"的、最朴素也最关键的功夫。
所以,如果你也想写出更可靠的代码,我想把这次踩坑最想说的话送给你:请别轻视那些"小到不值一提"的细节,对它们也保持一份较真——去搞清楚每一个你常用的运算符、API、语法的精确行为,去多想一步每一个边界值的情况,去把那些"看起来理所当然"的地方,真正地弄明白。用 || 时,就搞清它和 ?? 的精确区别;写每一个默认值、每一个判断时,就多想一下 0、空、false 这些边界。因为编程的可靠,从来不是靠在大处不出错,而是靠在每一个小处都不含糊;魔鬼藏在细节里,而你对细节的每一分较真,都是在亲手把那个魔鬼,挡在门外。那个被 || 悄悄吃掉的 0,最终教给我的,正是这份对细节的敬畏与较真——它让我明白,真正拉开工程师可靠性差距的,往往不是那些惊天动地的大事,而正是对这一个个微不足道的小细节,是含糊带过、还是认真较真。愿你我都能成为对细节较真的人,把每一行看似平凡的代码,都写得经得起推敲、靠得住、不含糊——因为正是这无数个被认真对待的细节,堆叠成了一个系统真正的可靠。
—— 别看了 · 2026