fetch() 进阶实战:AbortController、超时重试、并发控制与流式响应完全指南

深入讲解现代 fetch() API 的高级用法,包括 AbortController 请求取消、超时控制、指数退避重试、并发限制和流式响应处理,附完整可运行代码示例。

前端开发 2026-05-30 15 分钟

在 2026 年的前端开发生态中,fetch() 已经彻底取代了 XMLHttpRequest,成为浏览器端 HTTP 请求的事实标准。但大多数开发者对 fetch() 的使用仍停留在「发请求、拿数据」的初级阶段——一个简单的 fetch(url).then(r => r.json()) 就完事了。实际上,现代 fetch() API 配合 AbortControllerReadableStreamPromise 并发控制等能力,已经可以构建出媲美专业 HTTP 客户端库(如 Axios)的生产级请求方案,而且零依赖、体积为零

本文将从实际生产场景出发,带你掌握 fetch() 的全部高级能力。所有代码均基于原生 API,无需任何第三方库,可直接在浏览器和 Node.js 18+ 中运行。

🔐 一、请求取消与超时控制

在真实的 Web 应用中,用户会快速切换页面、反复点击搜索按钮、在文件上传中途取消操作。如果你的代码没有处理这些场景,就会出现竞态条件(Race Condition)——旧请求的响应覆盖新请求的结果,或者已离开页面的请求仍在后台消耗资源。

AbortController 基础

AbortController 是浏览器原生提供的请求取消机制。它的核心原理是通过一个 AbortSignal 对象,在请求和调用者之间建立一条「取消通道」。

// 创建一个 AbortController 实例
const controller = new AbortController()
const signal = controller.signal

// 将 signal 传递给 fetch
fetch('https://api.example.com/data', { signal })
  .then(response => response.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求已被取消')
    } else {
      console.error('请求失败:', err)
    }
  })

// 1 秒后取消请求
setTimeout(() => controller.abort(), 1000)

⚠️ 警告:fetch() 被取消后会抛出 AbortError,这是一个正常的控制流行为,不要把它当作真正的错误来处理。务必在 .catch() 中区分 AbortError 和网络错误。

超时控制的正确实现

很多开发者还在用 setTimeout + controller.abort() 手动实现超时,但实际上 AbortSignal.timeout() 已经是标准 API,一行代码搞定:

// ❌ 错误写法:手动管理超时
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 5000)
try {
  const res = await fetch(url, { signal: controller.signal })
  clearTimeout(timer)
  return res
} catch (e) {
  clearTimeout(timer)
  throw e
}

// ✅ 正确写法:使用 AbortSignal.timeout()
async function fetchWithTimeout(url, options = {}, timeoutMs = 5000) {
  const signal = AbortSignal.timeout(timeoutMs)
  const response = await fetch(url, { ...options, signal })
  return response
}

💡 提示:AbortSignal.timeout() 在超时后会自动调用内部的 abort(),无需手动清理定时器。浏览器兼容性方面,Chrome 103+、Firefox 100+、Safari 16+ 均已支持。

多信号合并:同时支持手动取消和超时

在真实场景中,你往往需要同时支持多种取消条件——用户手动取消、超时取消、页面卸载取消。AbortSignal.any() 可以将多个信号合并为一个:

// 合并多个取消条件
async function robustFetch(url, options = {}) {
  // 用户手动取消的 controller
  const userController = new AbortController()

  // 超时信号:10 秒
  const timeoutSignal = AbortSignal.timeout(10000)

  // 页面卸载信号
  const unloadSignal = AbortSignal.abort() // 触发在页面 unload 时

  // 任意一个信号触发就取消
  const combinedSignal = AbortSignal.any([
    userController.signal,
    timeoutSignal
  ])

  // 提供取消方法供外部调用
  const promise = fetch(url, {
    ...options,
    signal: combinedSignal
  })

  return {
    promise,
    cancel: () => userController.abort('用户取消')
  }
}

下面是三种超时方案的对比:

方案 代码量 多条件支持 浏览器兼容性 推荐
setTimeout + abort() 8-10 行 需手动管理 全部 ❌ 过时
AbortSignal.timeout() 1 行 单条件 Chrome 103+ ✅ 一般场景
AbortSignal.any() 3-5 行 多条件合并 Chrome 116+ ✅ 复杂场景

🚀 二、重试策略与指数退避

网络请求失败是常态而非异常。在移动端场景下,网络抖动导致的请求失败率可达 5%-15%。一个好的重试策略可以显著提升用户体验,但盲目重试会加剧服务端压力甚至触发限流。

指数退避(Exponential Backoff)

指数退避的核心思想是:每次重试的等待时间翻倍。这给了服务端恢复的时间,也避免了大量客户端同时重试造成的「惊群效应」。

// 生产级重试函数:指数退避 + 抖动
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
  let lastError

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, {
        ...options,
        signal: options.signal || AbortSignal.timeout(10000)
      })

      // 5xx 错误才重试,4xx 不重试
      if (response.status >= 500) {
        throw new Error(`Server Error: ${response.status}`)
      }

      return response
    } catch (err) {
      lastError = err

      // 如果是用户主动取消,不重试
      if (err.name === 'AbortError') throw err

      // 最后一次尝试失败,直接抛出
      if (attempt === maxRetries) throw err

      // 指数退避 + 随机抖动
      // 基础延迟:1s, 2s, 4s(指数增长)
      // 抖动范围:±50%(防止惊群)
      const baseDelay = Math.pow(2, attempt) * 1000
      const jitter = baseDelay * (0.5 + Math.random())
      const delay = Math.min(jitter, 30000) // 最大等待 30 秒

      console.log(`第 ${attempt + 1} 次重试,等待 ${Math.round(delay)}ms...`)
      await new Promise(resolve => setTimeout(resolve, delay))
    }
  }

  throw lastError
}

📌 **记住:**重试策略的三个关键参数:最大重试次数(通常 2-4 次)、基础延迟(通常 1-2 秒)、抖动因子(通常 0.5-1.0)。盲目设置过多重试次数会导致用户体验严重下降。

哪些错误值得重试?

不是所有失败都应该重试。正确的重试策略需要区分错误类型:

错误类型 示例 是否重试 原因
网络断开 TypeError: Failed to fetch ✅ 重试 可能是暂时性网络抖动
服务端错误 HTTP 500, 502, 503 ✅ 重试 服务端可能暂时过载
请求超时 AbortError(超时触发) ✅ 重试 服务端响应过慢
客户端错误 HTTP 400, 401, 403, 404 ❌ 不重试 重试不会解决问题
用户取消 AbortError(手动触发) ❌ 不重试 用户意图
请求体过大 HTTP 413 ❌ 不重试 需要修改请求内容
限流 HTTP 429 ⚠️ 谨重重试 使用 Retry-After 头的值
// 智能重试:根据状态码和 Retry-After 头决策
async function smartRetry(url, options = {}, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options)

    if (response.ok) return response

    // 处理 429 限流:读取 Retry-After 头
    if (response.status === 429 && attempt < maxRetries) {
      const retryAfter = response.headers.get('Retry-After')
      const delay = retryAfter
        ? parseInt(retryAfter, 10) * 1000
        : Math.pow(2, attempt) * 1000
      await new Promise(r => setTimeout(r, delay))
      continue
    }

    // 5xx 可重试
    if (response.status >= 500 && attempt < maxRetries) {
      await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
      continue
    }

    // 其他错误直接返回
    return response
  }
}

📊 三、并发控制与请求队列

当你需要一次性发起大量请求时(比如批量上传文件、并发查询多个 API),不受控制的并发会带来两个严重问题:浏览器对同一域名的并发连接数限制(Chrome 限制为 6 个),以及服务端被瞬间流量打垮

Promise 并发限制器

一个通用的并发限制器(Concurrency Limiter)是每个前端项目的基础设施:

// 通用并发限制器
class ConcurrencyPool {
  constructor(limit = 6) {
    this.limit = limit
    this.running = 0
    this.queue = []
  }

  async add(asyncFn) {
    // 如果已达并发上限,等待
    if (this.running >= this.limit) {
      await new Promise(resolve => this.queue.push(resolve))
    }

    this.running++
    try {
      return await asyncFn()
    } finally {
      this.running--
      // 释放一个等待中的任务
      if (this.queue.length > 0) {
        const next = this.queue.shift()
        next()
      }
    }
  }
}

// 使用示例:并发请求 100 个 API,同时最多 5 个
const pool = new ConcurrencyPool(5)
const urls = Array.from({ length: 100 }, (_, i) => `/api/items/${i}`)

const results = await Promise.all(
  urls.map(url =>
    pool.add(() => fetch(url).then(r => r.json()))
  )
)

console.log(`成功获取 ${results.length} 条数据`)

⚡ **关键结论:**并发限制的最佳值取决于服务端承受能力和网络环境。对于 API 请求,建议限制在 3-6 个;对于文件上传,建议限制在 2-3 个。可以通过 navigator.connection.effectiveType 动态调整——4G 网络用 6 个,3G 网络用 2 个。

进度追踪:Promise.allSettled 与逐个结果

Promise.all() 有一个致命缺陷:任何一个请求失败,整个批次都会失败。在批量操作中,你更需要的是「尽可能多成功」的策略:

// 批量请求:容错 + 进度追踪
async function fetchAllWithProgress(urls, concurrency = 5) {
  const pool = new ConcurrencyPool(concurrency)
  const results = []
  let completed = 0

  const tasks = urls.map((url, index) =>
    pool.add(async () => {
      try {
        const response = await fetch(url)
        if (!response.ok) throw new Error(`HTTP ${response.status}`)
        const data = await response.json()
        results[index] = { status: 'fulfilled', data }
      } catch (err) {
        results[index] = { status: 'rejected', error: err.message }
      } finally {
        completed++
        // 上报进度(可用于进度条)
        const percent = Math.round((completed / urls.length) * 100)
        console.log(`进度: ${completed}/${urls.length} (${percent}%)`)
      }
    })
  )

  await Promise.all(tasks)

  const succeeded = results.filter(r => r.status === 'fulfilled').length
  const failed = results.filter(r => r.status === 'rejected').length
  console.log(`完成: ${succeeded} 成功, ${failed} 失败`)

  return results
}

取消与并发的结合

在搜索框等实时场景中,用户快速输入时会产生大量请求,你需要取消旧请求 + 并发控制的组合策略:

// 搜索框防抖 + 请求取消 + 并发控制
function createSearchClient() {
  let currentController = null

  return {
    async search(query) {
      // 取消上一次请求
      if (currentController) {
        currentController.abort('新请求取代旧请求')
      }

      currentController = new AbortController()

      try {
        const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
          signal: currentController.signal
        })
        return await response.json()
      } catch (err) {
        if (err.name === 'AbortError') return null // 被取消,忽略
        throw err
      }
    },

    cancel() {
      if (currentController) {
        currentController.abort()
        currentController = null
      }
    }
  }
}

// 配合防抖使用
const client = createSearchClient()
let debounceTimer

inputElement.addEventListener('input', (e) => {
  clearTimeout(debounceTimer)
  debounceTimer = setTimeout(() => {
    client.search(e.target.value)
      .then(results => {
        if (results) renderSearchResults(results)
      })
      .catch(err => showError(err.message))
  }, 300) // 300ms 防抖
})

💡 四、流式响应处理

fetch() 返回的 Response 对象支持 ReadableStream,这意味着你可以边接收边处理数据,而不用等整个响应下载完成。这在处理大文件下载、AI 流式输出(SSE)、NDJSON 数据流等场景中至关重要。

流式读取 AI 响应(SSE)

2026 年几乎所有 AI API(OpenAI、Claude、国产大模型)都支持流式输出。用 fetch() 处理 Server-Sent Events 的正确方式:

// 流式读取 AI API 响应
async function streamAIResponse(prompt, onChunk, onDone) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ prompt, stream: true })
  })

  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    buffer += decoder.decode(value, { stream: true })

    // 按行解析 SSE 格式
    const lines = buffer.split('\n')
    buffer = lines.pop() // 最后一个可能不完整,留到下次

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = line.slice(6).trim()
        if (data === '[DONE]') {
          onDone?.()
          return
        }
        try {
          const parsed = JSON.parse(data)
          const content = parsed.choices?.[0]?.delta?.content
          if (content) onChunk(content)
        } catch {
          // 忽略非 JSON 行
        }
      }
    }
  }
}

// 使用
streamAIResponse(
  '解释量子计算的基本原理',
  (chunk) => {
    // 每收到一小段文本就更新 UI
    document.getElementById('output').textContent += chunk
  },
  () => console.log('流式输出完成')
)

流式下载大文件(带进度)

传统方式需要等整个文件下载完才能使用,而流式下载可以实时显示进度并逐步写入:

// 流式下载文件:带进度回调
async function downloadWithProgress(url, onProgress) {
  const response = await fetch(url)
  const contentLength = response.headers.get('Content-Length')
  const total = contentLength ? parseInt(contentLength, 10) : 0

  const reader = response.body.getReader()
  const chunks = []
  let received = 0

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    chunks.push(value)
    received += value.length

    if (total > 0) {
      const percent = Math.round((received / total) * 100)
      onProgress?.(percent, received, total)
    }
  }

  // 合并所有 chunk 为一个 Blob
  const blob = new Blob(chunks)
  return blob
}

// 使用:下载文件并显示进度条
downloadWithProgress('/files/report.pdf', (percent, loaded, total) => {
  progressBar.style.width = `${percent}%`
  statusText.textContent = `${(loaded / 1024 / 1024).toFixed(1)}MB / ${(total / 1024 / 1024).toFixed(1)}MB`
}).then(blob => {
  // 创建下载链接
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'report.pdf'
  a.click()
  URL.revokeObjectURL(url)
})

用 TransformStream 实现 NDJSON 流式解析

NDJSON(Newline Delimited JSON)是大数据场景下常用的流式数据格式。用 TransformStream 可以优雅地实现流式解析管道:

// NDJSON 流式解析器
function createNDJSONParser() {
  let buffer = ''

  return new TransformStream({
    transform(chunk, controller) {
      buffer += chunk
      const lines = buffer.split('\n')
      buffer = lines.pop() // 最后一行可能不完整

      for (const line of lines) {
        const trimmed = line.trim()
        if (trimmed) {
          try {
            controller.enqueue(JSON.parse(trimmed))
          } catch (err) {
            console.warn('解析 NDJSON 行失败:', trimmed, err)
          }
        }
      }
    },

    flush(controller) {
      // 处理最后残留的数据
      if (buffer.trim()) {
        try {
          controller.enqueue(JSON.parse(buffer.trim()))
        } catch {
          // 忽略
        }
      }
    }
  })
}

// 使用:流式处理 NDJSON 数据
async function streamNDJSONData(url) {
  const response = await fetch(url)

  const parsedStream = response.body
    .pipeThrough(new TextDecoderStream())
    .pipeThrough(createNDJSONParser())

  const reader = parsedStream.getReader()

  let count = 0
  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    count++
    // value 已经是解析好的 JSON 对象
    processRecord(value, count)
  }

  console.log(`共处理 ${count} 条记录`)
}

💡 提示:TransformStream + pipeThrough() 组合是 Web Streams API 的精髓。你可以串联多个 TransformStream 来构建数据处理管道——解码 → 解析 → 过滤 → 转换,每一步都是流式的,内存占用极低。

⚙️ 五、生产级 fetch 封装最佳实践

把上面的所有能力组合起来,我们可以构建一个零依赖、生产可用的 HTTP 客户端:

// 零依赖生产级 HTTP 客户端
function createHttpClient(baseURL = '', defaultOptions = {}) {
  const pool = new ConcurrencyPool(defaultOptions.maxConcurrency || 6)

  async function request(url, options = {}) {
    const fullURL = new URL(url, baseURL).toString()

    // 合并默认 headers
    const headers = {
      'Content-Type': 'application/json',
      ...defaultOptions.headers,
      ...options.headers
    }

    // 合并 signal:支持用户传入的 signal
    const timeout = options.timeout ?? defaultOptions.timeout ?? 10000
    const signals = [AbortSignal.timeout(timeout)]
    if (options.signal) signals.push(options.signal)
    const signal = AbortSignal.any(signals)

    const maxRetries = options.retries ?? defaultOptions.retries ?? 3

    return pool.add(() =>
      fetchWithRetry(fullURL, { ...options, headers, signal }, maxRetries)
        .then(async response => {
          if (!response.ok) {
            const body = await response.text().catch(() => '')
            throw new Error(
              `HTTP ${response.status}: ${response.statusText} - ${body.slice(0, 200)}`
            )
          }
          const contentType = response.headers.get('Content-Type') || ''
          if (contentType.includes('application/json')) {
            return response.json()
          }
          return response.text()
        })
    )
  }

  return {
    get: (url, opts) => request(url, { ...opts, method: 'GET' }),
    post: (url, body, opts) => request(url, { ...opts, method: 'POST', body: JSON.stringify(body) }),
    put: (url, body, opts) => request(url, { ...opts, method: 'PUT', body: JSON.stringify(body) }),
    delete: (url, opts) => request(url, { ...opts, method: 'DELETE' }),
  }
}

// 使用示例
const api = createHttpClient('https://api.example.com', {
  timeout: 8000,
  retries: 2,
  maxConcurrency: 5,
  headers: { 'Authorization': 'Bearer token123' }
})

// GET 请求
const users = await api.get('/users')

// POST 请求
const newUser = await api.post('/users', { name: '张三', role: 'dev' })

下面是我们方案与 Axios 的对比:

特性 原生 fetch 封装(本文) Axios
包体积 0 KB ~13 KB (gzip)
请求取消 AbortController(原生) CancelToken(已废弃)
流式响应 ReadableStream(原生) 需额外配置
并发控制 自带实现 需第三方库
自动重试 自带实现 axios-retry
超时控制 AbortSignal.timeout() 内置 timeout 选项
浏览器兼容 Chrome 42+, Firefox 39+ 全部(含 IE)
Node.js 兼容 Node.js 18+(原生支持) 全部
推荐场景 ✅ 现代项目首选 ⚠️ 需兼容 IE 时使用

🎯 总结与建议

fetch() 在 2026 年已经是一个非常成熟的 HTTP 客户端 API。配合 AbortControllerReadableStreamPromise 并发控制,你完全可以在不引入 Axios 等第三方库的情况下,构建出生产级的网络请求方案。

关键结论:

  • AbortSignal.timeout() 替代手动 setTimeout + abort()——更简洁、更安全
  • AbortSignal.any() 合并多个取消条件——用户取消、超时、页面卸载三合一
  • 用指数退避 + 抖动实现重试——不要盲目重试,要区分错误类型
  • 用并发限制器控制批量请求——保护浏览器连接池和服务端
  • ReadableStream + TransformStream 处理流式数据——AI 流式输出、大文件下载、NDJSON 解析的利器
  • 不要在新项目中为了 fetch 封装引入 Axios——除非你需要兼容 IE

如果你正在使用 jsjson.com 的在线工具处理 JSON 数据,上述流式解析和并发控制的思路同样适用。无论是批量格式化 JSON 文件,还是处理大型 JSON 数据集的转换,ReadableStream 的流式思维都能帮你突破内存限制,处理远超单次加载大小的数据量。

📌 **记住:**最好的依赖是零依赖。在引入任何第三方 HTTP 库之前,先评估原生 fetch() 是否已经能满足你的需求——在 2026 年,答案大概率是「能」。

📚 相关文章