微前端架构下的 TypeScript 类型治理实践

日期: 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 个文件,承担了远超其职责的工作:

types/
├── auto-imports.d.ts    ← 自动生成,没问题
├── shims-vue.d.ts121 行!塞了 .vue 声明 + store 类型 + 50 行全局工具类型
├── container.d.ts249 行箱管类型,零引用(属于箱管子应用)
├── user.ts              ← 仅 store 和 1 个组件使用
├── flow.ts              ← 仅首页使用
├── msg.ts               ← 仅首页使用
├── marketing.ts         ← 仅首页使用
├── form.d.tsForm 组件专属
├── components.d.tsForm 组件专属
└── tabs.ts              ← 主应用路由/菜单类型

核心问题:没有分类标准,什么类型都往 types/ 里扔。

1.2 全局类型的"三重影分身"

PageParam 这个接口,同时存在于:

// 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 个模块文件一应俱全。但是:

# 搜索整个 monorepo,谁在用 @cmclink/types?
$ grep -r "@cmclink/types" --include="*.ts" --include="*.vue" .
# 结果:0 条

零消费者。包搭好了,没人接入。


二、设计思想

2.1 类型归属三问

每遇到一个类型定义,问三个问题:

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 跳转可用,重构安全
// ✅ 工具类型:全局使用,无需导入
const data: Recordable = {}
const el: Nullable<HTMLElement> = null

// ✅ 业务类型:显式导入,来源清晰
import type { PageParam } from '@cmclink/api'
import type { FormSchema } from '@cmclink/types/form'

2.3 就近原则

类型应该离使用它的代码尽可能近:

❌ 远距离: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:

// 📍 唯一定义: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.tsstores/types.tsUserRoleType 仅 store 和 1 个组件使用
就近化flow.ts/msg.ts/marketing.tsviews/home/types.ts首页专属 UI 类型
合并form.d.ts + components.d.tscomponents/Form/src/types.tsForm 组件专属

结果:10 个文件 → 3 个文件,删除 406 行。

3.2 Phase 1 (P0):接入 @cmclink/types

// tsconfig.app.json — 引入公共全局类型
{
  "include": [
    "src/**/*",
    "../../packages/types/src/global.d.ts",   // 新增
    "../../packages/types/src/env.d.ts",       // 新增
    "../../packages/types/src/router.d.ts"     // 新增
  ]
}
// 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):消除隐式依赖

// ❌ 治理前: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/ 文件数103-70%
types/ 总行数~630~120-81%
shims-vue.d.ts 行数12118-85%
重复类型定义3 处(PageParam 等)0-100%
死文件/零引用1 个(container.d.ts 249 行)0-100%
@cmclink/types 消费者01(主应用)从零到一
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 为后续子应用铺路

现在任何新子应用接入公共类型只需:

# 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 这篇文章):

┌─────────────────────────────────────────────┐
│           类型放哪里?速查表                   │
├─────────────────────────────────────────────┤
│                                              │
│  ≥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 里引入运行时依赖            │
└─────────────────────────────────────────────┘

七、总结

这次类型治理的本质是建立秩序。代码库像一座城市,类型定义是城市的地址系统。当地址系统混乱时,每个人都在花时间找路;当地址系统清晰时,所有人都能快速到达目的地。

三个核心原则:

  1. 单一来源 — 每个类型只定义一次
  2. 显式优于隐式 — 业务类型必须 import,来源可追溯
  3. 就近原则 — 类型离使用它的代码越近越好

100 分钟的投入,换来的是整个团队在类型管理上的长期效率提升。值得。


如有疑问,欢迎在 Code Review 中讨论,或直接找我聊。