现代JavaScript特性

梳理ES2015及后续JavaScript的实用特性:词法绑定、简洁的数据操作、迭代、异步流程,以及兼容性边界。

难度 进阶 时长 标准深度约11分钟
版本 Node 24
what

ES6是ES2015的旧称。“ES6+”通常泛指ES2015及后续版本;它们增加了语法和内置对象, 但没有替换JavaScript的核心模型。

trap

简洁语法可能藏起原有语义:const对象仍可修改,展开只做浅拷贝,可选链只保护明确写出的链, Promise也不会自行取消。

fix

根据语义选择特性,测试null、0和共享嵌套对象等边界值,并分别验证解析器支持与所需的 运行时API。

是什么,为什么存在

ES6是第六版ECMAScript的非正式名称。它在2015年标准化, 所以正式名称是ES2015。 ECMAScript版本(ECMAScript edition) 定义JavaScript语言; 浏览器和Node等运行时实现这份规范。“ES6+”没有精确的上限,通常包含ES2015以及后续每年加入的 async函数、对象展开、可选链和空值合并等特性。

重点不在于背一张新标点清单。现代特性为原有JavaScript语义 提供了更清楚的写法:块级绑定减少 var引起的意外交互;解构为值的各部分命名;迭代协议让不同集合共享遍历契约;Promise则把 异步工作的结果变成可组合的值。底层规则符合任务时,这些特性可以省去粘合代码。

当前应用代码、软件包源码、构建配置和生成代码中都会出现这层语言特性。 即使项目最终发布的是 转换后的产物,源码通常仍采用现代语法。目标运行时依然重要,因为解析器、语法转换、内置对象与 宿主API是四道不同的兼容性边界。

这个主题梳理这些边界以及跨越边界的常用特性。解构、集合、类、模块和异步控制流分别有专门主题 展开讲解。阅读本篇是为了看清它们怎样配合,以及哪些假设必须通过测试验证。

工作原理

现代JavaScript特性大致分为两类。语法变更影响源码的解析方式, 例如const、箭头函数、解构模式、 可选链和import声明。运行时新增内容则是程序调用的对象或方法,例如Map、Set、Promise和 较新的集合方法。有些提案会同时增加语法与运行时行为。

let和const创建词法绑定,作用域是最近的块。进入该块后、声明完成初始化前,两者都处于 暂时性死区。const禁止给绑定重新赋值,但不会冻结通过该绑定访问的对象。绑定应保持同一标识时 使用const;算法本来就需要重新赋值时使用let。

箭头函数缩短了函数表达式,并从外层词法环境捕获this。 它没有自己的this、arguments,也不能 作为构造函数。这使它很适合小型回调,却不适合机械地替换对象方法;对象方法的接收者可能需要由 调用位置决定。

解构是一种模式,不是通用的深拷贝操作。数组模式按顺序消费 可迭代对象 (iterable) ,对象模式则读取具名属性。默认值只在取出的值为undefined时执行。剩余语法 收集余下输入,展开语法则把输入展开到函数调用、数组字面量或对象字面量中。

数组展开遵循迭代协议。对象展开的规则不同,它把自有的可枚举字符串属性和Symbol属性复制到 新的普通对象。在两种常见容器中,结果都是 浅拷贝(shallow copy) : 嵌套对象的引用仍然共享。属性顺序也很重要,因为后出现的对象展开或属性定义会覆盖先出现的值。

当左侧值为null或undefined时,可选链使用 短路求值 (short-circuit evaluation) 。空值合并表达式left ?? fallback也只为这两个值选择回退。 left || fallback则不同,它还会替换0、false、空字符串和NaN。

Map、Set与迭代协议明确了集合行为。 Map可以用任意类型的值作为键,并在迭代时保持插入顺序。 Set按照SameValueZero相等规则为每个不同值保留一个条目。生成器通过包含yield的函数管理 迭代器状态,因此使用方可以通过for...of或展开语法逐项取得值。

Promise表示最终完成的结果。async函数总是返回Promise;await会暂停该函数,直到等待的值落定, 但不会阻塞JavaScript线程。Promise.all等组合器表达一组操作的处理策略。ECMAScript模块通过 明确的import和export建立关系,模块加载与软件包解析则由宿主运行时提供。

实际的兼容性检查分为四步:

  1. 解析器必须能识别源码语法。
  2. 语法转换必须保留代码依赖的语义。
  3. 运行时必须提供所需的内置对象和方法。
  4. 宿主必须提供DOM或Node文件系统模块等平台API。

构建成功只能证明该构建实际执行过的检查。它不能证明目标运行时具备每个内置对象,也不能证明 浏览器提供Node API,或转换后的代码保留迭代器关闭、属性访问顺序等可观察细节。

示例

词法绑定与简洁函数

这个示例组合了块级绑定、箭头函数、参数解构、默认参数、模板字符串和for...of。每项特性都省掉 少量样板,但数据流仍然清楚:taxRate是不会重新赋值的绑定,lowStockCount则会重新赋值。

bindings-and-functions.js
const products = [
  { sku: 'A-10', price: 12, stock: 2 },
  { sku: 'B-20', price: 8, stock: 0 },
];

const taxRate = 0.2;

const labelProduct = ({ sku, price, stock = 0 }, currency = 'EUR') => {
  const gross = price * (1 + taxRate);
  return `${sku}: ${gross.toFixed(2)} ${currency}, stock=${stock}`;
};

let lowStockCount = 0;
for (const product of products) {
  console.log(labelProduct(product));
  if (product.stock < 1) lowStockCount += 1;
}

console.log(`low stock: ${lowStockCount}`);
A-10: 14.40 EUR, stock=2
B-20: 9.60 EUR, stock=0
low stock: 1

参数模式说明了格式化函数会读取哪些商品字段。stock = 0默认值适用于缺失属性或显式的 undefined,但不适用于null。循环中的product使用const,因为每轮迭代都会创建一个绑定, 而循环体不会给它重新赋值。

规范化记录而不抹掉合法假值

边界代码经常需要分层设置默认值。解构处理缺失字段,可选链处理缺失的中间对象,??保留合法的 零值,对象展开让用户偏好覆盖默认偏好。函数返回新结果,不会修改输入。

normalize-customer.js
function normalizeCustomer(raw = {}) {
  const {
    id = 'guest',
    contact,
    preferences = {},
    retries = 3,
  } = raw;

  return {
    id,
    email: contact?.email ?? 'missing',
    preferences: { theme: 'light', ...preferences },
    retries,
  };
}

const customer = normalizeCustomer({
  id: 'U-7',
  contact: null,
  preferences: { density: 'compact' },
  retries: 0,
});

console.log(JSON.stringify(customer));
{"id":"U-7","email":"missing","preferences":{"theme":"light","density":"compact"},"retries":0}

显式的null联系信息会在contact?.email处停止,因此表达式得到undefined,再由??提供 missing。代码没有使用||,所以重试次数0得以保留。嵌套的偏好对象也是新对象,但其中若存放 其他对象,这些对象仍会共享。

用集合协议为事件建立索引

Map表示以用户ID为键的索引,每个Set则去掉同一用户的重复操作。生成器按需提供摘要。最后的 展开会把生成器消费成数组,这说明内置集合与自定义生成器采用相同的迭代协议。

index-events.js
const events = [
  ['U-1', 'view'],
  ['U-2', 'buy'],
  ['U-1', 'buy'],
  ['U-1', 'view'],
];

const actionsByUser = new Map();

for (const [userId, action] of events) {
  const actions = actionsByUser.get(userId) ?? new Set();
  actions.add(action);
  actionsByUser.set(userId, actions);
}

function* summarize(index) {
  for (const [userId, actions] of index) {
    yield `${userId}:${[...actions].join(',')}`;
  }
}

console.log([...summarize(actionsByUser)].join(' | '));
U-1:view,buy | U-2:buy

输出遵循Map键与Set值的首次插入顺序。再次为U-1添加view不会产生第二个Set条目,也不会 移动原条目。需要排序时应明确执行排序,而不是假设另一种集合类型会给出所需次序。

组合相互独立的异步工作

两次等待相互独立,因此Promise.all会在使用任一结果前观察两者。尽管商品Promise先落定, async函数恢复时拿到的结果数组仍按输入顺序排列。.mjs扩展名告诉Node 24把这个自包含文件 视为模块,因此文件可以使用顶层await。

load-order.mjs
const wait = (value, milliseconds) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds, value));

async function loadOrderSummary() {
  const [customer, items] = await Promise.all([
    wait({ name: 'Mira' }, 20),
    wait(
      [
        { quantity: 2, unitPrice: 6.25 },
        { quantity: 1, unitPrice: 5 },
      ],
      5,
    ),
  ]);

  const total = items.reduce(
    (sum, { quantity, unitPrice }) => sum + quantity * unitPrice,
    0,
  );

  return `${customer.name}: ${items.length} items, EUR ${total.toFixed(2)}`;
}

console.log(await loadOrderSummary());
Mira: 2 items, EUR 17.50

Promise.all会让结果位置与输入位置保持对应,落定顺序不会改变结果次序。任一输入拒绝时,组合后的 Promise也会拒绝。除非底层操作的API支持取消,而且代码明确提出取消请求,否则其他操作仍会继续。

陷阱

修复方法: 使用const表达绑定意图,再单独选择数据所有权策略。调用方需要快照时构造新对象; 状态本来就要变化时,通过范围明确的所有者公开修改,并测试嵌套引用。Object.freeze()也是浅层操作, 不能代替对嵌套值所有权的分析。

修复方法: 只处理undefined时使用解构默认值,null与undefined都表示缺失时使用??。 如果null表示格式错误而非缺失,应单独验证。不要只测试缺失属性,还要为领域允许的每个假值添加测试。

修复方法: 按明确的允许列表构造公开输出。在所有权必须改变的层级复制领域值,也不要假设展开会 保留原型、访问器或属性描述符。修改结果中的嵌套值,并向输入新增意外凭据,以这两种测试检查边界。

修复方法: 标出每个确实可选的跳转,例如account?.profile?.name,并决定缺失值应在哪一层变成 错误或回退。不要到处追加?.;必需字段仍应在验证时失败,不能悄悄传播undefined。

修复方法: 对支持协作式取消的操作传入AbortSignal,或者等必要的清理完成后再释放共享资源。 只有调用方确实需要每项结果时才选择Promise.allSettled;它改变的是报告策略,不是取消行为。

深入 版本、转换与运行时支持

版本、转换与运行时支持

ECMAScript 2015的改动范围很大,因此“ES6”这个简称一直沿用。此后的规范按年份发布。当前运行时 不会开启一个整体的“ES6模式”,而是实现来自多个版本的一组语法与运行时语义。本主题frontmatter中的 Node 24记录了可运行示例使用的环境,并不表示每个浏览器都具有相同支持。

版本名称回答的是某项语言特性在哪里标准化,却不能单独回答项目能否使用它。这个决定还取决于目标引擎、 文件的解析目标、构建转换和可用全局对象。即使JavaScript token本身有效,软件包元数据和文件扩展名 也可能改变Node把文件解析为ECMAScript模块还是CommonJS。

解析发生在特性检测之前

不支持的语法通常会在解析时失败。如果引擎无法解析同一文件其他位置的可选链,就没有机会运行 if (supportsOptionalChaining)之类的条件。构建工具可以先降低语法再交给目标,也可以向该目标发布 另一份文件。运行时特性检测只适用于引擎已经能够解析的值。

内置对象的失败方式不同。即使某个引擎没有Map,包含new Map()的源码也可能通过解析,随后在 执行该行时抛出引用错误。语法转换器不一定会安装全局对象或方法。项目如果使用polyfill,就必须让它 符合目标环境、加载顺序和应用所需语义。

简洁语法仍有可观察操作

源码更短不代表语义步骤更少。对象解构会读取属性,可能调用getter或Proxy trap。数组解构会请求 迭代器值,并可能在模式提前结束时关闭迭代器。对象展开会按规定顺序枚举符合条件的键并读取其值。 之后发生的失败不会回滚已经产生的效果。

这在信任边界尤其重要。解构本身不验证类型,可选链无法区分有意缺失的字段与格式错误的数据,对象展开 也不会定义输出schema。应把这些形式视为访问和构造语法,另行验证外部值,并单独列出公开字段。

模块会改变加载契约

ECMAScript模块拥有自己的顶层作用域,以严格模式运行,并且只通过明确的export暴露绑定。静态 import声明作为模块加载的一部分解析,不是普通函数调用。顶层await属于模块语法,所以宿主必须 先把文件识别为模块。

ECMAScript规范定义模块记录和语言行为,Node与浏览器则定义如何通过说明符定位资源。在打包工具中 能够解析的路径,直接交给Node时未必能解析。应验证实际部署入口、软件包元数据和import说明符, 不要根据编辑器的语法高亮推断模块行为。

兼容性声明需要可执行证据

有效的兼容性测试应该运行目标实际收到的产物。对于未经转换的Node代码,可以执行node entry.mjs。 对于打包后的浏览器应用,应使用生产目标配置构建,并在支持范围内最旧的引擎中打开产物。对源码文件运行 单元测试可以覆盖应用行为,却不一定覆盖部署时使用的解析器和加载器。

在兼容性声明旁记录目标和构建路径。“现代JavaScript”对于发布决策过于宽泛;“未打包的.mjs 入口可以在Node 24运行”则可测试。支持情况改变后,应重复同一命令或浏览器测试,而不是猜测上一位作者 所说的“ES6+”究竟指什么。

延伸阅读

检查点

4个问题 · 1道输出预测题 · 1道找错题

复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?