跳到正文
前端知识库
设计模式

设计模式面试真题补充:创建、切换与隔离

根据编号 11《设计模式面试真题》补充工厂、单例、策略、观察者/发布订阅和代理模式的边界与前端场景。

6 分钟设计模式 · 工厂 · 单例 · 策略 · 观察者 · 代理 · 面试

设计模式面试真题补充:创建、切换与隔离

本文根据编号 11《设计模式面试真题》整理。项目已有模式文章分别介绍定义和示例,本页把 PDF 中的 6 道题串成一套“问题 -> 结构 -> 代价 -> 场景”的回答框架。

1. 先判断是不是需要模式

设计模式不是越多越好。先问三个问题:

  1. 哪一部分代码会变化?
  2. 变化是否会反复出现,且调用方不应该跟着变化?
  3. 引入抽象后,测试、扩展或协作是否真的更容易?

如果对象创建只有一行、算法只有一个实现、事件只在一个地方使用,直接函数或普通模块通常更清晰。模式的价值在于隔离变化,不在于增加类的数量。

面试可以用下面的顺序回答任何模式题:

定义 -> 解决的变化点 -> 参与者关系 -> 一个最小实现 -> 适用场景 -> 代价与替代方案

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 与条件分支的取舍

少量、稳定的分支用 ifswitch 更直接;当分支内部逻辑复杂、需要独立测试,或新增规则不应修改核心流程时,再抽成策略。策略对象过多且没有统一契约,会把一个简单函数问题变成难以追踪的调用链。

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 是语言级拦截机制,可以拦截 getsethas 等操作;代理设计模式是架构层的职责分离。两者可以结合,但不是同一个概念。比如响应式系统用 Proxy 追踪属性访问,而权限、缓存和延迟加载仍然是代理模式的业务应用。

Q&A 索引

本页的短答已合并到同目录的 99-高频追问Q&A.md;本页保留 PDF 中需要展开说明的模式结构、代码和工程边界。