介绍
在现代前端项目中,HTTP 请求是不可或缺的基础能力。尤其在移动端场景下,网络环境复杂多变(弱网、切换基站、断线重连),一个轻量、可控的请求工具尤为重要。与其引入数十 KB 的第三方库,不如基于浏览器原生的 fetch API 封装一个轻量级的请求工具,既满足日常需求,又保持极小的打包体积。
实现一个轻量级的 axios
或许你不需要一个重量级的 HTTP 库——下面的实现仅约 2KB,却涵盖了拦截器、基础 URL、查询参数、超时、请求去重、响应类型转换等核心功能,足以应对大多数业务场景。
核心能力
- 拦截器 — 请求拦截器 + 响应拦截器(fulfilled/rejected 管道)
- 超时 — 实例级默认超时 + 请求级覆盖,基于
AbortController - 请求去重 —
extra.dedupe相同并发请求复用首个 Promise - 查询参数 — 自动拼接
?key=value,支持与拦截器追加的参数合并 - 响应类型 —
json/text/blob/arrayBuffer/formData - 请求方法 —
get/post/put/delete/patch/head/options
你可以在此基础上按需扩展重试、弱网提示、请求取消等移动端常用能力。
ts
type RequestExtra = {
/** 重试次数,配合响应拦截器 rejected 使用 */
retryCount?: number
/** 重试间隔(ms) */
retryDelay?: number
/** 是否启用请求去重,相同请求并发时复用第一个 Promise */
dedupe?: boolean
[key: string]: any
}
type RequestConfig = RequestInit & {
url: string
data?: any
params?: Record<string, string | number | boolean>
responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData'
/** 请求超时时间(ms),传入 0 表示不超时,优先使用请求级配置,否则回退到实例级默认值 */
timeout?: number
extra?: RequestExtra
}
type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise<RequestConfig>
type RequestErrorInterceptor = (
error: unknown,
config: RequestConfig,
) => RequestConfig | Promise<RequestConfig>
type ResponseInterceptor<T = any, R = T> = (response: T, config: RequestConfig) => R | Promise<R>
type ResponseErrorInterceptor<T = any> = (error: unknown, config: RequestConfig) => T | Promise<T>
export class Fetcher {
private baseURL = ''
private defaultTimeout: number | undefined
private pendingMap = new Map<string, Promise<any>>()
private requestInterceptors: Array<{
fulfilled: RequestInterceptor
rejected: RequestErrorInterceptor
}> = []
private responseInterceptors: Array<{
fulfilled: ResponseInterceptor
rejected: ResponseErrorInterceptor
}> = []
constructor(config?: { baseURL?: string; timeout?: number }) {
if (config?.baseURL) {
this.baseURL = config.baseURL.replace(/\/$/, '')
}
this.defaultTimeout = config?.timeout
}
/** 添加请求拦截器,第二个参数为错误处理,返回移除函数 */
useRequest(fulfilled: RequestInterceptor, rejected?: RequestErrorInterceptor) {
const entry = { fulfilled, rejected: rejected ?? ((e) => Promise.reject(e)) }
this.requestInterceptors.push(entry)
}
/** 添加响应拦截器,第二个参数为错误处理(支持重试),返回移除函数 */
useResponse(fulfilled: ResponseInterceptor, rejected?: ResponseErrorInterceptor) {
const entry = { fulfilled, rejected: rejected ?? ((e) => Promise.reject(e)) }
this.responseInterceptors.push(entry)
}
/** 生成请求去重的唯一 key,基于 method + url + params + headers */
private dedupeKey(config: RequestConfig): string {
const { method, url, data, params, headers } = config
const h: Record<string, string> = {}
if (headers) {
new Headers(headers).forEach((v, k) => (h[k] = v))
}
return `${method}:${url}:${JSON.stringify(params ?? {})}:${JSON.stringify(data ?? null)}:${JSON.stringify(h)}`
}
private joinURL(base: string, path: string): string {
return base + (path.startsWith('/') ? path : '/' + path)
}
private async execute(config: RequestConfig): Promise<any> {
const { method: _method = 'GET', url, data, ...rest } = config
const method = _method.toUpperCase()
let merged: RequestConfig = {
...rest,
url: this.joinURL(this.baseURL, url),
method,
}
const params = rest.params
// 执行请求拦截器,按 then(onFulfilled, onRejected) 成对执行
let configPromise: Promise<RequestConfig> = Promise.resolve(merged)
for (const { fulfilled, rejected } of this.requestInterceptors) {
configPromise = configPromise.then(
(cfg) => fulfilled(cfg),
(error) => rejected(error, merged),
)
}
merged = await configPromise
// 序列化查询参数
const allParams = { ...params, ...merged.params }
if (Object.keys(allParams).length) {
const search = new URLSearchParams(
Object.entries(allParams).map(([k, v]) => [k, String(v)]),
).toString()
merged.url += (merged.url.includes('?') ? '&' : '?') + search
}
const { url: finalUrl, params: _1, extra: _2, timeout, ...init } = merged
// 超时控制:请求级 > 实例级,0 表示不超时
const effectiveTimeout = timeout ?? this.defaultTimeout
if (effectiveTimeout && effectiveTimeout > 0) {
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(new DOMException('Request timeout', 'TimeoutError')),
effectiveTimeout,
)
// 合并外部 signal 与超时 signal
if (init.signal) {
init.signal.addEventListener('abort', () => controller.abort(), { once: true })
}
init.signal = controller.signal
// 请求结束后清理定时器
controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), { once: true })
}
if (!['GET', 'HEAD'].includes(method)) {
init.headers = new Headers(init.headers)
if (data != null) {
if (
typeof data === 'object' &&
!(
data instanceof FormData ||
data instanceof Blob ||
data instanceof URLSearchParams ||
data instanceof ReadableStream ||
data instanceof ArrayBuffer ||
ArrayBuffer.isView(data)
)
) {
init.body = JSON.stringify(data)
init.headers.set('Content-Type', 'application/json')
} else {
init.body = data as BodyInit
}
} else if (!init.headers.has('Content-Type')) {
init.headers.set('Content-Type', 'application/json')
}
}
// 请求去重:相同请求并发时复用已有 Promise
if (merged.extra?.dedupe) {
const key = this.dedupeKey(merged)
const pending = this.pendingMap.get(key)
if (pending) return pending
}
const promise = this.doFetch(finalUrl || '', init, merged)
if (merged.extra?.dedupe) {
const key = this.dedupeKey(merged)
this.pendingMap.set(key, promise)
promise.finally(() => this.pendingMap.delete(key))
}
return promise
}
/** 实际发起请求,响应拦截器按 then(onFulfilled, onRejected) 成对执行 */
private async doFetch(url: string, init: RequestInit, config: RequestConfig): Promise<any> {
let promise: Promise<any> = fetch(url, init)
for (const { fulfilled, rejected } of this.responseInterceptors) {
promise = promise.then(
(response) => fulfilled(response, config),
(error) => rejected(error, config),
)
}
return promise
}
async request<T = any>(config: RequestConfig): Promise<T> {
const response = await this.execute(config)
if (response instanceof Response) {
const responseType = config.responseType ?? 'json'
return response[responseType]() as T
}
return response as T
}
get<T = any>(url: string, config?: Omit<RequestConfig, 'url' | 'data' | 'body'>) {
return this.request<T>({ ...config, method: 'GET', url })
}
post<T = any, D = any>(
url: string,
data?: D,
config?: Omit<RequestConfig, 'url' | 'data' | 'body'>,
) {
return this.request<T>({ ...config, method: 'POST', url, data })
}
put<T = any, D = any>(
url: string,
data?: D,
config?: Omit<RequestConfig, 'url' | 'data' | 'body'>,
) {
return this.request<T>({ ...config, method: 'PUT', url, data })
}
delete<T = any>(url: string, config?: Omit<RequestConfig, 'url' | 'data' | 'body'>) {
return this.request<T>({ ...config, method: 'DELETE', url })
}
patch<T = any, D = any>(
url: string,
data?: D,
config?: Omit<RequestConfig, 'url' | 'data' | 'body'>,
) {
return this.request<T>({ ...config, method: 'PATCH', url, data })
}
head<T = any>(url: string, config?: Omit<RequestConfig, 'url' | 'data' | 'body'>) {
return this.request<T>({ ...config, method: 'HEAD', url })
}
options<T = any>(url: string, config?: Omit<RequestConfig, 'url' | 'data' | 'body'>) {
return this.request<T>({ ...config, method: 'OPTIONS', url })
}
}js
export class Fetcher {
baseURL = ''
defaultTimeout
pendingMap = new Map()
requestInterceptors = []
responseInterceptors = []
constructor(config) {
if (config?.baseURL) {
this.baseURL = config.baseURL.replace(/\/$/, '')
}
this.defaultTimeout = config?.timeout
}
/** 添加请求拦截器,第二个参数为错误处理,返回移除函数 */
useRequest(fulfilled, rejected) {
const entry = {
fulfilled,
rejected: rejected ?? ((e) => Promise.reject(e)),
}
this.requestInterceptors.push(entry)
}
/** 添加响应拦截器,第二个参数为错误处理(支持重试),返回移除函数 */
useResponse(fulfilled, rejected) {
const entry = {
fulfilled,
rejected: rejected ?? ((e) => Promise.reject(e)),
}
this.responseInterceptors.push(entry)
}
/** 生成请求去重的唯一 key,基于 method + url + params + headers */
dedupeKey(config) {
const { method, url, data, params, headers } = config
const h = {}
if (headers) {
new Headers(headers).forEach((v, k) => (h[k] = v))
}
return `${method}:${url}:${JSON.stringify(params ?? {})}:${JSON.stringify(data ?? null)}:${JSON.stringify(h)}`
}
joinURL(base, path) {
return base + (path.startsWith('/') ? path : '/' + path)
}
async execute(config) {
const { method: _method = 'GET', url, data, ...rest } = config
const method = _method.toUpperCase()
let merged = {
...rest,
url: this.joinURL(this.baseURL, url),
method,
}
const params = rest.params
// 执行请求拦截器,按 then(onFulfilled, onRejected) 成对执行
let configPromise = Promise.resolve(merged)
for (const { fulfilled, rejected } of this.requestInterceptors) {
configPromise = configPromise.then(
(cfg) => fulfilled(cfg),
(error) => rejected(error, merged),
)
}
merged = await configPromise
// 序列化查询参数
const allParams = {
...params,
...merged.params,
}
if (Object.keys(allParams).length) {
const search = new URLSearchParams(
Object.entries(allParams).map(([k, v]) => [k, String(v)]),
).toString()
merged.url += (merged.url.includes('?') ? '&' : '?') + search
}
const { url: finalUrl, params: _1, extra: _2, timeout, ...init } = merged
// 超时控制:请求级 > 实例级,0 表示不超时
const effectiveTimeout = timeout ?? this.defaultTimeout
if (effectiveTimeout && effectiveTimeout > 0) {
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(new DOMException('Request timeout', 'TimeoutError')),
effectiveTimeout,
)
// 合并外部 signal 与超时 signal
if (init.signal) {
init.signal.addEventListener('abort', () => controller.abort(), { once: true })
}
init.signal = controller.signal
// 请求结束后清理定时器
controller.signal.addEventListener('abort', () => clearTimeout(timeoutId), { once: true })
}
if (!['GET', 'HEAD'].includes(method)) {
init.headers = new Headers(init.headers)
if (data != null) {
if (
typeof data === 'object' &&
!(
data instanceof FormData ||
data instanceof Blob ||
data instanceof URLSearchParams ||
data instanceof ReadableStream ||
data instanceof ArrayBuffer ||
ArrayBuffer.isView(data)
)
) {
init.body = JSON.stringify(data)
init.headers.set('Content-Type', 'application/json')
} else {
init.body = data
}
} else if (!init.headers.has('Content-Type')) {
init.headers.set('Content-Type', 'application/json')
}
}
// 请求去重:相同请求并发时复用已有 Promise
if (merged.extra?.dedupe) {
const key = this.dedupeKey(merged)
const pending = this.pendingMap.get(key)
if (pending) return pending
}
const promise = this.doFetch(finalUrl || '', init, merged)
if (merged.extra?.dedupe) {
const key = this.dedupeKey(merged)
this.pendingMap.set(key, promise)
promise.finally(() => this.pendingMap.delete(key))
}
return promise
}
/** 实际发起请求,响应拦截器按 then(onFulfilled, onRejected) 成对执行 */
async doFetch(url, init, config) {
let promise = fetch(url, init)
for (const { fulfilled, rejected } of this.responseInterceptors) {
promise = promise.then(
(response) => fulfilled(response, config),
(error) => rejected(error, config),
)
}
return promise
}
async request(config) {
const response = await this.execute(config)
if (response instanceof Response) {
const responseType = config.responseType ?? 'json'
return response[responseType]()
}
return response
}
get(url, config) {
return this.request({
...config,
method: 'GET',
url,
})
}
post(url, data, config) {
return this.request({
...config,
method: 'POST',
url,
data,
})
}
put(url, data, config) {
return this.request({
...config,
method: 'PUT',
url,
data,
})
}
delete(url, config) {
return this.request({
...config,
method: 'DELETE',
url,
})
}
patch(url, data, config) {
return this.request({
...config,
method: 'PATCH',
url,
data,
})
}
head(url, config) {
return this.request({
...config,
method: 'HEAD',
url,
})
}
options(url, config) {
return this.request({
...config,
method: 'OPTIONS',
url,
})
}
}根据业务添加拦截器
基于 Fetcher 添加请求和响应拦截器,构成完整的请求管道:
- 请求拦截 — 自动注入
Authorizationtoken - HTTP 状态码检查 — 非 2xx 直接 reject
- 响应类型转换 — 按
responseType解析响应体 - 业务数据解析 — 检查
code并提取data,支持extra.retryCount重试
ts
import { toast } from 'sard'
import { Fetcher } from './fetcher'
export const fetcher = new Fetcher({ baseURL: '/' })
fetcher.useRequest((config) => {
const headers = new Headers(config.headers)
headers.set('Authorization', `Bearer ${localStorage.getItem('token')}`)
return { ...config, headers }
})
fetcher.useResponse((response: Response) => {
if (response.status < 200 || response.status >= 300) {
return Promise.reject(response)
}
return response
})
fetcher.useResponse((response: Response, config) => {
const responseType = config.responseType ?? 'json'
return response[responseType]()
})
fetcher.useResponse(
(response: { code: number; data: unknown; message: string }) => {
if (!response || typeof response !== 'object') {
return Promise.reject('Error')
}
if (response.code !== 200) {
return Promise.reject(response.message || 'Error')
}
return response.data
},
async (error, config) => {
// 手动 abort:跳过重试,直接抛出
if (error instanceof DOMException && error.name === 'AbortError') {
throw error
}
const extra = config.extra ?? {}
if (extra.retryCount && --extra.retryCount > 0) {
await new Promise((r) => setTimeout(r, extra.retryDelay ?? 1000))
return fetcher.request(config)
}
let errorText = ''
if (typeof error === 'string') {
errorText = error
} else if (error instanceof Response) {
errorText = error.status + ' ' + error.statusText
} else if (error instanceof Error) {
errorText = error.message
}
toast({
title: errorText || 'Error',
timeout: 3000,
})
throw error
},
)js
import { toast } from 'sard'
import { Fetcher } from './fetcher'
export const fetcher = new Fetcher({ baseURL: '/' })
fetcher.useRequest((config) => {
const headers = new Headers(config.headers)
headers.set('Authorization', `Bearer ${localStorage.getItem('token')}`)
return {
...config,
headers,
}
})
fetcher.useResponse((response) => {
if (response.status < 200 || response.status >= 300) {
return Promise.reject(response)
}
return response
})
fetcher.useResponse((response, config) => {
const responseType = config.responseType ?? 'json'
return response[responseType]()
})
fetcher.useResponse(
(response) => {
if (!response || typeof response !== 'object') {
return Promise.reject('Error')
}
if (response.code !== 200) {
return Promise.reject(response.message || 'Error')
}
return response.data
},
async (error, config) => {
// 手动 abort:跳过重试,直接抛出
if (error instanceof DOMException && error.name === 'AbortError') {
throw error
}
const extra = config.extra ?? {}
if (extra.retryCount && --extra.retryCount > 0) {
await new Promise((r) => setTimeout(r, extra.retryDelay ?? 1e3))
return fetcher.request(config)
}
let errorText = ''
if (typeof error === 'string') {
errorText = error
} else if (error instanceof Response) {
errorText = error.status + ' ' + error.statusText
} else if (error instanceof Error) {
errorText = error.message
}
toast({
title: errorText || 'Error',
timeout: 3e3,
})
throw error
},
)使用
ts
import { fetcher } from '@/utils/request'
// 自动带 token、自动解析 data 字段
const user = await fetcher.get<User>('/user', { params: { id: 1 } })
// 带重试
await fetcher.get('/unstable-api', {
extra: { retryCount: 3, retryDelay: 500 },
})
// 单次请求覆盖超时(默认不限)
await fetcher.get('/slow-api', { timeout: 10000 })
// 请求去重:并发相同请求只发一次
const [a, b] = await Promise.all([
fetcher.get('/data', { params: { id: 1 }, extra: { dedupe: true } }),
fetcher.get('/data', { params: { id: 1 }, extra: { dedupe: true } }),
])
// a === b
// 手动取消请求
const controller = new AbortController()
fetcher.get('/cancel-me', { signal: controller.signal })
controller.abort()
// POST 请求(data 支持泛型)
interface CreateUser {
name: string
age: number
}
await fetcher.post<{ id: number }, CreateUser>('/user', { name: 'Tom', age: 18 })
// 获取文本响应
const html = await fetcher.get<string>('/page', { responseType: 'text' })