ES Modules vs CommonJS
两种模块系统的本质区别
JavaScript 历史上存在两套模块系统,它们在语法、加载方式和执行模型上都有根本差异。理解这些差异对于配置 TypeScript 编译器至关重要。
两种模块系统的差异
// ES Modules(推荐,TypeScript 原生支持)
import { createServer } from 'http';
import type { IncomingMessage } from 'http'; // 仅导入类型,编译后删除
export { myFunction };
export default class MyClass {}
// CommonJS(Node.js 传统方式)
const http = require('http');
module.exports = { myFunction };
import type:类型专用导入
// 编译后 import type 完全被删除(不留任何 JS 代码)
import type { User, Post } from './types';
import type * as T from './types';
// 推荐用于:避免循环依赖、明确区分类型与值导入
import { createUser } from './users'; // 值导入
import type { CreateUserInput } from './users'; // 类型导入
声明文件(.d.ts)
为 JS 库编写声明文件
// legacy-lib.d.ts:为纯 JS 库提供类型声明
// declare 关键字:声明已存在但不由 TS 管理的类型/变量
declare function legacyFormat(str: string, options?: { uppercase?: boolean }): string;
declare class LegacyDatabase {
constructor(connectionString: string);
query(sql: string): Promise<unknown[]>;
close(): void;
}
declare const APP_VERSION: string;
// 声明模块
declare module 'some-css-loader' {
const content: { [className: string]: string };
export default content;
}
// 扩展全局类型
declare global {
interface Window {
analytics: { track(event: string, props?: object): void };
}
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
NODE_ENV: 'development' | 'production' | 'test';
}
}
}
@types/ 包与 DefinitelyTyped
@types 的工作原理
许多 JavaScript 库是用纯 JS 写的,没有内置类型声明。DefinitelyTyped 是一个社区维护的大型 GitHub 仓库(microsoft/DefinitelyTyped),收录了数千个流行库的 .d.ts 声明文件,发布为 npm 上的 @types/xxx 包。安装后 TypeScript 会自动找到并使用这些类型声明。
# 为 Express 安装类型声明
npm install express
npm install --save-dev @types/express
# 为 Node.js 内置模块安装类型(fs/path/http 等)
npm install --save-dev @types/node
# 查询某个包是否有 @types:
# https://www.typescriptlang.org/dt/search 或
npm info @types/lodash # 如果有输出则存在
声明文件的查找顺序
TypeScript 在引入一个模块时,按以下顺序查找类型声明:
// 当你写 import 'lodash' 时,TypeScript 按序查找:
// 1. node_modules/lodash/package.json 的 "types" 或 "typings" 字段
// 2. node_modules/lodash/index.d.ts
// 3. node_modules/@types/lodash/index.d.ts
// 4. 如果都找不到,该模块类型为 any(在 strict 模式下会报错)
// 可以在 tsconfig.json 中指定 typeRoots 来控制查找位置
// 也可以用 types 数组只引入特定的 @types 包
路径别名(paths)
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"],
"@types/*": ["src/types/*"]
}
}
}
// 使用路径别名(代替冗长的相对路径)
import { Button } from '@components/Button'; // 而不是 ../../components/Button
import { formatDate } from '@utils/date';
moduleResolution 策略
使用 Vite/Bun/Turbopack 等现代打包器的项目,推荐 moduleResolution: "bundler"、module: "ESNext"、target: "ESNext"。配合 verbatimModuleSyntax: true 强制区分类型导入和值导入。
命名空间(namespace)与模块的关系
TypeScript 早期使用 namespace(曾叫 module)来组织代码,在 ES Modules 出现前是唯一的代码隔离方式。现在大多数场景应该使用 ES Modules,但 namespace 在某些特定场景仍然有用。
// .d.ts 文件中使用 namespace 扩展第三方类型(合法场景)
declare namespace Express {
interface Request {
// 扩展 req 对象,添加认证用户信息
user?: {
id: string;
email: string;
roles: string[];
};
sessionId?: string;
}
}
// 扩展 NodeJS 进程环境变量类型(配合 dotenv 使用)
declare namespace NodeJS {
interface ProcessEnv {
// 将 process.env 的访问变为类型安全
DATABASE_URL: string;
NODE_ENV: 'development' | 'production' | 'test';
PORT?: string;
JWT_SECRET: string;
}
}
声明文件作者的最佳实践
为 JavaScript 库手写高质量 .d.ts
// legacy-lib.d.ts — 为第三方 JS 库编写声明文件的完整示例
// 1. 函数重载:同名函数不同参数的精确类型
declare function parseDate(s: string): Date;
declare function parseDate(timestamp: number): Date;
declare function parseDate(s: string, format: string): Date;
// 2. 可调用对象(函数对象,同时有方法)
interface LegacyLib {
// 作为函数调用
(config: { debug?: boolean; timeout?: number }): void;
// 同时有属性
version: string;
configure(options: Record<string, unknown>): void;
}
declare const legacyLib: LegacyLib;
// 3. 全局变量和命名空间混合(UMD 库的声明方式)
export as namespace myLib; // 允许 window.myLib 全局访问
export = myLib; // CommonJS: require('myLib') 的类型
declare function myLib(selector: string): HTMLElement[];
declare namespace myLib {
ajax(url: string): Promise<unknown>;
}
export = module 是 TypeScript 的 CJS 模块导出语法,对应 module.exports = ...。使用 export = 的模块在导入时必须用 import x = require('module')(CJS 风格)或 import x from 'module'(需要 esModuleInterop: true)。不能在同一个文件中同时使用 export = 和普通的 export { ... }——这是"默认导出"风格和"命名空间导出"风格的根本冲突。
三斜线指令(Triple-Slash Directives)
三斜线指令是特殊的单行注释,只在 .d.ts 文件或文件顶部使用,用于声明文件间的依赖关系。在现代项目中已很少需要手动写三斜线指令,但理解它们有助于读懂老项目和 DefinitelyTyped 的声明文件。
/// <reference types="node" />
// 等同于在 tsconfig 的 types 中添加 "node"
// 告诉 TS:这个文件依赖 @types/node 的类型声明
/// <reference path="./globals.d.ts" />
// 引用另一个 .d.ts 文件,建立依赖关系
// 现代项目中应使用 import 代替
/// <reference lib="dom" />
// 包含内置 lib 声明(如 DOM 类型)
// 在 tsconfig 的 lib 选项中设置更推荐
// 注:这些指令必须写在文件的最顶部,在任何代码和普通注释之前
本章小结
- ESM vs CJS:ESM 静态分析(支持 Tree Shaking),CJS 运行时动态加载;TypeScript 源码统一用 import/export,通过 tsconfig module 选项控制编译输出格式。
- import type:纯类型导入,编译后完全删除,不产生任何 JS 代码;推荐配合 verbatimModuleSyntax: true 强制区分;有助于避免循环依赖。
- 声明文件 .d.ts:只包含类型信息,不含实现代码;declare 关键字声明"已存在但不由 TS 管理"的类型;declare global 和 declare namespace 用于扩展全局类型。
- @types/ 与 DefinitelyTyped:社区维护的类型声明仓库;现代库多已内置类型(package.json 有 "types" 字段);无内置类型的老库才需要单独安装 @types/xxx。
- moduleResolution:现代打包器项目用 "bundler";Node.js ESM 项目用 "node16"/"nodenext";路径别名(paths)需要同时配置打包器(如 vite resolve.alias)才能在运行时生效。