模块、包、模块解析与声明文件

编辑器能跳转到一个包的类型定义,程序运行时却提示找不到模块,这并不矛盾。类型文件和实际执行的 JavaScript 是两条需要对齐的路径。下面沿着一次 import,分别看编译器、Node 和构建工具会去哪里找文件。

排查 import 时分两步:先确认 TypeScript 找到的类型定义,再确认运行时或构建器找得到实际 JavaScript。编辑器不再报错,只证明第一步可能解决了;还要运行构建产物,检查第二步。

从拆成两个文件开始理解模块

模块让一个文件明确说明自己提供什么、使用什么。export 暴露需要给其他文件使用的值或类型,import 声明依赖。拆文件的目的不是把代码切碎,而是让功能边界和依赖方向看得见。

这里有两个阶段:TypeScript 先判断导入是否符合类型和模块规则,运行时再真正加载 JavaScript 文件。检查成功不代表 Node 一定找得到输出文件,所以还要确认扩展名、包的 type 字段以及最终目录结构。

.d.ts 像一份类型接口说明,它描述某个实现有什么能力,但通常不包含那个实现。声明里写“这个函数返回 number”,不会让实际返回字符串的第三方代码自动变正确。学习时打开生成的 JavaScript 和声明文件对照一次,再按官方模块说明区分类型导入与运行时导入。

1. ESM 基础

TypeScript
// math.ts export const pi = Math.PI export function area(radius: number): number { return pi * radius ** 2 } // index.ts(NodeNext ESM 源码常写输出扩展名) import { area } from "./math.js" console.log(area(2))

import type 保证导入只用于类型:

TypeScript
import type { User } from "./models.js"

2. 选择模块解析策略

场景 module moduleResolution 重点
现代 Node ESM/CJS NodeNext NodeNext 遵循 package.json type、扩展名、exports
Vite/webpack/esbuild ESNext Bundler 由构建器完成最终解析,允许省略部分相对扩展名
旧 Node CommonJS 按项目约束 不建议新项目使用旧 node10 迁移时核对依赖

TypeScript 6/7 废弃或移除多项旧模块配置,新项目不要复制早期教程中的 classic、AMD/UMD/system 或 baseUrl 旧习惯。

3. package.json 导出与类型

json
{ "name": "@example/math", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "files": ["dist"] }

包发布前应在临时项目执行真实安装测试,而不是只在 monorepo 路径别名下成功。

4. .d.ts 的作用与边界

TypeScript
// index.d.ts export interface ClientOptions { endpoint: URL timeoutMs?: number } export declare class Client { constructor(options: ClientOptions) get(path: string): Promise<unknown> }

声明文件只描述一个已经存在的 JavaScript 模块,不生成实现。错误的 .d.ts 会让检查器相信错误事实。官方模块理论给出的对应关系包括:

声明 JS TS 源
.d.ts .js .ts / .tsx
.d.mts .mjs .mts
.d.cts .cjs .cts

5. 生成声明

json
{ "compilerOptions": { "declaration": true, "declarationMap": true, "emitDeclarationOnly": false, "outDir": "dist" } }
Shell
npx tsc -p tsconfig.json npm pack --dry-run

公开函数应写稳定返回类型,避免把内部路径、匿名复杂类型或私有依赖泄漏进声明产物。

6. 消费第三方类型

优先级:

  1. 包自带 types/导出声明。
  2. 对应 @types/package-name。
  3. 项目内写最小、准确的声明并配运行测试。

空声明 declare module "legacy"; 会把整个模块视为 any,只适合临时迁移,必须跟踪清理。

7. 模块增强与全局声明

TypeScript
export {} declare global { interface Window { analytics?: { track(name: string): void } } }

全局增强应放在模块文件中,并确保运行时确实安装了对应全局值。类型声明不会创建 window.analytics。

8. 循环依赖与类型导入

类型循环有时可被检查器处理,运行时值循环却可能读到尚未初始化的绑定。用 import type 消除纯类型边,并通过依赖倒置拆分真正的运行时环。

flowchart LR DOMAIN[domain types] --> SERVICE[service] DOMAIN --> ADAPTER[adapter] SERVICE --> PORT[port interface] ADAPTER --> PORT

9. 排错命令

Shell
npx tsc --traceResolution npm pack --dry-run node --input-type=module -e "import('@example/math').then(console.log)"

--traceResolution 解释 TypeScript 为什么选择某个源/声明文件;运行命令验证 host 的真实选择。

参考:Modules、Modules: Theory、Declaration Files。