你真的理解 require 和 import 的区别吗?不只是语法不同,它们的加载时机、值的传递方式、循环依赖处理都截然不同。本文通过 require 源码和 ESM 规范,解释这些差异背后的实现机制,让你彻底搞懂 JavaScript 模块系统的运行原理。
模块化的演进
JavaScript 最初为浏览器脚本语言,没有模块系统。随着应用规模增长,全局变量污染和代码组织问题凸显,催生了模块化方案。
全局变量时代:所有代码共享全局作用域,变量冲突频发。
javascript
// 多个脚本容易产生命名冲突
var data = "script1";
var data = "script2"; // 覆盖前一个
IIFE 模式:利用函数作用域隔离变量。
javascript
var Module = (function () {
var private = "private";
return { public: "public" };
})();
CJS (2009):Node.js 采用,服务端模块规范。
AMD (2010):RequireJS 推广,浏览器异步加载方案。
ESM(2015):ECMAScript 官方标准,静态结构。
语法对比
CJS 语法
导出方式:
javascript
// 1. module.exports 导出对象
module.exports = { name: "foo", version: "1.0" };
// 2. module.exports 导出函数
module.exports = function () {};
// 3. module.exports 导出类
module.exports = class MyClass {};
// 4. exports 添加属性(exports 是 module.exports 的引用)
exports.name = "foo";
exports.version = "1.0";
// 注意:直接赋值 exports 会断开引用
// exports = {} // ❌ 无效
导入方式:
javascript
// 1. 导入整个模块
const module = require("./module");
// 2. 解构导入
const { name, version } = require("./module");
// 3. 动态路径(运行时计算)
const env = "production";
const config = require(`./config.${env}`);
// 4. 条件导入
if (condition) {
const module = require("./module");
}
ESM 语法
导出方式:
javascript
// 1. 命名导出 (Named Export)
export const name = 'foo';
export function fn() {}
export class MyClass {}
// 2. 批量命名导出
const name = 'foo';
const version = '1.0';
export { name, version };
// 3. 重命名导出
export { name as moduleName };
// 4. 默认导出 (Default Export) - 每个模块只能有一个
export default function() {}
// 或
export default class MyClass {}
// 或
export default { name: 'foo' };
// 5. 混合导出(命名 + 默认)
export const name = 'foo';
export default function() {}
// 6. 转发导出
export { name } from './other.js';
export * from './other.js';
export { default as otherDefault } from './other.js';
导入方式:
javascript
// 1. 导入命名导出
import { name, version } from "./module.js";
// 2. 导入并重命名
import { name as moduleName } from "./module.js";
// 3. 导入默认导出
import MyModule from "./module.js";
// 4. 混合导入
import MyModule, { name, version } from "./module.js";
// 5. 导入所有命名导出为命名空间对象
import * as Module from "./module.js";
// 6. 仅执行模块
import "./module.js";
// 7. 动态导入(可在任意位置,返回 Promise)
const module = await import("./module.js");
// 或
if (condition) {
import("./module.js").then((module) => {});
}
CJS 加载机制
CJS 是 Node.js 实现的模块系统。不同于语言层面的特性,CJS 是一个运行时概念 ,核心是 Node.js 内置的 require 函数。理解 require 的实现原理,有助于理解 CJS 的各种特性。
require 实现原理
require 函数负责加载模块,内部维护 require.cache 缓存对象。下面是简化的伪源码:
javascript
function require(modulePath) {
// 1. 解析为绝对路径
const absolutePath = require.resolve(modulePath);
// 2. 检查缓存
if (require.cache[absolutePath]) {
return require.cache[absolutePath].exports;
}
// 3. 创建 Module 对象
const module = {
id: absolutePath,
exports: {},
loaded: false,
// ... 其他属性
};
// 4. 提前放入缓存(处理循环依赖的关键)
require.cache[absolutePath] = module;
// 5. 读取文件内容
const code = fs.readFileSync(absolutePath, "utf8");
// 6. 包装为函数
const wrapper = "(function (exports, require, module, __filename, __dirname) { " + code + "\n});";
// 7. 编译并执行
const compiledWrapper = vm.runInThisContext(wrapper);
compiledWrapper.call(
module.exports, // this 指向 exports
module.exports, // 参数 1: exports
require, // 参数 2: require
module, // 参数 3: module
absolutePath, // 参数 4: __filename
path.dirname(absolutePath) // 参数 5: __dirname
);
// 8. 标记为已加载
module.loaded = true;
// 9. 返回 module.exports
return module.exports;
}
// require.cache: 缓存对象
require.cache = {};
// require.resolve: 解析路径
require.resolve = function (modulePath) {
// 解析算法:相对路径、绝对路径、node_modules 查找等
return absolutePath;
};
从伪源码可以看出,require 做了这些事:解析路径、检查缓存、创建 Module 对象、提前放入缓存、包装并执行代码、返回 module.exports。这种设计带来了以下特性。
官方文档 :Node.js Modules: The module wrapper | Node.js Modules: require
同步执行,不支持 top-level await
从伪源码的第 5 步可以看到,require 使用 fs.readFileSync 同步读取文件,会阻塞代码直到模块加载完成。由于 CJS 模块代码被包装在普通函数中(非 async 函数),因此不支持 top-level await。
javascript
// module.js
console.log("module 执行");
module.exports = { name: "foo" };
// main.js
console.log("开始");
const mod = require("./module"); // 阻塞,等待 module.js 执行完成
console.log("结束", mod.name);
// 输出:
// 开始
// module 执行
// 结束 foo
// ❌ CJS 不支持 top-level await
// await someAsyncFunction(); // SyntaxError: await is only valid in async functions
值拷贝
伪源码的第 4 步将 Module 对象放入缓存,第 2 步检查缓存时直接返回 module.exports。这意味着所有 require 返回的是同一个对象引用。
javascript
// counter.js
let count = 0;
module.exports = {
count: count, // 导出时 count 的值为 0
increment() {
count++;
},
};
// main.js
const counter = require("./counter");
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0(对象属性未变,count 是模块内部变量)
// other.js
const counter2 = require("./counter");
console.log(counter === counter2); // true(同一个对象)
循环依赖处理
伪源码的关键设计是第 4 步:在模块代码执行前,就将 Module 对象放入缓存 (此时 loaded: false)。这使得循环依赖时,后加载的模块能拿到前一个模块未完成的 exports。
javascript
// a.js
console.log("a 开始");
exports.done = false;
const b = require("./b");
console.log("在 a 中,b.done =", b.done);
exports.done = true;
console.log("a 结束");
// b.js
console.log("b 开始");
exports.done = false;
const a = require("./a"); // 此时 a 未执行完,exports.done = false
console.log("在 b 中,a.done =", a.done);
exports.done = true;
console.log("b 结束");
// main.js
require("./a");
// 输出:
// a 开始
// b 开始
// 在 b 中,a.done = false
// b 结束
// 在 a 中,b.done = true
// a 结束
官方文档 :Node.js Modules: Cycles
不要重新赋值 exports
伪源码第 7 步执行包装函数时,将 module.exports 作为参数传入,赋值给 exports。这意味着 exports 只是 module.exports 的引用。一旦重新赋值 module.exports,引用关系就断开,之后修改 exports 就无法导出了。
javascript
// module.js
exports.name = "foo";
console.log(exports === module.exports); // true
module.exports = { version: "1.0" }; // 重新赋值
console.log(exports === module.exports); // false
exports.age = 18; // 无效,exports 已失效
// main.js
const mod = require("./module");
console.log(mod); // { version: '1.0' }
缓存清除与模块重载
由于 require.cache 是一个普通的 JavaScript 对象,可以通过删除缓存来强制重新加载模块。这在开发热重载等场景中很有用。
javascript
// module.js
console.log("模块执行");
module.exports = { timestamp: Date.now() };
// main.js
const mod1 = require("./module"); // 输出: 模块执行
console.log(mod1.timestamp);
// 删除缓存
delete require.cache[require.resolve("./module")];
const mod2 = require("./module"); // 再次输出: 模块执行
console.log(mod2.timestamp); // 不同的时间戳
console.log(mod1 === mod2); // false
ESM 加载机制
ESM 是 ECMAScript 标准定义的模块系统,是语言层面的特性。不同于 CJS 的运行时加载,ESM 在代码执行前进行静态分析,确定模块依赖关系。
ESM 加载过程
ESM 的加载过程是将入口文件(entry point file)转换为模块实例(module instance)的过程。这个过程分为三个阶段:Construction (构建)、Instantiation (实例化)、Evaluation(求值)。
核心概念
Module Record(模块记录):
- 文件被解析后生成的数据结构
- 包含模块的 import/export 信息、代码等
Module Instance(模块实例):
- 由代码(code)和状态(state)组成
- 代码是指令集(如何做事的配方)
- 状态是变量的实际值(存储在内存中)
Entry Point File(入口文件):
- 模块图的起点
- 浏览器通过
<script type="module" src="main.js">指定入口
阶段 1:Construction(构建)
查找、获取、解析文件,构建模块图。
关键点:
- Loader 负责查找和获取文件,浏览器和 Node 的 loader 不同
- 解析时识别所有静态
import/export声明 - 逐层构建完整的模块依赖图
- 动态
import()不在此阶段处理
Module Map(模块映射):
- Loader 使用 Module Map 缓存模块
- 键是模块的唯一标识,值是 Module Record
- 确保每个模块只被加载和解析一次
javascript
// Module Map 示例(概念)
{
'https://example.com/main.js': ModuleRecord { ... },
'https://example.com/counter.js': ModuleRecord { ... }
}
阶段 2:Instantiation(实例化)
在内存中为导出值分配空间,建立 import/export 的实时绑定(live bindings)。
实时绑定(Live Bindings):
- Export 和 import 指向内存中的同一个位置
- 导出模块修改值,导入模块能看到变化
- 与 CJS 的值拷贝不同
javascript
// counter.js
export let count = 0;
export function increment() {
count++; // 修改 count
}
// main.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1(实时绑定,看到了变化)
关键点:
- 此阶段只分配内存,不填充值
- 导出的函数声明会在此阶段初始化
- 使用深度优先后序遍历(depth-first post-order traversal)
阶段 3:Evaluation(求值)
执行模块的顶层代码(top-level code),填充内存中的值。
如网络请求] D -->|否| F[完成] E --> F
关键点:
- 顶层代码:函数外的代码
- 每个模块只求值一次(Module Map 确保)
- 可能产生副作用(side effects):网络请求、修改 DOM 等
- 深度优先后序遍历:先求值依赖,再求值当前模块
浏览器和 Node.js 的 Construction 差异
ESM 的三阶段加载流程在浏览器和 Node.js 中是一致的,但在 Construction 阶段存在差异。Construction 包含两个过程:Module Resolution (模块解析,确定模块路径)和 Fetch(获取文件)。
浏览器
Module Resolution(模块解析):
浏览器使用完整的 URL 作为模块标识符。
html
<!-- 入口文件 -->
<script type="module" src="/main.js"></script>
javascript
// main.js - 必须使用相对路径或绝对路径
import { add } from "./math.js"; // ✅ 相对路径
import { config } from "/config.js"; // ✅ 绝对路径
import { api } from "https://cdn.example.com/api.js"; // ✅ 完整 URL
// ❌ 裸模块标识符(bare specifier)不支持
import { lodash } from "lodash"; // 报错:Failed to resolve module specifier
Import Maps:浏览器通过 Import Maps 支持裸模块标识符。
html
<script type="importmap">
{
"imports": {
"lodash": "https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.js",
"vue": "/node_modules/vue/dist/vue.esm-browser.js"
}
}
</script>
<script type="module">
import _ from "lodash"; // ✅ 解析为 https://cdn.jsdelivr.net/npm/...
import { createApp } from "vue"; // ✅ 解析为 /node_modules/vue/...
</script>
Fetch(获取文件):
- 并行下载:浏览器会并行发起多个 HTTP 请求下载依赖模块
- 阻塞行为:必须等所有依赖下载完才能进入 Instantiation 阶段
- 非阻塞渲染 :
<script type="module">默认具有defer特性,不会阻塞页面渲染
解析模块路径] --> B[Fetch: 发起 HTTP 请求] B --> C[并行下载 a.js] B --> D[并行下载 b.js] B --> E[并行下载 c.js] C --> F[等待所有依赖下载完成] D --> F E --> F F --> G[进入 Instantiation 阶段]
Node.js
Module Resolution(模块解析):
Node.js 支持裸模块标识符,使用复杂的解析算法查找模块。
javascript
// Node.js 支持多种导入方式
import { readFile } from "fs"; // ✅ 内置模块
import express from "express"; // ✅ node_modules 查找
import { add } from "./math.js"; // ✅ 相对路径
import { config } from "/abs/path/config.js"; // ✅ 绝对路径
Node.js 的解析算法:
- 内置模块 :如
fs、path,直接返回 - 相对/绝对路径:按路径查找
- 裸模块标识符 :从当前目录开始,逐层向上查找
node_modules
javascript
// 当前文件:/project/src/index.js
import express from "express";
// Node.js 查找顺序:
// 1. /project/src/node_modules/express
// 2. /project/node_modules/express
// 3. /node_modules/express
package.json 的 exports 字段:
json
{
"name": "my-package",
"exports": {
".": "./dist/index.js",
"./utils": "./dist/utils.js"
}
}
javascript
import pkg from "my-package"; // 解析为 ./dist/index.js
import utils from "my-package/utils"; // 解析为 ./dist/utils.js
官方文档 :Node.js: Modules: Packages | Node.js: ECMAScript modules
Fetch(获取文件):
- 同步读取:Node.js 使用同步 I/O 读取本地文件
- 无网络延迟:读取本地文件速度快,但仍需等待所有依赖读取完
- 性能影响:大量模块时文件 I/O 仍有性能影响
解析模块路径] --> B[Fetch: 同步读取 a.js] B --> C[同步读取 b.js] C --> D[同步读取 c.js] D --> E[所有文件读取完成] E --> F[进入 Instantiation 阶段]
动态 import
ESM 提供两种导入方式:静态导入 (import 声明)和动态导入 (import() 函数)。
静态导入
静态导入在 Construction 阶段处理,必须在模块顶层使用。
javascript
// ✅ 顶层静态导入
import { add } from "./math.js";
import express from "express";
// ❌ 不能在代码块、函数、条件语句中使用
if (condition) {
import { add } from "./math.js"; // SyntaxError
}
function loadModule() {
import express from "express"; // SyntaxError
}
特点:
- 在代码执行前完成(Construction 阶段)
- 支持静态分析:打包工具可进行 tree-shaking
- 路径必须是静态字符串(不能是变量)
javascript
const path = './math.js';
import { add } from path; // ❌ SyntaxError
动态导入 import()
import() 是一个返回 Promise 的函数,可在任意位置使用。
javascript
// ✅ 条件加载
if (condition) {
const module = await import("./module.js");
}
// ✅ 函数内使用
async function loadModule() {
const express = await import("express");
return express.default;
}
// ✅ 动态路径
const env = "production";
const config = await import(`./config.${env}.js`);
// ✅ 按需加载(代码分割)
button.addEventListener("click", async () => {
const { Chart } = await import("./chart.js");
new Chart(data);
});
返回值:Module Namespace Object(模块命名空间对象)
javascript
// math.js
export const add = (a, b) => a + b;
export default function multiply(a, b) {
return a * b;
}
// main.js
const module = await import("./math.js");
console.log(module);
// {
// add: [Function: add],
// default: [Function: multiply]
// }
module.add(1, 2); // 3
module.default(2, 3); // 6
静态导入 vs 动态导入
| 特性 | 静态导入 import |
动态导入 import() |
|---|---|---|
| 语法 | 声明语句 | 函数调用(返回 Promise) |
| 使用位置 | 仅模块顶层 | 任意位置(函数、代码块等) |
| 路径 | 必须是静态字符串 | 可以是动态表达式 |
| 执行时机 | Construction 阶段 | 运行时(Evaluation 阶段) |
| Tree-shaking | ✅ 支持 | ❌ 不支持 |
| 条件加载 | ❌ 不支持 | ✅ 支持 |
| 返回值 | 直接绑定导出值 | Promise |
官方文档 :TC39: import()
静态结构
ESM 的 静态import/export 声明具有静态结构,在代码执行前就能确定模块依赖关系。这使得编译器和打包工具能在 Construction 阶段进行静态分析,带来诸多优化。
1. Tree-shaking(树摇优化)
打包工具能识别未使用的导出,移除死代码。
javascript
// utils.js
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
} // 未被使用
export function multiply(a, b) {
return a * b;
} // 未被使用
// main.js
import { add } from "./utils.js";
console.log(add(1, 2));
// 打包后:subtract 和 multiply 被移除
2. 循环依赖检测
构建工具能在编译时检测循环依赖。
javascript
// a.js
import { b } from "./b.js";
export const a = 1;
// b.js
import { a } from "./a.js"; // 循环依赖
export const b = 2;
// 构建工具可在编译时发出警告
3. 导出验证
在 Instantiation 阶段验证所有导入是否有对应的导出。
javascript
// math.js
export const add = (a, b) => a + b;
// main.js
import { add, subtract } from "./math.js";
// 错误:Instantiation 阶段报错
// SyntaxError: The requested module './math.js' does not provide an export named 'subtract'
值实时绑定,不能在导入后被修改
ESM 的导出和导入建立实时绑定(Live Bindings):import 和 export 指向内存中的同一位置,导出模块修改值时,导入模块能实时看到变化。
javascript
// counter.js
export let count = 0;
export function increment() {
count++;
}
// main.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1 - 实时看到变化
// ❌ 导入的绑定是只读的
count = 10; // TypeError: Assignment to constant variable
原理 :在 Instantiation 阶段,count 在内存中只有一份,import 和 export 都指向这个位置。
官方文档 :TC39: Module Environment Records | Node.js: Modules: Cycles
循环依赖处理
ESM 通过实时绑定 和三阶段加载优雅地处理循环依赖。
javascript
// a.js
import { b } from "./b.js";
export let a = "a";
console.log("a.js:", b);
// b.js
import { a } from "./a.js";
export let b = "b";
console.log("b.js:", a); // ReferenceError: Cannot access 'a' before initialization
// main.js
import "./a.js";
执行流程:
- Construction 阶段 :构建模块图,
main.js→a.js→b.js - Instantiation 阶段 :
- 为
a.js的a在内存中分配空间(未赋值) - 为
b.js的b在内存中分配空间(未赋值) - 建立实时绑定:
a.js的 import 绑定到b.js的 export,反之亦然
- 为
- Evaluation 阶段 (深度优先后序遍历):
- 先执行
b.js:此时a还未赋值,直接报错Cannot access 'a' before initialization
- 先执行
关键点:
- Instantiation 阶段已建立所有绑定关系
- Evaluation 阶段只是填充值
- 实时绑定,并且被初始化后,才能访问到正确的值
支持 top-level await
ESM 支持在模块顶层直接使用 await,无需包裹在 async 函数中。CJS 模块被包装在普通函数中执行,不支持 top-level await。
javascript
// data.js
const response = await fetch("https://api.example.com/data");
export const data = await response.json();
// main.js
import { data } from "./data.js";
console.log(data); // 等待 data.js 完成后执行
执行时机:
- top-level await 在 Evaluation 阶段执行
- 当前模块会暂停,等待 Promise 完成
- 依赖当前模块的父模块也会等待
使用场景:
javascript
// 1. 动态加载配置
const config = await import(`./config.${process.env.NODE_ENV}.js`);
export default config;
// 2. 等待数据库连接
import mongoose from "mongoose";
await mongoose.connect(process.env.DB_URL);
export { mongoose };
// 3. 条件加载 polyfill
const locale = navigator.language;
if (!Intl.PluralRules) {
await import(`https://cdn.example.com/polyfill/${locale}.js`);
}
注意事项:
javascript
// ⚠️ 阻塞效应:所有依赖此模块的模块都会等待
// slow-module.js
await new Promise((resolve) => setTimeout(resolve, 5000));
export const value = "done";
// main.js
import { value } from "./slow-module.js"; // 等待 5 秒
console.log(value);
// ⚠️ 错误处理:未捕获的 Promise rejection 会导致模块加载失败
await fetch("https://invalid-url.com/data"); // 整个模块加载失败
常见问题
ESM 相比 CJS 有什么好处?
1. 静态分析,支持 Tree-shaking
ESM 的 import/export 在编译时就能确定依赖关系,打包工具可以移除未使用的代码,显著减小打包体积。CJS 的 require 是运行时调用,无法静态分析。
2. 原生支持,无需打包
ESM 是 ECMAScript 标准,浏览器和 Node.js 原生支持。CJS 需要打包工具(Webpack、Browserify)转换才能在浏览器运行。
3. 异步加载,不阻塞渲染
ESM 在浏览器中异步加载,不会阻塞页面渲染(<script type="module"> 默认 defer)。CJS 同步加载不适合浏览器。
4. 实时绑定,动态反映变化
ESM 的导入导出是实时绑定,导出模块修改值时,导入模块能立即看到变化。CJS 是值拷贝,看不到后续变化。
5. 支持 Top-level await
ESM 可以在模块顶层直接使用 await,适合异步初始化场景(如数据库连接、配置加载)。CJS 不支持。
6. 更好的循环依赖处理
ESM 通过实时绑定处理循环依赖,虽然访问未初始化变量会报错(TDZ),但更容易发现问题。CJS 返回未完成的 exports,容易产生难以调试的 bug。
7. 统一的模块标准
ESM 是浏览器和 Node.js 通用的标准,同一套代码可以跨平台运行,无需维护两套模块系统。
在 Node.js 中 CJS 和 ESM 能否互相引用?
ESM 引用 CJS:✅ 支持
ESM 可以通过 import 引用 CJS 模块,Node.js 会将 module.exports 作为默认导出。
CJS 引用 ESM:❌ 不支持同步引用
CJS 的 require 是同步的,无法加载异步的 ESM 模块。必须使用动态 import() 函数(返回 Promise)。
Node 中如何判断一个 .js 文件是 CJS 还是 ESM?
Node.js 通过以下规则判断:
- 文件扩展名 :
.mjs文件是 ESM,.cjs文件是 CJS - package.json 的 type 字段 :
"type": "module":.js文件视为 ESM"type": "CJS"或无 type 字段:.js文件视为 CJS(默认)
- 查找最近的 package.json:从当前文件向上查找最近的 package.json
ESM 和 CJS 的差异
| 维度 | CJS | ESM |
|---|---|---|
| 定位 | Node.js 运行时模块系统 | ECMAScript 语言标准 |
| 加载时机 | 运行时(同步) | 编译时静态分析 |
| 语法 | require / module.exports |
import / export |
| 值的导出 | 值拷贝(快照) | 实时绑定(引用) |
| 循环依赖 | 返回未完成的 exports | 通过实时绑定处理,访问未初始化会报错 |
| Top-level await | ❌ 不支持 | ✅ 支持 |
| Tree-shaking | ❌ 不支持 | ✅ 支持 |
| 动态导入 | ✅ 原生支持(require 本身) |
需使用 import() 函数 |