【前端团队】统一的请求方案

1,758 阅读8分钟

有一段时间没有敲文章了,最近都在整理团队建设相关的事项,发现还是有不少事情可以记录一下的。例如本次的主题:请求方案的封装。在封装请求之前,我们需要知道,为什么我们需要做这个封装,而不是为了封装而封装。

为什么需要封装统一的请求?

团队内大多数项目都是直接引用的Axios,它已经帮我们做了一层很完整的XMLHttpRequest封装了,为什么我们需要做所谓的请求封装呢?

大概总结了下面几个原因:

① 团队规范化

这里的规范化有两层含义,一层是编码规范,一层是用法规范。

有了统一的封装方案,后续的开发基本上是不会出现组员写请求时自由发挥的情况,就算出现这种情况,我们也有公认的标准,在Code Review的时候是可以有理有据的要求组员进行调整。

这种团队层面的规范,光靠引入Axios是无法办到的,所以还是需要人为的增添一层封装。

② 团队开发效率提升

对于开发团队来说,统一的开发语言可以减少团队成员的认知成本。无论是新加入的成员还是老成员,都只需要熟悉一套开发模式,就可以加入团队内的任意一个项目。

③ 留下拓展空间

假设封装好的请求方案已经被团队完全接纳并使用,那么我们就等于掌控了每个项目的所有请求的生命周期,并且我们可以在每个周期做任何事情。比如:拓展Loading功能、拓展埋点上报功能(请求发起、请求成功、请求异常)、拓展业务异常编码处理功能...

怎么封装?

接下来将会具体介绍封装的逻辑,分为下列三个部分:

初始化 ==> 生命周期处理 ==> 构造器

初始化

初始化其实没什么好说的,正常使用Axois的时候我们基本都会做这个操作,这里我们主要需要设定请求的通用超时时间,以及默认请求头的设置。

const axiosInstance = axios.create({ timeout: 60000 })

axiosInstance.defaults.headers.common.ACCESS_TOKEN = localStorage.getItem(Consts.ACCESS_TOKEN) || ''

生命周期处理(拦截器)

请求预处理

顾名思义,这是请求正式发送之前的处理时机。

在这个时间段内,我们可以再次处理请求头,为每个请求的头都添加一个X-TRACE-ID,用于串联前后端的链路,这个traceID在后续线上问题定位的时候将会起到很大的作用。

除了请求头相关的处理,我们还可以在请求发起时呼出Loading提示,具体的loading实现可以视项目而定,它可以是Vant的loading也可以是Element的loading甚至可以是微信小程序的loading,我们在拦截器中只管调用,并不关心其外部真正的实现。

不过在实际开发中,并不是每个请求我们都希望它提示loading,而且loading的文案也期望是可配置的,所以我们需要在请求的config中拓展一个loadingText字段,用于管理loading提示。

除上面的两个功能,我们还可以加上埋点上报的逻辑,在发送请求的同时,生成一条请求日志,里面记录了当前时刻的请求快照,便于后期的问题定位以及数据沉淀。如果想了解eventTrack相关的实现可以查看上一篇文章

const beforeRequest = config => {
    const traceID = randomString(32, 'Aa#')
    config.headers['X-TRACE-ID'] = traceID
    if (config.loadingText) {
        loadingToast(config.loadingText)
    }
    if (!config.ignoreTrack) {
        const { data = {}, url, params = {} } = config
        window.eventTrack.track({
            elemId: 'BEFORE_REQUEST',
            eventType: 'request',
            extraParams: { traceID, url, data, params },
        })
    }
    return config
}
axiosInstance.interceptors.request.use(beforeRequest)

请求回包处理

这个是请求成功返回的时机,在这里我们也可以内置一些通用的逻辑。

如果在上一个周期我们有拓展Loading功能的话,这里就需要补上一个取消Loading的钩子,但是这里有一点需要注意,我们在调用loadingClear之前最好判断一下本次请求是否设定过loadingText,不这么做的话就会“误伤”同期并发的其他请求。

除了取消Loading,我们还可以在此处理业务层面的异常,这里主要需要和后端沟通通讯规范,以下面代码为例,我们认为code'0'的请求返回属于业务异常返回,这种时候就需要将code和返回的message提示出来,不过这也是一样的,这类全局的配置我们都需要提供一个“豁免”入口,有些情况下我们是需要手动处理业务异常,而不是直接toast提示。

和预处理一样,这里也是有必要加上埋点上报的,这里上报的就是请求返回的快照,因为都有保留traceID,后期我们可以很容易回溯整个操作链路。

const afterResponse = response => {
    const { data, config } = response
    if (config.loadingText) {
        loadingClear()
    }
    const code = String(data.code)
    if (code !== '0' && data.message && !config.hideResToast) {
        Toast(`${data.message}--${data.code}`)
    }
    if (!config.ignoreTrack) {
        const { headers } = config
        const traceID = headers['X-TRACE-ID'] || ''
        window.eventTrack.track({
            elemId: 'AFTER_RESPONSE',
            eventType: 'request',
            extraParams: { traceID, code, message: data.message || '' },
        })
    }
    return data
}

请求异常处理

在这里周期里,主要处理的是非业务异常,一般是各类4XX5XX状态码的返回。

这里我们需要将埋点上报的优先级调高一点,出现这类异常的时候我们是需要及时告知到的,具体上报的实现和前面两个周期一样。

然后我们还需要留一个异常处理钩子,用来支持业务侧手动处理异常的需求。后面才是我们默认的异常处理逻辑,这里展示一个最常用的操作:

对HTTP状态码为401的情况进行处理,一般出现这种状态码就代表我们的登录态失效了或者该用户没有携带登录态访问我们的页面,在这里我们就可以统一的进行拦截,让用户去获取有效的登录态,而不需要开发人员在每个页面都去处理这种逻辑。

const errorResponse = error => {
    loadingClear()

    if (!error.response) {
        return Promise.reject(error)
    }

    const { data, status, config } = error.response

    if (!config.ignoreTrack) {
        const { headers } = config
        const traceID = headers['X-TRACE-ID'] || ''
        window.eventTrack.track({
            elemId: 'ERROR_RESPONSE',
            eventType: 'request',
            extraParams: { traceID, status, data },
        })
    }

    if (config.failHook) {
        return config.failHook({ status, data })
    }

    /**
     * 状态码为401时需要重新登录
     */
    if (status === 401) {
        // do something
        return Promise.reject(new Error('请重新登录'))
    }

    return Promise.reject(error.response.data)
}
axiosInstance.interceptors.response.use(afterResponse, errorResponse)

构造器

初始化和拦截器都准备好之后,其实已经可以直接将实例导出使用了。

如果还想做一些进阶操作的话,就可以新增一个请求构造器。这里做的所有操作都是领先于请求预构造周期的,构造器核心还是透传两类参数,一个是请求基础信息baseInfo,另一个则是额外拓展的参数信息options。在透传的同时,我们可以对两类参数进行进一步操作。下面介绍一种较为常见的操作:请求体非空过滤。

因为在构造器内,我们可以感知到本次请求的所有数据,所以在正式生成请求之前,我们可以对传输的data进行二次处理,具体可以参考下面的代码片段,我们在options中感知到emptyFilter配置,如果声明了这个配置,我们将会对data内的属性进行遍历,剔除掉值为空的属性。

/**
 * 通用请求生成器
 * @param {Object} baseInfo
 * @param {string} baseInfo.name
 * @param {string} baseInfo.method
 * @param {string} baseInfo.url
 * @returns
 */
const requestCreator = baseInfo => {
    /**
     * @param {Object} options
     * @param {Object} options.data
     * @param {Object} options.headers
     * @param {String} options.loadingText
     * @param {Boolean} options.hideResToast
     * @param {Boolean} options.ignoreTrack
     * @param {Boolean} options.emptyFilter
     * @param {Function} options.failHook
     */
    return (options = {}) => {
        if (options.emptyFilter && options.data) {
            options.data = objectEmptyFilter(options.data)
        }
        return axiosInstance({ ...baseInfo, ...options })
    }
}

怎么推行?

配套文档

完成团队性质的改造后,第一要务是及时输出配套的文档,团队成员得要先知道这个东西该怎么用,才有可能会在后续的开发中用上。

上文介绍了封装的思路,但是未提用法,这样其他成员就算想用也无从下手。如果没有时间输出完整的技术文档,至少需要输出一份简单的使用指南,示例如下。

// infra.js
import { requestCreator } from '../request'
const API_LIST = [
    /* 获取短信验证码 */
    { name: 'getSmsCode', method: 'POST', path: 'message/code' },
]
const infraService = {}
API_LIST.forEach(u => {
    infraService[u.name] = requestCreator({ ...u, url: `${Config.domainServiceURL}/infra/${u.path}` })
})

export default infraService

// login.vue
import InfraService from '@/api/modules/infra.js'

const handleGetSmsCode = () => {
    InfraService.getSmsCode()
}

内置脚手架

一般团队中都会抽离出适合自身团队的脚手架或者是项目模板,我们要做的就是推动内置请求方案,这样一来新项目都会默认用上该方案,从源头上收束了实现发散的可能性。

小结

至此,一个简易的请求封装就完成了,可优化的空间还很大,但是基本已经够用了。在团队开发层面的设计,往往不需要过于的追求完美,这类产出的核心是提高效能,而提高效能最需要的是简单易懂