多年的java编程经历,使我不自觉的在编写前端代码时,同时将一些面向对象的特性应用到前端代码中去。这样的习惯,个人认为比较适合规模相对大一点的前端工程。本篇文章,是在我编写基于websocket的实时通信业务时,遇到的扩展性的问题。虽然硬编码当然可以解决问题,但是总觉得不够优雅,下面是我对前端代码如何扩展,而又遵循开闭原则的思考过程。
一、 适合场景
我分享的思考方法,适用于同一类数据结构通信,但是需要根据其中不同的数据,前端需要采用不同的处理逻辑。在增加新的处理逻辑时,无需修改原有任何代码,只需增加新的处理逻辑代码。这完好的实现开闭原则 ------ 对修改关闭、对扩展开放。举实例来说:
我需要在产品中,增加即时通信功能,首先需要支持的是聊天,第一版中,需要支持2类数据:
- 文字
- 图片
当我们完成功能后,据市场反馈和顾客提出新的要求,需要增加支持新类型,以保持用户对于IM惯有的认知:
- 表情
- 文件
- 外链
ok,后续继续来新的需求:
- 音频
- 视频
ok,后续继续来新的需求:
- ...
ok,物联网、数字孪生场景也来了,要支持:
- 更新指定的设备数据
- 弹窗告警
- 音频告警
- 收到相关数据后,通知其他系统的设备做出响应
- ...
可以看到,这就是典型的同一种数据结构,不同的业务数据,需要不同的业务实现的场景。要不断实现支持新增业务,最常用的当然是if... else... 或者 switch ... case ...。 但是,这样的代码都在一个通信处理的函数中处理吗?如果其中的某一项或多项业务逻辑存在变更了,怎么修改?想想都头疼!
二、思考点
如何解决上面场景的持续扩展?终端考虑以下几点
2.1 数据接收和数据逻辑处理必须分离
在上面的IM需求中,一个客户端和服务端通过同一种协议连接(websocket),在客户端数据会统一在一个点接收(比如:websocket的onmessage方法),但是,要想实现不同数据有不同业务的无侵入扩展,数据的逻辑处理,必须分离出去,而且,不同的逻辑处理也必须分离。
2.2 数据逻辑处理机制,必须支持不同处理逻辑的动态导入。
如果满足 2.1 的要求,不同的数据处理逻辑彼此分离,比如处理文字的,和处理图片是2个函数。那么,在扩展增加新的出来逻辑时,原有的数据逻辑处理机制,必须能够自动识别新增的处理逻辑,而不需要去修改原有的代码。
这里提供一种思路,基于常用设计模式-工厂模式、单例模式,以及利用vite的静态导入,就可以实现无侵入扩展和修改。具体如下:
三、思路
3.1 通过 vite 的静态导入,来实现动态不同业务处理逻辑的动态导入。
我们知道:vite对于静态资源的导入处理有多种方式,import.meta.glob 函数支持从文件系统导入多个模块。前端代码则可以利用此函数,来实现将多个文件(每个文件实现一种数据处理逻辑)集中导入到一个数据结构中。在使用时,只需要从这个数据结构中,取出适合的处理逻辑实例。
3.2 通过工厂模式、接口来分离业务处理逻辑
工厂模式,就是集中创建对象。放在当前的需求场景下,就是利用工厂模式来集中创建和取出所有的不同 数据处理逻辑对象
通过接口来分离业务处理逻辑,一方面可以规范主要的数据结构和属性,一方面可以规范处理动作。好处是谁用谁知道。
四、解决实现
本文使用 typescript实现,js实现是一个原理,不再单独列出。
4.1 文件结构
主要实现有4类文件,如下图所示:
- IHandler.ts 业务处理接口定义
- HandlerFactory.ts 处理器工厂类
- handlers/*.ts 不同的业务处理类
- 调用代码,不在此目录
4.1 定义接口
我们首先,需要定义处理数据的接口。规定,所有的数据业务处理类,必须实现此接口:
ts
export interface IHandler {
handler(data: string): void; // 规范处理行为
cmd(): number; // 标识处理行为
}
接口 IHandler,定义的所有数据处理的统一处理函数: hander ,此函数接收一个 string 类型的数据(当然可以是其他你需要的任何类型)。不同的业务处理逻辑,自行实现此函数。
cmd函数,规定返回一个number类型。此函数的目的在于标识不用的实现类。可通过下方代码来理解这一点。
4.2 定义工厂类
工厂类的主要职责,是将不同的业务实现类,根据不同标识,动态导入类中的一个数据结构中,并对外提供接口,来获取不同的业务处理类实例。代码如下:
ts
import { IHandler } from './IHandler';
export default class HandlerFactory {
// 工厂实例
private static ins: HandlerFactory;
// 对象容器
private handlers = new Map();
private constructor() {
//this.initIns();
}
// 利用vite的静态导入,动态生成实例,并存入对象容器
private async initIns() {
const modules = import.meta.glob('./handlers/*.ts');
for (const path in modules) {
const file = await modules[path]();
const myClass = file.default;
const ins = new myClass() as IHandler;
this.handlers.set(ins.cmd(), ins);
}
}
// 获取某个实例
public getHandler(code: number): IHandler {
return this.handlers.get(code);
}
// 获取工厂实例
public static getIns(): Promise<HandlerFactory> {
return new Promise(async (resolve) => {
if (!this.ins) {
this.ins = new HandlerFactory();
await this.ins.initIns();
resolve(this.ins);
}
resolve(HandlerFactory.ins);
});
}
}
4.2.1 getIns方法
代码中可以看到,工厂类的使用入口是一个异步的静态方法 getIns,该方法返回工厂类的唯一实例对象。由于initIns是一个异步方法,因此需返回一个Promise对象。此处使用了单例模式。为简化起见,代码中仅处理了正常的情况。
4.2.2 initIns 方法
initIns方法的主要职责,是动态的引入某个目录下(此处是相对路径 : handlers)下的所有ts文件,并将这些ts文件转换为类的实例,存入工厂实例的map中去。
其中,this.handlers.set(ins.cmd(), ins); 此行代码就将该对象实例,以其cmd()的值为key,存入map中,便于通过此key来获取实现类实例。
4.2.3 getHandler 方法
getHandler方法,提供了获取根据不同业务处理类实例标识,获取业务处理类实例对象的功能。
4.3 定义业务实现类
此处,定义2个实例的处理
Handler1 代码:
ts
import { IHandler } from '../IHandler';
export default class Handler1 implements IHandler {
handler(data: string): void {
console.log('我是 hander1, 收到参数:', data);
}
cmd(): number {
return 1;
}
}
Hanlder2 代码
ts
import { IHandler } from '../IHandler';
export default class Handler2 implements IHandler {
handler(data: string): void {
console.log('我是 hander2, 收到参数:', data);
}
cmd(): number {
return 2;
}
}
类 Handler1和Handler2 都实现了 IHandler接口。handler函数根据实际需要来实现不同的业务逻辑,cmd方法,则返回不同的标识。
注意:cmd() 返回的值,必须是唯一的。
4.4 调用代码
调用代码很简单,在某个代码中编写代码如下:
ts
const handlerFactoryInstance = await HandlerFactory.getIns();
function handler(cmd: number, data: string): void {
handlerFactoryInstance.getHandler(cmd).handler(data);
}
// 测试代码,此处为了说明,直接调用handler方法,手动赋值为 2
const cmd = 2;
handler(cmd, 'hello world');
代码中可以看到,我们定义了一个handler函数,此函数接收2个参数:cmd和data。当然也可以接收一个data参数,cmd是在data中的一个属性。
首先,我们获取一个工厂类的实例,自定义函数 handler(cmd: number, data: string): void ,就是业务处理函数的统一数据入口,此函数通过调用工厂实例的getHandler方法,来获取指定标识的处理器实例。然后调用实例的handler方法来调用对应的实现逻辑。
下面的测试代码,当手动指定cmd=1时,和 cmd=2时,控制台将分别打印对应的实现信息。
我们扩展新的业务处理逻辑时,只需要在handler目录下,新建实现IHandler接口的文件,并实现对应的业务处理逻辑即可。
至此,我们就实现了同构数据、不同业务实现的无侵入扩展。