Max
搜索
返回故事会

前端工程化中的设计模式实战全指南

42 分钟阅读0Max ZhangFrontend
设计模式SOLIDVueReactTypeScript

那段时间我每天上班第一件事,就是打开那个八百多行的 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[] // 列表页不需要
}

就一个简单的用户列表页,只需要 nameavatar,但你不能不用这个臃肿的 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}`)
  }
}

这段代码的问题在哪?UserServiceMySQLDatabase 焊死了。哪天你想换 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 接口就是这个插座规范,MySQLDatabasePostgresDatabase 是火力发电站和水力发电站。电器(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 的 NodeListHTMLCollection,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 种模式的名字重要得多。

读者来信

0/1000

暂无来信,期待你的分享。