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 是静态加载,在编译时确定依赖关系
    • CJS 是动态加载,在运行时确定依赖关系
  • 文件扩展名

    • ESM 通常使用 .mjs 扩展名或通过 package.json type 字段声明
    • CJS 通常使用 .cjs 扩展名
  • 顶层 await

    • ESM 支持顶层 await
    • CJS 不支持顶层 await
  • 模块作用域

    • ESM 有自己独立的模块作用域
    • CJS 共享 Node.js 的模块作用域

理解这些差异对于后续的配置决策至关重要,特别是在混合使用两种模块系统的项目中。

2. 基础环境准备

在开始配置之前,我们需要确保开发环境满足基本要求:

  1. Node.js 20+ 安装

    # 使用 nvm 安装特定版本
    nvm install 20
    nvm use 20
    
  2. TypeScript 5.5+ 安装

    npm install typescript@5.5 --save-dev
    
  3. 项目初始化

    mkdir node-ts-project
    cd node-ts-project
    npm init -y
    
  4. 基础目录结构

    /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. 性能优化与最佳实践

  1. 模块选择策略

    • 新项目优先使用 ESM
    • 现有项目逐步迁移到 ESM
    • 库项目应同时支持两种模块系统
  2. 构建优化

    • 使用增量编译减少构建时间
    • 分离类型检查和代码生成
    • 考虑使用 esbuild 或 swc 加速编译
  3. 依赖管理

    • 优先选择同时支持 ESM 和 CJS 的依赖
    • 对于仅支持一种模块系统的依赖,使用动态导入或条件性引入
  4. 测试策略

    • 为两种模块系统分别编写测试
    • 确保跨模块调用的正确性
    • 测试构建产物的实际运行情况

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. 测试策略

确保双模块系统正确工作的测试策略:

  1. 单元测试

    "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"
    }
    
  2. 集成测试

    • 测试 ESM 和 CJS 之间的互操作性
    • 测试构建产物在实际 Node.js 环境中的运行情况
  3. 类型测试

    // test/types.test.ts
    import assert from 'assert'
    import { expectType } from 'tsd'
    
    import { someFunction } from '../src'
    
    expectType<string>(someFunction(123))
    

14. 迁移策略

将现有 CJS 项目迁移到双模块系统的步骤:

  1. 评估现有代码库

    • 识别所有 require 调用
    • 检查动态模块加载
    • 评估第三方依赖的模块系统支持
  2. 逐步迁移

    • 先迁移工具脚本和测试代码
    • 然后迁移库代码
    • 最后迁移应用入口点
  3. 混合模式运行

    • 使用 .mjs .cjs 扩展名区分模块类型
    • 配置 package.json 的 exports 字段
    • 使用动态导入处理跨模块系统调用
  4. 全面测试

    • 确保所有功能在两种模块系统下正常工作
    • 测试构建产物的大小和性能
    • 验证类型声明的正确性

15. 未来展望

随着 Node.js 生态系统的演进,ESM 正在成为主流模块系统。然而,CJS 由于其广泛的现有代码库和简单的使用方式,仍将在相当长的时间内继续存在。作为开发者,我们应该:

  • 在新项目中优先使用 ESM
  • 为现有库添加 ESM 支持
  • 关注 Node.js 模块系统的未来发展
  • 参与相关工具链的改进

双模块系统配置虽然增加了初始复杂度,但它为项目提供了更大的灵活性和更长的生命周期。通过合理的架构设计和自动化工具,这种复杂性可以得到有效管理。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐