很多写了几年 JS 的朋友,在被问到“你现在用的是 ES6 还是 ES2024”时,通常会愣一下。这不怪大家,因为 ECMAScript 版本迭代的方式,从 2015 年开始就彻底变了,版本号从 ES6 跳到 ES2015,再变成每年一个版本,确实容易把人绕晕。今天这篇不打算照搬规范,而是想从我自己的实际经历出发,聊聊我对 JS 和 ECMAScript 版本这件事的理解,包括各版本里真正影响写码方式的特性、日常工程里怎么做版本兼容,以及我踩过的一堆和版本有关的坑。如果你刚接触前端,这篇文章能帮你把散落的版本知识串起来;如果你写过几年 JS 但没系统梳理过版本,那这篇文章应该也能帮你补上一些盲区。
1. ECMAScript 到底是什么:为什么总有人把 JS 和它搞混
1.1 一段简史:从浏览器混战到 TC39 接管
JavaScript 是 Netscape 在 1995 年搞出来的脚本语言,火了之后微软也做了个 JScript,两边语法不完全一样,前端要写两套代码,非常痛苦。为了避免继续分裂,Netscape 把 JavaScript 交给 ECMA 这个标准组织,由 TC39 技术委员会维护,标准的正式名字叫 ECMAScript。所以 JavaScript 是 ECMAScript 的其中一种实现,JScript、ActionScript 也是。我们平时说的“JS 版本”,其实是在说“这份代码用到了哪个版本的 ECMAScript 特性”。
这个认识很重要,因为很多兼容性问题的根子都在这里:语言规范和运行时环境不是一回事。规范规定了语法和内置对象,但一个特性最终好不好用,还得看你跑的宿主环境——浏览器、Node.js、小程序、WPS 的 JS 宏——是否实现了它。举个例子,你在 Chrome 最新版里写Array.prototype.at很顺畅,但同样的代码放到一个老版本的 WebView 里可能就直接崩掉。版本不是玄学,是实实在在的运行环境差距。
1.2 版本编号的套路:ES6、ES2015、ES2024,到底哪个对
ECMAScript 1 在 1997 年发布,之后 1998 有 2,1999 有 3,然后跳过 4,2009 年来了个 ES5。ES5 是当时第一个被浏览器广泛支持、前端敢于放心用的版本。2015 年 6 月,ES6 正式发布,官方改成了以年份命名,叫 ES2015,所以 ES6 就是 ES2015,是同一个东西的不同叫法。
从那以后 TC39 承诺每年发布一个新版本,于是就有了 ES2016、ES2017……一直到现在 ES2024。你可能要问:那为什么现在还有人张口闭口说 ES6?因为 ES6 带来的变化实在太大了,可以说是现代 JavaScript 的地基,大家习惯用它当分水岭。而真正查文档时,还是要以年份版本为准,比如“Array.prototype.at是 ES2022 的特性”“Object.groupBy是 ES2024 的特性”。这种命名方式一开始我也不习惯,但用多了会发现它其实更直接:版本年份和发布时间的对应关系一目了然。
2. 各版本核心特性盘点:哪些是真的改变了我的代码
2.1 ES5:var 和 function 打天下的时代,JSON 是最大恩人
我刚开始写前端时,能用的东西不多:变量只有var,函数只有function,循环就是 for、while,继承全靠原型链。ES5 加入了一批到现在都很核心的能力:严格模式、JSON.parse 和 JSON.stringify、Object.defineProperty,还有数组上的 forEach、map、filter、reduce 等。这些特性今天看起来普通,但在当年极大解放了生产力。Object.defineProperty后来成了 Vue 2 响应式的根基,JSON更是让前后端数据交换变得规范。
那个年代最痛苦的是浏览器兼容。比如 IE7 不支持 JSON.parse,我当时得引入一个 json2.js 垫片才能用。这也是我第一次意识到“同一个 JS 版本在不同环境里支持度不一样”。如果你现在写代码还要兼容老旧的嵌入式浏览器,ES5 的这套标准反而是最稳妥的底线。
2.2 ES2015(ES6):箭头函数、解构、类和模块,一次重构我的编码习惯
ES2015 是真正的核弹级更新。let/const解决了 var 的变量提升和全局污染问题,让块级作用域变得可控。模板字符串告别了字符串拼接地狱。箭头函数不仅缩短了写法,还没有自己的 this、arguments,让回调里的 this 指向问题大大缓解。解构赋值让交换变量、函数返回多值都变得非常自然。class语法让习惯了面向对象的人更容易上手,Promise则让异步代码不再一直往回调里套。
还有模块系统的import/export,直接取代了以前各种全局命名空间和 script 顺序依赖。这些特性合在一起,才让 ES6 之后的前端工程化变成可能。我第一次用import的时候,才意识到原来代码之间的关系是可以静态表达出来的。新手如果还停留在 var + function + script 标签的阶段,跨过 ES6 这关会明显感受到现代前端的大门打开了。
2.3 ES2016 到 ES2022:每年一小步,几个新朋友
ES6 之后每年的更新规模都不大,按官方说法是“增量发布”。但累积起来,现代写法已经发生了很大的变化。我按年份整理一下常用的,可以当快速索引:
| 版本 | 年份 | 代表特性 |
|---|---|---|
| ES2016 | 2016 | **指数运算符,Array.prototype.includes |
| ES2017 | 2017 | async/await,Object.values/entries,padStart/padEnd |
| ES2018 | 2018 | for await...of,对象展开...,正则命名捕获组,Promise.finally |
| ES2019 | 2019 | Array.flat/flatMap,Object.fromEntries,catch 参数可省略 |
| ES2020 | 2020 | 可选链?.,空值合并??,BigInt,Promise.allSettled,globalThis |
| ES2021 | 2021 | String.replaceAll,Promise.any,逻辑赋值操作符&&=、||=、??= |
| ES2022 | 2022 | class 字段,顶层await,Object.hasOwn,Array.at,Error.cause |
这里面有不少是对痛点的直接回应。以前访问深层对象属性,总得写一串a && a.b && a.b.c,ES2020 的可选链直接写成a?.b?.c。以前判断对象属性得用Object.prototype.hasOwnProperty.call,字长又容易踩原型链的坑,ES2022 的Object.hasOwn就舒服很多。数组的at()允许用负数索引,arr.at(-1)取最后一个元素,比arr[arr.length - 1]可读性强不少。我不建议死记年份,但遇到旧代码出现奇怪写法时,能大致猜到它大概是哪个版本引入的,排查会快很多。
2.4 ES2023 到 ES2024:最新的甜点
近两年的版本在命名上还是年份,特性偏向给数组和异步打补丁。ES2023 主要加了findLast、findLastIndex,以及一组不可变数组方法toSorted、toReversed、toSpliced、with。名字很好认,原来的sort、reverse、splice会原地改数组,加个to前缀就是返回新数组。这个设计在 React、Redux 这类强调不可变数据的场景里很受欢迎。
ES2024 值得关注的新特性有Promise.withResolvers和Object.groupBy、Map.groupBy。Promise.withResolvers可以在外层拿到 resolve 和 reject,写某些事件驱动的 Promise 时不用再包一层构造函数。Object.groupBy可以把数组按 key 分组,比如把一堆订单按状态分组,一行搞定。你可能会疑惑:怎么以前没听过这些名字?因为它们太新了,最新浏览器和 Node 版本才支持,很多团队还在配置转译阶段。所以“知道”和“能用”之间,差的正是下一章要聊的兼容性。
3. 版本兼容性:怎么做到新语法不拖垮老用户
3.1 先搞清楚 JS 是规范还是实现,以及为什么兼容性总出问题
前面说过,ECMAScript 只是规范,最终能不能运行取决于宿主。浏览器内核要升级才支持新语法,Node 也要等 V8 引擎跟进。我没法期待所有用户都用最新 Chrome,有时候 IE11 还占着不小的比例;企业内部系统里,老 WebView 更是能坑到想骂人。所以工程上必须把“源码里用的新特性”和“运行环境能接受的老特性”这两件事分开处理。
语法层面的差异尤其致命。箭头函数、展开运算符、可选链这些属于语法,旧引擎遇到会直接解析失败,报一个SyntaxError: Unexpected token,连运行的机会都没有。API 层面的差异相对温和,比如某个数组方法不存在,顶多报xxx is not a function。两类问题的处理方式完全不一样,如果混着处理,排查起来会绕很多弯路。
3.2 语法差异用转译解决,API 差异用垫片解决
对于语法差异,主流做法是用 Babel 把高版本语法转译成低版本语法。比如可选链a?.b会被转成a === null || a === undefined ? undefined : a.b。这个过程发生在构建时,不需要在浏览器里额外加载一堆运行时。箭头函数、class、模块等也能转成 ES5 的函数表达和 CommonJS 形式。但转译只能处理语法,处理不了新增的内置对象和方法。
String.prototype.includes、Array.prototype.flat、Promise、BigInt这些属于内置 API,转译器不会凭空造出来,只能用垫片去补。最常见的是 core-js,它会在全局对象上补出旧环境缺失的方法。这也是为什么很多 Babel 配置里会同时出现@babel/preset-env和core-js:前者管语法,后者管 API。两者配合才是完整的兼容方案。
3.3 实操:配置 browserslist + Babel + core-js 的稳妥方案
如果你正在搭建项目,我建议直接按照浏览器支持范围来配置。先建一个.browserslistrc,写上你们实际需要支持的浏览器:
last 2 versions not dead > 0.5%这个文件的意思是:覆盖全球使用率超过 0.5% 的最近两个大版本浏览器,并且不包含已停止维护的。Browserslist 会计算出一份目标浏览器列表,Babel 和 core-js 都知道该为这些环境补什么。
接着安装基本依赖:
npm install -D @babel/core @babel/cli @babel/preset-env core-jsbabel.config.json可以这样写:
{ "presets": [ [ "@babel/preset-env", { "useBuiltIns": "usage", "corejs": 3 } ] ] }useBuiltIns: "usage"的意思是按需注入垫片:代码里用了 includes,core-js 就只补 includes,而不是把所有 polyfill 全塞进去。这个东西很多人第一次配的时候会漏掉,结果打包出来体积暴增。我见过一个项目只因为没设 usage,main bundle 凭空多了 200 KB。如果你不想让转译在旧浏览器上出问题,务必检查这一条。
4. 常见误区与高频问题排查:版本说变就变,但坑是永恒
4.1 把语法特性和内置对象混为一谈
最常见的误区是认为“用了 Babel 就万事大吉”。其实 Babel 能转语法,但没法转掉所有差异。比如箭头函数内部的arguments和动态this在转换后仍和原语义不同;class转换出的 ES5 代码也只是模拟,很多边界行为不一致。更典型的问题是有些人只配了 Babel 没配 core-js,新语法编译通过了,但Promise在 IE11 里依然不存在,运行的时候就报Promise is not defined。我排查过不少类似 bug:编译过程没报错,问题一定出在 API 层。
另一个误区是把“新特性”等同于“标准全环境可用”。判断一个特性能不能用,直接去 MDN 看兼容性表格,比听别人说“这个特性很流行”靠谱得多。比如structuredClone频繁被推荐,但它根本不是 ECMAScript 标准,而是 Web API,在某些非浏览器环境里压根没有。
4.2 从高频需求看版本差异:忽略大小写、字符串包含、函数对象
有段时间总看到有人在搜“js忽略大小写”和“js判断字符串是否包含”。这其实是一个经典需求:判断字符串里有没有某个子串,不考虑大小写。ES2015 提供了String.prototype.includes:
const title = 'Hello ECMAScript'; title.includes('ecma'); // false,因为区分大小写 title.toLowerCase().includes('ecma'.toLowerCase()); // true如果你只是判断存在性,includes比indexOf语义更直白,也不需要判断!== -1。但要留意,includes、startsWith、endsWith都是 ES2015 加的,老环境要用垫片。还有一个相关的点:includes在区分大小写的问题上并没有原生方法,所以忽略大小写得自己先归一化,并没有includesIgnoreCase这种东西。
至于“js函数是对象吗”,答案从 ES1 至今都是:函数就是对象,函数属于 Function 类型,有length、name、prototype等属性。但 ES6 的箭头函数也是对象,却不能作为构造函数,也没有prototype。这跟原型链的学习是连在一起的:class只是构造函数的语法糖,底层对象机制没有变过。理解原型链,再回头看版本差异会清楚很多,因为很多“魔法”其实是继承链上的属性查找。
4.3 常用 API 版本速查表,遇到问题先查这里
下面这个表可以帮你形成快速记忆:
| API | 版本 | 备注 |
|---|---|---|
JSON.parse | ES5 | 2009 年就有,现在完全没理由不用 |
Object.defineProperty | ES5 | Vue 2 的依赖追踪核心 |
Promise | ES2015 | 现代异步基础,需要 polyfill |
async/await | ES2017 | 可以用 Babel 转译 |
Object.fromEntries | ES2019 | 把键值对数组变对象 |
Array.at | ES2022 | 支持负索引 |
Object.hasOwn | ES2022 | 比 hasOwnProperty 更安全 |
structuredClone | 非 ECMAScript | 浏览器 Web API,Node 版本也看实现 |
遇到“为什么这个 API 在我公司项目里不能用”的时候,先从表里找版本号,再去看运行环境版本,这个排查路径往往最快。不要上来就怀疑业务代码,兼容性列表看一遍基本就有底了。
4.4 排查版本问题的三条实用经验
第一,看报错类型。SyntaxError: Unexpected token '?'基本就是用了旧引擎解析不了的语法;TypeError: xxx.flat is not a function则大概率是缺 API 垫片。第二,用特性探测代替 UA 判断,比如脚本开头判断typeof Promise !== 'undefined',比读navigator.userAgent去猜浏览器版本可靠得多。第三,遇到“我的代码在本地好好的,线上就崩”时,先去查目标环境的浏览器和 Node 版本,别急着改代码。这三条帮我省下过非常多时间。
5. 学习路径建议:如何稳步跟上 ECMAScript 的版本节奏
5.1 不用背规范,但要有一套追踪新特性的方法
很多人一听到“每年一个新版本”就焦虑,觉得学不完。实话说,ECMAScript 每年的新增内容并不多,真正影响日常开发的更少。我的方法是长期看 TC39 的 Stage 列表。一个提案从 Stage 3 进入 Stage 4 意味着基本要转正了,提前了解名字和用途,等它落到正式版本时不陌生。偶尔去扫一眼 MDN 的“JavaScript 参考”页面,或者订阅几个前端周刊,效果比买一本厚厚的书好得多。
5.2 一套我自己的学习路线:从 ES5 打地基,用 ES6+ 写业务
我建议新手按这个顺序走:先吃透 ES5 的核心,包括变量、函数、闭包、原型链、严格模式;然后学 ES2015 的那批特性,重点掌握let/const、模板字符串、解构、箭头函数、class、Promise、模块;再往后就看业务需要。比如你经常处理同时多个请求,就去学Promise.allSettled;处理深度嵌套的对象,就去学可选链;写不可变数据逻辑,就去学 ES2023 的toSorted那一组。按需学习性价比最高。
5.3 学习过程中容易走的弯路
第一个弯子是盲目追新:项目还在兼容 IE11,却非要在代码里用Array.at,结果线上崩了才回头改。第二个弯子是把 TypeScript 和 ECMAScript 混为一谈:TypeScript 是超集,很多语法(比如 enum、namespace、装饰器)并不是 ECMAScript 标准,要分清楚哪些是 JS 的、哪些是 TS 的。第三个弯子是只看新特性名字,不动手跑一遍。比如Object.groupBy我第一次用时想当然以为是返回对象,实际在Map.groupBy里返回 Map,不看文档直接写很容易翻车。
最后一个我自己很受益的小习惯:在团队里维护一份“当前使用的最低 ECMAScript 版本”文档,把项目配置里的 browserslist、Babel preset、允许使用的特性列清楚。新同事加入时看一眼,能省掉很多关于“这个能不能用”的争论。版本号本身不是目的,让代码在目标环境里稳定跑起来才是。