JavaScript 设计模式实战:10 种常用模式的 JS 实现

wbcm
wbcm 见习用户见习用户
发布于 2026-10-20 11:09 ·8 浏览 ·2 回复
内容摘要

围绕单例、工厂、策略、观察者、发布订阅等十种设计模式给出 JavaScript 最小可用实现,说明各自解决的问题与易错点,并强调模式只是对反复出现结构问题的通用解法,仅当变化点被隔离、新增情况无须改动老代码时才值得使用,否则直白代码更好。

学完这篇你能得到 10 种常用设计模式在 JavaScript 里的最小可用实现,知道每种模式解决什么问题、代码怎么写,以及实际使用中最容易出错的地方。

第 0 步:先记住一个前提

设计模式不是公式,而是对“某个反复出现的结构问题”的通用解法。判断要不要用,只看两点:变化点是否被隔离出来了,以及新增一种情况时要不要改动老代码。如果两条都不满足,直接写直白代码更好。

第 1 种:单例(Singleton)

保证全局只有一个实例,常用于配置中心、弹窗管理器。

const Config = (() => {
  let instance = null;
  const create = () => ({ env: 'prod', api: 'https://api.example.com' });
  return { get: () => instance || (instance = create()) };
})();

Config.get() === Config.get(); // true

注意:ES Module 本身就是单例,export default {} 就够用了,不必手写闭包。手写版本在热更新、多入口打包时可能被实例化多次。

第 2 种:工厂(Factory)

把 new 和分支判断收进一个函数,调用方只关心拿到的对象能干什么。

function createUser(type) {
  const map = {
    admin: () => ({ type, perms: ['read', 'write', 'delete'] }),
    guest: () => ({ type, perms: ['read'] }),
  };
  if (!Object.hasOwn(map, type)) throw new Error('未知用户类型: ' + type);
  return map[type]();
}

注意:用对象当字典时一定要用 Object.hasOwn 或 Object.create(null) 判断键是否存在,否则 constructor、__proto__ 会被误当成合法类型。

第 3 种:策略(Strategy)

把一堆 if/else 换成表驱动,新增分支时只加一项。

const discounts = {
  wechat: p => p * 0.95,
  alipay: p => p * 0.98,
  cash: p => p,
};
const pay = (type, price) => discounts[type](price);

第 4 种:观察者(Observer)

被观察者直接持有观察者列表,状态一变就逐个通知。

class Subject {
  #list = [];
  subscribe(fn) { this.#list.push(fn); return () => this.#list = this.#list.filter(f => f !== fn); }
  notify(data) { this.#list.forEach(fn => fn(data)); }
}

第 5 种:发布订阅(Pub/Sub)

比观察者多一个中间事件中心,发布方和订阅方互不认识。

const bus = {
  map: new Map(),
  on(k, fn) { (this.map.get(k) || this.map.set(k, []).get(k)).push(fn); },
  emit(k, ...args) { (this.map.get(k) || []).forEach(fn => fn(...args)); },
  off(k, fn) { const a = this.map.get(k) || []; this.map.set(k, a.filter(f => f !== fn)); },
};

注意:订阅返回的取消函数一定要在组件卸载或页面销毁时调用,否则回调里引用的 DOM 和闭包变量无法被释放,单页应用里这是最常见的内存泄漏来源。

第 6 种:装饰器(Decorator)

不修改原函数,在外面包一层加行为,比如计时、重试、鉴权。

const withTiming = fn => (...args) => {
  const t = performance.now();
  const r = fn(...args);
  console.log(`${fn.name} 耗时 ${performance.now() - t}ms`);
  return r;
};

注意:@decorator 语法仍在标准推进中,编译配置不同行为也不同;生产项目里用上面这种高阶函数组合最稳妥。

第 7 种:代理(Proxy)

用 Proxy 拦截读写,实现缓存、校验、访问控制。

const cache = new Map();
const api = new Proxy({}, {
  get(_, key) {
    if (!cache.has(key)) cache.set(key, async () => (await fetch('/api/' + key)).json());
    return cache.get(key);
  },
});

注意:拦截 get 时不要再接用 target[key] 里的 this,内部方法要用 Reflect.get(target, key, receiver) 传 receiver,否则 this 会指向代理对象,容易出现奇怪问题。

第 8 种:适配器(Adapter)

把老接口或第三方接口统一成项目内部的标准形状。

const oldUser = { user_name: 'tom', user_age: 20 };
const adapt = u => ({ name: u.user_name, age: u.user_age });

第 9 种:命令(Command)

把操作封装成对象,天然支持撤销、重做、队列。

const cmd = {
  do: () => list.push(item),
  undo: () => list.splice(list.indexOf(item), 1),
};

第 10 种:责任链(Chain)

每个节点只判断自己关心的条件,处理不了就交给下一个。

function chain(...fns) {
  return (ctx) => { for (const fn of fns) { const r = fn(ctx); if (r !== undefined) return r; } };
}

注意:责任链一定要有兜底节点或明确的返回值约定,否则条件都不命中时会静默返回 undefined,排查起来很费劲。

小结

  • 单例防重复实例,工厂收 new,策略替 if/else,观察者和发布订阅做解耦。
  • 装饰器包函数、代理拦读写、适配器转形状、命令可撤销、责任链拆判断。
  • 判断标准只有两条:变化点是否隔离、加需求要不要改老代码。
  • 真正踩坑的多在细节:字典原型污染、订阅未取消、Proxy 的 this、责任链缺兜底。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-905.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 2

runyu
runyu 正式会员正式会员认证极客认证极客 1楼 2026-10-20 11:15

这篇的骨架很稳,"变化点是否被隔离 + 新增情况要不要改老代码"这个判据,比背 UML 图实用得多;下面补三处我认为值得改的实现细节。

策略模式那张表会踩和工厂一样的原型链原型:pay('constructor', 100) 调的是 Object 构造函数,pay('toString', 100) 直接返回 "[object Object]",pay('__proto__', ...) 才报 TypeError——一个静默返回错误值、一个报错,比抛异常难查得多。表驱动一律用 Map 或 Object.create(null),别用对象字面量当字典;Object.hasOwn 守卫当然也行,但最容易忘的就是策略这种"看起来只有几个键"的表。

观察者的 notify 建议显式拍快照 [...this.#list].forEach(...)。你现在退订走 filter 重建数组,实际语义是"本轮通知照发完,下一轮才生效",多数场景没问题但容易被同事误解;一旦有人图省事改成 splice 就地删,forEach 按索引遍历会直接跳过下一位订阅者,这类 bug 只在"通知中退订"时才复现,很难测出来。要哪种语义都行,关键写进注释。

dp32323
dp32323 正式会员正式会员 #1054 2楼 2026-10-20 11:19
runyu:这篇的骨架很稳,"变化点是否被隔离 + 新增情况要不要改老代码"这个判据,比背 UML 图实用得多;下面补三处我认为值得改的实现细节。 **策略模式那张表会踩…

两处都成立,尤其第一处——pay('toString', 100) 静默返回 "[object Object]" 比直接抛错危险得多,线上只会表现为“金额算出来是个字符串”,排查得从业务层往回倒。

策略表我倾向于直接上 Map,不只是躲原型链,还顺手支持非字符串键(比如枚举数字、Symbol),Object.create(null) 则得在每个取用处补一行 if (!fn) throw。至于 Object.hasOwn 守卫,正如你说的,键一少就没人记得加,靠约定不如靠数据结构。

观察者这块我再加一个更隐蔽的:subscribe 用 filter 按函数引用退订,同一个函数订阅两次,退订一次会把两条一起删掉。如果场景里可能出现重复订阅(比如组件重复挂载没做好清理),建议 subscribe 返回一个带 id 的句柄,退订按 id 删,或者干脆 #list 换成 Set,语义和去重都更清楚。快照那点完全同意,写成 [...this.#list].forEach 再跟一句注释“本轮通知不受退订影响”,比事后解释省事。

另外你说补三处,我这边只看到策略和观察者两段,第三处是不是发布订阅那段(事件中心的对象键问题、on 重复注册)被截断了?想接着看,尤其是中间事件中心怎么防止内存泄漏——那才是 Pub/Sub 落地最容易翻车的地方。