← 与Go的区别 Buffer.readInt32LE() 方法 →

Node.js require与import区别指南:CommonJS到ESM的迁移策略与性能实测

著
原创 2026-10-16 Node.js 已有人查阅

模块系统的历史包袱不是技术债,是设计约束

Node.js2009年问世时,JavaScript还没有官方的模块系统。CommonJS应运而生,require()成为事实上的标准。这个选择在当时是务实的,但它把“同步加载”和“运行时解析”两个特性同时写进了Node.js的基因。

ES Module是ECMAScript2015才纳入规范的语言级模块方案。它解决的不只是语法糖问题:静态分析、循环依赖处理、树摇优化,这些都是CommonJS在设计层面无法实现的。Node.js对ESM的支持经历了从实验性标志到默认启用的漫长过程,直到Node.js20以后才趋于稳定。

理解require和import的差异,本质上是在理解两套模块规范的设计哲学冲突,而不是简单的“旧写法”和“新写法”之争。

加载时序:同步阻塞vs静态解析

require()的同步行为常常被误解。当你写constfs=require('fs')时,Node.js会立即执行文件读取、编译和包装,直到模块的exports对象准备就绪,require才返回。后续代码才开始执行。这个过程是阻塞的,没有回调,没有Promise。

import的行为在语言层面不同。静态import语句在模块图构建阶段就被解析,所有依赖关系在代码执行之前已经确定。这意味着import声明会“提升”到模块顶部,即使你写在文件末尾,效果也一样。但也正因为静态解析,import不能出现在条件语句中,除非使用动态import()表达式。

// require:运行时解析,条件加载合法
if (process.env.NODE_ENV === 'development') {
  const debug = require('./debug-tools');
  debug.init();
}

// import:静态解析,条件加载非法
// if (process.env.NODE_ENV === 'development') {
//   import debug from './debug-tools';  // 语法错误
// }

动态import()返回Promise,在需要按需加载的场景中可以替代条件require,但引入了异步语义。

缓存机制的表面相似与深层差异

两者都缓存模块,但缓存查找的时机不同。require在调用Module._load时检查Module._cache,命中则直接返回已缓存的exports对象,整个过程是同步的、快速的。

import的缓存机制更复杂。GitHub上一个被广泛引用的性能问题报告显示,在重复加载同一模块时,import的耗时可能达到require的20倍以上。核心原因在于ESM加载器在Node.js中主要用JavaScript实现,每次import都需要经过异步的解析、链接和缓存查询步骤,即使模块已经缓存,这些前置操作的异步开销依然存在。而require的同步顺序查找几乎不产生额外开销。

这并不意味着import在所有场景下都更慢。首次加载时,ESM加载器可以并行读取多个文件,这在冷启动的大型项目中可能带来优势。但在热路径中反复导入同一模块——比如在函数内部多次import()——性能差异会变得显著。

互操作性:单向兼容而非双向对称

Node.js对两种模块系统的互操作支持是精心设计过的,但存在明确的不对称性。

CommonJS加载ESM的能力在Node.js20.19.0/22.12.0之后才默认可用,且要求ESM模块图中不包含顶层await,否则会抛出ERR_REQUIRE_ASYNC_MODULE。ESM加载CommonJS则宽松得多:Node.js会通过静态分析尝试识别CommonJS的命名导出,如果识别失败,仍然可以通过默认导出的方式获取整个module.exports对象。

跨模块系统的循环依赖是被禁止的。ESM依赖CommonJS形成回环时,会抛出ERR_REQUIRE_CYCLE_MODULE。纯CommonJS或纯ESM内部的循环依赖仍然可以工作,但跨边界的循环没有任何变通方案,只能通过延迟加载打破回环。

路径解析规则的差异经常被低估

require的路径解析是宽容的。你可以写require('./utils'),Node.js会依次尝试utils.js、utils.json、utils.node,如果utils是一个目录,还会查找其中的index.js。扩展名和目录索引都是自动补全的。

import的路径解析是严格的。import'./utils'在ESM中会直接抛出ERR_MODULE_NOT_FOUND,因为import不做扩展名探测,也不支持目录索引导入。你必须写import'./utils.js'或import'./utils/index.js'。

这个差异在迁移过程中最容易导致运行时错误。很多从CommonJS迁移到ESM的项目在开发环境正常,部署后因为路径解析失败而崩溃,根源就在这里。

项目中的选型判断

在一个持续维护的Node.js后端项目中,我经历过从全CommonJS到逐步引入ESM的迁移过程。最初的判断是“新代码用import,老代码保持require”。这个策略在小规模下可行,但当两类模块开始相互引用时,问题就暴露了。

一个典型的踩坑场景:某个工具模块用require导出,测试文件用import引入。如果工具模块的导出方式依赖运行时赋值(比如在条件分支中设置module.exports),ESM的静态分析无法识别命名导出,测试文件中import{utilFn}from'./utils'会失败,必须降级为默认导入再解构。

我的经验是:在一个项目中保持模块系统的一致性比追求“新语法”更重要。如果项目已经大量使用CommonJS,全部迁移到ESM的成本不只是语法替换,还包括路径修正、互操作调整、依赖库的ESM兼容性排查。如果项目从零开始,或者依赖库已经ESM化,直接使用ESM是更清晰的选择。

TypeScript项目中有个实际的操作建议:在tsconfig.json中设置"module":"Node16"或"NodeNext",开启ES ModuleInterop:true。这会让TypeScript正确处理两种模块系统之间的类型兼容,同时强制你在导入CommonJS模块时使用默认导入语法。

package.json中type字段的实际影响

"type":"module"不是可选项,而是项目级别的模块系统声明。设置之后,所有.js文件都被当作ESM处理,require不再可用。"type":"commonjs"是默认值,保持CommonJS行为。

这个字段的影响范围是整个包目录,包括node_modules中的依赖解析行为。在没有明确type字段的包中,.js文件默认按CommonJS处理,这就是为什么很多老牌npm包可以在ESM项目中通过import正常使用——Node.js的互操作层将它们识别为CommonJS并提供默认导出。

.cjs和.mjs扩展名是覆盖type字段的强制手段。无论package.json如何声明,.cjs始终是CommonJS,.mjs始终是ESM。这在需要同时维护两种格式的包中很有用,但会增加项目复杂度。

常见错误码与异常处理

ERR_REQUIRE_ESM:在旧版Node.js(20.19之前)中用require加载ESM模块时抛出。解决方式是改用动态import(),或者将调用方改为ESM。

ERR_REQUIRE_ASYNC_MODULE:require加载的ESM模块包含顶层await时抛出。顶层await只能在ESM中使用,require的同步本质无法等待异步操作完成。

ERR_MODULE_NOT_FOUND:import路径缺少扩展名或指向目录时抛出。检查导入路径是否完整指定了.js扩展名。

ERR_UNKNOWN_FILE_EXTENSION:import遇到Node.js不认识的扩展名时抛出。.ts文件在纯Node.js环境中无法直接导入,需要ts-node或编译步骤。

ERR_REQUIRE_CYCLE_MODULE:跨CommonJS/ESM边界的循环依赖。只能通过重构打破循环,或使用延迟加载。

与相邻概念的职责对比

requirevsimport()动态导入:动态import()返回Promise,可以条件执行,本质上是异步的。它解决的是静态import无法覆盖的场景,但不应作为require的“异步版”来理解。动态import()的缓存行为与静态import一致,但每次调用都会创建新的Promise对象。

ESM的default导出vsCommonJS的module.exports:CommonJS中module.exports就是导出的全部内容,没有“默认导出”的概念。ESM互操作层将CommonJS的module.exports映射为ESM的default导出,但命名导出需要Node.js的静态分析来“猜测”。如果CommonJS模块的导出是动态赋值的(比如在函数中设置module.exports.foo=...),ESM命名导入会失败。

Node.js的模块解析vs浏览器的ESM解析:浏览器ESM没有node_modules解析逻辑,必须使用相对路径或完整URL。Node.jsESM保留了node_modules的barespecifier解析,但加入了自己的条件导出规则。同一个包在浏览器和Node.js中的导入行为可能不同。

练习思考题

  1. 如果一个模块需要在开发环境输出调试日志,在生产环境静默,用require和import分别应该如何实现?两种方案的差异在哪里?

  2. 为什么import{foo}from'./cjs-module'有时会失败,而importcjsModulefrom'./cjs-module';const{foo}=cjsModule总是有效?

  3. 在TypeScript项目中,ES ModuleInterop:true开启后,importexpressfrom'express'和import*asexpressfrom'express'在类型层面有什么区别?

  4. 设计一个迁移方案:一个50个文件的Express项目,其中30个文件使用require,20个使用import。如何安全地统一到一种模块系统?

延伸阅读与参考文献

Node.js 官方文档,模块解析和加载,https://url.nodejs.cn/api/packages/5.html,系统性地说明了require和import在解析、加载、缓存各阶段的规范行为。

Node.js 官方文档,ECMAScript模块,https://nodejs.org/download/release/v24.20.0/docs/api/esm.html,ESM在Node.js中的完整实现细节,包括JSON模块和WebAssembly导入。

Node.js Package Examples,Shipping ESMfor CommonJS,https://nodejs.github.io/package-examples/04-cjs-esm-interop/shipping-esm-for-cjs/,Node.js官方维护的包示例,涵盖require(esm)的限制和特性检测方法。

Marijn Haverbeke,Eloquent JavaScript(2022),https://archive.org/download/lrnX_marijn-haverbeke-eloquent-java-script-2022-2/,关于CommonJS循环依赖处理机制的经典论述,解释了模块系统在依赖回环中的设计取舍。

GitHubIssue#52369,Re-importsinESMare20-25timesslowerthanre-requiresinCommonJS,https://github.com/nodejs/node/issues/52369,Node.js核心贡献者关于ESM加载器性能的实测数据和根因分析。

Node.js Package Configuration Guide,Migratingimports,https://nodejs.github.io/package-examples/05-cjs-esm-migration/migrating-imports/,从require迁移到import的实操指南,包含路径解析、命名导出、循环依赖等迁移场景。

← 与Go的区别 Buffer.readInt32LE() 方法 →
分享笔记 (共有 篇笔记)
验证码:
微信公众号