懒人教程!封装axios,实现全局Loading动画和异步竞态的问题

1. 页面加载状态

在实际项目中,一个页面会同时发送多个异步请求去获取数据展示,当发起请求的时候,给页面加入loading动画,但我们并不知道这些请求什么时候全部完成,不知道在何时关闭Loading。如果单独就一个页面而言,可以借助Promise.all(),单独写逻辑去实现,但是页面多了同样很繁琐。
我想了个方法是在vuex中维护一个请求的个数count,在请求发起之前count+1,请求完成(成功或者失败)时count-1,最后当count为0的时候关闭弹窗,这样就可以全局控制Loading

const request = axios.create({
    baseURL: '/api',
    withCredentials: true, // 是否在跨域请求时发送Cookie
    timeout: 6000 // 超时时间
})
request.interceptors.request.use(
    config => {
        store.commit('app/CHANGE_COUNT', store.getters.requestCount + 1)
        if (store.getters.requestCount > 0) {
            store.commit('app/OPEN_LOADING')
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)
request.interceptors.response.use(
    response => {
        store.commit('app/CHANGE_COUNT', store.getters.requestCount - 1)
        if (store.getters.requestCount === 0) {
            store.commit('app/CLOSE_LOADING')
        }
        const res = response.data
        // 只有code为0才返回数据,code为其他直接弹出错误提示
        if (res.code === 0) {
            return res
        } else {
            ElMessage.error({
                message: res.msg || '网络开小差了!',
                duration: 1.5 * 1000
            })
            return Promise.reject(res)
        }
    },
    error => {
        store.commit('app/CHANGE_COUNT', store.getters.requestCount - 1)
        if (store.getters.requestCount === 0) {
            store.commit('app/CLOSE_LOADING')
        }

        ElMessage.error({
            message: '网络开小差了!',
            duration: 2 * 1000
        })
        return Promise.reject(res)
    }
)

这时,又出现问题了,上面的情况之针对一层,如果A、B同时调用,而C在A调用成功之后再调用,则会出现问题,count可能会出现 1->2->1->0->1->0 的情况,loading会重复的关闭打开
我又在上面的方法中多加了新的参数seq(顺序)。
seq支持三个参数

  1. f(first),第一 (执行完毕后计数不减1)
  2. m(middle), 中间的 (请求发起时计数+1,完成时计数-1)
  3. l(last), 最后的 (执行前计数不加1)
    seq默认为m,就相当于上面只有一层的情况,则上述A、B、C请求就是
function A() {
    return request({
        url: '/A'
        seq: 'f'
    })
}
function B() {
    return request({
        url: '/B'
    })
}
function C() {
    return request({
        url: '/C'
        seq: 'l'
    })
}
A().then(res => {
    C()
})
B()
//count 1->2->1->0

同时,考虑到有些接口确实不需要loading,多加了一个参数noLoad,控制当前请求,是否会进入加载的逻辑,修改之后如下

const request = axios.create({
    baseURL: '/api',
    withCredentials: true, // 是否在跨域请求时发送Cookie
    timeout: 6000 // 超时时间
})
request.interceptors.request.use(
    config => {
        if ((config.seq === 'm' || config.seq === 'f') && !config.noLoad) {
            store.commit('app/CHANGE_COUNT', store.getters.requestCount + 1)
            if (store.getters.requestCount > 0) {
                store.commit('app/OPEN_LOADING')
            }
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)
request.interceptors.response.use(
    response => {
        if ((response.config.seq === 'm' || response.config.seq === 'l') && !response.config.noLoad) {
            store.commit('app/CHANGE_COUNT', store.getters.requestCount - 1)
            if (store.getters.requestCount === 0) {
                store.commit('app/CLOSE_LOADING')
            }
        }
        const res = response.data
        // 只有code为0才返回数据,code为其他直接弹出错误提示
        if (res.code === 0) {
            return res
        } else {
            ElMessage.error({
                message: res.msg || '网络开小差了!',
                duration: 1.5 * 1000
            })
            return Promise.reject(res)
        }
    },
    error => {
        if ((config.seq === 'm' || config.seq === 'l') && !config.noLoad) {
            store.commit('app/CHANGE_COUNT', store.getters.requestCount - 1)
            if (store.getters.requestCount === 0) {
                store.commit('app/CLOSE_LOADING')
            }
        }
        ElMessage.error({
            message: '网络开小差了!',
            duration: 2 * 1000
        })
        return Promise.reject(res)
    }
)

2. 异步竞态的问题

场景描述
现在页面上有一个分类筛选功能,用户点击切换A类目,调用A接口,A接口还没有返回就有点击切换B类目,调用B接口,结果B接口却比A接口先返回数据,导致用户选的是B类目,表格展示的确实A类目。
解决的方法也蛮多的,我这里借助的是axios的取消请求CancelToken,当相同url的接口再次请求时,如果上一个请求还未完成,则取消上一个正在pending的请求。
在vuex中维护一个对象pendingObj,以请求的url为key,将pending中的请求放到pendingObj中,然后在每次请求之前,判断请求的url是否在对象pendingObj中,如果在则取消它,同时删除key。同理也可以添加一个参数cancel,控制当前请求,是否需要取消

let cancelToken = axios.CancelToken
// 以请求的url为key,将pending中的请求放到pendingObj中
let pendingObj = {}
const request = axios.create({
    baseURL: '/api',
    withCredentials: true, // 是否在跨域请求时发送Cookie
    timeout: 6000 // 超时时间
})
request.interceptors.request.use(
    config => {
        const url = config.baseUR + config.url
        let objItem = pendingObj[url]
        if (!config.cancel) {
            if (objItem) {
                // 执行取消函数
                objItem.cancel({
                    // 传递 config配置参数 和取消的标志
                    ...objItem.config,
                    cancel: true
                })
                // 删除key
                delete pendingObj[url]
            }
            config.cancelToken = new cancelToken((c) => {
                pendingObj[url] = {
                    cancel: c,
                    config
                }
            })
            store.commit('app/CHANGE_REQUEST', pendingObj)
        }
        if ((config.seq === 'm' || config.seq === 'f') && !config.noLoad) {
            store.commit('app/CHANGE_COUNT', store.getters.requestCount + 1)
            if (store.getters.requestCount > 0) {
                store.commit('app/OPEN_LOADING')
            }
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)
request.interceptors.response.use(
    response => {
        // 请求完成删除key
        if (response.config.cancel) {
            delete pendingObj[response.config.url]
            store.commit('app/CHANGE_REQUEST', pendingObj)
        }
        if ((response.config.seq === 'm' || response.config.seq === 'l') && !response.config.noLoad) {
            store.commit('app/CHANGE_COUNT', store.getters.requestCount - 1)
            if (store.getters.requestCount === 0) {
                store.commit('app/CLOSE_LOADING')
            }
        }
        const res = response.data
        // 只有code为0才返回数据,code为其他直接弹出错误提示
        if (res.code === 0) {
            return res
        } else {
            ElMessage.error({
                message: res.msg || '网络开小差了!',
                duration: 1.5 * 1000
            })
            return Promise.reject(res)
        }
    },
    error => {
        if ((config.seq === 'm' || config.seq === 'l') && !config.noLoad) {
            store.commit('app/CHANGE_COUNT', store.getters.requestCount - 1)
            if (store.getters.requestCount === 0) {
                store.commit('app/CLOSE_LOADING')
            }
        }
        // 错误原因为取消的时候,不弹出错误提示
        if (config.cancel) {
            return Promise.reject('cancel')
        }
        ElMessage.error({
            message: '网络开小差了!',
            duration: 2 * 1000
        })
        return Promise.reject(res)
    }
)

这时,我们可以进一步实现切换页面的时候,取消上一个页面中还在pending的请求的功能,只需在main.js中修改router.beforeEach

router.beforeEach((to, from, next) => {
    // 路由切换的时候清除掉之前正在pending的请求
    let obj = store.getters.pendingRequest
    for (let key in obj) {
        obj[key].fun({
            ...obj[key].config,
            cancel: true
        })
    }
    // 清空pendingRequest
    store.commit('app/CHANGE_REQUEST', {})
})

此外,还可以加上一些其他的封装

  1. 如post请求传递form-data时,请求头要加上Content-Type= 'application/x-www-form-urlencoded',data要序列化成URL的形式,以&进行拼接,推荐qs
  2. 文件下载时 config.responseType = 'blob',也可以在request.interceptors.response.use中就直接出来下载逻辑
    这里就不一一说明了,完整代码在我的github项目中可以看到vue3-vite-admin(scr/utils/request.js),因为文章代码中涉及到一些方法未定义,如vuex的逻辑,最好还是在项目里去找找看。使用vuex是因为感觉这些变量在某些业务逻辑里可能会用到。
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容