CommonJS 和 ES Module 模块化规范

6 阅读4分钟

来源

  • CommonJS 是社区(CommonJS 项目组)约定,非语言规范(2009 - 2010)。
  • ES Module 是 ECMAScript 语言标准的一部分(2015 年(规范正式发布)。

导入:

  • CJS 的 require()同步的:require 执行到那一刻才会得到导入结果。
  • ESM 的 import 默认处于顶层位置。是模块级别的静态导入,会在模块解析阶段就建立依赖关系。ESM 也有动态导入:await import("./xxx.js")

导出方式

  • CJS

    • 导出对象:exports = module.exports = { add }
    • 默认导出:module.exports = function add() {}
  • ESM

    • 导出对象:export function add () {}
    • 默认导出:export default function add() {}

导出内容

  • CJS

    • 模块导出的是 值的拷贝(基本类型复制值,对象类型复制引用)。
    • 导入方允许修改
    • 导出方改变基本数据类型,替换整个对象,导入方均不会看到变化。原始对象上修改可以。
  • ESM

    • 是活绑定,导出方修改可以实时看到变化。
    • 导入方不可以修改。

使用环境

  • CJS:服务器端(Node环境)
    • 文件扩展名 .mjs
    • package.json 设置 "type": "module"
  • ESM:浏览器 + 服务器端(通用)
    • 标签引入:<script type="module" src="index.js"></script>

注意事项

  • Node 用 package.json 的 "type" 决定 .js 是按 CJS 还是 ESM 解释。

    • "type" 只影响当前 package 范围内的 .js.mjs 永远按 ESM,.cjs 永远按 CJS。
  • ESM 下没有 __dirname / __filename(需要用 import.meta.url + fileURLToPath 处理)。

    import path from "node:path";
    import { fileURLToPath } from "node:url";
    // ESM 下获取所在文件夹地址
    const __filename = fileURLToPath(import.meta.url);
    const __dirname = path.dirname(__filename);
    console.log(__dirname);
    
  • 两套体系可以互相引用,但“互操作”不是随便写就能稳定运行,通常需要正确的后缀(.cjs/.mjs)、正确的导入方式,或者借助 createRequire。

    // 在 CJS 代码里加载 ESM(require 不行),要用 import()(动态导入)
    // CJS 加载 ESM 的安全写法
    (async () => {
      const mod = await import("./esm.mjs"); // 不可顶层导入
      console.log(mod.default);
    })();
    
  • 不要用 require 和 import 去加载非模块化文件

    json 文件 CJS 可以直接 reuquire,ESM 需要额外处理。

处理 json 文件

  • CJS: Node.js 内置了对 CommonJS 的 require 的支持,当 require 一个 .json 文件时,它会:读取文件内容;将内容解析为 JSON 对象;直接返回该对象。

  • ESM: ES6 的 import 语法是静态分析且默认只处理 JavaScript 模块。在 Node.js 中,直接使用 import pkg from "../package.json" 会报错,因为:Node.js 默认不识别 .json 文件作为 ES 模块

解决方法

  1. 声明类型为 json
import pkg from "../package.json" with { type: "json" };
console.log(pkg);

const pkg = await import("../package.json", { with: { type: "json" } });
console.log(pkg.default); // 输出直接可使用的 json 对象
  1. 运行中读取
import { readFileSync } from "fs";
const pkg = JSON.parse(
  readFileSync(new URL("../package.json", import.meta.url), "utf8"),
);
  1. ESM 下的替代方案
import { createRequire } from "module";
const require = createRequire(import.meta.url);
const pkg = require("../package.json");

requier 同步导入 ≠ 动态导入

  • require() 是 CommonJS 的同步加载函数,并且在运行时执行
  • 它“看起来动态”的点在于:你可以在 if、函数体里、甚至用变量拼路径后再调用 require()
  • 可以说 require 是 运行时加载 / 可动态调用
  • 说动态导入容易和 import() 混淆。

既然 require 是运行时加载,那 webpack 怎么在编译的时候分析依赖关系呢?

不矛盾,因为这是两件事

  • require 的“同步”描述的是运行时行为:代码执行到这一行时,Node 同步加载并返回模块
  • webpack 分析依赖发生在构建时:它不执行你的业务逻辑,而是先把源码当文本做语法解析(AST),从中提取 require/import 语句,建立依赖图
// 对这种写法,webpack 很容易静态分析:
const a = require("./a");

// 对这种动态表达式,静态分析就困难:
const a = require("./" + name);

相对路径的不同含义(扩展)

  1. 在 Node.js 中,任何文件系统操作(如 fs.readFile、fs.stat、fs.createReadStream 等),如果传入的是相对路径,都会相对于当前工作目录(Current Working Directory, cwd) 进行解析。
    • 当前工作目录是启动 Node.js 进程时的目录,可以通过 process.cwd() 获取。
    • 因此在Node中操作文件,建议用绝对路径。
// 假设当前工作目录是 /home/user/project
// 文件 /home/user/project/data.txt 存在
fs.readFileSync("./data.txt"); // 读取 /home/user/project/data.txt

// 如果运行 node src/app.js,而 src/app.js 内部有上述代码,
// 因为进程是在 /home/user/project 启动的,所以仍然读取 /home/user/project/data.txt
  1. 当使用 require('./foo') 或 import './foo.js' 时,相对路径的解析基准是当前模块文件所在的目录
// 文件 /home/user/project/src/app.js
require("../data.txt"); // 解析为 /home/user/project/data.txt