跳到主内容

4

ES2020-2025 现代特性

可选链 / 私有字段 / 不可变数组方法 / 装饰器——5 年小步迭代里值得吸收的部分

必学19 分钟

这一节梳理 ES2020-2025 五年间的语言新特性——不是 MDN 风格的语法手册,是"每个特性替换了什么旧写法、什么时候该用、什么时候不该用"的判断地图。

§4.1-§4.3 把 JS 语言核心 / 异步模型 / 模块系统讲清楚了。这一节聚焦近 5 年的增量更新——很多读者最近一次系统学 JS 是 2017-2018 年(ES6 + ES2017),但 JS 在那之后每年都在小步迭代,5 年下来累积的变化已经相当可观。

本节核心论点

  1. ES6(2015)后没有再出现革命性的语法变化——年度更新都是"补一个常用 API"或"修一个语义陷阱"
  2. ?. / ?? / #field 是必学三件套——业务代码每天都在用
  3. 不可变数组方法(toSorted / toReversed)是 React / 函数式风格的福音——替代会变异原数组的旧 API
  4. 装饰器、Records & Tuples 等"看似酷"的特性还不该用——稳定性 / 兼容性 / 工具链支持都不够

怎么读这一节(建议):

你是直接读
新人 / 转行者§1 可选链 + nullish 合并 → §3 私有字段 → §4 不可变数组方法
写过几年 JS 但没跟 2020+ 更新§1 → §3 → §4(按顺序读完)
跟过新特性,想知道"哪些不该用"§6 ES2025+ → §9 翻车库
要带 AI 写现代 JS§8 AI 最小集 → §9 翻车库

通读约 30-40 分钟;按上述路径挑读 10-15 分钟。§1 可选链 + nullish 合并§3 私有字段 + 顶层 await是日常代码最高频的部分。


0. 演进史:从 ES6 大爆炸到年度小步迭代

ES6(2015)之后,TC39 改成每年一个版本号——大量"看似不起眼"的小改进累积下来,5 年的差异比想象中大。

版本重点特性
ES6 / ES2015let / const / 箭头函数 / Promise / class / 模块(革命性
ES2017async / await / Object.entries / Object.values
ES2018异步迭代(for await...of)/ 对象展开({...obj}
ES2019Array.flat / Array.flatMap / Object.fromEntries
ES2020?. 可选链 / ?? nullish 合并 / 动态 import / BigInt
ES2021逻辑赋值(??=)/ 数字分隔符(1_000_000)/ replaceAll
ES2022私有字段(#field / 顶层 await / Object.hasOwn
ES2023不可变数组方法toSorted / toReversed / findLast
ES2024Promise.withResolvers / Object.groupBy / Set 集合操作
ES2025+using 资源管理 / 装饰器(Stage 3)/ Iterator helpers

TC39 提案流程:理解"什么时候能用"

JavaScript 新特性的标准化经过 TC39 五个阶段:

Stage名称含义能在生产用吗
0Strawman想法❌ 完全不能
1Proposal提案❌ 完全不能
2Draft草案❌ 不应该
3Candidate候选(语法基本定型)⚠️ TypeScript / Babel 通常已支持
4Finished已纳入下一年标准✅ 浏览器陆续实现,可放心用

经验法则

  • 看到"Stage 4" → 安全用(确认目标浏览器版本)
  • 看到"Stage 3" → 工具链已支持,但谨慎(语法可能微调)
  • Stage 1-2不要用(提案可能被砍)

权威参考:TC39 提案列表(英文,所有提案的当前状态)。

三个判断维度:哪些能直接用、哪些需编译、哪些不要用

"Stage 4 = 标准" 不等于"你可以直接 ship"。实际生产能不能用还要看三件事

维度 1:浏览器兼容(最硬约束)

特性进入 Stage 4 后,浏览器实现还需要 6-18 个月才稳定——你的目标用户还在用 18 个月前的浏览器吗?

判断手段怎么用
Can I Use输入特性名,看主流浏览器从哪个版本开始支持
MDN 文档底部 BCD 表每个特性都列了浏览器支持版本
Browserslist 配置项目设定支持范围(如 > 0.5%, last 2 versions),构建工具按它编译
statcounter.com看你目标地区的浏览器实际市占率

维度 2:能编译降级吗(软约束)

很多语法特性即使浏览器不支持,也能通过编译降级(Babel / SWC / esbuild)转换成等价的旧语法。但不是所有都能降级——有运行时依赖的就不行。

类别能编译降级吗例子
纯语法糖✅ 能?. / ?? / 解构 / 箭头函数
可用 polyfill 模拟✅ 能Array.toSorted / Object.groupBy / Promise.allSettled
改变运行时语义的新对象⚠️ 看实现BigInt(部分能 polyfill)/ WeakRef
依赖底层引擎能力❌ 不能WeakRef / FinalizationRegistry / 顶层 await(运行时支持)
依赖宿主 API❌ 不能structuredClone / URL.canParse

维度 3:协议 / 工具链是否就位

某些特性虽然语法上能用,但配套工具链没跟上就不能在生产用:

特性风险
Stage 3 装饰器(新版)TS 5.0+ 才支持,老 TS 项目得先升级
using / await usingTS 5.2+,esbuild / SWC 需要近期版本
Import Attributes(with老 bundler 可能不识别——确认 esbuild / Vite / webpack 版本
顶层 await必须是 ESM 模块;CommonJS 项目无法用

实战判断流程

来了一个新特性,按下面顺序问自己:

  1. 它在 Stage 几? → Stage 1-2 不用,Stage 3-4 继续
  2. 目标浏览器原生支持吗? → 看 Can I Use → 全支持就直接用
  3. 不全支持的话,能编译降级吗? → 是 → 配 Babel preset / TS target → 用
  4. 降级不了? → 加 polyfill(core-js)或者等到目标用户都升了再用
  5. 有协议 / 工具链依赖? → 升级 TS / bundler / Node 版本到位了再用

经验:你不需要"用到所有最新特性"——?. / ?? / #field / 不可变数组方法 / satisfies 这五个就能解决日常 90% 的痛点,全部 Stage 4 + 全主流浏览器原生支持 + 工具链全就位。其他先观望。

core-js(@zloirock 维护)是事实上的标准 polyfill 库——Babel preset-env 默认用它。配置一次,几乎所有 ES 特性都能 polyfill 到 ES5。


1. ES2020 核心:可选链 + nullish 合并

这两个操作符是 ES2020 最重要的两个特性——几乎每段业务代码都用得上。

1.1 可选链(?.

解决了什么:访问可能不存在的属性时,避免一连串 && 守卫。

// ❌ 旧写法:层层 && 守卫
const street = user && user.address && user.address.street;
 
// ✅ 新写法:可选链
const street = user?.address?.street;

规则:当 ?. 左侧是 nullundefined 时,整个表达式短路返回 undefined(不报错)。

三种用法

// 1. 属性访问
obj?.prop;
obj?.["prop"];
 
// 2. 函数调用
func?.(); // func 不存在时返回 undefined,不报错
arr?.[0]; // 安全访问数组
 
// 3. 嵌套
user?.posts?.[0]?.comments?.length;

经典场景:可选回调

// ❌ 旧写法
if (callback) {
  callback(data);
}
 
// ✅ 新写法
callback?.(data);

1.2 nullish 合并(??

解决了什么:取默认值时,区分"null / undefined"和"假值"(0 / '' / false)。

// ❌ 旧写法:用 ||,假值都被替换
const port = config.port || 3000; // ❌ port = 0 也会被替换成 3000
 
// ✅ 新写法:用 ??,只在 null / undefined 时取默认
const port = config.port ?? 3000; // ✅ port = 0 保留 0

?? vs || 关键差异

表达式|| 结果?? 结果
0 ?? 'default''default'0
'' ?? 'default''default'''
false ?? 'default''default'false
null ?? 'default''default''default'
undefined ?? 'default''default''default'
NaN ?? 'default''default'NaN

经验法则

  • 取默认值时默认用 ??——除非你确实想把所有假值替换(如空字符串当无效)
  • || 仍然有用的场景:明确想把"falsy 值"全部当空(如 count || '暂无'

AI 时代踩坑高发:AI 经常写 || 默认值,导致 0 / '' 被替换。review 时检查 || 是不是该用 ??

1.3 ES2020 其他特性简提

// 1. Promise.allSettled —— 已在 §4.2 详解
// 2. globalThis —— 跨环境(浏览器 / Node / Worker)的统一全局对象
// 3. BigInt —— 超过 Number.MAX_SAFE_INTEGER 的整数
const big = 9007199254740993n; // 末尾加 n
big + 1n; // 9007199254740994n
 
// 4. 动态 import —— 已在 §4.3 详解
// 5. String.matchAll —— 全局正则匹配返回迭代器
const matches = "aaa".matchAll(/a/g);
[...matches]; // [['a'], ['a'], ['a']]

权威参考:MDN: 可选链MDN: nullish 合并


2. ES2021:逻辑赋值 + 数字分隔符

ES2021 主要是"小修小补"——四个特性都很实用,但都是"锦上添花"级别。

2.1 逻辑赋值(||= / &&= / ??=

解决了什么:把"判断 + 赋值"压缩成一个操作符。

// 旧写法 → 新写法
a = a || b;
a ||= b;
a = a && b;
a &&= b;
a = a ?? b;
a ??= b;

经典场景:默认值赋值

// ❌ 旧写法
function init(options) {
  options = options || {};
  options.timeout = options.timeout ?? 5000;
}
 
// ✅ 新写法
function init(options) {
  options ??= {};
  options.timeout ??= 5000;
}

2.2 数字分隔符(_

解决了什么:长数字难读。

// ❌ 难读
const billion = 1000000000;
 
// ✅ 易读
const billion = 1_000_000_000;
 
// 适用于所有数字字面量
const hex = 0xff_ff_ff;
const binary = 0b1010_0001;
const float = 3.141_592_65;

注意

  • 只是视觉提示,不影响解析(1_000 === 1000
  • 不能在小数点旁、字面量首尾、连续两个

2.3 String.prototype.replaceAll

解决了什么:全局替换字符串不再需要正则。

// ❌ 旧写法:要写正则 + g 标志
"hello world hello".replace(/hello/g, "hi");
 
// ✅ 新写法
"hello world hello".replaceAll("hello", "hi");

replace() 默认只替换第一个——这是早期 JS 的设计选择,现在 replaceAll() 补全了。

2.4 Promise.any

已在 §4.2 详解——第一个成功就返回。


3. ES2022 核心:私有字段 + 顶层 await

ES2022 的几个特性影响深远:私有字段终结了"用闭包模拟私有"的历史,顶层 await 改变了 ESM 模块初始化方式。

3.1 私有字段(#field

解决了什么:JS 第一次有了真正的私有成员——闭包 / TS private 都不再是首选。

class Counter {
  #count = 0; // # 开头 = 私有字段
 
  increment() {
    this.#count++;
  }
 
  get value() {
    return this.#count;
  }
}
 
const c = new Counter();
c.increment();
c.value; // 1
c.#count; // ❌ SyntaxError: Private field '#count' must be declared in an enclosing class

关键特性

  • 真正运行时私有——外部访问会直接语法错误
  • 与 TypeScript 的 private 不同(TS 的 private 只在编译期检查,运行时仍可访问)
  • 可以用 # 命名方法、static、accessors
class Foo {
  #privateField = 1;
  #privateMethod() {
    return "private";
  }
  static #privateStatic = "static";
  get #computed() {
    return this.#privateField * 2;
  }
}

与三种"私有"方案对比

方案真正私有?代码可读性何时用
命名约定(_field❌(仅约定)老代码 / 不在乎运行时安全
闭包(IIFE)ES6 之前 / 函数式风格
TypeScript private❌(只编译期)只用 TS 的项目(不需要运行时保护)
#field(ES2022)现代标准——首选

3.2 顶层 await(top-level await)

已在 §4.2 详解——ESM 模块顶层可以直接 await,不需要包在 IIFE 里。

// ✅ ESM 模块顶层
const config = await fetch("/config.json").then((r) => r.json());
export default config;

3.3 Object.hasOwn()

解决了什么:替代 Object.prototype.hasOwnProperty.call(obj, key) 的繁琐写法。

// ❌ 旧写法:要防原型污染
if (Object.prototype.hasOwnProperty.call(obj, "key")) {
  /* ... */
}
 
// ❌ 反例:直接调 obj.hasOwnProperty 不安全(对象可能没有原型)
if (obj.hasOwnProperty("key")) {
  /* ... */
} // 如果 obj = Object.create(null) 会报错
 
// ✅ 新写法
if (Object.hasOwn(obj, "key")) {
  /* ... */
}

经验:检查"对象自身是否有某属性"时,默认用 Object.hasOwn——更短、更安全。

3.4 Error cause(错误链)

解决了什么:包装错误时保留原始错误信息。

async function fetchUser(id) {
  try {
    return await fetch(`/api/user/${id}`);
  } catch (err) {
    // ❌ 旧写法:原始错误信息丢失
    throw new Error(`Failed to fetch user ${id}`);
 
    // ✅ 新写法:保留原始错误链
    throw new Error(`Failed to fetch user ${id}`, { cause: err });
  }
}
 
// 调用方
try {
  await fetchUser(123);
} catch (err) {
  console.error(err); // 主错误
  console.error(err.cause); // 原始 fetch 错误
}

何时用:分层错误处理——业务层包装底层错误时用 cause 保留原始信息,方便 debug。

3.5 at() 数组方法(负索引)

解决了什么:取数组最后一个元素更优雅。

const arr = [1, 2, 3, 4, 5];
 
// ❌ 旧写法
arr[arr.length - 1]; // 5
 
// ✅ 新写法
arr.at(-1); // 5
arr.at(-2); // 4

也支持字符串

"hello".at(-1); // 'o'

3.6 类静态块

解决了什么:类内部的初始化逻辑(特别是涉及 await / 复杂分支时)。

class Service {
  static #config;
 
  static {
    // 类静态块:类定义时执行一次
    if (typeof window !== "undefined") {
      this.#config = { env: "browser" };
    } else {
      this.#config = { env: "node" };
    }
  }
 
  static getConfig() {
    return this.#config;
  }
}

何时用:复杂的类静态初始化逻辑——简单赋值仍然用 static field = value

权威参考:MDN: 私有属性MDN: Object.hasOwnMDN: Error causeMDN: Array.prototype.at


4. ES2023:不可变数组方法

ES2023 引入了一组不变异原数组的新方法——这是 React / 函数式风格的重大福音。

4.1 老 API 的问题:会变异原数组

const arr = [3, 1, 2];
 
// ❌ 老 API:直接修改原数组
arr.sort(); // arr 变成 [1, 2, 3]
arr.reverse(); // arr 变成 [3, 2, 1]
arr.splice(0, 1); // arr 变成 [2, 1]

问题:在 React 里不能直接 setState——React 通过引用比较判断是否变化,原数组被修改但引用不变,React 不会重新渲染

// ❌ React 反例
const [items, setItems] = useState([3, 1, 2]);
 
function handleSort() {
  items.sort(); // 改了原数组,但引用没变
  setItems(items); // React 看引用相同,不渲染
}

4.2 新 API:返回新数组

ES2023 给四个变异方法都提供了"返回新数组"的版本:

老 API(变异)新 API(不变异)含义
arr.sort()arr.toSorted()排序
arr.reverse()arr.toReversed()反转
arr.splice()arr.toSpliced()删除 / 插入
arr[i] = xarr.with(i, x)修改某个索引

实战示例

const arr = [3, 1, 2];
 
// ✅ 新 API
const sorted = arr.toSorted(); // [1, 2, 3],arr 仍是 [3, 1, 2]
const reversed = arr.toReversed(); // [2, 1, 3],arr 仍是 [3, 1, 2]
const removed = arr.toSpliced(0, 1); // [1, 2],arr 仍是 [3, 1, 2]
const replaced = arr.with(0, 99); // [99, 1, 2],arr 仍是 [3, 1, 2]

React 友好的写法

// ✅ React 正例
const [items, setItems] = useState([3, 1, 2]);
 
function handleSort() {
  setItems(items.toSorted()); // 新数组,引用变了,React 渲染
}

4.3 findLast / findLastIndex

解决了什么:从后往前找元素。

const arr = [1, 2, 3, 4, 5];
 
// 找最后一个偶数
arr.findLast((n) => n % 2 === 0); // 4
arr.findLastIndex((n) => n % 2 === 0); // 3
 
// ❌ 老写法:先反转再找
[...arr].reverse().find((n) => n % 2 === 0);

何时用:日志 / 时间序列从后往前找。

4.4 兼容性

  • Node.js 20+ 全支持
  • 主流浏览器 2023 年起全支持
  • 旧浏览器需要 polyfill(core-js

权威参考:MDN: Array.toSortedMDN: Array.toReversedMDN: Array.findLast


5. ES2024:分组 + 集合操作 + Promise.withResolvers

ES2024 加了几个"早就该有"的标准 API。

5.1 Promise.withResolvers

解决了什么:手动构造可外部 resolve / reject 的 Promise。

// ❌ 旧写法:要把 resolver 函数赋值到外部变量
let resolveFn, rejectFn;
const promise = new Promise((resolve, reject) => {
  resolveFn = resolve;
  rejectFn = reject;
});
 
// 在另一个地方
resolveFn("done");
 
// ✅ 新写法
const { promise, resolve, reject } = Promise.withResolvers();
 
// 在另一个地方
resolve("done");

何时用:异步事件等待 / 自定义 Promise 控制流——比 new Promise(...) 更简洁。

5.2 Object.groupBy / Map.groupBy

解决了什么:数组按某属性分组——之前要手写 reduce。

const users = [
  { name: "Alice", role: "admin" },
  { name: "Bob", role: "user" },
  { name: "Carol", role: "admin" },
];
 
// ❌ 旧写法:手写 reduce
const grouped = users.reduce((acc, user) => {
  acc[user.role] = acc[user.role] || [];
  acc[user.role].push(user);
  return acc;
}, {});
 
// ✅ 新写法
const grouped = Object.groupBy(users, (user) => user.role);
// {
//   admin: [{ name: 'Alice', ... }, { name: 'Carol', ... }],
//   user: [{ name: 'Bob', ... }],
// }

Map.groupBy:分组键是对象 / Symbol 时用。

兼容性:Node 21+ / Chrome 117+ / Safari 17.4+——较新,旧环境需要 polyfill 或 lodash groupBy

5.3 Set 集合操作

新加了七个集合操作方法:

const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
 
a.union(b); // {1, 2, 3, 4}
a.intersection(b); // {2, 3}
a.difference(b); // {1}
a.symmetricDifference(b); // {1, 4}
a.isSubsetOf(b); // false
a.isSupersetOf(b); // false
a.isDisjointFrom(b); // false

何时用:替代之前的 [...new Set([...a, ...b])] 等丑陋写法。

5.4 Array.fromAsync

解决了什么:从异步迭代器构建数组。

// ❌ 旧写法
async function load() {
  const result = [];
  for await (const item of fetchStream()) {
    result.push(item);
  }
  return result;
}
 
// ✅ 新写法
const result = await Array.fromAsync(fetchStream());

权威参考:MDN: Promise.withResolvers(英文);MDN: Object.groupBy(英文)。


6. ES2025+:还在路上的特性

下面这些特性大多是 Stage 3,TypeScript / 现代 Node 支持,但生产代码请谨慎——语法可能微调,工具链支持不齐。

6.1 显式资源管理(using / await using

解决了什么:资源(文件句柄 / 数据库连接 / 锁)的自动释放。

// 文件句柄会在作用域结束时自动 dispose
{
  using file = openFile("data.txt"); // 类似 RAII
  // ... 使用 file
} // 离开块作用域时自动调用 file[Symbol.dispose]()
 
// 异步版本
{
  await using db = await connectDB();
  // ... 使用 db
} // 自动调用 await db[Symbol.asyncDispose]()

何时用:需要确定性资源释放的场景(替代 try/finally 模板代码)。

兼容性:TypeScript 5.2+ / Node 20.11+。业务代码暂时还不是首选——除非你的目标环境完全支持。

权威参考:MDN: using(英文);TC39 提案

6.2 装饰器(Decorators,Stage 3)

解决了什么:声明式给类 / 方法 / 字段附加行为。

function logged(originalMethod, context) {
  return function (...args) {
    console.log(`Calling ${context.name}`);
    return originalMethod.apply(this, args);
  };
}
 
class Service {
  @logged
  fetch() {
    // ...
  }
}

关键提醒

  • ES2024+ / TS 5.0+ 用的是新装饰器(Stage 3)——和老的 TypeScript 实验装饰器不兼容
  • NestJS / MobX 等流行库还在用老的 Stage 2 装饰器
  • 维护老库时不要混用——配置 experimentalDecorators 即用老的,不配置就是新的

生产采用建议

  • 新项目可以用 Stage 3 装饰器(如果你的工具链支持)
  • 维护用了 NestJS 等老装饰器库的项目,继续用老装饰器

权威参考:TC39 装饰器提案

6.3 Iterator helpers(迭代器助手)

解决了什么:让 iterator 支持链式 map / filter / reduce。

function* naturals() {
  let n = 1;
  while (true) yield n++;
}
 
const result = naturals()
  .filter((n) => n % 2 === 0)
  .take(3)
  .toArray();
// [2, 4, 6]

为什么有用:处理无限序列 / 流式数据(之前要手写 generator)。

兼容性:Stage 3,Node 22+ 实验性支持。

权威参考:TC39 提案

6.4 Records & Tuples(Stage 2,不要用

目标:原生不可变数据结构(深度不可变的对象 / 数组)。

// ❌ 还不能用!只是提案
const record = #{ x: 1, y: 2 };
const tuple = #[1, 2, 3];
 
record === #{ x: 1, y: 2 }; // true(值相等)

关键提醒

  • 只是 Stage 2 提案——还在讨论,可能被砍
  • 不要在生产代码里用——即使有 polyfill 也不建议
  • 如果需要不可变数据结构,Immer

权威参考:TC39 Records & Tuples 提案


7. 三档归位

知识点归位理由
可选链(?.必学业务代码每天都用
nullish 合并(??必学取默认值的现代标准;与"或运算符" || 区分
逻辑赋值(??= / 短路或赋值)必学简化"判断 + 赋值"模式
数字分隔符(1_000_000必学长数字必加,提高可读性
String.replaceAll必学替代正则全局替换
私有字段(#field必学现代 class 的真正私有
顶层 await必学ESM 模块初始化标配
Object.hasOwn必学替代 hasOwnProperty.call
Error cause(错误链)必学分层错误处理
Array.at(-1) 负索引必学取末尾元素更优雅
不可变数组方法(toSorted 等)必学React / 函数式必备
findLast / findLastIndex必学从后往前找元素
Promise.withResolvers仍需理解自定义异步控制流时用
Object.groupBy / Map.groupBy仍需理解兼容性较新(2024+);老环境用 lodash
Set 集合操作(union / intersection 等)仍需理解替代之前的展开运算丑陋写法
类静态块仍需理解复杂初始化时用,简单赋值不需要
using / await using仍需理解生产采用还需 1-2 年
装饰器(Stage 3)仍需理解TS 5.0+ 支持;新老装饰器不要混用
Iterator helpers仍需理解处理流式数据时用
Promise.allSettled / Promise.any仍需理解已在 §4.2 详解
异步迭代(for await...of仍需理解已在 §4.2 详解
Object.entries / Object.values / flat可委托AI 写得熟,知道存在即可
BigInt可委托业务很少用;处理超大整数(加密 / 大数据)时知道
Records & Tuples不学Stage 2,不要用

8. AI 时代写现代特性的最小集

最重要的 review 点

?? vs || 误用

AI 经常默认写 || 取默认值——但业务里 0 / '' 通常是合法值,应该用 ??

// ❌ AI 常生成
const port = config.port || 3000; // port = 0 被替换
 
// ✅ 你应改为
const port = config.port ?? 3000; // port = 0 保留

判断:业务里 0 / '' / false 是不是合法值?是 → 用 ??

不可变数组方法(React 友好)

AI 在 React 项目里仍然可能写老的会变异方法

// ❌ AI 生成
function handleSort() {
  items.sort();
  setItems(items); // 引用没变,不会渲染
}
 
// ✅ 你应改为
function handleSort() {
  setItems(items.toSorted());
}

私有字段不要混用 TS private#field

class A {
  private x = 1; // TS 私有(编译期)
  #y = 2; // 真正私有(运行时)
}
 
// ❌ 同一个项目里随机选,没有原则
// ✅ 团队约定:要么全用 TS private,要么全用 #field

经验:纯 TS 项目可以用 private(编辑器报错也够);需要真正运行时保护时用 #field


9. 翻车库

9.1 ?? 误用为 ||

症状:用户输入 0 / '' / false 被当成"无效值"替换。

修复:取默认值时用 ??,不用 ||(除非确实想替换所有假值)。

// ❌ 反例
const count = data.count || 10; // count = 0 → 被替换成 10
 
// ✅ 正例
const count = data.count ?? 10; // count = 0 → 保留 0

9.2 ?. 链式后做赋值

症状a?.b = 1 报语法错误。

原因:可选链不能用在赋值左侧——它是只读的。

修复:用普通条件判断:

// ❌ 反例
user?.profile.name = "Alice"; // SyntaxError
 
// ✅ 正例
if (user?.profile) {
  user.profile.name = "Alice";
}

9.3 私有字段 #x 不能动态访问

症状:想用变量名访问私有字段,报错。

原因#field词法绑定的,不能动态访问。

修复:用普通对象 / Map / 公共方法:

class A {
  #data = { x: 1 };
 
  // ❌ 反例:试图动态访问私有字段
  get(key) {
    return this.#[key]; // SyntaxError
  }
 
  // ✅ 正例:暴露 getter,内部走对象
  get(key) {
    return this.#data[key];
  }
}

9.4 装饰器跨版本不兼容

症状:新装饰器(Stage 3)和老装饰器(Stage 2,experimentalDecorators)混用导致错误。

原因:两套装饰器的运行时签名完全不同。

修复:项目级别选一种:

// tsconfig.json
{
  "compilerOptions": {
    "experimentalDecorators": false, // 用新装饰器(默认)
    // 或
    "experimentalDecorators": true // 用老装饰器(NestJS 等)
  }
}

经验已经用了 NestJS / MobX 的项目继续用老装饰器;新项目可以从新装饰器开始。

9.5 toSorted / toReversed 与老 sort / reverse 混用

症状:有时变异原数组、有时不变——团队代码风格混乱。

修复团队约定全用新 API——不变异原数组是默认。lint 规则可以禁用老的 sort / reverse

9.6 Object.groupBy 兼容性

症状:测试通过,生产环境某些用户报 Object.groupBy is not a function

原因:兼容性较新(Node 21+ / Chrome 117+),旧浏览器 / 旧 Node 不支持。

修复

  • 加 polyfill(core-js
  • 或用 lodash groupBy 兜底
  • 或在 package.json 限制 Node 版本
{
  "engines": {
    "node": ">=21.0.0"
  }
}

9.7 顶层 await 阻塞模块加载

症状:用了顶层 await 的模块拖慢整个应用启动。

原因:顶层 await 会阻塞依赖该模块的所有模块——加载链上的所有模块都要等。

修复

  • 慎用顶层 await——只在真正需要模块初始化时异步等待时用
  • 大部分场景下用 async function init() + 在使用方调用更好
// ❌ 反例:拖慢启动
const config = await fetch("/config.json").then((r) => r.json());
export default config;
 
// ✅ 正例:导出 Promise 让使用方决定何时等
export const configPromise = fetch("/config.json").then((r) => r.json());
 
// 使用方
const config = await configPromise;

10. 读完本节你应该能

ES2020 核心

  • 写出可选链(?.)的三种用法(属性 / 函数调用 / 数组)
  • 区分 ??|| 的差异,知道何时该用哪个
  • 给一段用 || 取默认值的代码,能识别 ?? 是不是更合适

ES2022 核心

  • #field 写真正的私有成员
  • 区分 #field / TS private / _field 命名约定的差异
  • 写顶层 await 加载配置模块
  • Object.hasOwn 替代 hasOwnProperty.call
  • 用 Error cause 包装错误时保留原始错误链

不可变数组方法

  • 列出四对会变异 / 不变异方法(sort/toSorted 等)
  • 在 React 里用新 API 替代老的会变异方法

ES2024 + 提案

  • 知道 Promise.withResolvers / Object.groupBy / Set 集合操作存在
  • 解释为什么 Records & Tuples 还不能用
  • 知道新装饰器和老装饰器(experimentalDecorators)不兼容

设计判断

  • 解释为什么不可变数组方法对 React / 函数式风格特别重要
  • 用"Stage 进度"判断一个特性能否在生产用
  • 解释为什么顶层 await 应该谨慎用

11. 延伸阅读

访问性说明:MDN 中文版直接可访问;TC39 GitHub 仓库英文但访问稳定。


下一节:§4.5 TypeScript 类型系统——结构化类型 / 泛型 / 类型收窄 / 工具类型。