7.3 模块加载:从CommonJS到ESM


7.3 模块加载:从CommonJS到ESM

本节摘要:模块系统的核心差异在两件事:加载时序(运行时拉取还是编译期解析)与导出语义(值的拷贝还是活的绑定)。CommonJS 的 require 是运行时同步函数、导出值拷贝;ES 模块的 import 是静态结构、导出活绑定,循环依赖时两者行为迥异。本节沿演化史讲清两套体系、循环依赖的现场、动态 import 与打包生态的衔接,最后给出新旧代码的选型口径。

两套模块系统的现场差异

同一份「计数器模块」在两套体系下的行为对照:

// CommonJS 侧 // counter.js let count = 0; function inc() { return ++count; } module.exports = { count, inc }; // main.js const counter = require('./counter'); console.log(counter.count); // 0 —— 导入那一刻的值拷贝 counter.inc(); counter.inc(); console.log(counter.count); // 仍是 0!main 拿到的是快照
// ES 模块侧 // counter.js export let count = 0; export function inc() { count++; } // main.js import { count, inc } from './counter.js'; console.log(count); // 0 inc(); inc(); console.log(count); // 2 —— 绑定指向模块内的变量本体

CommonJS 的导出是赋值瞬间的快照:count 的值在 require 那一刻被拷走,之后模块内部再怎么变,引用方手里的旧值不动(要拿新值得调用函数或访问 counter.count 属性形式——属性形式每次访问都取当前值,这是另一个细节陷阱)。ES 模块导出的是活绑定:import 进来的 count 就是原模块那个变量在当前作用域的别名,模块内变了,引用方立刻看到。这个差异在循环依赖场景下会被放大成完全不同的行为。

一、加载与求值的时序

CommonJS:运行时、同步、动态。 require 是普通函数调用,参数甚至可以是表达式:

const path = require(process.env.PLUGIN_NAME); // 运行时才决定加载谁

加载动作:找到文件 → 读入 → 包一层函数执行 → 拿到 module.exports → 缓存(同一路径只执行一次,后续 require 返回缓存)。同步的设计源于 Node 的本地文件读取(快),拿到浏览器里就成了灾难——网络延迟期间主线程干等,这正是早期浏览器模块方案(AMD)诞生的原因。

ES 模块:编译期、静态、异步三段式。 import 与 export 必须写在顶层(不能塞进 if),模块结构在代码运行前就完整确定。引擎的处理分三步:构建(递归发现并下载全部依赖,第 5 章的 modulepreload 提前占管道)→ 实例化(建立导出与导入的绑定关系,此刻还没有求值)→ 求值(按依赖序深度优先执行各模块顶层代码)。求值顺序确定:依赖先于依赖方,同层按 import 出现顺序。

静态性的红利被整个工具链吃掉:打包器靠 import 分析画出完整依赖图,tree-shaking(摇掉没被导入的导出)依赖「结构可知」;没有静态结构,CommonJS 的动态 require 让打包器只能保守地全量保留——这就是同一段库代码 ESM 版体积更小的原因。

图 7.3-1 模块生态演化时间线

图 7.3-1 模块生态演化时间线

二、循环依赖:两套体系的现场

模块 A 引用 B、B 又引用 A,是真实项目里躲不开的结构(工具函数互相调用、类型与运行时纠缠)。

CommonJS 的现场:require 是边执行边拉取,先到先得。A 开始执行 → require B → B 执行 → B 里 require A → A 已在缓存(部分执行的 exports 对象)→ B 拿到 A 此刻已完成的半成品(后面才定义的导出是 undefined)→ B 执行完 → 回到 A 继续执行完。

// a.js(CommonJS) exports.name = '模块A'; const b = require('./b'); console.log('A 看到 b.name =', b.name); // 模块B(B 已完整执行) exports.late = 'A 的后置导出'; // b.js(CommonJS) const a = require('./a'); // 拿到 A 的半成品 console.log('B 看到 a.name =', a.name); // 模块A(已赋值部分) console.log('B 看到 a.late =', a.late); // undefined(A 还没执行到那行) // 执行 require('./a') 的实测输出: // B 看到 a.name = 模块A // B 看到 a.late = undefined // A 看到 b.name = 模块B

ES 模块的现场:实例化阶段就把所有绑定关系画好(指向变量本体而不是值),求值时 B 若在 A 之前执行,访问 A 的绑定得到的是未初始化的变量(TDZ,第 1 章),运行时直接 ReferenceError;但把访问推迟到函数被调用时(那时求值已完成),则一切正常。

// a.mjs(ES 模块) import { bName, printA } from './b.mjs'; export const aName = '模块A'; console.log('A 里读 bName =', bName); // 正常:此刻 B 已完成 // b.mjs import { aName } from './a.mjs'; export const bName = '模块B'; export function printA() { return aName; } // console.log(aName) 写在 B 顶层会触发 TDZ 报错(A 尚未求值) // 实测输出:A 里读 bName = 模块B;调用 printA 正常返回 模块A

工程结论两句话:循环依赖尽量拆(把公共部分抽到第三个模块,依赖图变成菱形);拆不掉时,访问推迟——模块顶层只建引用,真正的取值放在函数体内等调用时再发生。这条建议对两套体系同样成立。

三、动态 import 与工程衔接

静态 import 之外的动态形式是一个返回 Promise 的函数:

button.addEventListener('click', async () => { const { openChart } = await import('./图表模块.js'); // 点击时才下载该模块及依赖 openChart(container); });

动态 import 是代码分割的语言级钩子:打包器见到它就切出一个异步块,首屏不加载、用到再取。重型可视化、富文本编辑器、地图这类「多数用户不打开」的功能,全靠它从首屏负担里摘出去。与之配套的工程约定:路由级分割(每个路由一个异步块)、组件级分割(弹层与重控件按需)、分割粒度以「用户旅程」为界,不以文件大小为界。

Node 侧的现状是双轨并存:CommonJS 仍是存量主流,ESM 是新代码方向,包的入口用 exports 字段同时声明两种入口;迁移期的经典坑是 __dirnamerequire 在 ESM 里不存在(前者用新全局替代思路、后者用动态 import 或构建工具接管),以及扩展名必须写全。

选型口径收束:浏览器新项目一律 ESM;Node 新模块优先 ESM、照顾旧生态可双格式发布;老 CommonJS 代码读懂 require 缓存与值拷贝语义即可,不必为改而改。工具链(打包器、tree-shaking、代码分割)全部围绕 ESM 的静态结构建立——这门语言生态未来十年的地基已经定了。

⚠️ 常见坑:ES 模块的 import 路径必须带相对前缀与扩展名(浏览器环境),构建工具虽然代劳了补全,但直接跑源文件时忘了写就是「模块解析失败」;另一个高频坑是把 CommonJS 的「require 缓存」当模块级单例用——ESM 求值同样只发生一次,但活绑定让你必须显式导出可变状态与修改函数,藏着掖着的隐式全局再无藏身之处。

  • 加载语义分水岭:require 运行时同步函数,import 编译期静态结构;
  • 导出语义分水岭:CommonJS 值拷贝快照,ESM 活绑定指向变量本体;
  • 循环依赖:CJS 拿半成品(后置导出 undefined),ESM 拿未初始化绑定(顶层访问 TDZ 报错);
  • 动态 import 是代码分割的语言钩子,路由级与组件级按用户旅程切;
  • 新代码一律 ESM,老代码读懂语义即可,迁移以入口双声明过渡。

综合演练:把一个 CommonJS 老模块迁到 ESM

一个典型老模块(导出工厂函数、内部 require 依赖、用 module.exports 挂单例),迁移前后对照:

// 迁移前:CommonJS 形态 const validate = require('./validate'); // 静态依赖 const adapters = { mysql: require('./mysql-adapter') }; let instance; module.exports = { create(config) { if (!validate(config)) throw new Error('配置不合法'); if (!instance) instance = adapters.mysql(config); return instance; }, reset() { instance = undefined; } // 测试用 };
// 迁移后:ESM 形态 import validate from './validate.js'; // 扩展名补全 import mysqlAdapter from './mysql-adapter.js'; let instance; export function create(config) { if (!validate(config)) throw new Error('配置不合法'); if (!instance) instance = mysqlAdapter(config); return instance; } export function reset() { instance = undefined; }

迁移清单逐项对账:require 改 import(顶层静态、补 .js 扩展名);module.exports 改命名导出(调用方从「拿对象属性」变「按名导入」,tree-shaking 从此认得谁没被用);__dirname 若被用到,改由入口注入或用新全局(ESM 里没有这个变量);动态部分(如按配置加载适配器)改成动态 import 的 Promise 形式。包对外双轨的口子留在清单文件的 exports 字段里:require 与 import 各指向一个入口,老消费者不炸、新消费者吃糖。

最常见的两类翻车也顺便预警:循环依赖行为变化——迁移后原来「碰巧能用」的半成品访问变成 TDZ 报错,这是好事(把隐性时序问题显性化),解法照旧是拆公共模块或推迟访问;执行时机变化——ESM 求值在解析完成后按依赖序进行,比 CommonJS 的「首次 require 时刻」更早也更确定,依赖「谁先被 require 谁先跑」的老初始化顺序可能露出问题,按依赖图梳理而不是按运气。

常见问答

import 和 require 能在一个文件里混用吗?
纯 ESM 文件里没有 require(那是 CommonJS 的运行时函数);CommonJS 文件里可以 import() 动态加载 ESM(拿到的是带命名导出的模块对象)。反过来「ESM 里同步 require」不存在。所以混用只有一种形态:以 ESM 为主、动态 import 兜旧包。构建工具会代你处理大部分场景,但直接跑源码时这套边界要心里有数。

为什么打包后代码的执行顺序看起来变了?
打包器把模块图拍平或重排,但语义等价是硬约束:依赖先于依赖方求值的规则被保留,变的只是物理位置与包装形态。如果你观察到「行为真的变了」,大概率是原代码依赖了「文件在 bundle 里的出现顺序」这种未定义行为——解法是把隐式依赖改成显式 import,让依赖图说真话。

tree-shaking 什么时候会失效?
三种典型:模块有副作用(顶层改了全局状态,打包器不敢删——包作者用「副作用清单」声明空白的模块才可摇);导出被动态访问(属性名拼接取值,静态分析跟不上);CommonJS 入口(值拷贝语义没有可摇的静态结构)。库作者视角的对策:提供 ESM 入口、声明副作用清单、顶层无副作用;使用者视角:确认锁文件里拉的是 ESM 版本,别让一个旧格式的传递依赖拖累整棵树。

双格式包怎么发布?
在包清单的 exports 字段里同时声明两把钥匙:require 指向 CommonJS 产物、import 指向 ESM 产物,各配自己的扩展名。两个工程细节决定成败:两份产物的模块实例必须隔离(互不引用对方的内部模块,否则一个包里出现两份单例);「副作用清单」要写对位置,别把有副作用的模块标成可摇。发布后用两种宿主各装一次验证,是最低成本的回归。

import map 是什么,和本章什么关系?
它是浏览器原生的一张「模块说明符解析表」:页面里声明「某裸名对应某地址」,模块代码就可以写裸导入而无需打包。它解决的是「浏览器没有包管理」的缺口——让不经过构建工具的页面也能用模块化依赖(配合 CDN 的 ESM 包)。对工程项目它暂时还是配角(打包器仍是主流),但对快速原型、教学环境、微前端的运行时映射,它已经够用。把它理解成「加载时序体系的一个外挂解析层」,出现时不陌生即可。

动态 import 的参数能用变量吗?
能,但有限制:打包器要求「静态可分析的前缀」——路径的固定部分必须是字面量,变量只能出现在后半段(反引号模板里前半固定)。import(./modules/${name}.js) 可以被切分成按需块;import(fullPath) 这种全变量的写法则会让打包器放弃分析、原样保留,产物不可控。这与 CommonJS 时代的完全动态 require 相比是刻意的收紧:保住「依赖图基本可知」这条工具链的命根子。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U