设计模式这东西,放在前端圈子里一直有点尴尬。你说它重要吧,面试必问;你说它实用吧,很多人写了好几年业务代码,感觉一个都没用过。我自己带团队这几年感受特别深:真正的分水岭不是谁背得熟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]
if (!factory) {
throw new Error(`Unknown field type: ${type}`)
}
return factory.create(config)
}
这样每新增一种字段类型,只需要注册一个新的Factory,不需要动已有的逻辑。
工厂模式在前端的应用场景其实比想象中多:
- 请求适配:根据环境不同创建不同的HttpClient实例。
- 组件渲染:根据数据中的type字段渲染不同组件,这在表单、消息中心、动态列表里非常常见。
- 图标渲染:根据图标名创建对应的SVG组件,很多组件库内部就是这么干的。
这里想多说一句,工厂模式和策略模式在写法上有点像,都是"根据条件选一个东西出来",但本质不同:工厂选的是一个对象创建策略,解决"怎么new出来"的问题;策略选的是一个算法实现,解决"怎么执行"的问题。两者经常会配合使用,别搞混。
2.3 建造者模式:当构造函数参数多到怀疑人生
建造者模式这个名字一听就很Java,但在前端也不是没用武之地。它解决的问题是:当一个对象的构造函数参数过多、且很多参数有默认值时,直接new会因为参数顺序和可选参数的存在变得极度难读。
实际场景里,最典型的例子是封装一个请求配置对象。假设你有一个请求函数,要支持url、method、headers、params、data、timeout、withCredentials、transformRequest等一系列配置:
typescript复制// 如果用构造函数直接传,调用方根本记不住参数顺序
const request = new Request(
'https://api.example.com',
'GET',
{ 'Content-Type': 'application/json' },
null,
undefined,
5000,
true,
null
)
这种代码写起来想死,读起来更想死。建造者模式的思路是把参数设置拆成一个个链式方法,让代码像读文章一样顺:
typescript复制class RequestBuilder {
private config: RequestConfig = {
method: 'GET',
timeout: 5000,
withCredentials: false,
}
url(url: string) {
this.config.url = url
return this
}
method(method: HttpMethod) {
this.config.method = method
return this
}
headers(headers: Record<string, string>) {
Object.assign(this.config.headers, headers)
return this
}
timeout(ms: number) {
this.config.timeout = ms
return this
}
build(): RequestConfig {
return this.config
}
}
// 使用
const config = new RequestBuilder()
.url('https://api.example.com')
.method('POST')
.headers({ 'Content-Type': 'application/json' })
.timeout(10000)
.build()
前端很多配置类的库,底层其实就是这种思想,只不过用参数对象的方式实现了。比如axios的配置就是这样,你传一个对象进去,它内部做深度合并。对象参数本身就是一种简化版的建造者模式——牺牲了链式调用的可读性,换来了更少的代码量。
3. 结构型模式:组件设计中的组合艺术
3.1 装饰器模式:高阶组件与函数增强
装饰器模式的核心思想是:在不修改原对象的情况下,给对象动态添加职责。这个思想在前端可以说是遍地开花,最典型的两个体现就是高阶组件(HOC)和高阶函数。
先看一个实际场景。管理后台里,有多个页面需要权限校验,没有权限就跳转到登录页。最常见、最朴素的写法是在每个组件的useEffect里手动判断权限:
tsx复制const AdminPage = () => {
useEffect(() => {
const hasPermission = checkPermission('admin')
if (!hasPermission) {
navigate('/login')
}
}, [])
return <div>管理员内容</div>
}
这段逻辑如果复制到十几个页面,哪一天权限判断逻辑变了,要改十几个文件。用装饰器思想,把权限校验逻辑抽成一个高阶组件:
tsx复制const withPermission = (WrappedComponent: React.ComponentType, requiredRole: string) => {
return function PermissionHOC(props: any) {
const hasPermission = checkPermission(requiredRole)
if (!hasPermission) {
return <Navigate to="/login" replace />
}
return <WrappedComponent {...props} />
}
}
// 使用
const AdminPageWithPermission = withPermission(AdminPage, 'admin')
这样哪里需要权限,套一层就够了,原组件不需要知道权限逻辑的存在。
前端里装饰器模式的典型应用远不止权限控制:
- 埋点上报:withTracking包裹组件,自动上报组件曝光和点击。
- 性能监控:包裹组件,记录渲染耗时。
- 错误边界:withErrorBoundary包裹组件,统一处理渲染错误。
- 日志增强:高阶函数,给原有函数加上日志输出。
我在团队里见过大量的函数增强场景,其实不需要装额外的库,一个with开头的高阶函数就解决了。比如给异步请求函数加缓存:
typescript复制const withCache = (fn: Function, cacheKey: string) => {
const cache = new Map()
return async (...args: any[]) => {
const key = `${cacheKey}_${JSON.stringify(args)}`
if (cache.has(key)) {
return cache.get(key)
}
const result = await fn(...args)
cache.set(key, result)
return result
}
}
装饰器模式相对继承的优势很好理解:继承是静态的、单条的链路,而装饰是动态的、可叠加的。一个组件既需要权限校验又需要埋点,用装饰器可以叠加两层;用继承就很麻烦了,总不能搞一个AdminPageWithPermissionAndTracking吧。
不过React社区这几年也在反思HOC方式,因为层层包裹会带来嵌套地狱和props命名冲突的问题。于是有了Render Props,又有了Hooks。但注意,Hooks和装饰器不是非此即彼的关系,Hooks解决的是有状态逻辑的复用,装饰器解决的是职责的附加。两者可以共存,实际项目里非常常见的组合是"外层用HOC拿到配置,组件内部用Hooks处理细节"。
3.2 适配器模式:新老接口的桥
适配器模式解决的是接口不兼容的问题:让原本因为接口不同而无法协作的类/对象能够一起工作。前端完美应用中,最高频的场景就是数据格式适配。
后端给你返回的数据,和前端组件需要的数据,往往不是同一个格式。比如后端返回:
javascript复制{
"user_name": "张三",
"user_age": 25,
"role_id": 3
}
但前端表格组件需要的是:
javascript复制{
"name": "张三",
"age": 25,
"role": {
"id": 3
}
}
没有适配层的话,你只能在每个使用这个数据的地方手动转换,一旦后端改了字段名,全项目跟着遭殃。更合理的做法是在接口层统一做一次适配:
javascript复制const adaptUserData = (rawData) => ({
name: rawData.user_name,
age: rawData.user_age,
role: { id: rawData.role_id },
})
这是最简单形式的适配。复杂一点的场景,比如你要在代码里对接一个第三方地图SDK,但SDK的API风格和你自己封装的map工具类完全不同,这时候适配器可以帮你隔离这种差异:
typescript复制class MapSDKAdapter {
private sdk: ThirdPartySDK
constructor(sdk: ThirdPartySDK) {
this.sdk = sdk
}
showMap(element: HTMLElement) {
return this.sdk.display(element)
}
addMarker(lat: number, lng: number) {
return this.sdk.putPin({ lat, lng })
}
setCenter(lat: number, lng: number) {
this.sdk.moveTo(lat, lng)
}
zoom(level: number) {
this.sdk.scale(level)
}
}
这样业务代码只需要依赖一个稳定、统一的MapAdapter接口,底层到底用的是百度还是高德还是Mapbox,对业务层不可见。哪天要替换地图服务商,只改Adapter内部实现,业务代码一行都不用动。这就是适配器模式的架构价值。
3.3 代理模式:不直接操纵,而是加一层中间层
代理模式的核心是:为对象提供一种代理,以控制对这个对象的访问。它和装饰器模式有点像,都是在不修改原对象的基础上加逻辑,但侧重点不一样:装饰器侧重"增强功能",代理侧重"控制访问"。
前端里用得最多的代理场景就是拦截器。axios拦截器本质上就是一套代理机制:你在真正发请求之前和拿到响应之后,插入一层逻辑,这层逻辑可以统一控制请求的进出。
typescript复制// axios 请求拦截器
axios.interceptors.request.use(
(config) => {
config.headers.Authorization = `Bearer ${localStorage.getItem('token')}`
return config
},
(error) => {
return Promise.reject(error)
}
)
// axios 响应拦截器
axios.interceptors.response.use(
(response) => {
if (response.data.code === 401) {
// 登录过期,跳转登录页
redirectToLogin()
return Promise.reject(new Error('Unauthorized'))
}
return response.data
},
(error) => {
// 统一错误提示
message.error(error.message)
return Promise.reject(error)
}
)
这种拦截能力,比在每个业务请求里手动加token、手动处理错误要优雅得多。核心逻辑只有一处,维护成本极低。
JavaScript语法层面也有一个原生的代理实现:Proxy对象。它可以在不改变原对象的前提下,拦截对对象属性的读取、赋值、删除等操作。Vue 3的响应式原理,底层就是靠Proxy实现的。手动实现一个简易版的状态代理:
javascript复制const createReactive = (target, onChange) => {
return new Proxy(target, {
get(obj, prop) {
return Reflect.get(obj, prop)
},
set(obj, prop, value) {
const result = Reflect.set(obj, prop, value)
onChange(prop, value)
return result
}
})
}
const state = { count: 0 }
const reactiveState = createReactive(state, (prop, value) => {
renderUI(prop, value)
})
reactiveState.count = 1 // 自动触发 UI 更新
代理模式在缓存场景里也很有用。比如图片懒加载、接口结果的缓存代理,思路都是一样的:先检查缓存,命中就返回缓存,没命中才去执行真正的操作。
这里有个我踩过的坑:用Proxy做代理时,如果对性能敏感的对象包装不当,会引入额外开销。Proxy的每个属性访问都有拦截成本,对大数组、高频读取的对象要谨慎使用,或者只在关键节点做代理,不要包一层巨大的对象。
4. 行为型模式:事件驱动与逻辑解耦
4.1 观察者模式与发布订阅:组件通信的底层逻辑
观察者模式在前端的地位,怎么强调都不为过。Vue的响应式系统、React的状态更新机制、Redux的dispatch与subscribe、EventTarget的addEventListener,全是观察者思想的具体实现。
为了说明白这个模式,我需要先讲清楚观察者模式和发布订阅模式的区别。很多人会混为一谈,实际上两者的关键差异是消息通道是否存在。
观察者模式里,目标对象直接维护观察者列表,状态变化时直接调用观察者的方法,目标知道观察者的存在。而发布订阅模式中,发布者和订阅者互不知晓,中间有一个消息通道(事件总线)在调度。用代码对比一下:
typescript复制// 观察者模式:目标直接通知观察者
class Subject {
private observers: Observer[] = []
addObserver(observer: Observer) {
this.observers.push(observer)
}
notify() {
this.observers.forEach(observer => observer.update())
}
}
// 发布订阅模式:事件总线转发
class EventBus {
private events: Record<string, Function[]> = {}
on(eventName: string, callback: Function) {
if (!this.events[eventName]) {
this.events[eventName] = []
}
this.events[eventName].push(callback)
}
emit(eventName: string, data: any) {
this.events[eventName]?.forEach(callback => callback(data))
}
}
在真实的前端业务中,发布订阅模式的实用性要更广,因为它彻底解耦了生产者和消费者。跨组件通信、跨模块通信、兄弟组件通信,都可以用一个EventBus解决。但这里我想加一句忠告:别滥用。事件驱动的代码特点是"跳",逻辑分散在各处的订阅者中,一旦事件名没有规范管理,调试起来就是灾难。我在实际开发中看到过项目里EventBus事件满天飞,上线两天就后悔了。小项目可以用,一旦业务复杂起来了,一定要上状态管理库来约束数据流,而不是靠事件总线散弹式沟通。
4.2 策略模式:消灭if-else的利器
策略模式的核心是:定义一组算法,把它们各自封装起来,并且在运行时可以互相替换。前端里最常见的if-else地狱,大部分情况都能用策略模式来解决。
举个例子,多端项目的分享功能。同样的分享入口,在不同平台上要调用的分享逻辑完全不同:
typescript复制// 优化前:大量 if-else
const shareTo = (platform, title, url) => {
if (platform === 'wechat') {
// 调用微信 SDK
wxShare(title, url)
} else if (platform === 'weibo') {
// 调用微博 SDK
weiboShare(title, url)
} else if (platform === 'qq') {
// 调用 QQ SDK
qqShare(title, url)
} else if (platform === 'telegram') {
// 调用 Telegram SDK
telegramShare(title, url)
}
}
用策略模式重构后,每个平台是一个策略,通过注册表选择策略:
typescript复制const shareStrategies = {
wechat: (title: string, url: string) => wxShare(title, url),
weibo: (title: string, url: string) => weiboShare(title, url),
qq: (title: string, url: string) => qqShare(title, url),
telegram: (title: string, url: string) => telegramShare(title, url),
}
const shareTo = (platform: string, title: string, url: string) => {
const strategy = shareStrategies[platform]
if (!strategy) {
throw new Error(`Unsupported platform: ${platform}`)
}
return strategy(title, url)
}
这样新增一个平台,只需要在shareStrategies里加一项,不需要改动shareTo的逻辑。策略模式另一个很适合在前端落地的场景是表单校验。各种校验规则本身就是一个个策略:
typescript复制const validators = {
required: (value: string) => (value.trim() ? '' : '该字段必填'),
minLength: (min: number) => (value: string) =>
value.length >= min ? '' : `最少输入${min}个字符`,
isEmail: (value: string) =>
/^\S+@\S+\.\S+$/.test(value) ? '' : '邮箱格式不正确',
isPhone: (value: string) =>
/^1[3-9]\d{9}$/.test(value) ? '' : '手机号格式不正确',
}
校验规则以策略对象的方式组织,可复用、可组合。以后要加规则,加一个函数就行。
4.3 状态模式与状态机:把复杂状态转移变成可视化逻辑
状态模式的核心思想是:当一个对象的内部状态改变时,它的行为也随之改变。它把每个状态的行为封装到独立的状态类中,避免了大量的if-else来判断当前状态。
前端里状态机最经典的场景就是订单生命周期管理。订单有创建、待支付、已支付、已发货、已完成、已取消等状态,每个状态下能执行的操作完全不同:
- 待支付:可以取消,可以支付
- 已支付:可以申请退款、等待发货
- 已发货:可以确认收货
- 已完成:可以评价
- 已取消:不能再做任何操作
如果用布尔变量维护状态,代码会迅速腐化:
typescript复制// 反例:状态一多就无法维护
if (isPaid && !isShipped && !isCancelled) {
// ...
}
正确做法是显式地建模状态机。可以用XState这样的专业状态机库,也可以根据复杂程度手写一个简洁的状态转移表:
typescript复制const orderStateMachine = {
init: 'pending',
states: {
pending: {
events: {
PAY: { target: 'paid', action: 'onPaid' },
CANCEL: { target: 'cancelled', action: 'onCancelled' },
},
},
paid: {
events: {
SHIP: { target: 'shipped', action: 'onShipped' },
REFUND: { target: 'refunded', action: 'onRefunded' },
},
},
shipped: {
events: {
CONFIRM: { target: 'completed', action: 'onCompleted' },
},
},
completed: { events: {} },
cancelled: { events: {} },
refunded: { events: {} },
},
transition(currentState: string, event: string) {
const stateDefinition = this.states[currentState]
const eventDefinition = stateDefinition.events[event]
if (!eventDefinition) {
throw new Error(`事件 ${event} 在状态 ${currentState} 下不可执行`)
}
return eventDefinition.target
},
}
这样状态转移的合法性是显式的、集中式的,任何时候都知道"当前状态能不能执行这个操作"。这比零散的if判断要强得多——逻辑是数学意义上可验证的,测试也容易写。
我还想提一嘴,状态机思想在跨端开发现出越来越重要。比如在最新的多agent框架设计里,主从模式本质上是把子agent看作一种特殊的工具调用,而工具调用的生命周期——等待、运行、完成、失败——就是一个典型的状态机(更准确地说,是一个有限状态机)。前端工程师如果能把状态机思维内化,做动画切换、流程编排、机器人的对话管理,都会顺很多。我建议应届生或初级前端,至少写一版基于状态转移表的简单状态机,收获非常大。
5. 前端特有模式的演进:从MVC到Hooks的思维方式
5.1 组件设计中的模式组合
设计模式很少单打独斗,到了组件设计层面更是如此。一个健壮的组件,往往是多个模式协同的结果:外部用工厂模式创建不同形状的组件,内部用策略模式处理不同逻辑路径,状态变化用观察者/发布订阅通知外部,渲染层面用装饰器包装公共逻辑。
这里面最经典的一种组合理念是容器组件与展示组件的拆分,它本质上是对职责分离的一种体现。容器组件负责获取数据、管理状态、处理交互逻辑;展示组件只负责接收props并把界面渲染出来。当这种拆分做到位,展示组件的复用性会大大提升,容器组件也可以独立测试。
在React里,这种拆分和Hooks配合得非常好。容器组件的逻辑可以用自定义Hooks提取出来:useUser、useList、usePermission,这样Container组件只负责编排Hooks,展示组件更纯粹。组件库的开发更是如此,内部设计时就会考虑"把模式揉进API":用户学习成本低,扩展也方便。比如一个弹窗组件,用策略模式处理不同状态的按钮,用工厂模式处理不同场景下的构建方式,用发布订阅模式做onOpen/onClose的对外通知。
5.2 Hooks如何改变设计模式的表达方式
很多人学设计模式时有个困惑:React都进入Hooks时代了,怎么这模式还在讲类?其实Hooks不是让设计模式消失,而是改变了设计模式的表达方式。
在Class时代,状态逻辑复用靠HOC或Render Props,也就是装饰器模式和代理模式的具象。在Hooks时代,同样的能力被重新组织成了自定义Hooks。比如之前讲的withPermission高阶组件,用Hooks可以改造成:
typescript复制const usePermission = (requiredRole: string) => {
const hasPermission = checkPermission(requiredRole)
return hasPermission
}
const AdminPage = () => {
const hasPermission = usePermission('admin')
if (!hasPermission) {
return <Navigate to="/login" replace />
}
return <div>管理员内容</div>
}
写法上确实简洁多了,但底层仍然是"控制访问"的思想,只是把控制点从组件包裹层下沉到了函数内部。
同样的道理,观察者模式在Hooks时代的体现是useEffect加依赖收集。Vue 3的watchEffect和React的useEffect,都是让框架替你维护观察者列表,而你的回调函数就是那个自动被收集的观察者。你不需要手动addObserver、removeObserver了,框架在背后做了这一切。但如果你理解了观察者模式的本质,调试依赖收集问题时就会思路清晰很多。
5.3 主从模式与新架构思维
前面提过,最新的多agent设计里流行主从模式。如果抽离出场景,它的本质是:一个主控单元负责任务的编排和决策,把复杂的子任务分发给各从属单元,从属单元执行完再汇报结果。这套思想和前端里很多架构模式是相通的。
比如微前端里的主应用与子应用:
- 主应用负责登录态、导航、公共依赖,这就是"主"。
- 子应用各自负责一个业务域,独立开发、独立部署,这就是"从"。
主从模式的核心价值在于"让每个单元保持简单"。主控只做任务分发,不关心执行细节;从属只做执行,不关心总目标。我实际做微前端和BFF层时,体会最深的是:把任务拆清楚比代码本身重要得多。拆不清楚的话,不管用什么模式都救不了。
前端的设计模式知识不能停在"背"上,要提炼成这套"谁负责什么"的思维。碰到一个复杂需求,第一反应不是"我要用观察者模式",而是"这个系统里有几个角色、各自要维护什么状态、状态之间怎么通信"。想清楚这些,你会发现很多设计模式是自然涌现的,而不是硬套上去的。
6. 面试官真正想听到的:高频考点与回答思路
6.1 打通"八股文"和"项目经验"的桥梁
前端面试里设计模式常见的问法有这么几类:
- "介绍一下观察者模式和发布订阅模式的区别"——考察对事件机制的理解深度。
- "你在项目里用过哪些设计模式"——考察实战意识,很多人在这里翻车,因为答不出具体场景。
- "说说HOC和Hooks的关系"——考察对React演进的理解。
- "如果状态管理库不够用,你会怎么自己实现一个"——考察单例、观察者、代理等模式的综合应用。
回答的层次感很重要。如果只答了概念,面试官没法判断你是背的还是真的理解;如果只说场景但说不清原理,又会显得浮于表面。比较理想的框架是"概念+区别+场景+权衡"。以观察者模式为例:
首先说核心思想:定义对象间一对多的依赖关系,当一个对象改变状态时,所有依赖它的对象都会得到通知并自动更新。然后说和发布订阅的区别:观察者模式中目标知道观察者,发布订阅中间有事件通道两边互不认识。接着说项目里怎么用的:比如在组件库中,弹窗组件用事件机制通知外部打开和关闭;或者自己封装的Store里,用订阅列表维护数据变更监听。最后补一句权衡:直接依赖事件总线虽然解耦,但事件不直观、调试困难,所以小范围的事件可以用EventBus,大规模的数据流还是用Redux、Zustand之类的状态管理库更好。
这个问题答下来,面试官能明显感觉你是真的写过大项目的人,而不是在背八股。
6.2 在简历和项目复盘里如何体现设计模式思维
还有一个求职者经常忽略的点:设计模式思维不是要在面试时才临时组织,而是在写简历和复盘项目时要展示出来的。简历上写"使用了观察者模式实现了多组件间状态同步"就比"负责搭建项目前端架构"要有信息量得多。
我复盘自己这些年带的项目,发现评估一个前端工程师的设计模式掌握程度,最高效的办法就是看他处理过的三个场景:复杂表单、大型列表、音视频或实时通信页面。这几个场景都有天然的复杂性,做得好不好一眼就能看出来。表单适合用策略模式和工厂模式;列表适合用观察者模式和代理模式;实时页面适合用发布订阅和状态机。
如果你在自己的项目总结里能说出"我为什么没用Redux而是自己封装了一个三层结构:底层用单例维护状态、中间层用观察者通知订阅方、最外层用代理拦截赋值逻辑做数据校验",这套回答比背10篇文章都有说服力。
关于设计模式还有一个特别容易被忽视的维度:在团队规范里落地。设计模式不是一个人的事,如果团队里有同学用了完全不同的思路实现同样的功能,代码维护成本会指数级上升。所以我现在设计项目时,会先跟团队约定:哪些场景约定用什么样的模式。这个动作本身,比纠结"要不要用某一个具体模式"重要得多。前端工程化的最高境界,从来不是用了多少模式,而是通过模式形成了可以预判的代码结构,让新人进来也能快速上手。
我个人这几年带过不少前端新人,发现一个很值得注意的现象:真正掌握设计模式的人,刚接手一个复杂项目时感觉是"这代码的骨架长得有规律",而没掌握的人感觉是"每个文件都是孤岛,都要从头啃一遍"。有规律你就可以做推理:这个函数八成在某个策略表里,这个组件八成被某个HOC包过。这种感觉,才是设计模式给你的真正议价能力。如果看完这篇你对"模式是骨架的规律"这句话有了体感,那说明我今天写的这些没有一个字是白费的。
