命名空间与模块
从「抽一个共享格式化模块」讲起:ES 模块的导出导入姿势、namespace 还会出现在哪、循环依赖为什么会炸、ESM 与 CommonJS 互操作的 default 差异。
0. 真实场景:第二个文件开始的「作用域危机」
FANDEX 早期有个 800 行的 utils.ts:格式化日期、计数、生成 slug 全在一个文件里。拆分的第一次尝试:
// utils/format.ts
export function formatDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
// pages/stats.ts
formatDate(new Date()); // 报错:找不到名称 formatDate在浏览器直接跑两个 script 标签的年代,这种代码靠全局变量就能通。但现代工程里每个文件都是模块:文件即作用域,想用的东西必须显式导入导出。本篇讲清楚这套系统的规则,以及 namespace 这个历史产物在今天还剩什么用。
1. 一句话理解
模块(module)= 文件作用域 + 显式导入导出:一个文件里有
import或export,它就是模块,内部声明不再泄漏到全局。命名空间(namespace)= 把一组声明挂到一个全局对象下的旧分组方式,是 ES 模块普及前的产物,今天主要活在类型声明文件和老框架里。
2. 动手:把 utils 拆成规范的模块
2.1 命名导出:默认选择
// src/utils/format.ts
export function formatDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
export function formatCount(n: number): string {
return n >= 10000 ? `${(n / 10000).toFixed(1)}w` : String(n);
}
export type Slug = `${string}-${number}`; // 类型也走 export// pages/stats.ts
import { formatDate, formatCount } from '../utils/format';
import type { Slug } from '../utils/format'; // 纯类型导入,编译后消失export 关键字放在声明前是最常见写法;也可以底部集中 export { formatDate, formatCount }。类型与值同文件导出没问题,但导入时值与类型分开写是个好习惯——它让「哪些导入只活在编译期」一目了然,也为 verbatimModuleSyntax(见import type 与 verbatimModuleSyntax)铺路。
2.2 桶文件(barrel):一组模块的统一入口
// src/utils/index.ts
export * from './format';
export * from './slug';// 任何地方
import { formatDate, slugify } from '../utils';桶文件方便,但有两个代价:模块多了以后启动要解析整棵桶树;桶文件是循环依赖高发地(A 桶引 B,B 又引 A 桶)。小项目随意,库项目慎用。
2.3 default 导出:克制使用
// src/utils/logger.ts
const logger = {
info(msg: string) { console.log(`[INFO] ${msg}`); },
error(msg: string) { console.error(`[ERR ] ${msg}`); },
};
export default logger;export default 一个文件只能有一个,导入方可以随意命名(import log from ... / import logger from ... 都行)——这个自由正是团队代码库的死穴:同一个东西五个人五个名字。经验法则:工具函数、组件用命名导出;只有「模块本体就是一个东西」时用 default。
3. namespace:它是什么,还会在哪遇到
// namespace 的样子
namespace Validation {
export interface StringValidator {
isAcceptable(s: string): boolean;
}
export const version = '1.0';
}
Validation.version; // 编译后:一个普通对象属性访问namespace 编译后就是一个 IIFE 包裹的对象(历史任务:在没有模块语法的年代模拟「分组」)。今天的决策表:
| 场景 | 用 namespace 吗 |
|---|---|
| 应用代码(无论前端后端) | 不用,用 ES 模块 |
给无类型的第三方库写声明(.d.ts 里的 declare namespace) | 常见,得会读 |
扩展全局对象 / 给别人的模块补类型(declare global、模块扩展) | 相关语法见模块声明与全局类型增强 |
| 老项目(jQuery 时代代码、部分 Angular 老库) | 会大量遇到,会读即可 |
会读比会写重要:看到 declare namespace NodeJS { interface ProcessEnv { ... } } 这类代码,知道它在「往一个全局命名空间里合并类型」就够了,新代码不写它。
4. 循环依赖:类型导入是解药之一
拆模块拆到一半常见的死局:
// src/utils/format.ts
import { truncate } from './slug'; // 想用 slug 的截断
export function excerpt(text: string) {
return truncate(text, 80);
}
// src/utils/slug.ts
import { formatCount } from './format'; // 又想用 format 的计数
export function truncate(text: string, n: number): string { /* ... */ }TS 编译不报错(它能容忍循环),但运行时是雷:ES 模块循环引用时,后初始化的一方拿到的绑定可能是 undefined,报「xxx is not a function」且栈信息指不到根因。修复三板斧:
- 下沉共同依赖:把两者都用到的部分抽到第三个文件,这是根治;
- 类型层面的循环用
import type:类型导入编译后完全消失,不产生运行时边,循环立即断开(见 import type 篇); - 延迟引用:把 import 挪进函数体内(动态
import()),把循环边推迟到调用时。
5. ESM 与 CommonJS:互操作的两个坑
TS 项目同时面对两套模块体系,日常会撞上两件事。
5.1 default 导出的差异
CommonJS 的 module.exports = {...} 整体导出,被 ESM import x from 进来时,x 到底是整个对象还是对象的 default 属性,取决于 esModuleInterop 这类互操作配置。开严格配置(esModuleInterop: true 或 Node16+ 解析)后行为才统一。不要在混搭项目里手写「无 default 的 CJS」再按 ESM 方式导入,报「has no default export」先查互操作配置。
5.2 ESM 的扩展名纪律
Node 原生 ESM 里相对导入必须写扩展名:
import { formatDate } from './format'; // Node ESM 下报错:找不到模块
import { formatDate } from './format.js'; // 正确:源码写 .js,编译后不变
import { formatDate } from './format.ts'; // 仅 allowImportingTsExtensions + noEmit 场景源码是 TS 却要写 .js 后缀,是 ESM 的「编译产物视角」,新人最困惑的一条。打包器(Vite/esbuild)会自动补全,所以 Web 项目感觉不到;Node 直跑项目必踩。模块解析策略(bundler / node16 / nodenext 怎么选)见模块解析策略。
6. 坑点与自检
坑 1:新代码里写 namespace
应用代码写 namespace 等于手写一个全局单例,模块化、tree-shaking、测试隔离全部受损。见到就换 ES 模块。
坑 2:default 导入名五花八门
import X from './a' 的 X 由导入者起名。库入口只有一个 default 时,团队约定导入名写进 code review checklist。
坑 3:循环依赖静默通过
类型检查不报循环,运行时才炸。用 madge --circular 之类的工具在 CI 里查依赖环。
坑 4:在 ESM 项目里忘写扩展名
报「Cannot find module」先看是不是 Node 原生 ESM 在跑,相对路径补 .js。
坑 5:把「模块」当成 module 关键字
老代码里的 module Foo {} 是 namespace 的旧写法,与「ES 模块」重名但无关。读老代码时注意区分。
自检清单
- 能说出「文件什么时候变成模块」(有 import 或 export)
- 会写命名导出、类型导出、桶文件,并说出 default 的克制用法
- 能解释 namespace 的编译产物与它今天仅存的场景
- 能定位并修一个简单循环依赖(下沉、import type、延迟引用)
- 知道 Node ESM 相对导入要写
.js扩展名
7. 练习
- 把一个两文件互相 import 的小例子改到编译通过、
npx tsx运行不炸,分别用「下沉」和「import type」两种方式各做一遍。 - 写一个
src/utils/index.ts桶文件,然后故意在 format.ts 里 import 桶文件自身,用madge --circular src(或目测)找出这个环。 - 读一段你手头项目的
.d.ts(如@types/node里的任意文件),找出一个declare namespace,说清它在往哪个全局空间合并什么类型。
8. 下一步
- import type 与 verbatimModuleSyntax:类型导入的编译期/运行时边界
- 模块解析策略:import 路径是怎么找到文件的
- 声明文件编写:.d.ts 的写法全解
- 模块声明与全局类型增强:declare module 与 declare global