先说明一个很容易绕晕的点:ECMAScript和JavaScript不是两门并列的语言,它们是同一门语言的两副面孔。很多人写了好几年JavaScript,却不清楚自己写的每行代码里,哪一部分是ECMAScript规定的,哪一部分又完全取决于运行环境。今天这篇就围绕这个“核心差异”展开,把标准与实现、版本演进、宿主环境、转译工具这些线索逐条理清楚。无论你是刚入门的前端新人,还是写了好几年业务代码、想系统补一遍语言底层的老手,这文章都值得花十分钟看完。
我会从历史说起,但不会背教科书。毕竟这个差异本身就是一段活生生的行业演化史,理解它的来龙去脉,比背十个定义要有用得多。
1. 两个名字一段公案:ECMAScript标准是怎么来的
1.1 浏览器大战逼出一份中立规范
要搞懂核心差异,得先回答一个根本问题:为什么这门语言不直接叫JavaScript,非要弄出一个念着很拗口的ECMAScript?
事情得回到上世纪九十年代。浏览器开始内置脚本语言后,头部厂商各自推出了自己的脚本实现。虽然各家都声称自己“兼容主流脚本语言”,但实际做出来的API、语法细节、异常行为却各干各的。那个年代最痛苦的人就是前端开发:一份代码在一个浏览器里跑得欢,换到另一个浏览器里直接白屏,连报错信息都看不懂。
这时候终端用户根本不在乎你用什么引擎,他们只在乎网页能不能打开、功能能不能用。为了不让整个生态继续分裂下去,必须有人出来主持大局,把语言能力统一成一份所有浏览器都愿意执行的规范。于是标准组织介入了。
当时几个主流实现之间已经不是“略有出入”的程度了,而是连核心对象的行为都分叉。比如数组的遍历方法、字符串的替换行为、日期解析的规则,不同浏览器给出的结果能差得很远。如果继续放任,前端开发会一直被困在“兼容性补丁”的泥潭里。规范的出现是必然的。
1.2 为什么叫ECMAScript而不是JavaScript
这里就有一个经常被忽略的细节。标准组织接手时,并没有直接沿用“JavaScript”这个名字,而是另起了一个中立的名称,取标准组织名加Script合成而来。为什么不直接用原来的名字?
不是看不顺眼,是没法用。原来的名字是某头部浏览器厂商的商标资产,而且后来另一家软件巨头也搞了一版自己的脚本语言,这名字在商业和法律上太敏感了。你要是把标准直接命名为JavaScript,等于要求其他厂商在明面上承认竞争对手的品牌,这谁能答应?
最终方案就是:标准层面用ECMAScript这个中立名称,产品层面继续叫JavaScript。于是你看到的情况就变成了——浏览器里的脚本语言叫JavaScript,但决定它语法和行为规则的规范叫ECMAScript。
1.3 所以“核心差异”的第一层含义是什么
第一层核心差异其实是“官方名称和俗称”的差异。你可以这么理解:ECMAScript是户口本上的正式姓名,JavaScript是大家叫习惯的小名。正式姓名用于法律文书、标准文档、面试题;小名用于日常交流、技术社区、招聘要求。
但事情没这么简单。如果只是同一个东西的两种叫法,就不会有这么多人困惑了。真正的差异藏在第二层:标准只管一部分,另一部分由运行环境决定。这才是开发者每天都会切身感受到的“差异”。
2. 规范与实现到底怎么分工:ECMAScript管什么,JavaScript管什么
2.1 标准只管“语言内核”,宿主能力不在规范里
翻开ECMAScript规范,你会发现它的内容边界非常明确:语法定义、关键字与运算符、内置对象、类型系统、控制流、函数、模块、异步机制,这些属于语言内核。换句话说,标准规定的是“这门语言本身长什么样”。
但同样一段JavaScript代码,在浏览器里能用document.getElementById操作页面元素,在服务端JavaScript运行时里,document根本不存在,取而代之的是文件读写、网络请求、环境变量这些能力。ERP系统能用DOM操作表格,机器人项目能用蓝牙API控制硬件,这些五花八门的能力都不是ECMAScript规定的。
所以我们可以画出一条分界线:
- ECMAScript规范负责定义“JavaScript语言”的骨架和肌肉,包括变量、类型、语句、函数、内置对象、异步机制。
- 运行环境(浏览器、服务端运行时、嵌入式环境)负责提供“全球环境”,也就是那些能触碰外部世界的接口。
这种分工关系,才是ECMAScript与JavaScript核心差异里最有价值的部分。
2.2 一个类比理解边界:普通话规范与实际使用
给你打个比方,会更容易记住。ECMAScript相当于国家层面发布的普通话规范:声母、韵母、声调、常用词汇、基本语法,这些都写进标准文档里。JavaScript则像是普通话在不同地区的实际使用:福建人说普通话会带一点闽南腔,东北人说普通话会带儿化音,但这些都不影响沟通,因为底层遵循的是同一套规范。
但千万别把“腔调差异”当成“标准差异”。浏览器环境里多了window、document、fetch这些接口,服务端环境里多了process、全局文件接口、事件循环的扩展能力,这些都属于“地区方言”,不属于普通话规范。你可以把这份方言当成运行环境自己加的能力。
2.3 一张表看清边界
| 层级 | 包含内容 | 举例 |
|---|---|---|
| ECMAScript语言内核 | 语法、类型、运算符、控制流、函数、类、模块、异步机制 | let、const、async/await、Promise、Array.prototype.map |
| 浏览器宿主层 | DOM/BOM、网页相关API、事件机制、网络能力 | document.querySelector、window.location、fetch、setTimeout |
| 服务端宿主层 | 文件系统、进程、网络服务、系统信息 | 文件读取接口、HTTP服务器接口、操作系统信息接口 |
| 其他宿主层 | 嵌入式、桌面端、工具链定制 | 脚本扩展、自动化接口、设备通信接口 |
看到表格就清楚了:JavaScript能做的事,远大于ECMAScript规定的事。你写代码时同时使用了两套东西——一套是标准的,一套是宿主给的。这两者混在一起,才是完整的JavaScript。
3. ES6不是多了个箭头函数那么简单:版本演进重塑了语言面貌
3.1 从ES3到ES5的长停滞,前端开发者等得太久
ECMAScript的版本演进,本身就是一门值得细讲的课。早期最广为使用的是ES3,这版规范支撑了浏览器脚本开发非常多年。后来ES4项目因为各方分歧太大被放弃,导致语言标准长期没有大进展。前端开发者那几年处于一种很尴尬的状态:业务需求每天在变,但语言本身几乎原地踏步。
直到ES5发布,补齐了一些通用的能力,比如严格模式、数组方法、JSON对象、属性描述符。但ES5更多是修补,谈不上革命。真正让这门语言脱胎换骨的,是ES2015。
3.2 年度发版之后,每次都在改什么
从ES2015开始,ECMAScript彻底改成“每年一版”的节奏。这个改变影响深远:语言不再需要憋一个大版本,每年发布当年已经确定的新特性,日期一到就发版,没做完的再等明年。
ES2015就是我们常说的ES6。它带来的不是某一个功能点,而是一整套现代化语法体系:let和const、箭头函数、模板字符串、解构赋值、默认参数、class类语法、Promise、模块、Proxy、Symbol、Set和Map。这些特性叠加在一起,让JavaScript从“写网页表单校验的脚本语言”进化成“能支撑复杂业务工程的语言”。
再往后,ES2016加了指数运算符和Array.prototype.includes,ES2017加了async/await,ES2018加了对象的扩展运算符和Promise.finally,ES2019加了可选链的前置版本——这些特性你可能平时都在用,但未必知道它们属于哪个版本的标准。
我整理了一张简化版本对照表,方便你建立时间线:
| 标准版本 | 发布年份 | 代表性特性 |
|---|---|---|
| ES3 | 1999 | 基础语法、正则、try/catch |
| ES5 | 2009 | 严格模式、数组方法、JSON、访问器属性 |
| ES6/ES2015 | 2015 | let/const、箭头函数、class、Promise、模块 |
| ES2016 | 2016 | 指数运算符、Array.includes |
| ES2017 | 2017 | async/await、Object.entries |
| ES2018 | 2018 | 对象展开、异步迭代、Promise.finally |
| ES2019 | 2019 | 数组flat、Object.fromEntries、可选捕获 |
| ES2020 | 2020 | 可选链、空值合并、globalThis、BigInt |
| ES2021 | 2021 | 逻辑赋值运算符、Promise.any、String.replaceAll |
| ES2022 | 2022 | 顶层await、类字段、错误原因 |
| ES2023 | 2023 | 数组从尾部查找元素的新方法 |
3.3 版本演进带来的新“差异”形态
版本不停更新,别急着高兴,这里产生了第二层核心差异:标准层每年在变,但用户手里的浏览器环境不可能每年跟着换。这就是为什么老教程里天天教你写ES5风格代码,而新项目可以直接用ES2022语法。
差异不再是“ECMAScript vs JavaScript”的不同,而是“新版标准 vs 老版引擎”的时间差。比如在看ES2017已经定义了async/await,但某些旧环境根本不懂什么叫async。标准写得很美好,实际运行环境却可能落后好几年。正是这种时间差,催生了后面要说的转译工具和垫片方案。
4. 同一段脚本,换个环境就变脸:引擎与宿主的隐形边界
4.1 严格模式、全局对象、模块:标准写得很清晰,实际实现五花八门
我经常跟朋友说,排错最有价值的第一步,不是看报错,而是先确认代码所处的环境。同一个语法,在标准文档里是这么定义的,到了具体引擎里可能因为实现版本、宿主API、运行时配置不同,行为差异极大。
举几个实际例子。
第一个是严格模式。ES5引入严格模式,规定了很多“不允许”的写法,比如不允许使用with、不允许给只读属性赋值、不允许未声明变量。标准文档写得明明白白,但某些老引擎对严格模式的检测并不完全一致,个别边界情况在旧环境里会“假装报错”,然后继续执行。
with (Math) { console.log(PI); }这段代码在非严格模式下是可以运行的,因为with能把对象的属性直接变成当前作用域的变量。但在严格模式下,规范直接禁止with。所以到了ES5之后的环境中,这段代码一运行就会抛SyntaxError。这就是标准的强制力:语法层面直接不让写。
第二个是全局对象的问题。浏览器顶层var声明的变量会挂到全局对象上,但标准层面并没有强制规定“全局对象就是window”。不同宿主环境给了不同的全局对象。长期以来开发者被这个差异折腾得够呛,直到ES2020提供了globalThis这个统一入口,才算是给所有环境一个标准答案。
但globalThis也不是银弹。在某些运行环境里,实现它的版本滞后,你依然得自己判断环境类型去取对应的全局对象。
第三个是模块机制。ES6开始有了规范的模块系统,但早期浏览器和运行时对它支持不一。标准定义了模块的加载与执行语义,实际宿主环境则需要提供模块的解析算法、路径规则、缓存策略。所以你会发现,同样是import语句,在浏览器里、在其他运行时里、在打包工具里,解析规则是有差别的。
4.2 排查策略:先分清“标准层错误”和“宿主层错误”
既然标准与宿主边界这么明显,排查问题时就能直接分层。我自己的习惯是看到报错先问三个问题:
- 这段代码在语法层面对不对?如果语法不对,是标准规定不能写,还是当前引擎还没支持?
- 报错涉及的对象和API有没有在全局环境里?比如
document is not defined,这是宿主能力缺失。 - 新版API在当前运行环境有没有垫片?比如老环境不支持
Promise,这是标准与实现的时间差。
照着这个顺序排查,大部分困扰新人的“玄学报错”都能迅速定位。举个例子,你在服务端代码里用了document.getElementById,报错不是因为JavaScript语法有问题,而是这个代码运行在一个没有document的环境里。这是宿主层的问题,换到浏览器环境就自然解决了。
同样,你写了一行arr.at(-1),在某个旧引擎里报错,这就要查一下ES2022的Array.prototype.at是否被当前环境支持。如果明确不支持,就要么改写法、要么加垫片,而不是怀疑自己写错了语言。
5. 开发时如何用好这套差异:转译、垫片和类型系统
5.1 转译器把明天语法搬到今天
理解了版本演进带来的时间差,你就会明白转译工具为什么如此重要。一句话总结:转译器让你在代码里自由使用最新版ECMAScript语法,最终产出文件则编译成目标环境能看懂的老版本语法。
拿最典型的箭头函数举例。源代码里写:
const add = (a, b) => a + b;经过转译,会变成差不多这样:
function add(a, b) { return a + b; }老环境不认识箭头函数?没关系,转译器在打包阶段就把新语法翻译成旧语法,产出文件里根本没有箭头函数。这样一来,你在开发时写得舒服,发布后运行稳定,两者兼顾。
我见过不少团队的做法是:源码统一使用最新甚至Next版本语法,转译目标锁定ES5或ES2017,具体看用户浏览器覆盖范围。这是个合理的工程实践。值得注意的是,不是所有语法都能被转译器完美降级,比如某些依赖引擎内部行为的新API,单纯翻译语法是不够的。
5.2 用polyfill补齐缺失API
语法层面的问题交给了转译器,但API层面的缺失得另找办法。因为很多新特性不是一种语法,而是一个内置对象或方法。你没法通过改写语法来“翻译”Promise,因为它存在于运行时的对象体系里,你需要的是一个在运行时补上这个对象的垫片。
打个比方:转译器解决的是“说法不一样”的问题,垫片解决的是“根本没有这个工具”的问题。老环境没有Promise,我们就自己实现一个Promise塞到全局环境里,代码在运行时找到的Promise是这个自定义版本。
实际操作中,垫片往往跟转译器搭配使用:转译器处理语法,垫片补齐API。两者方向不同,目的相同——消除标准演进与旧环境之间的落差。
5.3 TypeScript与ECMAScript的关系
聊到这里,必须提一下TypeScript这类类型超集。因为很多人会把TypeScript和ECMAScript混为一谈,其实完全不是一回事。
TypeScript是建立在ECMAScript之上的类型系统扩展。它的核心思路是:在标准JavaScript语法的基础上,增加类型标注、接口、枚举、泛型等能力,让开发阶段能获得更强的静态检查。编译之后,TypeScript源码会变成普通JavaScript,输出目标可以由你选择是ES5、ES2017还是更新版本。
所以TypeScript和ECMAScript的关系不是替代关系,而是“在标准之上附加一层开发期能力”的关系。编译后的产物最终仍然是ECMAScript程序,只是不同版本的选择罢了。这也是为什么TS项目配合转译器一起用这么常见:一个负责类型校验,一个负责语法降级,各干各的。
5.4 转译到什么版本才合适
我不建议无脑把目标版本调到ES5,因为转译过度会让生成代码变得臃肿,还可能引入不必要的运行成本。个人经验中,转译目标的选定取决于产品用户群:
- 面向C端大众用户的大型网站,用户环境碎片化严重,最好转译到ES5或ES2017,配合完整垫片,稳妥为主。
- 内部管理系统、工具链、后端项目,使用者是新版浏览器,可以直接把目标定在ES2020甚至ES2022,代码更简洁,构建产物更小。
这么做的理由很简单:标准版本越新,代码越接近原始写法,可读性和运行效率越好;但覆盖面越大,你就越要照顾老环境。这个权衡没有标准答案,完全看你的用户画像。多留意自己产品的访问环境数据,就能定出一个合适的档位。
6. 那些“怪癖”背后的标准暗礁:经典疑问拆解
6.1 typeof null 的历史包袱
一个被无数面试官翻来覆去问的题目:为什么typeof null返回的是"object",但null明明不是对象?
console.log(typeof null); // "object"这个“错误”其实藏在标准的历史包袱里。早期引擎实现中,类型的标识是通过低位标记判断的,对象类型的标记值时,null的空指针恰好与对象类型标记撞在了一起。后续标准修订时,考虑到已经有大量线上代码依赖这个行为,就一直没有修改。严格来说这是规范层面的“历史遗留设计”,不是哪家浏览器自己搞出来的。
明白这个起源,你会发现很多前端问题根本不是“面试官刁难”,而是标准与实现演化过程中留下的结构性痕迹。与其死记结论,不如了解一下为什么。
6.2 加号的双重身份与ASI分号规则
再来看一个让人头疼的例子:
console.log("1" + 2); // "12" console.log("1" - 2); // -1加号运算符有点特殊,它既负责字符串拼接,又负责数值相加。当一边是字符串时,另一边会被转为字符串;而减号运算符只有数值语义,所以"1"会被转成数字再做减法。这套行为在ECMAScript规范里写得非常明确,但在日常工作里经常被当成“JavaScript很奇怪”的吐槽素材。
另一个典型的规范细节是自动分号插入。标准允许你在多数语句结尾省略分号,并在特定规则下自动插入分号。这听起来很省事,但在某些边界情况下会产生诡异行为:
const result = [1, 2, 3].map(item => item * 2) console.log(result)这段代码单独看问题不大,但如果上一行没有分号,且下一行以方括号或小括号开头,就可能被解析成一次函数调用,而不是一个新语句的开头。规范就是这么规定的:自动插入分号有它的规则,但规则并不理解你的“视觉意图”。
6.3 面试中被反复拷打的问题,底层其实都是规范细节
很多经典面试题,表面上是考“语法怪癖”,本质上考的是你对ECMAScript规范机制的理解程度。比如:
var和let的区别,根子在变量声明的作用域与提升机制上。- 箭头函数没有自己的
this,因为标准规定它继承外层词法作用域。 Promise的执行顺序,要看标准里微任务队列的规定。- 可选链和空值合并运算符,是ES2020才有的语法能力,老环境必须转译才能跑。
new.target、super、类字段这些,也全是标准层定义的行为。
当你把问题归到“这是规范怎么定义的”之后,记忆负担会小很多。标准文档读起来虽然晦涩,但遇到疑难时翻一翻,比在网上搜各种互相矛盾的答案要可靠得多。这也是我推荐每个有一定经验的前端开发者认真读一遍规范总览的原因。
写在最后:我个人的真实体会
说点掏心窝子的经验。从我开始接触前端到现在,最明显的变化是:JavaScript的生态复杂度不断提高,但语言本身的标准化程度也在同步提高。十年前写代码,到处是兼容性补丁,今天写代码,更多是选择用哪个标准版本再配合什么工具链。
我自己的习惯是,遇到任何怪异报错先拆成“标准问题”和“环境问题”两层。标准问题就查ES规范文档,环境问题就查对应宿主环境的支持清单。做完这个分类,八成的疑问都能在几分钟内定位。剩下的两成,往往才是真正值得深挖的实现细节。
ECMAScript和JavaScript的核心差异,说到底就是“标准定义”和“具体实现”之间那条线。理解线在哪,你就掌握了这门语言最底层的认知框架。以后不管是学新特性、读框架源码、还是跟面试官过招,都能少踩很多坑,也会比大多数人看得更通透。