乘风原创程序

  • 如何在Vue项目中添加接口监听遮罩
  • 2021/1/25 11:30:07
  • 一、业务背景

    使用遮罩层来屏蔽用户的非正常操作,是前端经常使用的方式。但是在一些项目中,并没有对遮罩层进行统一管理,这就会造成如下的问题:
    (1)所有的业务组件都要引入遮罩层组件,也就是每个.vue业务组件,都在template中引入了Mask组件。组件在项目的各个角落都存在,不利于管理,代码极度冗余。
    (2)Mask组件都分散到业务的各个角落,所以控制是否显示遮罩层的变量也散在业务组件中。比如使用maskShow来控制是否展示遮罩层时,一个较为复杂的项目中会产生200+的maskShow变量。
    (3)maskShow过多且融入在业务中,同时maskShow的变量往往写在接口的回调函数中,经常会出现忘记改变变量的情况,造成遮罩层该显示和不该显示的逻辑出错。
    (4)项目经常是在本地调试,而真实运行却又在线上,(3)中的问题在本地经常无法验证出。因为这些问题经常是在线上网络环境较差的情况出现。如一个按钮按完之后,需要等接口返回才能再次点击,但是本地因为返回速度较快,如果忘记添加遮罩层也不会有什么问题。但如果是网络有问题的线上环境,就很容易出现,且该问题一旦出现,很难定位,大大影响工作效率。

    二、问题分析

    根据上述的背景,在实际项目中添加一个公共的遮罩层组件进行管理,就变的十分有意义。经过分析,具体需要解决如下问题:
    (1)遮罩层出现和关闭的时机。
    (2)Mask组件设计。
    (3)该组件如何优雅的引入到项目中,不产生耦合。
    (4)如何在已有的项目中,渐进式的更换原有的maskShow的方式,从而不造成大面积问题。
    (5)细节问题

    三、组件设计

    1、遮罩层出现和关闭的时机

    该问题根据不同业务需求决定,但是笔者认为,大部分遮罩的出现和关闭主要取决于接口的请求和返回,一个接口在请求pending状态下,显示遮罩层,所有接口返回则关闭遮罩。本文主要解决的是接口请求遮罩问题,使用ts进行编写,且并不会罗列所有细节。

    2、Mask组件设计

    Mask组件为一个class,将细节屏蔽在class内部。
    (1)class内部最主要功能为添加和删除遮罩层,传输的当前请求接口的url。

    class Mask {
     // 显示遮罩层
     appendMask(url: string): void{}
    
     // 删除遮罩层
     removeMaskl(url: string): void{}
    }
    
    

    (2)添加遮罩层函数,请求时调用该函数,传入当前接口url。函数内部维护一个监听对象,用以监听当前是否存在pending状态的请求。该对象的value为该接口pending状态的数量。通过假设遮罩视图组件已经挂载到了Vue原型链上,如果没有,则在组件上方引入即可。

    // 监听对象数据类型定义
    interface HTTPDictInterface {
     [index: string]: number;
    }
    
    appendMask(url: string): void{ 
    
     if(!this.monitorHTTPDict[url]){
     this.monitorHTTPDict[url] = 0;
     }
     this.monitorHTTPDict[url] += 1;
    
     // 如果存在监听接口,则显示遮罩层
     if(!this.mask && Object.keys(this.monitorHTTPDict).length){
    
     // 在body上添加遮罩层样式,$Mask为遮罩层样式组件
     const Constructor = Vue.extend(Vue.prototype.$Mask);
     this.mask = new Constructor().$mount();
    
     document.body.appendChild(this.mask.$el);
     }
    }
    
    

    (3)删除遮罩层函数,每次请求结束之后都会调用该函数,当发现请求监听对象为空时,删除的遮罩层。如果没有pending状态的接口,删除该对接的key。该对象为空且有遮罩层的情况下,删除遮罩层。

    removeMask(url: string): void{
    
     // 成功返回后
     if (this.monitorHTTPDict[monitorUrl]) {
     this.monitorHTTPDict[monitorUrl] -= 1;
     if (this.monitorHTTPDict[monitorUrl] <= 0) {
     delete this.monitorHTTPDict[monitorUrl];
     }
     }
    
     // hasMask用以检测页面是否存在遮罩层标签元素
     if (this.mask && this.hasMask() && !Object.keys(this.monitorHTTPDict).length) {
     document.body.removeChild(this.mask.$el);
     this.mask = null;
     }
    
     this.timer = null;
    }
    
    

    3、该组件如何优雅的引入到项目中,不产生耦合。

    使用该组件,需要在所有的请求发起之前调用appendMask函数,所有的请求结束之后调用removeMask函数。这就有如下两种调用方式。
    (1)使用axios等组件的回调,完成函数调用。但是这种做法并没有将Mask组件的代码独立于项目,它依赖于具体接口框架的API。

    instance.interceptors.request.use((config) => {
    
     // 添加遮罩层
     mask.appendMask(config.url);
    
     return config;
    });
    
    

    (2)添加init函数,直接在原生XMLHttpRequest对象中注入回调。更改原生XMLHttpRequest函数,在事件'loadstart'和'loadend'中注入回调,需要注意的是,loadstart接收的传参中,并没有当前请求的url,所以还需要改写open函数,把open接收传参的url挂载到新的xhr对象上。慎用该方法。因为更改原生API的方式十分危险,在很多编码规范中是禁止的,如果所有人都对原生API进行改写,当同时引入这些框架会产生冲突,造成无法意料的后果。

    // 通过传参来决定是否使用该方法
    
    init(){
     if (this.autoMonitoring){
     this.initRequestMonitor();
     }
    }
    
    // 新的xmlhttprequest类型
    interface NewXhrInterface extends XMLHttpRequest{
     requestUrl?: string
    }
    
    // 原生注入
    initRequestMonitor(): void{
    
     let OldXHR = window.XMLHttpRequest;
     let maskClass: Mask = this;
    
     // @ts-ignore,编码规范不允许修改XMLHttpRequest
     window.XMLHttpRequest = function () {
    
     let realXHR: NewXhrInterface = new OldXHR();
     let oldOpen: Function = realXHR.open;
    
     realXHR.open = (...args: (string | boolean | undefined | null)[]): void => {
    
     realXHR.requestUrl = (args[1] as string);
     oldOpen.apply(realXHR, args);
    
     };
    
     realXHR.addEventListener(`loadstart`, () => {
    
     const requestUrl: string = (realXHR.requestUrl as string);
    
     const url: string = maskClass.cleanBaseUrl(requestUrl);
    
     // 开启遮罩
     maskClass.appendMask(url);
     });
    
     realXHR.addEventListener(`loadend`, () => {
    
     const responseURL: string = (realXHR as XMLHttpRequest).responseURL;
     const url: string = maskClass.cleanBaseUrl(responseURL);
    
     // 删除遮罩
     maskClass.removeMask(url);
     });
    
     return realXHR;
     };
    }
    
    

    (3)注入使用方式,直接调用init。这样改项目的所有请求都会经过Mask。

    new Mask().init()

    4、如何在已有的项目中,渐进式的更换原有的maskShow的方式,从而不造成大面积问题。

    如果直接在全项目中使用,牵扯的面积就会变得很广,会大面积的产生问题,反而得不偿失。所以应该采取一种渐进更换的方式,做到平滑过渡。主要思路是通过配置页面和黑名单的方式,来决定哪些页面引入该组件,从而让每个组员自己修改,毕竟页面的负责人才是最了解当前页面业务的人。至于如何黑名单还是白名单,则由项目的具体业务决定。

    // key需要监听的路由页面,value为一个数组,数组中填写的接口为黑名单,不需要监听的接口
    const PAGE_ONE = `/home`;
    const PAGE_TWO = `/login`;
    const HTTO_ONE = `xxx`
    
    export const maskUrlList = {
     [PAGE_ONE]: [HTTO_ONE],
     [PAGE_TWO]: [],
    };
    
    

    appendMask方法过滤黑名单和没有配置的页面。maskUrlList为控制的对象,先检查页面路由,之后检查是否存在黑名单。

    appendMask(url: string): void{
    
     // 获取当前页面的path,获取页面路径,根据hash和history模式进行区分
     const monitorPath: string = this.getMonitorPath();
    
     // maskUrlList为配置项,先检查页面路由,之后检查是否存在黑名单
     if (this.maskUrlList[monitorPath]
     && !this.maskUrlList[monitorPath].includes(url)) {
     if (this.monitorHTTPDict[url] === undefined) {
     this.monitorHTTPDict[url] = 0;
     }
     this.monitorHTTPDict[monitorUrl] += 1;
     }
    
     // 添加遮罩层
     if (!this.mask && this.hasMonitorUrl()) {
     const Constructor = Vue.extend(Vue.prototype.$Mask);
     this.mask = new Constructor().$mount();
    
     document.body.appendChild(this.mask.$el);
     }
    }
    
    

    5、细节问题

    (1)渲染之后才关闭遮罩层,将实际删除遮罩层逻辑放到定时器中,Vue的异步渲染采用的promise,所以关闭在如果放在渲染之后,需要放入setTimeout中。这里涉及到事件循环的知识。当接口返回,如果需要渲染页面,则会异步执行一个Promise,Promise为微任务,setTimeout为宏任务,当主线程执行完毕后,会先执行微任务,之后才会执行异步的宏任务setTimeout。

    // 清理遮罩层
    if (!this.timer) {
     this.timer = window.setTimeout(() => {
    
     if (this.mask && this.hasMask() && !this.hasMonitorUrl()) {
     document.body.removeChild(this.mask.$el);
     this.mask = null;
     }
    
     this.timer = null;
    
     }, 0);
    }
    
    

    (2)过滤接口的‘?',以及hash模式下的‘#',

    // 获取请求接口的url
    getMonitorUrl(url: string): string{
     const urlIndex: number = url.indexOf(`?`);
     let monitorUrl: string = url;
     if (urlIndex !== -1) {
     monitorUrl = url.substring(0, urlIndex);
     }
     return monitorUrl;
    }
    // 获取当前路由path
    getMonitorPath(): string{
    
     const path: string = this.mode === HASH_TYPE ? window.location.hash : window.location.pathname;
    
     let monitorPath: string = path;
    
     if (this.mode === HASH_TYPE) {
     monitorPath = monitorPath.substring(path.indexOf(`#`) + 1);
     }
    
     // 截图路径,删除请求参数
     const hashIndex: number = monitorPath.indexOf(`?`);
    
     if (hashIndex !== -1) {
     monitorPath = monitorPath.substring(0, hashIndex);
     }
    
     return monitorPath;
    }
    
    

    (3)接口过滤baseUrl。细心的话,会发现在使用axios的接口时,自行决定是否带入baseUrl,那是因为axios会在请求时进行区分过滤。如果项目前期并没有很好的定义使用方式的话,会有两种不同使用axios的方式。那么,就需要对baseUrl进行过滤。

    // 去除baseUrl
    cleanBaseUrl(fullUrl: string): string {
    
     const baseUrlLength: number = this.baseUrl.length;
     return fullUrl.substring(baseUrlLength);
     
    }
    
    

    (4)组件初始化,通过传入params的方式,将对象实例化出来。

    new Mask({
     modeType, // hash或history
     autoMonitoring, // 是否更写原生XMLHttpRequest对象
     maskUrlList, // 配置引入的页面和接口
     baseUrl, // 当前项目的baseUrl
     ...
    }).init()
    
    

    四、总结

    本文介绍了统一遮罩层的背景、问题及设计方案。但并没有将所有细节进行列举,这需要根据实际业务进行选择。但大体方案已经列出:
    (1)遮罩层应该在一些接口pending装的时候显示,所有接口返回后自动关闭。这里的接口是指需要监听的接口
    (2)组件最重要的两个函数为appendMask添加遮罩层和removeMask删除遮罩层。
    (3)如果想Mask完全独立,并不想依赖于第三方库(axios)的回调,可以直接对XMLHttpRequest进行改写,但这样做风险很大,并不建议。
    (4)组件更换统一组员自己配置路由及监听接口的方式。这里的逻辑可以自行决定,如果要监听的接口多,可以采用黑名单,反之则白名单。
    (5)对渲染的优化、请求带参数、路由的模式进行了优化。