这个事情分两部分说。
第一个问题是,我们需要用代码内容以外的信息(比如文件扩展名来确定一段代码是否是es module。
这件事情的根子是在TC39,在设计es module时就无法从语法上严格区分一段代码到底是es module还是传统的script(注意 commonjs 本质上仍然是传统script)。
有人可能会问,难道不是有import
、export
语句就是es module啊? 从开发者的理解上来说,确实是这样。但问题是,没有import
、export
语句也不代表就不是es module。
曾经node社区在TC39的代表提出提案(tc39/proposal-UnambiguousJavaScriptGrammar)来通过语法区分。可能的方案有几种:
-
类似
"use strict"
,我们可以通过引入"use module"
指令来解决。
【优点:容易理解,也很容易实现,没有额外的解析成本;缺点:对于大多数已经有export
语句的模块来说,有点脱裤子放屁。】 -
通过
export
语句是否存在来分辨,对于本身不需要export
的模块,开发者通过加入export {}
(这是语法上允许的export语句,虽然啥都不导出)来标记其为es module。
【优点:对于大多数模块来说不需要额外标记;缺点:由于export
语句并不必然在代码头部,所以解析器需要预扫描export
语句,决定是否是es module。】 -
引入某种新的语法来标记。
【优缺点:类似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
不包含export
、import
等只允许在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)。