Node ES Module 为什么需要文件扩展名

解释 Node.js 中 ES Module 必须使用文件扩展名的原因

问题

为什么 Node.js 在使用 ES Module 时必须加上文件扩展名?

解答

这个问题涉及两个方面:如何识别 ES Module,以及为什么需要完整路径。

无法从语法上区分 ES Module

TC39 在设计 ES Module 时,无法从语法上严格区分一段代码是 ES Module 还是传统 script(CommonJS 本质上仍是传统 script)。

虽然有 importexport 语句的代码是 ES Module,但没有这些语句也不代表就不是 ES Module。

Node 社区曾在 TC39 提出提案(tc39/proposal-UnambiguousJavaScriptGrammar)来通过语法区分,可能的方案包括:

方案 1:引入 “use module” 指令

"use module";
// 模块代码

优点是容易理解和实现,没有额外解析成本;缺点是对于已有 export 语句的模块显得多余。

方案 2:通过 export 语句判断

对于不需要 export 的模块,开发者通过 export {} 标记为 ES Module。

// 没有导出内容,但标记为 ES Module
export {};

优点是大多数模块不需要额外标记;缺点是 export 语句不一定在代码头部,解析器需要预扫描。

方案 3:引入新语法标记

优缺点类似方案 1。

这些方案在 TC39 讨论时都未通过,将来也不太可能引入。TypeScript 使用的是方案 2 来确定是否是 ES Module。

需要外部信息标识

既然无法通过代码内容判断,就需要外部信息:

Web 平台:通过 <script type="module"> 标明

<script type="module" src="./app.js"></script>
<link rel="modulepreload" href="./module.js">
new Worker(path, { type: 'module' });

Node.js:通过文件扩展名或 package.json 标明

// 使用 .mjs 扩展名
import "./my-module.mjs";
// 或在 package.json 中设置
{
  "type": "module"
}

为什么需要完整路径

Node.js 的 CommonJS 模块有复杂的 fallback 机制。对于 require('./my-module'),会依次查找:

  1. my-module(无扩展名)
  2. my-module.js
  3. my-module/index.js

这种机制基于文件系统访问成本很小的假设,在服务器端可以接受。

但在浏览器端,这样的 fallback 会导致多次网络请求,成本无法接受。因此浏览器端的 import 语句使用标准 URL,通常包含完整的文件扩展名:

// 浏览器端需要完整路径
import "./my-module.js";

Node.js 加入 ES Module 时需要考虑与浏览器的一致性,因此也要求使用完整路径。

浏览器端的额外限制

浏览器端不能直接使用「裸名字」:

// 不能直接使用
import "my-module";

// 需要通过 import maps 定义
<script type="importmap">
{
  "imports": {
    "my-module": "./node_modules/my-module/index.js"
  }
}
</script>

Node.js 虽然可以像 require 那样直接使用 import "my-module",但也加入了类似 import maps 的机制。

Web 平台与 Node.js 的解析差异

对于 import "./file.js"

  • Web 平台:总是将 file.js 作为 ES Module 解析
  • Node.js:根据外部信息解析,.js 后缀默认按 CommonJS 解析,除非 package.json 中设置了 "type": "module"

Web 平台在确定脚本资源时(如缓存),不仅考虑 URL,还需要纳入解析目标(parse goal)。

Node.js 为了兼容既有的 CommonJS 资产,决定同时支持 ES Module 和 CommonJS,因此 import "./file.js" 不能总是按 ES Module 解析。Node.js 的模块缓存一直基于 URL 唯一(文件系统没有 MIME type)。

关键点

  • TC39 无法从语法上严格区分 ES Module 和传统 script,因此需要外部信息(文件扩展名或配置)来标识
  • 浏览器端使用 <script type="module">,Node.js 使用 .mjs 扩展名或 package.json 的 "type": "module" 字段
  • CommonJS 的 fallback 机制在浏览器端会导致多次网络请求,ES Module 需要完整路径以保持与浏览器的一致性
  • Web 平台总是将 .js 文件作为 ES Module 解析,而 Node.js 默认按 CommonJS 解析(除非明确配置)