尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

require 改 import 的坑:CommonJS 与 ES Module 模块系统解析与迁移指南

发布时间:2026/9/26 5:48:30

资讯中心
01
ARTICLE

require 改 import 的坑:CommonJS 与 ES Module 模块系统解析与迁移指南

require 改 import 的坑:CommonJS 与 ES Module 模块系统解析与迁移指南
“require 改 import”这件事看起来就是把一行代码换掉实际改起来却经常一地鸡毛。我印象最深的一次是有位同事把一个跑了两年的内部工具从 CommonJS 迁到 ES Module改完第一行import { createServer } from http就报错一查原因不是拼写问题而是整个模块系统都换了。类似的问题这几年在 Node.js 生态里反复出现有人问为什么import ./utils找不到文件有人问为什么require在 ESM 文件里直接 ReferenceError还有人问__dirname怎么突然就没了。这些现象背后其实是一件事require()和import是两套完全不同的模块导入机制。前者来自 CommonJS 标准是 Node 早期自建的模块方案后者来自 ECMAScript 官方标准是 JavaScript 语言层面的模块方案。下面我会从这两套方案诞生的背景讲起把语法差异、运行机制、迁移踩坑以及日常选型一次说透。内容偏实战适合写 Node.js 项目时被模块写法困扰过的开发者参考。1. 这两套模块系统是怎么来的一段绕不开的历史1.1 为什么 Node 当年选了 CommonJSNode.js 在 2009 年诞生时JavaScript 还没有官方模块系统。浏览器里多个 JS 文件靠script标签按顺序加载文件之间的依赖靠全局变量谁的代码先执行、谁后执行都要靠人工维护非常容易冲突。服务端和浏览器不一样它需要文件级别的隔离和显式的依赖声明一个文件依赖另一个文件系统必须清清楚楚地知道加载顺序。CommonJS 就是当时社区提出的方案核心只有三样东西require()负责加载模块module.exports负责导出接口exports是module.exports的简写别名。这套设计非常直白底层基本就是“同步读取文件、编译执行、缓存结果”。Node 选择它的理由也很务实服务端所有模块都躺在本地磁盘上同步读取的成本很低加载流程简单可控对后端服务的性能也更友好。所以从 2010 年开始npm 生态里的包几乎全是用 CommonJS 写的。哪怕今天打开node_modules里的老包翻开源码大概率还是module.exports xxx的影子。这种历史惯性非常大也是后面import在 Node 里推广缓慢的根本原因。1.2 ES Module 标准落地后Node 是怎么接住第二套方案的2015 年ES6 正式把import和export写进了 JavaScript 语言标准。但它的设计目标和 CommonJS 完全不同它不是为运行时设计的而是为静态分析设计的。import和export必须出现在模块顶层导入路径不能是动态拼接的字符串因为编译器要在真正执行代码之前就建立起模块之间的依赖图。这么设计有三个直接好处第一很多错误可以在运行前被发现比如导入了一个根本不存在的导出第二构建工具可以借此做 tree shaking把没用到的代码在打包时丢掉第三浏览器和服务器可以共用同一套语法前端工程化工具链从此统一。换句话说ES Module 是“语言标准”的产物CommonJS 是“Node 生态先行的产物”。Node.js 从 8.5 版本开始实验性支持 ES Module到 13.2 之后才默认启用真正普及到生产环境是 Node 14/16 LTS 时代。但麻烦在于npm 上绝大多数包仍然是 CommonJSNode 不可能一夜之间把老的模块系统扔掉于是只能两套并行以.mjs结尾、或者所在 package.json 里声明了type: module的文件走 ESM其他文件默认走 CommonJS。这就解释了为什么你在 Node 里写import有时能用、有时报错——你先得搞清楚当前这个文件被 Node 判定成了哪套模块系统。2. 语法层面的差异写错第一行就会踩的坑2.1 最基本的导入导出写法对照先看最常见的两种写法。CommonJS 导出// user.cjs const name zhang; function greet() { return hello ${name}; } module.exports { name, greet };ES Module 导出// user.mjs export const name zhang; export function greet() { return hello ${name}; }对应导入// CommonJS const { name, greet } require(./user.cjs);// ES Module import { name, greet } from ./user.mjs;看起来差不多但背后的语义完全不同。CJS 的module.exports { ... }创建的是一个普通对象导入方拿到的是这个对象而 ESM 的export是声明式的绑定导入方拿到的不是一个对象快照而是对原模块变量的实时引用。后面讲循环依赖的时候这个区别会再次冒出来。除开基础写法还有几个 ESM 的常用语法大家要熟悉重命名导入用as比如import { greet as sayHello } from ./user.mjs整体导入用import * as user from ./user.mjs默认导出用export default function () {}对应导入import user from ./user.mjs。这些语法规则是固定的不能像 CJS 那样随便构造。2.2 默认导出的坑require 看到的是对象import 看到的是 defaultCJS 里最常见的做法是module.exports 一个函数或者module.exports { a: 1, b: 2 }引入时require直接把整个 exports 对象拿过来。但 ESM 里的“默认导出”是一个特殊命名叫default。Node 在加载一个 CJS 模块时会把module.exports整体映射为 ESM 的默认导出。所以你在 ESM 里写import foo from ./cjs-module.cjs通常能直接拿到 CJS 的module.exports。真正容易翻车的是命名导入当你在 ESM 里写import { a } from ./cjs.cjs时Node 会调用一个叫 cjs-module-lexer 的工具去静态分析这个 CJS 文件试着识别module.exports对象里有哪几个属性可以当作命名导出。大多数简单对象是能识别出来的但遇到动态组装导出就麻烦了module.exports Object.assign({ a: 1, b: 2 }, getExtra());b这个属性是运行时算出来的lexer 很可能识别不到。结果 ESM 里import { b } from ./x.cjs直接报错你只能改成import x from ./x.cjs再手动解构。这个问题的本质是CJS 的导出发生在运行时而 ESM 的命名导入发生在编译期两者天然有信息差。如果你是库作者想被 ESM 用户友好地使用最好显式导出具名变量不要只丢一个动态组装的对象出来。2.3 扩展名、目录导入、JSONESM 的三条硬规矩Node 判断模块类型的规则非常明确.mjs结尾的文件永远按 ESM 加载。.cjs结尾的文件永远按 CommonJS 加载。.js结尾的文件看最近的 package.json 的type字段。type: module时按 ESM没有该字段时按 CJS。移植 CJS 代码到 ESM 时最容易触犯三条硬规矩。第一导入路径必须写完整文件名import ./util.js不能省略.js更不能依赖 Node 去“猜”。第二不能像 CJS 那样import ./controllers自动加载目录下的index.js必须写完整路径import ./controllers/index.js。第三JSON 文件不能直接importNode 20 支持import data from ./config.json with { type: json }但老版本不认老项目中多半要靠readFile或createRequire兜底。这些限制源自 ESM 对浏览器 URL 语义的兼容。在浏览器里/a和/a.js本来就是两个不同资源模块解析器不可能去猜你有没有省略后缀。Node 为了不让两套生态产生分裂ESM 解析规则直接继承这套更严格的语义。关于 JSON我个人的习惯是如果只是读一次配置用readFileJSON.parse最省心如果这个 JSON 是某个 CJS 老模块的默认导出那就用createRequire。这个技巧后面会展开讲。3. 运行时行为同步加载、缓存、作用域与循环依赖3.1 加载时机编译期解析 vs 运行时执行require()是一个普通函数代码执行到这一行模块才会被加载、执行、缓存。因为它是运行时函数所以可以玩出很多动态操作const lang process.env.LANG zh ? ./i18n.zh.json : ./i18n.en.json; const dict require(lang);这种写法在 CJS 里完全合法。ESM 的import声明则是编译期语法不能放进if里路径也不能是模板字符串。要动态加载只能用import()。注意这里的import()是个函数形式的动态导入它返回 Promiseif (process.env.LANG zh) { const dict await import(./i18n.zh.json); }两者的加载时机差异带来的另一个现象是错误发现时机不同。ESM 在模块实例化阶段就会校验导入的名称是否存在import { missing } from ./x.mjs即使你后面一行都没调用加载阶段就会报错CJS 的require不会做这种校验const { missing } require(./x.cjs)大概率只是拿到一个undefined直到你真正调用它才爆炸。前者对代码质量更友好后者则更宽松。3.2 缓存机制一个以路径为 key一个以 URL 为 key两套模块系统都有缓存目的是避免同一个模块被反复执行。但缓存的 key 大不相同CJS 以解析后的绝对文件路径为 key同一个文件被require一百次第一次执行后剩下九十九次都走缓存ESM 的缓存以完整的“资源标识符”为 key本质上更像 URL所以import(./a.js)和import(./a.js?t1)会被当成两个不同模块分别加载。这个差别在实际工程里有个很经典的应用做热更新或版本化缓存时ESM 可以通过在 URL 后面拼 query 参数来强制绕过缓存CJS 想做类似的事情要麻烦得多。反过来如果你在微前端或双格式包场景里调试“为什么改了代码不生效”就要先想到是不是模块缓存两头各有一份在作怪。3.3 环境差异this、__dirname、严格模式CJS 模块顶层this指向module.exportsESM 模块顶层this是undefined。这个坑非常隐蔽有些老代码喜欢在模块顶层写this.foo 1来导出迁到 ESM 后会静默失效不会报错但导出凭空少了一块。__dirname和__filename在 CJS 里是内置变量ESM 里没有。ESM 只提供import.meta.url需要自己转一次import { fileURLToPath } from node:url; import path from node:path; const __filename fileURLToPath(import.meta.url); const __dirname path.dirname(__filename);如果你代码里到处都是__dirname建议先封一个工具函数再统一替换。另外ESM 默认就是严格模式不能使用未声明的变量delete等受限语法也会直接报错。CJS 模块默认不是严格模式除非你自己写use strict。所以有些代码在 CJS 里“碰巧能跑”比如给未声明变量赋值迁移到 ESM 后会第一时间原地崩溃这一点排查时要格外留意。3.4 循环依赖CJS 拿到半个对象ESM 用 live binding 撑住循环依赖是所有模块系统都绕不开的话题。CJS 遇到循环引用时因为require是逐行执行如果 A 还没执行完就回头requireB而 B 又require了 A那么 B 拿到的是 A 当前已经导出的部分对象可能缺字段。举个最简单的例子// a.cjs const b require(./b.cjs); module.exports.hello () hello from a;// b.cjs const a require(./a.cjs); module.exports.say () a.hello();实际执行时A 先执行require(./b.cjs)进入 BB 里require(./a.cjs)返回的是 A 尚未执行完的部分 exports此时a.hello还不存在等到module.exports.say () a.hello()真正被调用时a.hello是undefined调用就崩了。ESM 的 live binding 机制能缓解这个问题import导入的不是值快照而是对原变量的引用只要调用发生在模块初始化完成之后函数声明往往能正常互通。但这不代表 ESM 能解决所有循环依赖一旦两个模块在初始化阶段就互相读取对方的值依然会踩坑。从业者的建议始终是不要在业务代码里制造循环依赖。遇到循环依赖优先抽公共模块比依赖模块系统兜底要靠谱得多。3.5 动态导入import() 才是 require 的正面对手如果只对比require()和静态import会得出“require 更强因为它能动态加载”的结论。但 ESM 世界里真正对标require的其实是import()它不是import的语法变体而是另一个独立 API。import()可以放在任何位置返回 Promise天然支持异步初始化甚至能在 CJS 文件里加载 ESM 模块const mod await import(./esm-module.mjs);反过来ESM 文件里想用require标准做法是createRequire这个我们放在迁移部分重点讲。一句话总结CJS 的“动态加载”能力ESM 通过import()完全具备真正的差异在于 ESM 多了一套静态分析的严格约束以及随之而来的 tree shaking 收益。4. 从 require 迁到 import 的实测踩坑记录4.1 一次真实的内部工具迁移我迁过一个内部框架的中间件模块总共 80 多个文件初始状态全是 CommonJS。整体步骤大概是这样的在 package.json 里加type: module这一步会让所有.js文件默认按 ESM 解析。用 esbuild 做一次自动转换把require换成importmodule.exports换成export。扫描转换后仍残留的require调用逐个判断是漏转还是必须保留。处理扩展名和目录导入把import ./services改成import ./services/index.js。重新定义__dirname、__filename。跑完整测试根据报错逐项修复运行时行为差异。自动转换工具能解决大部分纯语法问题但解决不了语义问题。最典型的一处原代码里module.exports { m1, m2 }被转成export default { m1, m2 }后ESM 消费者不能再用命名导入import { m1 } from ./middlewares.mjs因为打包器不会自动为对象属性生成具名导出。最后我只能手动补export const m1 ...这种显式导出。4.2 三个高频报错及排查思路迁移过程中一定会遇到报错最常出现的三个是ERR_REQUIRE_ESMCJS 文件里require()一个 ESM 模块。这是 Node 的硬限制CJS 不能同步加载 ESM。解决方法是改成动态导入await import(./esm-module.mjs)或者把调用方也改成 ESM。ERR_MODULE_NOT_FOUNDESM 里路径解析失败。排查顺序先确认相对路径写没写对再看有没有漏掉扩展名第三看是不是目录导入没有补index.js。ERR_UNKNOWN_FILE_EXTENSIONESM 直接import了一个.json或.png之类的非 JS 资源。Node 原生环境不支持这种写法需要绕道JSON 用readFileJSON.parse图片等静态资源要在构建工具层面换成 URL 或 base64。这里顺便回应一个热搜词常见困惑在 webpack 或 Vite 工程里写require(/assets/logo.png)能正常工作是因为打包器把图片当模块做了内联转换而不是 Node 原生能力。换成 Node 直接跑一个.png的import当然会报错。分清“构建工具增强”和“Node 原生模块系统”很重要否则排查方向都会错。4.3 兼容策略createRequire 与双格式包纯 ESM 工程最头疼的问题是有些 CJS 老包确实只能在运行时按需加载或者你需要读 JSON 配置。此时可以用createRequire在 ESM 文件里再造一个requireimport { createRequire } from node:module; const require createRequire(import.meta.url); const legacy require(legacy-cjs-pkg); const config require(./config.json);createRequire创建的require和 CJS 原生行为基本一致算是 ESM 世界的“逃生舱”。但要注意它救急可以不要整个项目到处用否则等于交出了 ESM 的静态分析优势tree shaking 也就无从谈起了。如果你是 npm 库作者想让自己的包同时服务 CJS 和 ESM 用户最标准的方式是在package.json的exports里做条件分发{ name: my-lib, exports: { .: { require: ./dist/index.cjs, import: ./dist/index.mjs } } }这样 CJS 用户require(my-lib)走 cjs 入口ESM 用户import my-lib走 mjs 入口。代价是同一份逻辑会有两份代码、两份单例状态如果库内部维护了全局可变状态两个入口之间不共享容易引发诡异 bug。我的建议是发双格式包的库内部尽量避免全局状态或者设计成无状态函数。5. 项目里到底用哪种写法我的选择标准5.1 新项目、老项目、库项目分别怎么决策如果你的 Node 版本在 20 以上新项目直接用 ESM 是我的默认选项。不是因为追赶时髦而是import/export是语言标准现代工具链打包器、检查器、测试框架都在围绕它做优化。只有 ESM 的静态结构能天然支持 tree shakingrequire的动态特性决定了构建工具很难对 CJS 做可靠的死代码消除。老项目则不建议突然搞“全量迁移”。老代码的风险不在语法而在运行时行为差异顶层this、__dirname、循环依赖、动态 require这些坑一个比一个隐蔽。我见过一个团队一晚上把上百个文件改成import结果上线前才发现某个循环依赖在 CJS 下碰巧能跑在 ESM 下直接初始化失败。稳妥的做法是新文件用.mjs或.jstype: module老文件保持 CJS让两套系统共存一段一段验证最后再统一。库项目建议直接双格式。源码用 ESM 写构建时用打包器分别产出dist/index.cjs和dist/index.mjs再通过exports做条件分发。现在很多工具比如 tsup 都可以一条命令同时输出两套格式省心不少。5.2 混用场景的边界什么情况必须用哪一种两种模块系统在一个工程里并存时边界要非常清楚。我直接用一张矩阵总结场景写法注意事项ESM 加载 CJSimport x from ./x.cjs默认导入没问题命名导入依赖静态分析ESM 内想用 requirecreateRequire(import.meta.url)只用于救急不滥用CJS 加载 ESMawait import(./x.mjs)必须异步不能同步 require ESMCJS 加载 CJSrequire(./x.cjs)无额外限制条件动态加载import()两套系统都能用在这个矩阵里唯一被 Node 明确禁止的是 CJS 同步require()ESM。只要你守住这条边界其他组合在工程上都是可行的。很多同事问我“以后是不是所有代码都写 import 就行”我的回答是新代码默认写 import但你要清楚node_modules里跑的仍然是大量 CommonJS 包。很多被吐槽的 import 坑本质上不是 import 本身的锅而是你换了一套模块系统后还带着旧系统的习惯在写新代码——比如省略后缀、直接用__dirname、在导出对象上动态挂属性。换一套规则就按新规则来。最后再分享一个我自己的判断方法遇到模块写法选择时先别问“这句该用 require 还是 import”而是问“这一层的边界到底该是 CJS 还是 ESM”。工具链脚本、被老包依赖的内部模块、原生动态加载场景继续走 CJS业务逻辑、新实现、需要异步加载和 tree shaking 的地方往 ESM 走。把边界划分清楚后require 和 import 之间的矛盾就少了一大半。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。