这是从「工程师」走向「架构师」的分水岭。架构不是画一张漂亮的分层图,而是在「变化、成本、复杂度」之间持续做权衡。本章讲透 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%。
都是架构师用线上事故和返工换来的教训。
从「会写代码」到「能设计系统」,必须亲手做一次完整决策。
每题都要能「举例子」回答,才算真懂。
什么是「好架构」的判断标准?「过度设计」和「设计不足」各举一个你见过的真实例子。
答案:好架构的核心判断标准只有一个——需求的变更速度 vs 改动的成本,即让「改动成本的增长曲线」比「系统复杂度」更平缓。具体可拆解为三点:① 新人能快速找到代码在哪(路径清晰);② 改一个功能不影响无关模块(隔离良好);③ 砍功能就像拔插件不用拆墙(可回滚)。
SOLID 五原则各解决什么问题?用你自己的代码各举一个违反的例子和修正方法。
答案:S——单一职责:解决「一个模块因多个原因被迫修改」。例:组件既获取数据又渲染又做 CSV 导出。修正:拆成 fetch 的 Hook、纯函数 filter、CSV 工具函数。O——开闭原则:解决「加功能必须改老代码」。例:支付方式 if 链。修正:策略注册表。L——里氏替换:解决「子类破坏基类的约定」。例:ErrorBoundary 吞掉致命错误。修正:把致命错误继续抛出。I——接口隔离:解决「调用方被它不需要的东西绑架」。例:给 UserCard 传整个 User 对象而不是只传 name + avatar。D——依赖倒置:解决「高层逻辑被具体实现锁死」。例:业务代码直接 import fetch。修正:依赖抽象的 Repository 接口,实现可替换。
为什么说「策略模式 + 注册表」是开闭原则的标准落地?新增一种支付方式需要改哪些代码?
答案:开闭原则的核心是「对扩展开放,对修改关闭」——新增功能时新增代码而不是改动老代码。策略模式 + 注册表通过把「每种支付方式」抽成独立策略对象挂到 Map 里,使 pay 函数本身不包含任何分支,只做「查表 + 执行」。所以新增一种支付方式(比如「境外卡」)需要做的唯一改动是:新建一个策略文件并注册一行,pay 函数一行都不用改。这就是字面意义上的「对修改关闭」——老代码零改动。
前端分层架构的依赖方向为什么必须「向下」?shared 被 features 反向依赖会发生什么?
答案:依赖方向向下的目的是让「稳定层」不依赖「易变层」——shared(基础设施)是最稳定的,features(业务功能)是最易变的。如果允许 features 反向依赖 shared 的同时,shared 也依赖 features(更糟),就会形成循环依赖:改一个 feature 可能影响 shared,进而所有依赖 shared 的模块都被波及——这就是「一处改动全站崩」的根因。
适配器模式与装饰器模式的区别是什么?各举一个前端场景。
答案:适配器解决「接口不兼容」——把一个接口转成另一个接口,调用方无感。前端场景:两套不同后端返回的订单数据结构不同,用 adaptOrder 转成统一 Domain 模型。装饰器解决「给已有功能增强能力」——原函数签名不变,行为变强。前端场景:给 loadData 包一层 withRetry,原函数没动,调用方看到的还是 loadData(),但多了重试能力。一句话区分:适配器改变形状,装饰器增加能力。
发布订阅模式和直接调用函数有什么区别?什么时候该用事件总线、什么时候该用显式状态管理?
答案:直接函数调用是同步的、强耦合的——调用方必须知道被调用方是谁;发布订阅是异步的、解耦的——发布者不知道谁在听。何时用事件总线:① 发布者和订阅者可能动态变化;② 模块间无直接关系(如跨 feature 通知);③ 需要「广播」——一个事件多个监听者(如埋点、日志、UI 更新)。何时用显式状态管理(Zustand/Redux):多个组件共享同一份状态且需要持久化,状态变更需要严格可追溯。
用状态机重写一个「if 层层嵌套」的订单流程,非法转移会怎样?为什么状态机天然可测试?
答案:状态机把所有「状态 × 事件的合法转移」写成一张表,不合法转移直接抛错(transitions[state][event] 为 undefined 就 throw)。相比 if/else:① 每种流转一目了然;② 非法操作在编码阶段就暴露,而不是线上才踩到。状态机天然可测试的原因是:状态转移表是一个有限的数据结构,测试可以遍历(状态数 × 事件数)矩阵——每个合法转移断言一次,每个非法转移断言抛错一次,100% 覆盖转移表即 100% 覆盖业务规则,比断言 DOM 的 UI 测试稳定得多。
实体、值对象、聚合根的区别?为什么值对象要不可变?
答案:实体有唯一标识、有生命周期、状态会变(User 的 active 状态会变但 id 不变);值对象无标识、由值本身定义、不可变(Money 的 amount + currency 唯一确定它,两个 Money 相等就是相等);聚合根是一组对象的唯一入口,外部只能通过根操作(Order 的 items 不能让外部直接 push)。值对象不可变的原因是:可以放心传递和比较——一个 Money 对象创建后,任何拿到它的人都不用担心它被偷偷改过(引用透明)。如果 Money 是可变的,a.add(b) 之后 a 就被篡改了,别的持有 a 的代码全被污染。不可变让值对象天然线程安全、无副作用、可哈希。
什么信号出现时该考虑 Monorepo?什么信号出现时该考虑微前端?各自的代价是什么?
答案:该考虑 Monorepo 的信号:① 2 个以上前端应用需要共享公共库(设计系统、请求层、工具库);② 发现复制粘贴的代码超过 3 处;③ 改一个公共 bug 要 PR 多个仓库。代价:工具链复杂度上升、CI 需处理增量缓存、依赖提升可能引发幽灵依赖。该考虑微前端的信号:① 有独立团队各自独立迭代同一个产品;② 老系统需要渐进式迁移且不能推翻重来;③ 确实需要不同技术栈并存。代价:多运行时性能损耗、子应用生命周期/样式隔离/路由协调复杂度、调试链路增长、分享公共依赖变难。
ADR 必须包含哪几个章节?为什么「代价」章节必不可少?
答案:ADR 必须包含的章节:① 标题与编号(ADR-XXXX:简明描述);② 状态(提议 / 已接受 / 已废弃 / 被替代);③ 背景(为什么需要这个决策,不决策会怎样);④ 决策(具体选了什么方案,怎么落地);⑤ 理由(为什么选 A 而不是 B,列出评估维度);⑥ 备选方案(考虑过哪些,为什么没选);⑦ 代价(引入的复杂度、并存期、学习成本、兼容成本);⑧ 验证(PoC 数据或上线后指标)。为什么代价章节不可少:没有任何技术选型是免费的——写下来能强迫决策者直面「为这个选择付出了什么」,也能让未来的读者在评估「要不要推翻这个决策」时有据可查。没有代价的 ADR,要么是拍脑袋,要么是没想清楚。
技术选型五步法是什么?为什么「PoC 小样验证」比看文档和社区讨论更可靠?
答案:技术选型五步法:① 明确问题(我们解决什么,不解决会怎样);② 列候选(3 个以内);③ 定评估维度并打分(社区活跃度 / 团队熟悉度 / 学习成本 / 长期维护性 / 契合度,加权打分);④ 做小样验证(PoC 2~3 天);⑤ 写 ADR 存档。PoC 更可靠的原因:文档是「作者声称的」,社区讨论是「别人在不同场景的感受」,PoC 是「在我们的真实数据、我们的使用频率、我们的技术栈边界」上验证。文档不会说「这个库在低端安卓机上内存爆炸」,社区不会提醒「你的产品和文档里 80% 的场景不一样」——只有 PoC 会发现这些。PoC 的核心价值不是证明「能行」,而是发现「哪里不行」。
如果有人提议给 10 人团队的新项目上微前端,你会问哪些问题来说服或阻止?把对话流程写出来。
答案:第一问:谁和谁独立交付?如果回答「我们 10 个人是一个产品经理带一个完整 Microservice」,那就没有独立团队的边界——微前端解决多团队独立交付的前提不存在。第二问:为什么不共享代码?如果理由是「模块之间没关联」,问「那公共登录态、UI 组件、请求层怎么处理?」——你会发现这些都必须共享,而共享依赖在微前端里是版本协商中最痛的问题。第三问:发版频率差多少?如果回答「大家差不多一周一次」,说明没有「不同团队不同节奏」的诉求。第四问:技术栈为什么要不同?如果答案是「有人喜欢 Vue 有人喜欢 React」,这个理由不成立——技术栈异构带来的成本远大于「让少数人换个框架」。总结:四个关键问题都拿不到足够证据时,正确答案是 Monorepo 或单体——用一个简单系统解决现在的问题,而不是用一个复杂系统解决「未来可能」的问题。