对于axios的二次封装实现规范请求

825 阅读7分钟

前言

正常情况下其实没必要封装axios, 他本身就是对xhr的封装, 使用上已经足够方便了, 没必要做过多的处理.

不过工具方便是因为工具做的好, 但你没办法让用的人能写的好代码, 所以需要有一套规范来统一, 统一什么呢. 请求嘛, 无非需要确认好的就是请求的参数和响应的参数, 以及和后端对接的请求结构体

这里我想实现一个功能, 能够配置请求的入参和出参格式, 配合ts在使用时直接可以看到数据结构. 那又有人要问了, 我这样写不就可以了

interface GetUserRequest {
    id: string;
}

interface GetUserResponse {
    name: string;
    age: number; 
}

const getUser = (params: GetUserRequest): GetUserResponse => {
    return request.get('/user')
}

这样是实现了, 但是要写很多的重复代码以及类型定义, 那你又要问了, 那我把类型定义写到方法上呢

const getUser = (params: {
     id: string;
}): {
    name: string;
    age: number; 
} => {
    return request.get('/user')
}

这不是不好看嘛, 而且也不好将类型定义复用。即使这些都不是问题, 那么每个请求最起码也有下边一行

const getUser = () => request.get('/user')

那么这里不是每次你都还需要写 request.get 嘛. 其实这里问题不大, 没必要省代码省到很离谱的地步, 这里纠结的问题也不是为了省多少代码, 这只是其中一小部分, 更多的还是类型定义方面。我希望在用的时候能够自动带上请求入参出参的类型

因此有一天我在github上看到了一个对axios的二次封装的项目 axits

因此基于这个思路我也封装了一个, 使用示例如下:

示例

apiList 是个对象, 里面包含了你注册的所有请求, 使用如下

const res = await apiList.getUser({
    id: ''
});

const res = await apiList.checkToken({
    token: 'xxx'
});

使用时你只需要找到 apiList 里对应的方法, 提供入参即可

注册请求示例如下:

import { createRequest } from '@hushaha/request';
import type { APISchemaResponse, ApiSchemas } from '@hushaha/request';

interface APISchemaRes extends APISchemaResponse {
    getUser: {
        request: {
            petId: string;
        };
        response: {
            name: string;
        };
    };
}

const apis: ApiSchemas<APISchemaRes> = {
    getUser: {
        path: 'GET pet/:petId',
        headers: {
            'Content-Type': 'application/json'
        }
    }
};

const { apiList } = createRequest<APISchemaRes>(
    {
        baseURL: '/api'
    },
    apis
);

export { apiList };

这里我注册了一个 getUser 方法

apis 里进行地址, 请求类型, 自定义请求头的配置

APISchemaRes 里进行出入参的类型定义

因为有 APISchemaResponse, ApiSchemas 的约束, 这里必须是一一对应, 每个请求都有类型定义和配置定义

使用示例如下:

image.png

使用时输入 apiList. 就会自动带出他里边存在的方法

这个请求配置的结构我只是觉得也不错, 所以沿用了, 如果你不喜欢, 改一下 apis 的结构即可

解析

说下我做这个事情的整体流程, 我会一步步的实现, 讲述我平常实现需求的思路

先分析下我的需求

我希望能提供一个 create 方法, 我执行完这个方法给我返回一个对象 apiList , 这个对象包含所有请求, 使用示例如上

然后执行 create 方法时我需要传入接口配置和类型定义, 因此我们先画个壳出来

初步开发

我定义这个 create 方法叫 createRequest , 需要接收 axios 的默认配置以及我们定义的接口配置

我希望的定义接口样式如下:

const apis = {
    getUser: {
        path: 'GET pet/:petId',
    }
}

因此apis的ts类型应该长这个样子:

import type { AxiosRequestConfig } from 'axios';

export type ApiSchemas = {
    [string]: AxiosRequestConfig & {
        path: string;
    };
};

那我们开始编写 createRequest 方法:

import axios, type { AxiosRequestConfig, CreateAxiosDefaults } from 'axios';

export const createRequest = (requestConfig: CreateAxiosDefaults, apiSchema: ApiSchemas) => {
    const client = axios.create(requestConfig);

    const apiList = attachApiList(client, apiSchema);

    return { apiList, client }
}

这个 attachApiList 方法就应该组装出上述的 apiList 对象出来, 那个对象的类型应该是这样的:

type ApiList = {
	[string]: (params: any) => Promise<any>;
};

因此 attachApiList 方法如下(简化一下处理path的部分):

import type { AxiosInstance } from 'axios';

const attachApiList = (client: AxiosInstance, apiSchema: ApiSchemas) => {
    const apiList: ApiList = Object.create(null);

    for (const apiName in apiSchema) {
        const apiConfig = apiSchema[apiName];

        apiList[apiName] = (params) => {
            const _params = { ...(params || {}) };
            const { path, ...config } = apiConfig;

            // 这里处理apiConfig, 解析出path中的请求类型和url,以及url上的参数

            const requestParams = USE_DATA_METHODS.includes(method)
                ? { data: _params }
                : { params: _params };

            return client.request({
                url,
                method: method.toLowerCase(),
                ...requestParams,
                ...config
            });
        }
    }

    return apiList;
}

到这里核心逻辑已经实现丸辣, 开始补充 ts

补充ts

首先需要在外部定义好请求的入出参类型定义, 而这个是在外边定义的再接进我们的方法里, 所以需要用到泛型. 我们定义外部提供的接口类型定义如下:

export type APISchemaResponse = Record<
    string,
    {
        request: Record<string, any> | void;
        response: Record<string, any> | any;
    }
>;

因此 createRequest 方法改造后如下:

export const createRequest = <T extends APISchemaResponse>(
    requestConfig: CreateAxiosDefaults,
    apis: ApiSchemas<T>
) => {
    const client = axios.create(requestConfig);

    const apiList = attachApiList<T>(client, apiSchema);

    return { apiList, client }
}

这里将泛型 T 传给 attachApiList , 目的是因为我们要根据传入的 APISchemaResponse 取出他的 key 作为枚举类型

继续调整 attachApiList 的类型定义:

type ApiList<T extends APISchemaResponse> = {
    [K in keyof T]: (params: T[K]['request']) => Promise<T[K]['response']>;
};

const attachApiList = <T extends APISchemaResponse>(
    client: AxiosInstance,
    apiSchema: ApiSchemas<T>
): ApiList<T> => {
    const apiList: ApiList<T> = Object.create(null);

    // ...

    return client.request({
        url,
        method: method.toLowerCase(),
        ...requestParams,
        ...config
    });
}

此时 attachApiList 返回的 apiList 的类型应该定义成了 key 是传入的 APISchemaResponse 的 key, value 是一个函数, 这个函数的入参是 APISchemaResponse 的 value 的 request 类型, 返回值是 response 类型

完事了, 接下来只需要完善一些额外逻辑, 加上 cancel 功能, 提供 client 出去支持自定义拦截器, 默认我也提供一套拦截器规则

cancel功能

这里用类实现, 主要创建一个Map, 往里添加 abortController , 然后在每次请求时把 abortController 存起来, 在打开 cancel 的请求中根据当前 key 取出Map中的abortController 执行 abort 方法即可

具体实现如下:

class AbortHttp {
    private cancelMaps = new Map();

    getAbortKey(url: string) {
        return url.split('?')[0];
    }

    setAbortController(key: string, controller: AbortController) {
        this.cancelMaps.set(key, controller);
    }

    abort(key: string, type: 'check' | 'remove' = 'check') {
        switch (type) {
            case 'remove':
                this.cancelMaps.delete(key);
                break;
            case 'check':
            default:
                if (this.cancelMaps.has(key)) {
                    this.cancelMaps.get(key).abort();
                }
                break;
        }
    }

    clear() {
        this.cancelMaps.clear();
    }
}

export default new AbortHttp();

接入到请求中应该是这样, 调整下 createRequest 入参

export const createRequest = <T extends APISchemaResponse>(
    requestConfig: CreateAxiosDefaults,
    apis: ApiSchemas<T>,
    {
        interceptorsRequest,
        interceptorsResponse
    }: {
        interceptorsRequest?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig;
        interceptorsResponse?: (res: any) => any;
    } = {}
) => {
    client.interceptors.request.use(
        (
            config: InternalAxiosRequestConfig & {
                isCancel?: boolean;
            }
        ) => {
            try {
                const { isCancel } = config;
                const key = abortHttp.getAbortKey(config.url!);

                if (isCancel) {
                    abortHttp.abort(key);
                }

                const controller = new AbortController();
                config.signal = controller.signal;
                abortHttp.setAbortController(key, controller);
            } catch (e) {
                throw new Error(`接口报错:${e}`);
            }

            if (interceptorsRequest) {
                config = interceptorsRequest(config);
            }

            return config;
        }
    );
}

总结

上面说过, 这个封装对我来说确实起到了规范作用, 有时候写的时候嫌麻烦就不想定义类型, 用的时候 any 一把梭, 如果用这个方式还是很容易管控起来.

说过其中定义请求参数的地方是可以自定义修改的, 所以如果你不喜欢 GET /url 的方式, 你可以拿下我的代码修改这一部分逻辑即可

因为有朋友对这个封装的意义有不同看法,这里我再统一说明一下

首先这个封装的最大意义在于暴露出一个状态 apiList, 当你在组件中想做请求时直接使用 apiList. 就可以直接选择你想要请求的接口, 不需要去找你的请求在那个api文件中

其次这个封装的核心不在于定义接口的参数部分, 而是在于ts类型校验上, 不需要你手动定义 GetReqGetRes 类型在每个接口上, 取名字都得烦死, 可以在统一的地方定义所有的类型, 单独使用类型使用时也可以通过 APISchemaRes['getUser']['request'] 方式使用。

然后选择 GET /pet/:id 的格式纯粹是一点重复的代码都不想写,这样定义的话就没有path、没有methods、没有data

然后定义接口参数可以改造成任意格式, 比如说定义成:

const apis = { 
    getUser: { 
        path: '/pet',
        methods: 'get'
    }
}

只是解析的时候按照这个结构去解析罢了

链接

axits

我封装的请求源码

我的博客地址

自定义脚手架开发