可选链?.和双问号??落地已经很多年了,可真到要维护一个还得兼容老 WebView、老 Node 的项目时,很多人第一反应还是去翻 Babel 配置——可选链和双问号这两个语法到底该挂哪个 Babel 插件、插件名从proposal改成了transform要不要跟着改、preset-env认不认、loose开了会留下什么后遗症。这些问题看着琐碎,但只要有一处配错,轻则产物里残留?.在老环境里直接抛出语法错误,重则整条构建链在压缩阶段解析失败。这篇就从语法语义一路讲到插件选型、手写降级逻辑、构建接入和踩坑排查,适合正在做兼容性改造的前端、需要维护老构建链路的工程同学,也适合想搞明白 Babel 插件到底在改什么的人。
1. 先把这两个语法解决的问题说透
1.1 从一串&&到一次求值的演进
在?.出现之前,取深层字段基本只有两种写法:一串&&,或者try/catch。前者最常见,也最容易埋雷。
// 老写法 function getCity(order) { if (order && order.address && order.address.city) { return order.address.city; } return null; }这段代码有三个毛病。第一,判断条件用的是真值判断,city如果是空字符串、0、false,都会被当成“没有值”,语义被悄悄改写。第二,路径有几层就要写几层,字段再加一级就得回来改判断。第三,也是最容易被忽略的:如果对象来自一次函数调用,比如getOrder() && getOrder().address,函数会被调用两次,第一次是判空,第二次是取值,有副作用或者开销大的时候就难受了。
可选链把这三件事一次性解决了。order?.address?.city只判断null和undefined,其他值原样放行;路径长度只体现在代码里,不需要额外的判断条件;整条链里的每个对象表达式只求值一次,短路之后后面全部跳过。写成getOrder()?.address?.city,getOrder只会被调用一次,这是它跟&&写法最本质的差别,也是最容易被讲漏的一点。
这里还要强调一个常被误解的细节:a?.b.c里的?.只保护到a为止,a为空时整个表达式是undefined,但a.b为空时,后面的.c依然会抛错。可选链不会自动把整条路径都变成“安全访问”,它保护的是你打问号的那个位置以及它右边的求值过程。想逐级保护,就得逐级写?.。
1.2??和||差的那一个字符
这两个运算符长得像,语义差别却直接决定业务对不对。核心区别只有一句话:||判断的是“假值”,??判断的是“空值”。
| 左值 | a || b的结果 | a ?? b的结果 |
|---|---|---|
0 | b | 0 |
'' | b | '' |
false | b | false |
NaN | b | NaN |
null | b | b |
undefined | b | b |
| 普通对象 | 对象本身 | 对象本身 |
前端业务里被||坑得最多的场景,几乎都和“0 和空串是合法值”有关。分页参数page || 1,传 0 就被改成 1;超时配置timeout || 3000,配 0 表示不限时,结果被强行改成 3000;开关字段enabled || true,用户明确关掉了,又被打开。这类问题用??改一行就能修掉,前提是团队里所有人都清楚两者的边界。
还有一条冷知识值得知道:历史上有一个特殊对象,它在布尔判断里表现为假,但用??判断时不算空值,所以x || y会走y,x ?? y会保留x。这属于语言规范附录里的历史遗留,日常业务基本碰不到,但它恰恰说明??的判定条件和||压根不是一回事。理解了这一点,去看各种“为什么要用??”的讨论就不会觉得是玄学。
1.3 既然语法这么好,为什么还要插件
因为语法标准和运行环境是两件事。可选链和双问号属于 ES2020,在较新的浏览器和 Node 版本里是原生能力,但在一些老版本 WebView、老版本 Node 运行时、以及部分内嵌容器里,这两套语法根本不存在,引擎读到?.会直接抛SyntaxError,整段脚本不执行。
更麻烦的是工具链这一层。构建过程里不只有运行时引擎在解析代码,还有一堆工具在做解析:ESLint 的 parser、webpack 内部用于分析模块依赖的解析器、压缩器、代码分割插件、依赖分析工具。老版本的这些工具可能连“认识”这两个语法都做不到,报错信息通常只有一句Unexpected token,看不出是哪一层出的问题。
所以插件的价值不只是“给老浏览器降级”,它同时在做构建链路的兼容。搞清楚这一点,后面选型的时候就不会只盯着浏览器版本,而会顺带检查整条链路上每个解析器的版本。
2. 语法边界:哪些写法看着合理其实会报错
2.1 可选链的几种形态与各自的短路行为
可选链严格来说只有两种语法位置:成员访问和函数调用。展开之后日常会见到四种写法,行为各有差异。
| 写法 | 含义 | 短路条件 | 注意点 |
|---|---|---|---|
a?.b | 属性访问 | a为null/undefined | 最常见 |
a?.[k] | 计算属性访问 | a为null/undefined | k在短路时不求值 |
a?.() | 调用 | a为null/undefined | 保留原this |
a.b?.() | 方法调用 | a.b为null/undefined | this仍指向a |
a?.[k]里有个容易忽略的细节:当a为空发生短路时,方括号里的表达式k不会被求值。如果k本身是一次函数调用,短路之后这次调用就不会发生。这条规则和&&的短路是一致的,但在阅读代码时很容易看漏。
?.()的this绑定是另一个高频疑问点。obj.fn?.()在obj.fn存在时,this依然指向obj,这一点和obj.fn()完全一致。降级之后为了保证这个行为,转译产物会用.call(obj)的形式显式绑定,这也是为什么产物看起来比源码啰嗦不少。
delete是可选链的一个特殊搭配。delete a?.b是合法的,a为空时删除操作被跳过,整个表达式返回true;a不为空时正常删除并返回删除结果。降级产物的分支里要分别保留这两个返回值,写错了就会变成undefined,在if里判断时行为完全不同。
2.2 不能写的地方,以及报错背后的原因
可选链不是万能胶,下面这些位置写了就是语法错误,编译阶段就会拦下来。
| 写法 | 结果 | 原因 |
|---|---|---|
a?.b = 1 | 语法错误 | 可选链不能作为赋值目标 |
a?.b++ | 语法错误 | 自增需要可写引用 |
new a?.b() | 语法错误 | new的目标不允许可选链 |
a?.b\tpl`` | 语法错误 | 标签模板不允许可选链 |
super?.() | 语法错误 | super不允许可选链 |
a ?? b || c | 语法错误 | ??不能与||、&&裸混用 |
最后一条是最容易犯的。??和||、&&混用时必须加括号,规范里这叫早期错误,解析阶段直接拒绝,而不是运行时才出问题。想表达“先取空值兜底,再取假值兜底”,必须明确写成(a ?? b) || c或者a ?? (b || c),两种括号位置语义完全不同,别指望引擎帮你猜。
a ?? b ? c : d倒是合法的,因为条件运算符的优先级低于??,会解析成(a ?? b) ? c : d。这种写法虽然能跑,但建议加括号,读代码的人少花三秒。
还有一个解析器层面的小细节值得提一句:数字字面量后面直接跟?.是合法的可选链,不会被误认为小数点。所以在某些代码里看到数字直接接问号,不用怀疑是不是排版错误,那是标准语法。真正要注意的是老版本解析器可能在这里判断失误,升级 parser 版本通常就好了。
3. Babel 插件怎么选:命名、版本和接入方式
3.1 插件名的三段式演变,别被旧文档带偏
Babel 生态里这类“新语法”插件经历过一次统一改名。早期叫proposal,因为它们还在提案阶段;语法正式进入标准之后,统一改成transform,因为它们的职责就是语法转换,不再和提案状态挂钩。
| 语法 | 旧插件名 | 新插件名 |
|---|---|---|
| 可选链 | @babel/plugin-proposal-optional-chaining | @babel/plugin-transform-optional-chaining |
| 双问号 | @babel/plugin-proposal-nullish-coalescing-operator | @babel/plugin-transform-nullish-coalescing-operator |
| 逻辑赋值 | @babel/plugin-proposal-logical-assignment-operators | @babel/plugin-transform-logical-assignment-operators |
表格第三行要单独强调:??=、||=、&&=属于逻辑赋值运算符,是另一个提案,有独立的插件。项目里看到a ??= b报错,先别怀疑是不是双问号插件没装,很可能装的是nullish-coalescing而不是logical-assignment。这是实际排查中出现频率相当高的一次误判。
改名的过渡期内,旧名字一般还会作为别名保留一段时间,所以配置里写旧名通常还能跑。但新项目没必要留着旧名,统一用transform-*更省心,也避免未来升级时集中爆雷。
3.2 preset-env、独立插件、loose 三种接法怎么选
实际项目里接这两个语法有三条路,各有适用场景。
第一条是交给@babel/preset-env,配好browserslist,让它按目标环境自动决定要不要转。这是默认推荐,好处是目标环境升级之后,插件会自动退出,产物自然变干净,不需要手工维护。缺点是转不转由 browserslist 决定,一旦有人写了过于宽松的目标(比如某个早已不需要兼容的版本),产物里就会多出一堆没必要的降级代码。
第二条是显式挂独立插件。适合两种场景:一是整个项目已经不走preset-env,只挂了几个零散插件;二是需要针对特定目录做额外处理,比如第三方包里残留了可选链,而项目本身的node_modules是被排除的。这种情况下可以用overrides或者include把那个包单独捞出来再转一遍。
第三条是开启宽松模式。转译器提供了一个假设开关,大意是“代码里不会出现那个历史特殊对象”,开启之后判断逻辑从“严格等于 null 或 undefined”简化成“宽松等于 null”,产物会短一截。省下来的字节在大项目里不算少,代价是对那个特殊对象的行为和规范不一致。业务代码里基本不可能出现这个对象,所以这个开关在多数项目里是安全的,但如果是基础库、SDK、要在任意宿主环境里跑的代码,建议还是保守一点,别开。
三条路的取舍可以简单归成一句:业务应用用 preset-env,基础库慎用宽松模式,第三方依赖用 overrides 兜底。
3.3 到底是谁在转译:别让工具链重复劳动
一个现代前端项目里,能处理语法的工具至少有五六个,搞清楚分工能省下大量排查时间。
| 工具 | 是否负责降级?./?? | 说明 |
|---|---|---|
| Babel | 是 | 按配置和 targets 转译 |
TypeScript (tsc) | 是 | 由target决定,ES2020 及以上保留 |
| SWC | 是 | 由target或jsc.target决定 |
| esbuild | 是 | 由target决定 |
| webpack 4 内置解析器 | 否 | 只解析不降级,版本旧会直接报错 |
| webpack 5 内置解析器 | 否 | 能解析,不降级 |
| terser 5 | 部分 | 能解析能压缩,降级要看配置 |
表里最关键的两行是 webpack 4 和 terser。webpack 4 内置的解析器版本较老,遇到?.会在模块解析阶段直接报Module parse failed,很多人第一反应是 Babel 没配好,其实是 webpack 自己在解析阶段就挂了,Babel 压根没轮到执行。解决办法只有两个:把语法在 loader 阶段就转掉,或者升级构建工具。
terser 的情况稍好,新版能正确解析 ES2020 语法,但它默认不会主动把语法降到更低版本,除非你显式指定输出目标。指望压缩器顺手做语法降级,通常会失望。
还有一个常见误区是把这两个语法和 polyfill 混为一谈。?.和??是纯语法糖,降级之后变成三元表达式和逻辑运算,不需要任何运行时辅助函数,也不需要core-js提供实现。项目里如果为了这两个语法去装了一堆 polyfill 包,那是白装。真正需要 polyfill 的是Promise、Array.prototype.flat这类新增的运行时能力,和语法转换完全是两条线。
4. 手写一遍降级逻辑:Babel 插件到底改了什么
4.1 先看官方产物长什么样
理解插件最快的方式是先看输入输出。下面几组对照能覆盖大部分情况。
// 源码 const city = order?.address?.city;// 降级产物(非宽松模式,示意) var _order$address; const city = (_order$address = order === null || order === void 0 ? void 0 : order.address) === null || _order$address === void 0 ? void 0 : _order$address.city;产物里出现了三样东西:void 0、严格的=== null || === void 0判断、以及用于暂存中间结果的临时变量。void 0是undefined的稳妥写法,因为在老环境里undefined是可以被重新赋值的标识符,直接写有风险。严格判断是为了避开那个历史特殊对象导致的宽松比较误差。
再看一个带调用的例子。
// 源码 const result = api.getUser?.(id);// 降级产物(示意) var _api$getUser; const result = (_api$getUser = api.getUser) === null || _api$getUser === void 0 ? void 0 : _api$getUser.call(api);注意.call(api)。这里如果不显式绑定,this就丢了,方法内部访问this.xxx会直接报错。这是可选链降级里最容易被手写实现漏掉的一点,也是最容易在测试里漏测的一点,因为很多方法压根不用this,测不出问题。
4.2 临时变量不是啰嗦,是保住语义的关键
为什么一定要提临时变量?把上一个例子改成内联写法就明白了:
// 错误示范:对象表达式被求值了两次 api.getUser === null || api.getUser === void 0 ? void 0 : api.getUser.call(api);如果api.getUser是一个 getter,或者api本身来自函数调用,这段代码会执行两次,副作用直接翻倍。可选链的语义里明确规定每个对象表达式只求值一次,降级必须守住这条。
第二个原因是this。内联写法里api.getUser.call(api)的api也要再求值一次,同样有副作用风险。所以 Babel 在遇到非简单标识符时,会额外提一个临时变量把api也存下来。
第三个原因是delete。delete a?.b在两个分支上的返回值不同,用三元表达式必须把两个返回值都写对,用临时变量反而更清晰。
当然也不是所有情况都要提变量。对象是纯标识符(比如a?.b里的a)时,求值没有副作用,直接内联成a === null || a === void 0 ? void 0 : a.b就够了,产物更短。这个优化判断就是插件里isPure一类的检查在起作用。
4.3 一个能跑的最小插件
下面这个插件只处理最简单的单层可选链,目的是把 AST 改写的思路讲清楚,生产环境请直接用官方插件。
// babel-plugin-demo-optional-chaining.js module.exports = function demoOptionalChaining({ types: t }) { // 生成 a === null || a === void 0 function nullishCheck(node) { return t.logicalExpression( '||', t.binaryExpression('===', t.cloneNode(node), t.nullLiteral()), t.binaryExpression( '===', t.cloneNode(node), t.unaryExpression('void', t.numericLiteral(0)) ) ); } return { name: 'demo-optional-chaining', visitor: { // 只处理 a?.b 这种单层形态 OptionalMemberExpression(path) { const { node } = path; // 只处理带问号的那一层,链中间节点跳过 if (node.optional !== true) return; // 只支持纯标识符,复杂表达式交给官方插件 if (!t.isIdentifier(node.object)) return; const obj = node.object; // 构造 a.b const member = t.memberExpression( t.cloneNode(obj), node.property, node.computed ); // 构造 a == null ? void 0 : a.b const replacement = t.conditionalExpression( nullishCheck(obj), t.unaryExpression('void', t.numericLiteral(0)), member ); path.replaceWith(replacement); }, }, }; };这段代码里有几个值得琢磨的点。第一,t.cloneNode不能省,Babel 的 AST 节点是对象引用,同一个节点挂到两个位置会导致后续遍历和生成阶段出问题,症状通常是产物里某段代码莫名其妙变成[object Object]或者直接报错。第二,判断node.optional === true是为了只处理真正带问号的那一层,a?.b.c这种链式结构的中间节点和末尾节点要区别对待。第三,明确只支持纯标识符,是有意为之——处理复杂表达式需要提临时变量、需要访问path.scope生成唯一名字、需要处理嵌套链,代码量会翻好几倍,写清楚边界比写一个跑不通的“通用版”更有价值。
想在本地验证这段插件,写个最简的调用脚本就行:
// run.js const babel = require('@babel/core'); const plugin = require('./babel-plugin-demo-optional-chaining'); const code = 'const x = a?.b;'; const out = babel.transformSync(code, { plugins: [plugin], configFile: false, babelrc: false, }); console.log(out.code); // 预期输出类似:const x = a === null || a === void 0 ? void 0 : a.b;自己动手写一遍之后再回头看官方插件,会更容易理解它为什么要处理那么多分支:链式嵌套、计算属性、调用、delete、super、临时变量的作用域提升、pure判断、宽松模式分支。这些分支不是过度设计,每一条都对应一个真实存在的语义缺口。
5. 接入项目:配置、验证、体积三件事
5.1 一份能直接抄的配置
推荐做法是只配preset-env,让目标环境决定一切。
// babel.config.js module.exports = { presets: [ [ '@babel/preset-env', { targets: { chrome: '60', safari: '11', node: '10', }, bugfixes: true, // 语法转换不需要 polyfill,这里保持 false useBuiltIns: false, }, ], ], };targets里写的版本决定了产物形态。Chrome 80 及以上原生支持这两个语法,产物里就会原样保留;Safari 11 不支持,就会被降级。这个判断完全由 preset-env 内部的数据表完成,不需要人工干预。
如果项目用browserslist统一管理目标,写成.browserslistrc更集中:
> 0.5% last 2 versions not dead iOS >= 11第二种接法是显式挂插件,适合不走 preset-env 的场景,或者需要单独给某个目录加料:
// babel.config.js module.exports = { plugins: [ '@babel/plugin-transform-optional-chaining', '@babel/plugin-transform-nullish-coalescing-operator', // 注意:??= 是另一个插件 '@babel/plugin-transform-logical-assignment-operators', ], };第三种是给第三方依赖兜底。node_modules默认被排除在外,如果某个包发布的是 ES2020 源码,而压缩或解析环节又不支持,就得单独捞出来:
// babel.config.js module.exports = { presets: ['@babel/preset-env'], overrides: [ { test: /node_modules[\\/]some-legacy-lib/, presets: [ ['@babel/preset-env', { targets: { ie: '11' } }], ], }, ], };这里的目标版本可以比主项目更激进,因为第三方包通常只需要跑通老环境的最低要求。实际排查中,这类“某个依赖里带了新语法导致构建失败”的问题,十次里有八次都能用overrides解决。
5.2 验证方式:别只看构建成功
构建不报错不等于产物正确。三个层次的验证,缺一不可。
第一层用 CLI 直接看输出:
npx babel src/index.js --out-file /tmp/out.js --config-file ./babel.config.js grep -n "?" /tmp/out.js # 粗略看一眼还有没有裸问号更精确的做法是搜索?.和??的字面组合,用正则匹配,避免把三元运算符当成漏网之鱼。
第二层做跨目标对照。把同一份源码分别按两套 targets 转一遍,对比产物差异,能快速确认配置是否真的生效:
npx babel src/index.js --presets='[["@babel/preset-env",{"targets":{"chrome":"90"}}]]' -o /tmp/modern.js npx babel src/index.js --presets='[["@babel/preset-env",{"targets":{"ie":"11"}}]]' -o /tmp/legacy.js diff /tmp/modern.js /tmp/legacy.js | head -40第三层跑产物测试。产物文件直接用老版本 Node 执行,或者放到老环境的模拟容器里跑一遍核心用例。这一步很多人会省掉,但真出问题往往就出在“语法转对了,语义错了一点”的地方,比如this丢了、delete返回值不对、短路分支里本该跳过的副作用被执行了。测试用例里特意写上这几个场景,比笼统地跑通主流程有用得多。
5.3 体积影响:别让降级把包撑大
可选链和双问号降级之后的产物通常比源码长,深层嵌套尤其明显。a?.b?.c?.d这种四层链,降级后可能变成三层嵌套三元加一堆临时变量声明。
量化一下会更直观。拿一段包含大量可选链的业务代码,分别按现代目标和老目标转译,再用同一套压缩配置处理,对比 gzip 后的体积。实测下来,一个重度使用可选链的中型项目,两套产物的体积差通常在几个百分点这个量级,不算离谱,但也谈不上可以忽略。
真正让体积失控的通常不是这两个语法本身,而是重复转译。典型场景是 TypeScript 先用target: ES5把可选链降级了一遍,Babel 又按老目标降级一遍,产物里就出现了“已经不可能为空的值还在做空值判断”这种冗余代码。压缩器能干掉一部分,但干不干净。合理的分工是只让一个工具负责语法降级:要么tsc输出 ES2020、Babel 负责降级;要么tsc直接输出目标版本、Babel 只做语法之外的转换。两者选一,别都干。
6. 踩坑记录与排查速查表
6.1 几个真实排查过的典型问题
报错信息只有Unexpected token,看不出在哪一层。这是最典型的定位难题。排查顺序建议从后往前:先确认报错文件的实际内容里有没有?.,再用最小复现单独跑一次 Babel,最后才怀疑构建配置。很多时候问题不在 Babel,而在 Babel 之前或之后的某一层。
Babel 配好了,构建还是挂。八成是 webpack 4 的内置解析器在报错,或者是某个 loader 内部的解析器版本太老。判断方法:临时把源码里的?.手动改成&&,如果构建通过,说明是解析阶段的问题,而不是转换阶段。
第三方依赖里带新语法。表现是本地开发正常,一跑生产构建就挂,因为node_modules默认被 Babel 排除。用overrides针对具体包名单独处理,比把整个node_modules都纳入编译安全得多,后者的构建耗时会翻倍。
ESLint 报解析错误,代码本身没问题。ESLint 的 parser 和 Babel 是两套配置。用默认解析器时要把ecmaVersion提到 2020 或更高,用 Babel 解析器时要确认 parser 包版本。这两处漏配一个,编辑器里就会满屏红线。
??和||混用报语法错误。加括号就行,不是工具问题,是规范要求。麻烦的是压缩之后括号可能被去掉又重新加上,如果产物在老环境报错,检查一下源码里有没有裸混用。
降级后this丢了。自己写降级逻辑或者用了某些自定义转换工具时容易遇到。表现是方法内部访问this得到undefined,报错位置看起来和可选链毫无关系。这类问题只能靠测试用例覆盖,看代码很难发现。
以为装了 core-js 就能解决。语法错误和 API 缺失是两回事,?.报错不是 polyfill 能解决的。遇到SyntaxError就去找语法转换,遇到xxx is not a function才去找 polyfill,两条排查线不要混。
6.2 排查速查表
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
Uncaught SyntaxError: Unexpected token '.' | 运行时环境不支持该语法 | 确认 Babel 是否覆盖该文件 |
Module parse failed: Unexpected token | 构建工具解析器版本旧 | 在 loader 阶段先转译,或升级工具 |
产物里仍有?. | targets 配置过宽,或文件被 exclude | 检查 browserslist 与 include 规则 |
Invalid left-hand side | 可选链被用在赋值左侧 | 改成先判空再赋值 |
Unexpected token '||' | ??与||裸混用 | 加括号明确优先级 |
方法内this为undefined | 降级时未保留调用上下文 | 检查是否用了非标准转换工具 |
| 构建慢了一倍 | 整个node_modules被纳入编译 | 改用overrides精确指定包名 |
| 编辑器满屏红线但能跑 | ESLint parser 版本或ecmaVersion不匹配 | 升级 parser 或调高ecmaVersion |
6.3 几条使用时的心得
降级产物里的空值判断看着啰嗦,但它是语义保真的代价。试图用更短的写法“优化”它,大概率会在某个边界上翻车,比如宽松比较碰上那个历史特殊对象,或者临时变量省掉之后对象表达式被求值两次。
深层链式调用要克制。a?.b?.c?.d?.e写起来爽,读起来费劲,而且一旦中间某一层返回了非预期类型,调试成本很高。实际项目里更推荐在边界处做一次数据规整,把可选链留给真正可能为空的几个关键位置,而不是层层设防。
??替换||这件事要分场景做,不能一刀切。像enabled || false这种,左值是布尔值,两者语义一致,改了没意义;但像count || 0、name || '未命名'这种,左值可能是0或空串,就必须改。判断标准很简单:问自己一句“左值取 0、空串、false 时,业务上算有效值吗”,算就得用??。
老项目改造时,先确认构建链路能不能解析,再谈降级。顺序反了会出现“插件装了但报错还在”的困惑局面。
最后分享一个我一直在用的自检习惯:每次改完 Babel 配置,不看构建日志,直接去产物里搜?.和??这两个字符组合,搜索结果为空才算过关。这比看“构建成功”四个字可靠得多,也花不了几秒钟。