记一次异步加载的实现-发布订阅模式的实际运用

1、问题起因

由于团队使用的Ajax请求库是自己在HTML5提供的fetch API的一层封装(后文简称sdk),其中包裹了许多业务参数,直接调用这个sdk可以省时省力避免因其他因素而产生不确定的bug。但是sdk有个很不方便的特点就是只能当它调用了业务初始化接口获得响应内容结果之后才能正常工作。假设我们在页面中有个接口必须要等到sdk初始化执行,如果这个初始化接口执行失败,整个页面将无法呈现,就没有后话了。因此,我们是可以认定,sdk初始化接口一定可以请求成功,这是我们对sdk进行改造的前提。

由于存在这样的一个限制,假设有个数据请求接口必须要在页面初始化之后就立即加载,此时就会存在问题,无法确定sdk是否初始化。之前同事的解决办法是将接口全部写在跟初始化平级的界面里,然后在这个界面里面接收来自组件的各种指令,获取数据传入到组件中,但是使用过程中感觉特别麻烦。每次新增业务逻辑可能都需要改页面级内容,根据组件拆分的原则,本来页面级也不应该负责这些组件数据的请求,违背了单一职责的原则更令人头疼的事,假设有领券这类等操作当用户发起点击操作需要禁用领券按钮,等请求执行完成之后再解冻的需求,这种场景感觉完全无能为力了。因此我思考着如何扩展这个sdk能力提升开发体验且改进代码质量。

2、开始

由于sdk源码不便于贴出,我们使用以下代码模拟这个场景:

import axios, { AxiosInstance } from 'axios';
class Request {
    /** @type { AxiosInstance } */
    request = null
    constructor() {
        this.timeout = 3000;
    }
    initialize() {
        this.request = axios.create({
            baseURL: '/',
            timeout: this.timeout
        })
    }
    post() {
        return this.request.post.apply(this, arguments)
    }

}

export default new Request()
复制代码

可以看出,在调用initialize方法之前调用post方法,request变量还不存在使用会报错。

2.1、 最初的梦想

我们的思考方向是,sdk内部存在一个请求队列,当执行post方法的时候,request实例如果没有初始化,我们先将其加入到请求队列里,当request初始化以后,我们再将这个队列过一遍即可。
好,开始改造Request类:

class Request {
    /** @type { AxiosInstance } */
    request = null

    constructor() {
        this.timeout = 3000;
        // 增加缓存执行队列
        this.requestQueues = [];
    }
    initialize() {
        this.request = axios.create({
            baseURL: '/',
            timeout: this.timeout
        })
        console.log("this request lib has been initialized!")
        // 如果当前有缓存队列,清楚缓存队列
        while (this.requestQueues.length > 0) {
            let execute = this.requestQueues.pop()
            typeof execute === 'function' && execute();
        }
    }
    post() {
        var args = arguments;
        if (!this.request) {

            const callback = () => {
                return this.request.post.apply(this, args);
            }
            // 将执行函数加入队列
            this.requestQueues.push(callback)
            return
        }
        return this.request.post.apply(this, arguments)
    }
}
复制代码

2.2、 当外界需要函数的返回结果怎么办?

在2.1中,我们只是简单的将执行缓存加入到了执行队列里面,当用户在初始化之前执行post方法的时候,虽然可以执行,但是用户无法拿到函数的执行结果,因此,我们要着手思考在不改变API的设计的前提下,支持函数带结果返回。

class Request {
    /** @type { AxiosInstance } */
    request = null
    constructor() {
        this.timeout = 3000;
        this.requestQueues = [];
    }
    initialize() {
        this.request = axios.create({
            baseURL: '/',
            timeout: this.timeout
        })
        console.log("this request lib has been initialized!")
        while (this.requestQueues.length > 0) {
            let execute = this.requestQueues.pop()
            let call = execute.dowork;
            try {
                // 判断返回结果是否是Promise
                const result = typeof call === 'function' && call();
                if (result && typeof result.then === 'function') {
                    result.then(response => {
                        // 在异步结果中报告
                        execute.trigger("success", response);
                    }).catch((err) => {
                        // 捕获错误
                        execute.trigger("error", err);
                    });
                } else {
                    // 不是Promise直接报告结果
                    execute.trigger("success", result);
                }
            } catch (exp) {
                execute.trigger("error", exp);
            }
        }
    }
    post() {
        var args = arguments;
        if (!this.request) {
            const callback = {
                dowork: () => {
                    return this.request.post.apply(this, args);
                },
            }
            //此处我们在这儿需要用到发布订阅模式
            callback.channels = {};
            //订阅特征频道事件
            callback.on = function (channel, func, once = false) {
                callback.channels[channel] = { func, once };
            }
            //取消订阅特征频道事件
            callback.off = function (channel) {
                delete callback.channels[channel];
            }
            // 订阅一次频道事件
            callback.once = function (channel, callback) {
                callback.on(channel, callback, true);
            }
            //触发订阅
            callback.trigger = function (channel, args) {
                var action = callback.channels[channel]
                //如果事件已经被取消订阅了,将不再需要触发
                if (!action) {
                    console.warn("this channel has been off")
                    return;
                }
                const { func, once } = action;
                if (typeof func === 'function') {
                    func(args);
                    // 单次订阅,用完之后立刻销毁
                    once && delete callback.channels[channel]
                }
            }
            this.requestQueues.push(callback)
            // 对外返回一个Promise,可以使得外界支持异步,能使得操作可以停在那儿(个人感觉就像是钩子函数的感觉,哈哈哈)
            return new Promise((resolve, reject) => {
                callback.on("success", function (response) {
                    resolve(response)
                });
                callback.on("error", function (response) {
                    reject(response);
                })
            });
        }
        return this.request.post.apply(this, arguments)
    }
}
复制代码

2.3 SDK 忘记初始化了怎么办?

假设初始化操作忘记调用了怎么办,难不成一直在这儿傻等吗?(就像我们之前提到的领券操作,当一定的时间之后玩法完成应该提示超时并可以让用户重新操作,否则用户可能认为你的系统产生bug了呢?)当然不可能,我们还需要对其增加超时的反馈。
继续改造代码:

class Request {
    /** @type { AxiosInstance } */
    request = null
    constructor() {
        this.timeout = 3000;
        this.requestQueues = [];
    }
    initialize() {
        this.request = axios.create({
            baseURL: '/',
            timeout: this.timeout
        })
        console.log("this request lib has been initialized!")
        // timeout error
        while (this.requestQueues.length > 0) {
            let execute = this.requestQueues.pop()
            let call = execute.dowork;
            // 如果执行到当前时刻的时候,已经超时,将不在执行了。
            if (execute.timeout) {
                return
            }
            try {
                const result = typeof call === 'function' && call();
                if (result && typeof result.then === 'function') {
                    result.then(response => {
                        execute.trigger("success", response);
                    }).catch((err) => {
                        execute.trigger("error", err);
                    });
                } else {
                    execute.trigger("success", result);
                }
            } catch (exp) {
                execute.trigger("error", exp);
            }
        }
    }
    post() {

        var args = arguments;

        if (!this.request) {

            const callback = {
                dowork: () => {
                    return this.request.post.apply(this, args);
                },
                timeout: false
            }

            callback.channels = {};

            callback.on = function (channel, func, once = false) {
                callback.channels[channel] = { func, once };
            }

            callback.off = function (channel) {
                delete callback.channels[channel];
            }

            callback.once = function (channel, callback) {
                callback.on(channel, callback, true);
            }

            callback.trigger = function (channel, args) {
                var action = callback.channels[channel]
                if (!action) {
                    console.warn("this channel has been off")
                    return;
                }
                const { func, once } = action;
                if (typeof func === 'function') {
                    func(args);
                    once && delete callback.channels[channel]
                }
            }
            // 获取当前时间
            var now = new Date().getTime();
            var timer = setInterval(() => {
                var tick = new Date().getTime();
                if (tick - now >= this.timeout) {
                    //如果请求超时,将终止之前注册的事件,并报告超时结果
                    callback.off("success");
                    callback.off("error");
                    callback.timeout = true;
                    callback.trigger("timeout");
                    console.log('the request lib initialization timeout')
                    clearInterval(timer);
                }
            }, 100);
            /* 在此,我没有想到更好的超时处理的方法,若有更好的方法请各位读者指教,谢谢 */

            this.requestQueues.push(callback)

            return new Promise((resolve, reject) => {

                callback.on("timeout", function () {
                    resolve({ errno: 1, errmsg: "接口请求超时" });
                })

                callback.on("success", function (response) {
                    clearInterval(timer)
                    resolve(response)
                });

                callback.on("error", function (response) {
                    clearInterval(timer)
                    reject(response);
                })
            });
        }
        return this.request.post.apply(this, arguments)
    }
}
复制代码

3、 结果

<script>
import request from "@/request/index";
export default {
  name: "App",
  mounted() {
    var start = new Date().getTime();
    console.log(0);
    // 模拟初始化的耗时操作
    setTimeout(() => {
      request.initialize();
      var end = new Date().getTime();
      console.log(end - start);
    }, 100);
    request.post("/demo").then(res => {
      console.log(res)
    }).catch(err => {
      console.log(err)
    });
  },
};
</script>
复制代码

执行结果:

WX20210701-210557@2x.png

我们将定时器改为4S后执行,
可以看到,顺利的执行了超时操作,并且原来的请求没有继续执行

WX20210701-210945@2x.png

4、总结:

其实这个异步加载场景是我在面试滴滴的二面中被问到的一个问题,由于当时我处在一个不太安静的环境面试,没有准确的设计异步的处理思路,没有回答上来。后来我又在面试美团的过程中被要求设计一个发布订阅模式,由此对发布订阅模式有了一个较为深刻的印象,因此在遇到这个业务场景的时候,下意识的就想到了这个解法。

由于笔者水平有限,写作过程中难免出现错误,若有纰漏,请各位读者指正,请联系作者本人,邮箱404189928@qq.com,你们的意见将会帮助我更好的进步。本文乃作者原创,若转载请联系作者本人。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享