likes
comments
collection
share

用 Proxy 封装 API 请求

作者站长头像
站长
· 阅读数 4

通常,前端封装完基本的 axios 实例后,还需要将每个 API 封装成一个个函数,比如:

import { req } from './axiosInstance'
export const userApi = {
  getPages: (params) => req.get('user/pages', { parmas }),
  update: (id, data) => req.put(`user/${id}`, data),
  //... 其他用户相关的接口
}

这种方式会有很多的样板代码,而且要给每个接口起名字。 本文提供一种新颖的方式,只需少量代码即可封装 API 请求。请看代码和注释:

const createReq = (axiosInstance) =>
  new Proxy({},{// 创建一个代理对象
      get: (_target, prop) => //无论获取什么属性,都返回一个函数
        (url, ...rest) => //第一个参数是URL,其他视为请求参数或者配置
          axiosInstance.request({//将参数交给 axios.request 处理
            method: prop,//默认要获取的属性是 method
            url,
            ...transformPayload(url, rest), //转换对应的 params,query,body, 往下看有源码
          }),
    }
  )

调用示例:

const req = createReq(axios.create({}))

req.get('user/pages', { query:{ ... } })

// URL的 :id 会在 transformPayload 中处理
req.put('user/:id', { params:{ id }, body:{ ... }})

// 如果有其他配置,比如timeout, 在最后一个参数配置
req.get('user/pages', { query:{ ... } }, { timeout: 3000 })

// 如果接口本来没有参数,第二个参数就是配置
req.get('user/self', { timeout: 3000 })

如果你在 JS 项目这样封装,大概率会被打,因为有几个问题没解决:

  1. 没有限制 req 能使用哪些属性,比如 req.any() 也是会被受理。
  2. 没有任何智能提示,req(url 什么字符串都可以填,且经常需要查阅文档)。

解决方案是: 用 Typescript,步骤如下:

  1. 定一个 ApiRecord 的类型, API 的信息定义在这里
  2. 基于 ApiRecord 映射出 req 的类型

首先,制定一个类型,为了能够轻松的拿到API信息,这里选用 key,value 结构。 并使用两层这样的对象,第一层对象的 key 是 method,第二层对象的 key 是 url, value 则是这个接口对应的请求参数,和返回值

type ApiType = Record<string, Record<string, any>>

// 实际类型结构
type ActualApi = {
  get: {
    'user/pages': {
      query: { skip: number; take: number }
      response: UserDTO
      //  body
      //  parmas
      //  headers
    } //基本就是这 5 个属性
  }
}

开始写代码,需要一定的 TS 范型知识,暂时不懂也没关系,认真看注释。

import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'

// 将第一层对象简单重映射,保留key,value映射成函数类型
type MakeReqType<T extends ApiShape> = {
  [Method in keyof T]: RequestFn<T[Method]>
}

//将第二层对象传入范型T,得到一个函数类型,
type RequestFn<T extends Record<string, any>> = <K extends keyof T>(
  url: K, //第一个参数是url
  ...rest: MakeParams<T[K]> //其他参数
) => Promise<AxiosResponse<T[K]['response']>['data']> //返回值类型

// 解析参数类型, 使用 具名元组 来描述参数,
// [number,string]这个叫元组,元素有固定类型的数组。
// [a:number,b:string] 每个元素还具有名字,叫具名元组
type MakeParams<
  T extends BaseShape,
  Payload = Omit<T, 'headers' | 'response'>,
  Config = AxiosRequestConfig & Simplify<Pick<T, 'headers'>>
> = Payload extends EmptyObject
  ? [config?: Config] //没有请求参数时, 第二个参数就是config, 而且是可选的
  : [payload: Simplify<Payload>, config?: Config]

//基本的类型
type BaseShape = {
  headers: Record<string, any>
  response: unknown
}

//请求参数的类型
type PayloadType = {
  query?: any
  params?: any
  body?: any
}

//工具类型, 代码来自 type-fest
declare const emptyObjectSymbol: unique symbol
type EmptyObject = { [emptyObjectSymbol]?: never }
type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {}

const descendByKeyLength = (kvs: [string, any][]): [string, any][] =>
  kvs.sort((a, b) => b[0].length - a[0].length)

// input('user/:id', ['id' '666']),
// output => 'user/666'
const replaceURL = (url: string, sortedKeyValues: [string, any][]) =>
  sortedKeyValues.reduce(
    (acc, [k, v]) => acc.replace(':'.concat(k), String(v)),
    url
  )

//主要负责转换请求参数
const transformPayload = (
  url: string,
  args: DynamicParmasType
): AxiosRequestConfig => {
  if (args.length < 1) return {}
  const [payload, config] = args
  const { query, params, body, ...rest } = payload as PayloadType
  const actualUrl =
    params == null
      ? url
      : replaceURL(url, descendByKeyLength(Object.entries(params)))
  return {
    url: actualUrl,
    params: query,
    data: body,
    ...rest,
    ...(config ?? {}),
  }
}

// 中间态
type DynamicParmasType =
  | [config?: AxiosRequestConfig]
  | [payload: PayloadType, config?: AxiosRequestConfig]

export const createReq = <T extends ApiShape>(axiosInstance: AxiosInstance) =>
  new Proxy({} as T, {
    get:
      (_target, method: string) =>
      (url: string, ...rest: DynamicParmasType) =>
        axiosInstance.request({
          method,
          url,
          ...transformPayload(url, rest),
        }),
  })

基于Proxy的请求对象封装完成,实际使用的时候,只需要定义好 ApiRecord 类型,把它传给 createReq 的范型参数,后续新增或者变更,修改 ApiRecord 类型即可,req 不用变。

// types/api-record.ts

type UserDTO = {
  id: number
  name: string
  age: number
}

export type ApiRecord = {
  get: {
    'user/pages': {
      query: { skip: number; take: number; where: any }
      response: UserDTO
    }
    'user/:id': {
      params: { id: number }
      response: UserDTO
    }
  }
  post: {
    user: {
      body: { name: string; age: number }
      response: UserDTO
    }
  }
  //..others
}
// api/req.ts
import axios from 'axios'
import { ApiRecord } from '../types/api-record'

const instance = axios.create({
  baseURL: '/api',
})
instance.interceptors.response.use(v => v.data)

export const req = createReq<ApiRecord>(instance)

现在,根据 ApiRecord 的定义, req 只有 get,post 两个属性,整个调用过程都会有智能提示。

动图展示

用 Proxy 封装 API 请求

总结

通过 Proxy + 类型定义,封装 API 请求, 简化接口的维护成本,调用时伴随智能提示,优化开发体验。

彩蛋

pnpm create tealina