JavaScript 社区每隔一段时间就会冒出一批“新东西”,而比新东西更早出现在你时间线上的,往往是各种提案。做前端的人应该都感受过那种矛盾:一边是生产环境里写着 ES2020 时代的老代码,一边是 TC39 会议上刚讨论到一半、连语法糖都还没定下来的未来特性。这种“未来”和“现在”之间的拉扯,恰恰是 JavaScript 生态最有意思的地方。
这篇文章我想认真聊聊 JavaScript 的提案机制和近期值得关注的新特性。不是那种把 spec 搬运一遍的解读,而是站在一个普通开发者的角度,探讨哪些提案值得你提前了解、哪些正在改变我们的编码方式、哪些项目可能就诞生在某个提案的启发之下。内容适合所有 JavaScript 开发者,无论你是在写 React、Vue,还是做 Node.js 服务端,理解提案的推进规律,会让你对语言本身的演化更有掌控感。
1. 提案机制怎么说:从创意到标准的多级关口
1.1 TC39 与 Stage 0-4 的完整链路
ECMAScript 标准的演进并不是“某个大神提一个需求,然后开会通过”那么简单。负责管理 JavaScript 标准的委员会叫 TC39,它把每一个新特性从提出到落地划分成了五个阶段,也就是大家常说的 Stage 0 到 Stage 4。每个阶段都是一道关口,提案必须达到相应成熟度才有资格进入下一阶段。
- Stage 0(strawman,稻草人):任何人都可以提交的想法,可能只是几段粗糙的代码或一个简短的问题描述,没有任何 API 设计,甚至可能只是一个纯粹的“愿望”。
- Stage 1(proposal,提案):TC39 正式采纳这个方向,会有一个 champion(负责人)跟进,描述问题域、用例、潜在语义和难点。进入这个阶段,说明委员会认为它值得认真讨论。
- Stage 2(draft,草案):提案有了初步的语法和 API 设计文本,但细节还在变动。Stage 2 意味着委员会认可了整体方案,开始逐条打磨。
- Stage 3(candidate,候选):API 结构基本冻结,不再接受破坏性修改,重点转向收集实现反馈。这个阶段最值得开发者关注,因为后面改动会很小,你看到的基本就是最终形态。
- Stage 4(finished,定稿):提案已经通过测试套件,被多个实现支持,正式写入规范。下一版 ECMAScript 发布时它就是标准的一部分。
这个过程可以用一个粗糙的比喻来理解:Stage 0 像是你朋友在饭局上随口说的“要不我们做个 XX 功能”,Stage 1 是你决定认真立项,Stage 2 出了第一版详细设计方案,Stage 3 是图纸冻结、开始施工试运行,Stage 4 则是竣工验收。很多前端工程师只盯着最后的结果,却忽略了 Stage 2 到 Stage 3 之间才是影响语言走向的黄金讨论期。
1.2 为什么建议关注 Stage 3 而非 Stage 4
我的经验是,真正值得开发者花时间研究的,永远是 Stage 3,而不是 Stage 4。Stage 4 的特性虽然已经进入标准,但“进入标准”和“你代码里能用上”之间往往隔着 Node.js 版本、浏览器发布周期、构建工具版本好几道墙。相反,Stage 3 的提案已经冻结了 API,你完全可以通过 Babel 插件、Polyfill 或者 Nightly 版本的运行时提前体验。
更重要的是,Stage 3 阶段往往是讨论最充分、社区反馈最真实的时期。提案的 champion 需要收集各种实现方的意见,如果你发现某个 API 设计在实际场景里很别扭,现在提意见还来得及。等它变成 Stage 4,再提意见基本就是石沉大海。我见过不少开发者抱怨“这个 API 设计太怪了”,可他们根本没参与过 Stage 3 的反馈期,这种抱怨其实挺没必要的。
另外,TC39 现在的节奏已经变成了每年发布一版 ECMAScript 标准,也就是说 Stage 4 的提案通常会在一年内正式进入规范。到了 Stage 3,你拿到的 API 和一年后正式发布的内容基本没有差别,提前学习的时间成本很低,收益却很明确。
1.3 标准节奏背后的逻辑变化
早期 ECMAScript 的版本更迭很慢,ES5 到 ES6 隔了六年,ES6 到 ES7 又用了将近一年半。后来 TC39 调整了策略,把大型、复杂的特性拆成更小的独立提案,每个提案各自走自己的阶段,不再捆绑成大版本一次性发布。这种变化直接影响了我们接收新特性的方式:过去你要等一个“大版本”才能吃到一堆新语法,现在几乎每隔几个月就能在浏览器里看到一个新 API 落地。
这个“小步快跑”的模式也改变了工具链的地位。以前 Babel 是“提前使用未来特性”的代名词,现在仍然很重要,但原生实现追赶的速度明显加快了——V8 和 SpiderMonkey 的团队会紧盯着 Stage 3 提案,在提案冻结后很快就实现出来,这给了开发者更多选择。
2. 值得关注的提案:近期最值得花时间的几个方向
2.1 Decorators 装饰器:五年长跑终于走到关键阶段
如果你用过 Python 的装饰器、Java 的注解或者 C# 的 Attribute,那么 JavaScript 的 decorators 提案对你来说绝不陌生。它本质上是一种在类、方法、属性和访问器上附加逻辑的语法,可以让你在声明处直接包装、替换或配置目标对象。这个提案在 JavaScript 社区里折腾了至少五年,光是语义就推倒重来了好几次,目前处于 Stage 3。
新版装饰器语义和早期 TypeScript 的 experimentalDecorators 有很大区别:
- 装饰器不再是简单的“函数叠加”,而是通过
addInitializer来注册初始化逻辑,支持value/get/set等多种元数据; - 装饰器只能应用于类、类方法、类访问器、类字段,不能装饰普通函数或对象字面量;
- 静态块和私有元素也能被装饰,适用范围比以前更广。
举个简单例子,一个memoize装饰器可能是这样:
function memoize(fn, context) { const cache = new Map(); return function (...args) { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = fn.apply(this, args); cache.set(key, result); return result; }; } class Calculator { @memoize factorial(n) { return n <= 1 ? 1 : n * this.factorial(n - 1); } }这里memoize接收原方法并返回一个带缓存的封装版本。在框架层面,装饰器很适合做依赖注入、组件注册、权限标记、日志埋点这类横切关注点。实际开发中,Angular 和 NestJS 已经重度使用装饰器,只是它们目前还是基于 TypeScript 的 experimental 模式。标准装饰器一旦定稿并得到运行环境支持,这些框架的元编程能力会有一次明显的跃升。
从实操角度看,我现在不会建议在大型生产项目里大规模用标准装饰器,因为 Babel 的配置和 Stage 3 的语义细节都还需要磨合。但在规模可控的内部工具、小玩具项目里试试水,是了解它最佳方式。
2.2 Temporal:Date 的“继承人”终于要来了
JavaScript 的Date对象长期被吐槽,月份从 0 开始,时区处理困难,API 设计老旧,而且所有操作都是可变的——setMonth直接改原对象。如果你想做跨时区的日历计算,原生Date基本是一场灾难。Temporal 提案的目标就是彻底替换Date在日历和时钟领域的位置,提供一套更现代、不可变、支持多种日历体系的时间 API。目前处于 Stage 3,而且实现已经比较完整。
Temporal 的核心类型包括:
Temporal.PlainDate:纯日期,不含时间和时区;Temporal.PlainTime:纯时间,不含日期和时区;Temporal.PlainDateTime:不含时区的日期和时间组合;Temporal.Instant:时间戳,表示绝对时间点;Temporal.ZonedDateTime:带时区和日历的完整时间点;Temporal.Duration:时间段,支持年、月、日、小时、分钟、秒、毫秒等粒度;Temporal.Now:获取当前时间点的各种便捷方法。
Temporal 最大的亮点是不可变性和清晰的计算方法。比如你想给某天加一个月,不需要手动处理每个月的天数差异:
const date = Temporal.PlainDate.from('2024-01-31'); const nextMonth = date.add({ months: 1 }); console.log(nextMonth.toString()); // 2024-02-29同样的操作如果用原生Date,你需要自己判断目标月份有多少天。Temporal 还解决了“混用时区”的问题,ZonedDateTime明确包含时区和日历,不会默默做本地时区转换。
在实际项目里,我建议在涉及排班、日历、订单周期计算的场景优先考虑引入@js-temporal/polyfill来试用 Temporal。虽然它体积不小,但对于时间逻辑复杂的模块,它的可读性和正确性远胜自己手写日期计算。等未来原生支持普及,再把 polyfill 换成原生实现即可。
2.3 类型注解:JavaScript 与 TypeScript 的一次世纪和解尝试
这个提案比较特殊,目前还处在 Stage 1,由 TypeScript 团队提出,目标是给 JavaScript 增加类型注解语法,但把类型检查完全排除在运行时之外。通俗理解:你可以在 JS 文件里写let count: number = 42,JS 引擎会像忽略注释一样忽略: number这部分,类型检查交给外部的类型检查器(比如 tsc 或 type checker)完成。
这种设计一旦落地,意味着我们可能不再需要构建步骤来剥离类型,也用不着 TypeScript 编译器生成.js文件。Node.js 已经有实验性的--experimental-strip-types功能,Web 浏览器也可以直接运行带类型注解的 JS。它为什么会引起这么大关注?因为它直接关系到一个核心问题:JavaScript 生态到底是“继续依靠 TypeScript 做类型层”,还是“语言本身就原生支持类型语法”。
当然,这个提案要落地还有很长的路要走。类型注解只是语法层面的“外壳”,TypeScript 的核心价值在于类型系统和编译器对类型的深入分析。就算 JS 原生支持了类型注解,TS 的 utility types、泛型约束、条件类型等也不会原封不动搬到 ECMAScript 规范里。我个人的判断是:未来两到三年,我们会看到一个“JS 语法支持类型注解,TS 专注类型算法,边界越来越清晰”的格局。
从这个提案里能看出的趋势是,TC39 越来越重视开发者体验和生态兼容性,而不是像早期那样只关心语言理论。提案的 champion 会和 TypeScript 团队保持密切沟通,尽量确保新语法不会破坏现有的 TS 生态。
2.4 正则、异步迭代与底层 API 的小步进化
除了那些“改变世界”的大提案,TC39 还在推进一批小而有用的 API,它们单看不显眼,放在一起却能显著提升开发效率。
RegExp.escape提案(Stage 3)解决了一个常见痛点:我们需要把用户输入转义成正则字符串时,经常要手写/[.*+?^${}()|[\]\\]/g。这个提案直接提供RegExp.escape(str),语义清晰,不再容易写错。
Array.fromAsync已经进入 Stage 4。它跟Array.from类似,但从异步可迭代对象、Promise 数组等来源创建数组,并且对每个元素执行await。它在需要并发拉取多个异步数据源、然后统一处理结果的场景下非常顺手。
Promise.withResolvers也是已经进入 Stage 4 的提案。以前要拿到一个 Promise 的 resolve 和 reject 函数,必须写一个比较绕的包装器,现在可以这样:
const { promise, resolve, reject } = Promise.withResolvers();还有Uint8Array.prototype.toBase64()和fromBase64(),这个提案一旦落地,编码解码就会变得简单很多:
const bytes = new Uint8Array([72, 101, 108, 108, 111]); const b64 = bytes.toBase64(); console.log(b64); // "SGVsbG8="这类小提案的价值在于降低“随手写工具函数”的心智负担。很多时候我们写代码感到繁琐,不是逻辑复杂,而是基础 API 不够顺手。这些渐进式改进虽然不会成为技术新闻的头条,却会在几年的时间里默默改变你的日常编码体验。
2.5 更远期的探索:信号量、调度器与结构化克隆
如果把目光再放远一点,TC39 也在探索并发和调度相关的方向。scheduler提案希望给 JavaScript 提供一种更精细的任务调度机制,能区分用户交互优先级和后台任务优先级。想想现在的前端应用,大量图片加载、数据分析任务和用户点击事件挤在同一个事件循环里,全靠手写requestIdleCallback或setTimeout来“偷时间”,这显然不够优雅。
Scheduler如果正式进入标准,未来我们可能可以像这样表达优先级:
scheduler.postTask(() => { // 高优先级任务 }, { priority: 'user-blocking' });这类提案虽然短期内无法落地,但方向已经明确了:JavaScript 正在从一个“单线程事件驱动”的语言,逐渐走向“显式表达并发度和优先级”的平台级语言。这意味着前端不仅能写出更复杂的交互,还能有效治理渲染性能和后台任务之间的冲突。
我更想强调的是,并发和并行是两个不同的问题。Worker、SharedArrayBuffer 解决的是“并行”和“共享内存”,而 Scheduler、Promise 机制解决的是“并发调度”。未来两者会进一步结合,让数据密集型应用在浏览器里获得更接近原生应用的性能体验。
3. 实际开发中怎么尝鲜:配置、工具与直接能用的代码
3.1 用 Babel 提前跑起来
如果你想在项目里体验 Stage 3 的语法提案,Babel 几乎是最成熟的选择。以装饰器为例,你需要装这几个包:
npm install --save-dev @babel/core @babel/cli @babel/preset-env @babel/plugin-proposal-decorators然后在 Babel 配置里显式声明装饰器的版本语义:
{ "plugins": [ ["@babel/plugin-proposal-decorators", { "version": "2023-11" }] ], "presets": [ ["@babel/preset-env", { "targets": { "node": "current" } }] ] }这里最大的坑是版本参数。老版本 Babel 默认走的是legacy语义,也就是 TypeScript 的 experimentalDecorators 那套。如果你直接用默认配置,却写了新式装饰器的代码,会在编译阶段遇到各种奇怪报错。所以一定要显式指定version: "2023-11"。我踩过一次这个坑,当时把代码交给同事,他那边跑不动,最后发现是 Babel 的装饰器版本配置不一致。
3.2 通过 Polyfill 使用 Temporal
对于 Temporal 这类纯 API 提案,不需要语法转译,只要引入 Polyfill 就能在现有环境里跑。官方推出了@js-temporal/polyfill包,用法很直接:
npm install @js-temporal/polyfillconst { Temporal } = require('@js-temporal/polyfill'); const now = Temporal.Now.zonedDateTimeISO(); console.log(now.toString()); const duration = Temporal.Duration.from({ hours: 1, minutes: 30 }); console.log(duration.total('minutes')); // 90在浏览器端,你可以直接通过 CDN 引入 UMD 包,或使用 ES Module 方式导入。需要注意,Temporal 的 API 数量比较多,Polyfill 体积也不小,建议只在确实需要的时间处理模块里局部引入,不要全局扩散。而且 Temporal 提案的某些边界语义还在微调,虽然 API 结构已经冻结,但如果你做的是对时间精度要求极其苛刻的金融类应用,现阶段还是保守一点,先用成熟库如 dayjs 或者 luxon 更稳。
3.3 原生环境中直接可用的 Stage 4 新特性
除了通过工具链提前体验,很多 Stage 4 特性已经在现代运行时里原生可用了。拿Array.fromAsync举例,我在 Node.js 20+ 或最新浏览器里可以直接跑:
async function* asyncSequence() { for (let i = 0; i < 5; i++) { await new Promise((resolve) => setTimeout(resolve, 10 * i)); yield i * 2; } } const results = await Array.fromAsync(asyncSequence()); console.log(results); // [0, 2, 4, 6, 8]再比如Promise.withResolvers,写异步初始化逻辑时会舒服不少:
function waitForEvent(target, eventName) { const { promise, resolve, reject } = Promise.withResolvers(); target.addEventListener(eventName, resolve, { once: true }); target.addEventListener('error', reject, { once: true }); return promise; }这类新 API 的意义不在于把代码写得多么花哨,而在于减少样板代码。你不需要每次手动 new Promise 再手动把 resolve 函数丢到事件回调里了。
3.4 使用工具查提案状态:一条建议路径
尝鲜之前,先搞清楚提案到底处于哪个阶段,非常关键。我常用的几个渠道:
- 官方提案列表:
github.com/tc39/proposals,按阶段分组,更新及时; - TC39 会议纪要:每次会议后都会有详细的 notes 发布在 TC39 的官方仓库里;
- 浏览器兼容性查询:
caniuse.com、kangax.github.io/compat-table/esnext/可以看到各引擎的实现情况。
这几个渠道配合起来,你就能在几秒钟内判断一个“新特性”到底是“已经能用于生产”,还是“社区刚提出来的脑洞”,还是“已经凉透了的老黄历”。
4. 趋势解读:未来两到三年 JavaScript 会往哪里走
4.1 并发与调度:从“事件循环不够用”到“主动表达优先级”
前端应用越来越复杂,单线程事件循环逐渐成了性能瓶颈。以前我们用setTimeout拆任务、用requestAnimationFrame做动画、用requestIdleCallback做后台低优先级工作,这种“被动调度”不够直观,也很难配合现代浏览器的渲染流水线。
TC39 显然看到了这个空白。scheduler提案、AsyncContext(用于在异步任务之间传递上下文,比如 tracing ID)都在试着给运行时增加显式表达上下文和优先级的能力。这会带来两个改变:一是框架和浏览器之间会有更细粒度的协作,React 的并发渲染可能会更深入依赖这类原生能力;二是开发者能写出更“可预期”的复杂异步代码,而不是把一切交给引擎的任务队列去猜。
我个人的看法是,这个方向不会在短期内变成人人都会用 API,但它会影响“框架设计”和“运行时行为”。真正写业务代码的人可能很少直接调用 Scheduler,但框架会用它在合适的时间调度任务,最终提升你产品的流畅度。
4.2 类型与可读性:原生类型注解带来的生态变局
TypeScript 今天的地位已经无可替代,但它也有自身痛点:需要编译步骤、类型系统和运行时逻辑耦合在一起。原生类型注解一旦被正式接受,新的项目可能会天然支持“剥掉类型”的运行方式,构建链条可以被大幅简化。
这带来的替代并不是 TypeScript 的消亡,而是 TypeScript 的角色会变得更清晰。检查器、语言服务、编译器依旧存在,但它们不再是“必须放在构建流程前面”的不可分割工具。你可以写原生 JS + 类型注解,也可以继续用完整的 TS 和更丰富的类型构造。两个生态的分界线会逐步清晰:一个是“标准 JavaScript 语言”,一个是“TypeScript 超集语言”。
对前端工程化来说,这无异于一次减负。我见过大量团队被构建配置弄得焦头烂额,很大一部分都源于“把 TS 编译成 JS”这一步。如果未来浏览器和 Node.js 都能原生跑类型注解语法,这一步在很多简单场景下都可以省略。
当然,“趋势”不等于“马上能落地”。我还是建议保持在当前生产项目继续使用 TypeScript,但可以关注--experimental-strip-types之类的实验特性,用一些边缘小项目测试,不必激进迁移。
4.3 正交性与小提案模式
回顾近几年进入 Stage 4 的提案,你会发现一个规律:越来越少出现 ES6 那种“一把梭”的大特征,取而代之的是大量正交的、可独立使用的小 API 和语法糖。这种变化对开发者其实更友好,因为每个特性的学习成本更低了,框架和工具链也可以逐一适配,不至于因为一个大版本更新而被迫重构。
对新手来说,这种趋势也意味着“学习 JavaScript 语法”和“学习 API 标准”这两件事越来越并驾齐驱。过去的 JavaScript 手册可能只有几百页,现在官方规范已经厚得像字典。你不需要背下所有 API,但应该学会怎么快速查找和判断状态。
4.4 前端开发者如何跟上趋势:一套低成本学习路径
我说一下自己长期坚持的方法:每个季度花半天时间扫一遍 TC39 提案列表,重点关注所有 Stage 3 的条目;挑出和手头项目相关的,写一个最小可运行的 Demo;然后再去看相关的 polyfill 和原生实现情况。这个方法成本很低,但能让你始终处在“提前一两个版本理解语言”的位置。
不要试图追每一个提案。对于大多数业务开发者,真正值得深入理解的是那些会改变框架写法或生态系统格局的提案(比如装饰器、类型注解、Temporal),以及那些能减少样板代码的小 API。其余的阶段 0/1 提案看看标题就行,深入了解很容易被各种废弃设计消耗时间。
5. 常见疑问与避坑记录:那些被误解的“未来特性”
5.1 Polyfill 和语法转译不是一回事
这是我认为最需要被纠正的误区。很多新手看到某个提案说“可以用 polyfill 实现”,就在所有代码里直接引用,但 polyfill 只能覆盖“API 形态”的特性,比如Array.fromAsync、Promise.withResolvers这类运行时方法。对于“语法形态”的特性,比如装饰器、管道运算符、可选链(当年的?.),polyfill 是无能为力的,必须借助 Babel、Sweet.js 这类工具在编译阶段转换成目标环境能理解的语法。
换句话说,判别方法很简单:如果提案引入的是一个“函数”或“对象方法”,大概率能 polyfill;如果提案引入的是一个“新的语法符号”,就百分百需要编译工具参与。做反了,代码在本地跑得通,到生产环境就炸,而且报错信息往往很误导人。
5.2 怎么判断网上教程说的是不是过时信息
JavaScript 相关教程的过时速度可能是所有编程语言里最快的。两年前写“装饰器即将发布”的文章,到今天语义已经变了;三年前说“Temporal 快定稿了”,到今天还在 Stage 3。我踩过最典型的一个坑是照着旧教程配置 Babel 装饰器插件,结果version配置名都不存在,编译直接崩。
判断教程是否过时的最靠谱方式,就是去官方提案仓库核对 Stage 状态。如果你看的教程基于 Stage 2 或更早的设计,里面一半代码可能都是错的。真正落地到生产环境前,以 Stage 3 之后的设计为准。另外可以看一眼文章发布时间和它引用的 proposal 链接,如果链接已经 404,基本上可以当作历史资料来读。
5.3 那些年我们一起追过、最后凉了的提案
提案多了,自然也有不那么顺利的。最典型的是Object.observe(),当年曾被 Chrome 部分实现,也出现在很多响应式框架的前瞻文章里,后来因为性能和语义问题被正式撤回。另一个例子是“尾调用优化”(TCO),ES6 正式定义了它,但各家引擎出于调试困难和栈追踪性能的考虑,大多没有完整实现,到现在你在实际编码中依然很难依赖它。
这提醒我们一个残酷的事实:提案不是承诺。TC39 有权利在任何一个阶段撤回或者大幅修改提案。这也是为什么我在很多地方强调“学习和使用要分开”:你可以积极了解所有 Stage 3 提案,但生产环境里的核心依赖一定要等生态足够成熟,否则就会成为那些凉掉的提案的陪葬品。
5.4 一些我可以直接复用的避坑清单
- 装饰器版本配置:Babel 里必须显式指定
"version": "2023-11",否则默认 legacy 语义会让你写出过时代码; - Temporal 导入路径:不同打包工具和 Node 环境对
@js-temporal/polyfill的支持有差异,注意按项目模块系统选择入口; - 现代浏览器兼容性:Stage 4 特性在各浏览器和 Node 版本里的落地时间可以差一年以上,用 polyfill 或垫片统一处理更省心;
- 不要在业务核心里占坑:提案 API 虽然诱人,但它的语义存在微小变动可能,核心支付、关键数据链路里还是选择稳定库更稳妥。
6. 写在最后:我的实践体会与后续建议
我在不少项目里尝试过各种“未来特性”,有成功的,也有翻车的,但我始终觉得,JavaScript 的可预测性恰恰藏在这种提案机制里。它不像很多闭源生态那样突然丢一个不兼容的大版本,而是通过公开讨论、分阶段推进、工具链预演,让你有充足时间预判变化、准备迁移。
我个人目前最关注三件事:标准装饰器大规模进入前端框架、Temporal 的正式定稿与原生实现,以及类型注解提案能否在 Node.js 的 strip-types 实验中被验证。这三件事无论哪一件正式落地,都会显著改变我们常见的编码模式。
如果你也想跟进,我还真有一个特别推荐的“小技巧”:建立一个文件夹,专门放各种提案的 minimial reproduction,每看到一个有意思的 Stage 3 提案,就写一个不超过 20 行的示例代码,记录它解决的问题、带来的新写法和可能的坑。几年下来,这个文件夹就是你理解 JavaScript 演进的最佳个人资料库。
JavaScript 的演进不会停下,但好在 TC39 给了我们一个足够清晰的路标。未来的特性会一批批从提案变成现实,关键是你要站在正确的时间点去认识它们。