设计模式面试真题补充:创建、切换与隔离
根据编号 11《设计模式面试真题》补充工厂、单例、策略、观察者/发布订阅和代理模式的边界与前端场景。
设计模式面试真题补充:创建、切换与隔离
本文根据编号 11《设计模式面试真题》整理。项目已有模式文章分别介绍定义和示例,本页把 PDF 中的 6 道题串成一套“问题 -> 结构 -> 代价 -> 场景”的回答框架。
1. 先判断是不是需要模式
设计模式不是越多越好。先问三个问题:
- 哪一部分代码会变化?
- 变化是否会反复出现,且调用方不应该跟着变化?
- 引入抽象后,测试、扩展或协作是否真的更容易?
如果对象创建只有一行、算法只有一个实现、事件只在一个地方使用,直接函数或普通模块通常更清晰。模式的价值在于隔离变化,不在于增加类的数量。
面试可以用下面的顺序回答任何模式题:
定义 -> 解决的变化点 -> 参与者关系 -> 一个最小实现 -> 适用场景 -> 代价与替代方案
2. 工厂:把创建逻辑从业务中移开
2.1 三种工厂的区别
| 类型 | 结构 | 新增产品时的变化 |
|---|---|---|
| 简单工厂 | 一个入口按参数分发实例 | 通常要修改分发函数 |
| 工厂方法 | 每种产品有对应工厂或创建方法 | 可增加新的工厂和产品 |
| 抽象工厂 | 创建一组相互匹配的产品族 | 适合整套实现一起切换 |
简单工厂的核心不是 switch 本身,而是让调用端只知道稳定的抽象:
class JsonLogger {
write(message) {
return JSON.stringify({ message })
}
}
class TextLogger {
write(message) {
return `[log] ${message}`
}
}
function createLogger(format) {
if (format === 'json') return new JsonLogger()
if (format === 'text') return new TextLogger()
throw new Error(`Unsupported format: ${format}`)
}
const logger = createLogger(config.format)
logger.write('ready')
如果业务代码到处判断 format,工厂并没有真正隔离变化;应该把判断集中在边界处,并让返回对象遵守同一契约。产品数量很多、分发条件经常变化时,可以用注册表替代不断增长的 switch,但要明确注册时机和未知类型的错误策略。
2.2 前端场景
- 按运行平台创建不同的上传器或支付适配器;
- 按环境创建
console、HTTP 或监控日志实现; - 按图表类型创建统一的渲染器;
- 按主题创建一组相互配套的组件样式。
工厂的代价是多一层间接调用和更复杂的调试路径。对象结构稳定且没有第二种实现时,直接构造更容易维护。
3. 单例:唯一实例不等于全局变量
单例保证某个作用域内的实例只有一个,并提供统一访问入口。常见的闭包实现是惰性创建:
function getSingle(create) {
let instance
return function getInstance(...args) {
if (!instance) instance = create(...args)
return instance
}
}
const createLoginLayer = getSingle(() => {
const node = document.createElement('div')
node.hidden = true
document.body.appendChild(node)
return node
})
const first = createLoginLayer()
const second = createLoginLayer()
console.log(first === second) // true
3.1 面试中的边界
- “唯一”要先说明范围:一个模块、一个页面、一个请求上下文,还是整个进程。
- 单例中的可变状态会形成隐式耦合,测试之间可能互相污染。
- SSR 不能把用户请求相关数据放在模块级单例中,否则可能串数据;每个请求应创建独立的 store、router 或服务容器。
- 热更新、微前端和 iframe 可能存在多个运行时,模块单例未必等于浏览器全局唯一。
- 需要可替换实现时,依赖注入通常比隐藏式单例更容易测试。
Vuex、Redux store、缓存管理器和某些事件中心常表现出单例形态,但“大家都能 import 到”不是使用单例的充分理由。先确认生命周期和所有权,再决定是否共享。
4. 策略:把可替换算法变成对象或函数
策略模式把一组可互换的算法封装起来,让上下文只依赖统一接口。PDF 中的奖金计算和表单校验都属于“规则会增加或变化”的场景。
const rules = {
required(value) {
return value.trim() ? '' : '必填'
},
minLength(value, length) {
return value.length >= length ? '' : `至少 ${length} 个字符`
},
}
function validate(value, checks) {
for (const check of checks) {
const error = rules[check.name]?.(value, ...check.args)
if (error) return error
}
return ''
}
validate('abc', [
{ name: 'required', args: [] },
{ name: 'minLength', args: [6] },
])
策略与工厂可以组合:工厂负责选择或创建策略,策略负责执行算法。策略适合运行时切换,例如支付渠道、排序规则、折扣规则、校验规则和不同设备的渲染方式。
4.1 与条件分支的取舍
少量、稳定的分支用 if 或 switch 更直接;当分支内部逻辑复杂、需要独立测试,或新增规则不应修改核心流程时,再抽成策略。策略对象过多且没有统一契约,会把一个简单函数问题变成难以追踪的调用链。
5. 观察者与发布订阅:通知关系是否经过中介
两者都表达“一对多通知”,关键差异是依赖关系:
观察者:Subject 直接持有 Observer 列表并通知
发布订阅:Publisher -> EventChannel -> Subscriber
观察者适合主体和观察者关系明确的组件或模型。发布订阅适合多个模块通过事件类型通信,发布者不需要知道订阅者是谁。
一个可维护的事件中心至少要提供取消订阅和一次性订阅:
class EventBus {
constructor() {
this.events = new Map()
}
on(type, handler) {
const handlers = this.events.get(type) || new Set()
handlers.add(handler)
this.events.set(type, handlers)
return () => this.off(type, handler)
}
once(type, handler) {
const stop = this.on(type, (payload) => {
stop()
handler(payload)
})
return stop
}
off(type, handler) {
const handlers = this.events.get(type)
if (!handlers) return
handlers.delete(handler)
if (handlers.size === 0) this.events.delete(type)
}
emit(type, payload) {
for (const handler of this.events.get(type) || []) {
handler(payload)
}
}
}
5.1 工程边界
- 组件卸载时必须取消订阅,否则会重复响应或保留已卸载组件的闭包。
- 事件名应按领域分层,例如
order.created,避免全局字符串冲突。 - 事件中心不应成为隐形的全局数据流;关键状态变化仍应有明确的状态模型。
- 异步处理要考虑错误隔离、顺序和背压,不能假定所有订阅者都同步且快速。
6. 代理:不改变调用方的访问方式
代理对象实现与真实对象相同或兼容的接口,在访问前后增加控制。PDF 中出现的缓存代理、图片虚拟代理和 Axios 拦截器可以归为不同类型:
| 类型 | 额外职责 | 示例 |
|---|---|---|
| 虚拟代理 | 延迟昂贵对象的创建 | 图片先显示占位图,加载完成后替换 |
| 缓存代理 | 缓存重复计算结果 | 多参数计算结果按 key 缓存 |
| 保护代理 | 权限、参数或访问控制 | 请求前检查角色和 Token |
| 远程代理 | 封装远程调用 | API client、RPC stub |
虚拟代理应让业务调用方仍然只调用 setSrc,而不是让页面自己管理“占位、预加载、替换”三套逻辑:
function createImageProxy(realImage) {
const placeholder = '/placeholder.png'
let preload
return {
setSrc(src) {
realImage.src = placeholder
preload = new Image()
preload.onload = () => {
realImage.src = src
}
preload.src = src
},
}
}
缓存代理要定义缓存 key、容量和失效策略;如果参数包含对象,直接 join 可能产生碰撞或依赖对象字符串化结果。生产代码应使用稳定序列化或显式 key。
6.1 JavaScript Proxy 与代理设计模式
ES6 Proxy 是语言级拦截机制,可以拦截 get、set、has 等操作;代理设计模式是架构层的职责分离。两者可以结合,但不是同一个概念。比如响应式系统用 Proxy 追踪属性访问,而权限、缓存和延迟加载仍然是代理模式的业务应用。
Q&A 索引
本页的短答已合并到同目录的 99-高频追问Q&A.md;本页保留 PDF 中需要展开说明的模式结构、代码和工程边界。