17 · 架构与设计系统(详解版)
一句话定位:架构决定「代码怎么分、依赖往哪走、谁能改谁」;设计系统决定「界面怎么长、组件怎么复用、多团队怎么保持一致」。两者都是用约束换长期效率。
读完本篇你应该能做到
- 为一个中大型前端项目划出清晰的层次与模块边界,并用工具把边界固化下来;
- 独立设计并落地一套设计系统(令牌 → 组件 → 文档 → 治理),说得清每一层的职责;
- 判断何时该上微前端,能对比五种隔离方案的代价,而不是只会说「用 qiankun」;
- 用 ADR、度量与绞杀者模式推动架构演进,做到「不停业务换地基」。
0. 领域导论
0.1 这个领域解决什么问题
| 现象 | 根因 | 归属 |
|---|---|---|
| 10 个团队改同一个仓库,天天冲突 | 单体前端 + 无边界 | 模块划分 |
| 三个业务线三套按钮,视觉不一致 | 无设计系统或采纳率低 | 设计系统 |
| 改一个公共组件,五个页面挂 | 组件 API 不稳定、无版本策略 | 资产治理 |
| 微前端上线后样式互相污染 | 无 CSS 隔离与命名约定 | 隔离方案 |
| 新人三个月还不敢改核心模块 | 依赖混乱、没有门面与文档 | 分层与边界 |
| 技术债越积越多,没人敢动 | 无 ADR、无度量、无 owner | 治理机制 |
| 每次发版都要全量回归 | 模块间隐式耦合,无法独立验证 | 演进策略 |
架构问题有个共同特征:短期看不出来,长期无法回避。它不会让某个功能失败,但会让「每一次改动都变慢一点」,直到某天团队发现:加一个筛选条件要改 6 个文件、跑 20 分钟测试、还要找 3 个人确认。
三条会同时恶化的曲线:
┌──────────── 改动成本(随耦合度上升)
│ ┌─── 回归范围(随隐式依赖上升)
│ ┌────┴─── 认知负担(随模块数量上升,若无边界)
│ /
│ /
└──────────────────────────► 时间
架构工作的目标不是「画得漂亮」,而是让这三条曲线在团队规模翻倍时不会失控。
0.2 一个类比
把前端架构想成城市规划:
- 分区(模块边界):住宅、商业、工业分开,互不干扰。分区不是为了好看,而是为了「建一栋楼不用重新论证整座城市」。
- 道路(依赖方向):单向主干道。允许所有路口都能掉头,早晚高峰就会堵死。
- 公共设施(设计系统):水电、路灯统一标准,不是每栋楼自己打井、自己发电。
- 旧城改造(演进):不能推平重建,要边住边改(绞杀者模式)。推平重建的方案经常死在「住户不同意」上,等价于业务不同意停三个月。
- 规划文档(ADR):为什么这条路这么修,后人要知道。没有它,每换一任负责人就会重新讨论一次。
三条直接推论:
- 没有分区的城市也能住人,只是每加一栋楼都要重新挖路——这就是没有模块边界的小团队项目,早期很快,规模一上来就寸步难行。
- 路修得越宽不代表越通畅,关键在「有没有单向、有没有红绿灯」——对应「目录分得越细不代表架构越好」,关键在依赖规则。
- 公共设施要先有归属部门,否则坏了没人修——对应设计系统必须有 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。分层真正解决的是:
- 变化隔离:UI 改版只动
views,业务规则改只动features/model,接口字段改只动features/api。三者变化频率不同,混在一起就会「改文案顺手动到计算逻辑」。 - 可测试性:
features/model是纯函数,不依赖 DOM 与网络,可以直接单测;views只做编排,用组件测试覆盖。逻辑埋在组件里,测试就得挂载整个页面。 - 可并行:两个人分别改不同 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 一张表看清依赖矩阵
| 从 \ 到 | app | views | features | shared |
|---|---|---|---|---|
| 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 个文件 | 按复杂度分层,简单页面两层足够 |
| 用路径别名掩盖耦合 | @/ 让跨层引用看起来「很自然」 | 别名不改变依赖方向,规则要单独校验 |
怎么验证你的分层是有效的:
- 删掉某个 feature 目录,构建是否还能通过?(能通过说明没有隐式依赖)
- 随机挑一个模块,问「改这个需求要动几个目录」?(超过 3 个说明边界有问题)
- 跑一次依赖图,看是否有环、是否有
shared → features的边。 - 让新人只看目录猜「改这个功能去哪」,猜对率是边界的真实指标。
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),
};
}
价值在三个地方:
- 字段变更只改一处:后端把
total_amount改成amount时,页面不用动。 - 类型收窄:后端状态码是数字,前端用联合类型,
switch能穷尽检查。 - 可测:
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>
);
}
三个细节容易被忽略:
{...rest}必须放最后,否则用户的className/onClick会被覆盖。type="button"要有默认值,否则在<form>里默认是submit,点一下表单就提交了。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.appendChild | SSR 报错、卸载残留 | 用 Portal 并在卸载时清理 |
| 组件依赖具体业务 store | 无法复用 | 通过 props / context 注入 |
1.5 L1 小结与自检
| 主题 | 关键结论 |
|---|---|
| 分层 | 四层单向依赖,变化隔离 |
| 依赖 | 底层定义接口,上层注入实现 |
| 模块 | 门面导出,DTO 与领域模型分离 |
| 设计系统 | 令牌 → 组件 → 模式 → 模板 + 文档 + 治理 |
| 组件 API | 透传、受控/非受控、组合优先、状态可感知 |
自检题
- 为什么
shared不能依赖features?——会造成反向依赖与循环引用,底层被业务绑死,复用与测试都变难;正确做法是shared定义接口,由app注入实现,并用 dependency-cruiser 在 CI 拦截。 - 模块的
index.ts有什么作用?——收敛公共导出面,隐藏内部结构,控制包体、避免深路径导入,让内部重构不影响调用方;注意别用export *,否则 tree-shaking 与依赖分析都会退化。 - 设计系统与组件库的区别?——设计系统包含 token、规范、文档、工具与治理机制;组件库只是其中一层实现,缺了 token 与治理,半年内必然腐化。
- 为什么组件要同时支持受控与非受控?——受控便于校验与联动,非受控减少样板代码;两种都支持是成本最低的折中,但同一组件生命周期内不能来回切换。
- 如何判断分层是否真的生效?——删掉一个模块能否构建、改一个需求要动几个目录、依赖图是否有环、新人能否猜对文件位置;四条里至少三条成立才算有效。
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 | 同上 |
三条约束:
- 不要用视觉值做名字:
--blue-500只应存在于原始层。 - 状态后缀统一:
-hover/-active/-disabled/-focus。 - 层级用语义词:
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
| 产物 | 谁消费 | 注意 |
|---|---|---|
| ESM | Vite / Rspack / webpack 5 | 保留 import 语义,利于 tree-shaking |
| CJS | Node、旧测试环境 | 不做 tree-shaking,体积大 |
.d.ts | TS 用户 | 要与 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"
}
}
三个容易踩的点:
exports一旦声明,未列出的路径全部不可访问(包括./dist/xxx),这是好事,但升级时容易打断用户。sideEffects必须标出 CSS,否则打包器会把import '@acme/ui/styles.css'摇掉。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
三个实践:
- 每个组件独立入口(
./button),给不用打包器优化的场景兜底。 - 避免在模块顶层做副作用(如注册全局指令、读
window),否则 tree-shaking 失效。 - CI 里加体积门禁:
size-limit或自研脚本,超过阈值直接失败。
2.3.6 文档与测试
| 类型 | 工具 | 覆盖什么 | 触发时机 |
|---|---|---|---|
| 交互文档 | Storybook | 每个状态可点、可调参 | 每次 PR 预览 |
| 行为测试 | Testing Library | 点击、输入、键盘 | 单测 |
| 视觉回归 | Chromatic / Playwright 截图 | 像素级变化 | PR |
| 可访问性 | axe-core / Storybook a11y | 对比度、标签、角色 | 单测 + PR |
| 类型测试 | tsd / vitest typecheck | API 类型契约 | 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 |
|---|---|---|
| 新增可选 prop | minor | 否 |
| 修 bug | patch | 否 |
| 改默认值 | minor(需公告) | 否,但要写迁移提示 |
| 重命名 prop | major | 是 |
| 删除 prop | major | 是 |
| 改 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 的三个坑:
- 弹窗挂到
document.body会丢样式——应挂到 Shadow 内的容器,或用统一的弹层服务。 position: fixed相对视口,但如果祖先有transform,包含块会变(详见 02 的 1.4)。- 主题变量能穿透,普通类名不能——所以设计系统必须用 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 | 记录选项、后果与回退 |
自检题
- 为什么组件不应直接使用原始色值?——无法换主题与品牌,语义不清晰;组件只消费语义/组件令牌,主题映射在语义层完成,换肤时只改一层。
- 什么情况下不该上微前端?——团队少、发布节奏一致、技术栈统一、共享状态多;此时隔离收益不抵通信、样式与版本治理成本,单仓多包更合适。
- 微前端里弹窗挂到
body会有什么问题?——脱离子应用沙箱与样式作用域,卸载后残留,层级与事件可能冲突;应挂到子应用容器或统一弹层服务,Shadow DOM 场景尤其要注意。 - Module Federation 出现
Invalid hook call通常是什么原因?——React 被打包了两份(shared未设singleton或版本不匹配),导致 Hook 在另一个实例上执行;排查共享作用域与React.version。 - 为什么 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 |
| 文档与代码不一致 | 用户不再信任文档 | 文档从代码生成,示例跑测试 |
| 版本不兼容升级 | 业务被迫 fork | SemVer + 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} />;
}
三个要点:
- 必须是「旧写法仍能工作」,只是告警;直接报错会让使用方不敢升级。
- 告警里写清替代方案与移除版本,否则用户不知道怎么改。
- 给一个明确的移除时间,否则兼容层会永久存在。
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
规则与源码分层一致:
- 底层包不依赖上层包;
- 共享运行时依赖用
peerDependencies; - 单仓用
workspace:*,发布时由发布工具替换为具体版本; - 跨包引用只走包名,不写相对路径。
{
"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 / closed | closed 外部完全拿不到 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 按序发布 |
| 度量 | 五维指标,找问题不排名 |
| 守护 | 静态 + 测试 + 运行时三层,存量冻结只拦新增 |
自检题
- 为什么不能「停业务重写」?——需求持续变化,重写周期长且风险集中,上线时可能已经过时;绞杀者模式按路由/流量逐步替换,每步可回退,双跑还能用数据验证。
- 设计系统采纳率低,通常是什么原因?——API 不好用、文档缺失、与设计稿不一致、迁移成本高、没有 owner;先修体验与工具,再加 lint 约束。
- Shadow DOM 下弹窗为什么要特殊处理?——样式与事件被隔离,挂到
document.body会丢样式且卸载残留;应渲染在 Shadow 内部或用统一弹层服务,必要时用::part暴露定制点。 - 架构度量为什么不能用来排名?——指标会被优化而非问题被解决(例如拆包刷模块数、写无断言测试刷覆盖率);应用于发现瓶颈与分配资源。
- 契约测试放在哪一层最有效?——放在团队边界(前后端、主应用与子应用、共享库与使用方),用共享 schema 校验;放在内部函数上收益低、维护成本高。
4. L4 专家:技术战略与组织
4.1 架构与组织(康威定律)
系统结构会复制组织的沟通结构。
这不是一句口号,而是可以直接观察到的现象:
| 组织现象 | 系统表现 |
|---|---|
| 两个团队共用一个模块 | 模块里出现两套风格、两套状态管理 |
| 前端与后端分属两个部门 | 接口频繁变更、DTO 层层包装 |
| 平台团队不接触业务 | 平台功能完备但没人用 |
| 每个业务线独立汇报 | 三套组件库、三套构建 |
逆康威(Inverse Conway):先设计想要的系统边界,再调整团队边界去匹配它。例如希望「订单」与「支付」解耦,就先让它们有各自的 owner、发布流水线与 oncall,而不是先画架构图再要求团队配合。
落地的三件事:
- 每个模块有唯一 owner:谁负责、谁审批变更、谁 oncall,写进 CODEOWNERS。
- 跨团队接口有契约:schema + 评审 + 变更流程,而不是「喊一声就改」。
- 共享资产有归属团队:设计系统、构建平台、工具库必须有固定投入,否则一定腐化。
# .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 自检
- 公司要「统一前端技术栈」,你如何制定路线?——先测基线(构建、发布、缺陷、重复实现),选 2–3 个杠杆;新项目强制、存量渐进;先统一构建与规范,再迁框架,每阶段有回退点与量化目标。
- 三个业务线坚持用自己的组件库,怎么收敛?——先统一 token 与交互规范(最小共识),再做适配层与合并计划;用复用率、维护成本、走查问题数据推动,而不是行政命令;给业务保留扩展点。
- 如何判断一个架构决策是「技术最优」还是「组织最优」?——看是否匹配团队能力、协作边界与长期维护成本;技术最优但无人维护,就是组织上的次优。
- 平台项目上线后使用率低,先查什么?——先查「采纳成本」与「是否解决真实痛点」,再查文档与推广;多数失败是「功能完备但接入要改 20 个文件」。
- 如何为架构演进争取资源?——把技术问题翻译成业务语言(交付周期、故障成本、人力浪费),给出基线与目标,明确投入产出与止损点。
5. 综合实战:单体前端 → 多团队并行
背景:一个 40 万行单体仓库,5 个团队,发布互相阻塞,构建 15 分钟,3 套 Button。
5.1 第一步:量化现状(没有数据不动手)
| 指标 | 现状 | 采集方式 |
|---|---|---|
| 构建时长 | 15min(全量)/ 4min(增量) | CI 日志 |
| 日均合并冲突 | 12 次 | git 统计 |
| 发布频率 | 1 次/周,需全量回归 | 发布记录 |
| 回归耗时 | 6 人 × 4 小时 | 测试记录 |
| 组件重复 | 3 套 Button、2 套 Table | AST 扫描 |
| 变更影响面 | 平均每个需求改 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 拦截。
- 要点:接口定义在领域层、实现在基础设施层,用依赖注入或「底层定义接口 + 上层注入」装配;用 ESLint
- 再追问:分层会不会导致「为了一个字段写五个文件」?
- 要点:会,所以要按复杂度分层。CRUD 页面两层足够;规则复杂、生命周期长、多人协作的模块才值得完整分层。分层的目标是隔离变化,不是统一模板。
链 2 · 微前端 → 五类隔离 → 沙箱边界
- 面试官:微前端要解决什么问题?
- 要点:多团队独立开发、独立发布、技术栈渐进迁移、故障隔离。团队小、发布节奏一致时,微前端是额外复杂度。
- 追问:qiankun 的沙箱隔离了什么?
- 要点:JS 用 Proxy 劫持
window+ 快照恢复,CSS 用 scoped 前缀或 Shadow DOM,路由由基座统一,状态通常不隔离。局限:eval/new Function逃逸、直接操作document、样式注入到head造成全局污染。
- 要点:JS 用 Proxy 劫持
- 再追问: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 被打包了两份,Hook 在另一个实例上执行,Dispatcher 为空;常见原因是
- 追问:怎么保证只有一个 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 个团队共同维护,发布互相阻塞。如何拆成可独立发布的架构,同时不停止业务?
评估点:拆分顺序、隔离方案、灰度与回滚、组织配套。
参考方案:
- 先量后拆:统计模块间依赖、发布冲突频率、构建时长,选「变更最频繁、依赖最少」的模块做第一个试点。
- 立契约:主应用提供路由、鉴权、布局、通信总线;子应用按约定注册,禁止直接读写主应用状态。
- 隔离:路由按前缀划分;样式用 scoped + 命名空间或 Shadow DOM;JS 用沙箱;共享依赖走 externals/MF 并锁版本。
- 双跑:新旧路径并存,按用户/租户灰度;埋点对比错误率与性能,异常一键切回。
- 治理:每个子应用有 owner 与发布流水线;契约变更走 RFC;定期清理不再使用的子应用与开关。
加分:指出微前端不是目的、独立发布才是;指出共享组件库要先统一;指出要防止「分布式单体」;给出量化的验收指标。
减分:直接上 iframe 当终态;不做契约直接共享全局变量;一次性全量迁移;没有回滚方案。
题 2 · 设计系统上线一年,采纳率只有 45%,业务宁愿自己写组件。你如何诊断并提升?
评估点:是否先找根因、是否用数据、是否能推动组织。
参考方案:
- 诊断:抽样 10 个自写组件,记录原因(API 不合用、样式难覆盖、文档缺失、与设计稿不一致、版本老旧);统计各原因的占比。
- 修体验:优先补「高频且被自写」的组件;开放样式覆盖点(token +
::part+className透传);补齐「何时用/不用」的文档与可运行示例。 - 降成本:提供 codemod 迁移旧组件;脚手架默认使用设计系统;组件需求统一入口,48 小时内响应。
- 加约束:lint 禁止自写高频组件与硬编码色值,但允许显式豁免并登记原因。
- 看效果:月度采纳率报告(覆盖率、重复实现数、令牌使用率),按团队可见,不排名。
加分:指出「先降门槛再加约束」的顺序;指出采纳率不是越多越好,过度统一会伤害特殊场景;给出豁免机制。
减分:直接发规范要求使用;把采纳率做成 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