Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/TypeScript 深度教程/第12章

第12章:模块深潜 —— 宿主、解析、ESM/CJS 与发布策略

4 分钟 · 更新于 2026-09-01

第12章:模块深潜 —— 宿主、解析、ESM/CJS 与发布策略

第6章解决“怎样配置一个项目”,本章进一步回答“为什么模块配置必须这样选”。核心原则:TypeScript 不是模块宿主,它必须模拟 Node.js、浏览器或 bundler 的真实加载规则。


一、脚本与模块

没有顶层 import/export 的传统脚本共享全局作用域;模块拥有独立作用域,并显式公开能力。

ts
// 模块
export const version = "1.0";

必要时可以用空导出强制把文件标记为模块:

ts
export {};

二、宿主决定规则

“宿主”是最终加载代码并解析模块说明符的系统:

  • Node.js 直接运行输出 JavaScript:Node.js 是宿主。
  • Vite/Webpack 打包源码:bundler 是 TypeScript 需要模拟的宿主。
  • 浏览器直接加载 ESM:浏览器和 Web Server 共同决定解析行为。
  • Bun、tsx 等直接加载 TypeScript:运行时仍是宿主。

TypeScript 要完成三项工作:

  1. 输出宿主能执行的模块格式。
  2. 确认输出代码中的导入能够解析。
  3. 为导入名称找到正确类型。

三、模块输出与模块解析不是一回事

  • module:描述输出格式和文件模块种类规则。
  • moduleResolution:描述如何从模块说明符查找文件和类型。

两者必须匹配真实宿主。即使 noEmit: truemodule 仍影响模块格式识别、互操作规则、import.meta 和顶层 await 等检查。

四、Node.js 中的模块识别

Node.js 根据扩展名和最近的 package.json 判断格式:

  • .mjs / .mts:ESM。
  • .cjs / .cts:CommonJS。
  • .js / .ts:受最近 package.json"type": "module" 影响。

因此 Node 应用通常选择 node16node18node20nodenext 这一类能够模拟 Node 规则的设置,而不是简单写 module: esnext 就认为足够。

五、模块说明符通常不会被改写

ts
import { helper } from "./helper.js";

TypeScript 默认不会替你把错误路径变成宿主可加载路径。Node ESM 的相对导入通常要求输出路径带扩展名,因此源码中常写对应输出的 .js 扩展名。

paths 也主要帮助 TypeScript 解析,不会自动改写输出说明符。

六、ESM 与 CommonJS 互操作

常见难点:

  • 默认导入在不同工具中含义不同。
  • CommonJS 的命名导出可能依赖静态分析猜测。
  • 同一个库在 Node 和 bundler 中表现可能不同。
  • 双包发布可能导致同一包被加载两次或类型分裂。

esModuleInteropallowSyntheticDefaultImports 改善部分兼容体验,但不会统一所有宿主行为。应用应选择与运行环境匹配的配置;库作者需要测试真实消费方式。

七、应用配置决策

使用 bundler 的应用

通常让 TypeScript 模拟 bundler 解析,并由 bundler 负责输出。类型检查独立运行:

text
源码 → bundler 转译/打包
源码 → tsc --noEmit 类型检查

由 Node.js 直接运行输出

配置必须模拟目标 Node 版本,并同步检查:

  • package.jsontype
  • 文件扩展名。
  • 相对导入扩展名。
  • CommonJS/ESM 互操作。

浏览器直接运行 ESM

导入必须是浏览器或 Import Map 能解析的 URL/说明符,不能依赖 Node 的包解析幻想。

八、库发布策略

库作者至少要决定:

  • 只发布 ESM、只发布 CJS,还是双格式。
  • exports 如何声明入口。
  • .d.ts.d.mts.d.cts 与运行文件是否对应。
  • 是否把内部文件暴露给深层导入。
  • 消费端是否会经过 bundler。

示意:

json
{
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    }
  }
}

双格式方案更复杂,不应复制模板后直接发布,必须建立 ESM、CJS 和 bundler 消费测试。

九、模块解析中的类型替换

TypeScript 查找运行文件时,会尝试关联声明文件或源码类型。例如运行时解析到 .js,类型系统可能对应查找 .d.ts。这要求声明文件的模块格式与对应 JavaScript 一致,否则会出现“类型认为能导入、运行时却失败”。

十、命名空间、全局脚本与三斜线指令

  • 新项目优先 ES 模块。
  • namespace 主要用于旧式全局代码和声明建模。
  • /// <reference types="..." /> 可声明类型包依赖。
  • /// <reference lib="..." /> 引入标准库声明。
  • /// <reference path="..." /> 属于历史文件组织方式,模块项目通常不需要。

十一、常见故障排查

当出现“编辑器正常、运行时报模块错误”时,按顺序检查:

  1. 谁是宿主:Node、bundler、浏览器还是测试工具?
  2. 目标文件被识别为 ESM 还是 CJS?
  3. package.json type 与文件扩展名是否一致?
  4. module / moduleResolution 是否模拟真实宿主?
  5. 输出中的模块说明符是否真实可解析?
  6. exports 是否允许当前入口?
  7. 类型声明是否与运行文件格式对应?

十二、常见误区

  1. 把 TypeScript 编译器当作模块加载器。
  2. Node 项目统一使用 module: esnext,忽略 Node 格式识别。
  3. 认为 esModuleInterop 能解决所有 ESM/CJS 差异。
  4. 认为 paths 会改写运行时代码。
  5. 发布双格式库但没有真实消费测试。
  6. .d.ts 与 JavaScript 模块格式不匹配。
  7. 在新模块项目中大量使用 namespace 和三斜线 path。

十三、练习

  1. 分别建立 Node ESM、Node CJS 和 Vite 项目,对比配置。
  2. 查看编译输出,确认模块说明符是否被改写。
  3. 创建一个仅 ESM 的小型库,用最小 Node 项目消费。
  4. 故意让 package.json type 与输出格式冲突,观察运行错误。
  5. 检查一个第三方包的 exportstypes 和声明文件格式。

十四、总结

  1. TypeScript 必须模拟宿主的模块规则。
  2. modulemoduleResolution、扩展名和 package.json 必须一致。
  3. 模块说明符默认不会因为类型检查而自动变成可运行路径。
  4. ESM/CJS 互操作因宿主而异,应用和库的配置策略不同。
  5. 库发布必须同时验证运行文件、声明文件和消费方式。

请继续阅读:第13章:声明文件与库发布。


原始资料引用


  • 第6章:模块化工程
  • 第13章:声明文件

本章目录
一、脚本与模块二、宿主决定规则三、模块输出与模块解析不是一回事四、Node.js 中的模块识别五、模块说明符通常不会被改写六、ESM 与 CommonJS 互操作七、应用配置决策八、库发布策略九、模块解析中的类型替换十、命名空间、全局脚本与三斜线指令十一、常见故障排查十二、常见误区十三、练习十四、总结原始资料引用Related Documents
苏ICP备2025204887号-2