Node.js 20 + TypeScript 5.5 实战:3步配置 ESM 与 CJS 双模块支持

当你在2024年启动一个新的Node.js项目时,模块系统的选择已经不再是非此即彼的单选题。随着Node.js 20对ES模块(ESM)的成熟支持,以及TypeScript 5.5在类型系统上的重大改进,现代后端开发迎来了模块化方案的最佳实践时代。

1. 理解现代Node.js的模块格局

在Node.js生态中,模块系统经历了从CommonJS(CJS)到ES Modules(ESM)的演进。截至2024年,两种规范各有其不可替代的优势:

CJS的坚守价值

  • 仍然是npm仓库中80%+模块的默认格式
  • 同步加载特性在特定场景下更符合直觉
  • __dirname __filename 的原生支持

ESM的现代优势

  • 静态分析带来的tree-shaking优化
  • 浏览器原生支持,统一前后端模块规范
  • top-level await等新语法的支持
// ESM的典型导入方式
import { createServer } from 'node:http'
import express from 'express'

// CJS的等效写法
const { createServer } = require('node:http')
const express = require('express')

关键决策点:如果你的项目需要大量使用npm上的传统模块,或者需要与旧系统集成,CJS可能更合适;如果是全新项目且追求长期可维护性,ESM是更好的起点。

2. 三阶段配置实战

2.1 基础环境搭建

首先确保你的开发环境满足:

  • Node.js 20.x+
  • TypeScript 5.5+
  • 包管理器(npm/yarn/pnpm)最新版
# 初始化项目
mkdir node-ts-dual-modules && cd node-ts-dual-modules
npm init -y

# 安装核心依赖
npm install typescript@5.5 @types/node --save-dev

创建基础 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

2.2 双模块支持配置

package.json 中添加模块定义:

{
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    }
  }
}

创建多目标构建脚本 build.js

import { execSync } from 'node:child_process'

// ESM构建
execSync('tsc -p tsconfig.esm.json')

// CJS构建
execSync('tsc -p tsconfig.cjs.json')

对应的两份TS配置:

// tsconfig.esm.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "ES2022",
    "outDir": "./dist/esm"
  }
}

// tsconfig.cjs.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "CommonJS",
    "outDir": "./dist/cjs"
  }
}

2.3 类型声明生成

添加类型声明生成配置:

{
  "compilerOptions": {
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "outDir": "./dist/types"
  }
}

最终构建命令:

node build.js && tsc -p tsconfig.types.json

3. 实战中的兼容性处理

3.1 文件扩展名策略

在双模块项目中,文件扩展名需要特别注意:

模块类型 推荐扩展名 说明
ESM .mts 明确表明模块类型
CJS .cts 便于工具链识别
通用 .ts 需要配合package.json使用
// utils.mts
export function capitalize(str: string) {
  return str.charAt(0).toUpperCase() + str.slice(1)
}

// utils.cts
exports.capitalize = function(str: string) {
  return str.charAt(0).toUpperCase() + str.slice(1)
}

3.2 条件性导入技巧

使用 import.meta.resolve 实现条件导入:

async function loadModule(moduleName: string) {
  try {
    if (process.versions.node.split('.')[0] >= '20') {
      const modulePath = await import.meta.resolve(moduleName)
      return import(modulePath)
    }
    return require(moduleName)
  } catch (err) {
    console.error(`Failed to load ${moduleName}:`, err)
    throw err
  }
}

3.3 性能优化配置

tsconfig.json 中添加这些优化选项:

{
  "compilerOptions": {
    "incremental": true,
    "composite": true,
    "tsBuildInfoFile": "./.cache/tsbuildinfo",
    "sourceMap": true
  }
}

4. 企业级项目的最佳实践

4.1 模块边界设计

推荐的项目结构:

src/
  core/           # 核心业务逻辑(ESM)
    services/
    entities/
  adapters/       # 适配层(CJS)
    legacy/
    third-party/
  shared/         # 通用工具(双模块)
    utils/
    types/

4.2 构建流水线优化

现代构建工具配置示例:

// vite.config.ts
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['es', 'cjs'],
      fileName: (format) => `index.${format}.js`
    },
    rollupOptions: {
      external: ['node:fs', 'node:path']
    }
  }
})

4.3 监控与调试

在开发环境添加模块加载日志:

// 在入口文件顶部添加
if (process.env.NODE_ENV === 'development') {
  import('node:module').then(({ Module }) => {
    const originalLoad = Module._load
    Module._load = function(request: string) {
      console.debug(`Loading: ${request}`)
      return originalLoad.apply(this, arguments)
    }
  })
}

5. 常见问题解决方案

5.1 类型定义冲突

当遇到第三方模块类型冲突时,创建 types/global.d.ts

declare module 'some-cjs-package' {
  import type { OriginalType } from 'some-cjs-package/actual-types'
  const value: OriginalType
  export = value
}

5.2 循环依赖处理

ESM下的循环依赖解决方案:

// a.mts
export let initialized = false
export function init() {
  initialized = true
}

// b.mts
import { initialized } from './a.mjs'
export function checkInit() {
  return initialized
}

// 在入口文件
import { init } from './a.mjs'
import './b.mjs'
init()

5.3 测试策略

Jest配置示例:

// jest.config.cjs
module.exports = {
  preset: 'ts-jest/presets/default-esm',
  globals: {
    'ts-jest': {
      useESM: true,
    },
  },
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1',
  },
}

6. 未来演进路线

随着Node.js生态的发展,建议关注这些趋势:

  1. 模块联邦 :探索 import() 动态加载与微前端结合
  2. Wasm集成 :ESM对WebAssembly的本地支持
  3. 类型演进 :TypeScript的 moduleSuffixes 提案
// 未来的可能配置
{
  "compilerOptions": {
    "moduleSuffixes": [".mts", ".ts", ".cts"]
  }
}

在实际项目中,我们发现采用渐进式迁移策略最为有效:先确保核心代码库使用ESM,然后逐步将依赖项迁移,最后处理边缘案例。这种方案既保证了开发效率,又为未来的技术演进留出了空间。

Logo

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

更多推荐