【前端探索】告别烂代码第二期!用策略模式封装分享组件

2023-04-22 16:09:46 浏览数 (1)


前言

为什么写本文?主要是通过对一个业务组件的代码重构,交流一下前端封装组件需要注意的问题。这中间也会穿插一些设计模式的小知识点,方便温故知新,如果有理解不准确和设计上不合理的地方,欢迎指正。

对设计模式的理解,前段时间在一篇《设计模式泛谈》文章中读到的一段话,不能再赞成,在这里贴一下:

要看一段代码是不是好的代码不是要生搬硬套看它符合什么样的模式,而是看它是否符合下面的五个设计原则。设计模式只是一个表面的套路,设计原则才是中心思想。或者说设计模式只是一个手段,设计原则才是目的。正如张三丰对张无忌对话的一样: “无忌你记住了吗” “全忘了” “那你可以上场了” 设计模式会随着语言的进步逐步变化和演进,但是设计原则是不会变的。 ——《设计模式泛谈》

将会学到

  • 六大设计原则
  • 策略模式的使用
  • 如何封装一套适合多种环境的分享组件

分析一下

首先分析一下,为什么需要封装分享功能?

我们的需求是什么?

是一行代码,在多个场景下,都能实现分享到QQ好友、微信好友、QQ空间、微信朋友圈的功能。

这里的多个场景,简单来说就是在不同的app的内嵌webview,就我们项目的H5页面来说,需要能支持分享的场景有:

  • QQ
  • 微信
  • 微信小程序
  • 王者人生app
  • 王者营地app
  • 掌上英雄联盟app
  • 游戏内的msdk浏览器(区分v3和v5浏览器)
  • 游戏内的slug浏览器
  • 系统自带浏览器
  • 等等。

我们需要自己实现这个分享的组件,明显这么多的场景,每次业务方自己去判断调哪个分享,明显是不合理的,我们需要的是一行代码就完成分享的功能。

重构前代码

我们一开始的封装也的确是这样的,外部使用分享组件的时候,不需要关心具体的场景,只要调一行initShare(options)来初始化分享配置,或者一行openShareUI()来弹出分享弹窗就可以了。

明显,这很符合“接口隔离原则”:暴露给用户的接口小而完备。

但是我们原来的分享组件,内部实现却存在着一些问题:

原来的分享组件是一个share.js,我们先来看下其代码实现:

代码语言:javascript复制
const shareUiObj = {};
// 处理给msdk和slugsdk用的分享UI
shareUiObj.initCommShareUI = function (callback) {
  // ...
};
// 处理给微信和QQ用的分享UI
shareUiObj.initCommShareTip = function () {
  // ...
};
/**
 * 打开自定义的分享UI组件
 * @exports openShareUI
 */
const openShareUI = function () {
  // ...
};
/**
 * 初始化方法 obj:{title:'标题',desc:'描述',icon:'图标',link:'地址',callback:''}
 * @exports initShare
 * @param obj 分享参数
 */
const initShare = function (obj) {
  if (typeof obj === 'undefined') obj = {};
  obj.title = obj.title || document.getElementsByTagName('title')[0].innerText;
  obj.desc = obj.desc || obj.title;
  obj.link = obj.link || window.location.href;
  obj.icon = obj.icon || 'http://ossweb-img.qq.com/images/pmd/igameapp/logo/log_igame_3.0.png';
  obj.type = obj.type || null;
  shareObject = obj;
  isWzydShare = obj.isWzydShare;

  const env = getEnv();
  if (env.isMsdk) {
    initMsdkShare();
  } else if (env.isGHelper) {
    initGHelperShare();
  } else if (env.isQQ) {
    initQQShare();
  } else if (env.isMiniProgram) {
    initMiniProgramShare();
  } else if (env.isWeixin) {
    initWeixinShare();
  } else if (env.isPvpApp) {
    initPvpShare();
  } else if (env.isTipApp) {
    initTipShare();
  } else if (env.isSlugSdk) {
    initInGameShare();
  }
};
// msdk分享
function initMsdkShare() {
  // ...
}
// slugsdk分享
function initInGameShare() {
  // ...
}
// 王者营地分享
function initGHelperShare() {
  // ...
}
// QQ分享
function initQQShare() {
  // ...
}
// 微信分享
function initWeixinShare() {
  // ...
}
// 小程序分享
function initMiniProgramShare() {
   // ...
}
// ...其他环境的实现

存在的问题

可以发现存在一些问题:

  1. 如果需要新增一个环境,我们对外暴露的initShare就必须要修改。明显这不符合“开闭原则”:对拓展开放,对修改封闭。
  2. 同个文件中,有各个环境下分享的实现,还有UI的处理,整个share.js有500 行代码,不符合“单一职责原则”:一个类的职责只有一个。
  3. 对类型没有校验,内部实现或者是外部调用接口的时候,如果有传参错误,难以发现。

显然,这段代码的优化空间很大

上面已经卖弄了三个设计模式的原则了,我们再来看下全部六大设计原则,看看我们还有那些可以优化的地方:

  1. 依赖倒置原则:高层模块不应该依赖底层模块。
  2. 开闭原则:对拓展开放,对修改封闭。
  3. 单一职责原则:一个类的职责只有一个。
  4. 替换原则(里氏替换原则):子类必须能够替换父类。
  5. 接口隔离原则:暴露给用户的接口小而完备。
  6. 迪米特法则(最少知识原则):一个类应该对其他类保持最少了解。

替换原则”的前提是面向接口编程,原先的实现并没有做到,但是分析我们的需求:对同个功能(分享)做多个实现(支持多个环境),面向接口编程显然是很好的选择,而“依赖倒置原则”和“迪米特法则”在我们后面进行面向接口编程的时候,也是需要注意的。

操作起来

下面开始动手吧!

制定方案

为了有更好的类型支持,我们使用TypeScript来实现组件,虽然我们的前端工程还是vue2,但是js代码中混用ts组件,没有什么问题,我们只需要添加ts的配置文件tsconfig.json,再安装下面这两个依赖就好了。

代码语言:javascript复制
npm install --save-dev typescript
npm install --save-dev @vue/cli-plugin-typescript

再结合我们上面确定的,要面向接口编程,设计模式中有一个“策略模式”很适合我们这种多环境支持的需求。

确定接口

首先我们需要确定对外提供什么样的接口,就像我们原来的实现一样,我们需要:

  • initShare接口,初始化分享配置
  • openShareUI接口,弹出分享弹窗

所以我们有这样的接口设计:

代码语言:javascript复制
export interface ShareOptions {
  title: string;  // 分享标题
  desc: string;   // 分享描述
  link: string;   // 分享链接
  icon: string;   // 分享缩略图
  miniprogramLink?: string;  // 小程序跳转链接,可选
  customShare: any;          // 自定义分享配置,可选
  callback: () => void       // 分享成功回调
}

export interface ShareInterface {
  initShare(options: ShareOptions): void;
  openShareUI(): void;
}

实现接口

接口确定后,我们的各个环境下的实现,就是对接口的实现,比如下面这个QQ的分享:

代码语言:javascript复制
import { ShareInterface, ShareOptions } from '../share-interface';
import loader from '../../little-loader';
import ShareUI from '../share-ui';

// QQ分享接口参考文档:// http://mqq.oa.com/api.html#js-mqq-core
export default class ShareQQ implements ShareInterface {
  public initShare(options: ShareOptions) {
    console.log('QQ分享', options);
    ShareUI.initCommShareTip();
    loader('https://open.mobile.qq.com/sdk/qqapi.js?_bid=152', () => {
      const { mqq } = window as any;
      mqq?.ui?.setOnShareHandler((type: any) => {
        if (type == 0 || type == 1 || type == 2 || type == 3) {
          const param = {
            title: options.title,
            desc: options.desc,
            share_type: type,
            share_url: options.link,
            image_url: options.icon,
            back: true,
            uinType: 0,
          };
          const callback = function () {
            options.callback?.();
          };
          mqq.ui.shareMessage(param, callback);
        }
      });
    });
  }
  public openShareUI(): void {
    ShareUI.showCommShareTip();
  }
}

其他环境下,也是类似这样实现,各种实现之前没有耦合,这符合“迪米特法则”。

抽取公共功能

为了符合“单一职责原则”,可以看到,上面代码中,对UI的处理,独立抽取出来,作为一个ShareUI的类,单独负责分享UI相关的功能。

对外暴露接口

最后,我们需要做的事对外暴露我们实现的功能。因为分享是一个全局的功能,我们最好对外提供一个分享处理的单例,使用者直接调用这个单例提供的功能。

代码语言:javascript复制
class Share {
  private static instance: Share = new Share();

  public static getInstance() {
    return this.instance;
  }

  public static initShare(options: ShareOptions|any) {
    // options填充默认数据
    if (typeof options === 'undefined') options = {};
    options.title = options.title || document.getElementsByTagName('title')[0].innerText;
    options.desc = options.desc || options.title;
    options.link = options.link || window.location.href;
    options.icon = options.icon || 'http://ossweb-img.qq.com/images/pmd/igameapp/logo/log_igame_3.0.png';

    this.getInstance().sharehandle.initShare(options);
  }

  public static openShareUI() {
    this.getInstance().sharehandle.openShareUI();
  }

  private sharehandle: ShareInterface;

  constructor() {
    const env = initEnv();
    if (env.isMsdkV5) {
      this.sharehandle = new ShareMSDKV5();
    } else if (env.isMsdk) {
      this.sharehandle = new ShareMSDKV3();
    } else if (env.isGHelper) {
      this.sharehandle = new ShareGHelp();
    } else if (env.isQQ) {
      this.sharehandle = new ShareQQ();
    } else if (env.isMiniProgram) {
      this.sharehandle = new ShareMiniprogram();
    } else if (env.isWeixin) {
      this.sharehandle = new ShareWx();
    } else if (env.isPvpApp) {
      this.sharehandle = new SharePvpApp();
    } else if (env.isTipApp) {
      this.sharehandle = new ShareTipApp();
    } else if (env.isSlugSdk) {
      this.sharehandle = new ShareSlugSdk();
    } else if (env.isLolApp) {
      this.sharehandle = new ShareLolApp();
    } else {
      this.sharehandle = new ShareOther();
    }
  }
}

export default Share;

可以看到,我们对外提供的initShare和openShareUI方法中,并不需要关心具体是什么环境,只管调用sharehandle的方法就可以了,具体环境的判断,在构建方法里面已经处理了。

同时我们可以发现,我们完全可以用ShareQQ替换sharehandle这个接口,这就是“替换原则”。

组件的目录结构

最后,我们再来看一下分享组件现在的结构,虽然代码量不一定减少,但确确实实比原来的500 行代码清楚得多。

如何扩展组件?

如果我们现在需要新增一种环境,我们需要做什么呢?

在重构后,我们只需要在plat目录下增加一种对接口的实现,在构造函数里面加下环境判断,就可以了。真实暴露给外部的方法,我们并不需要修改,而且我们也不要去修改,这就是“开闭原则”。

注意啦

再贴一下我对设计模式的理解,这个在第一期也有提到,但是真的很重要。

  • 使用TypeScrpit的优势,除了类型检查,更重要的是“更好的面向接口编程”!!
  • 使用什么样的设计模式不重要!!!设计模式只是让设计符合设计原则的手段,即使没学设计模式,符合设计原则的也是好代码。
  • 设计模式还是要学!!!学了设计模式能更快设计出符合设计原则的好代码。

参考文献

【前端学习笔记】TypeScript 快速上手

设计模式泛谈

策略模式

0 人点赞