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 参考
构造函数选项
| 参数 | 类型 | 描述 | 默认值 |
|---|---|---|---|
| baseURL | string | 基础 URL | - |
| timeout | number | 超时时间(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 | 超时回调 | - |
请求选项
| 参数 | 类型 | 描述 | 默认值 |
|---|---|---|---|
| url | string | 请求路径 | - |
| baseURL | string | 覆盖构造函数中的基础 URL | - |
| method | ‘GET’ | ‘POST’ | ‘PUT’ | ‘DELETE’ | 请求方法 | - |
| headers | object | 请求头 | - |
| params | object | URL 参数 | - |
| data | object | 请求体数据 | - |
| contentType | ‘application/json’ | ‘application/x-www-form-urlencoded’ | ‘multipart/form-data’ | 内容类型 | ‘application/json’ |
| setRequestInit | (config: RequestInit) => RequestInit | 请求配置,覆盖构造函数配置 | - |
| responseThen | - | 响应拦截器,覆盖构造函数配置 | - |
| timeout | number | 请求超时时间,覆盖构造函数配置 | - |
| ontimeout | (config: RequestInit) => void | 超时回调,覆盖构造函数配置 | - |
| abortController | AbortController | 自定义中断控制器 | 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 = EventSourceParseCallbackfetch/RestFetch