说明:本文发布之后,此问题的推进峰回路转,不停有新内容。文末新增一节 Updates,跟进本文发布之后的 ES Module 标准化进展情况。
浏览器大战多年了热度依旧高涨,大家终于在 JS 新特性的部署上达成一致纷纷追赶最新标准,然而 ES2015 中的 ES Module 这个万众期待的重要特性却始终迟迟未能实现。
等 2020 年回望历史,倘若我们错过了 ES Module 这艘船而 Node.js 死在汪洋大海之中,没有任何其他技术问题的重要性可以与此相比。
-- issac
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,既不导入也不导出任何东西,它只是对全局作用域做些小动作,比如这样:
// 一个合法的 Module
window.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": ["."],
}
这还没完,更多方案就不详述了,大家可以到 Node.js Wiki 上查看。
就个人偏好而言,尽管所有的方案都有利有弊,而 package.json
这条路为了兼容各种需求,修改版的提案已经越来越复杂,比较起来 .jsm
后缀倒是愈发显得简单清晰了。我更喜欢这个干净的解决方案。
现在的进展(2016.04.15)
已经加入 HTML 规范,WhatWG 刚刚发了一篇文章讲述他们如何经过艰苦卓绝的努力达成这一目标,接下来就看浏览器厂商实现了。
除此之外 WhatWG 手上还有一个 ES Module loader 规范,用于指定 Module 的动态加载方式。它曾经是 ES6 草案的一部分,但因为 ES2015 “要赶着发布来不及了”不幸被砍,目前归属 WhatWG 推进。
Node.js 这边,在相当一段时间里我们还要借助 transpiler 来体验 ES Module。这件事需要 V8、Node.js、WhatWG 共同协调完成。
按计划本月 Node.js 发布 6.0,顺利的话可以 确定集成 V8 5.0(BTW,一天后 V8 发布了 5.1),对 ES2015 的特性支持达到 93%——看来 ES Module 很可能会成为 “The last ES2015 feature” 了。
关注 ES Module 的进展,还可以看看几个地方:
- Node 社区提案和讨论:https://github.com/nodejs/nod...
- V8 的实现:https://bugs.chromium.org/p/v...
- Blink 的实现:https://bugs.chromium.org/p/c...
愿 ES Module 早日到来。
Updates
关于 ES Module 在 Node.js 环境下的识别方案,从一月份 bmeck 提出提案开始社区就持续沟通和争论,以下是相关进展更新。
- 2016.01.08
bmeck 提出关于 ES Module 的提案(增加新后缀.mjs
),社区讨论开始。 - 2016.02.06
社区提的方案归纳起来,有四个方向。 - 2016.04.15
本文发布的日子。 - 2016.04.20
经过两个月的密集讨论,四个方向只剩下两个存活:.mjs
派和package.json
派,然而这两派的争论非常激烈。 - 2016.04.27
鉴于.mjs
已经在正式提案中,倘若讨论持续僵持不下,不出意外.mjs
将会随着时间推移而正式成为规范。怀着这样的危机感,package.json
派发起了 In defense of dot js 来抗衡.mjs
的提案,要求保持.js
后缀不变而使用package.json
来识别 ES Module。 - 2016.06.14
重大转折!bmeck 提出一个新的方案 UnambiguousJavaScriptGrammar:既然两边的纠结都是因为无法从文件本身识别 ES Module 而起,不妨调整一点语法细节(ES Module 中的exports
语句不再是可选的,至少有一句exports {}
来表明该文件是个 ES Module),两派的争论就这么迎刃而解了! - 2016.07.06
经过 Node.js TSC 的讨论,Unambiguous JavaScript Grammar 方案正式加入提议(proposal)。 - 2016.07.07
虽然 Unambiguous JavaScript Grammar 加入了 Node.js 的草案提案(5.1章),但是考虑到距离 TC39 的七月会议只剩下一周时间,而 Node.js 这边希望做更充分的调研和测试再进行讨论,所以从这次 TC39 的议程中拿掉了。 - 2016.09.06
Domenic 提了import()
作为动态加载的方案,有望取代System.import()
或System.loader.import()
。 - 2016.09.17
ES Module 再次提上 TC39 的议事日程,相关的还有内建模块和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.10
bmeck 在 Twitter 表示已经实现了.mjs
加载器的原型,在 Node.js v9 中可以用 flag 的方式启用,(希望)在 v10 中正式推出。也就是还有一年的时间,一切顺利的话 2018 年 4 月就能看到 ES Module 正式加入 Node.js LTS。 -
2017.05.11
工具链对.mjs
后缀的支持都在推进中:- Babel: https://github.com/babel/babe...
- Babili/babel-minify already supports .mjs: https://github.com/babel/babi...
- AVA: https://github.com/avajs/ava/...
- Visual Studio Code: https://github.com/Microsoft/...
- 2018.03.30
Node.js 项目中和 ES Module 实现相关的 Issue 和 PR - 2018.04.25
Node.js 10.0.0 发布,加入了对 ES Module 的实验性支持(需要--experimental-modules
开启)
https://github.com/nodejs/nod... - 2019.03.28
新版 ES Module 设计定案,PR 合进主干(https://github.com/nodejs/nod...),特性有变,仍然使用--experimental-modules
开启。目前的计划是赶上 4 月份 Node.js 12 发布,最终在 2019 年 10 月进入 LTS。
参考资料
- https://www.nczonline.net/blo...
- https://github.com/nodejs/nod...
- http://awal.js.org/blog/es6/2...
- http://es2015-node.js.org/
- https://medium.com/@jasnell/a...
- http://2ality.com/2017/05/es-...
- https://github.com/nodejs/mod...