@vunk/shared

RestFetch ​

RestFetch 是一个功能强大的 HTTP 客户端封装,基于浏览器原生的 fetch API 实现。它提供了请求拦截、响应拦截、请求队列、缓存、超时处理等丰富功能。

基础用法 ​

创建实例 ​

typescript
import { RestFetch } from '@vunk/shared/fetch'

const client = new RestFetch({
  baseURL: 'https://api.example.com',
  timeout: 5000, // 5 秒超时
})

发送请求 ​

typescript
// GET 请求
await client.request({
  method: 'GET',
  url: '/users',
  params: { page: 1 }
})

// POST 请求
await client.request({
  method: 'POST',
  url: '/users',
  data: {
    name: 'John',
    email: 'john@example.com'
  }
})

高级特性 ​

中间件 ​

中间件提供了一种强大的方式来处理请求和响应。每个中间件可以访问请求上下文、响应数据,并决定是否调用下一个中间件。

typescript
// 添加时间戳中间件
client.addMiddleware(async (ctx, next) => {
  // 修改请求参数
  ctx.req.requestOptions.params = {
    ...ctx.req.requestOptions.params,
    startTime: Date.now(),
  }

  // 调用下一个中间件
  await next()

  // 处理响应数据
  const data = await ctx.res.when()

  if (data) {
    ctx.body = {
      response: data,
      endTime: Date.now(),
    }
  }
})

// 添加认证中间件
client.addMiddleware(async (ctx, next) => {
  ctx.req.requestOptions.headers = {
    ...ctx.req.requestOptions.headers,
    Authorization: `Bearer ${getToken()}`,
  }
  await next()
})

中间件上下文 (ctx) 包含以下属性:

  • req: 请求相关信息
    • requestOptions: RestFetch 请求选项
    • requestInit: 原生 fetch 请求配置
  • res: 响应相关信息
    • response: 响应数据
    • when(): 获取响应 Promise
  • body: 最终的响应数据
  • state: 中间件之间共享的状态对象

响应处理 ​

可以自定义响应数据的处理方式:

typescript
const client = new RestFetch({
  baseURL: 'https://api.example.com',
  // 自定义响应处理
  responseThen: (res) => {
    return res.blob()
  }
})

请求队列 ​

支持三种队列模式:

typescript
// 1. abort 模式:中断之前的请求
await client.request({
  method: 'GET',
  url: '/search',
  queue: {
    id: 'search',
    mode: 'abort'
  }
})

// 2. wait 模式:等待之前的请求完成
await client.request({
  method: 'GET',
  url: '/search',
  queue: {
    id: 'search',
    mode: 'wait'
  }
})

// 3. parallel 模式:并行执行 (默认)
await client.request({
  method: 'GET',
  url: '/search',
  queue: {
    id: 'search',
    mode: 'parallel'
  }
})

请求缓存 ​

typescript
// 使用缓存, 如果缓存 id 命中,则从 clone 流中直接读取
const data = await client.request({
  method: 'GET',
  url: '/users/1',
  cache: {
    id: 'user-1'
  }
})

// 强制更新缓存
const newData = await client.request({
  method: 'GET',
  url: '/users/1',
  cache: {
    id: 'user-1',
    forceUpdate: true
  }
})

文件下载 ​

typescript
await client.download({
  url: '/files/download',
  fileName: 'example.pdf', // 可选,自定义文件名
  overwriteName: true // 可选,是否覆盖响应头中的文件名
})

流式响应 ​

用于处理服务器发送事件(SSE)等流式数据:

typescript
await client.reader({
  url: '/stream',
  onmessage: (event) => {
    console.log('Received:', event.data)
  }
})

API 参考 ​

构造函数选项 ​

参数类型描述默认值
baseURLstring基础 URL-
timeoutnumber超时时间(ms)-
presetRequestInit(config: RequestInit) => RequestInit前置请求配置 (setRequestInit之前)-
setRequestInit(config: RequestInit) => RequestInit请求配置(可被请求选项覆盖)-
responseThen(res: Response) => any响应拦截器(可被请求选项覆盖)res => res.json()
requestThen(data: any) => any响应拦截器(responseThen之后)data => data
ontimeout(config: RequestInit) => void超时回调-

请求选项 ​

参数类型描述默认值
urlstring请求路径-
baseURLstring覆盖构造函数中的基础 URL-
method‘GET’ | ‘POST’ | ‘PUT’ | ‘DELETE’请求方法-
headersobject请求头-
paramsobjectURL 参数-
dataobject请求体数据-
contentType‘application/json’ | ‘application/x-www-form-urlencoded’ | ‘multipart/form-data’内容类型‘application/json’
setRequestInit(config: RequestInit) => RequestInit请求配置,覆盖构造函数配置-
responseThen-响应拦截器,覆盖构造函数配置-
timeoutnumber请求超时时间,覆盖构造函数配置-
ontimeout(config: RequestInit) => void超时回调,覆盖构造函数配置-
abortControllerAbortController自定义中断控制器new AbortController()
cache{ id: string, forceUpdate?: boolean }缓存配置-
queue{ id: string, mode?: ‘abort’ | ‘wait’ | ‘parallel’, leave?: boolean }队列配置-

中间件方法 ​

方法描述
addMiddleware(fn: RestFetchMiddleware)添加中间件
removeMiddleware(fn: RestFetchMiddleware)移除中间件

中间件接口 ​

typescript
interface RestFetchMiddleware<S = Record<string, any>> {
  (ctx: RestFetchMiddlewareContext<S>, next: () => Promise<any>): Promise<any>
}

interface RestFetchMiddlewareContext<S = Record<string, any>> {
  req: {
    requestOptions: RestFetchRequestOptions // 请求选项
    requestInit?: RequestInit // 原生请求参数
  }
  res: {
    response?: any // 响应数据
    when: () => Promise<any> // 获取响应Promise
  }
  state: S // 中间件共享状态
  body: any // 最终响应数据
}

基础示例 ​

流式响应示例 ​

开启 node server.cjs 后预览。

源码 ​

fetch/RestFetch
ts
import type { AnyFunc, NormalObject, ReturnVoid } from '@vunk/shared'
import type { EventSourceParseCallback } from 'eventsource-parser'
import { noop } from '@vunk-shared/function'
import { Deferred } from '@vunk-shared/promise'
import { createParser } from 'eventsource-parser'
import { stringify } from 'qs'

export class RestFetch {
  baseURL: RestFetchConstructorOptions['baseURL']
  setRequestInit: RestFetchConstructorOptions['setRequestInit']
  presetRequestInit: RestFetchConstructorOptions['presetRequestInit']
  responseThen: RestFetchConstructorOptions['responseThen']
  requestThen: RestFetchConstructorOptions['requestThen']
  timeout: RestFetchConstructorOptions['timeout']
  ontimeout: RestFetchConstructorOptions['ontimeout']

  protected caches: Record<string, Promise<Response>>
  protected queues: Record<string, {
    promise: Promise<Response>
    abortController: AbortController
  }[]>

  /**
   * 中间件队列
   */
  protected middlewareQueue: RestFetchMiddleware[] = []

  async runMiddlewares (ctx, index = 0) {
    if (index === this.middlewareQueue.length)
      return
    const middleware = this.middlewareQueue[index]
    await middleware(ctx, () => this.runMiddlewares(ctx, index + 1))
  }

  constructor (options: RestFetchConstructorOptions) {
    this.baseURL = options.baseURL
    this.setRequestInit = options.setRequestInit
    this.presetRequestInit = options.presetRequestInit
    this.responseThen = options.responseThen || (res => res.json())
    this.requestThen = options.requestThen || (res => res)

    this.timeout = options.timeout
    this.ontimeout = options.ontimeout

    this.caches = {}
    this.queues = {}
  }

  use (plugin: RestFetchPlugin, ...args) { return plugin(this, ...args) }

  response (
    options: RestFetchRequestOptions,
    requestInit?: RequestInit,
  ) {
    let readyPromise: Promise<Response>
    if (options.cache?.id) { // 如果提供缓存id 则从缓存获取promise
      if (!this.caches[options.cache.id] || options.cache.forceUpdate) { // 如果没有缓存先赋值, 或者需要强制更新缓存
        this.caches[options.cache.id] = this.initFetch(options, requestInit)
      }
      readyPromise = this.caches[options.cache.id]
      // https://github.com/whatwg/fetch/issues/196
      // res.json() 会消耗流数据 需要clone以便重用
        .then(res => res.clone())
    }
    else {
      readyPromise = this.initFetch(options, requestInit)
    }
    const responseThen = options.responseThen ?? this.responseThen
    return readyPromise.then(responseThen)
  }

  async request (
    options: RestFetchRequestOptions,
    state?: NormalObject,
    requestInit?: RequestInit,
  ) {
    const responseDef = new Deferred()

    const when = () => responseDef.promise
    const middlewareCtx = {
      req: {
        requestOptions: options,
        requestInit,
      },
      res: {
        when,
        response: undefined,
      },
      state: state ?? {},
      body: undefined,
    }

    // 定义 next 函数
    const next = async (index = 0) => {
      if (index >= this.middlewareQueue.length)
        return
      // 获取下一个中间件
      const middleware = this.middlewareQueue[index]
      await middleware(middlewareCtx, () => next(index + 1))
    }

    const middlewarePromise = next()

    this.response(
      middlewareCtx.req.requestOptions,
      middlewareCtx.req.requestInit,
    )
      .then((res) => {
        middlewareCtx.res.response = res
        middlewareCtx.body = res
        return res
      })
      .then(
        responseDef.resolve,
        responseDef.reject,
      )

    return responseDef
      .promise
      .then(() => middlewarePromise)
      .then(() => middlewareCtx.body)
      .then(this.requestThen)
  }

  async download (
    downloadOpts: {
      url: string
      fileName?: string
      overwriteName?: boolean
    },
    options?: Partial<RestFetchRequestOptions>,
    requestInit?: RequestInit,
  ) {
    return this
      .response({
        method: 'POST',
        url: downloadOpts.url,
        responseThen: res => res,
        ...options,
      }, requestInit)

      .then((res) => {
        const headers = res.headers

        // 获取文件名
        let fileName = downloadOpts.fileName ?? `${new Date()}`

        if (!downloadOpts.overwriteName || !downloadOpts.fileName) {
          // 如果不是确认覆盖文件名,或者没有提供文件名
          // 尝试从响应头获取文件名
          const disposition = headers.get('content-disposition')
          if (disposition) {
            const reg = /filename[*^]?=?"?([^&;"]*)"?/
            const fileNameEncode = reg.exec(disposition)?.[1]
            if (fileNameEncode) {
              fileName = decodeURIComponent(fileNameEncode)
            }
            const ns = fileName.split(`'`)
            if (ns.length > 1) {
              // ['utf-8', 'en', 'filename']
              const [charset, , ...names] = ns
              if (['utf-8'].includes(charset.toLocaleLowerCase())) {
                fileName = names.join(`'`)
              }
            }
          }
        }

        return Promise.all([
          res.blob(),
          fileName,
        ])
      })

      .then(([blob, fileName]) => {
        const link = document.createElement('a')
        link.href = window.URL.createObjectURL(blob)
        link.download = fileName
        link.click()
        window.URL.revokeObjectURL(link.href)
      })
  }

  async reader (
    readerOpts: {
      url: string
      onmessage: EventSourceParseCallback
      abortController?: AbortController
    },
    requestOptions?: Partial<RestFetchRequestOptions>,
    requestInit?: RequestInit,
  ) {
    const onmessage = readerOpts.onmessage ?? noop

    const reader: ReadableStreamDefaultReader<Uint8Array> = await this.response({
      url: readerOpts.url,
      method: 'POST',
      responseThen: res => res.body?.getReader(),
      abortController: readerOpts.abortController,
      ...requestOptions,
    }, requestInit)
    const decoder = new TextDecoder('utf-8')
    const parser = createParser(onmessage)
    if (!reader)
      return
    while (true) {
      const { done, value } = await reader.read()
      if (done)
        break
      parser.feed(decoder.decode(value))
    }
  }

  private async initFetch (
    options: RestFetchRequestOptions,
    init?: RequestInit,
  ): Promise<Response> {
    /* 超时处理 */
    let abortController = options.abortController
    if (!abortController) {
      abortController = new AbortController()
    }
    const timeout = options.timeout ?? this.timeout
    /* 超时处理 end */

    const headers = new Headers()

    // 初始化init参数
    let config: RequestInit = {
      method: options.method,
      headers,
      signal: abortController?.signal,
    }

    const postContentType = options.contentType || 'application/json'

    if (options.method !== 'GET') {
      headers.set('Content-Type', postContentType)
      switch (postContentType) {
        case 'application/json':
          config.body = JSON.stringify(options.data)
          break
        case 'application/x-www-form-urlencoded':
          config.body = stringify(options.data)
          break
        case 'multipart/form-data': {
        // 默认会生成带hash的 ContentType
          headers.delete('Content-Type')
          let formData = new FormData()

          if (options.data) {
            if (options.data instanceof FormData) {
              formData = options.data
            }
            else {
              const keys = Reflect.ownKeys(options.data) as string[]
              keys.forEach((key) => {
                formData.append(key, options.data?.[key] as string | Blob)
              })
            }
          }
          config.body = formData
          break
        }
      }
    }

    // 设置请求头
    if (options.headers) {
      Object.keys(options.headers).forEach((key) => {
        headers.set(key, options.headers?.[key])
      })
    }

    // 将params 参数拼接到url
    let params = ''
    if (options.params) {
      params = `?${stringify(options.params)}`
    }
    const input = (options.baseURL || this.baseURL) + options.url + params

    // 请求拦截
    const setRequestInit = options.setRequestInit ?? this.setRequestInit
    if (this.presetRequestInit) {
      config = this.presetRequestInit(config)
    }

    if (setRequestInit) {
      config = setRequestInit(config)
    }

    const fetchFn = () => {
      const p = fetch(input, {
        ...config,
        ...init,
      }).then((res) => {
        if (!res.ok)
          return Promise.reject(res)
        return res
      })

      /* 超时处理2 */
      if (!timeout)
        return p
      const ontimeout = options.ontimeout ?? this.ontimeout
      const timeoutId = window.setTimeout(() => {
        abortController?.abort()
        ontimeout?.(config)
      }, timeout)
      p.finally(() => {
        window.clearTimeout(timeoutId)
      })
      /* 超时处理2 end */

      return p
    }

    let fetchPromise: Promise<Response> | undefined

    /* 设置队列 */
    if (
      (!options.queue?.id)
      || options.queue.leave
    ) {
      fetchPromise = fetchFn()
      return fetchPromise
    }
    if (!this.queues[options.queue.id]) {
      this.queues[options.queue.id] = []
    }
    if (options.queue.mode === 'parallel') {
      fetchPromise = fetchFn()
      // 并行模式, 直接push
      this.queues[options.queue.id].push({
        promise: fetchPromise,
        abortController,
      })
    }
    else if (options.queue.mode === 'wait') {
      // 等待其他请求完成
      const promises = this.queues[options.queue.id].map(({ promise }) => promise)
      // 所有请求完成,无论成功失败
      const others = Promise.allSettled(promises)

      fetchPromise = others.then(() => {
        return fetchFn()
      })
      // 清空之前队列,只保留当前请求
      this.queues[options.queue.id] = [
        {
          promise: fetchPromise,
          abortController,
        },
      ]
    }
    else {
      // 取消其他请求
      this.queues[options.queue.id].forEach(({ abortController }) => {
        abortController?.abort()
      })
      fetchPromise = fetchFn()

      this.queues[options.queue.id] = [{
        promise: fetchPromise,
        abortController,
      }]
    }
    /* 设置队列 end */

    return fetchPromise ?? fetchFn()
  }

  addMiddleware (fn: RestFetchMiddleware) {
    this.middlewareQueue.push(fn)
  }

  removeMiddleware (fn: RestFetchMiddleware) {
    const index = this.middlewareQueue.indexOf(fn)
    if (index !== -1) {
      this.middlewareQueue.splice(index, 1)
    }
  }
}

export type RestFetchPlugin = (restFetch: RestFetch, ...args: any) => any

type ContentType = 'application/json' | 'application/x-www-form-urlencoded' | 'multipart/form-data'

export interface RestFetchMiddlewareContext<S = NormalObject> {
  /**
   * 请求参数
   */
  req: {
    /**
     * request 参数
     */
    requestOptions: RestFetchRequestOptions

    /**
     * 原生请求参数
     */
    requestInit?: RequestInit
  }

  res: {
    /**
     * this.response 返回值
     */
    response?: any

    /**
     * 返回 respose Promise
     */
    when: () => Promise<any>
  }

  /**
   * 中间件提供的参数
   */
  state: S

  /**
   * 接口返回值
   */
  body: any

}

export interface RestFetchMiddleware<S = NormalObject> {
  (ctx: RestFetchMiddlewareContext<S>, next: () => Promise<any>): Promise<any>
}

/**
 * RestFetch 构造函数参数
 */
export interface RestFetchConstructorOptions {

  baseURL: string

  /**
   * 请求拦截 在  setRequestInit 和 requestInterceptorQueue 之前
   */
  presetRequestInit?: (config: RequestInit) => RequestInit

  /**
   * 请求拦截 设置 RequestInit
   */
  setRequestInit?: (config: RequestInit) => RequestInit
  /**
   * 响应拦截
   * @default res => res.json()
   */
  responseThen?: (res: Response) => any
  /**
   * 响应拦截, 发生在 responseThen 之后
   * @default res => res
   */
  requestThen?: AnyFunc

  /**
   * 默认超时时间
   * 单位 ms
   */
  timeout?: number

  /**
   * 超时回调
   */
  ontimeout?: (config: RequestInit) => ReturnVoid

}

/**
 * restFetch.request 参数
 */
export interface RestFetchRequestOptions {
  /**
   * 请求地址
   * baseURL + url
   */
  url: string

  /**
   * 覆盖 RestFetchConstructorOptions 的 baseURL
   */
  baseURL?: string

  method: 'POST' | 'GET' | 'PUT' | 'DELETE'
  /**
   * 请求参数
   * 请求体
   */
  data?: Record<string, any>
  /**
   * 请求参数
   * 请求地址拼接参数
   */
  params?: Record<string, any>

  /**
   * 请求头
   * @default {}
   */
  headers?: NormalObject

  /**
   * 请求头
   * @default 'application/json'
   */
  contentType?: ContentType

  /**
   * 请求拦截 设置 RequestInit
   * 会覆盖 RestFetchConstructorOptions 的 setRequestInit
   *
   */
  setRequestInit?: (config: RequestInit) => RequestInit

  /**
   * 响应拦截
   * 会覆盖 RestFetchConstructorOptions 的 responseThen
   */
  responseThen?: (res: Response) => any

  /**
   * 自定义 AbortController
   * timeout 时间到会自动 abort
   * @default new AbortController()
   */
  abortController?: AbortController

  /**
   * 超时时间
   * 单位 ms
   * 会覆盖 RestFetchConstructorOptions 的 timeout
   */
  timeout?: number

  /**
   * 超时回调
   * 会覆盖 RestFetchConstructorOptions 的 ontimeout
   */
  ontimeout?: (config: RequestInit) => ReturnVoid

  /**
   * 设置缓存
   */
  cache?: {
    /**
     * 唯一标识
     */
    id: string
    /**
     * 是否强制更新
     */
    forceUpdate?: boolean
  }

  /**
   * 队列
   */
  queue?: {
    /**
     * 唯一标识
     */
    id: string

    /**
     * 选择模式,
     * abort. 有相同id的请求,打断之前的请后执行
     * wait. 有相同id的请求,等待之前的请求完成后再执行
     * parallel. 仅加入队列,与队列中其他请求并行执行
     *
     */
    mode?: 'abort' | 'wait' | 'parallel'

    /**
     * 是否离开队列
     */
    leave?: boolean
  }
}

/**
 * restFetch.response
 */
export interface RestFetchResponse {
  (
    options: RestFetchRequestOptions,
    requestInit?: RequestInit
  ): Promise<Response>
}

/**
 * restFetch.request
 */
export interface RestFetchRequest {
  (
    options: RestFetchRequestOptions,
    state?: NormalObject,
    requestInit?: RequestInit,
  ): Promise<any>
}

export type RestFetchReaderOnmessage = EventSourceParseCallback

fetch/RestFetch