Appearance
空值合并运算符 '??'——从短路求值到 Babel 转译的工程分析
?? 解决的问题很具体:给一个可能是 null 或 undefined 的值提供默认值。|| 也能干这件事,但它把 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 == null 是 true(历史遗留),但 document.all ?? 'default' 返回 document.all 本身,因为 document.all 不是真正的 null 或 undefined。
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 保存左操作数的求值结果,后续比较和返回值都用 _ref。void 0 是 undefined 的最短安全表达方式——在 ES3 时代 undefined 可以被重写,void 0 总是返回真正的 undefined。
这个转译细节在写 Babel 插件或做 AST 分析时需要注意——a ?? b ?? c 的转译会产生嵌套的临时变量赋值。
与 || 的选择:不是"哪个更好",是"falsy 值在业务中是否有意义"
选择 ?? 还是 ||,本质上取决于一个问题:在当前的业务语义中,falsy 值(0、''、false)是"缺少值"还是"有效值"?
| 场景 | falsy 值的含义 | 应该用 |
|---|---|---|
用户身高 height = 0 | 有效值(身高为 0 表示未填写?但 0 本身有意义) | ?? |
配置项 timeout = 0 | 业务上 0 可能表示"不超时" | ?? |
字符串 name = '' | 空字符串可能是有意义的用户名?通常是缺少值 | 看业务 |
布尔 enabled = false | false 是明确的"关闭",不是"未设置" | ?? |
API 返回 count = 0 | 0 是有效计数 | ?? |
一个直接的经验规则是:如果变量类型是 number 或 boolean,更适合优先考虑 ??;如果是 string,|| 和 ?? 的差异取决于空字符串在业务中是否有意义。
在大型代码库中做 || → ?? 的迁移时,风险点不在于修改了哪个文件,而在于 0、false、'' 的行为会静默变化。测试覆盖不足时,这类变化很容易漏掉。更稳妥的方式是先处理 number 和 boolean 类型的默认值,再逐个检查 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); // ❌ 不加括号先算乘法如果记不住优先级,加括号是最安全的做法——它不会带来运行时开销,只是明确求值顺序。
