第 2 章

ES1 到 ES2027:每个版本解决了什么,TC39 提案 Stage 0-4

装饰器提案在2015年由 Yehuda Katz 提出,被认为是"明显有用、实现简单"的特性,却在2023年才正式进入 Stage 3——中途经历了两次完整的设计推倒重来。这不是拖延,这是 TC39 流程刻意设计的慢节奏保护机制。

🔹 Level 1 · 你需要知道的

当你看到某个语法,第一个问题应该是"这是哪年加的?我的目标环境支持吗?"下表是完整的版本时间线,关键版本用加粗标注。

ECMAScript 版本完整时间线

年份 版本 最重要的3个新增 里程碑
1997 ES1 语言基础、typeofnew 起点
1998 ES2 编辑修正 无新语言特性
1999 ES3 正则表达式、try/catchdo-while 奠定10年基础
2009 ES5 严格模式、Object.defineProperty、JSON 修复10年债务
2015 ES6/ES2015 let/const、箭头函数、classPromiseMap/SetSymbol、模块 最大版本更新
2016 ES2016 Array.prototype.includes** 幂运算符 年度发布开始
2017 ES2017 async/awaitObject.entries/values、共享内存 异步革命
2018 ES2018 异步迭代、Promise.finally、Rest/Spread 属性
2019 ES2019 Array.flatObject.fromEntries、可选 catch 绑定
2020 ES2020 ?. 可选链、?? 空值合并、BigIntglobalThisPromise.allSettled
2021 ES2021 String.replaceAllPromise.any、逻辑赋值 ??= &&= ||=
2022 ES2022 类私有字段 #、顶层 awaitArray.at()Object.hasOwn
2023 ES2023 Array.findLastArray.toSorted(不可变排序)、Hashbang
2024 ES2024 Object.groupByPromise.withResolversArrayBuffer.resize
2025 ES2025 Iterator.prototype helpers、RegExp.escapeSet 集合运算
2026 ES2026 Float16ArrayMath.sumPrecise 草案阶段
2027 ES2027 Temporal(日期时间 API)、Signals Stage 3 候选

快速记忆法:ES3(1999)打基础,ES5(2009)清债务,ES2015(2015)大跃进,ES2017(async/await),ES2020(可选链),ES2022(私有字段)。


6个你最常问的语法是哪年加的

// 箭头函数 → ES2015 (2015)
const add = (a, b) => a + b;

// async/await → ES2017 (2017)
async function fetchData() {
  const data = await fetch('/api/data');
  return data.json();
}

// 可选链 → ES2020 (2020)
const name = user?.profile?.name ?? '匿名';

// 空值合并 → ES2020 (2020)
const timeout = config.timeout ?? 3000;

// 类私有字段 → ES2022 (2022)
class Counter {
  #count = 0;                    // 私有字段,外部无法访问
  increment() { this.#count++; }
  get value() { return this.#count; }
}

// 顶层 await → ES2022 (2022)(仅在 ESM 模块中有效)
// config.js
const config = await fetch('/api/config').then(r => r.json());
export default config;

🔸 Level 2 · 它是怎么运行的

里程碑版本的设计动机

ES5(2009):修补 ES3 的10年历史债务

ES3发布于1999年,此后10年(2000-2009年),ECMAScript 规范几乎没有任何更新。原因:

  1. IE 垄断浏览器市场(峰值占有率超过90%),微软没有动力更新规范
  2. ES4 失败(2008年)消耗了大量政治资本
  3. 市场对 JavaScript 的定位仍然是"简单脚本语言",而非生产级语言

ES5 的三个核心修复:

// 修复 1:严格模式 — 移除最危险的特性
'use strict';
// 禁止的行为(非严格模式下静默失败,严格模式下报错):
x = 10;                      // 未声明变量 → ReferenceError
delete Object.prototype;     // 删除不可删除 → TypeError
function f(a, a) { }         // 重复参数 → SyntaxError
with (obj) { }               // with 语句 → SyntaxError

// 修复 2:Object.defineProperty — 属性层面的元编程
// ES3 时代无法控制属性是否可枚举、可写、可配置
// ES5 解决了这个问题
const person = {};
Object.defineProperty(person, 'name', {
  value: 'Alice',
  writable: false,      // 不可修改
  enumerable: true,     // for...in 可以看到
  configurable: false   // 不可删除或重定义
});

// 修复 3:JSON 内置 — 之前需要 Douglas Crockford 的 json2.js 库
// JSON 实际上是在2001年由 Crockford 发明的,2009年 ES5 才内置
const obj = JSON.parse('{"name":"Alice","age":30}');
const str = JSON.stringify(obj, null, 2); // 第3个参数是缩进

ES2015(2015):冻结7年后的爆发

ES4 失败后,TC39 在2009-2015年花了6年开发 ES6(ES2015)。之所以加了那么多特性,是因为积压了大量在 ES4 时代就被讨论过但没有落地的需求:

ES2015 主要特性来源:
┌─────────────────────────────────────────────────────────────┐
│  特性               │  设计参考           │  解决的问题       │
├─────────────────────┼────────────────────┼─────────────────┤
│  let/const          │  块级作用域         │  var 提升陷阱     │
│  箭头函数           │  CoffeeScript       │  this 绑定混乱    │
│  class              │  (原型链语法糖)     │  继承写法繁琐     │
│  Promise            │  Promises/A+规范    │  回调地狱        │
│  模块(import/export)│  CommonJS/AMD      │  全局命名空间污染  │
│  解构赋值           │  Python/Haskell     │  属性提取冗余     │
│  模板字符串         │  其他语言           │  字符串拼接丑陋   │
│  生成器             │  Python             │  惰性序列/协程    │
│  Symbol             │  (新类型)           │  唯一标识符       │
│  Proxy              │  Python __getattr__ │  元编程           │
│  Map/Set            │  Java/Python        │  Object作为Map的坑│
│  WeakMap/WeakRef    │  (新)               │  内存泄漏防止     │
│  迭代协议           │  (统一接口)         │  for...of 的基础  │
└─────────────────────┴────────────────────┴─────────────────┘

ES2017(2017):async/await 的本质

async/await 是 Promise 的语法糖,但其背后是生成器(Generator)的机制。理解这一点能帮助你调试复杂的异步代码:

// async/await 的脱糖形态(简化版)
// 原始 async 函数:
async function fetchUser(id) {
  const response = await fetch(`/api/users/${id}`); // 暂停点
  const data = await response.json();               // 暂停点
  return data;
}

// 等价的 Generator + Promise 版本(脱糖):
function fetchUser(id) {
  return new Promise((resolve, reject) => {
    const gen = (function* () {
      try {
        const response = yield fetch(`/api/users/${id}`);
        const data = yield response.json();
        resolve(data);
      } catch (err) {
        reject(err);
      }
    })();

    function step(value) {
      const result = gen.next(value);
      if (result.done) return;
      result.value.then(step, (err) => gen.throw(err));
    }
    step();
  });
}
// 这就是为什么 async/await 里的错误可以用 try/catch 捕获
// Generator 的 gen.throw() 把 reject 转化成了可捕获的异常

ES2020:可选链和空值合并的设计决策

// 可选链 ?. — 解决"深层属性访问"的空指针问题
// 旧式(ES5时代,jQuery源码里大量这样的代码)
var name = user && user.profile && user.profile.name || '匿名';

// ES2020
const name = user?.profile?.name ?? '匿名';

// ?. 的完整用法(不只是属性访问)
const result1 = obj?.method?.();      // 可选方法调用
const result2 = arr?.[0];            // 可选索引访问
const result3 = obj?.['key'];        // 可选动态属性访问

// ?? 与 || 的区别(这是一个常见混淆点)
const a = 0 || 'default';   // 'default' — 0 是假值
const b = 0 ?? 'default';   // 0        — ?? 只在 null/undefined 时触发

const c = '' || 'default';  // 'default'
const d = '' ?? 'default';  // ''       — 空字符串不是 null/undefined

ES2022:类私有字段 # 的设计哲学

class BankAccount {
  #balance = 0;           // 私有字段:# 是语法的一部分,不是命名约定
  #owner;                 // 私有字段:可以不初始化

  constructor(owner, initialBalance) {
    this.#owner = owner;
    this.#balance = initialBalance;
  }

  deposit(amount) {
    if (amount <= 0) throw new Error('金额必须为正数');
    this.#balance += amount;        // 内部访问 ok
    return this;
  }

  get balance() { return this.#balance; } // 只读公开接口
}

const account = new BankAccount('Alice', 1000);
account.deposit(500);
console.log(account.balance); // 1500
console.log(account.#balance); // SyntaxError: 外部无法访问

// 为什么用 # 而不是约定用 _ 前缀?
// _balance 只是命名约定,JavaScript引擎不强制,外部仍然可以访问
// #balance 由引擎强制执行,尝试在类外部访问会直接报 SyntaxError

TC39 提案流程:Stage 0-4

TC39 提案生命周期:
                                      ┌──────────────────────────────┐
                                      │  任何人都可以提交 Stage 0     │
                                      └──────────────────────────────┘
Stage 0 (Strawman / 想法)
  ├─ 进入条件:TC39 成员认为值得跟踪
  ├─ 文档要求:非正式描述
  ├─ 典型时间:数周到数月
  └─ 可能结果:进入 Stage 1 或被搁置

Stage 1 (Proposal / 正式提案)
  ├─ 进入条件:至少1个 TC39 成员愿意做 Champion(提案负责人)
  ├─ 文档要求:解决的问题、高层 API、示例代码
  ├─ 典型时间:数月到数年
  └─ 可能结果:进入 Stage 2、修改后重提、被废弃

Stage 2 (Draft / 草案)
  ├─ 进入条件:委员会认为该特性值得进入规范
  ├─ 文档要求:完整的规范文本草稿(可能不精确)
  ├─ 典型时间:数月到数年(高风险阶段)
  └─ 可能结果:进入 Stage 3 或倒退回 Stage 1

Stage 3 (Candidate / 候选)
  ├─ 进入条件:规范文本完整,至少两个独立实现
  ├─ 文档要求:完整精确的规范文本,指定评审人签字
  ├─ 典型时间:数月(等待更多实现和反馈)
  └─ 可能结果:进入 Stage 4 或倒退(极少)

Stage 4 (Finished / 完成)
  ├─ 进入条件:至少两个符合规范的独立实现、完整测试套件、编辑批准
  ├─ 文档要求:Test262 测试套件、正式规范 PR
  └─ 结果:进入下一年度 ECMAScript 版本发布

真实案例:Pipeline Operator 卡在 Stage 2

管道运算符(|>)是最典型的"卡在 Stage 2"案例。这个特性的核心用法很简单:

// 管道运算符的目标:让函数链式调用更易读
// 不用管道:
const result = JSON.stringify(Array.from(new Set([1,2,3,2,1])));

// 用管道(F# 风格 —— 其中一种提案):
const result = [1,2,3,2,1]
  |> new Set(%)    // % 是 "topic reference"(管道中的当前值)
  |> Array.from(%)
  |> JSON.stringify(%);

为什么卡了近10年(2015-2023+)?

Pipeline Operator 的分裂:
┌──────────────────────────────────────────────────────┐
│  阵营1:F# 风格(简洁)                               │
│  value |> fn                                          │
│  value |> fn(%, arg2)    // % 是 topic reference     │
│                                                       │
│  阵营2:Hack 风格(更灵活)                            │
│  value |> fn(%)                                       │
│  value |> %.method()                                  │
│                                                       │
│  阵营3:Smart 混合(最复杂)                           │
│  value |> fn             // 自动推断单参数            │
│  value |> fn(%, arg2)    // 显式多参数               │
└──────────────────────────────────────────────────────┘
三种语义不兼容,TC39 花了多年无法达成共识
最终2023年 Hack 风格成为主导方案,但仍在 Stage 2

🔺 Level 3 · 规范怎么定义的

TC39 章程与"如何添加新特性"

Ecma TC39 的正式章程(TC39 Charter)规定了委员会的决策机制:

"TC39 is a technical committee of Ecma International. It is responsible for editing and maintaining the ECMA-262 specification, which defines the ECMAScript programming language. TC39 operates by consensus: a proposal is accepted when no member objects to it, rather than by majority vote."

关键点:共识而非多数投票。这解释了为什么某些特性即使多数人支持,也可能因为一两个成员的强烈反对而停滞。

规范中关于提案接受标准的文字(来自 TC39 Process Document):

Stage 3 Entry Criteria:

  • The spec text must be complete and reviewed by designated reviewers and all ECMAScript editors.
  • All semantics, API, and syntax are finalized.
  • The ECMAScript specification editor has signed off on the current spec text.

Stage 4 Entry Criteria:

  • Two compatible implementations that pass the acceptance tests (which are part of Test262)
  • A pull request to tc39/ecma262 with the integrated spec text
  • All ECMAScript editors have signed off on the pull request

Test262 的重要性

Test262 是 ECMAScript 的官方测试套件,存储在 github.com/tc39/test262。进入 Stage 4 必须先有完整的 Test262 测试。这些测试也是 V8、SpiderMonkey、JavaScriptCore 验证实现正确性的基准。

"Don't Break the Web"在规范语言中的体现

ES5 规范的 Section 16(Errors)以及 Annex B 收录了 Web 兼容性要求。ES2015 规范在 Annex B 中加入了更多条目,明确标注了哪些特性是"为 Web 兼容性保留的历史遗产":

"The following features are specified for web browser implementations only. It is not part of the core ECMAScript specification. Conforming implementations of ECMAScript that are not web browsers are not required to implement these features."

这意味着 Node.js 理论上不需要实现 Annex B 的内容,但实际上它实现了大部分,因为很多 npm 包假设这些特性存在。


💎 Level 4 · 边界与陷阱

陷阱 1:装饰器提案的9年历程(2015-2023)

装饰器是 JavaScript 中最复杂的提案之一,经历了两次完整的设计重写:

装饰器提案时间线:
2015年 → Stage 0:Yehuda Katz(Ember框架核心成员)提出
          基于 Python 的装饰器设计
          TypeScript 1.5 提前实现(实验性)
          ↓
2016年 → Stage 1:进入正式提案
          ↓
2018年 → Stage 2:初版规范草案
          发现问题:与 TC39 的 "class fields" 提案语义冲突
          ↓
2019年 → 第一次重新设计
          "Legacy decorators"(TypeScript/Babel 已实现的版本)
          vs "Static decorators"(新版语义,不兼容旧版)
          委员会无法达成共识
          ↓
2021年 → 第二次重新设计
          "ECMAScript Decorators"(第三种语义)
          放弃与 TypeScript 实验版的兼容性
          ↓
2022年 → Stage 3:终于进入候选阶段
          ↓
2023年 → V8 12.0、SpiderMonkey 开始实现
          TypeScript 5.0 实现新版装饰器

三种装饰器语义的差异:

// TypeScript(旧式,实验性)装饰器 —— 2015-2022年广泛使用
// tsconfig: "experimentalDecorators": true
function log(target, key, descriptor) {
  const original = descriptor.value;
  descriptor.value = function(...args) {
    console.log(`calling ${key}`);
    return original.apply(this, args);
  };
  return descriptor;
}

class Service {
  @log              // 旧式装饰器语法
  getData() { return fetchData(); }
}

// ES2022 Stage 3 新版装饰器 —— 不兼容旧版!
function log(fn, ctx) {           // 不同的参数签名
  return function(...args) {
    console.log(`calling ${ctx.name}`);
    return fn.apply(this, args);
  };
}

class Service {
  @log              // 语法相同,但行为不同!
  getData() { return fetchData(); }
}
// 如果你的项目依赖旧式 TypeScript 装饰器,
// 升级到新版时需要重写所有装饰器

陷阱 2:Array.prototype.flattenArray.prototype.flat 的命名危机

2019年,Array.prototype.flatten 进入 Stage 3,实现已经发布,然后发生了"MooTools 危机":

// 1. MooTools(2006年发布的框架,仍有大量遗留使用)
// 在 Array.prototype 上定义了自己的 flatten 方法:
Array.prototype.flatten = function() {
  // MooTools 的实现,行为与规范草案不同
  return this.reduce((acc, val) => acc.concat(val), []);
};

// 2. 如果原生 flatten 方法被加入,会覆盖 MooTools 的方法
//    导致依赖 MooTools 的网站中 flatten 行为改变

// 3. 影响评估:
//    - npm 分析显示有数百万个网站仍在使用 MooTools
//    - 浏览器必须支持这些网站不崩溃

// 4. TC39 决定:将方法重命名为 flat
[1, [2, [3]]].flat();      // [1, 2, [3]] — 默认展开1层
[1, [2, [3]]].flat(Infinity); // [1, 2, 3]  — 展开所有层

// 类似地,flatMap 也保持了(不是 flattenMap)
[1, 2, 3].flatMap(x => [x, x * 2]); // [1, 2, 2, 4, 3, 6]

"MooTools 危机"的教训:即使是命名空间(Array.prototype)也会因遗留框架而受约束。这就是 Web Compatibility 原则的代价。

陷阱 3:"Don't Break the Web"如何阻止了显而易见的修复

案例 1:Array.prototype.containsArray.prototype.includes

原本这个方法叫 contains,2014年进入 Stage 3。然后发现 MooTools 也给 String.prototypeArray.prototype 加了 contains 方法,且行为不同。结果改名为 includes

// 现在是 includes(2016年 ES2016)
[1, 2, 3].includes(2);    // true
[1, 2, NaN].includes(NaN); // true — includes 用 SameValueZero 算法,正确处理 NaN
[1, 2, NaN].indexOf(NaN);  // -1  — indexOf 用严格相等,NaN !== NaN

案例 2:HTML 注释语法被迫保留

// 这段代码在现代 JS 引擎里仍然有效!
<!-- 这是 HTML 风格的注释
console.log('hello');
// --> 这也是注释

// 历史原因:1995年,不支持 JS 的浏览器会把 <script> 里的内容
// 当成 HTML 来解析,HTML 注释会隐藏 JS 代码
// 50年后的今天,删除这个特性会让某些遗留网站崩溃
// 所以 Annex B 保留了它

案例 3:0 前缀的八进制字面量(强制保留)

// ES3 时代的八进制写法(仍然在非严格模式下有效)
console.log(010);   // 8(八进制),不是10!
console.log(011);   // 9

// 在严格模式下已禁止(但非严格模式下仍有效)
'use strict';
console.log(010);   // SyntaxError: Octal literals are not allowed in strict mode

// ES2015 引入了明确的八进制语法(不存在歧义)
console.log(0o10);  // 8
console.log(0o11);  // 9

// 为什么旧语法还在非严格模式?
// 因为全球有无数个未使用严格模式的脚本文件中包含这种写法
// 修改会导致它们静默改变值,是高危操作

本章小结

  1. ECMAScript 从 ES1(1997)到 ES2025 经历了约30次更新,其中 ES3(1999)、ES5(2009)、ES2015 和 ES2017 是最重要的里程碑版本。ES2015 特性量大是因为 ES4 失败后积压了7年的需求。

  2. TC39 采用共识而非投票的决策机制,提案从 Stage 0(想法)到 Stage 4(发布)至少需要两个独立的符合规范的实现,以及完整的 Test262 测试套件,才能进入年度 ECMAScript 发布。

  3. 装饰器提案历时9年(2015-2023)才进入 Stage 3,核心原因是与类字段提案的语义冲突,以及 TypeScript 已实现了一个不兼容版本,导致需要推倒重来两次,且必须放弃与已有实现的兼容性。

  4. Array.prototype.flat(不是 flatten)是 Web Compatibility 原则的典型代价:MooTools 在 Array.prototype 上定义了同名但行为不同的方法,导致规范被迫改名。同样的故事还发生在 includes(原本叫 contains)等特性上。

  5. async/await 的底层是 Generator + Promise 的组合await 的每个暂停点对应一个 gen.next() 调用,错误通过 gen.throw() 传播,这解释了为什么 try/catch 能捕获 await 表达式抛出的错误。

本章评分
4.6  / 5  (101 评分)

💬 留言讨论