为何 ES Module 如此姗姗来迟

说明:本文发布之后,此问题的推进峰回路转,不停有新内容。文末新增一节 Updates,跟进本文发布之后的 ES Module 标准化进展情况。

浏览器大战多年了热度依旧高涨,大家终于在 JS 新特性的部署上达成一致纷纷追赶最新标准,然而 ES2015 中的 ES Module 这个万众期待的重要特性却始终迟迟未能实现。

等 2020 年回望历史,倘若我们错过了 ES Module 这艘船而 Node.js 死在汪洋大海之中,没有任何其他技术问题的重要性可以与此相比。
-- issac

Module 的规范是完工了的,只是对于模块如何加载和解析留给了“实现环境决定”——按历史经验,问题往往就出现在这一环。当然了不是烫手山芋 W3C 也不会就这么轻松甩开对吧,事实上这也不是 W3C 一家的事情,牵涉到 TC39、Node 技术委员会、Node 和前端两个开发社群,以及 npm 公司。

故事很长,我们从头说起。importexport 的语法规范很明确,模块的解析器 V8 早已实现,万事俱备只欠加载。区区加载能有多麻烦?

Module 的特性

在新规范下,JavaScript 程序划分成两种类型:脚本(我们以前写的传统JS)和模块(ES规范中新定义的 Module),模块有四项于脚本不同的特性:

  1. 强制严格模式(无法取消)
  2. 执行环境在一个非全局的作用域中
  3. 可以使用 import 导入其他 Module 的 binding
  4. 可以使用 export 导出本 Module 的 binding

看上去规则简单明白,但是要让一个解析器(parser)区分兼容这两种模式还挺复杂的。

解析器的难题

看看代码中是否包含 importexport 关键字不就可以判断它的类型了么?

不行。首先猜测用户意图是个危险行为,如果你猜对了,就更加掩盖了猜错可能会造成的风险。

而严格模式,除了运行时的一些要求之外还定义了几个语法错误:

  1. 使用 with 关键字;
  2. 使用八进制字面量(如 010);
  3. 函数参数重名;
  4. 对象属性重名(仅在 ES5 环境。ES6 取消了此错误);
  5. 使用 implementsinterfaceletpackageprivateprotectedpublicstaticyield 作为标识符。

这些语法错误需要在解析时就抛出来。所以如果以脚本模式解析到了文件末尾才发现有 export,就得从头重新解析整个文件来捕捉上述语法错误。

那我们换一条路,开始先假定为模块进行解析代码。既然 Module 语法相当于严格模式 + 导入导出 (importexport),我们可以用脚本模式 + 导入导出的语法来解析整个文件。然而这种解析规则已经超越了规范定义,这么扭曲的路线可以预见它成为 Bug 源泉的样子。

危险但不是不可能。OK 真正的麻烦来了:按照规范 importexport 都是可选的——你可以写一个 Module,既不导入也不导出任何东西,它只是对全局作用域做些小动作,比如这样:

// 一个合法的 Module
window.addEventListener("load", function() {
    console.log("Window is loaded");
});
// WAT!

总的来说,包含 importexport 表明它一定是个 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;如果 importrequire 各自负责导入各自的格式,那么开发者就需要知道所有依赖的库的格式,使用相应语法来导入它,并且在依赖的库们更换到新格式的时候修改自己的代码去兼容……在可预见的 CommonJS -> ES Module 漫长过渡期里这样的负担对社区而言不可接受。

为此社区提出了不少方案,(好消息)经过大量的讨论之后现在已经集中到两个选择还在讨论:

  1. 解析器自动检测。最大的好处是对用户而言透明,可惜原因如前所述,此方案已否定。
  2. 使用 "use module" 标注。一想到 JS 的未来永远都要在文件开头贴这么个膏药大家就不能忍了。否定。
  3. 新的文件后缀 .jsm。主要问题是现有社区工具链全部需要更新才能支持,另外和浏览器实现的统一也要考虑。
  4. 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)

你可能感兴趣的:(javascript,node.js,ecmascript,es6)