设计模式这东西,放在前端圈子里一直有点尴尬。你说它重要吧,面试必问;你说它实用吧,很多人写了好几年业务代码,感觉一个都没用过。我自己带团队这几年感受特别深:真正的分水岭不是谁背得熟23种GoF设计模式,而是谁能在遇到"这个需求怎么改都难受"的时候,条件反射地想到某个模式能破局。这篇东西我不想写成教科书,就按前端日常开发的真实场景来拆,把那些真正高频、真正解决过问题的模式讲透,顺带聊聊面试官到底想听到什么。
1. 为什么前端工程师需要设计模式:从八股文到架构思维
1.1 面试题背后藏的真实诉求
先看一个特别典型的面试场景。面试官问"讲讲观察者模式和发布订阅模式的区别",很多人第一反应是背定义:观察者是目标维护观察者列表,发布订阅有事件通道巴拉巴拉。这当然没错,但你有没有想过,面试官真正想确认的是什么?
其实他大概率是在评估:一个候选人接到复杂交互需求时,能不能不把代码写成意大利面。以我参与过的面试流程来说,前端岗位的考察重心和Java后端完全是两码事。后端考设计模式,考的是你懂不懂Sprint框架、能不能把服务拆出合理的边界;前端考设计模式,考的是你面对状态管理、组件通信、异步请求这些高频场景时,有没有一套可复用的组织思路。所以与其背概念,不如直接准备几个自己写过的真实例子,讲清楚当时遇到了什么困境、为什么选这个模式、效果怎么样。
1.2 前端场景的特殊性:和教科书里的模式不是一回事
还有一个很常见的误区,就是以为GoF那23种模式要全部学一遍。我自己的体会是,前端场景下高频用到的就那十几个,而且很多模式在使用时会因为JavaScript的特性产生变形。
举个最典型的例子:观察者模式。教科书里的结构是Subject、Observer各自是一个类,但在JavaScript里,一个函数加上一个数组就能实现同样的能力。更麻烦的是,前端里事件机制太常见了,DOM事件、自定义事件、EventEmitter、Redux、Vue的$emit、React的useEffect依赖收集,底层全是观察者思想的变体。你说它不经典?不可能。但你要死记硬背那个类结构,放到真实业务里反而不知道怎么用。
这也是为什么我一直建议大家带着"前端视角"去学设计模式。每个模式都要问三个问题:它解决了什么痛点?在JavaScript/TypeScript里怎么写最自然?在不用的框架里有什么替代方案?这三个问题想清楚,设计模式就不再是面试八股,而是真正长在自己身上的架构能力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 创建型模式在前端的落地:单例、工厂与建造者
2.1 单例模式:不只是全局变量,而是受控的全局入口
单例模式可能是前端里最常被无意识使用、又最容易被误解的模式。它的核心诉求是:保证整个应用生命周期内,某个类只有一个实例,并提供一个全局访问点。
很多新人会觉得,那不就是全局变量吗?我把一个对象挂到window上不就完了?严格来说,单例模式确实是对全局变量的规范化和受控化,但两者的差别在于:单例保证了实例状态的一致性,同时延迟了实例化时机,还能在这个唯一的入口上附加初始化逻辑。
前端最常见的使用场景有几个:
- 全局状态管理:如果不用Redux或Pinia这些库,自己封装一个Store时,天然就应该用单例模式。多个模块引入同一个store实例,才能读到同一份状态。
- 缓存管理:比如接口缓存,第一次请求后把数据缓存下来,后续请求直接读缓存,前提是缓存对象全局唯一。
- 全局配置:比如上报SDK的初始化配置,整个页面应该只有一份。
用一个简单的TypeScript实现来看:
typescript复制class Store {
private static instance: Store | null = null
private state: Record<string, any> = {}
private constructor() {
// 私有构造函数,防止外部 new
}
static getInstance(): Store {
if (!Store.instance) {
Store.instance = new Store()
}
return Store.instance
}
set(key: string, value: any) {
this.state[key] = value
}
get(key: string) {
return this.state[key]
}
}
// 使用
const store1 = Store.getInstance()
const store2 = Store.getInstance()
console.log(store1 === store2) // true
这里构造函数是私有的,外部没法直接new,只能通过getInstance获取,从而保证全局唯一。
不过我得说一句实在话:在现代前端工程化体系里,单例模式正在被模块系统天然替代。ES Module本身就有缓存机制,一个模块只会被初始化一次,后续import拿到的都是同一个引用。所以很多时候你不需要手写单例类,直接写一个模块级对象导出就够了:
typescript复制// store.ts
export const store = {
state: new Map(),
set(key: string, value: any) {
this.state.set(key, value)
},
get(key: string) {
return this.state.get(key)
}
}
模块就是天然的全局访问点,又是受控的。这种"模块即单例"的做法,比手写getInstance类更符合前端工程习惯。
2.2 工厂模式:把对象创建的复杂度封装起来
工厂模式解决的核心痛点是:当创建对象的逻辑变得复杂,或者需要根据条件创建不同对象时,把new的逻辑散落在各处会让代码难以维护。
我印象最深的一个案例是表单组件里的校验器。当时做的是一个低代码表单平台,字段类型有十几种,每种类型的组件、校验规则、默认值都不一样。最开始代码是这么写的:
typescript复制const createField = (type: string, config: FieldConfig) => {
if (type === 'input') {
return new InputField(config)
} else if (type === 'select') {
return new SelectField(config)
} else if (type === 'date') {
return new DateField(config)
}
// ... 十几种类型全堆在这里
}
这个函数肉眼可见地会越来越膨胀,而且每次新增类型都要改这个函数,违反了开闭原则。用简单工厂优化之后,把创建逻辑拆到各个类型自己的工厂函数里:
typescript复制class InputFieldFactory {
create(config: FieldConfig) {
return new InputField({ ...defaultInputConfig, ...config })
}
}
const fieldFactories: Record<string, FieldFactory> = {
input: new InputFieldFactory(),
select: new SelectFieldFactory(),
date: new DateFieldFactory(),
}
const createField = (type: string, config: FieldConfig) => {
const factory = fieldFactories[type]
i
