Node.js 20 + TypeScript 5.5 项目配置:ESM 与 CJS 双模式 tsconfig 详解
Node.js 20 + TypeScript 5.5 项目配置:ESM 与 CJS 双模式 tsconfig 详解
在当今的 Node.js 生态系统中,模块系统一直是开发者需要面对的重要选择。随着 ECMAScript Modules (ESM) 逐渐成为 JavaScript 的标准模块系统,而 CommonJS (CJS) 作为 Node.js 长期以来的默认模块系统,如何在项目中同时支持这两种模块规范成为了许多团队的实际需求。本文将深入探讨如何在 Node.js 20 和 TypeScript 5.5 环境下配置双模块支持,提供可直接复用的配置模板,并分析不同场景下的最佳实践。
1. 理解 ESM 与 CJS 的核心差异
在开始配置之前,我们需要清楚地理解这两种模块系统的本质区别。ESM 是 ECMAScript 标准的一部分,而 CJS 是 Node.js 早期采用的模块系统。它们的主要差异体现在以下几个方面:
-
语法差异 :
- ESM 使用
import/export语法 - CJS 使用
require/module.exports语法
- ESM 使用
-
加载行为 :
- ESM 是静态加载,在编译时确定依赖关系
- CJS 是动态加载,在运行时确定依赖关系
-
文件扩展名 :
- ESM 通常使用
.mjs扩展名或通过package.json的type字段声明 - CJS 通常使用
.cjs扩展名
- ESM 通常使用
-
顶层 await :
- ESM 支持顶层 await
- CJS 不支持顶层 await
-
模块作用域 :
- ESM 有自己独立的模块作用域
- CJS 共享 Node.js 的模块作用域
理解这些差异对于后续的配置决策至关重要,特别是在混合使用两种模块系统的项目中。
2. 基础环境准备
在开始配置之前,我们需要确保开发环境满足基本要求:
-
Node.js 20+ 安装 :
# 使用 nvm 安装特定版本 nvm install 20 nvm use 20 -
TypeScript 5.5+ 安装 :
npm install typescript@5.5 --save-dev -
项目初始化 :
mkdir node-ts-project cd node-ts-project npm init -y -
基础目录结构 :
/node-ts-project ├── /src │ ├── /esm │ └── /cjs ├── tsconfig.base.json ├── tsconfig.esm.json ├── tsconfig.cjs.json └── package.json
这种结构允许我们为不同的模块系统维护单独的配置和源代码目录。
3. 基础 TypeScript 配置
我们先创建一个基础的 TypeScript 配置,作为 ESM 和 CJS 配置的共享部分:
// tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node16",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["node_modules"]
}
这个基础配置包含了大多数项目都会需要的严格类型检查选项,以及一些路径别名设置。 moduleResolution 设置为 node16 是为了更好地支持 ESM 和 CJS 的混合环境。
4. ESM 专用配置
针对 ESM 模块系统,我们需要创建专门的配置:
// tsconfig.esm.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "esnext",
"outDir": "./dist/esm",
"rootDir": "./src/esm",
"declaration": true,
"declarationDir": "./dist/types",
"emitDeclarationOnly": false
},
"include": ["src/esm/**/*"]
}
关键配置说明:
"module": "esnext":指定使用 ESM 模块系统"outDir"和"rootDir":分别设置输出目录和源代码目录"declaration": true:生成类型声明文件"moduleResolution": "node16":使用 Node.js 16+ 的模块解析策略
注意:在实际项目中,你可能需要根据 Node.js 版本调整
module的值。对于 Node.js 20,es2022也是一个有效的选项。
5. CJS 专用配置
对于需要保持向后兼容性的 CommonJS 模块,我们创建如下配置:
// tsconfig.cjs.json
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist/cjs",
"rootDir": "./src/cjs",
"declaration": true,
"declarationDir": "./dist/types",
"emitDeclarationOnly": false
},
"include": ["src/cjs/**/*"]
}
关键差异点:
"module": "commonjs":指定使用 CommonJS 模块系统- 输出目录和源代码目录与 ESM 配置分开
6. 双模式构建的 package.json 配置
为了让我们的项目能够同时支持两种模块系统,需要在 package.json 中进行精心配置:
{
"name": "node-ts-project",
"version": "1.0.0",
"type": "module",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"require": "./dist/cjs/index.js",
"import": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts"
},
"./package.json": "./package.json"
},
"scripts": {
"build": "npm run build:esm && npm run build:cjs",
"build:esm": "tsc -p tsconfig.esm.json",
"build:cjs": "tsc -p tsconfig.cjs.json",
"prepublishOnly": "npm run build"
}
}
关键配置说明:
"type": "module":指定默认模块系统为 ESM"main":CJS 入口点"module":ESM 入口点"exports"字段:现代 Node.js 支持的更精细的入口点控制- 构建脚本:分别构建 ESM 和 CJS 版本
7. 实际开发中的模块互操作性
在实际项目中,我们经常需要在 ESM 和 CJS 之间进行互操作。以下是几种常见场景的处理方式:
7.1 ESM 中引入 CJS 模块
// src/esm/index.ts
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
// 引入 CJS 模块
const cjsModule = require('../cjs/some-cjs-module.cjs')
7.2 CJS 中引入 ESM 模块
// src/cjs/index.ts
async function loadEsmModule() {
const esmModule = await import('../esm/some-esm-module.mjs')
// 使用 esmModule
}
7.3 类型声明文件的处理
为了确保类型检查正确工作,我们需要为跨模块引入提供类型声明:
// types/modules.d.ts
declare module '../cjs/*.cjs' {
import { SomeType } from '../esm/types'
export const someValue: SomeType
// 其他导出
}
8. 高级配置技巧
8.1 条件性导出
在 package.json 中,我们可以根据不同的条件导出不同的模块:
"exports": {
".": {
"node": {
"import": "./dist/esm/node/index.js",
"require": "./dist/cjs/node/index.js"
},
"default": "./dist/esm/browser/index.js"
}
}
8.2 多目标构建
对于需要支持多种环境的库,可以配置多个构建目标:
"scripts": {
"build": "npm run build:esm && npm run build:cjs && npm run build:umd",
"build:esm": "tsc -p tsconfig.esm.json",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:umd": "rollup -c rollup.config.js"
}
8.3 开发环境优化
为了提高开发体验,可以配置 watch 模式:
"scripts": {
"dev": "concurrently \"npm run dev:esm\" \"npm run dev:cjs\"",
"dev:esm": "tsc -p tsconfig.esm.json -w",
"dev:cjs": "tsc -p tsconfig.cjs.json -w"
}
9. 性能优化与最佳实践
-
模块选择策略 :
- 新项目优先使用 ESM
- 现有项目逐步迁移到 ESM
- 库项目应同时支持两种模块系统
-
构建优化 :
- 使用增量编译减少构建时间
- 分离类型检查和代码生成
- 考虑使用 esbuild 或 swc 加速编译
-
依赖管理 :
- 优先选择同时支持 ESM 和 CJS 的依赖
- 对于仅支持一种模块系统的依赖,使用动态导入或条件性引入
-
测试策略 :
- 为两种模块系统分别编写测试
- 确保跨模块调用的正确性
- 测试构建产物的实际运行情况
10. 常见问题与解决方案
10.1 文件扩展名问题
在混合模块系统中,文件扩展名变得尤为重要。解决方案:
- 明确使用
.mjs和.cjs扩展名 - 在 package.json 中配置
"type": "module" - 使用
--experimental-specifier-resolution=node标志(Node.js < 20)
10.2 __dirname 和 __filename 在 ESM 中的替代
ESM 中没有这些 CJS 变量,替代方案:
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
10.3 动态导入的异步问题
ESM 的 import() 是异步的,而 CJS 的 require() 是同步的。解决方案:
- 在 CJS 中异步加载 ESM 模块
- 重构代码以避免同步模块加载
- 使用顶层 await(仅限 ESM)
10.4 类型声明合并
当同一个模块有 ESM 和 CJS 两种实现时,类型声明需要合并:
// types/index.d.ts
declare module 'my-module' {
import { ESMType } from './esm'
import { CJSType } from './cjs'
export * from './esm'
export * from './cjs'
// 合并类型
export type CombinedType = ESMType & CJSType
}
11. 实际项目中的配置示例
让我们看一个真实项目中的配置组合示例:
// 综合 tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"inlineSources": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"types": ["node"],
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["node_modules", "dist"],
"references": [
{ "path": "./tsconfig.esm.json" },
{ "path": "./tsconfig.cjs.json" }
]
}
12. 构建工具集成
现代构建工具如 esbuild、Rollup 和 Webpack 对双模块系统的支持情况:
12.1 esbuild 配置示例
// esbuild.config.js
import esbuild from 'esbuild'
const commonConfig = {
entryPoints: ['src/index.ts'],
bundle: true,
minify: true,
sourcemap: true,
}
// ESM 构建
esbuild.build({
...commonConfig,
format: 'esm',
outfile: 'dist/esm/index.js',
})
// CJS 构建
esbuild.build({
...commonConfig,
format: 'cjs',
outfile: 'dist/cjs/index.js',
})
12.2 Rollup 配置示例
// rollup.config.js
import typescript from '@rollup/plugin-typescript'
export default [
{
input: 'src/index.ts',
output: {
file: 'dist/esm/index.js',
format: 'esm',
},
plugins: [typescript({ tsconfig: './tsconfig.esm.json' })],
},
{
input: 'src/index.ts',
output: {
file: 'dist/cjs/index.cjs',
format: 'cjs',
},
plugins: [typescript({ tsconfig: './tsconfig.cjs.json' })],
},
]
13. 测试策略
确保双模块系统正确工作的测试策略:
-
单元测试 :
"scripts": { "test": "npm run test:esm && npm run test:cjs", "test:esm": "NODE_OPTIONS='--experimental-vm-modules' jest -c jest.esm.config.js", "test:cjs": "jest -c jest.cjs.config.js" } -
集成测试 :
- 测试 ESM 和 CJS 之间的互操作性
- 测试构建产物在实际 Node.js 环境中的运行情况
-
类型测试 :
// test/types.test.ts import assert from 'assert' import { expectType } from 'tsd' import { someFunction } from '../src' expectType<string>(someFunction(123))
14. 迁移策略
将现有 CJS 项目迁移到双模块系统的步骤:
-
评估现有代码库 :
- 识别所有
require调用 - 检查动态模块加载
- 评估第三方依赖的模块系统支持
- 识别所有
-
逐步迁移 :
- 先迁移工具脚本和测试代码
- 然后迁移库代码
- 最后迁移应用入口点
-
混合模式运行 :
- 使用
.mjs和.cjs扩展名区分模块类型 - 配置 package.json 的
exports字段 - 使用动态导入处理跨模块系统调用
- 使用
-
全面测试 :
- 确保所有功能在两种模块系统下正常工作
- 测试构建产物的大小和性能
- 验证类型声明的正确性
15. 未来展望
随着 Node.js 生态系统的演进,ESM 正在成为主流模块系统。然而,CJS 由于其广泛的现有代码库和简单的使用方式,仍将在相当长的时间内继续存在。作为开发者,我们应该:
- 在新项目中优先使用 ESM
- 为现有库添加 ESM 支持
- 关注 Node.js 模块系统的未来发展
- 参与相关工具链的改进
双模块系统配置虽然增加了初始复杂度,但它为项目提供了更大的灵活性和更长的生命周期。通过合理的架构设计和自动化工具,这种复杂性可以得到有效管理。
更多推荐



所有评论(0)