第12章:模块深潜 —— 宿主、解析、ESM/CJS 与发布策略
约 4 分钟 · 更新于 2026-09-01
第12章:模块深潜 —— 宿主、解析、ESM/CJS 与发布策略
第6章解决“怎样配置一个项目”,本章进一步回答“为什么模块配置必须这样选”。核心原则:TypeScript 不是模块宿主,它必须模拟 Node.js、浏览器或 bundler 的真实加载规则。
一、脚本与模块
没有顶层 import/export 的传统脚本共享全局作用域;模块拥有独立作用域,并显式公开能力。
ts
// 模块
export const version = "1.0";
必要时可以用空导出强制把文件标记为模块:
二、宿主决定规则
“宿主”是最终加载代码并解析模块说明符的系统:
- Node.js 直接运行输出 JavaScript:Node.js 是宿主。
- Vite/Webpack 打包源码:bundler 是 TypeScript 需要模拟的宿主。
- 浏览器直接加载 ESM:浏览器和 Web Server 共同决定解析行为。
- Bun、tsx 等直接加载 TypeScript:运行时仍是宿主。
TypeScript 要完成三项工作:
- 输出宿主能执行的模块格式。
- 确认输出代码中的导入能够解析。
- 为导入名称找到正确类型。
三、模块输出与模块解析不是一回事
- module:描述输出格式和文件模块种类规则。
- moduleResolution:描述如何从模块说明符查找文件和类型。
两者必须匹配真实宿主。即使 noEmit: true,module 仍影响模块格式识别、互操作规则、import.meta 和顶层 await 等检查。
四、Node.js 中的模块识别
Node.js 根据扩展名和最近的 package.json 判断格式:
- .mjs / .mts:ESM。
- .cjs / .cts:CommonJS。
- .js / .ts:受最近 package.json 的 "type": "module" 影响。
因此 Node 应用通常选择 node16、node18、node20 或 nodenext 这一类能够模拟 Node 规则的设置,而不是简单写 module: esnext 就认为足够。
五、模块说明符通常不会被改写
ts
import { helper } from "./helper.js";
TypeScript 默认不会替你把错误路径变成宿主可加载路径。Node ESM 的相对导入通常要求输出路径带扩展名,因此源码中常写对应输出的 .js 扩展名。
paths 也主要帮助 TypeScript 解析,不会自动改写输出说明符。
六、ESM 与 CommonJS 互操作
常见难点:
- 默认导入在不同工具中含义不同。
- CommonJS 的命名导出可能依赖静态分析猜测。
- 同一个库在 Node 和 bundler 中表现可能不同。
- 双包发布可能导致同一包被加载两次或类型分裂。
esModuleInterop 与 allowSyntheticDefaultImports 改善部分兼容体验,但不会统一所有宿主行为。应用应选择与运行环境匹配的配置;库作者需要测试真实消费方式。
七、应用配置决策
使用 bundler 的应用
通常让 TypeScript 模拟 bundler 解析,并由 bundler 负责输出。类型检查独立运行:
text
源码 → bundler 转译/打包
源码 → tsc --noEmit 类型检查
由 Node.js 直接运行输出
配置必须模拟目标 Node 版本,并同步检查:
- package.json 的 type。
- 文件扩展名。
- 相对导入扩展名。
- 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="..." /> 属于历史文件组织方式,模块项目通常不需要。
十一、常见故障排查
当出现“编辑器正常、运行时报模块错误”时,按顺序检查:
- 谁是宿主:Node、bundler、浏览器还是测试工具?
- 目标文件被识别为 ESM 还是 CJS?
- package.json type 与文件扩展名是否一致?
- module / moduleResolution 是否模拟真实宿主?
- 输出中的模块说明符是否真实可解析?
- exports 是否允许当前入口?
- 类型声明是否与运行文件格式对应?
十二、常见误区
- 把 TypeScript 编译器当作模块加载器。
- Node 项目统一使用 module: esnext,忽略 Node 格式识别。
- 认为 esModuleInterop 能解决所有 ESM/CJS 差异。
- 认为 paths 会改写运行时代码。
- 发布双格式库但没有真实消费测试。
- .d.ts 与 JavaScript 模块格式不匹配。
- 在新模块项目中大量使用 namespace 和三斜线 path。
十三、练习
- 分别建立 Node ESM、Node CJS 和 Vite 项目,对比配置。
- 查看编译输出,确认模块说明符是否被改写。
- 创建一个仅 ESM 的小型库,用最小 Node 项目消费。
- 故意让 package.json type 与输出格式冲突,观察运行错误。
- 检查一个第三方包的 exports、types 和声明文件格式。
十四、总结
- TypeScript 必须模拟宿主的模块规则。
- module、moduleResolution、扩展名和 package.json 必须一致。
- 模块说明符默认不会因为类型检查而自动变成可运行路径。
- ESM/CJS 互操作因宿主而异,应用和库的配置策略不同。
- 库发布必须同时验证运行文件、声明文件和消费方式。
请继续阅读:第13章:声明文件与库发布。
原始资料引用