技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

常用设计模式汇总,告诉你如何学习设计模式

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/98-常用设计模式汇总,告诉你如何学习设计模式
本文目录9 个章节
  1. 前言:先学会解决问题,再记模式名称
  2. 模板方法与策略模式
  3. 模板方法:固定骨架,开放少量步骤
  4. 策略模式:把可替换算法交给上下文
  5. 模板方法和策略怎么选
  6. 工厂、创建函数和注册表
  7. 文章开头任务场景的改写思路
  8. 选择检查表
  9. 参考链接

常用设计模式汇总,告诉你如何学习设计模式

Category(分类): JavaScript Status: 已整理

前言:先学会解决问题,再记模式名称

往期文章常把设计模式画成一张很大的思维导图。最开始学习设计模式时,我也读过经典的《设计模式:可复用面向对象软件的基础》:第一遍没看懂,又看一遍,23 个模式前后读了几次,还做了笔记,后来……好像全忘了。

等到面试或者跳槽,再翻出来,只记得工厂和单例,其他的又忘光了。问题不一定在于模式太多,而在于一次看太多、没有把它们放进真实问题里练习。比较稳妥的方式是先掌握少量常见思想,再用它们重构重复流程、可变算法和对象创建代码。剩下的模式等遇到相应问题时再学习。

本文保留企鹅故事,并重点展开模板方法、策略、创建/工厂三条主线。思维导图里的代理、组合、装饰器、Builder 等只是目录,不代表本文已经逐一实现十种模式。

设计模式思维导图

图片来源:原文思维导图,已本地化为 images/98-image-01.webp。原图来自第三方微信图床,转载、图中字体和商标授权未核实;本地化不等于取得授权。

温馨提示:设计模式是可复用的设计语言,不是必须套用的清单。借鉴思想,好用最重要。

模板方法与策略模式

先讲一个企鹅笑话:记者去南极采访企鹅,问第一只企鹅每天做什么,回答是“吃饭,睡觉,打豆豆”。问了 99 只,答案都一样。第 100 只企鹅只说“吃饭,睡觉”。记者问它为什么不打豆豆,企鹅撇嘴说:“我就是豆豆!”

假设三只企鹅都遵循“吃饭、睡觉、打豆豆”,只有最后一步不同。重复复制整段流程当然能工作,但变化点一多,修改公共步骤就要改很多处。

最初的重复代码:Java 对照

下面保留原文的 Java 版本。它是跨语言对照,不是 JavaScript 代码;每个 public class 通常应放在与类名匹配的独立 .java 文件中。

public class LittlePenguin {
  public void everyDay() {
    System.out.println("吃饭");
    System.out.println("睡觉");
    System.out.println("用小翅膀打豆豆");
  }
}

public class MiddlePenguin {
  public void everyDay() {
    System.out.println("吃饭");
    System.out.println("睡觉");
    System.out.println("用圆圆的肚子打豆豆");
  }
}

public class BigPenguin {
  public void everyDay() {
    System.out.println("吃饭");
    System.out.println("睡觉");
    System.out.println("拿鸡毛掸子打豆豆");
  }
}

Java 代码里的 publicSystem.out.println 和类继承都属于 Java 语法。把它复制到 .js 文件中不会运行。

先抽取独立步骤

也可以在 Java 中把吃饭、睡觉和打豆豆分别抽成方法;这能让步骤更清晰,但调用方仍然要自己保证执行顺序:

public class LittlePenguin {
  public void eating() {
    System.out.println("吃饭");
  }

  public void sleeping() {
    System.out.println("睡觉");
  }

  public void beating() {
    System.out.println("用小翅膀打豆豆");
  }
}

Java 的示例省略其他企鹅类。下面改用 JavaScript 主线,因为 JavaScript 的函数是一等值,固定流程和变化步骤可以直接用函数表达,不必先建立一组类。

模板方法:固定骨架,开放少量步骤

经典 GoF 模板方法的意图是:在一个方法中定义算法骨架,把某些步骤延迟给子类或钩子实现。经典的继承实现通常由基类安排步骤顺序,子类只改变允许变化的步骤。

Java 版:跨语言对照

原文的 Java 版本还可以稍微修正:如果希望 everyDay() 真正不能被子类覆盖,应在 Java 中使用 final;普通的 public 方法并不能保证固定流程。

public abstract class Penguin {
  public final void everyDay() {
    eating();
    sleeping();
    beating();
  }

  protected final void eating() {
    System.out.println("吃饭");
  }

  protected final void sleeping() {
    System.out.println("睡觉");
  }

  protected abstract void beating();
}

public final class LittlePenguin extends Penguin {
  @Override
  protected void beating() {
    System.out.println("用小翅膀打豆豆");
  }
}

这是 Java 语境下的模板方法示意。JavaScript 没有普通运行时意义上的 abstractfinal 关键字;TypeScript 的 abstract 主要是类型检查,也不能把编译后的 JavaScript 公共方法变成不可覆盖的方法。

JavaScript 函数式模板

在 JavaScript 中,可以把固定顺序写成一个函数,把变化步骤作为参数。这个实现保留了模板方法的意图,但不是必须使用继承:

function assertFunction(value, name) {
  if (typeof value !== 'function') {
    throw new TypeError(`${name} 必须是函数`)
  }
  return value
}

// 骨架由这里固定,beat 是可替换的钩子。
function dailyRoutine(beat) {
  assertFunction(beat, 'beat')

  return () => [
    '吃饭',
    '睡觉',
    beat(),
  ]
}

const littleRoutine = dailyRoutine(() => '用小翅膀打豆豆')
console.log(littleRoutine().join(','))
// 吃饭,睡觉,用小翅膀打豆豆

这个版本的好处是流程函数容易测试,返回数组也比直接打印更容易组合。如果项目确实需要多态对象,也可以使用 class;但不要把“使用继承”说成 JavaScript 实现模板方法的唯一方式。

模板方法 UML 示意图

图片来源:原文模板方法配图,已本地化。原图来自第三方微信图床,转载授权未核实;图片仅作概念对照,正文以代码语义为准。

策略模式:把可替换算法交给上下文

策略模式的核心是:把同一职责的多个算法分开,让上下文依赖一个稳定的策略契约并委托执行。策略可以是对象,也可以是 JavaScript 函数;是否在运行期间切换是可选能力,不是模式的必要条件。

原文用企鹅对象同时表示吃饭、睡觉和打豆豆,这其实把模板流程和策略混在了一起。为了突出策略本身,先只替换“打豆豆”的算法:

function createBehaviorContext(initialStrategy) {
  let strategy = assertFunction(initialStrategy, 'strategy')

  return {
    setStrategy(nextStrategy) {
      strategy = assertFunction(nextStrategy, 'strategy')
    },

    run(...args) {
      return strategy(...args)
    },
  }
}

const littleBeat = () => '用小翅膀打豆豆'
const middleBeat = () => '用圆圆的肚子打豆豆'
const bigBeat = () => '拿鸡毛掸子打豆豆'

const behavior = createBehaviorContext(littleBeat)
console.log(behavior.run())

behavior.setStrategy(middleBeat)
console.log(behavior.run())

behavior.setStrategy(bigBeat)
console.log(behavior.run())

策略也可以携带配置和私有状态:

function createPrefixStrategy(prefix) {
  return (name) => `${prefix}${name}`
}

const polite = createPrefixStrategy('请 ')
console.log(polite('打豆豆'))

这里闭包只是实现策略的一种手段。若策略在创建上下文时就确定并且不会改变,可以去掉 setStrategy;若每个请求都选择不同算法,也可以把函数作为请求参数传入。选择时机取决于生命周期和业务需求。

策略模式 UML 示意图

图片来源:原文策略模式配图,已本地化。原图来自第三方微信图床,转载授权未核实;请在获得许可后再用于公开发布。

模板方法和策略怎么选

它们的意图和实现关系并不完全相同,也不必只能二选一。下面的区别是常见启发式,不是语言规范:

关注点模板方法策略
主要意图固定一套流程,开放少数钩子替换某个完整职责或算法族
经典 GoF 关系常用继承表达骨架和步骤常用组合/委托表达算法替换
JavaScript 写法函数式模板、闭包、类都可以一个函数、闭包或带 execute 的对象都可以
选择时机变体常由具体实现决定可以在构造时、请求时或运行期间选择
耦合继承版通常耦合较紧委托版通常更松,但仍取决于契约

如果只有一个流程,且顺序不变、变化点很少,函数式模板通常足够;如果同一职责有多个算法并且调用方需要替换,策略更直观;两者也可以组合。不要因为“模式名”而增加不必要的类。

实际场景

框架代码经常有固定的 process() 流程,其中 preProcess() 可能是可选钩子;这可以表达模板方法的思想。支付、折扣、排序、校验等场景有多个互换算法,则可以使用策略。最终要看重复的流程、变化的边界、测试成本和对象生命周期,而不是把模式硬套成固定的选择时机。

工厂、创建函数和注册表

先看一个跨语言的分支例子

原文这里的 switch 使用的是 PHP 风格语法,保留它是为了说明历史业务场景;它不是 JavaScript,也不是 Java 完整程序:

switch ($taskInfo['type_id']) {
  case 1:
    $result = batchFrozen($rowKey, 1);
    break;
  case 2:
    $result = batchFrozen($rowKey, 0);
    break;
  case 3:
    $result = batchReshipment($rowKey);
    break;
  case 4:
    $result = batchCancel($rowKey);
    break;
  default:
    throw new InvalidArgumentException('未知任务类型');
}

注册表可以把变体和创建逻辑集中起来,减少业务调用方中的长分支;但选择逻辑并没有消失,未知类型仍然需要明确的错误策略。只有两个稳定分支时,清楚的 ifswitch 可能更易读,不必为了“去掉 if/else”而引入工厂。

名称要分清:简单工厂、注册表和 Factory Method

  • 工厂函数/简单工厂:一个函数根据输入创建或返回产品。
  • 注册表式工厂:用 Map、对象或容器登记类型到创建器的映射。
  • GoF Factory Method:经典结构中,Creator 提供创建方法,具体 Creator 子类通过覆写该方法改变所创建的 Product 类型。
  • Abstract Factory:创建一组相互匹配的产品,不等同于任意一个 createX() 函数。

原文的 Java Map<String, Penguin> 保存的是已经创建好的实例,因此更准确的名字是“实例注册表/缓存式简单工厂”,不是严格意义上的 GoF Factory Method;每次取出的是同一个对象,可能共享可变状态。

JavaScript 的 Map 创建器注册表

下面的示例把 Map 中的值设为创建函数,因此每次调用都会得到新对象。它可以在 Node.js 或浏览器控制台运行:

function routineFor(beat) {
  return () => ['吃饭', '睡觉', beat()]
}

const penguinCreators = new Map([
  ['little', () => ({
    kind: 'little',
    everyDay: routineFor(() => '用小翅膀打豆豆'),
  })],
  ['middle', () => ({
    kind: 'middle',
    everyDay: routineFor(() => '用圆圆的肚子打豆豆'),
  })],
  ['big', () => ({
    kind: 'big',
    everyDay: routineFor(() => '拿鸡毛掸子打豆豆'),
  })],
])

function createPenguin(kind) {
  const creator = penguinCreators.get(kind)
  if (!creator) {
    throw new RangeError(`未知企鹅类型:${kind}`)
  }
  return creator()
}

const little = createPenguin('little')
console.log(little.everyDay().join(','))
console.log(createPenguin('little') !== createPenguin('little'))
// 吃饭,睡觉,用小翅膀打豆豆
// true

如果产品本来就无状态,并且明确希望复用同一个实例,也可以登记实例;但应在文档中写清缓存/单例语义。Map.prototype.get() 未命中返回 undefined,应在工厂边界尽早抛出可读错误,而不是让调用方稍后遇到难定位的异常。

工厂模式 UML 示意图

图片来源:原文工厂模式配图,已本地化。原图来自第三方微信图床,转载授权未核实;不得把本地镜像视为已获得再发布许可。

Java 工厂示例:跨语言历史对照

原文的 Java 示例大意如下。它用抽象类复用日常流程,再通过静态 Map 取企鹅实例;这里加上了语言和生命周期说明:

import java.util.Map;
import java.util.function.Supplier;

public final class PenguinFactory {
  private static final Map<String, Supplier<Penguin>> CREATORS = Map.of(
    "little", LittlePenguin::new,
    "middle", MiddlePenguin::new,
    "big", BigPenguin::new
  );

  public static Penguin create(String name) {
    Supplier<Penguin> creator = CREATORS.get(name);
    if (creator == null) {
      throw new IllegalArgumentException("未知企鹅类型:" + name);
    }
    return creator.get();
  }
}

这段 Java 代码使用 MapSupplier 返回新实例,仍然是注册表式简单工厂;它没有 Creator 子类覆写 Factory Method。@Autowired 静态字段和静态代码块也不能当作 Spring 实例注入的可靠写法。真实 Spring 应使用构造器注入,并明确 Bean 是否有状态。

文章开头任务场景的改写思路

原文还展示了把几十个任务 case 放到 TaskFactory 中的做法。那段代码依赖 AbstractTaskParamWrapper、Spring 注解等未定义类型,只能当作 Java/Spring 伪代码,不能当作独立 JavaScript 示例。用 JavaScript 表达同一思想时,可以登记函数:

const taskHandlers = new Map([
  ['frozen', (rowKey) => batchFrozen(rowKey, 1)],
  ['unfrozen', (rowKey) => batchFrozen(rowKey, 0)],
  ['reshipment', (rowKey) => batchReshipment(rowKey)],
  ['cancel', (rowKey) => batchCancel(rowKey)],
])

function runTask(type, rowKey) {
  const handler = taskHandlers.get(type)
  if (!handler) {
    throw new RangeError(`未知任务类型:${type}`)
  }
  return handler(rowKey)
}

这里的 batchFrozen 等函数代表业务依赖,示例重点是注册和错误边界。注册表减少了调用方的分支,但新增任务仍要注册映射;它不是“消灭所有条件判断”的魔法。

选择检查表

遇到的问题可以先考虑
步骤顺序固定,只有少量受控钩子变化模板方法或函数式模板
同一职责有多个算法,调用方可能按请求替换策略函数、策略对象或组合
构造参数、依赖、产品类型或生命周期需要集中管理工厂函数、创建方法或注册表
只有一两个稳定分支且没有复用需求清楚的 if/switch

设计模式的价值是帮助你命名和隔离变化,不是证明某种写法“唯一正确”。先写出可读的代码,再根据重复、变化、测试和生命周期决定是否抽象。

参考链接

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS