Node.js ES Module 为什么必须加文件扩展名

解释 Node.js 中 ES Module 需要完整路径和扩展名的原因

问题

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

解答

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

无法从语法上区分 ES Module

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

虽然从开发者角度看,有 import/export 语句就是 ES Module,但没有这些语句也不代表就不是 ES Module。Node 社区曾在 TC39 提出过几种方案:

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

"use module";
// 模块代码

优点是容易理解和实现,缺点是对已有 export 语句的模块显得多余。

方案 2:通过 export 语句判断

对于不需要导出的模块,使用 export {} 标记:

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

优点是大多数模块不需要额外标记,缺点是解析器需要预扫描整个文件寻找 export 语句。TypeScript 就是使用这种方案。

方案 3:引入新语法标记

这些方案在 TC39 都未通过,将来也不太可能引入。

通过外部信息识别

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

  • Web 平台:通过 <script type="module"> 标明,或在其他 API 中指定,如 new Worker(path, {type: 'module'})
  • Node.js:通过文件扩展名 .mjs 或 package.json 中的 "type": "module" 字段标明

为什么需要完整路径

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

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

这种机制基于文件系统访问成本低的假设。但在浏览器端,这会导致多次网络请求,成本无法接受。因此浏览器端的 import 语句使用标准 URL,通常包含完整扩展名:

// 浏览器端
import MyModule from './my-module.js';

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

浏览器与 Node.js 的差异

浏览器端import "./file.js" 总是将 file.js 作为 ES Module 解析。

Node.js:根据外部信息解析。.js 后缀默认按 CommonJS 解析,除非 package.json 设置了 "type": "module"

{
  "type": "module"
}

此外,浏览器端不能直接使用裸名字导入:

// 需要通过 import maps 定义
import "my-module"; // 不能直接使用

Node.js 虽然支持裸名字导入,但也加入了类似 import maps 的机制。

关键点

  • TC39 无法从语法上区分 ES Module 和传统 Script,需要外部信息(扩展名或配置)来标识
  • 浏览器端 import 使用标准 URL,避免多次网络请求,Node.js 为保持一致性也要求完整路径
  • Web 平台通过 <script type="module"> 标识,Node.js 通过 .mjs 扩展名或 package.json 的 "type": "module" 标识
  • Node.js 中 .js 文件默认按 CommonJS 解析,除非明确指定为 module 类型