组织 TypeScript 代码,命名空间和模块走的是两条路:命名空间是 TypeScript 专有的全局对象式组织方式,诞生在 JavaScript 没有模块系统的年代;模块基于文件与 import/export,是 ECMAScript 2015 之后语言原生的一部分。现代项目直接用模块,命名空间只保留给三类场景——维护遗留代码、无构建工具的小型网页脚本、.d.ts 中的全局类型声明。接手过一个 2016 年启动的工单系统,全部类型挂在三层嵌套的命名空间下,新人改一个接口要在五个文件之间跳转,最后花了两个迭代迁到模块,依赖关系才算理清。下文从机制、加载、工具链、选型四个维度展开,并附可复用的迁移步骤。
核心机制:一个是全局对象,一个是文件作用域
两者本质差异在于代码住在哪:命名空间是挂在全局作用域上的具名 JavaScript 对象,模块是拥有独立作用域的文件,模块内不导出的内容外部拿不到。这个差异决定了隔离强度——两个不同模块永远不会向同一作用域贡献名称,而命名空间共享全局,重名风险靠命名约定规避。
先看命名空间的写法,它把一组校验器包进一个全局名字下:
// validators.ts —— 命名空间方式
namespace Validator {
export interface StringValidator {
isValid(s: string): boolean;
}
export class ZipCodeValidator implements StringValidator {
isValid(s: string): boolean {
return s.length === 5 && /^\d+$/.test(s);
}
}
}
// 同文件或引入了该文件的地方直接用
const v = new Validator.ZipCodeValidator();
console.log(v.isValid("12345")); // true
再看模块写法,文件本身就是边界,export 控制可见性:
// zipValidator.ts —— 模块方式
export interface StringValidator {
isValid(s: string): boolean;
}
export class ZipCodeValidator implements StringValidator {
isValid(s: string): boolean {
return s.length === 5 && /^\d+$/.test(s);
}
}
// main.ts
import { ZipCodeValidator } from "./zipValidator";
const v = new ZipCodeValidator();
console.log(v.isValid("12345")); // true
两段代码效果一致,但模块版本不需要记住全局名字,消费方自行决定导入后叫什么。还有一个自动判定规则容易踩坑:文件里只要出现 import 或 export,TypeScript 就把它当模块,此时文件内声明的命名空间不再进入全局作用域,外部直接引用会报”cannot find name”。
| 维度 | 命名空间 | 模块 |
|---|---|---|
| 本质 | 全局作用域里的具名对象 | 拥有独立作用域的文件 |
| 声明方式 | namespace X {} |
文件内出现 import/export 即自动成为模块 |
| 可见性控制 | export 修饰命名空间内部成员 |
模块级 export,未导出即私有 |
| 隔离强度 | 弱,共享全局作用域 | 强,模块间永不贡献同名 |
| 标准地位 | TypeScript 专有语法 | ECMAScript 2015 起的语言标准 |
| 跨文件方式 | 多文件同名合并,需三斜线指令关联 | import 按路径解析,天然多文件 |
加载方式与文件组织:拼接依赖加载器,模块靠路径解析
加载机制是第二个分水岭:命名空间编译后是普通全局代码,跨文件时靠 <reference> 指令把文件串起来,最终可以用 outFile 拼成单文件;模块则依赖模块加载器或支持 ES Modules 的运行时,每个源文件对应一个输出文件,按 import 路径解析加载。outFile 只在目标为 amd 或 system 时可用,目标为 commonjs 或 umd 时无法拼接。
跨文件命名空间的典型写法:
// file1.ts
namespace Geometry {
export const PI = 3.14159;
}
// file2.ts —— 三斜线指令建立文件关联
/// <reference path="./file1.ts" />
namespace Geometry {
export function area(r: number): number {
return PI * r * r; // 跨文件合并进同一命名空间
}
}
这种”同名自动合并”是把双刃剑:它让声明可以分散,也让依赖关系隐没在 <reference> 链里——页面忘了引某个文件,运行时直接抛 undefined,编译期却查不出来。模块的依赖写在 import 上,文件头一眼可见,未使用的依赖还会被静态分析工具标红。生产环境里排查过一次线上报错,根因就是拼版脚本漏了一个命名空间文件,这类问题在模块体系下会在构建阶段暴露。
| 维度 | 命名空间 | 模块 |
|---|---|---|
| 运行时依赖 | 无需加载器,全局可用 | 需 CommonJS 加载器或支持 ES Modules 的运行时 |
| 文件与输出关系 | 可多文件合并,支持 outFile 拼接 | 源文件与输出文件一一对应 |
| 跨文件引用 | 三斜线指令 /// |
import 路径解析(.ts/.tsx/.d.ts 顺序查找) |
| 动态加载 | 不支持 | 支持 import() 异步按需加载 |
| 典型使用环境 | 无构建的老式网页脚本 | Node.js、现代浏览器、打包器体系 |
工具链与生态:模块拿到压倒性支持
生态取向没有悬念:主流框架、打包器、运行时全部围绕模块构建,命名空间处于维护兼容状态。tree-shaking 是最实际的一环——打包器对模块的 export 做静态分析,未引用的代码直接剔除;命名空间编译后的对象属性访问难以静态判断,打包器几乎无法对它做有效优化。TypeScript 文档对现代代码的指引也是同一口径:对 Node.js 应用,模块是默认选择。
| 生态维度 | 命名空间 | 模块 |
|---|---|---|
| Node.js 支持 | 非原生组织方式 | 默认且推荐的组织方式 |
| 打包器(webpack/Vite/esbuild) | 兼容但难优化,tree-shaking 基本失效 | 一等公民,静态分析与裁剪完整 |
| 框架生态(React/Angular/Vue) | 基本不用 | 标准组织形式 |
| 类型声明文件 .d.ts | 常见(全局类型、声明合并) | 主流(模块化类型包) |
| 异步加载与代码分割 | 不适用 | import() 原生支持 |
需要说明的是,命名空间并非一无是处:在 .d.ts 里为没有类型的第三方库补全局声明、利用声明合并扩展既有类型,这两件事它做得比模块顺手。判断标准很简单——产物需不需要进打包体系,需要就用模块。
选型建议与迁移路径
选型结论一句话:新项目一律用模块;遗留命名空间代码按改动频率渐进迁移,不必一次性重写。两类写法的按场景对照如下:
| 项目场景 | 建议方案 | 理由 |
|---|---|---|
| 新建 Web/Node 项目 | 模块 | 标准、可摇树、工具链完整支持 |
| 维护遗留命名空间库 | 暂留命名空间,按模块暴露出口 | 一次性重写风险高,先包一层模块 API |
| 无构建步骤的小页面 | 命名空间或命名导出的全局脚本 | 无加载器环境,模块跑不起来 |
| .d.ts 全局类型补丁 | 命名空间 | 声明合并与全局可见恰好是需求 |
迁移本身不复杂,按四步推进即可:
- 梳理命名空间依赖图,列出每个文件向全局贡献的名称;
- 把顶层命名空间壳去掉,原
export成员改为模块级导出; - 消费方逐个改写为 import 引入,删除三斜线指令;
- 全量编译并跑测试,确认无”cannot find name”残留。
迁移时还要拆掉一层叫”无谓命名空间”的包装——模块文件里再套 export namespace Shapes 属于双重分组,消费方会写出 shapes.Shapes.Triangle() 这种别扭调用。模块文件本身就是逻辑分组,导出的顶层名由导入方决定,这层壳去掉后代码反而干净。
常见问题(FAQ)
Q1:TypeScript 新项目用哪种组织方式?
用模块。现代代码一律以模块为标准组织方式,命名空间仅用于遗留维护与特定声明场景。
Q2:为什么老项目里到处是命名空间?
它们早于 ES Modules 出现,当时 JavaScript 没有模块系统,命名空间前身叫”内部模块”,用于防全局重名。
Q3:命名空间和模块能不能在一个文件里混用?
能声明但别这么用。文件出现 import/export 即成为模块,其内命名空间失去全局可见性,外部引用会报错。