Skip to content

空值合并运算符 '??'——从短路求值到 Babel 转译的工程分析

?? 解决的问题很具体:给一个可能是 nullundefined 的值提供默认值。|| 也能干这件事,但它把 0''false 都当作"需要默认值"的信号——这在实际场景里经常是错的。

最经典的 case:

js
const height = 0;
const displayHeight = height || 100;  // 100 —— 0 是有效高度,不该被替换
const displayHeight2 = height ?? 100; // 0   —— 正确

height = 0 是一个合法的值,业务上应该显示"高度: 0"。但 || 遇到 0(falsy)就跳过了,导致 UI 上显示"高度: 100"。这类 bug 在表单默认值、配置合并、API 响应处理中反复出现。


短路求值的精确语义

ECMAScript 规范 §12.14 定义了 ?? 的求值算法。简化后的关键步骤:

text
1. 对左操作数求值 → lref
2. 获取 lref 的值 → lval
3. 如果 lval 是 undefined 或 null → 对右操作数求值并返回
4. 否则 → 返回 lval

两个值得注意的细节:

左操作数只被求值一次。 a ?? b 中,表达式 a 只会执行一次。如果 a 不是 null/undefined,b 根本不会被求值——短路语义。

判断条件是 === undefined || === null,不是 == null 虽然 x == null 等价于 x === null || x === undefined,但规范层面用的是严格比较。这个区别在遇到 document.all 时才会体现——document.all == nulltrue(历史遗留),但 document.all ?? 'default' 返回 document.all 本身,因为 document.all 不是真正的 nullundefined

js
document.all || 'fallback';   // 'fallback' —— document.all 是 falsy
document.all ?? 'fallback';   // HTMLAllCollection —— 它是已定义的

document.all 是 JS 中一个著名的"假值但不是 null/undefined"的案例——为了兼容上古浏览器检测代码,规范故意让它表现为 falsy。?? 是少数能区分这种情况的运算符。


Babel/TypeScript 转译:重复求值问题

?? 是 ES2020 引入的。在需要兼容旧浏览器的项目中,Babel 和 TypeScript 会将其转译为等价的三元表达式。问题在于——简单的转译会导致左操作数被求值两次:

js
// 源码
const result = getValue() ?? defaultValue;

// ❌ 简单转译 —— getValue() 被调用了两次
const result = getValue() !== null && getValue() !== undefined
  ? getValue()
  : defaultValue;

如果 getValue() 有副作用(比如读取后移动指针、修改外部状态),调用两次意味着副作用被执行两次——行为不等于源码。

Babel 和 TypeScript 实际生成的代码引入临时变量来解决这个问题:

js
// ✅ 实际转译
var _ref;
const result = (_ref = getValue()) !== null && _ref !== void 0
  ? _ref
  : defaultValue;

_ref 保存左操作数的求值结果,后续比较和返回值都用 _refvoid 0undefined 的最短安全表达方式——在 ES3 时代 undefined 可以被重写,void 0 总是返回真正的 undefined

这个转译细节在写 Babel 插件或做 AST 分析时需要注意——a ?? b ?? c 的转译会产生嵌套的临时变量赋值。


|| 的选择:不是"哪个更好",是"falsy 值在业务中是否有意义"

选择 ?? 还是 ||,本质上取决于一个问题:在当前的业务语义中,falsy 值(0、''、false)是"缺少值"还是"有效值"?

场景falsy 值的含义应该用
用户身高 height = 0有效值(身高为 0 表示未填写?但 0 本身有意义)??
配置项 timeout = 0业务上 0 可能表示"不超时"??
字符串 name = ''空字符串可能是有意义的用户名?通常是缺少值看业务
布尔 enabled = falsefalse 是明确的"关闭",不是"未设置"??
API 返回 count = 00 是有效计数??

一个直接的经验规则是:如果变量类型是 numberboolean,更适合优先考虑 ??;如果是 string||?? 的差异取决于空字符串在业务中是否有意义。

在大型代码库中做 ||?? 的迁移时,风险点不在于修改了哪个文件,而在于 0false'' 的行为会静默变化。测试覆盖不足时,这类变化很容易漏掉。更稳妥的方式是先处理 numberboolean 类型的默认值,再逐个检查 string 类型的语义。


??&& / || 不能混用的根因

js
1 && 2 ?? 3; // SyntaxError

这不是运行时限制,是解析器层面的语法约束。TC39 的讨论记录显示,这个限制的原因不是"语义上无法定义",而是"开发者从 || 切换到 ?? 时容易产生逻辑错误"——a || b ?? c 的求值顺序在不同语言中不一致,JS 选择直接报错迫使开发者写清楚括号。

混合使用时:

js
(1 && 2) ?? 3; // ✅ 明确优先级

与可选链的组合则没有这个限制:

js
obj?.prop ?? 'default';    // ✅ 自然组合
obj?.prop?.nested ?? 0;    // ✅

?.?? 是同一提案(Optional Chaining + Nullish Coalescing)的产物,设计上考虑了互操作性。?. 遇到 null/undefined 短路返回 undefined?? 正好能接住这个 undefined 给默认值——两者配合形成了一个完整的"安全访问 + 默认值"链路。


优先级位置

?? 优先级是 3,和 || 相同。比 +*. 都低,比 =?: 高。这意味着:

js
// 不需要括号
const x = a ?? b;           // ✅ = 优先级最低
const y = a ?? b ? c : d;   // ✅ 等价于 (a ?? b) ? c : d

// 需要括号
const z = (a ?? 1) + (b ?? 2); // ❌ 不加括号会先算 1 + b
const area = (height ?? 100) * (width ?? 50); // ❌ 不加括号先算乘法

如果记不住优先级,加括号是最安全的做法——它不会带来运行时开销,只是明确求值顺序。