前端工程化中的设计模式实战全指南
那段时间我每天上班第一件事,就是打开那个八百多行的 user-profile.tsx。
八百行。一个组件。里面塞了请求、状态管理、表单验证、埋点、权限判断、日期格式化、头像裁剪——你能想到的东西,它全干了。每次产品经理走过来,说"加个最近浏览记录",或者设计师说"头像那块改成圆角",我的胃就往下沉一沉。因为我知道,又要把这坨东西从头捏到尾。
我看过很多设计模式的文章。说实话,看完就忘。那些 UML 图画得很漂亮,C++ 的例子写得很标准,但我他妈的根本不知道回到自己的 React 项目里该怎么用。工厂模式跟我有什么关系?单例模式不就是全局变量吗?观察者模式和发布订阅到底差在哪?
直到某个周三下午。
那天我在重构这个八百行的组件,边写边骂。写到第三小时的时候,我突然停下来了。我盯着屏幕看了很久——我正在做的事情,不就是单一职责原则吗?拆出来的那个请求 hook,不就是工厂模式的思路?那套验证逻辑,换个写法不就是策略模式?
卧槽。
那些设计模式说的就是我现在写的这坨屎。
只是他们用了一堆我压根记不住的名字。
那一刻我突然明白了:设计模式不是让你背的。是让你在屎山面前,突然认出来的。
先退回去一步
做個比喻吧。你去装修房子,厨房怎么布局、水电管线怎么走、插座的间距留多少——这些东西根本不需要你从零设计。前人已经总结出了一套经验,你照着做就行。
设计模式就是这个意思。
它是程序员在几十年的实践中,踩了无数坑之后总结出来的一套"你照着做,大概率不会出事"的套路。
但它不是规则。
这是最要命的一个区别。规则是你必须遵守的,不遵守就出错。设计模式不是。你不按工厂模式写,代码也能跑。你不用单例,天不会塌。设计模式只是在说——哥们,这事儿我遇到过,这么干最顺手,你要不要试试?
理解了这一点,后面的事情就好说了。
SOLID:五个你可能听过、但一直没搞明白的原则
在聊具体模式之前,这几个东西绕不过去。你可以把它们理解为"内功"——招式各有不同,但心法是通的。
OCP:开放封闭原则
我最早接触这个原则,是一个 button 组件。
在鬼哭狼嚎的阶段,我是这么写按钮的:
function Button({ type, children }) {
if (type === 'primary') {
return <button className="bg-blue-500 rounded px-4">{children}</button>
} else if (type === 'danger') {
return <button className="bg-red-500 rounded px-4">{children}</button>
} else if (type === 'ghost') {
return <button className="bg-transparent border rounded px-4">{children}</button>
}
return <button className="bg-gray-500 rounded px-4">{children}</button>
}
每加一种颜色类型,就要加一个 else if。设计师有天抽风,说按钮要支持 success 绿色,你就得打开这个函数,往里塞新代码。括号套括号,越写越长。而且你改 primary 样式的时候,万一手滑,danger 也跟着坏了。
这他妈的叫"对修改开放"。
OCP 说的是反过来的:对扩展开放,对修改关闭。翻译成人话——加新功能的时候,只加新代码,不改旧代码。
这是改完的样子:
function Button({ variant = 'default', className = '', children, ...props }) {
const base = 'px-4 py-2 rounded font-medium'
const variants = {
primary: 'bg-blue-500 text-white',
danger: 'bg-red-500 text-white',
ghost: 'bg-transparent border border-gray-300',
default: 'bg-gray-100 text-gray-800',
}
return (
<button className={`${base} ${variants[variant] || variants.default} ${className}`} {...props}>
{children}
</button>
)
}
现在设计师要加 success 绿色按钮,你只需要在 variants 对象里加一行 success: 'bg-green-500 text-white'。旧代码碰都不碰。
类比: 一个汉堡店的师傅。老板说菜单上要加鸡肉汉堡,他怎么办?傻逼做法是拆开做牛肉汉堡的机器,加一根"鸡肉专用管",动完骨头下次做猪肉汉堡又要拆。聪明做法是——机器留一个"换肉饼"的槽,加新品的时候换一块肉饼就行,不用动机器本身。
Vue 的 slot 机制就是典型的 OCP 思维。你往组件里塞什么内容都行,组件自己不用改。
什么时候不该用: 如果你的按钮只有两种颜色,并且未来三年产品都没有加第三种颜色的计划——那 if-else 就够了。为了"以后可能会扩展"而抽象,这是 YAGNI 教我们要避免的。
SRP:单一职责原则
这个原则最好理解,但最难做到。
简单说:一个东西只做一件事。
你走进一家小面馆。老板一个人又揉面又炒菜又收钱又擦桌子,这种馆子一个人撑不住,味道也不稳定。正经餐馆什么样?厨师管后厨,服务员管点菜,收银管账——各司其职。
回到前面那个八百行的 user-profile.tsx:
// 这他妈的是一个函数在干三件事
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [posts, setPosts] = useState([])
useEffect(() => {
fetchUser(userId).then(setUser)
fetchPosts(userId).then(setPosts)
trackPageView('profile', userId) // 埋点也混进来了
}, [userId])
return (
<div>
<Avatar src={user?.avatar} />
<h1>{user?.name}</h1>
<PostList posts={posts} />
</div>
)
}
在一个人眼里是"获取用户数据然后渲染页面",在 SRP 眼里是三个人的活:获取用户、获取帖子、展示 UI。你在改埋点逻辑的时候不小心改错了请求参数——这就是单一职责缺失带来的连带伤害。
拆完之后:
// hook 只负责获取数据
function useFetchUser(userId: string) {
const [user, setUser] = useState(null)
useEffect(() => {
fetchUser(userId).then(setUser)
}, [userId])
return user
}
// 另一个 hook 只负责埋点
function useTrackPageView(event: string, userId: string) {
useEffect(() => {
trackPageView(event, userId)
}, [event, userId])
}
// 组件只负责渲染
function UserProfile({ userId }: { userId: string }) {
const user = useFetchUser(userId)
useTrackPageView('profile', userId)
return <UserProfileUI user={user} />
}
每个东西只操心自己的事。改埋点就去改 hook,不用担心把数据请求搞挂。
什么时候不该用: 如果你的组件只有 30 行,拆成三个文件反而增加了心智负担——别拆。SRP 是防止混乱的工具,不是强迫你把所有东西都原子化的戒律。
LSP:里氏替换原则
这个名字最他妈的唬人。我最开始听到"里氏替换"的时候,以为是个什么数学定理。
但其实它说的是一个很朴素的事情:儿子可以替代爸爸干活,但不能把活干错了。
经典例子:
class Bird {
fly(): string {
return '我飞走了'
}
}
class Duck extends Bird {
fly(): string {
return '呱呱叫着飞走了' // 扩展了 fly,但没改变 fly 本身的语义
}
}
鸭子是鸟,会飞——没问题。那企鹅呢?
class Penguin extends Bird {
fly(): string {
throw new Error('我他妈的不会飞啊!') // 破坏了父类的约定
}
}
这里的问题不是"企鹅不会飞",而是你把一个不会飞的东西扔进了一个"应该会飞"的继承链里。调用方拿了 Bird 的子类,理所当然地调 .fly(),结果炸了。
怎么办?把不会飞的单独抽出来。
interface Animal {
move(): string
}
class Duck implements Animal {
move(): string {
return '飞走了'
}
}
class Penguin implements Animal {
move(): string {
return '溜冰走了'
}
}
LSP 真正的意思是:子类在继承父类的时候,可以扩展能力,但不能改变父类已经承诺的行为。 你爸承诺了"我儿子能开车",结果你是个盲人——这不是你的错,是你爸不该做这个承诺。谁继承错了链,谁就违反了 LSP。
在前端框架里,Vue 和 React 的组件继承很少用(大家都用 composition 了),但类型系统里这个原则无处不在。比如你定义了一个 FormField 接口,说每个字段必须有 validate() 方法——那所有实现了 FormField 的类,validate() 必须返回一个有意义的结果,不能抛异常。
什么时候不该用: 前端很少用类继承。如果你不用继承,这个原则几乎影响不到你。不要因为学了 LSP 就强行用类。
ISP:接口隔离原则
"不要强迫用户依赖他们不需要的东西。"
你点外卖,有没有碰过这种套餐:一份盖饭,强制搭配一碗汤、一道凉菜、一杯奶茶——你只想吃那碗盖饭,别的都不想要。
UserCard 组件就是这样:
interface UserCardProps {
name: string
avatar: string
email: string
phone: string
address: string // 列表页根本不需要这个
bio: string // 列表页也不需要
lastLoginAt: string // 列表页不需要
permissions: string[] // 列表页不需要
}
就一个简单的用户列表页,只需要 name 和 avatar,但你不能不用这个臃肿的 Props 接口——除非你另写一个。
ISP 说:拆成几个小接口,各取所需。
interface UserBasic {
name: string
avatar: string
}
interface UserContact {
email: string
phone: string
address: string
}
interface UserProfile extends UserBasic, UserContact {
bio: string
lastLoginAt: string
permissions: string[]
}
列表页用 UserBasic,详情页用 UserProfile,谁都不会被迫接受多余的东西。
类比: 你买工具套装。好套装是每个工具独立成型,你缺什么买什么。烂套装是一个塑料壳里面硬塞了十五个螺丝刀头,你为了用一个还得买整套。
GraphQL 的按需查询就是这个原则的数据库版本——客户端说我要什么字段,服务器就返什么,不多不少。
什么时候不该用: 如果你的组件只有一个场景用到,拆 Props 接口纯粹是自嗨。ISP 是你有三个以上消费者时才会感受到的好——一个消费者,不需要隔离。
DIP:依赖倒置原则
这个原则我想了很久才想通。
名字叫"依赖倒置",但你把它读成"依赖抽象,别依赖具体"就通了。
// 硬写死了 MySQL
class UserService {
private db = new MySQLDatabase()
getUser(id: string) {
return this.db.query(`SELECT * FROM users WHERE id = ${id}`)
}
}
这段代码的问题在哪?UserService 和 MySQLDatabase 焊死了。哪天你想换 PostgreSQL,或者测试时想用内存数据库——对不起,得翻开 UserService 改代码。
DIP 说:UserService 不应该依赖具体的 MySQLDatabase,它俩都应该依赖一个抽象的 Database 接口。
interface Database {
query(sql: string): any
}
class MySQLDatabase implements Database {
query(sql: string) {
/* MySQL 实现 */
}
}
class PostgresDatabase implements Database {
query(sql: string) {
/* PostgreSQL 实现 */
}
}
class UserService {
constructor(private db: Database) {} // 依赖接口,不依赖实现
getUser(id: string) {
return this.db.query(`SELECT * FROM users WHERE id = ${id}`)
}
}
// 测试时用 mock,线上用 MySQL,换个数据库就像换插座
const service = new UserService(new MySQLDatabase())
类比: 你家的插座。电器不关心插座后面走的是火电还是水电,它只关心"能给我 220V"。Database 接口就是这个插座规范,MySQLDatabase 和 PostgresDatabase 是火力发电站和水力发电站。电器(UserService)依赖的是插座规范(Database 接口),而不是发电站的类型。
在前端里,DIP 最常见的使用场景是:你的业务逻辑 Hook 不直接依赖 axios,而是依赖一个抽象的 HttpClient 接口。换 fetch、换 axios、换内置请求方法——都只改注入的地方,不改业务逻辑。
什么时候不该用: 你的后端三年没换过数据库,也没有换的计划。抽象不是免费的——每一层抽象都在增加复杂度。只有当你真的面临"可能要换"的时候,抽象才开始值回票价。
创建型:谁在创建,怎么创建
创建型模式解决的中心问题是:对象怎么生产出来,怎么保证生产过程是可控的。
工厂模式
你去麦当劳点餐。你说"一个巨无霸套餐",服务员给你一个完整的套餐——汉堡、薯条、可乐,都装好了。你不用关心汉堡的肉饼是哪块、薯条炸了几分钟、可乐的糖浆浓度怎么调的。
工厂模式就是这个意思。
new 操作符就是你自己进后厨炸薯条。工厂是你对前台说一句话,成品端到你面前。
在 React 里,React.createElement 就是一个典型的工厂:
// 不用工厂——你自己 new,关心各种参数
const button = new VNode('button', { className: 'btn' }, ['Click me'])
// 用工厂——一句调用完事
const button = React.createElement('button', { className: 'btn' }, 'Click me')
jQuery 的 $() 也是工厂模式。你传一个选择器字符串,它回你一个 jQuery 对象——创建过程全封装在 $() 里面。
实际项目里最常见的使用场景:根据类型创建不同的表单字段。
interface FormField {
type: 'text' | 'select' | 'datepicker'
label: string
value: any
}
class FormFieldFactory {
static create(field: FormField) {
switch (field.type) {
case 'text':
return new TextField(field)
case 'select':
return new SelectField(field)
case 'datepicker':
return new DatePickerField(field)
default:
throw new Error(`Unknown field type: ${field.type}`)
}
}
}
后端返回一个 JSON,告诉你要渲染哪些字段,前端工厂统一处理。加新字段类型,工厂里加一行,其他地方不改。
什么时候不该用: 如果只有两种字段类型,switch 写两行就行,不用单独抽一个工厂类。工厂模式的价值在于"创建逻辑足够复杂,值得被封装"。
单例模式
一个国家同一时间只有一个总统。
这不是说总统不能换,是说"这个职位同一时刻只能有一个人坐着"。
单例就是程序里的总统。
class ModalManager {
private static instance: ModalManager
private modalStack: string[] = []
private constructor() {} // 注意:private constructor
static getInstance(): ModalManager {
if (!ModalManager.instance) {
ModalManager.instance = new ModalManager()
}
return ModalManager.instance
}
open(id: string) {
this.modalStack.push(id)
}
close() {
this.modalStack.pop()
}
get top(): string | undefined {
return this.modalStack[this.modalStack.length - 1]
}
}
// 全局任何地方拿到的都是同一个实例
const manager = ModalManager.getInstance()
manager.open('confirm-dialog')
场景很明确:全局状态管理、弹窗管理器、日志收集器——这些东西同一个程序里只需要一个。
很多人听到"单例"第一反应是"全局变量"。区别在哪?单例有一个 getInstance 的门禁,你什么时候取,它保证只有一个人。全局变量是个公共广场,谁都能放东西,谁也都能踩一脚。
什么时候不该用: 单例让你方便,但也让你的代码全耦合在同一个状态上。测试特别吃亏——你没法在测试里重置单例的状态。如果你的"全局需求"实际上可以用 Context 或 provide/inject 解决,就别用单例。
原型模式
你有一个精准的建筑蓝图。你想造 100 栋一模一样的房子——不是重新画 100 次蓝图,而是拿这 1 张蓝图,克隆 100 次。
const baseConfig = {
api: {
baseURL: 'https://api.example.com',
timeout: 5000,
headers: { 'Content-Type': 'application/json' },
},
auth: {
tokenKey: 'access_token',
refreshEndpoint: '/auth/refresh',
},
}
// 克隆并微调
function createAppConfig(overrides: Partial<typeof baseConfig> = {}) {
return deepMerge(baseConfig, overrides)
}
const testConfig = createAppConfig({
api: { baseURL: 'https://test-api.example.com' },
})
JavaScript 的 Object.create() 是原型模式的原生支持——新对象直接拿老对象当原型链。
const animal = {
speak() {
return `${this.name} makes a sound`
},
}
const dog = Object.create(animal)
dog.name = '狗'
dog.speak() // "狗 makes a sound"
Object.create 不调用构造函数,不复制属性——它只是在两个对象之间搭了一座原型链的桥。
什么时候不该用: 原型模式的好处是快(不用走构造函数),但代价是你的对象之间有了隐式的继承关系。当你需要清晰地知道一个对象有哪些属性的时候,原型链会让调试变得痛苦。大多数情况下,Object.assign 或展开运算符的浅拷贝就够了。
结构型:怎么把零件拼成一台能跑的机器
创建型管"生产零件",结构型管"把零件装起来"。
代理模式
你去一家大公司找技术总监谈合作。你不会直接推开他办公室的门——你会先经过前台。前台确认你的身份,登记来访目的,然后打电话帮他问一下"现在方便吗",最后才放你进去。
代理就是代码里的前台。
Vue 3 的 reactive() 系统是代理模式最精彩的例子:
const raw = { count: 0 }
const state = new Proxy(raw, {
get(target, key) {
console.log(`有人在读 ${String(key)}`)
track(target, key) // 收集依赖
return target[key]
},
set(target, key, value) {
console.log(`有人在改 ${String(key)}:${value}`)
target[key] = value
trigger(target, key) // 通知依赖更新
return true
},
})
state.count // 控制台:有人在读 count
state.count = 1 // 控制台:有人在改 count:1
你在用 state.count,你以为你在直接读写一个对象。实际上每一次读写都经过了 Proxy 这个前台——它在你"进门"的时候自动登记(track),在"离开"的时候自动通知(trigger)。你完全感觉不到它的存在,但它的工作一刻都没停。
图片懒加载也是代理模式的典型场景:
function lazyLoadImage(src: string): HTMLImageElement {
const img = new Image()
const proxy = new Proxy(img, {
set(target, key, value) {
if (key === 'src') {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
target.src = value // 真正进入视口才加载
observer.disconnect()
}
})
observer.observe(target)
return true
}
target[key] = value
return true
},
})
return proxy
}
什么时候不该用: Proxy 有性能损耗。如果只是简单的值读写,没有必要包一层。代理增加了间接性——调试时你会发现调用栈里多了一层 Proxy handler,排查起来更费劲。
装饰器模式
你有台新买的手机。你嫌它光着太滑,给它套了个壳。手机还是那个手机,功能一个不少,但多了一层——防摔。
装饰器就是这个逻辑:不改动原始对象,在它外面包一层新能力。
TypeScript 的实验性装饰器语法:
function timeTrack(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
const original = descriptor.value
descriptor.value = function (...args: any[]) {
const start = performance.now()
const result = original.apply(this, args)
const end = performance.now()
console.log(`[${propertyKey}] 执行耗时: ${(end - start).toFixed(2)}ms`)
return result
}
return descriptor
}
class ApiClient {
@timeTrack
async fetchUserData(userId: string) {
return fetch(`/api/users/${userId}`).then((r) => r.json())
}
}
const client = new ApiClient()
client.fetchUserData('123') // 控制台:[fetchUserData] 执行耗时: 234.56ms
你没有改 fetchUserData 的源代码,但你给它加了一个"计时"的能力。就像手机壳——手机还是那个手机,但现在多了一层安全感。
在 React 里,高阶组件(HOC)就是装饰器的 React 版本:
function withLoading<P>(Component: React.ComponentType<P>) {
return function LoadingWrapper(props: P & { loading: boolean }) {
if (props.loading) return <Spinner />
return <Component {...props} />
}
}
const UserListWithLoading = withLoading(UserList)
UserList 不知道 loading 状态这回事。我们包了一层 HOC 给它加了 loading 能力。不污染原始组件。
什么时候不该用: 装饰器套多了就变成了洋葱——一层一层剥开,全是包装壳。如果一个组件同时装了 auth、loading、error、tracking 四个 HOC,调试的时候你得记住"当前这层是谁包进来的"。装饰器超过三层,就该考虑重构了。
适配器模式
你去日本旅游,带了国产的插头。到酒店傻眼了——人家的插座是两孔扁的,你的充电头是三孔的,插不进去。你不是把充电头拆了重焊,而是买一个转换头。
适配器就是这个转换头。
前端最常见的场景——后端 API 返回 snake_case,前端用的变量名是 camelCase:
// 后端返回
const apiResponse = {
user_id: 123,
first_name: 'Max',
last_name: 'Zhang',
created_at: '2026-03-23',
}
// 适配器
interface User {
userId: number
firstName: string
lastName: string
createdAt: string
}
class UserAdapter {
static adapt(raw: typeof apiResponse): User {
return {
userId: raw.user_id,
firstName: raw.first_name,
lastName: raw.last_name,
createdAt: raw.created_at,
}
}
}
const user: User = UserAdapter.adapt(apiResponse)
好处很明显——前端代码里永远用 firstName,后端格式变了只改适配器,不碰业务代码。
另一个典型场景:第三方库的接口跟你项目里的约定不一致。你用了一个旧版的图表库,它的 setData 接受 {x: number, y: number}[],但你项目里全用的是 {label: string, value: number}[]。写个适配器,就不用在每个调用点都转格式。
什么时候不该用: 如果后端格式稳定,并且你们的项目约定就是跟后端字段名保持一致——那就直接用,别写适配器。适配器加了一层间接引用,增加代码量。只有当"不匹配"这个痛点是真实存在的,适配器才有价值。
外观模式
你拿起电视遥控器,按一下红色按钮,电视开了。你不用关心遥控器内部怎么发射红外信号、电视内部的电源模块怎么启动、信号接收器怎么解码——你只知道按一个按钮就行。
外观模式就是遥控器。
它提供一个简化的入口,把背后复杂的逻辑全藏起来。
Vue 的 computed 就是外观模式的绝佳例子:
const user = reactive({
firstName: 'Max',
lastName: 'Zhang',
settings: {
language: 'zh',
timezone: 'Asia/Shanghai',
},
})
// 没有外观:每次都要手动拼接
console.log(`${user.firstName} ${user.lastName}`)
// 有外观:一个属性搞定了背后的拼接逻辑
const displayName = computed(() => `${user.firstName} ${user.lastName}`)
console.log(displayName.value)
复杂的表单提交逻辑:
// 没有外观——每个操作都要知道这三步
async function submitFormRaw() {
const validated = await validateAll(formData)
if (!validated.success) return showErrors(validated.errors)
const sanitized = sanitizeInput(formData)
const result = await api.submit(sanitized)
if (result.error) return showErrorToast(result.error)
showSuccessToast('提交成功')
router.push('/dashboard')
}
// 有外观——一个方法,背后全干了
class FormFacade {
async submit() {
const validated = await validateAll(this.data)
if (!validated.success) return showErrors(validated.errors)
const sanitized = sanitizeInput(this.data)
const result = await api.submit(sanitized)
if (result.error) return showErrorToast(result.error)
showSuccessToast('提交成功')
router.push('/dashboard')
}
}
// 调用方只需要知道这一步
await formFacade.submit()
外观模式的意义不在于"封装"(如果只看封装的话,所有函数都是封装),而在于把多个子系统的交互收敛到一个固定的入口。如果将来表单验证逻辑从 validateAll 换成了别的库,调用方不用知道——改外观内部即可。
什么时候不该用: 外观容易变成"上帝对象"——一个类什么都干,回到我们开头说的八百行组件的问题。如果你的外观开始需要传 10 个参数来配置行为,它就失去了"简化入口"的意义。外观模式的好处是简单,代价是灵活性——你把复杂逻辑封在遥控器里了,但有些人就是想拆开电视机自己调。
行为型:对象之间怎么沟通
创建型管"造人",结构型管"组队",行为型管"队伍里的人怎么喊话"。
观察者模式
你关注了一个微信公众号。它发新文章的时候,你是被动收到的——你不用每隔五分钟刷一下"发了没",它自己会通知你。
观察者模式就是这个逻辑:一个对象(公众号)维护一个粉丝名单,状态变化了就逐个喊醒。
class Subject {
private observers: Array<(data: any) => void> = []
subscribe(fn: (data: any) => void) {
this.observers.push(fn)
return () => {
this.observers = this.observers.filter((o) => o !== fn)
}
}
notify(data: any) {
this.observers.forEach((fn) => fn(data))
}
}
// 使用
const userStore = new Subject()
// 小李关注了用户变更
const unsubscribe = userStore.subscribe((data) => {
console.log('小李收到通知:', data)
})
userStore.notify({ name: 'Max', action: 'login' }) // 小李收到通知
unsubscribe() // 小李取关了
Vue 的响应式系统底层就是观察者模式。computed 属性是一个观察者,它订阅所有被读到的 ref/reactive。当任何一个依赖变化时,computed 收到通知,重新计算值。
React 的 useEffect 也是观察者思想的体现——依赖数组就是你的"订阅列表",cleanup 函数就是"取关"。
什么时候不该用: 观察者模式让"通知"变简单,也让"调试"变困难。当 20 个观察者同时响应一个事件时,你的调用栈会变成一坨意大利面。而且如果某个观察者的回调里又触发了另一个通知,很容易形成事件循环。如果你只需要一个接收方,用回调函数,别套观察者模式。
发布订阅模式
读到这儿,你可能会想:观察者和发布订阅有什么他妈的区别?
好问题。它们太像了,我第一次学的时候也搞混过。
区别就一句话:观察者模式里,被观察者直接认识所有观察者。发布订阅里,两边互相不认识——中间有个叫"事件中心"的第三方。
class EventBus {
private events: Map<string, Array<(data: any) => void>> = new Map()
on(event: string, fn: (data: any) => void) {
if (!this.events.has(event)) this.events.set(event, [])
this.events.get(event)!.push(fn)
}
off(event: string, fn: (data: any) => void) {
const fns = this.events.get(event)
if (fns)
this.events.set(
event,
fns.filter((f) => f !== fn),
)
}
emit(event: string, data: any) {
this.events.get(event)?.forEach((fn) => fn(data))
}
}
// 使用——两个人隔空喊话
const bus = new EventBus()
// 组件A:我发布这个事件,但不知道谁会收到
bus.emit('user-login', { userId: 123 })
// 组件B:我订阅这个事件,但不知道是谁发布的
bus.on('user-login', (data) => {
console.log('组件B收到登录事件', data)
})
类比: 观察者是公众号直接发给粉丝(作者→读者,直接关系)。发布订阅是你在淘宝的发货系统里下了单——卖家发货,物流公司通知你到货。卖家不认识你,你不认识卖家,中间有淘宝这个"事件中心"在做路由。
Vue 2 的 $on、$emit 就是发布订阅模式的体现。兄弟组件通信不用通过父组件——事件总线搞定。
什么时候不该用: 事件总线是一个全局共享的"喊话器",时间长了你会不知道哪些组件在监听哪些事件。重构的时候,你怕删掉一个 emit 会影响到不知道藏在哪个角落的 on。如果你的组件关系是明确的父子关系,props + emit 比事件总线清楚得多。发布订阅适合松耦合的跨层级通信,不是让你绕过组件结构用的。
策略模式
你要从北京去上海。坐飞机?高铁?开车?每一种都是一套"策略"。你选不同的策略,但目的地不变。
策略模式把"算法"和"使用算法的上下文"分开——你用一个入口 formValidator,背后根据不同的字段类型选择不同的校验策略。
interface ValidationStrategy {
validate(value: any): string | null
}
const strategies: Record<string, ValidationStrategy> = {
isNonEmpty: {
validate(value: string) {
return value.trim() ? null : '不能为空'
},
},
isEmail: {
validate(value: string) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? null : '邮箱格式不正确'
},
},
isPhone: {
validate(value: string) {
return /^1[3-9]\d{9}$/.test(value) ? null : '手机号格式不正确'
},
},
minLength: {
validate(value: string, min: number = 6) {
return value.length >= min ? null : `最少 ${min} 个字符`
},
},
}
class FormValidator {
private checks: Array<{ strategy: ValidationStrategy; value: any; params?: any }> = []
add(value: any, name: string, params?: any) {
const strategy = strategies[name]
if (!strategy) throw new Error(`未知的校验规则: ${name}`)
this.checks.push({ strategy, value, params })
}
validate(): string | null {
for (const { strategy, value, params } of this.checks) {
const error = strategy.validate(value, params)
if (error) return error
}
return null
}
}
const validator = new FormValidator()
validator.add('13800138000', 'isPhone')
validator.add('a@b.com', 'isEmail')
validator.add('abc', 'minLength', 6)
console.log(validator.validate()) // "最少 6 个字符"
如果没有策略模式,你会怎么写?一个巨大的 if-else:
if (rule === 'isNonEmpty' && !value.trim()) return '不能为空'
else if (rule === 'isEmail' && !isEmailRegex.test(value)) return '邮箱格式不正确'
else if (rule === 'isPhone' && !isPhoneRegex.test(value)) return '手机号格式不正确'
// 五十行之后...
产品经理想加一个"身份证号校验"。策略模式:在 strategies 里加一条规则。if-else 模式:在一坨条件判断里找到合适的位置,插一脚,祈祷不改坏别的东西。
什么时候不该用: 策略只有两种的时候,不需要。校验规则只有"必填"和"邮箱"——直接写就行,套策略模式是过度设计。策略模式的价值在于"策略数量会持续增长"。
迭代器模式
不管你拿的是什么遥控器——空调的、电视的、机顶盒的——上面的"频道+"和"频道-"按钮,工作方式一模一样。按一下,下一个。不管背后是走有线电视信号还是网络流,操作方式不变。
迭代器模式就是这个:不管你背后是什么数据结构,遍历的方式是统一的。
JavaScript 的 Symbol.iterator 是语言内置的迭代器:
const myIterable = {
data: ['第一页', '第二页', '第三页'],
[Symbol.iterator]() {
let i = 0
return {
next: () => {
if (i < this.data.length) {
return { value: this.data[i++], done: false }
}
return { value: undefined, done: true }
},
}
},
}
for (const item of myIterable) {
console.log(item) // 第一页 / 第二页 / 第三页
}
有了 Symbol.iterator,你的自定义对象就能用 for...of、展开运算符、解构——跟原生数组一样。
实际场景:处理树形结构的遍历。
class TreeNode {
value: number
children: TreeNode[]
constructor(value: number, children: TreeNode[] = []) {
this.value = value
this.children = children
}
*[Symbol.iterator](): Generator<number> {
yield this.value
for (const child of this.children) {
yield* child // 递归迭代子节点——深度优先遍历
}
}
}
const tree = new TreeNode(1, [new TreeNode(2, [new TreeNode(4), new TreeNode(5)]), new TreeNode(3)])
for (const value of tree) {
console.log(value) // 1, 2, 4, 5, 3
}
一棵树,用 for...of 就能遍历。不管这棵树的结构多深——迭代器帮你在背后做完了深度优先。
前端里 DOM 的 NodeList、HTMLCollection,Map、Set——全是可迭代的。不管它们的数据结构多不同,你都可以用 for...of 统一遍历。
什么时候不该用: 如果只需要遍历一次,直接写 array.forEach() 就行,不用自己实现迭代器。迭代器有价值的地方是"你希望把遍历逻辑从业务逻辑里抽出来",让遍历本身成为一种可复用的工具。
退后三步:整幅画长什么样
把上面的所有内容收束起来,设计模式的世界就三层:
创建型(谁造)
├── 工厂:一句话点菜,别自己进后厨
├── 单例:整个系统只有一个的声音
└── 原型:拿蓝图克隆,别重画
结构型(怎么装)
├── 代理:前台帮你挡一下
├── 装饰器:套个手机壳,功能不变
├── 适配器:换个转换头,两边能用
└── 外观:给你一个遥控器,别管背后多复杂
行为型(怎么聊)
├── 观察者:关注公众号,发文自动推
├── 发布订阅:两人隔空喊话,中间有个传话筒
├── 策略:去北京可以坐飞机也可以高铁
└── 迭代器:不管什么设备,频道键都一样
你不需要背这 23 种模式。你只需要在面对一个混乱的代码块时问自己三个问题:
1. 这个问题是"谁创建对象"的问题吗? ——是的话,工厂、单例、原型里选。
2. 这个问题是"怎么把对象拼起来"的问题吗? ——是的话,代理、装饰器、适配器、外观里选。
3. 这个问题是"对象怎么互相通知和协调"的问题吗? ——是的话,观察者、发布订阅、策略、迭代器里选。
如果三个都不是?
那这个地方可能不需要设计模式。
FAQs——那些让你卡住的问题
Q: 我需要把 23 种全部背下来吗?
不需要。我写了这么多年前端,一个策略模式写出来之前脑子里根本不过"这是策略模式"这五个字。我是在写完之后突然意识到——哦,这就是策略模式。你不需要背,你需要在看到一段代码的时候能认出来"这是观察者"、"这是适配器"、"这是单例"。名字不重要,结构重要。
Q: 怎么判断该用哪种模式?
上面三个问题先问一遍。如果还是不确定——别用。先写传统的 if-else 和直接的函数调用。等代码开始刺痛你的时候,你自然知道该在哪里抽模式。
Q: 设计模式会不会让代码更复杂?
会。一定会。
所以有 YAGNI 原则——You Aren't Gonna Need It。你可能不需要它。在代码还简单的时候,别急着引入模式。一个 50 行的工具函数,用不上工厂。一个父子组件通信,用不上发布订阅。
设计模式是你代码变丑之后才请的装修队,不是刚搬进毛坯房就贴金箔。
写到这里,窗外已经完全黑了。
其实我想通这件事花了好几年。设计模式一开始对我来说就是面试题的答案——背两句 UML,记几个类图,面完就忘。直到我的代码变得足够恶心,恶心得连我自己都不想维护的时候,那些模式的名字突然全部活过来了。
它们说的就是我现在写的这坨屎。
只不过它们把"屎"叫做"反模式"。
希望下次你在一个八百行的 user-profile.tsx 面前举起双手的时候,能在心里默念一遍那三个问题。然后,你会知道从哪里下手。
这比背 23 种模式的名字重要得多。
读者来信
暂无来信,期待你的分享。