说明:本文发布之后,此问题的推进峰回路转,不停有新内容。文末新增一节 ,跟进本文发布之后的 ES Module 标准化进展情况。
浏览器大战多年了热度依旧高涨,大家终于在 JS 新特性的部署上达成一致纷纷追赶最新标准,然而 ES2015 中的 ES Module 这个万众期待的重要特性却始终迟迟未能实现。
等 2020 年回望历史,倘若我们错过了 ES Module 这艘船而 Node.js 死在汪洋大海之中,没有任何其他技术问题的重要性可以与此相比。 --
Module 的规范是完工了的,只是对于模块如何加载和解析留给了“实现环境决定”——按历史经验,问题往往就出现在这一环。当然了不是烫手山芋 W3C 也不会就这么轻松甩开对吧,事实上这也不是 W3C 一家的事情,牵涉到 TC39、Node 技术委员会、Node 和前端两个开发社群,以及 npm 公司。
故事很长,我们从头说起。import
和 export
的语法规范很明确,模块的解析器 V8 早已实现,万事俱备只欠加载。区区加载能有多麻烦?
Module 的特性
在新规范下,JavaScript 程序划分成两种类型:脚本(我们以前写的传统JS)和模块(ES规范中新定义的 Module),模块有四项于脚本不同的特性:
- 强制严格模式(无法取消)
- 执行环境在一个非全局的作用域中
- 可以使用
import
导入其他 Module 的 binding - 可以使用
export
导出本 Module 的 binding
看上去规则简单明白,但是要让一个解析器(parser)区分兼容这两种模式还挺复杂的。
解析器的难题
看看代码中是否包含import
和export
关键字不就可以判断它的类型了么?
不行。首先猜测用户意图是个危险行为,如果你猜对了,就更加掩盖了猜错可能会造成的风险。
而严格模式,除了运行时的一些要求之外还定义了几个语法错误:
- 使用
with
关键字; - 使用八进制字面量(如
010
); - 函数参数重名;
- 对象属性重名(仅在 ES5 环境。ES6 取消了此错误);
- 使用
implements
、interface
、let
、package
、private
、protected
、public
、static
或yield
作为标识符。
这些语法错误需要在解析时就抛出来。所以如果以脚本模式解析到了文件末尾才发现有 export
,就得从头重新解析整个文件来捕捉上述语法错误。
那我们换一条路,开始先假定为模块进行解析代码。既然 Module 语法相当于严格模式 + 导入导出 (import
和 export
),我们可以用脚本模式 + 导入导出的语法来解析整个文件。然而这种解析规则已经超越了规范定义,这么扭曲的路线可以预见它成为 Bug 源泉的样子。
危险但不是不可能。OK 真正的麻烦来了:按照规范 import
和 export
都是可选的——你可以写一个 Module,既不导入也不导出任何东西,它只是对全局作用域做些小动作,比如这样:
// 一个合法的 Modulewindow.addEventListener("load", function() { console.log("Window is loaded");});// WAT!
总的来说,包含 import
或 export
表明它一定是个 Module,但没有这两个关键字却不能证明它不是 Module。 ╮(╯_╰)╭
区分 JavaScript 文件类型的任务没法放在解析器里自动完成,我们需要在解析文件之前就知道它的类型。
浏览器的办法
这就是为什么浏览器的模块引用是这个写法:
当浏览器开始加载这个 foo.js
,它会边加载边解析,碰到 import { bar } from './bar.js'
的第一时间开始加载依赖的 bar.js
,加载完之后对其解析,检查其中是否导出了 bar
。如此往复完成整个 Module 的解析。
Node.js 呢
到了 Node.js,新的问题来了。
作为软件包仓库,npm 中现有的软件包都是 CommonJS 规范。ES Module 需要能够与 CommonJS 模块共存,允许开发者们逐步转向新的语法。
所谓的共存,主要是指 import { foobar } from 'foobar'
语法要支持 CJS Module 和 ES Module 两种包格式——如果 import
只能用来导入 ES Module 而 require
可以导入任意模块,那么所有人都会用 require
;如果 import
和 require
各自负责导入各自的格式,那么开发者就需要知道所有依赖的库的格式,使用相应语法来导入它,并且在依赖的库们更换到新格式的时候修改自己的代码去兼容……在可预见的 CommonJS -> ES Module 漫长过渡期里这样的负担对社区而言不可接受。
为此社区提出了不少方案,(好消息)经过大量的讨论之后现在已经集中到两个选择还在讨论:
- 解析器自动检测。最大的好处是对用户而言透明,可惜原因如前所述,此方案已否定。
- 使用
"use module"
标注。一想到 JS 的未来永远都要在文件开头贴这么个膏药大家就不能忍了。否定。 - 新的文件后缀
.jsm
。主要问题是现有社区工具链全部需要更新才能支持,另外和浏览器实现的统一也要考虑。 - 在
package.json
上发挥。这个门类下的提议就更多了,比如添加一个module
字段逐步替代掉main
:
{ // ... "module": "lib/index.js", "main": "old/index.js", // ...}
这个方案只适用单入口的情况,对多文件(比如 require('foo/bar.js')
的场景)就不行了。那就改成 modules
字段(复杂度陡升):
{ // ... // files: "modules": ["lib/hello.js", "bin/hello.js"], // directories: "modules": ["lib", "bin"], // files and directories: "modules": ["lib", "bin", "special.js"], // if package never uses CJS Modules "modules": ["."],}
这还没完,更多方案就不详述了,大家可以到 上查看。
就个人偏好而言,尽管所有的方案都有利有弊,而 package.json
这条路为了兼容各种需求,修改版的提案已经越来越复杂,比较起来 .jsm
后缀倒是愈发显得简单清晰了。我更喜欢这个干净的解决方案。
现在的进展(2016.04.15)
<script type="module" />
已经,WhatWG 刚刚发了讲述他们如何经过艰苦卓绝的努力达成这一目标,接下来就看浏览器厂商实现了。
除此之外 WhatWG 手上还有一个 ES Module loader 规范,用于指定 Module 的动态加载方式。它曾经是 ,但因为 ES2015 “要赶着发布来不及了”不幸被砍,目前 。
Node.js 这边,在相当一段时间里我们还要借助 transpiler 来体验 ES Module。这件事需要 V8、Node.js、WhatWG 共同协调完成。
按计划本月 Node.js 发布 6.0,顺利的话可以 确定(BTW,一天后 V8 发布了 5.1),对 ES2015 的特性支持——看来 ES Module 很可能会成为 “The last ES2015 feature” 了。
关注 ES Module 的进展,还可以看看几个地方:
- Node 社区提案和讨论:
- V8 的实现:
- Blink 的实现:
愿 ES Module 早日到来。
Updates
关于 ES Module 在 Node.js 环境下的识别方案,从一月份 bmeck 提出提案开始社区就持续沟通和争论,以下是相关进展更新。
- 2016.01.08bmeck 提出关于 (增加新后缀
.mjs
),社区讨论开始。 - 2016.02.06社区提的方案归纳起来,有。
- 2016.04.15本文发布的日子。
- 2016.04.20经过两个月的密集讨论,四个方向只剩下两个存活:
.mjs
派和package.json
派,然而这两派的争论非常激烈。 - 2016.04.27鉴于
.mjs
已经在正式提案中,倘若讨论持续僵持不下,不出意外.mjs
将会随着时间推移而正式成为规范。怀着这样的危机感,package.json
派发起了 来抗衡.mjs
的提案,要求保持.js
后缀不变而使用package.json
来识别 ES Module。 - 2016.06.14重大转折!bmeck 提出一个新的方案 :既然两边的纠结都是因为无法从文件本身识别 ES Module 而起,不妨调整一点语法细节(ES Module 中的
exports
语句不再是可选的,至少有一句exports {}
来表明该文件是个 ES Module),两派的争论就这么迎刃而解了! - 2016.07.06经过 Node.js TSC 的讨论,Unambiguous JavaScript Grammar 方案。
- 2016.07.07虽然 Unambiguous JavaScript Grammar ,但是考虑到距离 TC39 的七月会议只剩下一周时间,而 Node.js 这边希望做更充分的调研和测试再进行讨论,所以。
- 2016.09.06Domenic 提了 作为动态加载的方案,有望取代
System.import()
或System.loader.import()
。 - 2016.09.17ES Module 再次,相关的还有和
import()
。 -
2016.09.30
TC39 9月碰头会的与会者纷纷表示这次会议进展令人愉快,,以及。- Node.js 开发者想要提出一些修改规范的建议,也不知道合适不合适,沟通之后发现 TC39 是非常关心和在意每个社区的需求的(大家相谈甚欢)。
- 原本的 ES 规范要求模块加载过程需要先完成静态 parse 然后再 evaluate,但是现在的 Node.js CommonJS 模块无法满足这个要求(CJS 模块必须 evaluate 之后才知道 exports 的是什么)。讨论下来规范将会改为允许 parse 过程在碰到 import CJS 模块时进入一个挂起的状态,等待依赖树中的 CJS 模块 evaluate 之后再完成 parse。
-
对模块类型的检测目前是三个方案选项:
- Unambiguous JavaScript Grammar 看上去比较简单,但实现起来还是有不少坑;
- package.json 的办法比较累赘,局限也多;
-
.mjs
的方案最简单,看来是最可行的,而且也跟 Node.js 现有方式一致(用后缀.node
、.json
、.js
来区分加载类型)。除非 Unambiguous JavaScript Grammar 的实现问题都解决掉,否则最终方案就是它了。
-
import()
大家都觉得没问题,稳步推进中。 - 由于 ES Module 的静态特性,以前给 CJS 模块做动态 Mock、MonkeyPatch 的方式都不行了。不过解决办法也有,一是在加载阶段提供钩子,二是允许对已经加载的模块做热替换。
-
2017.02.12
Node.js CTC 和 TC39 的讨论:- 由于 ES6 模块的异步特性,require() 将无法加载 ES6 模块。
- Babel 目前支持的
import { foo } from 'node-cjs-module'
也不符合规范,想import
一个 NCJS 模块的话只能import m from 'node-cjs-module'
然后m.foo()
调用。 -
.mjs
是问题最少的选择。 - (悲伤的消息来了)就目前剩余的工作内容估计,距离 ES6 Module 最终实现大约还有至少一年的时间(往好的一面想,终于看得到 timeline 了)。
- 2017.05.10bmeck 已经实现了
.mjs
加载器的原型,在 Node.js v9 中可以用 flag 的方式启用,(希望)在 v10 中正式推出。也就是还有一年的时间,一切顺利的话 2018 年 4 月就能看到 ES Module 正式加入 Node.js LTS。 -
2017.05.11
工具链对.mjs
后缀的支持都在推进中:- Babel:
- Babili/babel-minify already supports .mjs:
- AVA:
- Visual Studio Code:
- 2018.03.30Node.js 项目中
- 2018.04.25Node.js 10.0.0 发布,加入了对 ES Module 的实验性支持(需要
--experimental-modules
开启) - 2019.03.28新版 ES Module 设计定案,PR 合进主干(),特性有变,仍然使用
--experimental-modules
开启。目前的计划是赶上 4 月份 Node.js 12 发布,最终在 2019 年 10 月进入 LTS。