用 Proxy 封装 API 请求

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

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

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

js 复制代码
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, 往下看有源码
          }),
    }
  )

调用示例:

js 复制代码
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 则是这个接口对应的请求参数,和返回值

ts 复制代码
type ApiType = Record<string, Record<string, any>>

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

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

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 不用变。

ts 复制代码
// 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
}
ts 复制代码
// 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 请求, 简化接口的维护成本,调用时伴随智能提示,优化开发体验。

彩蛋

使用 CLI 工具 create-tealina,快速创建一个TS全栈脚手架项目,其中前端自带这一套方案,而且 ApiRecord 也不需要手动编写 这里有文章介绍

bash 复制代码
pnpm create tealina
相关推荐
Eiceblue35 分钟前
使用 Python 实现 Excel 行列转置(保留单元格样式)
前端·python·excel
西西学代码1 小时前
像素和颜色之间的转换(Flutter)
前端·javascript·flutter
茶底世界之下1 小时前
羽毛颜色已经很鲜艳,还要继续加色吗?用 Awaking 试一次减饱和
前端·ios·运营
风早爽太1 小时前
Mac 搭建 Vue 开发环境
前端·vue.js
Lank_M1 小时前
Word图片裁剪转HTML失效,转换器各漏读了哪一层
前端
青烟小生1 小时前
前端架构设计实战:从组件思维到系统思维
前端
Amos_Web2 小时前
Rspack 源码解析(二十):Typed Hook 系统如何靠宏生成
前端·rust·前端框架
程序员大阳2 小时前
副队长CSS教程(29)–CSS Grid 实战:实现通用图文混排布局
前端·css·grid·图文混排
对空六课2 小时前
无头浏览器访问对跳出率的过滤规则
服务器·前端·算法·数据分析
小番茄夫斯基2 小时前
Nginx 介绍
前端