日期 : 2026-02-10
标签 : TypeScript, 微前端, 架构治理, DX
阅读时间: 约 12 分钟
写在前面
你有没有遇到过这样的场景:
- 打开一个
.ts文件,IDE 疯狂报红找不到名称"Recordable",但构建又能过? - 同一个
PageParam接口,在shims-vue.d.ts、@cmclink/api、@cmclink/types三个地方各定义了一遍? - 想给一个类型加个字段,不知道该改哪个文件,改完发现另一个地方还是旧的?
这些都是我们 CMCLink 主应用在类型管理上踩过的坑。本文记录了一次完整的类型治理过程------从混乱到有序,从 10 个散落的类型文件到 3 个职责清晰的文件,以及背后的设计思想。
一、治理前:问题全景
1.1 types 目录的"垃圾抽屉"
治理前,apps/main/src/types/ 目录有 10 个文件,承担了远超其职责的工作:
css
types/
├── auto-imports.d.ts ← 自动生成,没问题
├── shims-vue.d.ts ← 121 行!塞了 .vue 声明 + store 类型 + 50 行全局工具类型
├── container.d.ts ← 249 行箱管类型,零引用(属于箱管子应用)
├── user.ts ← 仅 store 和 1 个组件使用
├── flow.ts ← 仅首页使用
├── msg.ts ← 仅首页使用
├── marketing.ts ← 仅首页使用
├── form.d.ts ← Form 组件专属
├── components.d.ts ← Form 组件专属
└── tabs.ts ← 主应用路由/菜单类型
核心问题 :没有分类标准,什么类型都往 types/ 里扔。
1.2 全局类型的"三重影分身"
PageParam 这个接口,同时存在于:
typescript
// 1️⃣ shims-vue.d.ts(全局隐式声明)
declare global {
interface PageParam { pageSize?: number; pageNo?: number }
}
// 2️⃣ @cmclink/api/types.ts(公共包显式导出)
export interface PageParam { pageNo: number; pageSize: number }
// 3️⃣ @cmclink/types/global.d.ts(公共类型包全局声明)
declare global {
interface PageParam { pageSize?: number; pageNo?: number }
}
三份定义,字段还有微妙差异(pageNo 是否可选)。改一处,另外两处不会同步。
1.3 公共类型包的"空城计"
@cmclink/types 包已经创建好了,README 写得很漂亮,9 个模块文件一应俱全。但是:
bash
# 搜索整个 monorepo,谁在用 @cmclink/types?
$ grep -r "@cmclink/types" --include="*.ts" --include="*.vue" .
# 结果:0 条
零消费者。包搭好了,没人接入。
二、设计思想
2.1 类型归属三问
每遇到一个类型定义,问三个问题:
bash
Q1: 几个子应用在用?
├── ≥2 个 → 放公共包
└── 1 个 → 放当前应用
Q2: 它跟什么绑定?
├── API 请求 → @cmclink/api
├── 工具函数 → @cmclink/utils
└── 纯类型 → @cmclink/types
Q3: 在应用内,它属于谁?
├── 组件专属 → 就近放组件目录
├── Store 专属 → stores/types.ts
└── 页面专属 → views/xxx/types.ts
这三个问题形成了一个决策树,任何类型都能找到唯一归属。
2.2 显式优于隐式
全局类型(declare global)的最大问题是来源不可追溯 。当你在代码里写 params: PageParam,IDE 和代码审查者都不知道这个 PageParam 从哪来。
我们的原则:
| 类型种类 | 策略 | 理由 |
|---|---|---|
工具类型(Recordable/Nullable) |
全局声明 ✅ | 高频使用,类似 Promise/Record,显式导入反而增加噪音 |
业务类型(PageParam/TokenType) |
显式导入 ✅ | 来源可追溯,IDE 跳转可用,重构安全 |
typescript
// ✅ 工具类型:全局使用,无需导入
const data: Recordable = {}
const el: Nullable<HTMLElement> = null
// ✅ 业务类型:显式导入,来源清晰
import type { PageParam } from '@cmclink/api'
import type { FormSchema } from '@cmclink/types/form'
2.3 就近原则
类型应该离使用它的代码尽可能近:
bash
❌ 远距离:types/flow.ts → views/home/index.vue(跨 3 层目录)
✅ 就近化:views/home/types.ts → views/home/index.vue(同目录)
好处:
- 删除页面时类型跟着删,不会留下孤儿文件
- 代码审查时一目了然,不用跳来跳去
- IDE 自动补全更精准,因为作用域更小
2.4 单一来源(Single Source of Truth)
每个类型只在一个地方定义,其他地方 re-export:
typescript
// 📍 唯一定义:packages/types/src/form.ts
export type FormSchema = { ... }
// 📍 re-export:apps/main/src/components/Form/src/types.ts
export type { FormSchema } from '@cmclink/types/form'
修改时只改一处,所有消费者自动同步。
三、实施过程
3.1 Phase 0:types 目录清理
| 操作 | 文件 | 效果 |
|---|---|---|
| 删除 | container.d.ts (249 行) |
零引用的箱管类型,不属于主应用 |
| 合并 | user.ts → stores/types.ts |
UserRoleType 仅 store 和 1 个组件使用 |
| 就近化 | flow.ts/msg.ts/marketing.ts → views/home/types.ts |
首页专属 UI 类型 |
| 合并 | form.d.ts + components.d.ts → components/Form/src/types.ts |
Form 组件专属 |
结果:10 个文件 → 3 个文件,删除 406 行。
3.2 Phase 1 (P0):接入 @cmclink/types
jsonc
// tsconfig.app.json --- 引入公共全局类型
{
"include": [
"src/**/*",
"../../packages/types/src/global.d.ts", // 新增
"../../packages/types/src/env.d.ts", // 新增
"../../packages/types/src/router.d.ts" // 新增
]
}
typescript
// shims-vue.d.ts --- 从 121 行瘦身到 18 行
// 删除整个 declare global 块,全局类型由 @cmclink/types 统一提供
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}
3.3 Phase 2 (P1):消除隐式依赖
typescript
// ❌ 治理前:PageParam 从天上掉下来
export const getMessageLists = (params: PageParam) => { ... }
// ✅ 治理后:来源清晰
import type { PageParam } from '@cmclink/api'
export const getMessageLists = (params: PageParam) => { ... }
3.4 Phase 3 (P2):公共包升级 + API 类型规范化
Form 类型 :将 @cmclink/types/form 从精简版(75 行)升级为完整版(169 行),主应用改为 re-export(152 行 → 21 行)。
Profile 类型 :6 个内联 interface 从 API 文件提取到同目录 types.ts,API 文件只保留请求逻辑。
四、收益分析
4.1 可量化收益
| 指标 | 治理前 | 治理后 | 变化 |
|---|---|---|---|
types/ 文件数 |
10 | 3 | -70% |
types/ 总行数 |
~630 | ~120 | -81% |
shims-vue.d.ts 行数 |
121 | 18 | -85% |
| 重复类型定义 | 3 处(PageParam 等) | 0 | -100% |
| 死文件/零引用 | 1 个(container.d.ts 249 行) | 0 | -100% |
@cmclink/types 消费者 |
0 | 1(主应用) | 从零到一 |
| Form types.ts 行数 | 152(本地定义) | 21(re-export) | -86% |
| profile.ts 行数 | 84(混合) | 34(纯请求) | -60% |
4.2 不可量化收益
- IDE 体验提升 :全局类型由 tsconfig include 统一提供,
Recordable/ComponentRef等不再时有时无地报红 - 重构安全性:类型单一来源,改一处全局生效,不会出现"改了 A 忘了 B"
- 新人上手成本降低:类型归属有明确规则,不用猜"这个类型该放哪"
- 代码审查效率:API 文件只有请求逻辑,类型定义在独立文件,职责分离
- 跨子应用一致性 :其他子应用接入
@cmclink/types只需 3 步,类型定义天然统一
4.3 为后续子应用铺路
现在任何新子应用接入公共类型只需:
bash
# Step 1: 添加依赖
pnpm add -D @cmclink/types
# Step 2: tsconfig include 全局类型(复制 3 行)
# Step 3: 删除本地重复声明(如果有的话)
五、成本分析
5.1 一次性成本
| 项目 | 耗时 | 风险 |
|---|---|---|
| types 目录清理 + 就近化 | ~30 min | 低(纯重构,不改逻辑) |
| 接入 @cmclink/types + 删除重复声明 | ~10 min | 低(tsconfig + 删代码) |
| 消除隐式全局类型 | ~15 min | 低(添加 import 语句) |
| Form 类型升级 + re-export | ~30 min | 中(公共包变更,需验证) |
| Profile 类型提取 | ~15 min | 低(纯提取,不改逻辑) |
| 合计 | ~100 min | --- |
5.2 持续成本
| 项目 | 成本 | 说明 |
|---|---|---|
| 新增类型时的决策 | 极低 | 按决策树走,30 秒内确定归属 |
| 公共包类型变更 | 低 | 改一处,所有消费者自动同步 |
| 代码审查 | 降低 | 类型和逻辑分离,审查更聚焦 |
5.3 风险与缓解
| 风险 | 缓解措施 |
|---|---|
| 公共包改类型影响多个子应用 | 公共包类型变更需 CR 审批 + 全量构建验证 |
| IDE 全局类型偶尔不识别 | tsconfig include 路径明确,重启 TS Server 即可 |
| 团队成员不知道新规范 | 本文 + 公共TS类型管理方案.md + 代码审查把关 |
六、规范速查卡
贴在工位上(或者 bookmark 这篇文章):
bash
┌─────────────────────────────────────────────┐
│ 类型放哪里?速查表 │
├─────────────────────────────────────────────┤
│ │
│ ≥2 个子应用用? │
│ ├── 跟 API 绑定 → @cmclink/api │
│ ├── 跟工具函数绑定 → @cmclink/utils │
│ └── 纯类型 → @cmclink/types │
│ │
│ 仅 1 个子应用用? │
│ ├── 组件专属 → 组件目录/types.ts │
│ ├── Store 专属 → stores/types.ts │
│ ├── 页面专属 → views/xxx/types.ts │
│ └── API 专属 → api/xxx/types.ts │
│ │
│ 全局工具类型(Recordable 等)? │
│ → @cmclink/types/global.d.ts │
│ → tsconfig include 引入,无需 import │
│ │
├─────────────────────────────────────────────┤
│ ❌ 禁止: │
│ • shims-vue.d.ts 里加业务类型 │
│ • API 文件里内联 interface │
│ • 跨子应用直接 import 类型 │
│ • @cmclink/types 里引入运行时依赖 │
└─────────────────────────────────────────────┘
七、总结
这次类型治理的本质是建立秩序。代码库像一座城市,类型定义是城市的地址系统。当地址系统混乱时,每个人都在花时间找路;当地址系统清晰时,所有人都能快速到达目的地。
三个核心原则:
- 单一来源 --- 每个类型只定义一次
- 显式优于隐式 --- 业务类型必须 import,来源可追溯
- 就近原则 --- 类型离使用它的代码越近越好
100 分钟的投入,换来的是整个团队在类型管理上的长期效率提升。值得。
如有疑问,欢迎在 Code Review 中讨论,或直接找我聊。