这是从「工程师」走向「架构师」的分水岭。架构不是画一张漂亮的分层图,而是在「变化、成本、复杂度」之间持续做权衡。本章讲透 SOLID、分层、设计模式、DDD 思想、Monorepo 与微前端的取舍,以及如何用 ADR 把你的每个决策变成团队资产。
架构的本质是取舍,不是堆技术。
好的架构不是一个「终极正确」的形态,而是让系统在当前阶段以可接受的成本应对预期内的变化。判断标准只有一条:需求的变更速度 vs 改动的成本——架构的价值就是让「改动成本的增长曲线」比「系统复杂度」平缓。
| 权衡维度 | 过度设计的代价 | 设计不足的代价 |
|---|---|---|
| 抽象 | 两层包装查三层文件才能改一行 | 到处重复,改一处漏十处 |
| 分层 | 为小功能建五层目录 | 全部逻辑堆在组件里 |
| 性能 | 为不可能出现的 10 万 QPS 做集群 | 用户基数起来后重构核心路径 |
| 测试 | 为展示组件写 100% 覆盖 | 核心业务逻辑零测试 |
| 工具链 | 微前端 + Monorepo + 自研脚手架 | 单仓单应用一个 script 打天下 |
不是「背名词」,而是理解每个原则在解决什么痛点。
一个模块只应该有一个「改变的理由」。判断方法:给这个模块列「它会被哪些原因修改」,超过一个就该拆。
// 反例:一个组件做了四件事——加载数据、渲染列表、过滤、导出 CSV function UserListPage() { const [users, setUsers] = useState([]) // ① 数据获取 const [keyword, setKeyword] = useState('') // ② 搜索 const filtered = users.filter(u => u.name.includes(keyword)) const handleExport = () => { // ③ CSV 导出(另一个完全无关的职责) const csv = users.map(u => `${u.name},${u.email}`).join('\n') download(csv) } return <div>{filtered.map(u => <UserRow key={u.id} user={u} />)}</div> } // 正例:拆分——自定义 Hook 管数据,纯函数管过滤,工具函数管导出,组件只做组装 function useUsers() { /* 数据获取逻辑 */ } export function filterByName(users: User[], keyword: string): User[] { /* 纯逻辑,可单测 */ } export function toCsv(rows: User[]): string { /* 纯逻辑,可单测 */ } function UserListPage() { const { users } = useUsers() const filtered = filterByName(users, keyword) return /* 纯渲染 */ }
对扩展开放,对修改关闭:加新功能时尽量「新增代码」,而不是「改老代码」。前端最常见的落地方式:策略模式 + 配置表。
// 反例:加一种支付方式就要改 if 链 function pay(order: Order, method: string) { if (method === 'wechat') { wechatPay(order) } else if (method === 'alipay') { alipayPay(order) } else if (method === 'card') { cardPay(order) } throw new Error('unknown method') } // 正例:策略注册表——新增支付方式 = 新增一个文件,不改 pay 本体 type PayStrategy = { key: string; execute: (order: Order) => Promise<void> } const strategies: Record<string, PayStrategy> = { wechat: { key: 'wechat', execute: wechatPay }, alipay: { key: 'alipay', execute: alipayPay }, card: { key: 'card', execute: cardPay }, } async function pay(order: Order, method: string) { const s = strategies[method] if (!s) throw new Error('unknown method') await s.execute(order) }
再加一个「境外卡」?新增一个策略对象注册进去即可,pay 函数一行不用改。
子类型必须能替换父类型而不破坏行为。前端最实用的版本:「继承/扩展出来的东西,必须遵守基类定下的约定」。违反 LSP 的经典案例:正方形继承长方形(宽高独立设置会让面积逻辑出错)、抛「这个子类不支持」异常的基类方法。
// 反例:ErrorBoundary 吞掉所有错误,包括「不该吞」的 class AllErrorBoundary extends React.Component { componentDidCatch(error) { return '发生错误' } // 连致命错误也吞,页面假死 } // 正例:区分可恢复/不可恢复,把决定权交还给调用方 class ErrorBoundary extends React.Component { componentDidCatch(error) { if (error instanceof FatalError) { throw error } // 致命错误继续抛 this.props.onRecover?.() return '部分功能不可用' } }
不要强迫调用方依赖它不需要的东西。前端表现:组件 props 设计、API 返回类型设计、context 拆分。
// 反例:一个巨型 User 对象传给只显示名字的组件——组件被「无关字段」耦合 <UserCard user={user} /> // UserCard 其实只需要 name 和 avatar // 正例:按需取字段,组件只声明自己需要的 type UserCardProps = { name: string; avatarUrl?: string } <UserCard name={user.name} avatarUrl={user.avatarUrl} />
同理:context 拆成多个小 context(主题、语言、用户),避免任何组件被一个大 context 绑架;接口返回类型不要直接复用内部数据模型,按页面需求裁剪。
高层模块不依赖低层模块,两者都依赖抽象。前端版:业务逻辑不直接 import 具体的 fetch/axios,而是依赖「仓库接口」,实现可以替换(真实 API / Mock / 本地存储)。这正是上一章测试能轻松替换依赖的根基。
// 定义抽象:数据仓库接口(依赖倒置的「抽象」) export interface UserRepository { getUsers(): Promise<User[]> getUserById(id: number): Promise<User | null> } // 低层实现 A:真实 API export class HttpUserRepository implements UserRepository { async getUsers() { const r = await fetch('/api/users'); return r.json() } } // 低层实现 B:内存 Mock(测试 / 演示用) export class MemoryUserRepository implements UserRepository { private data = [{ id: 1, name: 'Tom' }] async getUsers() { return this.data } } // 高层逻辑只认接口,不认实现 function getUserCount(repo: UserRepository) { return repo.getUsers().then(us => us.length) }
核心思想:让「稳定」依赖「稳定」,让「易变」隔离在边缘。
src/ ├── app/ // 应用组装层:路由、Provider、布局、全局样式(依赖所有层) ├── pages/ // 页面级组件:一页一个文件,只做「组装」 ├── features/ // 业务功能域:按业务划分(auth / cart / order),各自私有组件+hooks+api ├── shared/ // 共享基础设施:UI 组件库、请求封装、工具函数、常量(被所有层依赖,不依赖任何业务) │ ├── components/ │ ├── hooks/ │ ├── api/ // 对后端接口的封装,统一错误处理/鉴权/取消 │ └── utils/ └── types/ // 全局类型定义(DTO、领域模型)
两条铁律:
状态管理(阶段六)解决的是「状态放哪」,分层架构解决的是「数据往哪流」。理想的流转:
用户操作 → 事件 → 业务逻辑(纯函数/Hook)→ 状态更新 → 视图渲染 → 用户看到
↓(需要数据时)
api 层请求 → 数据转换(DTO → 领域模型)→ 进入状态
raw => domain。好处:后端字段变化只改一处,领域模型稳定,组件永远用「自己语言」的数据。
GoF 23 种模式不用全背,前端真正高频的不到十种。
// 工厂模式:把「创建对象的细节」藏起来 // 比如按类型创建不同图表配置 function createChartConfig(kind: 'line' | 'bar' | 'pie'): ChartConfig { const base = { animation: true, responsive: true } switch (kind) { case 'line': return { ...base, type: 'line', smooth: true } case 'bar': return { ...base, type: 'bar', stacked: false } case 'pie': return { ...base, type: 'pie', donut: false } } } // 单例模式:全局唯一实例(但注意:模块级导出本身就是单例,通常不需要 class) // ES Module 天然单例——真正需要手动单例的是「避免重复初始化」的场景 let socket: WebSocket | null = null export function getSocket(): WebSocket { if (!socket || socket.readyState === WebSocket.CLOSED) { socket = new WebSocket('wss://api.example.com/ws') } return socket }
// 适配器模式:统一「长得不一样但语义相同」的接口 // 例:不同后端接口返回的日期格式不一样,适配成统一格式 interface OrderDTO { create_time: string } // 后端 A:'2026-08-20 09:00' interface OrderDTO2 { createdAt: number } // 后端 B:时间戳 function adaptOrder(raw: OrderDTO | OrderDTO2): Order { if ('create_time' in raw) return { id: raw.id, createdAt: new Date(raw.create_time) } return { id: raw.id, createdAt: new Date(raw.createdAt) } } // 装饰器模式(函数式版本):给函数加能力而不改它 function withRetry<T>(fn: () => Promise<T>, times = 3): () => Promise<T> { return async () => { let lastErr: unknown for (let i = 0; i < times; i++) { try { return await fn() } catch (e) { lastErr = e } } throw lastErr } } const loadWithRetry = withRetry(loadData) // 原函数没动,能力变强了
// 策略模式已在 SOLID-O 里演示(支付方式注册表),这里演示观察者 // 观察者/发布订阅:事件的发布与订阅解耦(EventEmitter 是浏览器原生实现) type Handler = (payload: unknown) => void class Bus { private map = new Map<string, Handler[]>() on(event: string, h: Handler) { const arr = this.map.get(event) ?? []; arr.push(h); this.map.set(event, arr) } off(event: string, h: Handler) { this.map.set(event, (this.map.get(event) ?? []).filter(x => x !== h)) } emit(event: string, payload?: unknown) { (this.map.get(event) ?? []).forEach(h => h(payload)) } } // 状态机:把「一堆 if」变成「状态 × 事件」的转移表——处理复杂流程的利器 // 例:订单状态机 type State = 'created' | 'paid' | 'shipped' | 'done' | 'cancelled' type Event = 'PAY' | 'SHIP' | 'COMPLETE' | 'CANCEL' const transitions: Record<State, Partial<Record<Event, State>>> = { created: { PAY: 'paid', CANCEL: 'cancelled' }, paid: { SHIP: 'shipped', CANCEL: 'cancelled' }, shipped: { COMPLETE: 'done' }, done: {}, cancelled: {}, } function transition(state: State, event: Event): State { const next = transitions[state][event] if (!next) throw new Error(`非法转移: ${state} + ${event}`) return next }
三者都是「逻辑复用」的手段,但定位不同:
| 模式 | 本质 | 场景 | 现状 |
|---|---|---|---|
| Render Props | 组件接收「返回 JSX 的函数」 | 把渲染权交给调用方 | 被 Hook 取代,很少用 |
| HOC | 函数接收组件返回新组件 | 需要「包装」组件时(鉴权、埋点) | 被 Hook 取代,仅剩少量场景 |
| 自定义 Hook | 把状态逻辑提取成以 use 开头的函数 | 绝大多数逻辑复用 | 现代 React 首选 |
// 自定义 Hook 是 React 生态最重要的「设计模式」 function useDebouncedValue<T>(value: T, delay = 300): T { const [debounced, setDebounced] = useState(value) useEffect(() => { const t = setTimeout(() => setDebounced(value), delay) return () => clearTimeout(t) }, [value, delay]) return debounced } // 组合出更大粒度的 Hook:搜素 + 防抖 + 请求 function useSearch<T>(query: string, fetcher: (q: string) => Promise<T>) { const debounced = useDebouncedValue(query, 300) const [data, setData] = useState<T | null>(null) const [loading, setLoading] = useState(false) useEffect(() => { if (!debounced) { setData(null); return } setLoading(true) fetcher(debounced).then(setData).finally(() => setLoading(false)) }, [debounced, fetcher]) return { data, loading } }
领域驱动设计的核心不是代码,是「用业务语言组织代码」。
一个词在业务里不同场景含义不同。比如「订单」在下单域(含购物车明细)和售后域(含退款状态)是完全不同的模型。强行统一成一个「大订单模型」会让两端都被对方的概念污染。前端落地点:features 目录就是你的限界上下文——每个 feature 有自己的模型、自己的 api 封装、自己的状态,不跨域共享模型。
| 概念 | 定义 | 前端例子 |
|---|---|---|
| 实体 | 有唯一标识、有生命周期、状态会变 | User(id 不变,active 状态变) |
| 值对象 | 没有标识,由「值」本身定义,不可变 | Money(金额+币种)、Address(直接比较值) |
| 聚合 | 一组对象 + 一个唯一入口(聚合根),外部只能通过根操作 | Order(含 items,只能通过 order.addItem 改) |
// 值对象:不可变 + 值相等 class Money { constructor(readonly amount: number, readonly currency: 'CNY' | 'USD') {} add(other: Money): Money { // 返回新对象,不修改自身 if (other.currency !== this.currency) throw new Error('币种不一致') return new Money(this.amount + other.amount, this.currency) } equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency } } // 聚合根:订单只能通过方法修改,外部碰不到内部数组 class Order { private items: OrderItem[] = [] addItem(item: OrderItem) { if (this.status !== 'created') throw new Error('已提交的订单不可修改') this.items.push(item) } total(): Money { return this.items.reduce(...) } }
什么时候需要、怎么搭、代价是什么。
# pnpm-workspace.yaml —— 声明工作区 packages: - apps/* // 应用:web、admin、docs - packages/* // 共享包:ui、utils、request、config
// apps/web/package.json —— 用 workspace: 协议引用本地包 { "dependencies": { "@company/ui": "workspace:*", "@company/request": "workspace:*" } }
// turbo.json —— 定义构建/测试的缓存与调度 { "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "test": { "dependsOn": ["^build"] }, "lint": { "dependsOn": ["^build"] } } } // 运行:turbo run build --filter=@company/web(只构建 web 及其依赖)
微前端解决的是「组织问题」,不是「技术问题」。
| 方案 | 原理 | 适合 | 注意 |
|---|---|---|---|
| iframe | 浏览器原生隔离 | 极简集成、完全隔离 | 体验割裂(跳转、弹层、通信麻烦) |
| qiankun | 基于 single-spa,HTML Entry + JS 沙箱 + 样式隔离 | 国内主流、文档全、上手快 | 沙箱有坑(动态 script、Webpack 5 兼容) |
| Module Federation(Webpack 5 / Rspack) | 构建时把子应用打成「远程模块」,运行时按需加载 | Webpack 系、追求运行时共享依赖 | 依赖版本协商是难点 |
架构师的每个决策都该是「文档化论证」而非「个人偏好」。
# ADR-0017:状态管理从 Redux 迁移到 Zustand 状态:已接受(2026-08-20,评审人:王工/李工) 背景 业务页面大量使用「局部状态 + 少量全局状态」模式,Redux 的样板代码 (action 类型、reducer、selector 的重复声明)占开发成本约 15%, 且团队新人上手周期长。我们不需要时间旅行调试、跨组件中间件等高级特性。 决策 新业务模块使用 Zustand;存量 Redux 代码不重写,随迭代自然替换。 理由 - Zustand 样板代码减少约 70%,set/get 心智模型更简单 - 支持 selector + useShallow,性能优化零成本 - 无 Provider 包裹,组件测试更简单 - 可持久化中间件满足当前需求 - 团队 4 人已有 Zustand 实战经验(1 个季度) 备选方案 - Jotai:原子模型更细,但团队不熟,且我们无「极细粒度共享状态」需求 - 维持 Redux:无迁移成本,但样板代码问题持续存在 代价 - 两套状态方案并存 2~3 个季度,新增代码需遵循「新用 Zustand」约定 - 存量 Redux 维护者只剩 1 人,需在交接文档中注明 验证 试点模块(商品详情页)迁移后,新增代码行数下降 30%, 测试用例不变的前提下测试时间下降 25%。
都是架构师用线上事故和返工换来的教训。
从「会写代码」到「能设计系统」,必须亲手做一次完整决策。
每题都要能「举例子」回答,才算真懂。
什么是「好架构」的判断标准?「过度设计」和「设计不足」各举一个你见过的真实例子。
SOLID 五原则各解决什么问题?用你自己的代码各举一个违反的例子和修正方法。
为什么说「策略模式 + 注册表」是开闭原则的标准落地?新增一种支付方式需要改哪些代码?
前端分层架构的依赖方向为什么必须「向下」?shared 被 features 反向依赖会发生什么?
适配器模式与装饰器模式的区别是什么?各举一个前端场景。
发布订阅模式和直接调用函数有什么区别?什么时候该用事件总线、什么时候该用显式状态管理?
用状态机重写一个「if 层层嵌套」的订单流程,非法转移会怎样?为什么状态机天然可测试?
实体、值对象、聚合根的区别?为什么值对象要不可变?
什么信号出现时该考虑 Monorepo?什么信号出现时该考虑微前端?各自的代价是什么?
ADR 必须包含哪几个章节?为什么「代价」章节必不可少?
技术选型五步法是什么?为什么「PoC 小样验证」比看文档和社区讨论更可靠?
如果有人提议给 10 人团队的新项目上微前端,你会问哪些问题来说服或阻止?把对话流程写出来。