1. 先给 ES9 画个像:为什么这一版值得重看一遍
ES9 是 ECMAScript 2018 的另一种叫法,2018 年 6 月正式定稿。和 ES6 那种跨时代的大版本不一样,ES9 这版特性不算多,但每一刀都切在“工程实用”上。很多人当年扫一眼就过去了,等到真正写异步拉取、处理正则、搞对象拷贝的时候,才发现这些新特性其实每天都在用,只是没意识到它们来自 ES2018。
这一版最核心的东西有八项:异步迭代器和for await...of、对象展开与剩余属性、Promise.prototype.finally、正则的四个增强(dotAll 标志、命名捕获组、后行断言、Unicode 属性转义),还有模板字符串的语法修订。单看每一项都不复杂,但组合起来解决的实际问题比 ES7 的async/await、ES8 的Object.entries要更贴近业务细节。
这篇文章把这些特性拆开讲,每个都带使用场景、代码示例、原理说明和踩坑记录。哪些浏览器支持,Babel 转译要注意什么,老项目升级会不会出问题,读完心里会有数。适合写业务代码的前端、Node.js 后端,以及所有需要处理流式数据或者复杂字符串匹配的开发者。
| 特性 | 一句话概括 | 主要受益场景 |
|---|---|---|
| 异步迭代器 | 用for await...of逐个消费异步数据 | 分页接口、文件流、数据库游标 |
| 对象 rest/spread | 对象也能展开和解构收集剩余属性 | 数据拷贝、剔除字段、函数传参 |
| Promise.prototype.finally | 无论成功失败都执行的善后回调 | loading 状态、资源释放 |
| 正则 dotAll | .终于能匹配换行符 | 跨行日志、HTML 注释匹配 |
| 正则命名捕获组 | 分组有名字,不用数括号了 | 复杂正则可读性提升 |
| 正则后行断言 | 匹配前面符合条件的位置 | 提取带前缀的数字、排除前缀 |
| Unicode 属性转义 | 按字符属性匹配,如中文、标点 | 多语言场景、文本清洗 |
| 模板字符串修订 | 标签模板可处理非法转义序列 | DSL、SQL 模板、Markdown 解析 |
从这个表能看出来,这版特性有几个共同点:都是补短板,都是写业务时高频需要的,而且大部分是“语法糖 + 内置能力”的组合。这意味着它不需要太重的运行时支持,只要转译或者说运行环境达标,收益是立竿见影的。
2. 异步迭代器:处理“异步的数据流”而不是“异步的数据”
2.1 为什么for...of拿异步数据不行
先问一个问题:如果你有一个 Promise 数组,直接for...of遍历,拿到的是 Promise 本身而不是数据。你当然可以在循环体里await,但这样代码写起来就别扭,而且for...of是同步迭代协议,它不知道你每次迭代实际上要等一段时间。
ES2018 给出的方案是引入异步迭代器协议:对象上挂Symbol.asyncIterator方法,返回一个每次调用next()都返回 Promise 的迭代器。配套的语法是for await...of,循环每次会自动等待上一个 Promise resolve,拿到真实值后再进入下一轮。
看一个最基础的生产者:
async function* createCounter(limit) { for (let i = 1; i <= limit; i++) { yield await Promise.resolve(i); } } (async () => { for await (const num of createCounter(3)) { console.log(num); // 依次输出 1 2 3 } })();这里有两个关键点。第一,async function*声明的是异步生成器,它内部可以用await也可以用yield,每次yield的值会被自动包装成 Promise。第二,for await...of会把循环体内的逻辑变成一次等待一个,天然串行。
很多刚接触的人容易把for await...of和Promise.all搞混。前者是逐个处理,后者是并发等待所有结果。它们的适用场景完全不同:分页拉数据要按顺序串行,批量请求没有依赖关系就适合用Promise.all并发。这个区分很重要,后面实战里会再展开。
2.2 三个典型场景:分页拉取、逐行读文件、流式数据处理
场景一:分页接口拉取所有数据
最常见的需求是某个接口限制了单次返回条数,你要把全部数据拉下来。用异步生成器写,代码会非常直观:
async function* fetchAllPages({ pageSize = 100, maxPage = 10 } = {}) { let page = 1; let hasMore = true; while (hasMore && page <= maxPage) { const { list, total, page: current } = await request(`/api/items?page=${page}&size=${pageSize}`); yield list; hasMore = page * pageSize < total; page++; // 如果想在拉取过程中提前终止,调用方 break 就会触发这里的 return } } (async () => { for await (const pageList of fetchAllPages()) { for (const item of pageList) { // 处理每一条数据 } } })();这段代码的好处是拉取逻辑和处理逻辑完全解耦。生成器内部管翻页、管终止条件,调用方只关心拿到的每一页数组。如果想中途停止,直接在for await里break,生成器内部会自动进入 finally 块,执行清理逻辑。
场景二:逐行读取大文件
Node.js 里读大文件,不能一次性readFile读进来,内存受不了。传统做法是用readline模块配合事件监听,写起来比较啰嗦。Node 10 之后readline.createInterface返回的对象支持异步迭代,可以像读数组一样逐行读文件:
const readline = require('readline'); const fs = require('fs'); async function processLogFile(path) { const rl = readline.createInterface({ input: fs.createReadStream(path), crlfDelay: Infinity, // 兼容 Windows 的 \r\n }); for await (const line of rl) { if (line.includes('ERROR')) { // 只处理错误日志,内存占用恒定 } } }这个写法在数据处理脚本里非常实用。文件几百 MB 甚至几个 GB 都没关系,因为每一行处理完就丢弃,内存峰值只有一行的数据量。
场景三:直接把 Node.js Stream 当异步迭代器用
Node 的Readable流从 Node 10 开始也实现了异步迭代器接口。这意味着你可以这样读取请求体或者文件内容:
async function readRequestBody(req) { let body = ''; for await (const chunk of req) { body += chunk.toString('utf-8'); } return body; }如果是自己写流,可以借助stream.pipeline配合异步生成器做数据转换。这个组合在写中间件、做数据清洗的时候非常省事。
2.3 这里有几个坑,提前告诉你
坑一:break不一定会触发生成器的 finally
理论上for await里break会让异步迭代器调用return()。但如果你用的是自定义异步迭代器而不是async function*,return()可能没有实现,那么清理逻辑就不会执行。所以自定义异步迭代器时,最好补上return()方法。
坑二:异步迭代器里的错误会直接跳出循环
for await中如果某个next()reject 了,循环体会直接抛出异常。想要容错,得在循环体里自己try/catch,或者生成器内部就把错误吞掉。
坑三:不是所有环境都原生支持
Node.js 10 开始有Symbol.asyncIterator,但实现不完全稳定;Node 12 之后才比较可靠。浏览器方面 Chrome 63 起支持,Firefox 57 起支持,Safari 的完整支持比较晚。写代码之前先确认目标环境,否则就要靠 Babel 转译。
3. 对象展开与剩余属性:最被低估的语法糖
3.1 从“剔除字段”开始说起
ES2015 给了数组的展开和函数的剩余参数,但对象一直没跟进。有了对象展开和剩余属性之后,很多以前要写好几行Object.assign或者手动删属性的操作,一行就搞定了。
最经典的用法是“剔除字段”:
const { password, ...safeUser } = user; console.log(safeUser); // 不包含 password 字段这个模式在接口返回数据脱敏、日志脱敏、表单提交前剔除多余字段时高频使用。以前要做这一步,得先解构出要删的字段,再拿到剩下的。现在写法直接、意图清晰,而且不会修改原对象。
对应的,对象展开可以快速做浅拷贝:
const config = { retry: 3, timeout: 5000 }; const newConfig = { ...config, retry: 5 }; // 覆盖默认值展开和覆盖的顺序是:后面的覆盖前面的。这个特性在做配置合并的时候非常顺手,比Object.assign({}, config, patch)可读性高不少。
3.2 浅拷贝这个坑,一定要牢记
对象展开做的是浅拷贝,这一点必须反复强调。展开语法只会复制一层引用,对象内部的嵌套对象仍然是同一个引用。改内部属性,原对象也会跟着变:
const original = { user: { name: '张三' }, tags: ['a', 'b'] }; const copy = { ...original }; copy.user.name = '李四'; copy.tags.push('c'); console.log(original.user.name); // '李四' console.log(original.tags); // ['a', 'b', 'c']这就是所谓浅拷贝。需要深拷贝的场景,要么用structuredClone,要么自己写递归克隆,要么借助工具库。JSON.parse(JSON.stringify(obj))也能用,但有函数、undefined、循环引用时会出问题,不在生产环境推荐。
还有个冷门但容易踩的坑:对象展开会忽略undefined和null值。也就是说{ ...null }不会报错,也不会多出属性,结果是一个空对象。这个特性在某些框架代码里会被利用来做条件展开:
const condition = isAdmin ? { admin: true } : null; const userInfo = { name: '张三', ...condition }; // 如果 isAdmin 为 false,userInfo 里就没有 admin 字段这种写法简洁,但可读性一般。团队协作时最好加注释说明意图,不然别人可能看不出这是个条件逻辑。
3.3 与 Object.assign 的对比,什么时候用哪个
对象展开和Object.assign功能上接近,但有几个差异值得注意:
| 对比项 | 对象展开 | Object.assign |
|---|---|---|
| 可读性 | 更直观,意图一目了然 | 需要理解第一个参数是目标对象 |
| 触发 setter | 不会触发源对象 getter,会触发目标对象的 setter | 会触发目标对象的 setter |
| 处理 null/undefined | 直接忽略,不会报错 | 会报错,必须先判空 |
| 拷贝属性 | 只拷贝可枚举属性 | 只拷贝可枚举属性 |
日常开发我更推荐对象展开。它写起来短、不出错,而且语义明确。Object.assign唯一的好处是兼容老环境,现在基本可以不用了。
4. Promise.prototype.finally:善后逻辑的正确归宿
4.1 finally 和 then 的差异,一句话讲明白
Promise.prototype.finally是 ES2018 给 Promise 补的最后一块拼图。它解决的问题是:不管 Promise 最终是 fulfilled 还是 rejected,都执行一段清理逻辑。
then也能做类似的事,但要写两遍:
promise .then(data => { hideLoading(); console.log(data); }) .catch(err => { hideLoading(); console.error(err); });用finally之后:
promise .finally(() => hideLoading()) .then(data => console.log(data)) .catch(err => console.error(err));finally的回调不接收任何参数,它不知道 Promise 成功还是失败。它的返回值也不影响 Promise 链的传递——finally返回的非 Promise 值会被忽略,原来then和catch拿到的数据照旧。这个“不干预结果”的特性,正是它适合做清理逻辑的原因。
不过有一个例外:如果finally回调里抛出了异常,或者返回了一个 rejected 的 Promise,那么这个异常会替代原有的结果,继续往下传递。这个行为要小心,它跟try...catch...finally里 finally 抛错一个道理,会覆盖原本的错误信息。
4.2 拿 loading 状态做例子,代码怎么写
最常见的场景是请求结束关闭 loading。如果不用finally,每次请求都要在成功和失败的分支里各写一遍关闭代码,非常容易漏。用finally以后,逻辑就集中在了一处:
async function fetchData() { showLoading(); try { const data = await request('/api/data'); render(data); } finally { hideLoading(); } }这里try...finally的写法比try...catch...finally更推荐。原因很简单:错误应该交给调用方处理,而不是在这个函数里吞掉。finally只负责收尾,错误继续向上抛,由更上层的错误边界统一处理。
除了 loading,finally还能用在很多地方:关闭数据库连接、释放文件句柄、清空定时器、恢复按钮点击状态、重置表单提交状态。这些场景的共同点是“无论成功失败都要做”,原来写两遍的地方,现在写一遍就够了。
4.3 一个容易忽略的性能细节
finally和then一样,返回的是一个新的 Promise。如果你在finally回调里返回一个 Promise,这个 Promise 会被等待,但其结果不会传给后续。这个特性可以用作“延迟清理”,比如请求完了等动画播完再隐藏 loading:
promise .finally(() => new Promise(resolve => setTimeout(resolve, 300))) .then(data => console.log(data));但注意,这里的等待时间会让 Promise 链整体变慢。如果这个 Promise 后面还有超时控制,要一并考虑进去。我在实际项目里就踩过这个坑:在finally里加了一个 500ms 的动画等待,结果下游的超时判断提前触发了,排查了半天才找到原因。所以finally里尽量不要做耗时操作,保持它“快进快出”的属性。
5. 正则四项增强:从“能写出来”到“写对”
5.1 dotAll 标志:.终于能匹配换行符
以前正则里的.默认匹配除换行符以外的任意字符。想匹配跨行的内容,只能写成[\s\S]或者[^],又丑又绕。ES2018 的 s 标志(也叫 dotAll)解决了这个问题:
// 匹配 HTML 注释,注释可能跨行 const result = '<!-- 第一行\n第二行 -->'.match(/<!--.*?-->/s);比如在解析日志、处理 HTML 片段时,跨行匹配是刚需。加上s标志后,.就等价于“任意字符”,包括换行。注意这个标志和m(多行模式)完全不是一回事:m影响的是^和$锚点的行为,让它们按行匹配;s只影响.是否匹配换行,两者互不冲突,可以同时使用。
5.2 命名捕获组:不用再数括号了
以前捕获组用数字编号访问,match[1]、match[2]。表达式一复杂,括号一多,数错编号是家常便饭。ES2018 让捕获组可以命名,用(?<name>...)的语法:
const pattern = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/; const match = '今天日期:2024-03-15'.match(pattern); console.log(match.groups.year); // '2024' console.log(match.groups.month); // '03' console.log(match.groups.day); // '15'使用replace时也有对应的语法,在替换字符串中用$<name>引用:
const dateStr = '2024-03-15'; const formatted = dateStr.replace( /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/, '$<day>/$<month>/$<year>' ); console.log(formatted); // '15/03/2024'命名捕获组还有一个额外的好处:正则表达式自文档化了。别人读你的正则,不用靠注释解释第几个括号是什么,直接看名字就懂。
这里有个容易踩的坑:同一个正则里,捕获组名称不能重复。写(?<id>\d+)后再写(?<id>\w+)会直接报错。另外,命名捕获组和普通捕获组可以混用,但混用时数字编号会重新排列,要注意别把顺序搞混。
5.3 后行断言:向前看和向后看,终于齐了
JavaScript 早就支持先行断言(lookahead):(?=...)表示后面必须跟着什么,(?!...)表示后面不能跟着什么。但一直缺后行断言(lookbehind)。ES2018 把这补齐了:
(?<=...):前面必须是这个模式(?<!...):前面不能是这个模式
常见的需求是提取带特定前缀的数字:
const price = '价格是 $100,折扣价 $80'; const prices = [...price.matchAll(/(?<=\$)\d+/g)].map(m => m[0]); console.log(prices); // ['100', '80']这里的(?<=\$)断言当前位置前面必须是美元符号,但美元的字符本身不会包含在匹配结果里。(?<!...)则是反向排除,比如提取“不跟在负号后面的数字”:
const text = '温度:-5 度,湿度 80%'; const positives = [...text.matchAll(/(?<![-\d])\d+/g)].map(m => m[0]); console.log(positives); // ['80']注意不要把后行断言和捕获组搞混。断言是零宽度的,它只检查条件,不消耗字符,不会出现在匹配结果里。这是理解断言的关键。
V8 引擎实现后行断言时有个细节:后行断言内部的匹配顺序是从右往左扫描的。这意味着在极少数复杂量词组合下,后行断言内的匹配行为和先行断言可能不一样。写复杂正则时,先在小数据上验证一下结果,别想当然。
5.4 Unicode 属性转义:按属性匹配字符,而不是枚举字符
以前想匹配“任何中文字符”或者“任何 Emoji”,只能枚举 Unicode 码点范围,又长又容易漏。ES2018 提供了\p{...}语法,按 Unicode 属性直接匹配,但必须配合u标志:
// 匹配任意字母 const letters = 'abc123'.match(/\p{L}+/gu); console.log(letters); // ['abc'] // 匹配中文汉字,Script=Han 表示汉字 const chinese = 'Hello 世界'.match(/\p{Script=Han}+/gu); console.log(chinese); // ['世界'] // 匹配 Emoji(涉及变体选择符时结果可能比预期多) const emojis = '今天天气☀️,心情😊'.match(/\p{Emoji}+/gu); console.log(emojis); // ['☀️', '😊']常用的属性有:\p{L}任意字母、\p{N}任意数字、\p{P}标点、\p{Script=Han}汉字、\p{Emoji}表情符号,以及\p{Cf}格式字符(比如零宽空格、软连字符)。后者在做文本清洗的时候特别有用,可以精准地把不可见字符删掉而不影响正常内容。
有两个必须记住的注意事项。第一,\p{...}必须配合u标志使用,否则正则引擎不认,直接报错。第二,老环境对这个特性的支持参差不齐,Node.js 10 才原生支持,之前的版本要么转译,要么用字符枚举替代。
6. 模板字符串的语法修订:为什么这也能算个新特性
6.1 这个修订解决的是什么问题
ES2015 引入模板字符串时规定:模板字符串里的转义序列必须是合法的。也就是说\u后面必须跟四位的十六进制,否则整个模板字符串直接报错。
这个限制在日常写普通模板字符串时没什么影响,但标签模板函数(tagged template)很受伤。标签模板函数的用途是接收“原始字符串”做处理,比如做 SQL 拼接、Markdown 渲染、正则构造。这些场景里,用户输入的字符串可能包含\u、\x这种看起来像转义但实际上不是的内容,比如 Windows 文件路径C:\Users\name,一放进模板字符串就崩。
其实标签模板函数的第一个参数给的是“原始字符串数组”,本来不需要做转义解码。但 ES2015 的规范没有给这种情况留余地,只要字符串里有非法转义序列,还没到标签函数手里就直接抛错了。
6.2 ES2018 的修订方式
ES2018 修订后的规则是:在标签模板函数里,非法转义序列不再直接导致语法错误。模板字符串的“cooked”值(也就是解码后的值)在非法位置变成undefined,但“raw”值(原始字符串)仍然保留,标签函数可以通过第一个参数的.raw属性拿到原始内容。
举个例子:
function tag(strings) { console.log(strings[0]); // undefined console.log(strings.raw[0]); // 'C:\\Users\\name' } tag`C:\Users\name`;ES2018 之前这段代码在解析阶段就报错了,现在可以正常运行,标签函数自己决定怎么处理这些非法转义。
这个修改对普通开发者最直接的价值有两个。第一,写 DSL 或者 SQL 模板的时候,不再需要担心用户输入里的反斜杠会炸掉整个模板。第二,写正则表达式模板时,可以用标签函数处理包含转义字符的输入,构建更安全的正则。
不过要注意,这个修订只适用于标签模板函数。普通模板字符串(不带标签)遇到非法转义序列仍然会报错,这个行为没有变。
7. 兼容性选型与老项目升级避坑
7.1 这些新特性支持到什么程度了
ES2018 发布的年份是 2018,到今天主流环境都已经原生支持。但“主流”和“你的用户环境”之间可能还有差距,尤其是涉及正则的新特性和异步迭代器这两个重头戏。
我整理过一份实际使用的兼容性判断标准:
| 特性 | Chrome | Firefox | Safari | Node.js |
|---|---|---|---|---|
| 异步迭代器 | 63+ | 57+ | 11.1+ | 10+(12 稳定) |
| 对象 rest/spread | 60+ | 55+ | 11.1+ | 8.6+ |
| Promise.finally | 63+ | 58+ | 11.1+ | 10+ |
| 正则 dotAll | 62+ | 78+ | 11.1+ | 8.10+ |
| 正则命名捕获组 | 64+ | 78+ | 11.1+ | 10+ |
| 正则后行断言 | 62+ | 78+ | 16.4+ | 8.10+(较少,10 完整) |
| Unicode 属性转义 | 64+ | 78+ | 11.1+ | 10+ |
这个表是我的底线标准。如果你的目标环境比这个还旧,就得考虑转译和降级方案。Node.js 的兼容性还要更细,因为就算同一个大版本比如 Node 8,小版本之间的行为也有差异。
7.2 用 Babel 转译,该注意什么
如果是老项目想用 ES2018 的新语法,Babel 是主力方案。改@babel/preset-env的时候,有几个点容易被忽略。
第一,预设里设置的targets决定了转译力度。如果 target 设置得太激进(比如只兼容最新 Chrome),Babel 可能不转译某些新语法,而是在代码里保留原样。这本身没问题,前提是你确定最终运行环境真的支持。
第二,大多数 API 层面的新特性不能靠语法转译,必须引入 polyfill 或 runtime 插件。比如Promise.prototype.finally,Babel 不会自动帮你生成finally,你得让@babel/preset-env的useBuiltIns: 'usage'生效,自动把core-js的 polyfill 引进来。异步迭代器更特殊,它依赖Symbol.asyncIterator,即便 polyfill 了运行时的next方法,你还得给对象挂上Symbol.asyncIterator属性,Babel 的transform-runtime插件会在这块做兼容处理。
第三,对象展开看起来是无害的语法糖,转译后变成Object.assign。但如果 source 对象里有__proto__这种特殊属性,展开和Object.assign的行为会略不同。我见过一次因为这个差异导致的线上 bug,对象里藏了个__proto__,展开复制后原型被污染了,排查非常费劲。
7.3 老项目实践中的常见问题速查
我在实际升级老项目碰到过不少问题,挑几个有代表性的列出来,你们可以对照排查:
| 症状 | 原因 | 处理方式 |
|---|---|---|
代码里用了for await...of,老环境直接 SyntaxError | 环境不支持异步迭代器语法 | 转译 + 给迭代对象挂Symbol.asyncIterator |
Promise.prototype.finally is not a function | 环境太老,没有这个方法 | 引入 core-js 的 promise.finally polyfill |
正则写了\p{L}但报Invalid regular expression | 忘了加u标志 | 正则末尾补上u,同时确认环境支持 u 标志 |
| 对象展开后内部嵌套对象被意外修改 | 浅拷贝导致,不是 bug 是预期行为 | 改用深拷贝方案 |
break跳出for await后,异步生成器内部资源没有释放 | 自定义迭代器没实现return() | 改用async function*或手动实现return() |
| 标签模板函数遇到非法转义序列被 eslint 报错 | ESLint 配置的 parser 版本太旧 | 升级 @babel/eslint-parser 和相关插件 |
7.4 升级节奏的建议
如果项目维护比较保守,不建议一次性把所有新特性全部引入。分批来,先把收益最大、风险最低的用了,稳住了再加别的。
我个人的推荐顺序是这样:第一步上对象 rest/spread 和Promise.finally,这两个改造量小、收益明显,测试覆盖也容易写。第二步处理正则的新特性,主要用于替换项目中那些晦涩难懂的[\s\S]、(?:...)写法,提升可维护性。第三步再碰异步迭代器,因为它的运行时不兼容面最广,需要调用方和被迭代的对象两配合,涉及面较大,建议先在小模块试点。
这个顺序不是拍脑袋定的,核心逻辑是“风险从低到高,依赖从少到多”。对象 rest/spread 和Promise.finally几乎不依赖其他环境特性,正则新特性只要环境支持u标志就能跑,而异步迭代器涉及协议、运行时和调用方式多层配合,相对最容易出问题。
最后说一个经验:不要觉得“Babel 能转译就等于可以随便用”。语法可以被转译,但运行时能力不一定能补齐。项目升级之前,先在目标环境的最低版本上跑一遍兼容性测试,比什么都有用。我在团队里一直坚持这条底线,出问题的概率低了不少。