Skip to content

为什么Node在使用es module时必须加上文件扩展名?

Posted on:2024年8月10日 at 19:54

这个事情分两部分说。

第一个问题是,我们需要用代码内容以外的信息(比如文件扩展名来确定一段代码是否是es module。

这件事情的根子是在TC39,在设计es module时就无法从语法上严格区分一段代码到底是es module还是传统的script(注意 commonjs 本质上仍然是传统script)。

有人可能会问,难道不是有importexport语句就是es module啊? 从开发者的理解上来说,确实是这样。但问题是,没有importexport语句也不代表就不是es module。

曾经node社区在TC39的代表提出提案(tc39/proposal-UnambiguousJavaScriptGrammar)来通过语法区分。可能的方案有几种:

  1. 类似"use strict",我们可以通过引入"use module"指令来解决。
    【优点:容易理解,也很容易实现,没有额外的解析成本;缺点:对于大多数已经有export语句的模块来说,有点脱裤子放屁。】

  2. 通过export语句是否存在来分辨,对于本身不需要export的模块,开发者通过加入export {}(这是语法上允许的export语句,虽然啥都不导出)来标记其为es module。
    【优点:对于大多数模块来说不需要额外标记;缺点:由于export语句并不必然在代码头部,所以解析器需要预扫描export语句,决定是否是es module。】

  3. 引入某种新的语法来标记。
    【优缺点:类似1】

但是这些方案在TC39讨论时都没法通过。并且可以判断,将来也不可能再引入。

PS:提醒,TypeScript就是使用 方案2 来确定是否是es module的。】

因为不能通过代码内容本身来判断是否是es module,那就需要某种外部信息。

对于Web平台来说,是通过<script type=module>来标明的(也延伸到其他标签,比如需要单独的<link rel=modulepreload>;也延伸到其他API,如new Worker(path, {type: 'module'})需要额外参数标明是es module)。

对于node.js这样的命令行来说,就要通过文件扩展名(.mjs)来标明,或者通过package.json文件中的"type": "module"字段来标明。


第二个问题是,我们需要用完整的路径(包含文件扩展名)来导入,即import "./my-module.mjs"而不是import "./my-module"

Node.js下的commonjs模块的resolve规则是按照服务器端脚本系统来设计的,它基于一个假设,即文件系统访问的成本是很小的(不过马后炮来说,今天的大型应用里,大量细碎小模块的resolve成本常常已经不能忽略),因此只要用起来方便,resolve规则复杂一点是ok的。

所以node.js的模块解析机制有复杂的fallback机制。比如对于require('./my-module') ,会先寻找该脚本同目录的my-module(不带有扩展名)文件,如果找不到则寻找my-module.js文件,如果再找不到则寻找my-module/index.js文件。

但如此的fallback如果无脑照搬到浏览器端,就会是多次的network roundtrip,这成本肯定是不能接受的。因此在浏览器端,import语句中引用的模块,就是一个标准的url,在没有其他额外处理(服务器端根据请求的url返回对应的文件,是可做类似node.js的fallback机制的)的情况下,通常也会包含完整的文件扩展名。

当年node.js加入commonjs模块时,它并不需要考虑和浏览器的一致性。即使后来前端的构建打包工具或一些前端加载器、框架等支持了commonjs模块,也是反过来去兼容node.js的。但今天node.js要加入es module,就需要考虑和浏览器的一致性。

最后,浏览器端import模块要注意的不仅是扩展名,还包括不能直接使用「裸名字」,即不能直接import "my-module"。如果要使用的话,需要通过import maps来预先定义。Node.js下虽然可以像require那样直接用import "my-module",但也加入了类似import maps的机制。

【补充】

之前遗漏了一个重要差异,对于import "./file.js",Web平台总是将file.js作为es module进行解析的,而node.js则总是依据前述外部信息对file.js进行解析。如后缀名为.js即默认按照commonjs进行解析,除非package.json中设定了"type": "module"。(node.js中commonjs模块如何当成一个es module使用,是另一个大问题,此处不赘述。)

理论上说,file.js不包含exportimport等只允许在es module中出现的语句,也不包含一些在es module中被禁用的特性,则file.js既可以按照es module解析,也可以按照传统script解析。Web平台就是如此,这就要求确定一个脚本资源时(比如缓存时),不是url唯一的,而是还需要纳入解析目标(parse goal)。(当然,本来就不是url唯一,需要考虑mime type的,但es module也仍然使用text/javascript的mime type。)

而node.js因为要考虑既有的commonjs资产,就决定要同时支持es module和commonjs,因此对于import "./file.js"就不可能总是按照es module解析。另一方面node.js的模块缓存一直以来也是基于url唯一的(文件系统没有mime type)。

原文转自:https://fe.ecool.fun/topic/6227d54d-4d71-47fb-9c72-359632847778