ES2021 新特性落地记录:replaceAll、Promise.any 和逻辑赋值运算符

三月这次评审的代码不是新需求,是组里负责这块的同事让我帮忙看一下订单模块里一批半年前写的工具函数,评估要不要趁这次重构顺手优化。翻这批代码的过程正好赶上 ES2021 那几个新提案定案——TC39 三月的会议上,String.prototype.replaceAllPromise.any、逻辑赋值运算符、数字分隔符这几项都推进到了 Stage 4,意味着规范基本定型,Babel 和 core-js 那边配套的插件也跟着更新了。评审这批旧代码时,正好能对照着看哪些地方是当时没有更好写法、只能那样写,哪些地方现在有了更干净的表达。

这几个特性单独看都不算大,不像 Promise、模块化那样牵动整个代码组织方式,更像是把一批长期存在、大家都用各自的 hack 绕过去的小麻烦,收拢成了标准能力。也正因为"小",很容易被当成语法糖一带而过,但翻旧代码的过程中发现,恰恰是这批小麻烦的 hack 写法里,藏着几个一直没暴露的边界问题——这也是这次评审比预期话题更多的原因。

全局替换不用再拿正则将就

订单模块里有个格式化函数,把用户备注里的换行符统一替换成空格,用来在列表页做单行摘要:

1function toSummary(text) {
2  return text.replace(/\n/g, ' ');
3}

这段代码本身没问题,replace 配合全局标志的正则确实能做全局替换。但再往下翻,另一个函数要把字符串里所有的字面量 . 替换成中文顿号,写法是这样的:

1function toReadablePath(path) {
2  return path.replace(/\./g, '、');
3}

这里的正则其实只是拿来当"全局替换"的替身用的,. 在正则里是元字符,得转义成 \. 才表示字面量的点。如果换成别的字符,比如要替换 +*( 这类正则元字符,转义规则得背一遍,稍微写错一个反斜杠,替换范围就不对了。这是我们这批工具函数里最常见的一种"正则滥用":并不需要模式匹配,只是想要把字符串里所有的某个子串换成另一个,却因为 String.prototype.replace 不支持全局替换普通字符串,被迫套上正则这层壳。

ES2021 的 String.prototype.replaceAll 就是专门解决这个问题的:

1function toReadablePath(path) {
2  return path.replaceAll('.', '、');
3}

不用转义,不用记正则元字符表,语义也更直接——就是"把这个子串全部换掉",没有模式匹配的歧义。它的第一个参数如果传字符串,就是纯字面量匹配;如果想要模式能力,也支持传全局正则(不带 g 标志的正则传进去会直接抛 TypeError,这个报错设计得很明确,不会让你写出"以为是全局替换、实际只替换了第一个"的隐蔽 bug):

1'2021-03-27'.replaceAll('-', '/'); // '2021/03/27'
2'2021-03-27'.replaceAll(/-/g, '/'); // 效果相同,但没必要

翻完这批工具函数,我数了一下,光订单模块里就有六处用正则做纯字符串替换、其中两处的转义是写错的(把 + 忘记转义,替换范围比预期大)。这类问题不是逻辑错误,测试用例覆盖到才会暴露,平时肉眼审查很难发现。replaceAll 从设计上直接消灭了这一类问题的来源——不需要转义,也就不存在转义写错的可能。

顺带留意了一下性能差异,虽然这批工具函数处理的字符串都不长(用户备注、路径这类文本),谈不上性能瓶颈,但了解一下底层实现上的差别还是有必要的。replaceAll 传字符串参数时,引擎内部走的是字符串查找算法,不需要构建和执行正则状态机;而正则版本即便匹配的是字面量,也得先编译成正则对象、走一遍正则引擎的匹配流程,理论上字符串直接匹配的开销更低。V8 团队在实现这个提案的时候也提到过这一点,纯字符串替换场景下 replaceAll 的性能通常不会比等价的正则写法差,日常业务代码里这点差异基本感知不到,但如果是处理大文本(比如日志清洗、大段富文本的批量替换)这类场景,选纯字符串参数而不是"看着差不多就无脑上正则",是有实际意义的。

不过 replaceAll 目前只有 Chrome 85 之后、Node 15 之后原生支持,Safari 那边到今年三月还没有稳定支持。我们这次是内部管理后台,浏览器基本锁定最新版 Chrome,可以直接用;但如果是面向外部用户的页面,得靠 core-js 3 的 polyfill 兜底,Babel 配置里加一条 @babel/plugin-proposal-string-replace-all 或者直接升级到支持这个提案的 core-js 版本,preset-env 会根据目标浏览器列表自动决定要不要打进去。

有一处细节我专门留意了一下:replaceAll 传字符串时不支持捕获组和 $1 这类替换模式,只能做纯文本替换;如果替换逻辑真的需要引用匹配到的内容(比如把 user_name 这种下划线命名转成驼峰),还是得老老实实传一个带 g 标志的正则,replaceAll 并不是要完全取代正则替换,它只是把"纯字符串全局替换"这个子场景单独摘出来给了一个更直接的方法:

1// 需要捕获组,replaceAll 仍然要配合正则
2function toCamelCase(key) {
3  return key.replaceAll(/_(\w)/g, (_, c) => c.toUpperCase());
4}

翻代码时我把这批工具函数按"是否用到捕获组"分了个类:不需要捕获组的六处全部换成了 replaceAll 传字符串;有一处依赖捕获组做驼峰转换的保留了正则写法,只是把原来漏加的 g 标志补上——这处之前只加了 i 标志忘了 g,只会替换第一个下划线,是评审时顺带发现的另一个问题,和这次的新特性没有直接关系,但也算是审这批代码的额外收获。

Promise.any 和 race、allSettled 到底有什么区别

订单详情页有个场景是从三个 CDN 节点分别请求同一份配置文件,只要有一个节点先返回成功结果就用它,不需要等其他两个。之前这位同事写的实现是拿 Promise.race 凑合用的:

1function fetchConfigFast(urls) {
2  return Promise.race(urls.map((url) => fetch(url).then((r) => r.json())));
3}

这段代码在正常情况下没问题,但有个隐患:Promise.race 是谁先"落定"(settled)就用谁的结果,不管落定的是成功还是失败。如果最快返回的那个节点恰好是失败的(网络抖动、404),race 会直接把这个失败结果抛出去,即使另外两个节点几乎同时也返回了成功结果,也来不及了。我们线上偶尔出现过配置加载失败的报错,排查下来就是这个原因——最先响应的节点是个临时故障的边缘节点。

这正是 Promise.any 要解决的问题。它和 race 的区别在于:any 只关心第一个成功的结果,会忽略中途的失败,只有当传入的所有 Promise 都失败时,才会拒绝,并且拒绝时抛出的是一个 AggregateError,把所有失败原因都收纳在 errors 数组里:

1function fetchConfigFast(urls) {
2  return Promise.any(urls.map((url) => fetch(url).then((r) => r.json())));
3}
4
5fetchConfigFast(cdnUrls).catch((err) => {
6  console.error('所有节点都失败了', err.errors);
7});

把这几个组合方法放一起对比会更清楚。Promise.all 要求全部成功,一个失败就整体失败;Promise.allSettled(ES2020 已经有)不管成功失败,等所有任务都跑完、把每一个的结果和状态都收集回来;Promise.race 谁先落定就用谁,不管成功失败;Promise.any 只等第一个成功的,中途的失败被悄悄吞掉,除非全部失败。这四个方法对应的其实是四种不同的业务语义:"必须全部成功""无论如何都要知道每一个的结果""不管谁先给结果""只要有一个能成"。选错了方法,代码逻辑是对的,但语义和真实需求对不上,出问题时很难从代码表面看出来——race 用在"多节点抢速度"这个场景,表面上跑得通,实际藏着"最快的失败会拖累整体"这个坑,这也是我建议把这处改掉的原因。

拿同一组 CDN 请求分别套这四个方法跑一遍,结果的形状差异会更直观:

1const tasks = cdnUrls.map((url) => fetch(url).then((r) => r.json()));
2
3await Promise.all(tasks);
4// 全部成功才 resolve,返回一个结果数组;任意一个失败,整体直接 reject
5
6await Promise.allSettled(tasks);
7// 总是 resolve,返回 [{ status: 'fulfilled', value }, { status: 'rejected', reason }, ...]
8
9await Promise.race(tasks);
10// 第一个落定的(无论成功失败)直接决定整体结果
11
12await Promise.any(tasks);
13// 第一个成功的决定结果;全部失败才 reject,抛出 AggregateError

allSettledany 容易被放在一起比较,但用途其实不同:allSettled 是"我要知道每一路的结果,不管好坏,自己再决定怎么处理",适合类似"批量导入,每一条各自成功或失败,最后汇总报告哪些失败了"这种需要完整状态的场景;any 是"我只要一个能用的结果,其余的我根本不关心",适合"多个来源里选一个可用的就行"这种场景。我们这次 CDN 配置的例子属于后者,用 allSettled 也能实现,但要额外自己写"找第一个 fulfilled 的"这段逻辑,any 直接把这个模式内置了。

值得记一句的是,any 并不会因为拿到第一个成功结果就去主动取消其余还在进行中的请求——fetch 本身默认也不支持取消,除非配合 AbortController 显式中止。也就是说即便第二个、第三个 CDN 节点的请求已经"没用"了,它们仍然会在后台跑完,只是结果被忽略。如果这些请求本身开销不小(比如涉及大文件下载),光靠 Promise.any 拿到第一个结果还不够,需要额外用 AbortController 把其余请求主动打断,否则浪费的是真实的网络和服务器资源,只是对调用方而言"看不见"而已。我们这次 CDN 配置文件体积很小,没有专门处理这一层,但记下来是因为如果换成体积更大的资源竞速场景,这一点会变得需要认真考虑。

Promise.any 目前的浏览器支持比 replaceAll 还要新,Chrome 85 起可用,Node 得 15 版本以上,我们线上 Node 服务这会儿还锁在 14 LTS,如果要在服务端用得靠 core-js 的 polyfill。内部管理后台走浏览器端问题不大,但这类新 API 引入之前,我都会先确认一遍目标环境的版本号,而不是看到能用就直接上。

AggregateError 本身也是 ES2021 新增的错误类型,值得单独说一下它和普通 Error 的区别。普通 try/catcherr 只能是一个错误对象,遇到"多个并发操作、每个都可能各自失败"的场景,以前只能自己拼一个数组把错误都塞进去;AggregateError 把这个模式标准化了,errors 属性就是一个错误数组,配合 Promise.any 天然契合。如果要手动抛出聚合错误,也可以直接 new AggregateError([err1, err2], '整体描述'),不一定非要等 Promise.any 帮你生成:

1async function fetchWithFallback(sources) {
2  const errors = [];
3
4  for (const source of sources) {
5    try {
6      return await source();
7    } catch (err) {
8      errors.push(err);
9    }
10  }
11
12  throw new AggregateError(errors, '所有数据源均不可用');
13}

这段代码和直接用 Promise.any 的效果类似,区别是它是严格串行尝试、而不是并发竞速。选哪种取决于场景:多个 CDN 节点本来就该并发抢速度,用 Promise.any;如果是"先查缓存、缓存没有再查数据库、数据库也没有再查远程接口"这种有明确优先级、且后面的数据源成本更高、不希望无谓地并发发起的场景,手动串行加 AggregateError 收尾会更合适。Promise.any 解决的是"谁都行,谁快用谁",不是"按顺序试到有一个能用为止",这两种需求表面相似,实际是两种不同的调度策略,不能因为都叫"重试"就混用。

逻辑赋值运算符简化了"没有就赋默认值"

这批工具函数里最常见的一类写法是"如果这个字段不存在,就给个默认值":

1function normalizeOptions(options) {
2  if (!options.pageSize) {
3    options.pageSize = 20;
4  }
5
6  options.headers = options.headers || {};
7  options.headers.timeout = options.headers.timeout || 3000;
8
9  return options;
10}

这类代码占了整个工具函数文件相当大的篇幅,模式高度重复:先判断某个值是否"没有",没有就赋值。ES2021 的逻辑赋值运算符把这个模式收进了一个运算符里,一共三个:&&=||=??=

a ||= b 等价于 a || (a = b),只有 a 是假值时才赋值:

1options.headers = options.headers || {};
2// 等价于
3options.headers ||= {};

a ??= b 等价于 a ?? (a = b),只有 anullundefined 时才赋值,这个和 ||= 的区别跟 ??|| 的区别是一回事——如果 pageSize 传的是 0||= 会把它当假值覆盖掉,??= 则会保留这个 0

1options.pageSize ??= 20;

这一处改成 ??= 而不是 ||= 是有意义的,因为分页大小传 0 在业务上虽然少见,但语义上是合法输入(比如某个场景故意想先不加载数据),||= 会悄悄把它改写成 20,这是一个不容易被发现的行为偏差。翻旧代码时我特意把这类"用 || 判断默认值、但字段可能合法为 0 或空字符串"的地方标出来,一共揪出三处潜在问题,都是当时图省事直接用 || 拍的。

这三处里最典型的一个是订单列表的"折扣金额"字段,0 表示这单没有折扣,是完全合法的业务值:

1// 旧写法:折扣为 0 时会被误判成"没传",被强行改写成默认值 10
2function applyDiscount(order) {
3  order.discountAmount = order.discountAmount || 10;
4  return order;
5}

这个 bug 平时不容易被测出来,因为大部分测试数据折扣都不是 0,只有真实出现"无折扣订单"的数据才会暴露问题,而且暴露出来的现象是"金额多算了 10",排查时很容易先怀疑计算逻辑,而不是怀疑这处默认值判断。换成 ??= 之后,只有 discountAmount 严格是 nullundefined 时才会被赋默认值,0 会被原样保留:

1function applyDiscount(order) {
2  order.discountAmount ??= 10;
3  return order;
4}

这也提醒我一个更通用的判断标准:凡是看到 xxx || 默认值 这个模式,先问一句"这个字段允许合法地是 0、空字符串或者 false 吗",如果允许,就该用 ??=;如果这个字段的假值状态本身就等同于"没有"(比如一个可选的字符串备注,空字符串和没填在业务上就是一回事),用 ||= 才是对的。这两个运算符不是谁比谁更"新更好",而是分别对应两种不同的判空语义,选错了不会报错,只会在特定输入下悄悄产生错误结果。

a &&= b 等价于 a && (a = b),只有 a 是真值时才赋值,常见场景是"如果这个对象存在,就更新它的某个属性":

1if (cache.entry) {
2  cache.entry = normalize(cache.entry);
3}
4// 等价于
5cache.entry &&= normalize(cache.entry);

整理下来,normalizeOptions 这个函数用逻辑赋值运算符重写之后是这样:

1function normalizeOptions(options) {
2  options.pageSize ??= 20;
3  options.headers ||= {};
4  options.headers.timeout ??= 3000;
5
6  return options;
7}

行数少了一半,但更重要的是每一行的意图更聚焦——不需要在脑子里先转一遍"判断再赋值"的两步逻辑,运算符本身就把这个模式说清楚了。需要提醒的是这三个运算符都是短路求值,右边表达式只有在需要赋值时才会执行,如果右边是一个有副作用的函数调用(比如发请求、写日志),这个"按需执行"的特性要提前想清楚,不要以为每次都会跑一遍。

这几个运算符目前主流浏览器和 Node 15 都已经支持,TypeScript 这边从 4.0 开始就已经支持编译层面的类型推导,不需要额外配置;用 Babel 的话得确认 @babel/plugin-proposal-logical-assignment-operators 有没有打进预设,如果用的是较新的 @babel/preset-env 加合适的 targets,一般会自动带上。

还有一个容易被忽略的行为差异:逻辑赋值运算符左边如果是一个对象属性访问,且这个属性挂了 getter/setter,赋值动作只有在真正触发赋值时才会调用 setter,短路时完全不会碰这个属性。这跟普通赋值 a.b = c 每次都会触发 setter 不一样。举个例子,如果 options.headers 是通过 Object.defineProperty 定义了带副作用的 setter(比如赋值时顺带打一条日志),用 options.headers ||= {}headers 已经存在时是不会触发这个 setter 的,因为短路直接跳过了赋值这一步:

1const options = {};
2let setCount = 0;
3
4Object.defineProperty(options, 'headers', {
5  get() {
6    return this._headers;
7  },
8  set(value) {
9    setCount += 1;
10    this._headers = value;
11  },
12});
13
14options.headers ||= {}; // headers 原本是 undefined,触发一次 setter,setCount 变成 1
15options.headers ||= {}; // headers 已经有值,短路跳过赋值,setCount 仍是 1

我们项目里目前没有大量使用带副作用 getter/setter 的对象,这个差异暂时不构成实际问题,但如果之后要接入某个响应式框架(这类框架内部经常靠属性拦截做依赖追踪),逻辑赋值运算符"短路时不触发 setter"这个特性就需要额外留意——它可能意味着某次本该触发的响应式更新被跳过了,排查起来会比较隐蔽。这算是提前记一笔,等真的用到响应式场景时再验证。

数字分隔符:给大数字加"千分位"

订单模块里有个和金额上限相关的常量,之前是这样写的:

1const MAX_ORDER_AMOUNT = 99999999; // 单位:分

八个 9 连在一起,光是数清楚有几位就得数一遍,改起来也容易手滑多写或少写一位。ES2021 的数字分隔符允许在数字字面量中间插入下划线,纯粹是给人眼睛看的,不影响数值本身:

1const MAX_ORDER_AMOUNT = 99_999_999; // 单位:分,约等于 99.9999999 万元

下划线可以插在整数、小数、科学计数法、二进制、十六进制里,只要不放在数字开头结尾、不连续出现两个就行:

1const oneMillion = 1_000_000;
2const pi = 3.141_592_6;
3const binary = 0b1010_0001_1000_0101;
4const hex = 0xff_ec_de_5e;

这算是这批 ES2021 特性里最"小"的一个,不涉及任何行为改变,纯粹是可读性上的便利。但恰恰因为它不改变任何运行时行为,接入成本几乎为零——不需要考虑兼容性顾虑之外的任何风险,唯一要确认的是构建工具认不认识这个语法。Babel 从 7.8 开始支持解析数字分隔符,Node 12.5 以上原生支持,我们这批工具函数改起来没有任何心理负担,看到长数字常量顺手就加上了。

不过这批工具函数里刚好有个例子说明了"看着像千分位,其实分隔位置不能瞎放"这件事。数字分隔符的分组是完全自由的,下划线插在哪里都合法,只要不在开头结尾、不连续,这意味着它不会像某些语言的千分位格式化那样自动按三位一组,写的人需要自己保证分组有意义:

1// 语法上完全合法,但分组毫无意义,比不加分隔符更容易看错
2const wrong = 9_99_99_999;
3
4// 按千分位分组,一眼能看出是九千九百九十九万九千九百九十九
5const right = 99_999_999;

这个坑不会导致任何运行时错误,9_99_99_99999_999_999 数值上完全相等,纯粹是给人看的分组信息如果标错了,反而会造成比不加分隔符更强的误导——因为读者会下意识信任这个分组、以为它是按惯例三位一组的。我们在评审时顺手加了一条不算严格的约定:分隔符统一按三位一组(整数部分),小数部分按业务含义自行分组(比如金额精确到分的场景,可以按两位一组对齐"元"和"分"),不追求语言层面的强制校验,靠评审时人工留意。

WeakRef 和 FinalizationRegistry:知道就好,不必强用

评审到最后,这位同事提了一个问题:订单模块里有个客户端缓存,缓存的 key 是订单 ID、value 是一个较大的详情对象,会不会因为缓存一直持有引用导致这些详情对象永远无法被垃圾回收,造成内存只涨不降。这个问题本身其实和 ES2021 没有直接关系,是评审过程里顺带聊起来的,但刚好这一年规范里新增的两个能力正好是用来回应这类问题的,值得借这个机会一起说清楚它们能做什么、不能做什么。

这正好牵出 ES2021 里另外两个新加入的能力——WeakRefFinalizationRegistry。它们和 ES6 就有的 WeakMapWeakSet 是同一类思路的延伸:WeakMap 只能用对象当键,WeakRef 则是把"弱引用"这个能力开放成了一个可以直接持有任意对象的包装器。用 WeakRef 包一层之后,这个引用不会阻止对象被垃圾回收,需要用的时候调用 .deref() 取出原对象,取到的可能是原对象,也可能因为已经被回收而是 undefined

1const cache = new Map();
2
3function setCache(id, detail) {
4  cache.set(id, new WeakRef(detail));
5}
6
7function getCache(id) {
8  const ref = cache.get(id);
9  const value = ref && ref.deref();
10  return value; // 可能是 undefined,说明已经被回收
11}

FinalizationRegistry 则是在对象被垃圾回收之后,注册一个回调去做清理,比如把 Map 里对应的 key 顺手删掉,避免 WeakRef 包装器本身在 Map 里越堆越多:

1const registry = new FinalizationRegistry((id) => {
2  cache.delete(id);
3});
4
5function setCache(id, detail) {
6  cache.set(id, new WeakRef(detail));
7  registry.register(detail, id);
8}

规范里给这两个 API 的定位说得很清楚:它们描述的是引擎"可能"的行为,而不是"承诺"的行为,垃圾回收本身什么时候跑、要不要跑、跑的时候顺序如何,完全是引擎实现细节,不同浏览器、不同 Node 版本的表现可能都不一样,甚至同一个引擎在不同负载下的表现也会变化。这种"结果正确但时机不确定"的特性,和我们平时依赖的绝大多数 API 不是一回事——平时写的代码几乎都要求"调用之后立刻能确定结果",WeakRef/FinalizationRegistry 恰恰反过来,逼着写代码的人接受一部分不确定性,这也是为什么规范文档和各家实现都反复强调不要把业务正确性建立在这两个 API 之上。

这两个 API 我不打算在订单模块里直接用。一方面垃圾回收的时机完全不可控,规范里明确写了"不保证回调一定会被调用、也不保证调用时机",用来做业务逻辑的一部分是不合适的,只能用来做资源清理这种"锦上添花、不做也不影响正确性"的辅助手段;另一方面调试这类代码非常麻烦,内存问题本身排查起来就费劲,混入不确定的回收时机之后,问题会更难复现。真要解决订单详情缓存的内存问题,更实际的做法是给缓存设置容量上限,用简单的 LRU 策略在超出容量时主动淘汰最老的条目——这是我们最后选的方案,不依赖任何新语言特性,行为完全可预测。

WeakRef 提出来是为了照顾一些框架和库作者做资源管理用的底层能力,日常业务代码遇到"内存该不该释放"的问题时,先想清楚生命周期该怎么管理,而不是指望一个新 API 替你收场。这个特性我记下来是为了知道它存在、知道它解决什么类型的问题,不代表现在就要用在项目里。

规范文档里还专门提了一个容易被滥用的场景,值得单独记一笔:不要拿 WeakRef 去做"观察对象什么时候被回收"这种带确定性预期的逻辑,比如靠 deref() 返回 undefined 来判断"这个对象刚刚被回收了、我现在要触发一次清理动作"。这个判断本身没错,但触发时机完全不可控——垃圾回收可能立刻发生,也可能拖到很久之后才发生,甚至在某些低内存压力的场景下长期不发生,如果业务逻辑依赖"回收发生的时刻",代码在不同机器、不同负载下会表现出完全不一样的行为,这种不确定性放在生产代码里排查起来比普通的时序问题更棘手,因为它连"稳定复现"这个前提都不成立。

顺着这个话题多说一句 WeakMapWeakSetWeakRef 的关系,容易混淆但值得分清楚。WeakMap 解决的是"给某个对象挂关联数据、但不希望这份关联阻止对象被回收",键必须是对象,整个 WeakMap 结构本身不可遍历,也拿不到 size,这是它牺牲掉的能力,换来的是"不会导致内存泄漏"这个保证。WeakRef 解决的是另一个更底层的问题——你想直接持有对某个对象的引用(而不是把它当某个容器的键),但又不想这份引用本身阻止回收。两者的应用场景不完全重叠:给 DOM 节点挂临时状态,WeakMap 更合适;实现一个"能感知对象是否还存活"的观察者或者缓存层,WeakRef 才是对的工具。我们这次讨论订单缓存时一度想直接套 WeakMap,后来发现不合适——WeakMap 的键虽然可以是对象,但缓存查找用的 key 是订单 ID(字符串),不是对象本身,WeakMap 用不上,这也是为什么真正贴合场景的是 WeakRef 而不是 WeakMap,尽管最终我们选择了更简单的 LRU 方案。

把几个特性放到同一段代码里检验

评审做到后半段,我干脆把订单详情页的初始化逻辑整段拎出来重写了一遍,作为验证这几个新特性能不能配合使用的样本。原来的写法是这样的:

1function initOrderDetail(rawOptions) {
2  var options = rawOptions || {};
3
4  if (!options.pollInterval) {
5    options.pollInterval = 3000;
6  }
7
8  options.retryPolicy = options.retryPolicy || {};
9
10  if (!options.retryPolicy.maxAttempts) {
11    options.retryPolicy.maxAttempts = 3;
12  }
13
14  var idText = String(options.orderId).replace(/[^0-9]/g, '');
15
16  return Promise.race([
17    fetchDetail(idText, options),
18    fetchDetailFromCache(idText),
19  ]).catch(function () {
20    return fetchDetailFromBackup(idText);
21  });
22}

这段代码本身能跑,但集中体现了这批工具函数里几乎所有的老问题:|| 判断默认值、忽略 0 这种合法假值的可能性、正则做纯字符替换、Promise.race 用在了"抢首个成功结果"而不是"抢首个落定结果"的场景(缓存查询几乎总是比真实接口快,一旦缓存本身返回了一个过期的失败标记,race 会直接采用这个失败结果,即使真实接口紧跟着就返回了有效数据)。用 ES2021 这批特性重写之后:

1function initOrderDetail(rawOptions) {
2  const options = rawOptions ?? {};
3
4  options.pollInterval ??= 3_000;
5  options.retryPolicy ||= {};
6  options.retryPolicy.maxAttempts ??= 3;
7
8  const idText = String(options.orderId).replace(/[^0-9]/g, '');
9
10  return Promise.any([
11    fetchDetail(idText, options),
12    fetchDetailFromCache(idText),
13  ]).catch(() => fetchDetailFromBackup(idText));
14}

行数从十六行压到十行,但更关键的变化在语义上:pollIntervalmaxAttempts 都可能合法地配置成 0(分别表示"不轮询"和"不重试"),用 ??= 之后这两种配置不会被误改写;retryPolicy 这个字段不存在合法的假值状态(要么是个对象,要么没有),用 ||= 语义反而更贴切,不需要为了统一风格强行都用 ??=;把 Promise.race 换成 Promise.any 之后,只要缓存和真实接口有一个成功,就能拿到结果,只有两个都失败才会走兜底的 fetchDetailFromBackup,这才是"多个来源里挑一个能用的"这个需求本来该有的行为。

这一段改完之后拿去补了单元测试,专门加了"缓存查询失败但真实接口成功""折扣金额传 0""轮询间隔传 0"这几个之前没有覆盖到的边界用例,全部通过。这些用例在旧代码上跑会直接失败,因为旧代码里那几个"假值当没传"的判断会把 0 悄悄改写掉。这也是这次评审里我觉得最实际的收获——不是"用了几个新语法",而是新语法的语义精确性,顺带逼出了几个原本被默认值判断掩盖掉的临界情况。

这批新特性怎么落地到构建链路里

评审完这几处之后,剩下的问题是怎么让这些新语法在我们的 Node 14 + Babel 7 构建链路里稳定跑起来。这几个特性目前分别处于不同的支持阶段:数字分隔符和逻辑赋值运算符是纯语法层面的新增,Babel 只需要能解析(parser 插件)加转译(transform 插件)就行,不依赖运行时 polyfill;而 replaceAllPromise.anyWeakRef 这几个是新增的内置方法/对象,属于运行时能力,光靠 Babel 转译语法解决不了,必须靠 core-js 注入具体实现。

我们项目里用的是 @babel/preset-env 配合 useBuiltIns: 'usage',让 Babel 根据代码里实际用到的 API、结合 browserslist 里配置的目标环境自动决定要不要注入对应的 polyfill,不需要手动一个个引入。升级到支持这批新特性的 core-js 3 之后,构建产物体积增加得并不多,因为这几个 API 本身实现都不复杂,真正占体积大头的还是老生常谈的 ArrayObject 那批方法的 polyfill。

TypeScript 这边,4.0 起 lib 定义文件已经补上了 es2021 相关的类型声明,只要 tsconfig.jsontargetlib 配置里加上 ES2021(或者更保守地单独列 esnext.stringesnext.promise 这类细分 lib),类型检查就能正常识别这些新方法,不会报"属性不存在"的错误。这一点在我们把 tsconfig.json 里的 libes2019 手动改成 es2021 时验证过——改之前 replaceAll 直接飘红,改完之后类型提示和参数校验都是对的,说明类型定义已经补齐,剩下要处理的只是运行时兼容性这一层。

这里有个 targetlib 的区别经常被搞混,值得单独说清楚。target 决定 TypeScript 把代码编译成哪个版本的语法(比如箭头函数要不要转成 function),lib 决定的是类型检查阶段能识别哪些内置 API 的类型声明,两者是独立的两件事。我们项目里 target 出于兼容性考虑保持在 es2018,但 lib 单独提到了 es2021,这种组合完全合法——意思是"编译输出仍然是老语法(配合 Babel 做进一步降级),但类型检查允许我在源码里写新 API"。如果只提 target 不管 lib,或者反过来,很容易出现"代码能跑但类型报错"或者"类型不报错但打包出来的代码用了浏览器不支持的方法"这两种错位,这一点在评审时特意跟这位同事确认过一遍,因为这两个配置项名字看着像是一回事,实际管的是两个完全不同阶段的问题。

另外一个容易被忽略的地方是 strictNullChecks 打开之后,??= 对类型收窄的帮助。如果一个变量类型是 number | undefined,用了 pageSize ??= 20 之后,TypeScript 能推导出这一行之后 pageSize 的类型收窄为 number,不再需要额外的非空断言或者类型守卫;但如果用的是 if (!pageSize) { pageSize = 20 } 这种老写法,收窄效果依赖控制流分析能不能覆盖到这种模式,实际测下来 TypeScript 4.x 对这两种写法的收窄支持都还可以,逻辑赋值运算符这边稍微更直接一点,因为语义本身就更贴近"赋值后类型必然确定"这件事,不需要编译器做额外的模式识别。

ESLint 规则跟不跟得上语法升级

新语法落地之后还有一个容易被忽视的环节:ESLint 的解析器本身要认识这些语法,否则会在完全合法的代码上报语法错误,而不是报正常的代码规范问题。我们项目当时用的是 @babel/eslint-parser(从 babel-eslint 改名过来不久),它是把源码交给 Babel 的解析器去理解语法结构,理论上只要 Babel 的 parser 插件支持了逻辑赋值运算符和数字分隔符,ESLint 这边就能正常解析,不需要单独升级 ESLint 本体。实际验证下来确实如此,options.pageSize ??= 20 这一行在升级 Babel 相关依赖之前,ESLint 会直接报 Parsing error: Unexpected token,升级之后这个报错就消失了,说明问题出在解析层而不是规则层。

如果项目用的是 @typescript-eslint/parser(走 TypeScript 编译器做解析),情况会不太一样,因为它依赖的是 TypeScript 自身的语法解析能力,只要 TypeScript 版本本身支持这批新语法(4.0 起就支持),解析器这边基本不需要额外操心,这也是为什么我们后来把项目里几个纯前端子包统一切到 TypeScript 之后,反而在语法层面的兼容问题变少了——不再依赖 Babel 和 ESLint 两边各自维护一份对新语法的支持进度,全部交给 TypeScript 编译器统一处理。

规则层面倒是有一条新规则值得手动打开:no-unused-expressions 和逻辑赋值运算符搭配使用时,早期版本的 ESLint 曾经把 a ||= b 误判成"未使用的表达式"报警告,这是规则本身对新语法语义理解滞后导致的,属于工具链跟不上语言演进速度的典型例子。升级到支持这批语法的 ESLint 版本之后这个误报就消失了,但如果团队里有人还在用旧版本的编辑器插件做实时校验(而不是走统一的 CI 检查),这类误报会造成"明明代码是对的,编辑器却一直标红"的困扰,值得在升级说明里专门提一句,免得大家以为是自己写错了。

评审收尾时定的几条团队约定

这次评审最后没有停留在"把这几个新特性介绍一遍",而是顺着这几处真实改动,和这位同事一起定了几条后续代码评审时可以直接对照的判断标准。第一条是看到 xxx || 默认值 这个模式时,评审人要主动追问一句这个字段是否存在合法的假值,而不是默认认为写法没问题就放行;第二条是看到多个并发请求要"抢速度"的场景,先确认清楚需求到底是"抢第一个落定的"还是"抢第一个成功的",这两者用错了不会在代码审查阶段被肉眼发现,只会在某个边缘节点抽风的时候才暴露;第三条是新语言特性引入之前,先确认一下目标运行环境(浏览器版本、Node 版本)而不是想当然地认为构建工具会兜底一切——polyfill 能补运行时行为,但补不了那些因为语义理解错误而写歪的业务逻辑。

这几条约定本质上不是关于"要不要用新语法",而是提醒大家新语法往往会把一部分原来隐藏在样板代码里的判断逻辑显式地暴露出来,这个显式化的过程正是重新审视这些判断是否真的正确的好机会。回到最开始的六处正则转义问题、一处 race/any 混用问题、三处 0 被错误覆盖的问题,没有一处是这次评审之前就被标记为"待修复"的已知缺陷,全部是顺着"这段代码能不能用新语法简化"这个动机往下挖,才顺带挖出来的。评审这轮改动量不算大,但顺带把几处沉默的边界条件问题一起解决了,这是这次评审比预期更值得记录下来的地方。

这批改动上线之后,我把涉及到的文件路径和改动点整理成一份简短的变更说明附在提交记录里,方便后面有人翻到这几处代码时知道为什么会是这样写,而不用重新猜一遍"当初为什么用 ??= 而不是 ||="。这类小改动如果不留一句注释或者提交说明,过一段时间同一处代码被别人不小心改回 || 判断也不奇怪——新语法带来的语义精确性,只有配合清楚的理由记录下来,才能在后续维护里持续发挥作用,否则很容易在下一轮不了解背景的改动里被悄悄抹平。