Skip to content

ES6 模块加载: 比你想象的更复杂 #5

@luokuning

Description

@luokuning

Nicholas C. Zakas 的一篇博客,原文点这里

ECMAScript 6 中最让人期待已久的特性之一就是模块化正式成为语言的一部分。多年来,JavaScript 开发者一直在如何组织他们的代码上挣扎着,以及如何选择 RequireJS, AMD 或者 CommonJS 来开发。模块化的正式定稿将会在未来完全消除这个问题,但现在,还存在很多关于模块化是如何工作的疑问。这些疑问存在的部分原因是目前还没有引擎能原生加载 ES6 模块,所以我希望这篇文章能澄清一些疑问。

什么是模块 (module)?

首先需要明白规范定义了两种 JavaScript 程序存在的形式: script 标签 (自 JavaScript 诞生开始我们就一直在用的) 和 ES6 的模块 ( module)。script 标签我们再熟悉不过,但是处理 module 的方式却有些不同,下面列出了模块的特殊点:

  1. 总是处于严格模式
  2. 具有顶层 (top-level) 作用域,但又不是全局 (global) 作用域
  3. 可以通过 import 关键字从其他模块导入程序绑定 (bindings, 通常就是变量或者函数)
  4. 可以通过 export 关键字导出指定的 bingdings

这些不同点看起来很微妙,但实际上足够让模块在解析和加载上与现有的 script 完全不同。

解析的差异

我们在 ESLint 上收到的关于 ES6 模块最常见的一个问题就是:

为什么我需要在一个模块被解析前指定它是一个模块?为什么不能通过查找 import 或者 export 关键字来确定它是模块?

我经常能在网上见到这种问题,因为大家一直难以理解为什么 JavaScript 引擎和工具不能自动检测一个文件是模块而不是普通的通过 script 标签引入的脚本。第一眼看过去,似乎检测 importexport 关键字就足以判断一个 JavaScript 文件是不是模块了,但实际上,只能说这样太 naive 了。

尝试去猜测用户的意图这太危险也不够清晰,如果你猜对了,也许能搞个大新闻,但是如果猜错了,你将来就要负责任的。

解析的挑战

为了能自动检测某个 JS 文件是否是模块,首先得解析整个文件。模块并不一定会使用 import 关键字,所以想的乐观一点的话就是模块很可能在文件的末尾使用了 export 语句,因此你也无法逃避要去解析整个文件。

要注意模块始终是处于严格模式下的,而严格模式不仅对于运行时有要求,还定义了以下的一些语法限制 (不能出现在严格模式中):

  1. with 语句
  2. 函数重复命名的参数
  3. 八进制数字直接量 (比如 010)
  4. 重复属性名 ( ES5 会报错,ES6 不会)
  5. 使用 implements, interface, let, package, private, protected, public, staticyield 作为标识符

所有上面这些语法错误在非严格模式中都不算错误。如果你已经知道了某个文件的末尾有 export 关键字,那么实际上你就得在严格模式下重新解析一遍整个文件,以确保不会出现上面的语法错误,而第一次解析就浪费在非严格模式的解析中了 (为了获取 export 关键字)。

很明显,如果你想通过文件内容来检测它是否是模块,那么你就必需总是强制地先把它当成模块来解析。由于模块的语法限制是严格模式外加允许存在 exportimport 关键字,所以你得默认允许这两个关键字的出现。如果你在非严格模式下去解析,那么 exportimport 关键字会被解析为语法错误。当然你可以自定义一个非严格模式下允许存在这两个关键字的解析模式,不过这样的话在这种反常模式解析出来的代码也不能用,因此当模式确定之后需要第二次解析。

那什么时候才能确定是模块?

边缘的情况就是模块其实并不一定会使用 exportimport 关键字,一个模块可以既不导入也不导出任何东西,很可能就是在全局作用域中修改一些什么东西。举个栗子,你可能只是想当 window.onload 在浏览器里被触发的时候输出一条信息,那么你定义的模块就会像这样:

// 一个合法的模块

window.addEventListener("load", function() {
    console.log("Window is loaded");
});

这个模块能被其他模块导入,自己也能被加载执行。如果只看源代码的话,没办法知道这其实是一个模块。

总结: exportimport 关键字的存在可能指示这是一个模块文件,但是没有的话也不能就说一定不是模块,所以在解析的时候是没有高效的办法确定一个文件是否是模块的。

加载的差异

解析模块的这些差异看起来着实有些微妙,但是对于加载模块的差异来说就不是这样了。当一个模块被加载完之后,import 关键字会触发加载指定的文件。被加载进来的文件必需在完全解析和加载 (不报错) 之后,模块才能开始执行。为了能尽快完成这些工作,当解析到 import 关键字的时候就会去加载指定的文件,然后优先解析模块其余的代码。

一旦依赖加载完毕,会有额外的一步来验证这些被导入的引用绑定实际上是真的存在于依赖文件中的。如果你从 foo.js 导入了 foo,那么 JavaScript 引擎就需要在继续执行前验证 foo 确实是从 foo.js 导出来的。

加载 (loading) 将会如何工作?

现在我希望你已经清楚了为什么在导入和解析一个模块之前就需要指明这是一个模块。在浏览器里你需要这样导入一个模块 (html) :

<script type="module" src="foo.js"></script>

script 标签没什么特别的,但是 type 要指定为module,这就告诉浏览器 foo.js 是一个模块。如果 foo.js 里又通过 import 导入了其他模块,那么这些文件会被动态的加载进来。

NodeJS 还没决定怎么加载 ES6 模块,但是目前最受推崇的一种方式是使用一个特定的扩展名,比如 .jsm,这样 NodeJS 就能知道这是一个 ES6 模块文件,从而能正确的加载它。

结论

普通脚本文件和 ES6 模块之间的差异确实很微妙,所以很难让一些开发者们理解为什么需要事先就定义一个文件是模块文件。我希望这篇文章能稍微澄清为什么不可能通过源代码就能自动检测一个文件是不是模块,以及一些工具比如 ESLint 需要你事先指定文件的类型。未来 ES6 模块将会成为最主要的 JavaScript 文件类型,而 script 标签只会存在老应用当中,到那个时候,很可能这些工具会默认的把文件都当成模块来处理。与此同时,我们正在经历一个 script 和 ES6 模块同时存在的艰难发展期。

更新

修正 (2016/4/06): 删掉了之前说的 import 语句只能出现在文章的开头 (译者注: import 其实可以出现在模块的任何位置,只要处于顶层就行,如果处于块级作用域则会报错)。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions