设计模式在前端开发中的实践(七)——中介者模式

中介者模式

中介者模式是前端开发中出现频率属于比较中庸的设计模式,不算多,也不算少,但是在某些合理的业务场景下使用,可以极大的提高代码的维护性。

1、基本概念

中介者模式(Mediator Pattern)是一种行为型设计模式,它通过封装一系列对象之间的交互,将对象之间的复杂关系转化为中介者对象与各个对象之间的简单交互,从而降低对象之间的耦合度。

在中介者模式中,对象之间不直接相互通信,而是通过中介者进行消息传递和协调。中介者对象拥有对各个对象的引用,可以了解和控制它们之间的关系和交互方式。当一个对象需要与其他对象进行通信时,它不需要直接与其他对象进行交互,而是通过中介者来发送消息或调用方法,由中介者将消息传递给其他相关的对象。

中介者模式的UML图如下:

简单解释一下这个UML图,MediatorCollege是被提炼的抽象类,Mediator类( )里面需要持有很多个College类( )的实例,因此此处表示的是聚合关系。不同的业务,根据其划分的业务类,ConcreteMediator类实现Mediator类,ConcreteCollege类实现College类。ConcreteMediator需要和对应的ConcreteCollege进行通信,所以有一个关联关系。

在早些年我刚毕业没多久的时候,我遇到过一个系统里面很复杂的通信关系,因为组件设计的失误,导致了很多时候组件之间通信只能使用EventBus的形式。于是比较囧的场景就来了,A组件通知到了B组件,在B组件的响应了A组件的事件可能又要通知CD组件,然后CD组件可能又要通知别的组件,如果运气不好,你去修改一个需求增加新的事件,这个事件传播链因为你增加了一个,如果一不小心形成了一个环,那完犊子了。

所以,在我之前的工作经历中,这个场景就是一个比较好的使用中介者模式重构的例子。

中介者模式的好处就是实际上把通讯的关系混乱的任意两个对象之间的通信关系进行了一个统一的管理,以后都不允许任意两个对象之间直接通信了,因此它有以下优点:

  • 1、减少了对象之间的直接依赖和耦合,使得代码结构更清晰,易于维护和扩展。
  • 2、通过集中化的中介者管理对象之间的交互,简化了对象之间的通信方式
  • 3、可以提高系统的灵活性和可复用性,增加了系统的可扩展性。

像我之前提到的例子,因为中介者其实它很明确的管理事件的,所以如果需要增加新的通信链路,是不太容易出问题的,方便后续的需求迭代。

需要注意的是中介者模式不是银弹,虽然简化了对象之间的通信方式,但是中介者模式的中介类是一个比较脆弱的稳定,因为一旦有需求的增加和改变,那么中介类就可能需要调整,代码的复杂度(这儿指的是逻辑上的复杂度)并没有降低,它只是从一个地方转嫁到了另外一个地方而已。

2、代码示例

此处,我是参考自《大话设计模式》,并结合自己的实际开发经验给的一个代码示例:

ts 复制代码
/**
 * 定义业务枚举,主要是为了方便协助者方便找它要通知的对象
 */
enum Role {
  Mediator = "mediator",

  FEDeveloper = "FEDeveloper",

  BEDeveloper = "BEDeveloper",
}

abstract class Person {
  role: Role;

  setRole(role: Role) {
    this.role = role;
  }
}

abstract class Mediator extends Person {
  role: Role = Role.Mediator;

  protected colleagueList: Colleague[] = [];

  public abstract send(
    message: string,
    sender: Colleague,
    receiverRole: Role
  ): void;

  addColleague(colleague: Colleague) {
    this.colleagueList.push(colleague);
  }
}

abstract class Colleague extends Person {
  protected mediator: Mediator;

  constructor(mediator: Mediator) {
    super();
    this.mediator = mediator;
  }

  public abstract notifyMessage(sender: Colleague, msg: string): void;
}

class ConcreteMediator extends Mediator {
  public send(message: string, sender: Colleague, receiverRole: Role): void {
    const targetColleague = this.colleagueList.find(
      (p) => p.role === receiverRole
    );
    if (!targetColleague) {
      console.log("不存在目标角色");
      return;
    }
    targetColleague.notifyMessage(sender, message);
  }
}

class BackEndAColleague extends Colleague {
  constructor(mediator: Mediator) {
    super(mediator);
    this.setRole(Role.BEDeveloper);
  }

  public notifyMessage(sender: Colleague, msg: string): void {
    console.log("消息的发送方", sender);
    console.log("接受到的消息:" + msg);
  }

  public sendMessageA() {
    this.mediator.send("后端向前端发消息", this, Role.FEDeveloper);
  }
}

class FrontEndColleague extends Colleague {
  constructor(mediator: Mediator) {
    super(mediator);
    this.setRole(Role.FEDeveloper);
  }

  public notifyMessage(sender: Colleague, msg: string): void {
    console.log("消息的发送方", sender);
    console.log("接受到的消息:" + msg);
  }

  public sendMessageB() {
    this.mediator.send("前端向后端发消息", this, Role.BEDeveloper);
  }
}

(function bootstrap() {
  // 初始化中介者
  const mediator = new ConcreteMediator();
  // 初始化每个协作对象,并且需要认知中介者
  const feDeveloper = new BackEndAColleague(mediator);
  const beDeveloper = new FrontEndColleague(mediator);
  // 中介者需要认识每一个协作对象
  mediator.addColleague(feDeveloper);
  mediator.addColleague(beDeveloper);
  // 前端向后端发消息
  feDeveloper.sendMessageA();
  // 后端向前端发消息
  beDeveloper.sendMessageB();
})();

3、在前端开发中的实践

3.1 Electron应用程序中解决多窗口之间通信

在我的职业生涯中,中介者模式也是少有的从开始参加工作就掌握的设计模式。

在我刚毕业的时候,参与过一个基于Electron的金融交易系统的开发。Electron分为主进程和渲染进程,然后在主进程需要控制一些交易数据的监听,因为主进程是基于Nodejs的,可以方便使用Socket通信(因为一些业务限制,我们没有在渲染进程使用WebSocket通信),交易窗口需要不断的订阅行情数据和退订行情数据,除此之外,不同的窗口之间也有通信的需求(如果你用过东方财富这类客户端的话,可以对一个合约代码的各类参数进行展开,相当于一个屏幕需要开启好多个子窗口,这样的子窗口就有了相互通信的需求),还有通知主进程新建窗口、关闭窗口等需求,在设计之初我们就考虑到这些窗口之间的交互会很复杂(当初的领导是一个前微软程序员,软件架构能力非常强)所以就采取了中介者模式来进行窗口的通信管理。

首先有一个JS文件配置了所有的事件名称,每个事件上分别写清楚当前事件从哪儿触发,它会触发什么事件;

主进程在一启动的时候监听它需要处理的事件;

以后,渲染进程在各自的页面内引入这个配置文件,直接使用ipcRender触发对应的事件到主进程。

主进程的代码如下(节选):

js 复制代码
const electron = require("electron");
const BrowserWindow = electron.BrowserWindow;
const MainLib = require("./main-process-lib").MainLib;

class UiManager extends mainLib {
  listen2Events() {
    /* 实际上很有很多监听的代码,为了篇幅,我将其删除 */
    this.ipcMain.on(this.systemEvent.tellTabChange, (event, params) => {});

    this.ipcMain.on(this.systemEvent.tellSubscribeQuote, (event, params) => {});

    this.ipcMain.on(this.systemEvent.templateCreated, (event, params) => {
      if (this.riskControlWindow !== null) {
        this.riskControlWindow.close();
      }
      // 协调向业务窗口发送数据
      if (this.centralWindow !== null) {
        this.centralWindow.webContents.send(
          this.systemEvent.deliveryTemplateCreated,
          params
        );
      }
    });
  }
}

const manager = new UiManager();
manager.listen2Events();

在业务窗口上的处理(节选):

js 复制代码
const DisposablePageController = require("../../module/disposable-controller");
class PageController extends DisposablePageController {
  viewReportTemplate(product) {
    // this.ipcRender是定义在DisposablePageController的
    this.ipcRender.send(
      this.systemEvent.otherWindow2Dashboard,
      this.systemEvent.ask2OpenApp,
      {
        openerId: `opener-management-product-report-${identity}`,
        appUrl:
          path.join(__dirname, "../../ui-template/management/new-report.html") +
          `?identity=${identity}&identityName=${identity_name}&type=product` +
          `${templateId ? "&defaultTemplateId=" + templateId : ""}` +
          `${restrictTpls ? "&restricts=" + restrictTpls : ""}`,
        appName: `产品报告 > ${identity_name}`,
        shouldDestroy: true,
      }
    );
  }
}

module.exports = { WidgetClass: PageController };

其它的业务窗口发送事件也是通过相同的方式,我就不再赘述了。

3.2 表单组件中处理

在表单处理中,我们会遇到一些级联业务的场景,这种业务场景可能并不是简单的省市县级联(如果是简单的省市县倒是可以抽离到一个单独的组件中),以下是我的一个实际项目的例子: 以上的消耗配置是一个数组,消耗配置跟奖池配置有关联,也就是说,我修改了消耗配置里面的某一项,我需要同步禁用我填写的某个礼物。

在这个场景下,因为消耗配置项是一个组件,奖池配置项是一个组件,如果直接使用EventBus是无法解决的,因为不知道谁要同步给谁。

此刻就是中介者模式大显身手的时刻了。首先,我们在表单组件渲染的时候监听子组件的渲染,这个位置,为了避免每个组件都去设置监听,我们用子组件回调父组件的方式。

vue 复制代码
<template>
  <div>
    <el-form ref="ruleForm" :model="form" label-width="180px" :rules="rules">
      <x-control v-for="item in controls" :type="item.type" :key="item.key" />
    </el-form>
  </div>
</template>

<script>
export default {
  name: "FormRender",
  data() {
    return {
      formSet: new Set(),
    };
  },
  created() {
    // 在组件创建的时候就设置监听
    this.$on("form-control-mounted", ({ component, type }) => {
      if(this.controls[type]) {
          this.controls[type].instance = component;
      }
    });
    this.$on("select-change", ({ component }) => {
      const intanceList = Object.values(this.controls).map(v => v.instance)
      intanceList.forEach(control => {
          // 除了来源组件,其余都通知,这种逻辑具体看你实际的业务,我展示的仅仅是一个demo
          if(control !== component) {
              // 模拟触发别的组件的回调
              control.$emit("on-change");
          }
      })
    })
  },
};
</script>

在子组件渲染的时候,触发父组件的监听:

vue 复制代码
<template>
  <el-input v-model="keyword" />
</template>

<script>
export default {
  name: "XInput",
  props: {
    type: String,
  },
  data() {
    return {
      form: null,
    };
  },
  mounted() {
    this.triggerEvent();
    this.setupListeners();
  },
  methods: {
    setupListeners() {
        this.$on('select-change', () => {
            // 仅仅展示的是一个demo,具体的业务请根据您的实际情况进行处理。
            console.log('something was changed');
        })
    },
    triggerEvent() {
      let parent = this.$parent;
      while (parent) {
        if (parent.$options && parent.$options.name === "FormRender") {
          parent.$emit("form-control-mounted", {
            component: this,
            type: this.type,
          });
          // 把最底层的form组件保存下来,后面使用的时候直接用
          this.setForm(parent);
          break;
        }
        // 一直向上递推
        parent = parent.$parent;
      }
    },
    setForm($form) {
      this.form = $form;
    },
  },
};
</script>

这样,Form表单里面就可以持有所有的表单组件的实例了。

现在,假设,我的一个组件发生了变化,告诉表单需要跟谁通信,然后表单就会把相应的数据推送至其它的组件。

3.3 插件系统通信

在插件系统中,插件之间的通信也可以采用这种方式(另外一个方式还可以使用依赖注入的方式,但是得事先准备一个IoC容器,IoC容器的设计与较为复杂)

首先,在编写插件的时候,我们可能是不知道要和谁通信的(假设你就是这个插件系统中的第一个插件,目前一个插件都没有,肯定是不知道将来有什么插件的),但是为了可扩展性,我们对外暴露出别人控制我们的能力,我们就可以监听插件管理者(即中介者)的一些事件。

当后续的开发人员开发自己的插件的时候,知道目前有一个我们这样的插件存在,然后阅读我们的插件API,知道怎么样触发我们监听的事件,从而配合完成系统中的逻辑。

中介者模式的优势在这儿主要能体现出3点:

  1. 降低耦合度: 插件之间不直接相互作用,而是通过中介者进行通信,这降低了耦合度,使得插件的添加、删除或修改变得更加灵活和容易。
  2. 增强扩展性: 新插件可以轻松地集成到系统中,只需与中介者交互,而无需了解系统的内部工作方式。
  3. 简化维护: 如果系统中的通信模式需要更改,只需修改中介者的逻辑,而无需修改所有的插件。

总结

正如上文所说,中介者所负责的业务是相当的复杂的,中介者的通信的管理代码也是最容易被改动的代码,但是好处就是脆弱的代码被抽离到了一处统一维护,使得别处的代码非常稳定,这样设计的好处就是各个窗口的代码极大的得到简化,提升的效率肯定是大于修改的效率的。

在实际项目中,在决定是否使用中介者模式之前,请一定要做好系统的评估,因为有时候错误的使用了中介者模式的话,反正会增加系统的维护难度,一定要避免过度设计。

中介者模式的使用场景是比较容易判断的,一旦出现多个对象之间通信,并且后期业务复杂度还有增加可能性,请尽早用中介者模式重构。

相关推荐
gqkmiss30 分钟前
Chrome 浏览器插件获取网页 iframe 中的 window 对象
前端·chrome·iframe·postmessage·chrome 插件
m0_748247553 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php
m0_748255023 小时前
前端常用算法集合
前端·算法
真的很上进3 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web130933203983 小时前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
NiNg_1_2344 小时前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
如若1234 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~5 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语5 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport5 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap