17

架构与设计系统

2,707 行约 127 分钟88 个代码块71 张表格更新于 2026-11-03

17 · 架构与设计系统(详解版)

一句话定位:架构决定「代码怎么分、依赖往哪走、谁能改谁」;设计系统决定「界面怎么长、组件怎么复用、多团队怎么保持一致」。两者都是用约束换长期效率。

读完本篇你应该能做到

  1. 为一个中大型前端项目划出清晰的层次与模块边界,并用工具把边界固化下来;
  2. 独立设计并落地一套设计系统(令牌 → 组件 → 文档 → 治理),说得清每一层的职责;
  3. 判断何时该上微前端,能对比五种隔离方案的代价,而不是只会说「用 qiankun」;
  4. 用 ADR、度量与绞杀者模式推动架构演进,做到「不停业务换地基」。

0. 领域导论

0.1 这个领域解决什么问题

现象根因归属
10 个团队改同一个仓库,天天冲突单体前端 + 无边界模块划分
三个业务线三套按钮,视觉不一致无设计系统或采纳率低设计系统
改一个公共组件,五个页面挂组件 API 不稳定、无版本策略资产治理
微前端上线后样式互相污染无 CSS 隔离与命名约定隔离方案
新人三个月还不敢改核心模块依赖混乱、没有门面与文档分层与边界
技术债越积越多,没人敢动无 ADR、无度量、无 owner治理机制
每次发版都要全量回归模块间隐式耦合,无法独立验证演进策略

架构问题有个共同特征:短期看不出来,长期无法回避。它不会让某个功能失败,但会让「每一次改动都变慢一点」,直到某天团队发现:加一个筛选条件要改 6 个文件、跑 20 分钟测试、还要找 3 个人确认。

三条会同时恶化的曲线:

                ┌──────────── 改动成本(随耦合度上升)
                │        ┌─── 回归范围(随隐式依赖上升)
                │   ┌────┴─── 认知负担(随模块数量上升,若无边界)
                │  /
                │ /
                └──────────────────────────► 时间

架构工作的目标不是「画得漂亮」,而是让这三条曲线在团队规模翻倍时不会失控。

0.2 一个类比

把前端架构想成城市规划:

  • 分区(模块边界):住宅、商业、工业分开,互不干扰。分区不是为了好看,而是为了「建一栋楼不用重新论证整座城市」。
  • 道路(依赖方向):单向主干道。允许所有路口都能掉头,早晚高峰就会堵死。
  • 公共设施(设计系统):水电、路灯统一标准,不是每栋楼自己打井、自己发电。
  • 旧城改造(演进):不能推平重建,要边住边改(绞杀者模式)。推平重建的方案经常死在「住户不同意」上,等价于业务不同意停三个月。
  • 规划文档(ADR):为什么这条路这么修,后人要知道。没有它,每换一任负责人就会重新讨论一次。

三条直接推论:

  1. 没有分区的城市也能住人,只是每加一栋楼都要重新挖路——这就是没有模块边界的小团队项目,早期很快,规模一上来就寸步难行。
  2. 路修得越宽不代表越通畅,关键在「有没有单向、有没有红绿灯」——对应「目录分得越细不代表架构越好」,关键在依赖规则。
  3. 公共设施要先有归属部门,否则坏了没人修——对应设计系统必须有 owner,否则组件库会在半年内腐化。

0.3 学习地图

17 架构与设计系统
  ├─ 前置:06 框架与组件化(组件 API)、09 工程化(Monorepo/构建)、07 状态管理
  ├─ 紧接:20 工程效能(组织、推动、度量)
  ├─ 交叉:13 测试(契约测试、视觉回归)、18 可观测(用线上数据验证架构)
  └─ 长期:技术战略、团队拓扑与梯队

与本套其他篇章的分工:

篇章负责回答本篇如何引用
06 框架与组件化组件内部怎么渲染、怎么复用本篇只谈组件「对外 API 与版本」
09 工程化与构建包怎么构建、怎么发版本篇谈「包之间怎么依赖」
13 测试与质量怎么验证行为本篇谈「架构约束怎么自动校验」
20 工程效能怎么推动组织本篇谈「技术方案怎么被采纳」

0.4 分级标尺

级别能力特征典型产出自检标准
L1 基础会按目录分层写代码清晰的项目结构能解释「这个文件该放哪、为什么」
L2 进阶会设计组件库与模块边界组件库、ADR、依赖规则能解释依赖方向与隔离代价
L3 高级能做微前端与设计系统治理架构方案、迁移计划、采纳率看板能说清「隔离到什么程度、代价是多少」
L4 专家定技术战略、推动组织架构标准、长期路线、组织机制能对「三年后的维护成本」负责

升级的判定标准(比「做过」更严格):

  • L1 → L2:你写的模块被别人复用,且没有因为你的改动导致别人返工。
  • L2 → L3:你设计的约束在你不参与时依然生效(lint、CI、模板、文档)。
  • L3 → L4:你的决策改变了资源分配(人力、预算、排期),并且能说清放弃了什么。

1. L1 基础:先把代码放对位置

1.1 四层结构

src/
├─ app/            应用级装配:路由、Provider、全局配置、错误边界
├─ views/          页面:路由级组合,只做编排,不写业务规则
├─ features/       业务模块:领域逻辑 + 该领域的组件 + 请求
└─ shared/         共享:组件库、工具、类型、请求封装(不含业务)
层职责允许依赖不该出现的东西
app全局装配、路由表、Provider 嵌套、权限入口所有层具体业务规则
views页面布局与区块编排、路由参数解析features、shared复杂计算、直接调接口
features领域模型、业务规则、业务组件、数据获取shared、同领域内部其他领域的内部实现
shared通用组件、工具函数、类型、HTTP 客户端无(最底层)任何业务名词

1.1.1 分层真正解决的三件事

很多人把分层理解成「文件分类」,于是一边分层一边把所有逻辑塞进 views。分层真正解决的是:

  1. 变化隔离:UI 改版只动 views,业务规则改只动 features/model,接口字段改只动 features/api。三者变化频率不同,混在一起就会「改文案顺手动到计算逻辑」。
  2. 可测试性:features/model 是纯函数,不依赖 DOM 与网络,可以直接单测;views 只做编排,用组件测试覆盖。逻辑埋在组件里,测试就得挂载整个页面。
  3. 可并行:两个人分别改不同 feature,冲突面缩到最小;没有边界时,所有改动都落在同一个 utils.ts,冲突是必然的。

1.1.2 依赖方向:为什么只能单向

        ┌─────────┐
        │   app   │  可以依赖所有层(装配者)
        └────┬────┘
             ▼
        ┌─────────┐
        │  views  │
        └────┬────┘
             ▼
        ┌─────────┐
        │features │  ← 同层之间尽量不互相 import
        └────┬────┘
             ▼
        ┌─────────┐
        │ shared  │  最底层,谁都能依赖它,它不依赖任何人
        └─────────┘

反向依赖的代价,用一个具体例子说明:

// ❌ shared/http.ts 里 import 了业务模块
import { getAuthToken } from '@/features/auth';

export async function request(url: string) {
  const token = getAuthToken();
  // ...
}

问题不在「能不能跑」,而在于:

  • shared 被所有模块依赖,现在它反过来依赖 features/auth,形成环;
  • 任何不使用登录的业务(如官网、活动页)也被迫打包 auth;
  • 想在另一个项目复用 shared,得把整个 auth 拖过去;
  • 测试 shared 必须先 mock 认证。

正确做法是把依赖倒过来:shared 定义接口,由 app 在启动时注入实现。

/**
 * shared/http.ts:只依赖抽象,不依赖具体业务。
 * 谁提供 token 由应用层决定。
 */
export interface TokenProvider {
  getToken(): string | null;
  onUnauthorized?(): void;
}

let tokenProvider: TokenProvider = { getToken: () => null };

/**
 * 由应用层在启动时注入,避免 shared 反向依赖 features。
 * @param provider 令牌提供者
 */
export function setTokenProvider(provider: TokenProvider): void {
  tokenProvider = provider;
}

/**
 * 统一请求封装。
 * @param url 请求地址
 * @param init fetch 配置
 * @returns 解析后的 JSON
 */
export async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
  const token = tokenProvider.getToken();
  const headers = new Headers(init.headers);
  if (token) headers.set('Authorization', `Bearer ${token}`);

  const res = await fetch(url, { ...init, headers });
  if (res.status === 401) tokenProvider.onUnauthorized?.();
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return (await res.json()) as T;
}
// ✅ app/bootstrap.ts:装配发生在最上层
import { setTokenProvider } from '@/shared/http';
import { authStore } from '@/features/auth';

setTokenProvider({
  getToken: () => authStore.token,
  onUnauthorized: () => authStore.logout(),
});

这就是依赖倒置在前端最朴素、也最常用的形态:底层定义接口,上层提供实现。它不需要框架,也不需要 DI 容器。

1.1.3 一张表看清依赖矩阵

从 \ 到appviewsfeaturesshared
app—✅✅✅
views❌⚠️ 仅页面内✅✅
features❌❌⚠️ 仅同领域✅
shared❌❌❌✅

⚠️ 的含义:允许,但要有理由。views 之间互相引用页面组件,通常是「本该抽成 feature」的信号;features/a 引用 features/b 的内部文件,通常应该改为「引用 b 的公开门面」或「把共享部分下沉到 shared」。

1.1.4 用工具固化边界(不靠自觉)

规则写在文档里,三个月后一定失效。必须让违规在提交前就报错。

手段一:ESLint 限制深层导入

{
  "rules": {
    "no-restricted-imports": ["error", {
      "patterns": [
        {
          "group": ["@/features/*/internal/*", "@/features/*/components/*"],
          "message": "请从模块门面导入:@/features/order"
        },
        {
          "group": ["../../features/*/model"],
          "message": "跨模块禁止直接引用内部模型,改用公开类型"
        }
      ]
    }]
  }
}

手段二:dependency-cruiser 校验依赖方向

// .dependency-cruiser.js
module.exports = {
  forbidden: [
    {
      name: 'no-shared-to-features',
      severity: 'error',
      comment: 'shared 是底层,不能依赖业务模块',
      from: { path: '^src/shared' },
      to: { path: '^src/(features|views|app)' },
    },
    {
      name: 'no-feature-to-view',
      severity: 'error',
      comment: '业务模块不能依赖页面',
      from: { path: '^src/features' },
      to: { path: '^src/views' },
    },
    {
      name: 'no-circular',
      severity: 'error',
      comment: '循环依赖会让打包顺序与初始化顺序不可控',
      from: {},
      to: { circular: true },
    },
  ],
  options: {
    doNotFollow: { path: 'node_modules' },
    tsPreCompilationDeps: true,
  },
};
# 本地与 CI 都能跑,违规直接失败
npx depcruise src --config .dependency-cruiser.js

手段三:把违规成本写进 PR 模板

- [ ] 新增跨层依赖已在 PR 描述里说明理由
- [ ] `pnpm depcruise` 通过(CI 已强制)

1.1.5 常见坑

坑表现修复
把「分层」做成「按文件类型分」components/、hooks/、utils/ 各装一堆业务先按业务域切,技术层只在 shared 内
shared 变成垃圾桶什么不敢放的都往 shared 塞进 shared 需满足「两个以上业务域使用且无业务语义」
门面文件导出一切index.ts 把内部全导出,等于没边界只导出被外部使用的 API
只在文档里写规则新人不知道、老人图快lint + CI 拦截
分层过度一个 CRUD 页面拆 8 个文件按复杂度分层,简单页面两层足够
用路径别名掩盖耦合@/ 让跨层引用看起来「很自然」别名不改变依赖方向,规则要单独校验

怎么验证你的分层是有效的:

  1. 删掉某个 feature 目录,构建是否还能通过?(能通过说明没有隐式依赖)
  2. 随机挑一个模块,问「改这个需求要动几个目录」?(超过 3 个说明边界有问题)
  3. 跑一次依赖图,看是否有环、是否有 shared → features 的边。
  4. 让新人只看目录猜「改这个功能去哪」,猜对率是边界的真实指标。

1.2 一个业务模块的内部结构

features/order/
├─ api.ts          请求与 DTO 转换(把后端字段翻译成领域模型)
├─ model.ts        领域模型与规则(纯函数,无副作用)
├─ hooks.ts        数据订阅与副作用(React 适配层)
├─ components/     该领域专用组件
├─ pages/          该领域的页面(可选,也可放 views)
├─ __tests__/      单测与组件测试
└─ index.ts        对外门面(只导出公开 API)

1.2.1 为什么要在 api 层做 DTO 转换

后端字段是「数据库视角」,前端模型是「界面视角」,两者不应该直接耦合。

/** 后端返回的原始结构(snake_case、字符串金额、状态码) */
interface OrderDTO {
  order_id: string;
  total_amount: string;
  status: 0 | 1 | 2 | 3;
  created_at: string;
}

/** 前端领域模型:语义化、不可变、可直接用于渲染 */
export interface Order {
  id: string;
  total: number;
  status: 'pending' | 'paid' | 'shipped' | 'closed';
  createdAt: Date;
}

const STATUS_MAP: Record<OrderDTO['status'], Order['status']> = {
  0: 'pending',
  1: 'paid',
  2: 'shipped',
  3: 'closed',
};

/**
 * 把后端 DTO 转成领域模型。
 * 后端改字段名时,只改这里,页面无感知。
 * @param dto 后端订单结构
 * @returns 前端订单模型
 */
export function toOrder(dto: OrderDTO): Order {
  return {
    id: dto.order_id,
    // 金额用字符串传输避免精度丢失,这里转成分(整数)再展示
    total: Math.round(Number(dto.total_amount) * 100),
    status: STATUS_MAP[dto.status] ?? 'pending',
    createdAt: new Date(dto.created_at),
  };
}

价值在三个地方:

  1. 字段变更只改一处:后端把 total_amount 改成 amount 时,页面不用动。
  2. 类型收窄:后端状态码是数字,前端用联合类型,switch 能穷尽检查。
  3. 可测:toOrder 是纯函数,测试覆盖率达 100% 很容易。

反模式:把 dto 直接 as Order 强转。类型检查通过了,运行时字段还是错的,且错误会扩散到所有页面。

1.2.2 门面导出的三条规则

// index.ts
export { OrderList } from './components/OrderList';
export { useOrder } from './hooks';
export type { Order } from './model';
// 不导出:内部实现、临时工具、后端 DTO
规则说明反例
只导出「别人真的会用」的导出面越大,重构成本越高把 components/ 全量 re-export
不导出 DTO 与内部类型防止调用方依赖后端结构export type { OrderDTO }
门面保持稳定内部随便改,外部感知不到外部直接 import ... from './model'

门面的隐性成本:export * 会破坏 tree-shaking(打包器可能保守地保留整个模块),也会让依赖关系变得不可分析。用显式导出,别图省事。

1.2.3 hooks 层是「适配层」,不是「第二套业务逻辑」

import { useQuery } from '@tanstack/react-query';
import { fetchOrder } from './api';
import type { Order } from './model';

/**
 * 订单查询:把「数据获取」与「组件渲染」解耦。
 * 规则留在 model.ts,这里只负责生命周期与缓存。
 * @param id 订单号
 */
export function useOrder(id: string) {
  return useQuery<Order>({
    queryKey: ['order', id],
    queryFn: () => fetchOrder(id),
    staleTime: 30_000,
  });
}

判断标准:把 hooks 里的逻辑抽出来,不看 React 也能测,说明规则放对了位置;如果 hooks 里全是 if (status === 'paid') 这类业务判断,应该下沉到 model.ts。

1.2.4 常见坑与验证

坑表现修复
api.ts 里写业务判断接口返回后直接算折扣业务规则放 model.ts
model.ts 里 import React纯逻辑被 UI 绑死model 只依赖类型与纯函数
hooks.ts 变成巨型文件500 行、十几个 hook按用例拆分,规则下沉
模块之间互相 import 内部文件依赖图出现密集交叉只允许门面导入
一个模块 20 个文件全在根目录找不到东西按 components/model/api 分

验证方式:给模块写一条「架构测试」——从模块外部只 import 门面,能否构造出完整用例。能,说明门面设计合理;不能,说明还有隐式依赖。

1.3 设计系统是什么

设计令牌(Token)
   ↓ 颜色 / 间距 / 字体 / 圆角 / 阴影 / 动效时长
基础组件(Primitives)
   ↓ Button / Input / Select / Modal / Table
复合组件(Patterns)
   ↓ Form / FilterBar / DataTable / PageHeader
页面模板(Templates)
   ↓ 列表页 / 详情页 / 表单页 / 仪表盘
文档与治理(Docs & Governance)
   ↓ 用法、可访问性、版本、采纳率、owner

1.3.1 设计系统不等于组件库

组成回答什么问题缺失后的症状
Token颜色/间距的唯一来源是什么硬编码色值满天飞,换肤要全量搜替换
组件交互的标准实现是什么每个团队自己写 Modal,焦点管理各不相同
模式一类页面应该怎么组织列表页三种布局,用户每次都要重新找按钮
文档什么时候用、怎么用、别怎么用组件有了但没人知道,或被误用
治理谁维护、怎么演进、如何度量半年后无人认领,组件库逐渐废弃

一句话:组件库解决「有没有」,设计系统解决「一致不一致、能不能长期一致」。

1.3.2 一个最小可用设计系统的清单

不需要一上来就做 60 个组件。一个能运转的最小版本只需要:

1. 令牌:色板(含语义色)、间距刻度、字号/行高、圆角、阴影、动效时长
2. 组件:Button / Input / Select / Checkbox / Modal / Table / Tooltip / Toast(8 个覆盖 80% 场景)
3. 文档:每个组件一页,含示例、API 表、可访问性说明、反例
4. 分发:一个包 + 按需入口 + 类型声明 + 样式产物
5. 治理:owner、issue 模板、版本策略、采纳率月报

1.3.3 设计系统的三种失败模式

失败模式一:只做组件,不做令牌
  → 换主题时发现颜色写死在 300 个文件里

失败模式二:只做规范,不做工具
  → 设计稿统一了,代码里没人执行,三个月后回到原样

失败模式三:做了但没人用
  → 组件 API 难用、文档缺失、与设计稿不一致,业务宁愿自己写

对应解法:令牌先行 → 工具降低采纳成本 → 用采纳率数据驱动改进,而不是发通知要求使用。

1.3.4 常见坑

坑后果修复
组件数量当 KPI维护成本爆炸,质量下降用「覆盖率 + 重复实现数」衡量
Token 只有原始值没有语义暗色模式无法实现补语义层,组件只消费语义层
组件直接读全局主题变量组件无法局部换肤组件用组件级 token,由主题映射
文档只有 API 表新人不知道怎么选加「何时用/何时不用」与完整示例
没有 owner组件缺陷无人修指定团队 + SLA + issue 模板

1.4 组件 API 的基本要求

1.4.1 透传原生属性(否则用户无法扩展)

import type { ButtonHTMLAttributes, ReactNode } from 'react';

type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: 'primary' | 'secondary' | 'danger' | 'text';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
  icon?: ReactNode;
};

/**
 * 基础按钮:透传原生属性,保证 onFocus / aria-* / data-* 都能用。
 * @param props 按钮属性
 */
export function Button({
  variant = 'primary',
  size = 'md',
  loading = false,
  icon,
  disabled,
  children,
  ...rest
}: ButtonProps) {
  return (
    <button
      type="button"
      className={`btn btn--${variant} btn--${size}`}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      {...rest}
    >
      {loading ? <span className="btn__spinner" aria-hidden="true" /> : icon}
      <span className="btn__label">{children}</span>
    </button>
  );
}

三个细节容易被忽略:

  1. {...rest} 必须放最后,否则用户的 className / onClick 会被覆盖。
  2. type="button" 要有默认值,否则在 <form> 里默认是 submit,点一下表单就提交了。
  3. aria-busy 只在 true 时输出,aria-busy={false} 会输出 aria-busy="false",虽然合法但噪音大。

1.4.2 受控与非受控都要支持

import { useCallback, useState } from 'react';

interface InputProps {
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
}

/**
 * 同时支持受控与非受控:
 * - 传 value 即受控,状态由外部管理;
 * - 不传 value 即非受控,内部维护状态。
 * 不要在两种模式之间来回切换,否则 React 会警告。
 */
export function Input({ value, defaultValue = '', onChange }: InputProps) {
  const isControlled = value !== undefined;
  const [inner, setInner] = useState(defaultValue);

  const current = isControlled ? value : inner;

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      if (!isControlled) setInner(e.target.value);
      onChange?.(e.target.value);
    },
    [isControlled, onChange]
  );

  return <input value={current} onChange={handleChange} />;
}

为什么不是「只支持受控」:简单场景下用户要写 useState 才能用一个输入框,样板代码过多;为什么不是「只支持非受控」:表单校验、联动、提交都需要读值。两种都支持是成本最低的折中。

1.4.3 组合优于配置

// ❌ 配置式:每加一个需求就加一个布尔属性
<Modal
  title="编辑"
  showClose
  showFooter
  showCancel
  showConfirm
  confirmText="保存"
  cancelText="取消"
  showIcon
  iconType="warning"
/>

// ✅ 组合式:结构由使用方决定
<Modal>
  <Modal.Header title="编辑" />
  <Modal.Body>{children}</Modal.Body>
  <Modal.Footer>
    <Button variant="secondary">取消</Button>
    <Button>保存</Button>
  </Modal.Footer>
</Modal>
方式优点缺点适用
配置属性上手快、约束强属性爆炸、无法覆盖特殊场景内部简单组件
组合子组件灵活、可读需要更多文档布局类、容器类组件
asChild / render语义与行为分离理解成本高需要换标签的场景
import { cloneElement, isValidElement, type ReactElement } from 'react';

interface AsChildProps {
  asChild?: boolean;
  children: ReactElement;
  className?: string;
}

/**
 * 把样式与行为「借给」子元素,避免多余的包裹层。
 * 生产环境更推荐 Radix 的 Slot 实现,处理了 ref 合并与事件合成。
 * @param props 属性
 */
export function Slot({ asChild, children, className, ...rest }: AsChildProps & Record<string, unknown>) {
  if (!asChild || !isValidElement(children)) {
    return <span className={className} {...rest}>{children}</span>;
  }
  const childProps = children.props as { className?: string };
  return cloneElement(children, {
    ...rest,
    className: [className, childProps.className].filter(Boolean).join(' '),
  } as never);
}

1.4.4 状态、可访问性、样式三者对齐

组件的「状态」不只是 disabled,还包括 hover、focus-visible、active、loading、invalid。API 设计时要保证:

要求做法
键盘可用原生元素优先;自定义控件实现 role + tabIndex + 键盘处理
焦点可见:focus-visible 保留轮廓,不要全局 outline: none
状态可被读屏感知aria-invalid、aria-describedby、aria-busy
尺寸可预测固定高度刻度(32/40/48),避免内容把布局撑开
主题可切换只消费语义 token,不写死颜色

1.4.5 常见坑

坑后果修复
...rest 放在 className 之前用户传的 className 被覆盖透传放在最后
用 type 命名变体与原生 type 冲突用 variant / tone
布尔属性堆叠API 组合爆炸变体 + 组合子组件
组件内部写死 z-index弹窗层级打架统一层级 token(如 --z-modal: 1000)
在组件里直接 document.body.appendChildSSR 报错、卸载残留用 Portal 并在卸载时清理
组件依赖具体业务 store无法复用通过 props / context 注入

1.5 L1 小结与自检

主题关键结论
分层四层单向依赖,变化隔离
依赖底层定义接口,上层注入实现
模块门面导出,DTO 与领域模型分离
设计系统令牌 → 组件 → 模式 → 模板 + 文档 + 治理
组件 API透传、受控/非受控、组合优先、状态可感知

自检题

  1. 为什么 shared 不能依赖 features?——会造成反向依赖与循环引用,底层被业务绑死,复用与测试都变难;正确做法是 shared 定义接口,由 app 注入实现,并用 dependency-cruiser 在 CI 拦截。
  2. 模块的 index.ts 有什么作用?——收敛公共导出面,隐藏内部结构,控制包体、避免深路径导入,让内部重构不影响调用方;注意别用 export *,否则 tree-shaking 与依赖分析都会退化。
  3. 设计系统与组件库的区别?——设计系统包含 token、规范、文档、工具与治理机制;组件库只是其中一层实现,缺了 token 与治理,半年内必然腐化。
  4. 为什么组件要同时支持受控与非受控?——受控便于校验与联动,非受控减少样板代码;两种都支持是成本最低的折中,但同一组件生命周期内不能来回切换。
  5. 如何判断分层是否真的生效?——删掉一个模块能否构建、改一个需求要动几个目录、依赖图是否有环、新人能否猜对文件位置;四条里至少三条成立才算有效。

2. L2 进阶:边界、令牌与微前端

2.1 模块边界怎么划

2.1.1 四种划分依据

划分依据例子优点风险适合
按业务域order / user / pay高内聚,需求可端到端交付跨域共享要多一层抽象大多数中大型项目
按技术层components / hooks / utils简单、直观业务散落,改需求要跨 5 个目录组件库、工具库
按路由/页面pages/order / pages/user与 URL 对齐,易于理解共享逻辑无处安放页面简单、无复用
按团队team-a / team-b责任清晰,减少冲突技术重复、公共能力分裂多团队单仓

推荐:业务域优先(vertical slice),技术层只在 shared 内部分。 理由只有一个:需求是按业务域来的。以「增加一个订单筛选」为例:

按业务域切:  features/order/{api,model,components}  ← 一个目录内完成
按技术层切:  api/order.ts + hooks/useOrder.ts + components/OrderFilter.tsx + types/order.ts

后者每次改动都要横跨四个目录,且很难判断「哪些文件属于同一个需求」。

2.1.2 一次需求改动,是边界的试金石

需求:订单列表增加筛选
   ├─ features/order/api.ts            新增查询参数、DTO 转换
   ├─ features/order/model.ts          筛选条件的默认值与校验
   ├─ features/order/components/Filter.tsx   筛选 UI
   └─ views/order-list.tsx             只做编排与路由参数解析

如果这次改动没有碰到 features/user、shared/ui、app/router,说明边界是健康的;如果每次需求都顺手改 shared,那不是共享,是耦合。

2.1.3 边界的三条判据

划不划一个模块,看三个问题:

判据问法如果答案是
变化频率它和别的部分是否因同样的原因变化?是 → 放在一起;否 → 拆开
所有权有没有明确的团队/个人负责?有 → 可以独立成模块;没有 → 先别拆
复用范围被几个业务域使用?≥2 且无业务语义 → 下沉 shared;只有 1 个 → 留在原地

反面案例:把「订单」拆成 order-list、order-detail、order-edit 三个模块,各自维护一份 Order 类型与状态映射。它们因同样的原因变化(订单字段调整),拆开只增加了同步成本。

2.1.4 共享代码的三层下沉

第一层:模块内部私有          features/order/utils/format.ts
第二层:跨模块共享(有业务)   features/shared-order/  ← 仍属业务域
第三层:通用能力(无业务)     shared/{ui,utils,http,types}

判断口诀:出现第二个使用方时再下沉,且下沉时要改名去掉业务词。formatOrderAmount 下沉后应变成 formatCurrency,否则 shared 会慢慢长成业务仓库。

/**
 * 通用金额格式化:不含任何业务语义。
 * @param cents 以分为单位的整数
 * @param currency 货币代码
 * @returns 展示字符串
 */
export function formatCurrency(cents: number, currency = 'CNY'): string {
  return new Intl.NumberFormat('zh-CN', {
    style: 'currency',
    currency,
    minimumFractionDigits: 2,
  }).format(cents / 100);
}

2.1.5 常见坑与验证

坑表现修复
模块按页面切,共享逻辑没处放pages/a 和 pages/b 各写一份下沉到 features 或 shared
过早抽象只有一个使用方就抽公共组件等第二个使用方出现
模块之间共享类型文件改一个字段全线报错各模块定义自己的模型,边界处转换
模块数量爆炸30 个 feature,每个 3 个文件按业务域合并,模块数控制在 8–15
边界靠 code review 守评审一忙就放行用 depcruise + lint 自动化

验证方式:

# 1) 生成依赖图,人工看是否有环与跨层边
npx depcruise src --include-only "^src" --output-type dot | dot -Tsvg > deps.svg

# 2) 统计每个模块被引用次数,次数异常高说明它是「隐性核心」
grep -r "from '@/features/" src --include=*.ts --include=*.tsx | cut -d/ -f2 | sort | uniq -c | sort -rn

2.2 设计令牌的三层结构

2.2.1 三层各自的职责

/* 1) 原始值(primitive):只描述「是什么」,不带语义 */
:root {
  --blue-50: #e6f4ff;
  --blue-500: #1677ff;
  --blue-600: #0958d9;
  --gray-100: #f5f5f5;
  --gray-900: #1f1f1f;
  --space-1: 4px;
  --space-2: 8px;
  --space-4: 16px;
  --radius-1: 4px;
  --radius-2: 6px;
  --font-size-2: 14px;
  --font-size-3: 16px;
}

/* 2) 语义值(semantic):描述「用在哪」,是主题切换的唯一开关 */
:root {
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-600);
  --color-text: var(--gray-900);
  --color-bg: #ffffff;
  --color-bg-subtle: var(--gray-100);
  --space-control-x: var(--space-4);
  --space-control-y: var(--space-2);
  --radius-control: var(--radius-2);
  --font-size-body: var(--font-size-2);
}

/* 3) 组件值(component):只在该组件内消费,便于局部定制 */
:root {
  --button-primary-bg: var(--color-primary);
  --button-primary-bg-hover: var(--color-primary-hover);
  --button-padding-x: var(--space-control-x);
  --button-radius: var(--radius-control);
}

.button--primary {
  background: var(--button-primary-bg);
  padding: var(--space-control-y) var(--button-padding-x);
  border-radius: var(--button-radius);
  font-size: var(--font-size-body);
}

.button--primary:hover {
  background: var(--button-primary-bg-hover);
}
层能不能跨层引用换主题时改哪层组件能不能直接用
原始值不能被组件引用不动❌
语义值可引用原始值改这一层可以,但不推荐
组件值可引用语义值通常不动✅ 推荐

核心规则:组件只消费组件值或语义值,永不直接消费原始值。 这条规则一旦破,暗色模式就只能在组件里写 if。

2.2.2 命名规范

推荐结构:--{类别}-{属性}-{变体}-{状态},类别可省略。

好名字坏名字原因
--color-text-secondary--gray-600-text命名绑定视觉值,换色就要改名
--space-section-gap--space-24语义化便于整体调整节奏
--button-bg-hover--button-hover-background-color冗长但信息不多
--radius-card--radius-8同上

三条约束:

  1. 不要用视觉值做名字:--blue-500 只应存在于原始层。
  2. 状态后缀统一:-hover / -active / -disabled / -focus。
  3. 层级用语义词:background / surface / border / text,不要用 light / dark 这类与主题耦合的词。

2.2.3 主题切换的三种作用域

/* A. 全局主题:挂在 html 上 */
[data-theme='dark'] {
  --color-text: #e6e6e6;
  --color-bg: #141414;
  --color-bg-subtle: #1f1f1f;
}

/* B. 局部主题:挂在任意容器上,适合「预览区」「嵌入模块」 */
.theme-preview[data-theme='dark'] {
  --color-text: #e6e6e6;
  --color-bg: #141414;
}

/* C. 跟随系统:用媒体查询设默认值,用户手动切换时用属性覆盖 */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme='light']) {
    --color-text: #e6e6e6;
    --color-bg: #141414;
  }
}

关键点:CSS 自定义属性会继承,所以局部主题只要设在容器上,子孙组件自动生效;这也是为什么「组件值」必须引用「语义值」而不是写死颜色——否则局部主题失效。

/**
 * 主题切换:先写 DOM,再持久化,最后通知订阅者。
 * @param theme 目标主题
 */
export function setTheme(theme: 'light' | 'dark' | 'system'): void {
  const root = document.documentElement;
  if (theme === 'system') root.removeAttribute('data-theme');
  else root.setAttribute('data-theme', theme);

  localStorage.setItem('theme', theme);
  window.dispatchEvent(new CustomEvent('themechange', { detail: theme }));
}

/** 首屏防闪烁:在 CSS 加载前同步执行,避免先亮后暗 */
export function applyThemeBeforePaint(): void {
  const saved = localStorage.getItem('theme');
  if (saved && saved !== 'system') {
    document.documentElement.setAttribute('data-theme', saved);
  }
}

2.2.4 令牌的单一来源与产物

Figma(设计稿)
   ↓ Tokens Studio 导出 JSON
tokens.json(唯一来源)
   ↓ Style Dictionary / 自研脚本
CSS 变量 + TS 常量 + 各端产物(小程序、RN)
{
  "color": {
    "primary": { "value": "{blue.500}", "type": "color" },
    "text": { "value": "{gray.900}", "type": "color" }
  },
  "space": {
    "control-x": { "value": "{space.4}", "type": "dimension" }
  }
}
/**
 * 由 tokens.json 生成的类型,保证代码里不会写错 token 名。
 * 生成脚本与设计稿保持同步,避免「设计改了代码没改」。
 */
export const tokens = {
  colorPrimary: 'var(--color-primary)',
  spaceControlX: 'var(--space-control-x)',
} as const;

export type TokenName = keyof typeof tokens;

为什么要生成 TS 常量:style={{ color: 'var(--color-primry)' }} 这种拼写错误不会有任何报错,只会静默失效。有了类型,拼错就编译不过。

2.2.5 常见坑与验证

坑后果修复
组件直接写 #1677ff换肤失效、暗色模式刺眼lint 禁止十六进制色值
原始值与语义值混用无法整体调整组件层只允许语义/组件 token
Token 命名带视觉值换品牌要全局改名用语义命名
只做亮色后期补暗色成本翻倍一开始就用语义层
设计稿与代码不同步走查问题反复出现token 由设计稿生成,纳入 CI 校验
// eslint:禁止在业务代码里写死颜色
{
  "rules": {
    "no-restricted-syntax": ["error", {
      "selector": "Literal[value=/^#([0-9a-fA-F]{3,8})$/]",
      "message": "请使用设计令牌(var(--color-*)),不要硬编码色值"
    }]
  }
}

验证方式:在控制台执行 getComputedStyle(document.documentElement).getPropertyValue('--color-primary');切换主题后再取一次,值应变化;对某个容器设置 [data-theme='dark'],其内部组件颜色应随之变化,而外部不变。

2.3 组件库的工程化

2.3.1 产物形态:一份源码,多种消费方式

packages/ui/
├─ src/                  源码(TS + CSS)
├─ dist/
│  ├─ esm/               ESM 产物(现代打包器)
│  ├─ cjs/               CJS 产物(Node / Jest)
│  ├─ types/             .d.ts
│  └─ styles.css         样式产物(可选按组件拆分)
└─ package.json
产物谁消费注意
ESMVite / Rspack / webpack 5保留 import 语义,利于 tree-shaking
CJSNode、旧测试环境不做 tree-shaking,体积大
.d.tsTS 用户要与 ESM 产物一一对应
CSS所有用户要明确「自动引入」还是「手动引入」

2.3.2 exports 与按需加载

{
  "name": "@acme/ui",
  "type": "module",
  "sideEffects": ["**/*.css"],
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js",
  "types": "./dist/types/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js"
    },
    "./button": {
      "types": "./dist/types/button/index.d.ts",
      "import": "./dist/esm/button/index.js",
      "require": "./dist/cjs/button/index.js"
    },
    "./styles.css": "./dist/styles.css",
    "./package.json": "./package.json"
  },
  "peerDependencies": {
    "react": ">=18",
    "react-dom": ">=18"
  }
}

三个容易踩的点:

  1. exports 一旦声明,未列出的路径全部不可访问(包括 ./dist/xxx),这是好事,但升级时容易打断用户。
  2. sideEffects 必须标出 CSS,否则打包器会把 import '@acme/ui/styles.css' 摇掉。
  3. peerDependencies 而不是 dependencies:React 必须由使用方提供,否则会打包出两个 React,导致 Hook 报错。

2.3.3 样式方案对比

方案隔离主题运行时成本SSR适合
CSS 变量 + 类名弱(靠命名)最好无好设计系统首选
CSS Modules中(构建期哈希)好无好业务项目
CSS-in-JS(运行时)强好有(序列化)需配置组件库较少用
CSS-in-JS(编译期)强好无好追求写法统一
Tailwind弱(工具类)好无好业务快速开发

组件库的默认建议:CSS 变量 + 语义类名 + 独立样式产物。原因是它同时满足「主题可换」「SSR 无额外配置」「消费方可以覆盖样式」三点,而这三点在组件库场景里权重最高。

2.3.4 SSR 与样式提取

/**
 * SSR 场景下,组件库不应在运行时注入 <style>。
 * 做法:构建期抽出静态 CSS,服务端渲染时由框架负责注入。
 */
import '@acme/ui/styles.css'; // 由应用入口引入,服务端与客户端共用

判断标准:如果组件库依赖 useInsertionEffect 或运行时 document.head.appendChild 注入样式,SSR 首屏会出现「无样式闪烁」或水合不一致。选型时优先无运行时注入的方案。

2.3.5 包体积与 tree-shaking

// ✅ 具名导入,打包器能静态分析
import { Button, Modal } from '@acme/ui';

// ⚠️ 命名空间导入:多数打包器仍能分析,但容易整包引入副作用
import * as UI from '@acme/ui';

// ❌ 深路径导入:在声明 exports 后可能直接报错
import Button from '@acme/ui/dist/button';
# 量化:对比「只用一个 Button」时的产物大小
npx source-map-explorer dist/assets/*.js

三个实践:

  1. 每个组件独立入口(./button),给不用打包器优化的场景兜底。
  2. 避免在模块顶层做副作用(如注册全局指令、读 window),否则 tree-shaking 失效。
  3. CI 里加体积门禁:size-limit 或自研脚本,超过阈值直接失败。

2.3.6 文档与测试

类型工具覆盖什么触发时机
交互文档Storybook每个状态可点、可调参每次 PR 预览
行为测试Testing Library点击、输入、键盘单测
视觉回归Chromatic / Playwright 截图像素级变化PR
可访问性axe-core / Storybook a11y对比度、标签、角色单测 + PR
类型测试tsd / vitest typecheckAPI 类型契约CI
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

/** 组件测试优先断言「用户能看到/能做到什么」,而不是内部 class */
test('loading 时不可点击且对读屏可见', async () => {
  const onClick = vi.fn();
  render(<Button loading onClick={onClick}>保存</Button>);

  const btn = screen.getByRole('button', { name: /保存/ });
  expect(btn).toBeDisabled();
  expect(btn).toHaveAttribute('aria-busy', 'true');

  await userEvent.click(btn);
  expect(onClick).not.toHaveBeenCalled();
});

2.3.7 发布流程:changesets

# 1) 开发者在 PR 里声明变更类型
pnpm changeset

# 2) 合并后由机器人开一个「版本发布」PR
#    自动汇总 changelog、升版本号、更新依赖

# 3) 合并发布 PR,CI 负责 npm publish + tag
变更版本是否要 codemod
新增可选 propminor否
修 bugpatch否
改默认值minor(需公告)否,但要写迁移提示
重命名 propmajor是
删除 propmajor是
改 DOM 结构minor(可能影响测试)视情况

2.3.8 常见坑

坑后果修复
React 打进 dependencies双实例、Hook 报错移到 peerDependencies
只出 CJS现代构建无法 tree-shaking同时出 ESM
sideEffects: false 但含 CSS样式被摇掉显式列出 **/*.css
组件库自带全局 reset污染使用方样式reset 由应用负责,库只做局部
文档只有代码块用户不知道何时用加「何时用/不用」与可访问性说明
大版本无 codemod升级停滞提供 jscodeshift 脚本

2.4 微前端:五种方案与真实代价

2.4.1 先回答「是否真的需要」

微前端解决的是组织问题,不是技术问题。只有在下面三条同时成立时才值得:

1. 多个团队需要独立发布(发布节奏不同)
2. 需要一个统一的入口与体验(不能拆成多个独立站点)
3. 技术栈或历史包袱不同,短期无法统一

不满足时的替代方案:

场景更好的选择
一个团队、发布节奏一致单仓多包(Monorepo),不要微前端
只是想做代码拆分路由级懒加载 + 模块边界
只是想复用组件组件库 + 设计系统
只是想隔离第三方页面iframe(最省事,别自研框架)

微前端的隐性成本:路由与鉴权的统一、样式冲突、运行时依赖版本、跨应用通信、发布编排、监控归属。这些成本不会因为框架好用而消失。

2.4.2 五种方案总表

方案JS 隔离CSS 隔离路由依赖共享接入成本典型场景
iframe最强(独立进程上下文)最强需同步不能共享低第三方系统、强隔离
运行时加载(qiankun 类)中(Proxy + 快照)中(scoped / Shadow DOM)基座统一通过 props/externals中多团队渐进迁移
Module Federation弱(同一运行时)弱(全局样式)基座或子应用强(共享模块)高同栈、深度共享代码
Web Components中(Shadow DOM)强(Shadow DOM)各自管理弱中跨框架组件级集成
iframe 沙箱 + DOM 代理(Wujie 类)强强(Shadow DOM)基座统一通过 props中既要隔离又要统一体验

2.4.3 iframe:隔离最强,体验代价最大

<!-- 子应用完全独立,父页面只能通过 postMessage 通信 -->
<iframe
  src="https://order.example.com/list"
  sandbox="allow-scripts allow-same-origin allow-forms"
  referrerpolicy="strict-origin-when-cross-origin"
  title="订单管理"
></iframe>
问题表现缓解
高度自适应内容变化后出现双滚动条子应用上报高度,父页面设置
路由不同步刷新后回到首页、前进后退失效父页面同步 history 与 postMessage
弹窗被裁剪子应用弹窗只在 iframe 内弹窗服务上提到父页面
登录态跨域 Cookie / Token 传递统一 SSO 或 postMessage 传 token
性能每个 iframe 一份运行时控制数量,避免嵌套
可访问性焦点在 iframe 间跳转混乱明确标题、焦点管理

结论:iframe 适合「这块东西我不想管」,不适合「我要和主站融为一体」。

2.4.4 运行时加载(qiankun 类):应用级隔离

基座(主应用)
 ├─ 路由表:/order/* → order 子应用
 ├─ 容器:<div id="subapp-container">
 ├─ 加载器:fetch HTML entry → 解析 script/style → 执行
 ├─ 沙箱:Proxy window + 快照恢复
 └─ 生命周期:bootstrap → mount → unmount

子应用只需要暴露三个生命周期:

// 子应用入口(以 qiankun 约定为例)
import { createRoot, type Root } from 'react-dom/client';
import App from './App';

let root: Root | null = null;

/**
 * 挂载:容器由基座传入,子应用不要自己找 document.body。
 * @param props 基座注入的属性(容器、全局状态、通信函数)
 */
export async function mount(props: { container: HTMLElement; globalState?: unknown }) {
  root = createRoot(props.container);
  root.render(<App basename="/order" />);
}

/** 卸载:必须清理 root、定时器、全局事件,否则切回时会内存泄漏 */
export async function unmount() {
  root?.unmount();
  root = null;
}
能力qiankun 的做法局限
JS 隔离Proxy 劫持 window,卸载时恢复快照eval、new Function、document.defaultView 可逃逸
CSS 隔离strictStyleIsolation(Shadow DOM)或 experimentalStyleIsolation(选择器加前缀)Shadow DOM 下弹窗、主题变量要额外处理
HTML Entry拉取子应用 HTML,解析并执行脚本依赖子应用可跨域访问,CSP 要放行
预加载prefetch 在空闲时预取子应用资源首次切换仍有白屏
通信initGlobalState + props共享状态过多会变成「分布式单体」

2.4.5 Module Federation:共享模块,而不是隔离应用

// 基座 webpack.config.js
new ModuleFederationPlugin({
  name: 'shell',
  remotes: {
    order: 'order@https://order.example.com/remoteEntry.js',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
});
// 子应用:暴露模块
new ModuleFederationPlugin({
  name: 'order',
  filename: 'remoteEntry.js',
  exposes: {
    './App': './src/App',
    './routes': './src/routes',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
  },
});
// 基座:运行时加载远程模块,必须用 lazy 形成异步边界
import { lazy, Suspense } from 'react';

const OrderApp = lazy(() => import('order/App'));

export function OrderRoute() {
  return (
    <Suspense fallback={<div>加载中…</div>}>
      <OrderApp />
    </Suspense>
  );
}
优点代价
共享依赖,包体小版本协商复杂,shared 配错会出现双实例
开发体验接近单仓需要构建期契约,子应用必须可独立构建
可按需加载远程模块运行时报错发生在浏览器,需额外的错误边界与降级
同栈团队协作风味最好技术栈必须统一,跨框架几乎不可行

单例冲突的典型症状:Invalid hook call、Context 读不到、useState 返回 null。排查方法:在控制台检查 window.__webpack_share_scopes__ 或打印 React.version,确认只有一个实例。

2.4.6 Web Components:组件级跨框架

/**
 * 用自定义元素封装一个跨框架组件。
 * 优点:浏览器原生隔离;缺点:props/事件不是 React 语义,需要桥接。
 */
class OrderBadge extends HTMLElement {
  static observedAttributes = ['count'];

  private shadow: ShadowRoot;

  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.render();
  }

  attributeChangedCallback() {
    this.render();
  }

  private render() {
    const count = Number(this.getAttribute('count') ?? 0);
    this.shadow.innerHTML = `
      <style>
        :host { display: inline-flex; }
        .badge { background: var(--color-primary, #1677ff); color: #fff; border-radius: 999px; padding: 2px 8px; }
      </style>
      <span class="badge" part="badge">${count}</span>
    `;
  }
}

customElements.define('order-badge', OrderBadge);
能力说明
样式隔离Shadow DOM 内样式不外泄,外部可用 ::part(badge) 定制
主题穿透CSS 自定义属性可以穿过 Shadow 边界,是主题方案的关键
事件需要 composed: true 才能穿透 Shadow 边界
表单需 ElementInternals 参与表单提交(详见 01 的 3.3)
SSR需要声明式 Shadow DOM 或客户端水合

2.4.7 iframe 沙箱 + DOM 代理(Wujie 类)

思路:JS 跑在 iframe 里获得原生隔离,DOM 渲染到主文档的 Shadow DOM 里获得样式隔离。

主应用
 ├─ Shadow DOM 容器 ← 子应用的真实 DOM 渲染在这里
 └─ 隐藏 iframe     ← 子应用的 JS 运行在这里(独立 window/document)
       ↕ Proxy:把 iframe 的 document 指向主文档的 shadowRoot
优点代价
JS 隔离接近 iframe(不怕 this 逃逸)实现复杂,依赖对 document 的代理
样式隔离强(Shadow DOM)弹窗、层级、position: fixed 仍有边界问题
体验比 iframe 好(无滚动条、无缝路由)调试链路长,出错定位困难

2.4.8 选型决策树

需要独立发布?
├─ 否 → 不做微前端(单仓多包 / 模块边界)
└─ 是
    ├─ 技术栈统一且要共享代码?
    │    ├─ 是 → Module Federation(同栈、共享依赖)
    │    └─ 否 → 运行时加载(qiankun 类)
    ├─ 只需要嵌入一个独立系统?
    │    └─ 是 → iframe(别自研)
    └─ 既要强隔离又要统一体验?
         └─ iframe 沙箱 + DOM 代理(Wujie 类)

2.5 微前端的六个隔离点

2.5.1 JS 隔离:沙箱到底隔离了什么

/**
 * 快照式沙箱:挂载前记录 window 变更,卸载时恢复。
 * 只能处理「赋值到 window 的属性」,无法处理闭包、原型链污染。
 */
function createSnapshotSandbox() {
  const snapshot = new Map<string, unknown>();

  return {
    /** 激活:记录当前 window 上的自定义属性 */
    activate() {
      for (const key of Object.keys(window)) {
        if (!(key in snapshot)) snapshot.set(key, (window as never)[key]);
      }
    },
    /** 失活:删除新增属性,恢复被修改的属性 */
    deactivate() {
      for (const key of Object.keys(window)) {
        if (!snapshot.has(key)) {
          delete (window as never)[key];
        }
      }
      snapshot.forEach((value, key) => {
        (window as never)[key] = value;
      });
    },
  };
}
隔离手段隔离强度逃逸路径
快照恢复弱闭包内引用、原型链修改、setTimeout 残留
Proxy 代理 window中this、(function(){return this})()、document.defaultView
iframe 原生隔离强通过 postMessage 显式通信
独立进程(跨站 iframe)最强无(但通信成本高)

结论:不要相信「JS 沙箱能完全隔离」。它能解决 90% 的全局变量污染,解决不了恶意代码与深层次 DOM 操作。

2.5.2 CSS 隔离:四种手段的真实差异

手段隔离强度主题/变量弹窗成本
命名前缀弱(靠约定)容易容易低
CSS Modules / 哈希中(构建期)容易容易低
Shadow DOM强变量可穿透,类名不穿透需渲染在 Shadow 内中
iframe最强需手动同步需上提高
/* 前缀方案:靠约定,必须配 lint 检查 */
.app-order .btn { /* ... */ }

/* Shadow DOM:外部只能通过 part 定制 */
/* :host { --button-bg: var(--color-primary); } */
/* ::part(button) { border-radius: 8px; } */

Shadow DOM 的三个坑:

  1. 弹窗挂到 document.body 会丢样式——应挂到 Shadow 内的容器,或用统一的弹层服务。
  2. position: fixed 相对视口,但如果祖先有 transform,包含块会变(详见 02 的 1.4)。
  3. 主题变量能穿透,普通类名不能——所以设计系统必须用 CSS 变量做主题。

2.5.3 路由隔离:谁拥有历史栈

方案 A:基座统一路由(推荐)
  /order/*   → 子应用 order
  /user/*    → 子应用 user
  子应用内部用 basename,不直接操作 window.history

方案 B:子应用自治路由
  子应用自己 pushState → 与基座的路由表冲突,前进后退易错乱

约定清单:

  • 子应用的所有路由必须带基座分配的前缀;
  • 子应用不得直接 window.location.href = ... 跳外部路由,应通过基座提供的导航 API;
  • 刷新页面时,基座要能根据 URL 恢复正确的子应用;
  • 404 与权限拦截由基座统一处理。

2.5.4 依赖隔离:单例与版本冲突

/**
 * 共享依赖的三条铁律:
 * 1) React / ReactDOM 必须单例,否则 Hook 与 Context 失效;
 * 2) 版本范围要写 requiredVersion,冲突时显式告警而不是静默降级;
 * 3) 工具库(lodash、dayjs)可以多实例,但会造成包体重复。
 */
依赖策略原因
react / react-dom单例,基座提供Hook、Context、事件系统依赖同一实例
设计系统单例主题上下文、样式顺序
状态库(redux/zustand)单例或按应用隔离跨应用共享状态时单例,否则隔离
工具库允许多实例无全局状态,代价只是体积
路由库跟随基座历史栈只能有一个主人

2.5.5 通信:三种方式与各自的风险

/** 1) props 注入:最可控,适合「基座 → 子应用」单向传递 */
mount({ container, user, navigate });

/** 2) 事件总线:适合松耦合,但事件名与载荷无类型约束,容易失控 */
const bus = new EventTarget();
bus.dispatchEvent(new CustomEvent('order:created', { detail: { id } }));

/** 3) 共享 store:最强耦合,只适合真正全局的状态(用户、主题、权限) */
方式耦合度适用风险
props低初始化配置、回调更新不及时,需配合订阅
事件总线中跨应用通知事件爆炸、命名冲突、无类型
共享 store高用户/主题/权限状态归属不清、调试困难

建议:默认 props + 少量事件;共享 store 只放「真的全局」的数据,并且在 ADR 里写清所有权。

2.5.6 隔离验收清单

  • 子应用卸载后,window 上不残留自定义属性与事件监听
  • 子应用样式不影响基座,基座样式不破坏子应用(含弹窗、Toast、下拉)
  • 路由前进/后退在两个应用间表现一致,刷新可恢复
  • 共享依赖只有一个实例(React 版本唯一)
  • 子应用独立构建、独立部署,基座无需重新发版
  • 子应用报错不影响基座(错误边界 + 降级)
  • 监控能区分应用维度(错误、性能、接口)

2.6 ADR:把架构决策写下来

2.6.1 模板

# ADR-0007:采用模块联邦而非 iframe 做微前端

## 状态
已接受(2026-03-01)| 替代 ADR-0003

## 背景
- 三个业务线需要独立发布,当前单仓每周只能发一次
- 技术栈统一为 React 18,共用一套设计系统
- 已评估:拆成三个独立站点会导致登录态与导航割裂

## 选项
A. 继续单仓(不做)
B. iframe 嵌入
C. 运行时加载(qiankun 类)
D. Module Federation

## 决策
选择 D。理由:同栈、需要共享设计系统与工具库;团队已有 webpack 5 经验。

## 后果
+ 共享依赖,包体可控;开发体验接近单仓
- 必须锁定 React 版本;构建期契约与版本协商需要专人维护
- 运行时报错在浏览器暴露,需要错误边界与远程加载降级

## 回退
保留单仓构建入口,出现严重问题时可按路由切回单体版本。

## 复盘时间
2026-06-01 评估发布频率、故障率与包体变化。

2.6.2 什么时候该写 ADR

该写不必写
引入/替换框架、构建工具改一个组件样式
微前端、Monorepo 等结构性决策日常依赖小版本升级
跨团队接口与契约内部函数重命名
有长期维护成本的方案可当天回退的小改动
被否决的重要提案(记录原因)重复的常规实现

2.6.3 维护方式

docs/adr/
├─ README.md          索引:编号、标题、状态、日期
├─ 0001-....md
├─ 0002-....md
└─ template.md
  • 状态只有四种:提议 / 已接受 / 已废弃 / 被替代;
  • 不修改已接受的 ADR 的结论,用新 ADR 替代并互相链接;
  • 每季度扫一次「已接受但未复盘」的条目。

2.6.4 常见坑

坑后果修复
只写结论不写选项无法判断是否考虑过更优解至少列 2 个选项含「不做」
不写回退方案出问题时只能硬扛明确止损点与回退步骤
ADR 写完就没人看新人重复踩坑放进 onboarding 与 PR 模板
用 ADR 追责没人敢写真实权衡只记决策与依据,不做绩效关联

2.7 L2 小结与自检

主题关键结论
边界业务域优先,三条判据:变化频率、所有权、复用范围
令牌原始 → 语义 → 组件三层,组件不碰原始值
组件库ESM + 类型 + CSS,peer 依赖,按需入口
微前端先问是否真需要,再按隔离强度选方案
隔离JS / CSS / 路由 / 依赖 / 状态,五个点都要验收
ADR记录选项、后果与回退

自检题

  1. 为什么组件不应直接使用原始色值?——无法换主题与品牌,语义不清晰;组件只消费语义/组件令牌,主题映射在语义层完成,换肤时只改一层。
  2. 什么情况下不该上微前端?——团队少、发布节奏一致、技术栈统一、共享状态多;此时隔离收益不抵通信、样式与版本治理成本,单仓多包更合适。
  3. 微前端里弹窗挂到 body 会有什么问题?——脱离子应用沙箱与样式作用域,卸载后残留,层级与事件可能冲突;应挂到子应用容器或统一弹层服务,Shadow DOM 场景尤其要注意。
  4. Module Federation 出现 Invalid hook call 通常是什么原因?——React 被打包了两份(shared 未设 singleton 或版本不匹配),导致 Hook 在另一个实例上执行;排查共享作用域与 React.version。
  5. 为什么 ADR 要写「被否决的选项」?——它记录了当时的约束与权衡,避免后人重复讨论,也能在条件变化时快速复用旧分析。

3. L3 高级:演进、治理与度量

3.1 绞杀者模式:不停业务换地基

3.1.1 五个阶段

阶段 1  旁路并行
  新需求只在新架构实现;旧架构只修 bug,不加功能
        │
阶段 2  路由级切换
  按路由/页面把流量切到新架构,比例 1% → 10% → 50%
        │
阶段 3  按流量迁移
  从「访问量低、依赖少」的模块开始,逐个搬到新栈
        │
阶段 4  双跑对比
  新旧同时可访问,对比错误率、性能、转化,异常即切回
        │
阶段 5  删除旧栈
  流量归零并观察一个发布周期后删除,移除开关与兼容层

3.1.2 路由级切换的实现

/**
 * 按百分比灰度:同一个用户始终命中同一侧,避免刷新后跳变。
 * @param key 稳定标识(用户 ID / 设备 ID)
 * @param percent 灰度比例 0-100
 */
export function inRollout(key: string, percent: number): boolean {
  // FNV-1a:简单稳定,避免引入依赖
  let hash = 0x811c9dc5;
  for (let i = 0; i < key.length; i += 1) {
    hash ^= key.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return Math.abs(hash) % 100 < percent;
}
/** 新旧页面共存:开关只决定渲染哪一侧,回退不需要发版 */
function OrderListRoute() {
  const { userId } = useUser();
  const { rolloutPercent } = useConfig();

  return inRollout(userId, rolloutPercent) ? <OrderListNext /> : <OrderListLegacy />;
}

3.1.3 双跑对比:用数据决定是否继续

维度对比方式判定
错误率新旧各自 JS 错误 / PV新架构不高于旧 + 0.1%
性能LCP p75、接口 P95不劣化 10% 以上
业务下单转化率、表单完成率无显著下降
稳定性白屏率、心跳中断率不高于旧架构
成本包体、请求数有明确收益或可接受

注意:双跑期间不要同时改业务逻辑,否则无法判断差异来自架构还是需求。

3.1.4 常见坑

坑后果修复
停业务重写周期长、需求变化、上线即过时绞杀者逐步替换
一次性全量迁移出问题无法定位与回退按路由/流量分批
开关没有清理半年后代码里 20 个 if迁移完成后立 ADR 记录清理时间
新旧共用可变全局状态双跑时互相污染状态按应用隔离,接口幂等
只对比性能不比业务体验变快但转化下降加业务护栏指标

3.2 设计系统的采纳率治理

3.2.1 五个指标的口径

指标定义采集健康值
组件覆盖率页面中来自设计系统的组件实例 / 全部组件实例AST 扫描> 80%
重复实现数自写 Button/Modal/Table 等高频组件的文件数AST 规则匹配持续下降
令牌使用率样式声明中使用 var(--*) 的比例CSS AST> 90%
版本集中度使用最新两个大版本的团队占比lockfile 分析> 90%
缺陷响应时长组件缺陷从提交到修复issue 系统< 3 天

3.2.2 采集:静态扫描比问卷可靠

/**
 * 简化版组件使用统计:扫描源码里的 JSX 标签与 import 来源。
 * 生产可用 @babel/parser + @babel/traverse 遍历 AST。
 * @param files 文件内容列表
 * @returns 设计系统组件与自写组件的计数
 */
export function countComponentUsage(files: string[]) {
  const result = { designSystem: 0, custom: 0 };
  const dsImport = /from ['"]@acme\/ui['"]/;

  for (const code of files) {
    if (!dsImport.test(code)) {
      // 没有引入设计系统,但出现了高频组件名,视为自写
      if (/<(Button|Modal|Table)\b/.test(code)) result.custom += 1;
      continue;
    }
    const matches = code.match(/<(Button|Input|Modal|Table|Select)\b/g);
    result.designSystem += matches?.length ?? 0;
  }

  return result;
}

3.2.3 治理手段的优先级

1. 默认正确:脚手架生成的页面已经用了设计系统
2. 低门槛:提供代码片段、迁移脚本、示例仓库
3. 有约束:lint 禁止硬编码色值/自写高频组件
4. 可发现:文档站能搜到、示例能复制
5. 有反馈:月度采纳率报告 + 问题响应 SLA

顺序很重要:先降低采纳成本,再加约束。反过来做,会得到「上有政策下有对策」——业务用 !important 覆盖你的组件,或者干脆绕过。

3.2.4 一个真实改进路径

阶段现状动作结果
第 1 月覆盖率 40%,3 套 Button统一 token,8 个高频组件新页面不再自写
第 3 月覆盖率 55%提供 codemod 迁移旧 Button存量下降 30%
第 6 月覆盖率 72%lint 拦截硬编码色值令牌使用率 92%
第 12 月覆盖率 85%采纳率月报 + owner 机制重复实现趋零

关键:每次只推进一步,每步都有可量化的收益,而不是一次性发一份 80 页规范。

3.2.5 常见坑

坑后果修复
只统计组件数量组件多但没人用统计覆盖率与重复实现
用行政命令推广表面执行、暗中绕过先修体验再加约束
没有 owner缺陷无人修,采纳率下降指定团队 + SLA
文档与代码不一致用户不再信任文档文档从代码生成,示例跑测试
版本不兼容升级业务被迫 forkSemVer + codemod + 弃用周期

3.3 组件 API 的演进策略

3.3.1 变更分类与版本

变更版本用户动作需要 codemod
新增可选属性minor无否
修复行为缺陷patch无否
修改默认值minor + 公告检查是否依赖旧默认否
重命名属性major改名是
删除属性major删除用法是
改变 DOM 结构minor(可能影响测试)检查选择器视情况
修改事件签名major改回调是

3.3.2 弃用要「可感知」

interface LegacyProps {
  /** @deprecated 使用 variant="danger" 替代,v3 将移除 */
  type?: 'danger' | 'primary';
  variant?: 'primary' | 'secondary' | 'danger';
}

/**
 * 兼容层:旧属性映射到新属性,并在开发环境告警。
 * 生产环境不告警,避免日志噪音。
 */
export function Button({ type, variant, ...rest }: LegacyProps) {
  if (process.env.NODE_ENV !== 'production' && type) {
    console.warn('[ui] Button 的 type 属性已废弃,请改用 variant');
  }
  const resolved = variant ?? (type === 'danger' ? 'danger' : 'primary');
  return <BaseButton variant={resolved} {...rest} />;
}

三个要点:

  1. 必须是「旧写法仍能工作」,只是告警;直接报错会让使用方不敢升级。
  2. 告警里写清替代方案与移除版本,否则用户不知道怎么改。
  3. 给一个明确的移除时间,否则兼容层会永久存在。

3.3.3 codemod:把迁移成本降到可接受

/**
 * jscodeshift 转换示例:把 <Button type="danger"> 改成 variant="danger"。
 * 用法:npx jscodeshift -t codemods/button-variant.ts src --extensions=tsx
 */
module.exports = function transform(fileInfo, api) {
  const j = api.jscodeshift;
  const root = j(fileInfo.source);

  root.findJSXElements('Button').forEach((path) => {
    j(path)
      .find(j.JSXAttribute, { name: { name: 'type' } })
      .forEach((attr) => {
        const value = attr.node.value;
        if (value?.type === 'StringLiteral' && value.value === 'danger') {
          attr.node.name.name = 'variant';
        }
      });
  });

  return root.toSource({ quote: 'single' });
};

codemod 的价值不在「省几分钟」,而在于让升级不再依赖每个人的记忆。100 个文件手改必然漏,脚本跑完可验证。

3.3.4 兼容层的设计边界

做法评价
旧属性 → 新属性映射 + 告警✅ 推荐
同时支持两套语义(type 与 variant 冲突时以谁为准)⚠️ 必须定优先级并写文档
永久保留兼容层❌ 代码腐化,新人无法判断该用哪个
用 Proxy 动态兜底所有旧属性❌ 类型丢失、性能差、无法静态分析

3.3.5 常见坑

坑后果修复
直接删属性并升 major使用方被迫停留在旧版先弃用一个大版本
弃用告警只在控制台没人看构建期 lint 也提示
codemod 不幂等重复执行改坏代码转换前判断是否已是新写法
兼容层无测试映射错误静默发生为旧属性写回归测试

3.4 多包版本与依赖治理

3.4.1 包的分层

packages/
├─ tokens/          设计令牌(无依赖)
├─ icons/           图标(无依赖)
├─ ui/              基础组件(依赖 tokens)
├─ charts/          图表(依赖 tokens + ui)
└─ utils/           工具函数(无依赖)
apps/
├─ admin/           依赖 ui + charts + utils
└─ portal/          依赖 ui + utils

规则与源码分层一致:

  1. 底层包不依赖上层包;
  2. 共享运行时依赖用 peerDependencies;
  3. 单仓用 workspace:*,发布时由发布工具替换为具体版本;
  4. 跨包引用只走包名,不写相对路径。
{
  "dependencies": {
    "@acme/tokens": "workspace:*"
  },
  "peerDependencies": {
    "react": ">=18"
  }
}

3.4.2 发布顺序与 changesets

# 1) 收集变更:开发者提交 changeset
pnpm changeset

# 2) 版本计算:工具会自动处理包之间的依赖顺序
pnpm changeset version

# 3) 发布:按拓扑序发布,先底层后上层
pnpm changeset publish
问题表现解法
发布顺序错误ui 依赖的 tokens 版本还不存在用 changesets 的固定/链接模式
内部依赖写成具体版本每次改动都要手动升级workspace:*
版本漂移不同应用用不同大版本定期跑锁文件分析,推动收敛
破坏性变更无感知升级后运行时才报错类型测试 + codemod + 预发布版本

3.4.3 版本漂移检测

# 列出各应用使用的 @acme/ui 版本分布
pnpm -r exec node -e "try{console.log(require('./package.json').dependencies)}catch(e){}" \
  | grep -o '"@acme/ui": "[^"]*"'

更可靠的做法是在 CI 里解析 lockfile,输出版本分布表,超过两个大版本就报警。

3.4.4 常见坑

坑后果修复
所有包都 private: false内部工具被误发布内部包 private: true
包之间循环依赖发布死锁、构建顺序无解抽公共包下沉
共享包带副作用使用方 tree-shaking 失效标注 sideEffects
每个应用各自升级版本碎片化统一升级窗口 + codemod

3.5 架构度量

3.5.1 五个维度

维度指标采集用途
耦合跨模块导入边数、环数dependency-cruiser JSON找该拆/该合的边界
重复相似代码块、重复组件名代码扫描判断是否该抽公共
复杂度单文件行数、依赖数、分支数lint 输出找重构热点
稳定性变更频率 × 缺陷数git log + 缺陷库识别高风险模块
交付构建时长、包体积、测试时长CI 指标判断工程效率

3.5.2 用数据找问题,而不是排名

# 导出依赖图数据,统计模块之间的边数
npx depcruise src --output-type json > deps.json

# 找出被依赖最多的模块(隐性核心)
node -e "
const d = require('./deps.json');
const count = new Map();
for (const m of d.modules) {
  for (const dep of m.dependencies) {
    const target = dep.resolved;
    count.set(target, (count.get(target) || 0) + 1);
  }
}
console.log([...count].sort((a, b) => b[1] - a[1]).slice(0, 10));
"

解读方式:

  • 被依赖最多的模块:如果它变化频繁,就是「架构瓶颈」;如果它很稳定,就是「合理的基础设施」。
  • 扇出最多的模块:依赖了十几个模块,通常是「上帝对象」,该拆。
  • 变更频率最高 + 缺陷最多:重构优先级最高。
  • 没有入边的模块:可能是死代码,也可能是入口。

3.5.3 度量的三个禁忌

禁忌后果替代
用指标排名奖惩数据被优化,问题被掩盖用于发现与分配资源
只统计不行动报表没人看每季度选 1–2 项改进
单一指标拆包刷「模块数」组合看:耦合 + 变更 + 缺陷

3.5.4 常见坑

坑后果修复
统计口径不一致不同团队数据不可比固定采集脚本与时间窗
只看静态指标不知道哪里真的痛叠加线上错误与变更频率
指标过多没人看得完每层最多 5 个

3.6 样式隔离的工程落地

3.6.1 从全局 CSS 迁移到作用域

第 1 步:统计全局选择器与冲突面(哪些类名被多个应用使用)
第 2 步:新代码强制 CSS Modules / 前缀,旧代码不动
第 3 步:按页面迁移,迁移时顺手删死样式
第 4 步:加 lint 禁止新增全局选择器
/* 迁移前:全局,容易冲突 */
.btn { padding: 8px 16px; }

/* 迁移后:作用域内,构建期加哈希 */
/* button.module.css */
.btn { padding: 8px 16px; }

3.6.2 用 @layer 管住优先级

/* 顺序即优先级:后面的层覆盖前面的层,与选择器权重无关 */
@layer reset, base, components, utilities;

@layer base {
  button { font: inherit; }
}

@layer components {
  .btn { padding: 8px 16px; border-radius: 6px; }
}

@layer utilities {
  .mt-4 { margin-top: 16px; }
}

价值:业务方要覆盖组件样式时,不需要靠 !important 或更长的选择器,只要写在更靠后的层。这是比 z-index 更根本的秩序问题——把「谁赢」从权重博弈变成声明式约定。

3.6.3 Shadow DOM 落地的四个决定

决定选项影响
是否开启open / closedclosed 外部完全拿不到 shadowRoot,调试困难
样式注入<style> / adoptedStyleSheets后者可复用同一份样式表,性能更好
主题CSS 变量 / ::part变量穿透最方便,::part 只能改指定部分
弹层Shadow 内 / 统一弹层服务Shadow 内需要自己管理层级与滚动锁

3.6.4 常见坑

坑后果修复
全局 reset 在 Shadow 内不生效组件样式与主站不一致在组件内显式声明基础样式
用 !important 覆盖优先级战争用 @layer 与语义 token
动态插入 <style>重复注入、内存增长用 adoptedStyleSheets 或构建期产物
作用域靠人工前缀漏写就污染构建期自动加前缀

3.7 架构守护:让约束在你不参与时也生效

3.7.1 三层守护

静态层:ESLint(导入规则、禁用 API)、dependency-cruiser(依赖方向)、
        stylelint(硬编码色值)、TypeScript(类型契约)
        ↓ 提交前 + CI
测试层:契约测试(接口 schema)、视觉回归(组件外观)、
        架构测试(模块可独立构建)
        ↓ PR
运行时层:错误监控(按应用/模块分桶)、性能指标、白名单告警
        ↓ 线上

3.7.2 契约测试:跨团队接口的护栏

import { z } from 'zod';

/** 前后端共享的响应契约:字段变更会同时让两边失败 */
export const OrderSchema = z.object({
  id: z.string(),
  total: z.number().int().nonnegative(),
  status: z.enum(['pending', 'paid', 'shipped', 'closed']),
});

export type OrderContract = z.infer<typeof OrderSchema>;

/**
 * 运行时校验:开发与预发环境开启,生产环境只上报不抛错。
 * @param data 接口返回
 */
export function parseOrder(data: unknown): OrderContract {
  const result = OrderSchema.safeParse(data);
  if (!result.success) {
    reportContractError(result.error.issues);
    if (process.env.NODE_ENV !== 'production') throw result.error;
  }
  return result.data as OrderContract;
}

3.7.3 架构测试:模块能否独立存在

/**
 * 架构测试:删除模块后其余代码仍可构建,说明没有隐式依赖。
 * 实现方式:CI 里临时移动目录 + 跑类型检查,或在依赖图上断言「无入边」。
 */
test('shared 不依赖任何业务模块', async () => {
  const graph = await buildDependencyGraph('src');
  const violations = graph
    .filter((edge) => edge.from.startsWith('src/shared'))
    .filter((edge) => /^src\/(features|views|app)/.test(edge.to));

  expect(violations).toEqual([]);
});

3.7.4 常见坑

坑后果修复
规则只在本地有人跳过 husky 就绕过了CI 必须再跑一次
规则太严业务频繁 eslint-disable分级:error 阻断、warn 提示
契约测试只在后端前端字段变更无人知双端共享 schema
架构测试无基线一上线几百条违规先冻结存量(baseline),只拦新增

3.8 L3 小结与自检

主题关键结论
演进绞杀者五阶段,按路由/流量切换,双跑对比
采纳率覆盖率 + 重复实现 + 令牌使用率,先降门槛再加约束
API 演进SemVer + 弃用告警 + codemod + 明确移除时间
多包分层依赖、peer 共享、changesets 按序发布
度量五维指标,找问题不排名
守护静态 + 测试 + 运行时三层,存量冻结只拦新增

自检题

  1. 为什么不能「停业务重写」?——需求持续变化,重写周期长且风险集中,上线时可能已经过时;绞杀者模式按路由/流量逐步替换,每步可回退,双跑还能用数据验证。
  2. 设计系统采纳率低,通常是什么原因?——API 不好用、文档缺失、与设计稿不一致、迁移成本高、没有 owner;先修体验与工具,再加 lint 约束。
  3. Shadow DOM 下弹窗为什么要特殊处理?——样式与事件被隔离,挂到 document.body 会丢样式且卸载残留;应渲染在 Shadow 内部或用统一弹层服务,必要时用 ::part 暴露定制点。
  4. 架构度量为什么不能用来排名?——指标会被优化而非问题被解决(例如拆包刷模块数、写无断言测试刷覆盖率);应用于发现瓶颈与分配资源。
  5. 契约测试放在哪一层最有效?——放在团队边界(前后端、主应用与子应用、共享库与使用方),用共享 schema 校验;放在内部函数上收益低、维护成本高。

4. L4 专家:技术战略与组织

4.1 架构与组织(康威定律)

系统结构会复制组织的沟通结构。

这不是一句口号,而是可以直接观察到的现象:

组织现象系统表现
两个团队共用一个模块模块里出现两套风格、两套状态管理
前端与后端分属两个部门接口频繁变更、DTO 层层包装
平台团队不接触业务平台功能完备但没人用
每个业务线独立汇报三套组件库、三套构建

逆康威(Inverse Conway):先设计想要的系统边界,再调整团队边界去匹配它。例如希望「订单」与「支付」解耦,就先让它们有各自的 owner、发布流水线与 oncall,而不是先画架构图再要求团队配合。

落地的三件事:

  1. 每个模块有唯一 owner:谁负责、谁审批变更、谁 oncall,写进 CODEOWNERS。
  2. 跨团队接口有契约:schema + 评审 + 变更流程,而不是「喊一声就改」。
  3. 共享资产有归属团队:设计系统、构建平台、工具库必须有固定投入,否则一定腐化。
# .github/CODEOWNERS:把所有权写进流程
/packages/ui/          @design-system-team
/packages/build-config/ @infra-team
/apps/admin/           @admin-fe
/apps/portal/          @portal-fe

4.2 技术规划怎么写

4.2.1 模板

## 1. 现状(数据)
- 发布频率 2 次/月,构建 12 分钟,线上故障 3 次/月
- 5 个团队共用单仓,日均冲突 12 次
- 3 套组件库,视觉走查每月 20 个问题

## 2. 问题(影响)
- 等待占交付周期 60%,需求平均 9 天上线
- 故障恢复平均 3 小时,值班压力大
- 视觉不一致导致品牌投诉与返工

## 3. 目标(可量化)
- 发布按需(每周 ≥ 5 次),构建 < 3 分钟
- MTTR < 30 分钟,变更失败率 < 10%
- 组件覆盖率 > 80%,走查问题下降 70%

## 4. 路径(分阶段)
Q1 流水线:CI 门禁、增量构建、缓存
Q2 灰度与回滚:开关、灰度平台、一键回滚
Q3 资产收敛:token + 高频组件 + codemod
Q4 架构演进:按业务域拆包,独立发布试点

## 5. 投入
2 人 × 2 季度 + 1 名设计 + 少量基础设施成本

## 6. 风险与回退
- 迁移期双栈并行,成本上升 15%
- 每阶段有独立回退点,可停在任一阶段
- 指标连续两周恶化即暂停并复盘

4.2.2 三个常见错误

错误表现修正
只有目标没有基线「提升研发效率」先测基线,再定目标值
只有方案没有取舍「全都做」明确不做什么(非目标)
没有回退一停就全停每阶段设计止损点

4.2.3 用「成本语言」讲技术方案

❌ 「升级构建工具,Rspack 比 Webpack 快 5 倍」
✅ 「构建 12 分钟,30 人每天等约 40 人时;升级后 3 分钟,
    一年回收约 200 人天,投入 2 人 × 3 周,净收益约 180 人天」

换算公式:

年浪费人天 = 单次等待时长 × 每日次数 × 人数 × 工作日
回收周期  = 投入人天 ÷ 每日节省人天

4.3 推动跨团队落地

4.3.1 五个阶段

阶段动作成功标志
立项用数据讲清成本,找共同痛点拿到预算与 sponsor
试点选愿意配合、痛点最深的团队有可复制的样板与数据
降门槛提供脚手架、codemod、迁移文档采纳成本低于自建成本
固化写进 CI、模板、规范新人默认走新路径
运营看板、答疑、定期升级采纳率持续上升,无回退

4.3.2 四类真实阻力与应对

阻力真实诉求应对
「我们业务特殊」怕改坏线上给逃生舱(显式豁免 + 登记),用数据说明特殊场景占比
「没时间迁移」排期被业务占满提供自动化迁移,把成本压到 1–2 天
「新方案不稳定」怕背故障灰度 + 回滚 + 试点数据 + 值班支持
「凭什么听你的」缺少授权找 sponsor、用试点收益说话、共建委员会

关键原则:不要用「大厂都这么做」说服人,要用「你的哪个痛点被解决了、代价是多少」。

4.3.3 度量推广效果

指标含义目标
采纳率新项目使用比例逐季上升
主动推荐率用户愿意推荐给别的团队> 50%
迁移耗时单个项目接入时间逐季下降
回退率接入后又退回的比例< 5%
支持负担每周答疑工单数逐季下降

「主动推荐率」是最真实的指标:被动遵守只能说明有行政压力,主动推荐才说明方案真的好用。

4.4 成本与收益

4.4.1 TCO(总拥有成本)视角

投入项一次性成本持续成本收益
统一组件库2 人 × 1 季度1 人维护 + 文档重复开发减少、视觉一致
微前端基础设施 2 人 × 1 季度版本协调、契约维护独立发布、故障隔离
自研图表引擎4 人 × 2 季度2 人长期差异化体验、性能可控
设计令牌体系1 人 × 1 月随设计稿更新换肤/多品牌、走查问题下降
构建平台2 人 × 1 季度1 人 oncall构建时长下降、缓存复用

判断口诀:持续成本 > 收益的 30%,就要重新考虑。很多平台项目死在「上线时很惊艳,维护时没人」。

4.4.2 决策矩阵

方案一次性成本持续成本收益可逆性结论
A 继续现状0冲突与返工0—不解决
B 单仓多包中低构建与冲突改善高推荐
C 微前端高中高独立发布中多团队才做
D 拆独立站点中中发布独立低体验割裂,慎用

4.5 长期演进判断

四个问题决定一个架构方向是否值得长期投入:

问题判断依据
是否解决长期痛点痛点是结构性的(组织、规模)还是一次性的
是否有复利用的人越多收益越大(平台、规范),还是线性投入
团队能否持续维护有没有 owner、有没有招聘与培养计划
是否与业务战略一致业务要全球化/多端/快速试错,架构能否支撑

可逆性优先:可逆的决策快速做(换一个 lint 规则),不可逆的决策慢一点、多听意见(换框架、上微前端、自研引擎)。

4.6 架构治理机制

机制作用频率
架构评审高风险变更前置评审按需(RFC)
技术雷达统一「采用/试验/评估/暂缓」口径每半年
标准分级强制(安全)/ 推荐(规范)/ 参考(实践)持续
owner 制度每个资产有负责人与 SLA持续
架构度量季度看耦合、重复、交付指标每季度
复盘故障与迁移问题闭环每次事件

标准分级很重要:把所有规范都设成「强制」,结果是一条都执行不了。强制项应该只保留「安全、合规、会导致故障」的少数几条。

4.7 L4 自检

  1. 公司要「统一前端技术栈」,你如何制定路线?——先测基线(构建、发布、缺陷、重复实现),选 2–3 个杠杆;新项目强制、存量渐进;先统一构建与规范,再迁框架,每阶段有回退点与量化目标。
  2. 三个业务线坚持用自己的组件库,怎么收敛?——先统一 token 与交互规范(最小共识),再做适配层与合并计划;用复用率、维护成本、走查问题数据推动,而不是行政命令;给业务保留扩展点。
  3. 如何判断一个架构决策是「技术最优」还是「组织最优」?——看是否匹配团队能力、协作边界与长期维护成本;技术最优但无人维护,就是组织上的次优。
  4. 平台项目上线后使用率低,先查什么?——先查「采纳成本」与「是否解决真实痛点」,再查文档与推广;多数失败是「功能完备但接入要改 20 个文件」。
  5. 如何为架构演进争取资源?——把技术问题翻译成业务语言(交付周期、故障成本、人力浪费),给出基线与目标,明确投入产出与止损点。

5. 综合实战:单体前端 → 多团队并行

背景:一个 40 万行单体仓库,5 个团队,发布互相阻塞,构建 15 分钟,3 套 Button。

5.1 第一步:量化现状(没有数据不动手)

指标现状采集方式
构建时长15min(全量)/ 4min(增量)CI 日志
日均合并冲突12 次git 统计
发布频率1 次/周,需全量回归发布记录
回归耗时6 人 × 4 小时测试记录
组件重复3 套 Button、2 套 TableAST 扫描
变更影响面平均每个需求改 7 个目录git diff 统计

5.2 第二步:定目标与路径

阶段动作目标回退点
1(4 周)pnpm workspace + 统一构建 + 缓存构建 < 5min保留旧构建入口
2(6 周)抽 shared/{tokens,ui,utils} + 依赖规则重复实现下降 50%组件仍可从旧路径导入
3(6 周)按业务域拆包 + 门面 + CODEOWNERS冲突下降 60%单包聚合仍可构建
4(8 周)独立发布(模块联邦 / 独立应用)试点1 个团队按需发布路由开关切回单体

5.3 第三步:关键实现

// 根 package.json:workspace + 统一脚本
{
  "private": true,
  "scripts": {
    "build": "pnpm -r --filter './packages/*' build && pnpm -r --filter './apps/*' build",
    "lint": "eslint . && pnpm depcruise",
    "test": "vitest run"
  }
}
# CI:只构建受影响的包,未命中缓存才全量
- name: Build affected
  run: pnpm turbo run build --filter=...[origin/main]
// 依赖方向用规则固化,防止架构回退
module.exports = {
  forbidden: [
    { name: 'shared-is-leaf', from: { path: '^packages/shared' }, to: { path: '^(apps|packages/(ui|charts))' } },
    { name: 'no-circular', from: {}, to: { circular: true } },
  ],
};

5.4 第四步:迁移节奏

第 1 周:只加规则,不拦存量(baseline 冻结)
第 2–3 周:新代码必须合规,老代码按触碰即改
第 4 周起:存量按「变更频率」排序,优先迁移高频模块

为什么按变更频率排:一个半年没人改的模块,迁移它只有风险没有收益;高频变更的模块,每迁一个都能立刻减少冲突。

5.5 第五步:验收清单

  • 构建 < 5min,增量 < 60s,缓存命中率 > 70%
  • 跨模块非法导入为 0(CI 拦截)
  • 组件覆盖率 > 80%,硬编码色值 < 10%
  • 任一团队可独立发布,且能一键回滚
  • 每个模块有 CODEOWNERS 与 oncall
  • 架构决策有 ADR,迁移开关有清理计划
  • 发布后错误率、LCP p75 不劣化

5.6 预期风险与应对

风险表现应对
迁移期双栈成本同一需求要改两处只在新栈加功能,旧栈只修 bug
团队不适应新规则频繁 eslint-disable提供自动修复与答疑,规则分级
共享包变成新瓶颈所有团队排队等 ui 发版组件级独立入口 + 快速发布通道
独立发布后故障增加变更失败率上升灰度 + 开关 + 一键回滚,先小流量

6. 常见误区对照表

误区事实
「架构就是目录结构」目录只是表象,核心是依赖方向、边界与所有权
「微前端能解决协作问题」它解决独立发布与隔离,协作问题还要靠契约、流程与 owner
「iframe 隔离最好所以最合适」弹窗、路由、通信、性能成本高,体验难统一
「组件库等于设计系统」还缺令牌、模式、文档与治理机制
「组件越多越完整」维护成本随数量上升,重复实现比缺失更常见
「一次重写最彻底」风险极高、周期长,绞杀者模式更稳
「架构定完就不用管」约束会腐化,需要 lint、CI、度量持续守护
「共享依赖越多越好」版本冲突会拖慢所有团队,共享要按「是否单例」分类
「设计系统是设计团队的事」工程实现、文档与治理同样关键,必须双 owner
「指标用来排名」会被优化而非问题被解决,应用于发现瓶颈与分配资源
「技术最优就是好架构」还要考虑组织、成本、可维护性与可逆性
「分层越细越专业」过度分层增加跳转成本,应按变化频率与复杂度决定
「沙箱能完全隔离 JS」存在 this、document.defaultView、iframe 等逃逸路径
「共享状态越多越方便」跨应用共享可变状态会变成「分布式单体」,难调试难回滚
「Token 只是颜色变量」还包含间距、字体、圆角、阴影、动效;且必须分三层
「升级就是改版本号」破坏性变更需要弃用周期、codemod 与迁移文档
「架构决策不需要记录」没有 ADR,后人会重复讨论并再次踩坑
「灰度就是发版后观察」灰度要有分批、指标阈值、自动止损与一键回滚

7. 面试高频题(附答题思路)

Q1:如何设计一个中大型前端项目的目录结构? 分层(app/views/features/shared)→ 单向依赖 → 门面导出 → 用 lint/depcruise 固化 → 用「改一个需求要动几个目录」验证。

Q2:微前端的方案与隔离? 五种方案(iframe / 运行时 / MF / Web Components / iframe 沙箱代理)→ JS、CSS、路由、依赖、状态五个隔离点 → 说清代价与适用,最后给判断条件。

Q3:设计系统包含什么? 令牌三层 → 基础组件 → 模式 → 模板 + 文档 + 工具 + 治理(owner、版本、采纳率)。

Q4:如何做组件库版本管理? SemVer + 弃用周期 + codemod + changesets + peerDependencies + 独立入口 + 体积门禁。

Q5:如何安全地重构旧架构? 绞杀者五阶段:旁路并行 → 路由级切换 → 按流量迁移 → 双跑对比 → 删除旧栈;强调每步可回退。

Q6:如何推动多团队统一? 数据讲成本 → 试点样板 → 工具降低采纳成本 → 写进 CI → 运营看板;再说阻力类型与应对。

Q7:架构如何度量? 耦合(跨模块边、环)、重复、复杂度、稳定性(变更 × 缺陷)、交付(构建/包体);用于发现问题而非排名。

Q8:康威定律在前端的应用? 团队边界影响系统边界;每个模块有 owner;跨团队接口契约化;共享资产有归属团队与 SLA。

Q9:设计令牌为什么要分层? 原始值(是什么)→ 语义值(用在哪)→ 组件值(谁在用);组件只消费后两层,主题切换只改语义层。

Q10:如何判断一个架构方案值不值得做? 看痛点是否结构性、是否有复利、持续维护成本、可逆性;用基线与目标量化,给出止损点。


8. 面试深挖

8.1 面试官在考察什么

维度看什么加分回答减分回答
分层能力边界是否可执行用依赖规则与工具固化「分三层就够了」
微前端隔离的真实成本说清 JS/CSS/路由/依赖/状态五类隔离「iframe 最省事」
设计系统从 token 到采纳三层 token + 版本策略 + 采纳率「做个组件库」
演进能否不停业务替换绞杀者 + 双跑 + 灰度 + 回滚「重构一遍」
决策是否有记录与度量ADR + 采纳率 + 成本收益凭感觉选型
组织能否推动落地试点 → 工具 → 门禁 → 运营「发个通知要求执行」
数据是否会量化基线、目标、置信区间、分桶只讲「感觉快了很多」

8.2 追问链

链 1 · 分层 → 依赖倒置 → 边界固化

  • 面试官:前端分层怎么划?
    • 要点:按「变化原因」划,而不是按文件类型。常见四层:视图(页面/组件)、应用(用例编排)、领域(业务规则与模型)、基础设施(请求、存储、埋点)。依赖只能向内,跨层引用要有唯一入口。
  • 追问:怎么防止下层反向依赖上层?
    • 要点:接口定义在领域层、实现在基础设施层,用依赖注入或「底层定义接口 + 上层注入」装配;用 ESLint no-restricted-imports、dependency-cruiser 或 eslint-plugin-boundaries 在 CI 拦截。
  • 再追问:分层会不会导致「为了一个字段写五个文件」?
    • 要点:会,所以要按复杂度分层。CRUD 页面两层足够;规则复杂、生命周期长、多人协作的模块才值得完整分层。分层的目标是隔离变化,不是统一模板。

链 2 · 微前端 → 五类隔离 → 沙箱边界

  • 面试官:微前端要解决什么问题?
    • 要点:多团队独立开发、独立发布、技术栈渐进迁移、故障隔离。团队小、发布节奏一致时,微前端是额外复杂度。
  • 追问:qiankun 的沙箱隔离了什么?
    • 要点:JS 用 Proxy 劫持 window + 快照恢复,CSS 用 scoped 前缀或 Shadow DOM,路由由基座统一,状态通常不隔离。局限:eval/new Function 逃逸、直接操作 document、样式注入到 head 造成全局污染。
  • 再追问:Module Federation 与 qiankun 的取舍?
    • 要点:MF 在构建期声明共享模块、运行时按需加载远程模块,适合同栈团队深度共享,但版本协商与双实例问题复杂;qiankun 偏「应用级」隔离,接入简单但通信、样式、依赖都要自己治理。选择看要「共享代码」还是「隔离应用」。

链 3 · 设计系统 → 三层令牌 → 采纳与演进

  • 面试官:设计令牌为什么要分三层?
    • 要点:原始层(色板、字号、间距的真实值)→ 语义层(color-text-primary、space-md)→ 组件层(button-bg)。组件只消费后两层,换主题或品牌时只改映射,不动组件。
  • 追问:组件 API 怎么演进才不破坏使用方?
    • 要点:新增可选属性优先;废弃用 @deprecated + 运行时告警 + codemod;大版本再删除。避免布尔属性堆叠,改用变体与组合子组件;DOM 结构变更要当作潜在破坏性变更。
  • 再追问:怎么证明设计系统有价值?
    • 要点:采纳率(使用组件的页面占比)、重复实现数、设计走查问题数、交付周期、包体积;只统计「组件数量」没有意义。

链 4 · 演进策略 → 双跑 → 回滚设计

  • 面试官:怎么把一个老后台迁到新架构?
    • 要点:先量化(构建、冲突、回归耗时)→ 旁路并行(新需求只在新栈)→ 路由级灰度(按用户稳定分桶)→ 按变更频率迁移 → 双跑对比 → 删除旧栈。
  • 追问:灰度比例怎么定?出问题怎么止损?
    • 要点:按流量与置信度定,先内部/白名单,再 1%→10%→50%;设护栏指标(错误率、LCP p75、转化率)与阈值,超阈值自动回滚;开关要能按用户/租户维度切。
  • 再追问:双跑期间怎么保证数据一致?
    • 要点:接口幂等、写操作只走一侧、读操作可双读对比;共享状态按应用隔离,避免两个应用同时改同一份本地缓存。

链 5 · 依赖治理 → 单例 → 版本协商

  • 面试官:微前端里为什么会出现 Invalid hook call?
    • 要点:React 被打包了两份,Hook 在另一个实例上执行,Dispatcher 为空;常见原因是 shared 未设 singleton、版本范围不匹配,或子应用把 React 打进了 bundle。
  • 追问:怎么保证只有一个 React 实例?
    • 要点:shared: { react: { singleton: true, requiredVersion: '^18' } },子应用用 externals 或 MF 共享;基座提供 react/react-dom,子应用只声明 peer;CI 里检查产物中 React 的副本数。
  • 再追问:共享依赖升级怎么协调?
    • 要点:锁定主版本、建立升级窗口、先基座后子应用、预发布版本验证;对不兼容的大版本用「多实例 + 隔离」而不是强行统一。

8.3 手写题

题 1 · 简易依赖注入容器(含循环依赖检测)

/**
 * 极简 DI 容器:支持单例/瞬时,检测循环依赖。
 */
function createContainer() {
  /** @type {Map<string, { factory: Function, singleton: boolean }>} */
  const registry = new Map();
  /** @type {Map<string, any>} */
  const instances = new Map();
  const resolving = new Set();

  /**
   * 注册工厂。
   * @param {string} name 标识
   * @param {(c: any) => any} factory 工厂函数
   * @param {{ singleton?: boolean }} [options] 配置
   */
  const register = (name, factory, { singleton = true } = {}) => {
    registry.set(name, { factory, singleton });
  };

  /**
   * 解析实例。
   * @param {string} name 标识
   */
  const resolve = (name) => {
    if (instances.has(name)) return instances.get(name);
    const entry = registry.get(name);
    if (!entry) throw new Error(`未注册: ${name}`);
    if (resolving.has(name)) throw new Error(`循环依赖: ${[...resolving, name].join(' -> ')}`);

    resolving.add(name);
    try {
      const instance = entry.factory(api);
      if (entry.singleton) instances.set(name, instance);
      return instance;
    } finally {
      resolving.delete(name);
    }
  };

  const api = { register, resolve };
  return api;
}

追问:

  • 循环依赖为什么必须显式报错?→ 否则会无限递归或拿到半初始化对象,问题在很远的地方才暴露。
  • 单例在 SSR 里有什么风险?→ 模块级单例被多个请求共享,会造成用户数据串号;应每请求创建容器。
  • 什么时候不该用容器?→ 依赖关系简单、以纯函数为主时,手动传参更清晰。

题 2 · Proxy + with 沙箱与逃逸分析

/**
 * 简易沙箱:拦截全局读写。
 * 注意:`with` 在严格模式下不可用,且存在逃逸路径,仅用于理解原理。
 * @param {Record<string, any>} [globals]
 */
function createSandbox(globals = {}) {
  const fakeWindow = Object.create(null);
  const rawWindow = window;

  const proxy = new Proxy(fakeWindow, {
    get(target, key) {
      if (key in target) return target[key];
      if (key in globals) return globals[key];
      const value = rawWindow[key];
      // 绑定 this,避免调用时指向 Proxy 导致非法调用
      return typeof value === 'function' ? value.bind(rawWindow) : value;
    },
    set(target, key, value) {
      target[key] = value;
      return true;
    },
    has(target, key) {
      return key in target || key in globals || key in rawWindow;
    },
  });

  /**
   * 在沙箱中执行代码。
   * @param {string} code 待执行代码
   */
  const run = (code) => {
    const fn = new Function('window', 'self', 'globalThis', `with(window){${code}}`);
    return fn(proxy, proxy, proxy);
  };

  return { proxy, run };
}

追问:

  • 这个沙箱能被逃逸吗?→ 能。this、(function(){return this})()、document.defaultView、iframe.contentWindow 都能拿到真实全局。
  • 生产级怎么做?→ iframe 原生隔离、ShadowRealm(尚未普及)或 qiankun 的快照 + Proxy 组合,并对 document 做代理。
  • 为什么 with 有性能问题?→ 作用域链在运行时动态解析,阻止 JIT 优化,执行明显变慢。

题 3 · 组件样式隔离的最小实现(Shadow DOM + 主题变量)

/**
 * 把一段 HTML 渲染进 Shadow DOM,并注入样式。
 * 主题通过 CSS 自定义属性穿透边界。
 * @param host 宿主元素
 * @param html 内容
 * @param css 样式
 */
export function mountShadow(host: HTMLElement, html: string, css: string) {
  const shadow = host.shadowRoot ?? host.attachShadow({ mode: 'open' });

  // adoptedStyleSheets 可复用同一份样式表,避免重复解析
  if ('adoptedStyleSheets' in Document.prototype) {
    const sheet = new CSSStyleSheet();
    sheet.replaceSync(css);
    shadow.adoptedStyleSheets = [sheet];
  } else {
    const style = document.createElement('style');
    style.textContent = css;
    shadow.appendChild(style);
  }

  const slot = document.createElement('div');
  slot.innerHTML = html;
  shadow.appendChild(slot);

  return {
    /** 卸载:清空内容,避免残留 DOM 与监听 */
    unmount() {
      shadow.innerHTML = '';
    },
  };
}

追问:

  • 为什么主题能穿透而类名不能?→ CSS 自定义属性是可继承属性,会沿 DOM 树(含 Shadow 边界)继承;类名选择器不跨越 Shadow 边界。
  • 弹窗怎么办?→ 渲染在 Shadow 内部,或用统一的弹层服务并在宿主上同步层级与滚动锁。
  • closed 模式有什么代价?→ 外部无法访问 shadowRoot,测试与调试困难,通常选 open。

8.4 场景设计题

题 1 · 5 年历史的单体后台,3 个团队共同维护,发布互相阻塞。如何拆成可独立发布的架构,同时不停止业务?

评估点:拆分顺序、隔离方案、灰度与回滚、组织配套。

参考方案:

  1. 先量后拆:统计模块间依赖、发布冲突频率、构建时长,选「变更最频繁、依赖最少」的模块做第一个试点。
  2. 立契约:主应用提供路由、鉴权、布局、通信总线;子应用按约定注册,禁止直接读写主应用状态。
  3. 隔离:路由按前缀划分;样式用 scoped + 命名空间或 Shadow DOM;JS 用沙箱;共享依赖走 externals/MF 并锁版本。
  4. 双跑:新旧路径并存,按用户/租户灰度;埋点对比错误率与性能,异常一键切回。
  5. 治理:每个子应用有 owner 与发布流水线;契约变更走 RFC;定期清理不再使用的子应用与开关。

加分:指出微前端不是目的、独立发布才是;指出共享组件库要先统一;指出要防止「分布式单体」;给出量化的验收指标。

减分:直接上 iframe 当终态;不做契约直接共享全局变量;一次性全量迁移;没有回滚方案。

题 2 · 设计系统上线一年,采纳率只有 45%,业务宁愿自己写组件。你如何诊断并提升?

评估点:是否先找根因、是否用数据、是否能推动组织。

参考方案:

  1. 诊断:抽样 10 个自写组件,记录原因(API 不合用、样式难覆盖、文档缺失、与设计稿不一致、版本老旧);统计各原因的占比。
  2. 修体验:优先补「高频且被自写」的组件;开放样式覆盖点(token + ::part + className 透传);补齐「何时用/不用」的文档与可运行示例。
  3. 降成本:提供 codemod 迁移旧组件;脚手架默认使用设计系统;组件需求统一入口,48 小时内响应。
  4. 加约束:lint 禁止自写高频组件与硬编码色值,但允许显式豁免并登记原因。
  5. 看效果:月度采纳率报告(覆盖率、重复实现数、令牌使用率),按团队可见,不排名。

加分:指出「先降门槛再加约束」的顺序;指出采纳率不是越多越好,过度统一会伤害特殊场景;给出豁免机制。

减分:直接发规范要求使用;把采纳率做成 KPI;只加 lint 不修 API。

8.5 最容易答错的点

常见说法纠正
「微前端能解决团队协作问题」它解决发布与隔离,协作问题还要靠契约、流程与 owner
「iframe 隔离最好所以最合适」弹窗、路由、通信、性能成本高,体验难统一
「沙箱能完全隔离 JS」存在 this、document.defaultView、iframe 等逃逸路径
「设计系统就是组件库」还包括 token、模式、文档、治理与采纳机制
「组件越多说明设计系统越成熟」重复与不一致减少、采纳率提升才是价值
「分层越细越专业」过度分层增加跳转成本,应按变化频率与复杂度决定
「共享依赖版本可以随便升」运行时单例冲突会导致难以定位的 bug(如 Invalid hook call)
「架构决策不需要记录」没有 ADR,后人会重复讨论并再次踩坑
「灰度就是发版后看一眼」灰度要有分批、护栏指标、自动止损与一键回滚
「Token 就是一组颜色变量」还包含间距、字体、圆角、阴影、动效,且必须分三层
「组件库升级只是改版本号」破坏性变更需要弃用周期、codemod 与迁移文档
「架构度量可以用来考核团队」指标会被博弈,应用于发现瓶颈与分配资源

9. 延伸资源

  • 《Software Architecture: The Hard Parts》(架构权衡与取舍)
  • 《Building Evolutionary Architectures》(演进式架构与适应度函数)
  • 《Team Topologies》(团队拓扑与平台团队)
  • 《Design Systems》(Alla Kholmatova,设计系统的组织方式)
  • ADR 规范与案例集:adr.github.io
  • Storybook 文档与 a11y 插件、Chromatic 视觉回归
  • Style Dictionary / Tokens Studio:令牌从设计稿到多端产物
  • Module Federation 官方文档;qiankun、Wujie 文档与源码
  • dependency-cruiser、eslint-plugin-boundaries:把边界变成可执行规则
  • 各大厂设计系统公开文档:Ant Design、Material 3、Carbon、Primer