1. 为什么我们需要封装fetch?
作为一名前端开发者,我深知原生fetch API的痛点。每次看到项目中重复的.then().catch()链式调用,还有那些无处不在的状态码判断,我就忍不住想:这真的有必要吗?
1.1 原生fetch的三大痛点
首先,让我们直面原生fetch的不足之处:
-
冗余的错误处理:每个请求都需要手动检查
res.ok,处理HTTP状态码,还要捕获可能的JSON解析错误。我曾经在一个项目中统计过,光是错误处理的代码就占了整个请求逻辑的70%。 -
缺乏统一配置:想要给所有请求添加统一的headers?比如Authorization token?原生fetch下你只能在每个调用处重复添加,维护起来简直是噩梦。
-
功能缺失:超时控制?请求取消?重试机制?这些常见需求fetch都没有内置支持,需要开发者自己实现。
1.2 封装的价值所在
封装fetch的核心价值在于:
- 减少重复代码:把通用的逻辑(如错误处理、headers设置)集中管理
- 统一行为:确保所有请求都遵循相同的处理流程
- 增强功能:添加超时、重试、拦截器等高级特性
- 提高可维护性:配置和逻辑集中在一处,修改时只需改动一个地方
typescript复制// 原生fetch vs 封装后的对比
// 原生 - 每个请求都要写这么多
fetch('/api/user', {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
})
.then(res => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then(data => console.log(data))
.catch(err => console.error(err))
// 封装后 - 简洁明了
api.get('/user').then(console.log).catch(console.error)
2. 核心封装方案详解
2.1 基础请求封装
让我们从最基础的封装开始。首先定义一个TypeScript接口来描述我们的请求配置:
typescript复制interface RequestConfig extends RequestInit {
url: string
baseURL?: string
timeout?: number
params?: Record<string, any>
data?: any
}
然后实现核心的request函数:
typescript复制async function request<T>(config: RequestConfig): Promise<T> {
const {
url,
baseURL = '',
timeout = 10000,
headers = {},
params,
data,
...rest
} = config
// 处理URL和查询参数
const fullUrl = new URL(url.startsWith('http') ? url : `${baseURL}${url}`)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
fullUrl.searchParams.append(key, String(value))
}
})
}
// 处理请求体
let body: BodyInit | null = null
if (data) {
body = JSON.stringify(data)
headers['Content-Type'] = 'application/json'
}
// 超时控制
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(fullUrl.toString(), {
...rest,
headers,
body,
signal: controller.signal
})
clearTimeout(timeoutId)
// 处理响应
if (!response.ok) {
const errorData = await parseErrorResponse(response)
throw new HttpError(response.status, errorData)
}
return parseResponse<T>(response)
} catch (error) {
clearTimeout(timeoutId)
throw normalizeError(error)
}
}
2.2 响应和错误处理
良好的错误处理是封装的关键。我们需要统一处理各种可能的错误情况:
typescript复制// 自定义HTTP错误类
class HttpError extends Error {
constructor(
public status: number,
public data: any
) {
super(`HTTP Error: ${status}`)
}
}
// 解析响应
async function parseResponse<T>(response: Response): Promise<T> {
const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
return response.json()
}
if (contentType?.includes('text/')) {
return response.text() as any
}
return response.blob() as any
}
// 解析错误响应
async function parseErrorResponse(response: Response) {
try {
return await response.json()
} catch {
return { message: response.statusText }
}
}
// 标准化错误
function normalizeError(error: unknown): Error {
if (error instanceof Error) {
if (error.name === 'AbortError') {
return new Error('请求超时,请稍后重试')
}
return error
}
return new Error('未知错误')
}
3. 高级功能实现
3.1 拦截器系统
拦截器是强大且灵活的功能,允许我们在请求发出前和响应返回后插入自定义逻辑:
typescript复制type RequestInterceptor = (config: RequestConfig) => RequestConfig | Promise<RequestConfig>
type ResponseInterceptor<T = any> = (response: T) => T | Promise<T>
type ErrorInterceptor = (error: Error) => Error | Promise<Error>
class HttpClient {
private requestInterceptors: RequestInterceptor[] = []
private responseInterceptors: ResponseInterceptor[] = []
private errorInterceptors: ErrorInterceptor[] = []
useRequestInterceptor(interceptor: RequestInterceptor) {
this.requestInterceptors.push(interceptor)
return this
}
useResponseInterceptor(interceptor: ResponseInterceptor) {
this.responseInterceptors.push(interceptor)
return this
}
useErrorInterceptor(interceptor: ErrorInterceptor) {
this.errorInterceptors.push(interceptor)
return this
}
async request<T>(config: RequestConfig): Promise<T> {
try {
// 应用请求拦截器
let finalConfig = config
for (const interceptor of this.requestInterceptors) {
finalConfig = await interceptor(finalConfig)
}
// 发送请求
let response = await baseRequest<T>(finalConfig)
// 应用响应拦截器
for (const interceptor of this.responseInterceptors) {
response = await interceptor(response)
}
return response
} catch (error) {
// 应用错误拦截器
let finalError = normalizeError(error)
for (const interceptor of this.errorInterceptors) {
finalError = await interceptor(finalError)
}
throw finalError
}
}
}
3.2 Token自动刷新
处理401状态码和token刷新是现实项目中的常见需求:
typescript复制let isRefreshing = false
let pendingQueue: Array<(token: string) => void> = []
async function refreshToken(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token')
if (!refreshToken) throw new Error('无刷新令牌')
const res = await baseRequest<{ access_token: string }>({
url: '/auth/refresh',
method: 'POST',
skipAuth: true,
data: { refresh_token: refreshToken }
})
localStorage.setItem('access_token', res.access_token)
return res.access_token
}
// 在请求拦截器中添加token
http.useRequestInterceptor(config => {
if (config.skipAuth) return config
const token = localStorage.getItem('access_token')
if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`
}
}
return config
})
// 在错误拦截器中处理401
http.useErrorInterceptor(async error => {
if (!(error instanceof HttpError) || error.status !== 401) {
return error
}
// 如果是刷新token的请求失败,直接跳登录
if (error.config.url.includes('/auth/refresh')) {
logout()
return error
}
// 加入重试队列
return new Promise((resolve, reject) => {
pendingQueue.push(newToken => {
error.config.headers.Authorization = `Bearer ${newToken}`
http.request(error.config).then(resolve).catch(reject)
})
if (!isRefreshing) {
isRefreshing = true
refreshToken()
.then(token => {
pendingQueue.forEach(cb => cb(token))
pendingQueue = []
})
.catch(err => {
pendingQueue = []
logout()
reject(err)
})
.finally(() => {
isRefreshing = false
})
}
})
})
3.3 请求重试机制
对于网络不稳定的情况,实现指数退避的重试机制:
typescript复制async function requestWithRetry<T>(
config: RequestConfig,
maxRetries = 3
): Promise<T> {
let lastError: Error
let attempt = 0
while (attempt <= maxRetries) {
try {
return await http.request<T>(config)
} catch (error) {
lastError = error as Error
attempt++
// 只有网络错误才重试
if (!isNetworkError(error) || attempt > maxRetries) {
break
}
// 指数退避
const delay = Math.pow(2, attempt) * 1000
await new Promise(resolve => setTimeout(resolve, delay))
}
}
throw lastError
}
function isNetworkError(error: unknown): boolean {
return error instanceof Error && (
error.message.includes('Failed to fetch') ||
error.message.includes('NetworkError') ||
error.message.includes('ECONNREFUSED')
)
}
4. 实战应用技巧
4.1 文件上传与下载
处理文件传输需要特殊考虑:
typescript复制// 文件上传带进度
async function uploadFile(
url: string,
file: File,
onProgress?: (percent: number) => void
): Promise<any> {
const formData = new FormData()
formData.append('file', file)
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100)
onProgress?.(percent)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText))
} catch {
resolve(xhr.responseText)
}
} else {
reject(new HttpError(xhr.status, xhr.responseText))
}
}
xhr.onerror = () => reject(new Error('Network error'))
xhr.open('POST', url)
const token = localStorage.getItem('access_token')
if (token) {
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
}
xhr.send(formData)
})
}
// 大文件下载
async function downloadFile(url: string, filename: string) {
const response = await http.request<Blob>({
url,
responseType: 'blob'
})
const blobUrl = window.URL.createObjectURL(response)
const a = document.createElement('a')
a.href = blobUrl
a.download = filename
a.click()
window.URL.revokeObjectURL(blobUrl)
}
4.2 并发请求控制
处理多个并发请求时的常见模式:
typescript复制// 并发请求限制
async function concurrentRequests<T>(
requests: (() => Promise<T>)[],
maxConcurrent = 5
): Promise<T[]> {
const results: T[] = []
const executing = new Set<Promise<any>>()
for (const request of requests) {
const p = request().then(res => {
results.push(res)
executing.delete(p)
})
executing.add(p)
if (executing.size >= maxConcurrent) {
await Promise.race(executing)
}
}
await Promise.all(executing)
return results
}
// 使用示例
const userIds = [1, 2, 3, 4, 5]
const users = await concurrentRequests(
userIds.map(id => () => http.get(`/users/${id}`)),
2 // 每次最多2个并发请求
)
4.3 数据缓存策略
实现请求缓存以减少不必要的网络请求:
typescript复制const cache = new Map<string, { data: any; expire: number }>()
async function cachedRequest<T>(
config: RequestConfig,
ttl = 60000 // 默认缓存1分钟
): Promise<T> {
const cacheKey = JSON.stringify(config)
const cached = cache.get(cacheKey)
if (cached && cached.expire > Date.now()) {
return cached.data as T
}
const data = await http.request<T>(config)
cache.set(cacheKey, {
data,
expire: Date.now() + ttl
})
return data
}
// 清除特定缓存
function clearCache(config?: RequestConfig) {
if (config) {
const cacheKey = JSON.stringify(config)
cache.delete(cacheKey)
} else {
cache.clear()
}
}
5. 调试与错误排查
5.1 网络问题诊断
typescript复制function diagnoseNetworkError(error: Error): {
type: string
message: string
solution: string
} {
const msg = error.message
if (msg.includes('Failed to fetch')) {
return {
type: 'NETWORK_FAILURE',
message: '无法连接到服务器',
solution: '请检查网络连接或服务器状态'
}
}
if (msg.includes('AbortError')) {
return {
type: 'TIMEOUT',
message: '请求超时',
solution: '请检查网络状况或增加超时时间'
}
}
if (msg.includes('CORS') || msg.includes('cross-origin')) {
return {
type: 'CORS_ERROR',
message: '跨域请求被阻止',
solution: '请检查服务器CORS配置'
}
}
return {
type: 'UNKNOWN_ERROR',
message: '未知网络错误',
solution: '请查看控制台获取详细信息'
}
}
5.2 请求日志记录
typescript复制interface RequestLog {
timestamp: Date
method: string
url: string
status?: number
duration: number
requestBody?: any
responseBody?: any
error?: any
}
const requestLogs: RequestLog[] = []
http.useRequestInterceptor(config => {
const log: RequestLog = {
timestamp: new Date(),
method: config.method || 'GET',
url: config.url,
requestBody: config.data,
duration: 0
}
requestLogs.push(log)
// 在config上附加log引用
return { ...config, _logRef: log }
})
http.useResponseInterceptor((response, config) => {
const log = (config as any)._logRef as RequestLog | undefined
if (log) {
log.duration = Date.now() - log.timestamp.getTime()
log.responseBody = response
log.status = 200
}
return response
})
http.useErrorInterceptor((error, config) => {
const log = (config as any)._logRef as RequestLog | undefined
if (log) {
log.duration = Date.now() - log.timestamp.getTime()
log.error = error
if (error instanceof HttpError) {
log.status = error.status
}
}
return error
})
6. 性能优化建议
6.1 减少包体积
typescript复制// 按需加载polyfill
if (!window.fetch) {
await import('whatwg-fetch').then(({ fetch }) => {
window.fetch = fetch
})
}
if (!window.AbortController) {
await import('abortcontroller-polyfill')
}
6.2 请求合并
对于短时间内可能发生的多个相似请求:
typescript复制const pendingRequests = new Map<string, Promise<any>>()
function deduplicateRequest<T>(config: RequestConfig): Promise<T> {
const key = `${config.method}:${config.url}:${JSON.stringify(config.data)}`
if (pendingRequests.has(key)) {
return pendingRequests.get(key) as Promise<T>
}
const promise = http.request<T>(config).finally(() => {
pendingRequests.delete(key)
})
pendingRequests.set(key, promise)
return promise
}
6.3 预加载策略
typescript复制// 预加载可能需要的资源
function prefetchResources(urls: string[]) {
urls.forEach(url => {
const link = document.createElement('link')
link.rel = 'prefetch'
link.href = url
document.head.appendChild(link)
})
}
// 使用示例
prefetchResources([
'/api/user/profile',
'/api/user/settings'
])
7. 测试与Mock方案
7.1 单元测试策略
typescript复制// 使用jest测试示例
describe('http client', () => {
beforeEach(() => {
jest.resetAllMocks()
localStorage.clear()
})
it('should add auth header', async () => {
localStorage.setItem('access_token', 'test-token')
const mockFetch = jest.fn().mockResolvedValue(new Response('{}'))
window.fetch = mockFetch
await http.get('/user')
expect(mockFetch).toBeCalledWith(
expect.anything(),
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer test-token'
})
})
)
})
it('should handle 401 and refresh token', async () => {
// 模拟第一次请求返回401
const mockFetch = jest.fn()
.mockRejectedValueOnce(new HttpError(401, {}))
.mockResolvedValueOnce(new Response(JSON.stringify({ access_token: 'new-token' })))
.mockResolvedValueOnce(new Response('{"name":"John"}'))
window.fetch = mockFetch
localStorage.setItem('access_token', 'expired-token')
localStorage.setItem('refresh_token', 'valid-refresh-token')
const result = await http.get('/user')
expect(result).toEqual({ name: 'John' })
expect(localStorage.getItem('access_token')).toBe('new-token')
})
})
7.2 Mock服务方案
typescript复制// 使用MSW(Mock Service Worker)设置API mock
import { setupWorker, rest } from 'msw'
const worker = setupWorker(
rest.get('/api/user', (req, res, ctx) => {
const isAuth = req.headers.get('Authorization') === 'Bearer valid-token'
if (!isAuth) {
return res(
ctx.status(401),
ctx.json({ error: 'Unauthorized' })
)
}
return res(
ctx.delay(150), // 模拟网络延迟
ctx.json({ id: 1, name: 'John Doe' })
)
}),
rest.post('/auth/refresh', (req, res, ctx) => {
const { refresh_token } = req.body as any
if (refresh_token === 'valid-refresh-token') {
return res(
ctx.json({ access_token: 'new-token' })
)
}
return res(
ctx.status(401),
ctx.json({ error: 'Invalid refresh token' })
)
})
)
// 在测试启动前
worker.start({
onUnhandledRequest: 'warn'
})
// 在测试结束后
afterAll(() => worker.stop())
8. 最佳实践总结
8.1 封装程度把控
- 适度封装:不要过度设计,简单的项目可能只需要基础封装
- 明确边界:区分哪些功能应该放在封装层,哪些应该留在业务代码
- 保持灵活:允许特殊情况绕过封装直接使用原生fetch
8.2 团队协作建议
- 统一规范:团队应该使用相同的封装方案
- 文档完善:记录所有配置选项和拦截器用法
- 版本管理:当封装逻辑更新时,通过版本号明确变更
8.3 性能权衡
- 缓存策略:根据业务需求选择合适的缓存时间
- 重试策略:不是所有错误都适合重试
- 并发控制:避免同时发起过多请求影响性能
8.4 未来演进
- TypeScript强化:不断完善类型定义
- Tree-shaking支持:确保只打包用到的功能
- 可观测性增强:更好的日志和监控集成
在实际项目中,我发现这套封装方案能够满足90%以上的需求场景。特别是在大型项目中,统一的请求处理能够显著提高代码质量和维护性。不过也要注意,封装不是万能的,要根据项目实际情况灵活调整。