ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ES9(ES2018)新特性详解:异步迭代器、对象展开与正则增强

ES9(ES2018)新特性详解:异步迭代器、对象展开与正则增强 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 为 falseuserInfo 里就没有 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只负责收尾错误继续向上抛由更上层的错误边界统一处理。除了 loadingfinally还能用在很多地方关闭数据库连接、释放文件句柄、清空定时器、恢复按钮点击状态、重置表单提交状态。这些场景的共同点是“无论成功失败都要做”原来写两遍的地方现在写一遍就够了。4.3 一个容易忽略的性能细节finally和then一样返回的是一个新的 Promise。如果你在finally回调里返回一个 Promise这个 Promise 会被等待但其结果不会传给后续。这个特性可以用作“延迟清理”比如请求完了等动画播完再隐藏 loadingpromise .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] // 匹配中文汉字ScriptHan 表示汉字 const chinese Hello 世界.match(/\p{ScriptHan}/gu); console.log(chinese); // [世界] // 匹配 Emoji涉及变体选择符时结果可能比预期多 const emojis 今天天气☀️心情.match(/\p{Emoji}/gu); console.log(emojis); // [☀️, ]常用的属性有\p{L}任意字母、\p{N}任意数字、\p{P}标点、\p{ScriptHan}汉字、\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 } tagC:\Users\name;ES2018 之前这段代码在解析阶段就报错了现在可以正常运行标签函数自己决定怎么处理这些非法转义。这个修改对普通开发者最直接的价值有两个。第一写 DSL 或者 SQL 模板的时候不再需要担心用户输入里的反斜杠会炸掉整个模板。第二写正则表达式模板时可以用标签函数处理包含转义字符的输入构建更安全的正则。不过要注意这个修订只适用于标签模板函数。普通模板字符串不带标签遇到非法转义序列仍然会报错这个行为没有变。7. 兼容性选型与老项目升级避坑7.1 这些新特性支持到什么程度了ES2018 发布的年份是 2018到今天主流环境都已经原生支持。但“主流”和“你的用户环境”之间可能还有差距尤其是涉及正则的新特性和异步迭代器这两个重头戏。我整理过一份实际使用的兼容性判断标准特性ChromeFirefoxSafariNode.js异步迭代器635711.11012 稳定对象 rest/spread605511.18.6Promise.finally635811.110正则 dotAll627811.18.10正则命名捕获组647811.110正则后行断言627816.48.10较少10 完整Unicode 属性转义647811.110这个表是我的底线标准。如果你的目标环境比这个还旧就得考虑转译和降级方案。Node.js 的兼容性还要更细因为就算同一个大版本比如 Node 8小版本之间的行为也有差异。7.2 用 Babel 转译该注意什么如果是老项目想用 ES2018 的新语法Babel 是主力方案。改babel/preset-env的时候有几个点容易被忽略。第一预设里设置的targets决定了转译力度。如果 target 设置得太激进比如只兼容最新 ChromeBabel 可能不转译某些新语法而是在代码里保留原样。这本身没问题前提是你确定最终运行环境真的支持。第二大多数 API 层面的新特性不能靠语法转译必须引入 polyfill 或 runtime 插件。比如Promise.prototype.finallyBabel 不会自动帮你生成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.asyncIteratorPromise.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 能转译就等于可以随便用”。语法可以被转译但运行时能力不一定能补齐。项目升级之前先在目标环境的最低版本上跑一遍兼容性测试比什么都有用。我在团队里一直坚持这条底线出问题的概率低了不少。
返回列表