现在我们已经获取了这个文件,我们需要将它解析为模块记录。这有助于浏览器了解模块的不同部分。
一旦模块记录被创建,它会被记录在模块映射中。这意味着在这之后的任意时间如果有对它的请求,加载器就可以从映射中获取它。
解析中有一个细节可能看起来微不足道,但实际上有很大的影响。所有的模块都被当作在顶部使用了 "use strict"
来解析。还有一些其他细微差别。例如,关键字 await
保留在模块的顶层代码中,this
的值是 undefined
。
这种不同的解析方式被称为「解析目标」。如果你使用不同的目标解析相同的文件,你会得到不同的结果。所以在开始解析你想知道正在解析的文件的类型 —— 它是否是一个模块。
在浏览器中这很容易。你只需在 script 标记中设置 type="module"
。这告诉浏览器此文件应该被解析为一个模块。另外由于只有模块可以被导入,浏览器也就知道任何导入的都是模块。
但是在 Node 中,不使用 HTML 标签,所以没法选择使用 type
属性。社区试图解决这个问题的一种方法是使用 .mjs
扩展名。使用该扩展名告诉 Node「这个文件是一个模块」。你会看到人们将这个叫做解析目标的信号。讨论仍在进行中,所以目前还不清楚 Node 社区最终会决定使用什么信号。
无论哪种方式,加载器会决定是否将文件解析为模块。如果是一个模块并且有导入,则加载器将再次启动该过程,直到获取并解析了所有的文件。
我们完成了!在加载过程结束时,从只有一个入口文件变成了一堆模块记录。
下一步是实例化此模块并将所有实例链接在一起。
就像我之前提到的,实例将代码和状态结合起来。状态存在于内存中,因此实例化步骤就是将内容连接到内存。
首先,JS 引擎创建一个模块环境记录(module environment record)。它管理模块记录对应的变量。然后它为所有的 export 分配内存空间。模块环境记录会跟踪不同内存区域与不同 export 间的关联关系。
这些内存区域还没有被赋值。只有在求值之后它们才会获得真正的值。这条规则有一点需要注意:任何 export 的函数声明都在这个阶段初始化。这让求值更加容易。
为了实例化模块图,引擎将执行所谓的深度优先后序遍历。这意味着它会深入到模块图的底部 —— 直到不依赖于其他任何东西的底部 —— 并处理它们的 export。
引擎将某个模块下的所有导出都连接好 —— 也就是这个模块所依赖的所有导出。之后它回溯到上一层来连接该模块的所有导入。
请注意,导出和导入都指向内存中的同一个区域。先连接导出保证了所有的导出都可以被连接到对应的导入上。
这与 CommonJS 模块不同。在 CommonJS 中,整个 export 对象在 export 时被复制。这意味着 export 的任何值(如数字)都是副本。
这意味着如果导出模块稍后更改该值,则导入模块并不会看到该更改。
相比之下,ES 模块使用叫做动态绑定(live bindings)的东西。两个模块都指向内存中的相同位置。这意味着当导出模块更改一个值时,该更改将反映在导入模块中。
导出值的模块可以随时更改这些值,但导入模块不能更改其导入的值。但是,如果一个模块导入一个对象,它可以改变该对象上的属性值。
之所以使用动态绑定,是因为这样你就可以连接所有模块而不需要运行任何代码。这有助于循环依赖存在时的求值,我会在下面解释。
因此,在此步骤结束时,我们将所有实例和导出 / 导入变量的内存位置连接了起来。
现在我们可以开始求值代码并用它们的值填充这些内存位置。
最后一步是在内存中填值。JS 引擎通过执行顶层代码 —— 函数之外的代码来实现这一点。
除了在内存中填值,求值代码也会引发副作用。例如,一个模块可能会请求服务器。
由于潜在的副作用,你只想对模块求值一次。对于实例化中发生的链接过程,多次链接会得到相同的结果,但与此不同的是,求值结果可能会随着求值次数的不同而变化。
这是需要模块映射的原因之一。模块映射通过规范 URL 来缓存模块,所以每个模块只有一个模块记录。这确保了每个模块只会被执行一次。就像实例化一样,这会通过深度优先后序遍历完成。
那些我们之前谈过的循环依赖呢?
如果有循环依赖,那最终会在依赖图中产生一个循环。通常,会有一个很长的循环路径。但为了解释这个问题,我打算用一个短循环的人为的例子。
让我们看看 CommonJS 模块如何处理这个问题。首先,main 模块会执行到 require 语句。然后它会去加载 counter 模块。
然后 counter 模块会尝试从导出对象访问 message
。但是,由于这尚未在 main 模块中进行求值,因此将返回 undefined。JS 引擎将为局部变量分配内存空间并将值设置为 undefined。
求值过程继续,直到 counter 模块顶层代码的结尾。我们想看看最终是否会得到正确的 message 值(在 main.js 求值之后),因此我们设置了 timeout。之后在 main.js
上继续求值。
message 变量将被初始化并添加到内存中。但是由于两者之间没有连接,它将在 counter 模块中保持 undefined。
如果使用动态绑定处理导出,则 counter 模块最终会看到正确的值。在 timeout 运行时,main.js
的求值已经结束并填充了该值。
支持这些循环依赖是 ES 模块设计背后的一大缘由。正是这种三段式设计使其成为可能。
随着 5 月初会发布的 Firefox 60,所有主流浏览器均默认支持 ES 模块。Node 也增加了支持,一个工作组正致力于解决 CommonJS 和 ES 模块之间的兼容性问题。
这意味着你可以在 script 标记中使用 type=module
,并使用 import 和 export。但是,更多模块特性尚未实现。动态导入提议正处于规范过程的第 3 阶段,有助于支持 Node.js 用例的 import.meta 也一样,模块解析提议也将有助于抹平浏览器和 Node.js 之间的差异。所以我们可以期待将来的模块支持会更好。
感谢所有对这篇文章给予反馈意见,或者通过书面和讨论提供信息的人,包括 Axel Rauschmayer、Bradley Farias、Dave Herman、Domenic Denicola、Havi Hoffman、Jason Weathersby、JF Bastien、Jon Coppeard、Luke Wagner、Myles Borins、Till Schneidereit、Tobias Koppers 和 Yehuda Katz,也感谢 WebAssembly 社区组、Node 模块工作组和 TC39 的成员们。
Lin 是 Mozilla 开发者关系组的一名工程师。她研究 JavaScript、WebAssembly、Rust 和 Servo,也画过一些代码漫画。
相关阅读:漫画:深入浅出 ES 模块(上篇)
本文来自网易实践者社区,经作者徐子航授权发布。