别一提ES版本就只想到“ES6”这三个字母。干这行久了你会发现你写的每一行JavaScript代码都跟ECMAScript版本演进有着直接关系。很多群里吵了十几年的老问题比如“为啥我这段代码在IE上白屏”“函数到底是不是对象”“原型链到底怎么串”追到根上都离不开ECMAScript规范里那些定义。包括现在热词里高频出现的“js判断字符串是否包含”“js忽略大小写”“js三级联动”“js函数”“js原型和原型链”这些在规范里都有明确的位置。我写这篇文章是想从一名整天跟代码打交道的开发者的角度把“JS ECMAScript 版本”这件事彻底捋清楚规范与实现差在哪、版本号怎么越叫越乱、每个版本带来了哪些值得你用的东西、以及在你实际写代码和配置工程时这些版本知识能怎么帮你少踩坑。不管你是刚入门前端的新手还是写了一段时间JS想补理论底子的朋友这篇都能用得上。1. 先搞明白ECMAScript和JavaScript到底是什么关系1.1 规范是菜谱JavaScript是做出来的菜如果你第一次接触“ECMAScript”这个词多半会困惑我写的是JavaScript你跟我说ECMAScript干嘛简单说ECMAScript是一份语言规范文档JavaScript是这份规范在浏览器里、在Node.js里、在各种宿主环境里的具体实现。用做菜来打比方ECMAScript是菜谱上面写着“鱼香肉丝需要什么原料、具体怎么炒”JavaScript是各地厨房按这个菜谱做出来的成品。菜谱合理做出来的菜才能好吃但同一个菜谱不同厨房做出来味道会有差别。这份规范由TC39技术委员会维护。TC39的成员来自各大浏览器厂商和科技公司他们聚在一起开会、讨论、提案最终确定某个特性要不要进标准。而像V8Chrome和Node.js的引擎、SpiderMonkeyFirefox的引擎、JavaScriptCoreSafari的引擎做的事就是把这个规范翻译成机器能跑的程序。很多人喜欢问“JS里为什么函数也是对象”如果你翻过规范会发现它在语言类型章节里对Object、Function、Callable这些概念有细致定义。函数之所以能当参数传递、能被赋值给变量是因为规范把它设计成了对象类型的一种。这种“语言根基”问题看一万个使用教程都不如回头读一遍规范来得清楚。1.2 从ES3到ES2015再到每年一版命名彻底乱过我入行那阵前辈们嘴里说的是ES3、ES5。ES3是1999年的老标准ES5是2009年出的加了严格模式、Object.defineProperty这类东西。然后2015年TC39放了大招一次新增了一大堆特性let、const、箭头函数、类、Promise、模块化、解构赋值……一次性吃太饱那年叫ES6。可问题来了TC39决定从那年以后每年都发布一个新版本不再等好几年憋大招。于是2015年的版本官方叫ES20152016年的叫ES20162017年叫ES2017以此类推。但江湖上的习惯没那么容易改直到现在还有不少人把所有新特性统一叫“ES6语法”。实际上ES2016之后的东西严格来说都是“ES2017、ES2018、ES2019、ES2020、ES2021、ES2022、ES2023、ES2024”……每一个版本都有自己的名字和特性清单。你如果去面试张口说“我会ES6”面试官多半还会等你说说具体是哪些特性。但如果你能跟他掰扯清楚“ES6特指ES2015年份版才是后续更准确的叫法”他反而会觉得你底子扎实。1.3 为什么前端总爱提“ES版本”这跟前端的天生困境有关你的代码要跑到用户的浏览器里而用户浏览器里装的引擎是哪一年生产的你说了不算。你用了一个去年才定的新语法结果用户用的是三年前的旧浏览器可能直接就SyntaxError报错白屏。而后端写Java或者Go运行环境是自己控制的升级依赖包就行不需要这么操心语言版本。热词里有条“为何很少人愿意手写js”其实原因也在这里。现代前端开发很少直接拿裸JS写项目一般都用框架和现成工具链。框架帮你把ES新特性转译成兼容代码让你能安心用新写法。但这不代表你不需要懂ES版本恰恰相反你越懂版本边界越能在框架出问题时快速定位根源。2. 版本演进中的几个关键转折点2.1 从var到let/const块级作用域是第一个大坎ES5时代声明变量只有var。var有个坑它是函数作用域而且存在变量提升。很多人第一次被它坑是在for循环里for (var i 0; i 5; i) { setTimeout(function() { console.log(i); }, 100); } // 输出5 5 5 5 5为什么不是0到4因为循环里的函数执行时循环早跑完了i已经变成5。var声明的i属于函数作用域所有回调共享同一个i。当年为了解决这个大家用IIFE包裹或者硬记一套闭包技巧。现在有了let声明的是块级作用域每次循环都会绑定一个新值直接写就完事for (let i 0; i 5; i) { setTimeout(() console.log(i), 100); } // 输出0 1 2 3 4const的意思是常量绑定声明的变量不能重新赋值。注意这个“不能重新赋值”指的是绑定关系不能变并不是对象的属性不能变。所以const obj { name: a }; obj.name b; // 合法对象内部可以修改 // obj {}; // 报错绑定不能重新指向我在项目里的习惯是默认用const只有当这个变量确实要重新赋值时才改成let。这样读代码的人一目了然一看就知道哪些变量是会变的哪些是固定引用。2.2 函数、原型链与class面向对象写法变了根没变要不要提“js函数是对象吗”当然要提。函数在JS里是对象所以它有属性、有方法甚至可以通过new来构造。更准确地说函数对象内部实现了[[Call]]内部方法所以可被调用。这个设计从ES3就有了跟新的class语法不冲突。ES5时代写继承很麻烦要绕原型链。原型链的核心就几句话每个函数都有prototype属性每个对象都有__proto__属性规范里叫[[Prototype]]访问一个属性时先找自身找不到沿__proto__往上找一直找到Object.prototype还是找不到就是undefined。顶上的Object.prototype的__proto__是null链就断了。function Parent(name) { this.name name; } Parent.prototype.say function() { console.log(this.name); }; function Child(name, age) { Parent.call(this, name); this.age age; } Child.prototype Object.create(Parent.prototype); Child.prototype.constructor Child;ES2015的class写起来要短得多class Parent { constructor(name) { this.name name; } say() { console.log(this.name); } } class Child extends Parent { constructor(name, age) { super(name); this.age age; } }class本质上是语法糖底层还是原型链那套但表达力强太多了。很多人以为class只是“长得像其他语言”是掩耳盗铃其实它补全了ES5阶段非常别扭的继承写法还支持super调用父类方法这是当年手搓原型链容易出错的地方。2.3 从回调到Promise再到async/awaitES62015引入了Promise这是异步编程的第一次翻身仗。在这之前异步全靠回调函数代码是往里缩的三层嵌套看哭人。Promise让流程变平了getData() .then(data process(data)) .then(result save(result)) .catch(err handleError(err));但你真写复杂业务会发现then链依然不够舒服尤其当你有if判断、循环、多个异步依赖时还是容易绕。ES2017给了async/await让异步代码可以像同步一样从上往下读async function run() { try { const data await getData(); const result await process(data); await save(result); } catch (err) { handleError(err); } }注意一个关键词热词里的“js 事件中的 event”其实跟异步模型关系很大。浏览器和Node.js都基于事件循环Promise的微任务机制让异步回调能在当前同步任务结束后尽快执行。理解了事件循环你才知道为什么setTimeout(() {}, 0)不一定在Promise前面还是后面执行这是个经典面试题但放到业务里也能解释很多诡异时序。2.4 解构、扩展运算符、可选链日常效率提升最明显的三个ES6的解构赋值能让你少写好多临时变量。以前从对象里取属性是这样const name user.name; const age user.age; const city user.address.city;现在是const { name, age, address: { city } } user;数组也一样可以从JSON里直接取值。热词里“js怎么取出数组”说的基本就是数组方法和解构的事。扩展运算符三个点也威力巨大。数组合并、对象拷贼方便const a [1, 2]; const b [3, 4]; const merged [...a, ...b]; // [1,2,3,4] const obj1 { x: 1 }; const obj2 { y: 2, ...obj1 }; // {y:2, x:1}ES2020的可选链和空值合并更是救命稻草。以前访问深层属性得不停判空if (res res.data res.data.list res.data.list.length) {} // 现在 if (res?.data?.list?.length) {}空值合并操作符??跟||的区别是||会把0、空字符串、false这类假值也当成“没有”而??只在左侧是null或undefined时才取右侧值。这两个坑了我好几次后来养成习惯需要兜底默认值时先想清楚语义到底要滤掉什么。3. 真实项目里如何用好ES新特性3.1 字符串与数组操作from includes到flat跟热词相关度最高的“js判断字符串是否包含”。老写法indexOf它有个问题返回的是索引得判断 0 才表示找到。ES2015的includes直接用布尔值const str hello world; str.includes(world); // true str.includes(World); // false“js忽略大小写”也很好解要么都转小写要么用正则加i标志const a Hello; const b hello; a.toLowerCase() b.toLowerCase(); // true /^hello$/i.test(a); // true数组操作是我现在写业务的主力。ES5时代光一个找到元素就得自己写循环现在有find、findIndex、includes、flat、flatMap。一个典型场景从接口返回的商品列表里找指定ID商品const product productList.find(item item.id targetId item.status 1);曾经用filter后取[0]性能上find并不仅仅是简短它是找到第一个就停下而filter会遍历全部。ES2019给了flat能把嵌套数组打平ES2022又给了findLast和findLastIndex从尾部找。3.2 Map和Set什么时候别用Object和ArrayES6引入Map和Set但很多老项目里几乎没人用宁愿用Object和Array凑合。Map和Set其实解决了很多Object当字典时的隐性问题。比如Object的键只能是字符串或Symbol数值键会被转成字符串Map的键可以是任意类型包括对象、函数这在实际业务里极其好用const cache new Map(); const keyObj { id: 1 }; cache.set(keyObj, 数据); cache.get(keyObj); // 数据Set天然去重配合扩展运算符一行解决const arr [1, 2, 2, 3, 3, 4]; const unique [...new Set(arr)]; // [1, 2, 3, 4]WeakMap和WeakSet的更特殊它们对键是弱引用不会阻止垃圾回收。用来存DOM节点对应的数据再合适不过节点被移除后Map里的关联项也能被自动回收不容易内存泄漏。3.3 模块化require和import到底有什么不同ES6加入了官方模块化语法import/export这之前Node.js用的是CommonJS的require/module.exports浏览器端则依赖各种打包工具自己造出来的方案。import/export是静态的编译器在解析阶段就能知道模块依赖关系可以做tree shaking把没用到的代码剔除require是运行时动态加载灵活性高但分析依赖很难。更关键的是import支持动态import()返回Promise可以实现按需加载// 点击按钮才加载某模块 button.addEventListener(click, async () { const { default: chart } await import(./chart.js); chart.render(); });这在做单页应用懒加载路由时是标配。3.4 几个热词场景的实战组合把热词串起来看“js三级联动”是很多人入门时的经典练习。省市区联动本质上就是三个下拉框之间的事件联动和数组筛选const data { 北京市: [朝阳区, 海淀区, 东城区], 上海市: [浦东新区, 静安区, 徐汇区], // ... };当省change时根据选中的省从data里取出市数组重新渲染市再联动区。这里用到的核心就是事件监听、数组查找、动态更新DOM全都是ES基础语法的组合应用。这种面试题看起来简单但手写代码时会不会用解构、forEach还是for of一眼就能看出对ES版本的熟悉程度。还有一个很常见的热词场景“js验证url有效性”。除了正则我更喜欢用URL构造函数function isValidUrl(str) { try { new URL(str); return true; } catch { return false; } }这里用到了try/catch、URL全局对象和ES2019的可选catch绑定catch后面可以省略参数这些细节点能够体现你对版本的敏感度。4. 版本兼容和工程化老项目怎么升级新项目怎么定版本4.1 Babel转译和core-js polyfill的基本逻辑前端工程化的核心思路是源码随便写新语法通过工具链转译成目标浏览器能跑的版本。Babel负责把新语法转成旧语法比如箭头函数转成function、class转成ES5原型链写法。core-js负责补API比如浏览器没有Array.prototype.includes就在原型上给你塞一个实现。很多人分不清语法转译和API补丁的区别。语法层面的东西像可选链、解构、箭头函数Babel能转。API层面的东西比如Promise、includes、Array.from转译器不管得靠polyfill在运行时补。如果你只配了Babel没配core-js会发现新语法能用但某些方法报错不是语法错误而是undefined就是因为polyfill没跟上。4.2 兼容表怎么看caniuse和MDN提到版本兼容别真去背“哪个特性哪个浏览器哪个版本支持”这种表格效率太低。实际做法是查caniuse网站或者直接看MDN每个特性页面底部的浏览器兼容性表格。重点是学会评估你自己的用户画像。如果是个管理后台用户都是公司内部人员基本都用Chrome最新版那就可以放心用近两年的特性。如果是电商C端网站用户里有一大堆老Android微信浏览器就得谨慎些。还有一个判断技巧看当前代码的运行环境。比如有人遇到ESLint的deprecation warning提示legacy js api其实不是ES版本的问题而是工具链升级后的配置废弃警告属于配置适配不是语言版本的事。但排查思路是共通的先分清是语法层面还是运行环境层面还是工程配置层面。4.3 不同运行环境里的ES版本差异同样的ES规范在不同宿主环境里支持度不一样。比如Node.js你用的Node 14和Node 20能跑的语法不一样Node 14对可选链、空值合并这些ES2020特性支持得很好但对ES2022的类静态块就够呛。写后端JS时要查清楚当前Node大版本支持哪些特性。再比如热词里提到的“wps js宏”WPS的JS宏环境加载的是自己内置的脚本引擎很多新特性很可能不支持。如果你用ES2015之后的花哨写法去写宏大概率直接报错。这种环境里就得写得更保守甚至只能用ES3/ES5风格。还有热词里那些“lxmusic音源js”、“野草音源js文件导入”、“洛雪音乐导入源.js”本质上是第三方脚本它们能跑到别人机器上前提是脚本里用的语法在目标环境支持。这类开源脚本一般会把版本要求写在文档里多少年没更新过的脚本还坚持用旧语法不适应新版本这才是很多用户导入后失效的真正原因。4.4 一个老项目渐进升级ES版本的实际路径我给过不少老项目做过ES升级最忌讳的是“今天把所有文件全改一遍”那排错成本会大到失控。我的习惯是先做三步第一给项目加上Babel配置允许你在不破坏现有代码的情况下逐步使用新语法。第二从新建文件和改动频繁的业务模块下手哪里动了就顺手把那段代码改成新写法不主动触碰没问题的历史代码。第三每次发布前留意构建产物大小和警告发现polyfill被异常注入再去查具体是哪个特性触发的。很多团队担心升级ES版本会引入风险。实际上语法层面的转化是相对安全且自动化的真正的风险往往来自polyfill带来全局变量冲突或者依赖库本身用了旧版本的标准。所以升级前先梳理清楚依赖树比直接改源码更重要。5. 我实践中的一些心得和避坑记录5.1 先分清报错类型再谈排查实际跑代码时如果你用了某版本的新特性而环境不支持最常见的是SyntaxError也就是解析都过不去。这类错误会直接告诉你在哪一行很多时候是代码里的新语法根本没法被旧引擎理解。比如老浏览器遇到箭头函数的箭头符号直接就爆SyntaxError了。这类问题的解法只有两条路升级环境或者让转译器先把代码转成旧语法。另一类是TypeError或者某个方法undefined比如Array.prototype.flat在旧浏览器里不存在调用时就是“flat is not a function”。这种往往是polyfill缺失解法是配置core-js或者自己打补丁。把这两类分开想排查效率会高很多。5.2 工具链版本警告的应对现在跑npm/yarn或者构建时经常看到一串warning像“[DEPRECATION WARNING] [legacy-js-api] ...”这类是工具链自己提示你某个旧API要废弃了。碰到这类警告我一般先看它属于哪一层是业务代码触发的还是某个依赖库内部触发的。如果是依赖库内部等库作者升级往往不现实可以用resolve.alias之类的方式强制替换成替代库。不要无视所有warning尤其是明确说deprecated的早晚有一天会变成error。5.3 手写JS练习的建议热词里“为何很少人愿意手写js”其实问得很有深度。因为框架和脚手架替我们做了太多事很多人写了三年React却不清楚事件循环和原型链。我的看法是框架要会用但JS的根基必须亲手敲一敲。不需要多复杂把经典小项目拿出来练就够做一个俄罗斯方块、飞行小游戏、三级联动、扑克牌。这些项目不需要装软件HTML文件直接打开就能跑练的就是对ES基础语法和版本特性的手感。有次我让新人实现一个俄罗斯方块他直接用class封装了方块和游戏循环协调得还不错说明他对ES6的面向对象有概念。而另一次我问起他把二维数组旋转的代码他写了一个三层嵌套循环加临时变量那个逻辑是对的但如果能想到用数组解构和坐标变换代码能简洁得多。这些差距只能靠多写、多看不同版本特性的用法来缩进。5.4 最后分享一个小技巧如果你也不确定某段代码在当前环境里支持不支持最快的验证方式是直接打开对应环境的控制台执行一下看看。而不是去翻文档或问同事。比如Node环境里跑node -e console.log([1,2,3].at?.(0))浏览器里按F12直接输入。实践中摸索多了你对某个环境的ES版本支持心里会有个谱慢慢就不用每次查了。ECMAScript版本这件事的意义不在于背诵每个版本号对应什么语法而在于它帮你建立了一条判断依据一段代码为什么在这个环境能跑、在那个环境不能跑为什么这种写法是官方的、那种写法是民间流行。有了这条判断线你写代码时的选择就会清晰很多。