从需求到代码:浅谈前端设计模式与实际应用

161 阅读21分钟

提到设计模式,大家往往会想到六大原则和23种常见模式;虽然不必把所有模式都牢记于心,但作为前端开发者,也需要了解并掌握一些高频使用的设计模式。

那么我们要先回顾下六大原则:

六大原则

原则:核心思想目的
依赖倒置原则(Dependence Inversion Principle)高层模块(业务层)不应直接依赖于低层模块(基础层),二者应依赖于抽象接口。减少高层模块和低层模块之间的耦合,增强代码的灵活性和可扩展性。
开闭原则(Open-Closed Principle)一个模块应该对扩展开放,对修改封闭,即可以在不修改原有代码的情况下,通过扩展来满足新需求。提升系统的可扩展性,同时保证原有功能不受影响。
单一职责原则(Single Responsibility Principle)每个模块或类应该仅负责一项职责,避免一个模块承担过多功能。减少代码的复杂性,增强代码的可读性、可维护性和可测试性。
迪米特法则(Law of Demeter)一个模块只应该与直接相关的模块交互,尽量减少与陌生模块的依赖,暴露简洁的接口。减少对象之间的耦合,提高代码的内聚性。
接口隔离原则(Interface Segregation Principle)不应该强迫客户端依赖它不需要的接口,接口应根据业务需求进行拆分和隔离。提高代码的灵活性和复用性,避免不必要的依赖。
里氏替换原则(Liskov Substitution Principle)子类对象应当能够替代父类对象,并且程序的功能不受影响,即子类应当能够扩展父类的功能,而不改变原有的行为。确保程序在使用继承关系时,能够在不破坏原有功能的前提下,扩展新的功能。

二十三种设计模式

在了解前端的设计模式之前,我们首先简单了解下经典的设计模式。

经典的设计模式分为三大类:创建型、结构型和行为型,总共有23种。

以下是对这23种设计模式的简要介绍:

创建型

创建型设计模式主要关注如何实例化对象,减少对象创建过程中的复杂性。

image.png

结构型

结构型设计模式关注如何组合类或对象以形成更大的结构。

image.png

行为型

行为型设计模式关注对象之间的交互方式。

image.png

前端九种设计模式

虽然前端可以借鉴传统的面向对象设计模式,但根据前端开发的特点,通常会有一些常用的设计模式。以下是前端开发中常见的九种设计模式

设计模式核心思想应用场景优点
模块化模式将代码分成多个小模块,每个模块只暴露必要的接口,内部逻辑封闭。避免全局变量污染,组织大规模应用,如使用 ES6 模块化、Webpack 等。提高代码的可维护性和复用性。
观察者模式当某个对象的状态发生变化时,自动通知所有依赖于它的对象。前端框架中的数据绑定、事件处理、发布/订阅系统。松耦合的事件驱动机制,适用于用户界面中事件处理的场景。
单例模式保证某个类只有一个实例,并提供一个全局访问点。前端中常见的如全局配置管理、路由管理器、状态管理器(如 Redux)。避免不必要的实例化,节省资源,确保全局访问。
工厂模式通过一个工厂方法来创建对象,避免直接使用 new 操作符。根据不同的条件或配置生成不同的对象。例如,前端框架中的组件工厂、UI库的组件创建。解耦对象的创建和使用,可以灵活地扩展类型。
代理模式通过代理对象来控制对另一个对象的访问,代理对象对原对象的访问进行控制。前端中常见的懒加载、权限控制、缓存代理等。可以控制访问的权限、性能优化(懒加载、缓存等)。
适配器模式通过适配器将不兼容的接口转换为客户端需要的接口。前端中常见的不同第三方库之间的适配,或者将老的接口适配到新的接口。解耦接口,使得不同模块可以互操作。
命令模式将请求封装成命令对象,以便通过不同的调用方式来执行。前端中常用于封装UI操作(如点击按钮执行某个命令)、事件处理。请求的发送者与接收者解耦,可以进行操作的撤销/重做。
状态模式根据对象的状态改变其行为,每种状态由一个类表示,状态的切换影响行为的变化。前端中常见的表单校验、组件的不同状态,或者与用户交互的不同界面状态(比如登录状态、加载状态等)。避免复杂的条件判断,使代码更清晰易懂。
策略模式定义一系列算法,将每个算法封装起来,让它们可以互换。前端中常用于数据的不同格式处理(如排序、过滤)、动画效果等。能够灵活地替换不同的策略,代码清晰,且易于扩展。

模块化模式 (Module Pattern)

背景需求

假设你正在开发一个电商平台,项目日渐复杂,涉及多个功能模块:用户登录、商品展示、购物车、订单管理等。随着项目的不断发展,你发现代码开始变得难以维护和扩展。不同模块之间的逻辑混在一起,很多功能重复且难以管理。多个开发人员同时开发时,代码冲突不断,修改一个功能时容易影响到其他部分。你需要一种能让代码结构更清晰、易于扩展且能减少开发冲突的方式。

为什么使用模块化模式?

在这个场景中,模块化模式正是解决这一问题的有效方式。模块化的核心思想是把功能划分为多个独立的模块,每个模块都具备自己独立的责任和功能,只暴露必要的接口供外部使用。这种设计方法有几个关键好处:

  • 解耦:每个模块只关注自己的职责,不会影响到其他模块的实现。
  • 提高可维护性:每个模块都能独立开发、测试和维护,减少了开发人员之间的冲突。
  • 增强可扩展性:模块化的设计使得系统能方便地进行功能扩展,例如在以后添加新功能时,只需要添加新的模块,而不需要修改现有模块的代码。
  • 复用性:通用功能(如购物车、商品展示等)可以被不同部分共享,提高了代码的复用性。

模块化模式如何在实际中应用?

假设我们要在电商平台中实现一个购物车模块,我们不仅需要添加、删除商品,还要计算总价、更新商品数量等。通过模块化模式,我们可以把购物车的逻辑封装成一个模块,同时将UI渲染逻辑封装成另一个模块,二者之间通过接口进行交互。这样做的好处是,每个模块只关心自己的职责,而不需要了解其他模块的实现细节。

代码示例

让我们来看一个例子,电商平台的购物车模块。

// cartModule.js - 购物车模块
const cartModule = (function () {
  let cartItems = []; // 私有数据,存储购物车商品

  // 私有方法:检查商品是否已存在
  function isProductExist(productId) {
    return cartItems.some(item => item.productId === productId);
  }

  // 计算购物车总价
  function calculateTotal() {
    return cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
  }

  // 公共接口
  return {
    addItem: function (product) {
      if (isProductExist(product.productId)) {
        // 如果商品已存在,增加数量
        cartItems = cartItems.map(item =>
          item.productId === product.productId
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      } else {
        // 如果商品不存在,添加商品
        cartItems.push({ ...product, quantity: 1 });
      }
    },

    removeItem: function (productId) {
      // 从购物车中移除商品
      cartItems = cartItems.filter(item => item.productId !== productId);
    },

    updateQuantity: function (productId, quantity) {
      // 更新商品数量
      cartItems = cartItems.map(item =>
        item.productId === productId
          ? { ...item, quantity }
          : item
      );
    },

    getItems: function () {
      // 获取购物车中所有商品
      return cartItems;
    },

    getTotal: function () {
      // 获取购物车总价
      return calculateTotal();
    }
  };
})();

// uiModule.js - 购物车UI模块
const uiModule = (function (cart) {
  // 渲染购物车
  function renderCart() {
    const items = cart.getItems();
    const total = cart.getTotal();
    console.log("购物车:");
    items.forEach(item => {
      console.log(`商品: ${item.name}, 数量: ${item.quantity}, 价格: ${item.price}`);
    });
    console.log(`总价: ¥${total}`);
  }

  return {
    init: function () {
      // 初始渲染购物车
      renderCart();
    },

    update: function () {
      // 更新购物车渲染
      renderCart();
    }
  };
})(cartModule);

// 使用示例
cartModule.addItem({ productId: 1, name: '苹果', price: 5.0 });
cartModule.addItem({ productId: 2, name: '香蕉', price: 3.0 });
cartModule.addItem({ productId: 1, name: '苹果', price: 5.0 });

uiModule.init();  // 初始显示购物车
cartModule.removeItem(2);  // 删除香蕉
uiModule.update();  // 更新显示购物车

  • 购物车模块 ( cartModule ) :负责管理购物车的商品数据,包括添加商品、移除商品、更新商品数量、计算总价等。所有的操作都封装在这个模块内部,外部只通过暴露的接口(如 addItem, removeItem等)进行交互。
  • UI 模块 ( uiModule ) :负责渲染购物车的用户界面。它调用 cartModule 提供的接口来获取购物车数据,并更新页面。
  • 私有与公共接口:在 cartModule 中,内部数据 cartItems 是私有的,只有模块内部的方法才能访问它。外部只能通过公开的接口与购物车交互。

随着项目的增长,可能会有更多的模块需要添加,如支付模块、订单模块等,而我们只需关注模块间的接口设计,其他部分可以独立发展、测试和部署。

观察者模式 (Observer Pattern)

背景需求

在我们的电商平台,购物车模块实现了基本的商品增删、总价计算等功能。现在,平台需要在购物车发生变化时,实时更新页面上的购物车信息。比如,当用户在购物车中添加或删除商品时,页面上的购物车图标、购物车总数、购物车总价都需要更新。

假设有多个地方需要依赖购物车状态的变化,比如:

  • 页面顶部的购物车图标需要显示当前购物车中的商品数量。
  • 页面上的商品展示区需要动态显示已添加的商品列表。
  • 页面底部的结算按钮需要动态更新显示购物车的总价。

传统的做法是,在购物车模块内直接手动更新这些地方的UI。然而,随着需求的增加,手动更新每个UI部分变得越来越困难,特别是在项目中有多个组件和页面需要响应购物车变化时。你需要一个能够在购物车数据变化时,自动通知所有相关模块更新的方案。

为什么使用观察者模式?

观察者模式是一种行为型设计模式,它允许对象(被观察者)在状态发生变化时通知所有依赖于它的对象(观察者)。在这种模式下,观察者和被观察者之间没有强耦合,观察者只关心被观察者的状态变化,而不需要知道被观察者的具体实现。

在本场景下,购物车就是“被观察者”,而购物车图标、商品展示区和结算按钮是“观察者”。每当购物车中的商品发生变化时,购物车图标、商品展示区和结算按钮都需要自动更新。观察者模式能够解耦这些部分,使得它们能够独立地响应购物车状态的变化,而无需相互依赖。

代码设计思考

  • 观察者与被观察者的解耦:通过使用观察者模式,我们可以解耦购物车与页面UI之间的关系。购物车模块只负责管理数据,并通过通知机制告知所有需要更新的地方,而UI模块只需要关注如何展示这些数据。
  • 灵活的扩展:如果以后有新的UI部分需要响应购物车数据变化(例如显示优惠券信息的模块),我们只需要将它注册为一个观察者,而不需要修改购物车模块的代码。
  • 代码复用:观察者模式使得我们能够复用购物车逻辑,只要有任何与购物车相关的模块需要监听购物车变化,我们就能通过简单的注册和通知机制来实现。

代码示例

我们继续使用上面的电商平台例子,加入观察者模式来实现购物车与UI模块的解耦。

// cartModule.js - 购物车模块 (被观察者)
const cartModule = (function () {
  let cartItems = [];
  let observers = []; // 存储所有观察者

  // 私有方法:检查商品是否已存在
  function isProductExist(productId) {
    return cartItems.some(item => item.productId === productId);
  }

  // 计算购物车总价
  function calculateTotal() {
    return cartItems.reduce((total, item) => total + item.price * item.quantity, 0);
  }

  // 通知所有观察者更新
  function notify() {
    observers.forEach(observer => observer.update(cartItems, calculateTotal()));
  }

  // 公共接口
  return {
    addItem: function (product) {
      if (isProductExist(product.productId)) {
        cartItems = cartItems.map(item =>
          item.productId === product.productId
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      } else {
        cartItems.push({ ...product, quantity: 1 });
      }
      notify(); // 通知所有观察者
    },

    removeItem: function (productId) {
      cartItems = cartItems.filter(item => item.productId !== productId);
      notify(); // 通知所有观察者
    },

    updateQuantity: function (productId, quantity) {
      cartItems = cartItems.map(item =>
        item.productId === productId
          ? { ...item, quantity }
          : item
      );
      notify(); // 通知所有观察者
    },

    getItems: function () {
      return cartItems;
    },

    getTotal: function () {
      return calculateTotal();
    },

    // 添加观察者
    addObserver: function (observer) {
      observers.push(observer);
    },

    // 移除观察者
    removeObserver: function (observer) {
      observers = observers.filter(obs => obs !== observer);
    }
  };
})();

// uiModule.js - 购物车UI模块 (观察者)
const uiModule = function () {
  // 更新购物车UI
  function update(cartItems, totalPrice) {
    console.log("购物车更新:");
    console.log("商品列表:");
    cartItems.forEach(item => {
      console.log(`${item.name} - 数量: ${item.quantity}, 价格: ¥${item.price}`);
    });
    console.log(`总价: ¥${totalPrice}`);
  }

  return {
    // 注册为购物车的观察者
    subscribe: function (cart) {
      cart.addObserver(this);
    },

    // 响应购物车数据变化
    update: update
  };
};

// 结算按钮模块 (观察者)
const checkoutModule = function () {
  // 更新结算按钮
  function update(cartItems, totalPrice) {
    console.log(`结算按钮更新,总价:¥${totalPrice}`);
  }

  return {
    // 注册为购物车的观察者
    subscribe: function (cart) {
      cart.addObserver(this);
    },

    // 响应购物车数据变化
    update: update
  };
};

// 使用示例
const cart = cartModule; // 购物车模块
const ui = new uiModule(); // UI模块
const checkout = new checkoutModule(); // 结算按钮模块

// 注册观察者
ui.subscribe(cart);
checkout.subscribe(cart);

// 操作购物车
cart.addItem({ productId: 1, name: '苹果', price: 5.0 });
cart.addItem({ productId: 2, name: '香蕉', price: 3.0 });
cart.addItem({ productId: 1, name: '苹果', price: 5.0 });

cart.removeItem(2); // 删除香蕉
cart.updateQuantity(1, 3); // 修改苹果的数量

  • 购物车模块 ( cartModule ) :这是“被观察者”模块,负责管理购物车数据。它拥有 addItemremoveItemupdateQuantity 等操作,当数据发生变化时,调用 notify() 方法通知所有注册的观察者(如 UI 模块和结算按钮模块)进行更新。
  • UI模块 ( uiModule ) 和 结算按钮模块 ( checkoutModule ) :这两个模块是“观察者”。它们通过 subscribe() 方法注册自己为购物车的观察者。每当购物车数据变化时,它们会自动调用 update() 方法来更新UI或显示结算信息。
  • 通知机制 ( notify ) :每当购物车的内容发生变化时,购物车模块都会调用 notify() 方法,遍历并通知所有注册的观察者更新他们的显示内容。这个机制让我们避免了手动更新多个地方的UI,减少了冗余的代码。

单例模式 (Singleton Pattern)

背景需求

在我们的电商平台中有一个功能:用户认证模块。用户登录后需要进行权限验证、token 存储等操作。无论用户在任何页面操作,都会依赖于这个认证模块来验证用户的身份和权限。

但是,认证信息和用户状态应该在整个应用中保持唯一,不同模块对该信息的访问必须是统一的。如果每次都创建一个新的认证实例,可能会导致数据的不同步、冗余的请求等问题,甚至可能带来认证数据的冲突。你需要确保整个应用中只有一个用户认证实例,且这个实例在整个生命周期内都保持一致。

为什么使用单例模式?

单例模式的核心思想是:确保一个类只有一个实例,并提供全局访问点。适用于那些系统中只需要一个实例来管理全局状态的场景。

在本例中,用户认证模块需要在多个页面或组件中共享,并且在整个生命周期内保持一致。通过使用单例模式,我们可以确保用户认证状态的数据始终是唯一的,并且所有页面共享同一个实例,避免数据不一致的问题。

单例模式的思考

  • 唯一性:我们需要确保整个系统中只有一个认证实例。无论在哪个页面进行用户操作,系统都会从同一个认证实例中获取信息。
  • 全局访问:这个单一实例应该对外提供一个统一的访问点,允许其他模块在需要时获取认证信息。
  • 延迟实例化:为了提升性能和降低内存占用,实例化认证模块的时机可以延迟到第一次需要时。

代码示例

下面的代码展示了如何在电商平台中使用单例模式来管理用户认证模块。

// authModule.js - 用户认证模块(单例模式实现)
const AuthModule = (function () {
  let instance; // 用来存储实例的变量

  // 私有构造函数:确保实例的唯一性
  function createInstance() {
    let userData = null; // 私有数据,存储用户认证信息

    // 私有方法:验证用户是否登录
    function isAuthenticated() {
      return userData !== null;
    }

    // 私有方法:设置用户信息
    function setUser(data) {
      userData = data;
    }

    // 公共方法:获取用户信息
    function getUser() {
      return userData;
    }

    // 公共方法:登录
    function login(username, password) {
      // 模拟请求后台进行认证
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          if (username === "user" && password === "password") {
            setUser({ username: "user", token: "abc123" });
            resolve("登录成功");
          } else {
            reject("用户名或密码错误");
          }
        }, 1000);
      });
    }

    // 公共方法:登出
    function logout() {
      userData = null;
    }

    // 返回一个包含公共方法的对象
    return {
      isAuthenticated,
      getUser,
      login,
      logout,
    };
  }

  return {
    // 获取单例实例的接口
    getInstance: function () {
      if (!instance) {
        instance = createInstance(); // 延迟实例化
      }
      return instance;
    },
  };
})();

// 使用示例
const auth1 = AuthModule.getInstance();
const auth2 = AuthModule.getInstance();

// 由于是单例模式,auth1 和 auth2 是同一个实例
console.log(auth1 === auth2); // true

// 登录操作
auth1.login("user", "password").then(message => {
  console.log(message);
  console.log("当前用户:", auth1.getUser());
}).catch(error => {
  console.log(error);
});

// 退出登录操作
auth2.logout();
console.log("用户退出后:", auth1.getUser()); // null

  • 实例化控制:在 AuthModule 内部,我们使用 instance 变量来存储唯一实例。通过 getInstance() 方法,确保了整个应用中只有一个用户认证实例,其他地方访问认证信息时,都是访问同一个实例。
  • 延迟实例化createInstance() 是一个私有的构造函数,只有当调用 getInstance() 时,实例才会被创建,这样避免了不必要的内存占用。
  • 全局访问:任何地方需要访问认证模块,只需通过 AuthModule.getInstance() 获取单一的实例。例如在不同的组件或页面中,我们可以通过 auth1auth2 来访问相同的认证信息,保证了数据的一致性。
  • 私有方法与公共方法:认证信息(如 userData)被封装在私有作用域内,外部无法直接访问。暴露出来的公共方法包括登录、登出、获取用户信息等,确保了数据的封装性和安全性。

工厂模式 (Factory Pattern)

背景需求

在我们的电商平台中有一个支付模块。平台支持多种支付方式,包括 支付宝微信支付信用卡支付。每种支付方式都有不同的支付流程和接口,而你不希望在代码中散布大量的支付方式判断逻辑。你希望通过某种方式,根据支付方式的不同,动态选择不同的支付实现。

同时,你希望支付模块能够灵活扩展,以便将来可以很方便地增加新的支付方式,比如 银联支付Apple Pay

为什么使用工厂模式?

工厂模式是一种创建型设计模式,它提供一个创建对象的接口,但不暴露对象创建的具体实现细节。工厂模式的关键点是:

  • 封装对象创建:通过工厂类将对象创建的逻辑封装起来,外部不需要关心如何创建对象,只需要通过工厂来获取对象。
  • 解耦客户端与具体实现:客户端不直接依赖具体的实现类,而是依赖于工厂来获取实例。这使得系统更加灵活,新增支付方式时不需要修改客户端代码。
  • 易于扩展:通过添加新的工厂方法,可以很方便地扩展新的支付方式,而不需要更改现有的代码。

代码设计思考

  • 接口统一:所有支付方式都应该有一个统一的接口(例如 Payment 类),确保每种支付方式都遵循相同的协议。
  • 工厂方法:工厂方法根据传入的参数(支付方式类型)决定具体创建哪一种支付实现。这样可以通过传入不同的参数来动态选择支付方式,而不需要修改其他代码。

代码示例

我们来通过工厂模式实现电商平台的支付模块。

// payment.js - 支付方式接口(支付抽象类)
class Payment {
  pay(amount) {
    throw new Error('pay method must be implemented');
  }
}

// Alipay.js - 支付宝支付(具体实现)
class Alipay extends Payment {
  pay(amount) {
    console.log(`通过支付宝支付 ¥${amount}`);
  }
}

// WechatPay.js - 微信支付(具体实现)
class WechatPay extends Payment {
  pay(amount) {
    console.log(`通过微信支付 ¥${amount}`);
  }
}

// CreditCardPay.js - 信用卡支付(具体实现)
class CreditCardPay extends Payment {
  pay(amount) {
    console.log(`通过信用卡支付 ¥${amount}`);
  }
}

// PayFactory.js - 支付工厂(工厂方法)
class PayFactory {
  static getPaymentMethod(type) {
    switch (type) {
      case 'alipay':
        return new Alipay();
      case 'wechat':
        return new WechatPay();
      case 'creditCard':
        return new CreditCardPay();
      default:
        throw new Error('不支持的支付方式');
    }
  }
}

// 使用示例
try {
  // 选择支付方式
  const paymentMethod = PayFactory.getPaymentMethod('wechat');  // 可以是 'alipay'、'wechat' 或 'creditCard'
  
  // 执行支付
  paymentMethod.pay(199.99);  // 输出: 通过微信支付 ¥199.99
} catch (error) {
  console.error(error);
}

  • 支付接口 ( Payment ) :这是一个抽象类(或接口),规定了所有支付方式必须实现的 pay 方法。每种支付方式都继承自 Payment 类,并实现其支付逻辑。
  • 具体支付方式类
  • AlipayWechatPayCreditCardPay 都是具体的支付方式实现,它们各自实现了 pay 方法,提供不同的支付处理逻辑。
  • 支付工厂类 ( PayFactory ) :这是工厂类,它提供了 getPaymentMethod 静态方法,根据传入的支付类型返回对应的支付方式实例。客户端无需关心具体是哪种支付方式,只需要向工厂请求相应的支付对象即可。
  • 灵活的扩展性:如果将来需要增加新的支付方式(比如 银联支付),只需要在工厂类中增加新的 case 语句,创建对应的支付方式实现,而不需要修改其他地方的代码。

代理模式 (Proxy Pattern)

背景需求

平台提供了用户查看商品、下单、支付等多项功能。平台中的商品信息存储在数据库中,获取商品信息需要与数据库进行交互,查询商品时可能会涉及到很多计算和数据处理。

然而,查询商品信息的过程相对较为复杂,可能会导致较高的延迟或性能问题。如果每次都直接请求数据库,不仅会增加服务器的负担,还可能对用户的体验造成影响。

于是决定使用代理模式来优化这一过程。希望在每次查询商品信息时,不是直接请求数据库,而是通过一个代理对象来进行处理,代理对象可以:

  1. 延迟加载:只有在真正需要商品信息时,才去查询数据库。
  2. 缓存处理:可以缓存查询结果,避免重复查询数据库。
  3. 权限控制:可以根据用户权限判断是否可以访问某些商品信息。

为什么使用代理模式?

代理模式的核心思想是:为其他对象提供一种代理以控制对这个对象的访问。它通过代理类控制对实际对象的访问,并在客户端和实际对象之间提供间接访问。

使用代理模式可以解决以下问题:

  1. 性能优化:通过代理对象来缓存或延迟加载数据,避免不必要的开销。
  2. 权限控制:通过代理对象来控制对真实对象的访问,增加安全性。
  3. 解耦:客户端与实际对象解耦,客户端不需要直接操作复杂的对象或不需要关心对象的状态。

代码设计思考

  • 代理类:代理类持有真实对象的引用,客户端通过代理对象来访问实际对象。代理类可以在访问实际对象之前做一些额外的操作,例如缓存、延迟加载、权限校验等。
  • 虚拟代理:代理对象延迟加载实际对象,直到需要时才创建和调用真实对象。比如只有用户真正查看某个商品时,才去查询数据库。
  • 保护代理:代理对象在访问实际对象之前进行权限检查,确保用户有权限进行操作。

代码示例

在这个例子中,我们将使用代理模式来优化商品信息的获取过程。具体来说,代理对象会延迟加载商品信息并且进行缓存处理。

// 商品信息接口
class Product {
  getProductInfo(productId) {
    throw new Error('getProductInfo method must be implemented');
  }
}

// 真实商品类(需要加载数据库的数据)
class RealProduct extends Product {
  constructor() {
    super();
  }

  getProductInfo(productId) {
    console.log('从数据库加载商品信息...');
    // 模拟数据库查询
    return { productId, name: `商品 ${productId}`, price: Math.random() * 100 };
  }
}

// 商品代理类(缓存商品信息,延迟加载)
class ProductProxy extends Product {
  constructor() {
    super();
    this.realProduct = null; // 代理对象持有真实对象的引用
    this.cache = {}; // 缓存商品信息
  }

  // 获取商品信息,支持延迟加载和缓存
  getProductInfo(productId) {
    // 如果缓存中有商品信息,直接返回缓存数据
    if (this.cache[productId]) {
      console.log('从缓存加载商品信息...');
      return this.cache[productId];
    }

    // 如果缓存中没有,创建真实对象并加载数据
    if (!this.realProduct) {
      this.realProduct = new RealProduct();
    }
    const productInfo = this.realProduct.getProductInfo(productId);

    // 将查询结果缓存
    this.cache[productId] = productInfo;
    return productInfo;
  }
}

// 使用示例
const productProxy = new ProductProxy();

// 第一次获取商品信息,会从数据库加载
const product1 = productProxy.getProductInfo(101);
console.log(product1);

// 第二次获取同一个商品信息,应该直接从缓存加载
const product2 = productProxy.getProductInfo(101);
console.log(product2);

// 获取其他商品信息
const product3 = productProxy.getProductInfo(102);
console.log(product3);

  • 商品信息接口 ( Product ) :定义了获取商品信息的方法 getProductInfo,所有具体的商品类都需要实现这个方法。
  • 真实商品类 ( RealProduct ) :这是一个具体的商品类,模拟从数据库中加载商品信息。实际情况中,它可能会调用数据库查询接口,这里我们用一个简单的模拟来表示。
  • 商品代理类 ( ProductProxy ) :代理类继承自 Product,它会通过 getProductInfo 方法来访问真实的商品信息。在访问商品信息时:
    • 如果缓存中有该商品信息,则直接返回缓存数据。
    • 如果缓存中没有该商品信息,则创建一个 RealProduct 实例,加载数据,并缓存起来。
    • 代理类控制了缓存的管理,从而避免了重复的数据库查询。
  • 延迟加载:商品信息只有在需要时才会加载,而不是一开始就创建实例。这个特性通过代理类来控制。

适配器模式 (Adapter Pattern)

背景需求

还是刚刚的例子,平台上有多个不同的支付渠道接口,包括 支付宝微信支付Apple Pay。每种支付方式的接口格式和调用方式都不一样。你想通过统一的方式来处理所有支付渠道的支付请求,避免在代码中直接处理每种支付方式的细节。

你希望能够通过一个适配器,使得这些不同的支付接口能够在同一代码框架下工作,而不需要修改原有的接口和实现。

为什么使用适配器模式?

适配器模式(也叫包装器模式)的核心思想是通过适配器类,将一个接口转换成客户希望的另一个接口,从而解决接口不兼容的问题。

适配器模式通常应用于以下场景:

  1. 需要与一些遗留的系统或库进行交互,但它们的接口与当前系统不兼容。
  2. 不同的类接口不兼容,但它们的功能相似,可以通过适配器进行统一调用。
  3. 需要将第三方库或工具类的接口调整为符合当前系统的需求。

代码设计思考

  • 接口不兼容:我们有不同的支付接口,它们的接口形式和参数都不相同。适配器模式的核心任务就是提供一个统一的接口,让客户调用时不需要关心底层支付接口的差异。
  • 适配器类:适配器类会实现一个统一的支付接口,并将请求转发到实际的支付实现上。在适配器中,适配不同支付接口的调用方式。

代码示例

在这个例子中,我们将通过适配器模式来处理不同支付方式的接口。每种支付方式有不同的调用方式,而我们通过适配器类来统一它们的接口。

// 统一的支付接口
class Payment {
  pay(amount) {
    throw new Error('pay method must be implemented');
  }
}

// 支付宝支付(原接口)
class Alipay {
  payWithAlipay(amount) {
    console.log(`通过支付宝支付 ¥${amount}`);
  }
}

// 微信支付(原接口)
class WechatPay {
  payWithWechat(amount) {
    console.log(`通过微信支付 ¥${amount}`);
  }
}

// 苹果支付(原接口)
class ApplePay {
  makePayment(amount) {
    console.log(`通过Apple Pay支付 ¥${amount}`);
  }
}

// 支付宝适配器
class AlipayAdapter extends Payment {
  constructor(alipay) {
    super();
    this.alipay = alipay;
  }

  pay(amount) {
    // 将统一的支付方法调用转发到支付宝的特定方法
    this.alipay.payWithAlipay(amount);
  }
}

// 微信支付适配器
class WechatPayAdapter extends Payment {
  constructor(wechatPay) {
    super();
    this.wechatPay = wechatPay;
  }

  pay(amount) {
    // 将统一的支付方法调用转发到微信支付的特定方法
    this.wechatPay.payWithWechat(amount);
  }
}

// 苹果支付适配器
class ApplePayAdapter extends Payment {
  constructor(applePay) {
    super();
    this.applePay = applePay;
  }

  pay(amount) {
    // 将统一的支付方法调用转发到苹果支付的特定方法
    this.applePay.makePayment(amount);
  }
}

// 使用示例
const alipay = new Alipay();
const wechatPay = new WechatPay();
const applePay = new ApplePay();

// 使用适配器来统一支付接口
const alipayAdapter = new AlipayAdapter(alipay);
const wechatPayAdapter = new WechatPayAdapter(wechatPay);
const applePayAdapter = new ApplePayAdapter(applePay);

// 通过统一的接口进行支付
alipayAdapter.pay(200);
wechatPayAdapter.pay(150);
applePayAdapter.pay(300);

  • 统一支付接口 ( Payment ) :定义了所有支付方式都应该实现的 pay 方法,确保不同支付方式使用相同的接口。
  • 不同支付实现类
    • AlipayWechatPayApplePay 是不同支付方式的实现类,每个类有自己的支付方法,并且接口形式各不相同。
    • 比如支付宝的支付方法是 payWithAlipay,微信支付是 payWithWechat,而苹果支付是 makePayment
  • 适配器类 ( AlipayAdapter , WechatPayAdapter , ApplePayAdapter )
    • 每个适配器类都继承自 Payment,并将统一的 pay 方法转发到各自的支付实现类(如 AlipayWechatPayApplePay)的具体方法。
    • 通过适配器,你可以统一调用 pay 方法,而不需要关心实际支付方式的差异。

命令模式 (Command Pattern)

背景需求

在电商平台的 购物车 功能,用户可以将商品添加到购物车、从购物车移除商品、修改商品数量等。每个操作(如添加商品、移除商品、修改数量)都需要记录下来,并且能够撤销(比如用户决定撤销之前的添加或删除操作)。此外,这些操作可能是异步的,操作的顺序也很重要。

传统的做法可能会使得操作逻辑非常复杂,尤其是在需要记录和撤销操作时。你需要一个更简洁、灵活的方式来管理这些操作和操作的撤销。

这时 命令模式 可以帮助你将请求和执行的逻辑解耦,并提供一个统一的接口来管理所有的操作。命令模式的核心思想是将一个请求封装为一个对象,从而使你能够使用不同的请求、队列或日志来参数化其他对象。

为什么使用命令模式?

命令模式的核心思想是将请求封装成对象,使得你能够将请求发送者与请求接收者解耦。通过这种方式,你可以:

  1. 解耦请求和执行:请求发送者和请求接收者之间不直接交互,发送者只关心请求对象,而不关心具体执行的操作。
  2. 支持撤销操作:由于命令对象封装了操作细节,可以方便地实现撤销功能。
  3. 支持队列管理:命令对象可以排入队列,并可以延迟执行。
  4. 参数化对象:你可以将不同的操作通过命令对象参数化,方便扩展和管理。

代码设计思考

  • 命令接口:定义一个 Command 接口,所有具体命令都需要实现此接口。
  • 具体命令类:每个具体的操作(如添加商品、删除商品、修改商品数量)都对应一个命令类,该类实现了 Command 接口,并封装了具体操作的执行逻辑。
  • 调用者类:负责请求命令的执行,通常会持有一个命令对象并通过它来执行操作。
  • 接收者类:执行命令的实际工作,这些操作通常在接收者类中实现。

代码示例

// 命令接口
class Command {
  execute() {
    throw new Error('execute method must be implemented');
  }

  undo() {
    throw new Error('undo method must be implemented');
  }
}

// 购物车类,接收者,实际执行购物车操作
class ShoppingCart {
  constructor() {
    this.items = [];
  }

  addItem(item) {
    this.items.push(item);
    console.log(`添加商品: ${item}`);
  }

  removeItem(item) {
    const index = this.items.indexOf(item);
    if (index > -1) {
      this.items.splice(index, 1);
      console.log(`移除商品: ${item}`);
    }
  }

  updateQuantity(item, quantity) {
    console.log(`修改商品 ${item} 数量为 ${quantity}`);
  }
}

// 具体命令类:添加商品命令
class AddItemCommand extends Command {
  constructor(shoppingCart, item) {
    super();
    this.shoppingCart = shoppingCart;
    this.item = item;
  }

  execute() {
    this.shoppingCart.addItem(this.item);
  }

  undo() {
    this.shoppingCart.removeItem(this.item);
  }
}

// 具体命令类:移除商品命令
class RemoveItemCommand extends Command {
  constructor(shoppingCart, item) {
    super();
    this.shoppingCart = shoppingCart;
    this.item = item;
  }

  execute() {
    this.shoppingCart.removeItem(this.item);
  }

  undo() {
    this.shoppingCart.addItem(this.item);
  }
}

// 具体命令类:修改商品数量命令
class UpdateQuantityCommand extends Command {
  constructor(shoppingCart, item, quantity) {
    super();
    this.shoppingCart = shoppingCart;
    this.item = item;
    this.quantity = quantity;
  }

  execute() {
    this.shoppingCart.updateQuantity(this.item, this.quantity);
  }

  undo() {
    console.log(`撤销修改商品 ${this.item} 数量的操作`);
  }
}

// 命令发起者类:用于发送命令请求
class ShoppingCartInvoker {
  constructor() {
    this.commands = [];
  }

  // 执行命令
  executeCommand(command) {
    this.commands.push(command);
    command.execute();
  }

  // 撤销最后一个命令
  undoCommand() {
    const command = this.commands.pop();
    if (command) {
      command.undo();
    }
  }
}

// 使用示例
const shoppingCart = new ShoppingCart();
const invoker = new ShoppingCartInvoker();

// 创建命令对象
const addItemCommand = new AddItemCommand(shoppingCart, '商品1');
const removeItemCommand = new RemoveItemCommand(shoppingCart, '商品1');
const updateQuantityCommand = new UpdateQuantityCommand(shoppingCart, '商品1', 2);

// 执行命令
invoker.executeCommand(addItemCommand);
invoker.executeCommand(updateQuantityCommand);
invoker.executeCommand(removeItemCommand);

// 撤销命令
invoker.undoCommand(); // 撤销移除商品
invoker.undoCommand(); // 撤销修改数量
invoker.undoCommand(); // 撤销添加商品

  • 命令接口 ( Command ) :这是所有具体命令类的基类,定义了 executeundo 方法,具体的命令类必须实现这两个方法。
  • 购物车类 ( ShoppingCart ) :它是命令模式中的“接收者”角色,实际执行购物车的操作,如添加商品、移除商品、更新商品数量等。
  • 具体命令类 ( AddItemCommand , RemoveItemCommand , UpdateQuantityCommand )
    • 每个具体命令类都封装了一个操作,并实现了 executeundo 方法。
    • AddItemCommand 封装了添加商品的操作,RemoveItemCommand 封装了移除商品的操作,UpdateQuantityCommand 封装了修改商品数量的操作。
    • 每个命令对象都持有对购物车对象的引用,执行命令时通过调用购物车的相应方法。
  • 命令发起者类 ( ShoppingCartInvoker ) :它负责调用命令的 executeundo 方法,管理命令的执行顺序,并支持撤销操作。
  • 撤销功能:每个命令类不仅实现了 execute 方法,还实现了 undo 方法。这样,如果需要撤销某个操作,只需调用 undo 方法即可。

命令模式将请求封装成对象,解耦了请求发起者和请求执行者。在需要将操作请求对象化、支持撤销操作、进行操作队列管理等场景下,命令模式特别有用。它能将客户端的请求与具体的实现解耦,增强了系统的灵活性。

命令模式适用场景:

  • 操作日志与撤销功能:当需要对一系列操作进行记录和撤销时,可以使用命令模式来处理。
  • 队列管理:在需要按顺序执行一系列命令、或异步执行操作时,命令模式非常适用。
  • 事件处理:可以用命令模式来封装事件处理逻辑,尤其是当事件触发的操作比较复杂时。

状态模式 (State Pattern)

背景需求

假设你正在开发一个 视频播放器,播放器的功能包括播放、暂停、停止、快进、快退等。每种状态(如播放、暂停等)都有一组独立的行为和过渡规则。为了简化代码的复杂性,你希望能够在播放器的不同状态之间动态切换,并让播放器的行为根据状态的变化而变化。

传统的做法是通过大量的 if-elseswitch 语句来判断当前状态,并执行相应的操作。然而,随着状态的增多,这种做法会变得非常臃肿,维护起来也非常困难。你需要一个更优雅的方式来管理不同的状态和状态之间的转换。

这时,状态模式就能派上用场。它能够通过将状态封装到不同的状态对象中,避免使用过多的条件语句,使得代码更加清晰易懂,并能方便地管理状态转换。

为什么使用状态模式?

状态模式的核心思想是将对象的状态和行为封装在状态对象中,当状态发生变化时,切换到对应的状态对象来改变对象的行为,而不依赖于大量的 if-elseswitch 判断。通过状态模式,可以:

  1. 避免大量的条件语句:每个状态都可以封装为一个对象,而不需要通过条件语句来判断当前状态。
  2. 状态与行为分离:不同的状态有不同的行为,状态和行为是解耦的,修改状态的逻辑时不需要修改具体的行为实现。
  3. 更易扩展和维护:当需要增加新的状态时,只需要添加新的状态对象,而无需修改现有代码。

代码设计思考

  • 状态接口:定义一个状态接口,该接口包含与当前状态相关的行为方法。
  • 具体状态类:每个具体状态实现状态接口,封装该状态下的行为。
  • 上下文类:用于管理当前的状态对象,并在不同状态之间进行切换。

代码示例

假设我们正在开发一个视频播放器,它有 播放(Playing)暂停(Paused)停止(Stopped) 三种状态。我们希望通过状态模式来管理这些状态,并能够在这些状态之间切换。

// 状态接口
class PlayerState {
  play() {
    throw new Error('play method must be implemented');
  }

  pause() {
    throw new Error('pause method must be implemented');
  }

  stop() {
    throw new Error('stop method must be implemented');
  }
}

// 具体状态:播放状态
class PlayingState extends PlayerState {
  constructor(player) {
    super();
    this.player = player;
  }

  play() {
    console.log('视频已经在播放中');
  }

  pause() {
    console.log('暂停视频');
    this.player.setState(this.player.pausedState);
  }

  stop() {
    console.log('停止播放视频');
    this.player.setState(this.player.stoppedState);
  }
}

// 具体状态:暂停状态
class PausedState extends PlayerState {
  constructor(player) {
    super();
    this.player = player;
  }

  play() {
    console.log('继续播放视频');
    this.player.setState(this.player.playingState);
  }

  pause() {
    console.log('视频已经暂停');
  }

  stop() {
    console.log('停止播放视频');
    this.player.setState(this.player.stoppedState);
  }
}

// 具体状态:停止状态
class StoppedState extends PlayerState {
  constructor(player) {
    super();
    this.player = player;
  }

  play() {
    console.log('开始播放视频');
    this.player.setState(this.player.playingState);
  }

  pause() {
    console.log('视频已经停止,不能暂停');
  }

  stop() {
    console.log('视频已经停止');
  }
}

// 上下文:播放器类
class VideoPlayer {
  constructor() {
    this.playingState = new PlayingState(this);
    this.pausedState = new PausedState(this);
    this.stoppedState = new StoppedState(this);

    // 初始状态为停止状态
    this.currentState = this.stoppedState;
  }

  setState(state) {
    this.currentState = state;
  }

  play() {
    this.currentState.play();
  }

  pause() {
    this.currentState.pause();
  }

  stop() {
    this.currentState.stop();
  }
}

// 使用示例
const player = new VideoPlayer();

player.play();  // 开始播放视频
player.pause(); // 暂停视频
player.play();  // 继续播放视频
player.stop();  // 停止播放视频
player.play();  // 开始播放视频

  • 状态接口 ( PlayerState ) :这是所有具体状态类的基类,定义了播放、暂停和停止的方法。每个具体状态类都需要实现这些方法。
  • 具体状态类 ( PlayingState , PausedState , StoppedState )
    • PlayingState 表示视频正在播放时的行为,pause 方法会将播放器的状态切换为 PausedStatestop 方法会将播放器的状态切换为 StoppedState
    • PausedState 表示视频暂停时的行为,play 方法会将播放器的状态切换为 PlayingStatestop 方法会将播放器的状态切换为 StoppedState
    • StoppedState 表示视频停止时的行为,play 方法会将播放器的状态切换为 PlayingState
  • 上下文类 ( VideoPlayer )
    • VideoPlayer 类持有不同状态的实例,并根据当前状态调用相应的方法。通过 setState 方法,播放器能够在不同的状态之间进行切换。
    • 通过调用 playpausestop 方法,播放器能够根据当前状态执行相应的操作,并动态切换到新的状态。

状态模式适用场景:

  • 对象的行为依赖于状态:当对象的行为在不同的状态下有所不同时,可以使用状态模式。例如,电商平台中的订单状态(已支付、待发货、已发货、已完成等)。
  • 状态变化复杂:当系统中存在多个状态且状态间的转换规则复杂时,状态模式提供了更清晰和结构化的方式来管理状态。
  • 避免过多的条件判断:如果代码中存在大量的 if-elseswitch 判断来决定行为,状态模式能够有效地将这些判断封装到不同的状态类中,减少代码的复杂性。

状态模式使得系统在增加新的状态时不会影响现有的代码,符合开闭原则(对扩展开放,对修改关闭)。 策略模式通过将算法或行为封装到策略类中,使得客户端可以灵活地选择不同的策略来执行不同的行为。它避免了大量的条件判断,并且便于扩展和维护。策略模式适用于多种行为之间存在切换的场景,能够将行为的变化与对象的状态变化解耦。

策略模式 (Strategy Pattern)

策略模式通过将算法或行为封装到策略类中,使得客户端可以灵活地选择不同的策略来执行不同的行为。它避免了大量的条件判断,并且便于扩展和维护。策略模式适用于多种行为之间存在切换的场景,能够将行为的变化与对象的状态变化解耦。

背景需求

在我们的电商平台支持多种支付方式,如 支付宝、微信支付、信用卡支付 等。每种支付方式的结算逻辑、支付流程等都不同。你希望能够在不同的支付方式之间进行切换,并且能够灵活地扩展更多的支付方式。

传统的做法是通过大量的 if-elseswitch 语句来判断支付方式,并在不同的支付方式下执行不同的结算流程。然而,随着支付方式的增加,维护这样的代码变得非常困难且容易出错。

这时 策略模式 就能派上用场。策略模式的核心思想是将不同的算法(或行为)封装成不同的策略对象,并让客户端根据需要动态选择策略来执行不同的行为。这样可以避免使用复杂的条件判断,并且能更方便地扩展新的策略。

为什么使用策略模式?

策略模式的核心思想是将行为的变化从客户端提取到策略对象中,使得客户端可以根据需要选择不同的行为。通过使用策略模式,可以:

  1. 避免条件语句:通过将不同的行为封装为策略对象,客户端不需要再通过条件判断来选择不同的行为。
  2. 易于扩展:当需要增加新的策略时,只需要添加新的策略类,而不需要修改现有的代码。
  3. 提高可维护性:每个策略类封装了一种行为,易于理解、修改和维护。

代码设计思考

  • 策略接口:定义一个统一的策略接口,该接口包含所有策略类必须实现的行为方法。
  • 具体策略类:每个具体的策略类实现策略接口,封装不同的行为。
  • 上下文类:用于管理策略对象,并提供切换策略的方法。

代码示例

假设我们正在开发一个 在线支付系统,支持 支付宝支付微信支付信用卡支付 三种支付方式。我们使用策略模式来封装不同的支付方式逻辑,并允许客户端根据选择的支付方式来支付。

// 支付策略接口
class PaymentStrategy {
  pay(amount) {
    throw new Error('pay method must be implemented');
  }
}

// 具体策略:支付宝支付
class AlipayPaymentStrategy extends PaymentStrategy {
  pay(amount) {
    console.log(`使用支付宝支付 ${amount} 元`);
  }
}

// 具体策略:微信支付
class WeChatPaymentStrategy extends PaymentStrategy {
  pay(amount) {
    console.log(`使用微信支付 ${amount} 元`);
  }
}

// 具体策略:信用卡支付
class CreditCardPaymentStrategy extends PaymentStrategy {
  pay(amount) {
    console.log(`使用信用卡支付 ${amount} 元`);
  }
}

// 上下文:支付上下文
class PaymentContext {
  constructor(paymentStrategy) {
    this.paymentStrategy = paymentStrategy; // 默认支付策略
  }

  setPaymentStrategy(paymentStrategy) {
    this.paymentStrategy = paymentStrategy; // 动态设置支付策略
  }

  pay(amount) {
    this.paymentStrategy.pay(amount); // 委托给当前策略执行支付
  }
}

// 使用示例
const paymentContext = new PaymentContext(new AlipayPaymentStrategy()); // 默认选择支付宝支付
paymentContext.pay(100);  // 使用支付宝支付 100 元

paymentContext.setPaymentStrategy(new WeChatPaymentStrategy()); // 动态切换为微信支付
paymentContext.pay(200);  // 使用微信支付 200 元

paymentContext.setPaymentStrategy(new CreditCardPaymentStrategy()); // 切换为信用卡支付
paymentContext.pay(300);  // 使用信用卡支付 300 元

  • 策略接口 ( PaymentStrategy ) :这是所有支付策略类的基类,定义了一个 pay 方法,具体的支付策略类需要实现此方法。
  • 具体策略类
    • AlipayPaymentStrategyWeChatPaymentStrategyCreditCardPaymentStrategy 分别代表不同的支付方式(支付宝支付、微信支付和信用卡支付)。
    • 每个策略类都实现了 PaymentStrategy 接口的 pay 方法,封装了各自的支付逻辑。
  • 上下文类 ( PaymentContext )
    • PaymentContext 类持有一个策略对象 paymentStrategy,并提供 setPaymentStrategy 方法用于动态切换策略。
    • 通过 pay 方法,PaymentContext 类将调用当前策略的 pay 方法来执行支付操作。
  • 动态切换策略:通过调用 setPaymentStrategy 方法,支付上下文可以在不同的支付策略之间进行切换。例如,用户可以在支付宝支付、微信支付和信用卡支付之间自由切换。

策略模式适用场景:

  • 多种行为的选择:当对象的行为可以根据不同的策略(或条件)变化时,可以使用策略模式。例如,不同的支付方式、不同的排序算法等。
  • 避免大量的条件判断:当代码中存在大量的 if-elseswitch 判断来选择不同的行为时,策略模式可以帮助消除这些条件判断,使代码更加简洁。
  • 需要动态切换策略:当需要在运行时根据某些条件选择不同的行为时,策略模式提供了一个灵活的机制来动态选择策略。

最后

陆陆续续写了这么多,感谢每一位坚持看到最后的朋友。如果你能从这篇文章中收获一些思路、启发或方法,那便是我最大的荣幸。设计模式只是其中的一个工具,但它的意义在于帮助我们更好地理解代码的架构,提升开发效率,简化复杂度。愿每一位开发者都能在自己的道路上走得更远,走得更稳。