设计模式在前端开发中的实践(十五)——外观模式

外观模式

外观模式,其实对于良好的设计系统软件绝对能看到外观模式的身影,如果要说它是一种设计模式,倒不如说它是一种设计思想。

在软件开发中,一旦你理解了封装继承多态的这些面向对象的特征,你就一定掌握了外观模式,即使在这之前你没有听说过它。

1、基本概念

外观模式(Facade),为子系统中的一组接口提供一个一致的界面,此模式定义了一个高层接口,这个接口使得这个子系统更加容易使用。

外观模式是依赖倒置原则和迪米特法则的体现。

依赖倒置原则强调两个点:

  • 高层模块不应该依赖于低层模块,两者都应该依赖于抽象。
  • 抽象不应该依赖于细节,细节应该依赖于抽象。

在外观模式中,高层的客户端代码不直接与系统的复杂底层(如多个子系统或模块)交互,而是通过一个外观接口进行交互。这个外观接口提供了一个简化的方式来访问底层功能,而底层模块的复杂性被封装在外观后面。因此,客户端代码依赖于这个抽象的外观接口,而不是具体的实现细节,这正是依赖倒置原则的体现。

迪米特法则又被称为"最少知道原则",其核心思想是"一个对象应该对其他对象有最少的了解。",外观模式就通过把底层一些复杂的实现细节封装起来,降低了系统的耦合性,这正是迪米特法则的体现。

外观模式的UML图如下:

在这个图里面,SubSystem Classes框选起来的那一部分我们都不需要关注,只有一个Facade类对外暴露了这个系统的能力,你就只需要按照API调用即可。

2、代码范式

这个代码范式仅仅是一个非业务场景的例子,因为外观模式展示的是一种设计思想,开发中需要根据自己的业务酌情处理即可。

ts 复制代码
class SubsystemOne {
    public methodOne() {
        console.log('do work in method one');
    }
}

class SubsystemTwo {
    public methodTwo() {
        console.log('do work in method two');
    }
}

class SubsystemThree {
    public methodThree() {
        console.log('do work in method three');
    }
}

class SubSystemFour {
     public methodFour() {
        console.log('do work in method four');
    }
}

class Facade {
    // 全部声明为私有属性,对外隐藏实现细节
    private one = new SubsystemOne();
    
    private two = new SubsystemTwo();
    
    private three = new SubsystemThree();
    
    private four = new SubsystemFour();
    
    // 对外暴露doWork1方法1
    public doWork1() {
        this.one.methodOne();
        this.three.methodThree();
        this.four.methodFour();
    }
    // 对外暴露doWork2方法2
    public doWork2() {
       this.two.methodTwo();
       this.four.methodFour();
    }
}

function bootstrap() {
    const facade = new Facade();
    facade.doWork1();
    facade.doWork2();
}

3、在前端开发中的实践

3.1、LocalForage

localForage 中文文档 (docschina.org)

localForage 是一个 JavaScript 库,通过简单类似 localStorage API 的异步存储来改进你的 Web 应用程序的离线体验。它能存储多种类型的数据,而不仅仅是字符串。

localForage 有一个优雅降级策略,若浏览器不支持 IndexedDB 或 WebSQL,则使用 localStorage。

js 复制代码
// 通过 localStorage 设置值 
localStorage.setItem('key', JSON.stringify('value')); doSomethingElse(); 
// 通过 localForage 完成同样功能 
localforage.setItem('key', 'value').then(doSomethingElse); 
// localForage 同样支持回调函数 
localforage.setItem('key', 'value', doSomethingElse);

我们可以保持跟localStorage大致相同的API的前提下,能够满足我们的存储需求。

比如,如果在前端缓存一些二进制数据流,数据可能会比较大,对于localStorage来说,一个域下面仅仅5M左右,(而且流文件转换到字符串还需要进行编码)满足不了我们的需求,localForage自动为我们切换到IndexedDB存储的话,我们使用起来就跟使用localStorage那么简单,在这个过程中,localForage底层是有很复杂的处理流程的,只不过它把这些细节都隐藏起来了,我们看到的就是预期的数据被存储到了指定的位置而已,所以说,这就是一种外观模式的实际场景。

3.2 Axios

这个是每个前端开发者都熟悉的不能再熟悉的一个库了。

浏览器暴露给我们的XMLHttpRequest是很复杂的,如果我们直接使用XHR对象的话,编程效率低。

而经过Axios封装之后,我们再操作XHR就简单的几个配置就完成了,这也是外观模式的实际场景。

还有一个关键点,在不同的环境下,想要发送请求的方式是不一样的,在浏览器中,使用XHR对象或者fetch,在Nodejs环境中,使用node的原生模块http,如果没有axios这个外观类,那我们还需要考虑不同的环境,而有了它之后,我们仅仅只需要无脑的按照axios的配置发送请求即可。

3.3 NestJS中的分层设计

因为严格来说,很多npm包都是为了解决某个领域下编程的便利性而进行的封装,所以它们都是外观模式的体现,我如果继续举这种例子的话,就有点儿水文的感觉了,因此,这节中,给大家一个我的实际编码例子。

我以我的最近所开发的BFF来举例。

业务逻辑层仅仅需要获取数据访问层拿到的数据,至于数据访问层怎么拿?用什么方式拿?业务逻辑层不用管,反正我就只需要给你预期规格的数据即可。

以下是我的代码设计:

首先是,数据访问层的接口定义

ts 复制代码
/**
 * 数据访问层的接口定义
 */
export interface IActEngineRepository {
  /**
   * 获取活动配置
   * @param param0
   * @returns
   */
  getConfig({ userId, actId }: ActGeneralParams): Promise<ConfigsListDto>;

  /**
   * 获取活动的信息
   * @param param
   * @returns
   */
  getActivityInfo({ userId, actId }: ActGeneralParams): Promise<ActInfoDto>;
  
  /**
   * 获取榜单信息
   * @param param0
   */
  getFullRankInfo({ userId, actId, rankId }: ActRankParams): Promise<RankDto>;
}

然后是通过gRPC获取业务服务器的数据的实现(省略了一部分代码,仅仅表达一个业务封装的含义)

ts 复制代码
/*
 * 以gRPC通信方式获取业务后端的数据
 */
@Injectable()
export class ActEngineGrpcRepository
  extends BaseActEngineRepository
  implements IActEngineRepository
{
  @Inject()
  protected readonly actParserService: ActEngineEntityParserService;

  /**
   * 获取活动配置
   */
  @AutoBind
  public async getConfig({
    userId,
    actId,
  }: ActGeneralParams): Promise<ConfigsListDto> {
    const configPromise = promisify<Empty, Metadata, Web.ConfigsReply>(
      this.client.configs,
    );
    const p = configPromise();
    const configs = await this.safetyPromise(p).then((response) =>
      response.toObject(),
    );
    return configs;
  }

  /**
   * 获取活动的信息
   * @param param
   * @returns
   */
  @AutoBind
  public async getActivityInfo({
    userId,
    actId,
  }: ActGeneralParams): Promise<ActInfoDto> {
    const actInfoPromise = promisify<Empty, Metadata, Web.ActInfoReply>(
      this.client.actInfo,
    );
    const p = actInfoPromise();
    const activityInfo = await this.safetyPromise(p).then((response) =>
      response.toObject(),
    );
    return activityInfo;
  }

  /**
   * 获取完整的榜单信息,内聚了获取列表和自己所处该榜单中位置的信息,隐藏了细节
   * @returns
   */
  public async getFullRankInfo({
    userId,
    actId,
    rankId,
    size,
  }: ActRankParams): Promise<RankDto> {
    const rankList = await this.getRankList({ userId, rankId, actId, size });
    let own: ActRankItem;
    if (userId) {
      own = await this.getOwnInfo({ userId, rankId, actId });
    } else {
      own = null;
    }
    return {
      list: rankList.listList,
      own,
    };
  }

  // 私有方法,内部细节,不对外暴露
  private async getRankList({
    userId,
    rankId,
    actId,
    dateTime,
    size = 100,
  }: ActRankParams): Promise<
    Omit<Web.RankDetailReply.AsObject, 'listList'> & {
      listList: Array<ActRankItem>;
    }
  > {
    const rankPromise = promisify<
      Web.RankDetailRequest,
      Metadata,
      Web.RankDetailReply
    >(this.client.rankDetail);
    const request: Web.RankDetailRequest = new Web.RankDetailRequest();
    const p = rankPromise(request);
    const rankInfo = await this.safetyPromise(p).then((response) =>
      response.toObject(),
    );
    return {
      ...rankInfo,
      listList: rankInfo.listList.map((v) => {
        return this.getRankDetail(v);
      }),
    };
  }

  // 私有方法,内部细节,不对外暴露
  private getRankDetail(input: CommonEntity.RankItem.AsObject): ActRankItem {
    return omit(
      {
        ...input,
        detail: input.user || input.work || input.room,
      },
      ['user', 'work', 'room'],
    );
  }
  
  // 私有方法,内部细节,不对外暴露
  private async getOwnInfo({
    userId,
    rankId,
    actId,
    dateTime,
  }: ActRankParams): Promise<ActRankItem> {
    const rankPromise = promisify<
      Web.RankMemberRequest,
      Metadata,
      CommonEntity.RankItem
    >(this.client.rankMember);
    const request: Web.RankMemberRequest = new Web.RankMemberRequest();
    const p = rankPromise(request);
    const rankInfo = await this.safetyPromise(p).then((response) =>
      response.toObject(),
    );
    return this.getRankDetail(rankInfo);
  }
}

然后对外暴露的是实现类,而依赖注入的依据是一个特征的Token信息:

ts 复制代码
@Module({
  providers: [
    {
      provide: ACT_ENGINE_REPO_TOKEN,
      useClass: ActEngineGrpcRepository,
    },
  ],
  exports: [ACT_ENGINE_REPO_TOKEN],
})
export class ActEngineModule {}

最后,业务逻辑层使用数据外观类:

ts 复制代码
@Injectable()
export class CollectionService {
  @Inject(ACT_ENGINE_REPO_TOKEN)
  protected readonly actEngineRepo: IActEngineRepository;
  
  // 获取活动的配置信息
  public getActConfig(params: ActParams) {
      return this.actEngineRepo.getConfig(params);
  }
}

这样的设计,一方面可以根据需求灵活的切换和业务后端的通信方式,也能隔绝业务后端的变更,起到一种"防腐"(业务后端的调整,不至于影响到下游的软件设计,不明白的同学可以查看我的这篇文章->设计模式在前端开发中的实践(六)------适配器模式 - 掘金 (juejin.cn))的作用。

总结

外观模式是一个几乎必用的设计模式,如果你之前不知道什么是外观模式,也能写出可维护性较好的代码,那么请你现在也忘掉什么是外观模式(因为你知道的多了,可能反而会对你形成一种束缚,实战开发中总结出来的经验,要比在书中按图索骥好的多)。

在软件设计的过程中,我们应该有意识的考虑到软件的分层,层与层之间就形成外观模式的Facade;另外,就是我们在维护屎山的时候,我们可以开发一个Facade来包裹住这个屎山(就好比家丑不外扬,哈哈哈),这样在外部调用的时候,可以有效降低出错的可能性。

所谓实践出真知,大家就根据自己的理解去编码吧,Good Luck!

如果大家喜欢我的文章,可以多多点赞收藏加关注,你们的认可是我最好的更新动力,😁。

相关推荐
茶茶只知道学习几秒前
通过鼠标移动来调整两个盒子的宽度(响应式)
前端·javascript·css
蒟蒻的贤3 分钟前
Web APIs 第二天
开发语言·前端·javascript
清灵xmf7 分钟前
揭开 Vue 3 中大量使用 ref 的隐藏危机
前端·javascript·vue.js·ref
su1ka11112 分钟前
re题(35)BUUCTF-[FlareOn4]IgniteMe
前端
测试界柠檬14 分钟前
面试真题 | web自动化关闭浏览器,quit()和close()的区别
前端·自动化测试·软件测试·功能测试·程序人生·面试·自动化
多多*15 分钟前
OJ在线评测系统 登录页面开发 前端后端联调实现全栈开发
linux·服务器·前端·ubuntu·docker·前端框架
2301_8010741515 分钟前
TypeScript异常处理
前端·javascript·typescript
ᅠᅠᅠ@16 分钟前
异常枚举;
开发语言·javascript·ecmascript
小阿飞_17 分钟前
报错合计-1
前端
caperxi18 分钟前
前端开发中的防抖与节流
前端·javascript·html