HTTP 请求

返回 JavaScript 基础

前端与后端通信的核心手段。浏览器原生提供 Fetch API;生产项目通常使用 Axios 封装,获得拦截器、超时、取消等能力。


Fetch API

浏览器原生,基于 Promise,无需安装。

// GET
const res = await fetch('/api/users')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
 
// POST(JSON)
const res = await fetch('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Alice' })
})
const created = await res.json()

携带 Cookie(跨域)

fetch('https://api.example.com/user', {
  credentials: 'include'   // same-origin(默认)/ include / omit
})

后端需同时设置 Access-Control-Allow-Credentials: trueAllow-Origin 不能为 *。详见 跨域处理

超时控制

fetch 本身无超时参数,用 AbortController 实现:

const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 5000)   // 5s 超时
 
try {
  const res = await fetch('/api/data', { signal: controller.signal })
  const data = await res.json()
} catch (e) {
  if (e.name === 'AbortError') console.error('请求超时')
} finally {
  clearTimeout(timer)
}

取消请求

const controller = new AbortController()
 
// 发起请求
fetch('/api/search?q=foo', { signal: controller.signal })
 
// 任意时刻取消
controller.abort()

Axios

npm install axios

基本使用

import axios from 'axios'
 
// GET
const { data } = await axios.get('/api/users', {
  params: { page: 1, size: 20 }   // 自动拼接查询字符串
})
 
// POST(自动序列化为 JSON)
const { data: created } = await axios.post('/api/users', { name: 'Alice' })
 
// PUT / PATCH / DELETE
await axios.put('/api/users/1', { name: 'Bob' })
await axios.patch('/api/users/1', { name: 'Bob' })
await axios.delete('/api/users/1')

创建实例(推荐)

将基础配置收拢在一个实例,避免全局污染:

// src/utils/request.js
import axios from 'axios'
 
const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' }
})
 
export default request

拦截器

请求拦截:自动注入 Token

request.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

响应拦截:统一解包与错误处理

后端通常返回统一信封格式:

{ "code": 0, "msg": "success", "data": { ... } }

在拦截器中统一解包,业务代码直接拿到 data

request.interceptors.response.use(
  (response) => {
    const { code, msg, data } = response.data
    if (code === 0) return data          // 直接返回业务数据
    // 业务错误(如 code === 401 token 过期)
    if (code === 401) {
      localStorage.removeItem('token')
      window.location.href = '/login'
      return Promise.reject(new Error(msg))
    }
    return Promise.reject(new Error(msg || '请求失败'))
  },
  (error) => {
    // HTTP 层错误(4xx / 5xx / 网络断开)
    const status = error.response?.status
    const msgMap = {
      400: '请求参数错误',
      401: '未登录或登录已过期',
      403: '无权限',
      404: '资源不存在',
      500: '服务器错误,请稍后重试',
    }
    const msg = msgMap[status] || error.message || '网络异常'
    console.error(`[request] ${msg}`)
    return Promise.reject(new Error(msg))
  }
)

Token 认证

JWT Bearer Token

// 登录获取 token
const { token } = await request.post('/auth/login', { username, password })
localStorage.setItem('token', token)
 
// 后续请求自动携带(见请求拦截器)
// Authorization: Bearer eyJhbGci...

Token 刷新(无感续期)

let isRefreshing = false
let waitQueue = []   // 刷新期间挂起的请求
 
request.interceptors.response.use(
  (res) => res,
  async (error) => {
    const original = error.config
    if (error.response?.status !== 401 || original._retry) {
      return Promise.reject(error)
    }
    original._retry = true
 
    if (isRefreshing) {
      // 排队等待新 token
      return new Promise((resolve) => {
        waitQueue.push((token) => {
          original.headers.Authorization = `Bearer ${token}`
          resolve(request(original))
        })
      })
    }
 
    isRefreshing = true
    try {
      const refreshToken = localStorage.getItem('refreshToken')
      const { token } = await axios.post('/auth/refresh', { refreshToken })
      localStorage.setItem('token', token)
      waitQueue.forEach((cb) => cb(token))
      waitQueue = []
      original.headers.Authorization = `Bearer ${token}`
      return request(original)
    } catch {
      localStorage.clear()
      window.location.href = '/login'
      return Promise.reject(error)
    } finally {
      isRefreshing = false
    }
  }
)

Session 模式下凭证在 Cookie 中,无需手动处理 Token,但跨域时需额外配置:

const request = axios.create({
  baseURL: '/api',
  withCredentials: true   // 自动携带 Cookie(跨域时生效)
})

后端需要:

// Spring Boot
config.setAllowCredentials(true);
config.setAllowedOrigins(List.of("http://localhost:5173"));  // 不能用 *

文件上传

// 表单选择文件后上传
async function upload(file) {
  const form = new FormData()
  form.append('file', file)
  form.append('type', 'avatar')
 
  const data = await request.post('/api/upload', form, {
    headers: { 'Content-Type': 'multipart/form-data' },  // axios 会自动设置 boundary
    onUploadProgress: (e) => {
      const percent = Math.round((e.loaded / e.total) * 100)
      console.log(`上传进度:${percent}%`)
    }
  })
  return data.url
}

文件下载

async function download(fileId, filename) {
  const res = await request.get(`/api/files/${fileId}`, {
    responseType: 'blob'
  })
  const url = URL.createObjectURL(res)
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  a.click()
  URL.revokeObjectURL(url)
}

取消请求

// Axios 使用 AbortController(Axios 1.x+ 推荐)
const controller = new AbortController()
 
request.get('/api/search', {
  params: { q: keyword },
  signal: controller.signal
})
 
// 取消(如组件卸载、搜索词变化时)
controller.abort()

Vue 中组件卸载自动取消:

import { onUnmounted } from 'vue'
 
const controller = new AbortController()
onUnmounted(() => controller.abort())
 
request.get('/api/data', { signal: controller.signal })

并发请求

// 同时发起多个请求,全部完成后处理
const [users, roles] = await Promise.all([
  request.get('/api/users'),
  request.get('/api/roles')
])
 
// 任一失败不影响其他(如仪表盘多卡片加载)
const results = await Promise.allSettled([
  request.get('/api/stats'),
  request.get('/api/recent'),
  request.get('/api/alerts')
])
results.forEach((r) => {
  if (r.status === 'fulfilled') console.log(r.value)
  else console.error(r.reason)
})

Vue Composable 封装

将请求逻辑封装为可复用的 useRequest

// src/composables/useRequest.js
import { ref, shallowRef } from 'vue'
 
export function useRequest(apiFn) {
  const data = shallowRef(null)
  const loading = ref(false)
  const error = ref(null)
 
  async function execute(...args) {
    loading.value = true
    error.value = null
    try {
      data.value = await apiFn(...args)
    } catch (e) {
      error.value = e
    } finally {
      loading.value = false
    }
  }
 
  return { data, loading, error, execute }
}

在组件中使用:

<script setup>
import { onMounted } from 'vue'
import { useRequest } from '@/composables/useRequest'
import { getUserList } from '@/api/user'
 
const { data: users, loading, error, execute } = useRequest(getUserList)
onMounted(() => execute({ page: 1, size: 20 }))
</script>
 
<template>
  <div v-if="loading">加载中...</div>
  <div v-else-if="error">{{ error.message }}</div>
  <ul v-else>
    <li v-for="u in users" :key="u.id">{{ u.name }}</li>
  </ul>
</template>

API 模块化(推荐目录结构)

src/
├── api/
│   ├── index.js        # 统一导出
│   ├── user.js         # 用户相关接口
│   ├── article.js      # 文章相关接口
│   └── upload.js       # 上传相关
└── utils/
    └── request.js      # axios 实例 + 拦截器
// src/api/user.js
import request from '@/utils/request'
 
export const getUserList = (params) => request.get('/user/list', { params })
export const getUserById = (id) => request.get(`/user/${id}`)
export const createUser = (data) => request.post('/user', data)
export const updateUser = (id, data) => request.put(`/user/${id}`, data)
export const deleteUser = (id) => request.delete(`/user/${id}`)

TypeScript 类型支持

// src/types/api.ts
export interface ApiResponse<T = unknown> {
  code: number
  msg: string
  data: T
}
 
export interface PageResult<T> {
  list: T[]
  total: number
  page: number
  size: number
}
 
// src/types/user.ts
export interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
  createdAt: string
}
// src/api/user.ts
import request from '@/utils/request'
import type { User, PageResult } from '@/types'
 
export const getUserList = (params: { page: number; size: number }) =>
  request.get<PageResult<User>>('/user/list', { params })
 
export const getUserById = (id: number) =>
  request.get<User>(`/user/${id}`)

开发环境代理(Vite)

本地开发时避免跨域,通过 Vite 代理转发到后端:

// vite.config.ts
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        // rewrite: (path) => path.replace(/^\/api/, '')  // 如后端无 /api 前缀
      }
    }
  }
})

配置环境变量区分环境:

# .env.development
VITE_API_BASE_URL=              # 留空,走 Vite proxy
 
# .env.production
VITE_API_BASE_URL=https://api.example.com
// src/utils/request.js
const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api'
})

代理仅在开发服务器运行期间有效;生产环境通过 Nginx 反代或正确配置 CORS 解决。


请求重试

async function requestWithRetry(fn, retries = 3, delay = 1000) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fn()
    } catch (e) {
      if (i === retries - 1) throw e
      await new Promise((r) => setTimeout(r, delay * (i + 1)))  // 指数退避
    }
  }
}
 
// 使用
const data = await requestWithRetry(() => request.get('/api/unstable'))

相关链接

  • 异步编程 — Promise / async/await 基础
  • 跨域处理 — 后端 CORS 配置(Spring Boot)
  • Vue — Composable 与状态管理中的请求集成
  • DOM与BOM — Fetch API、AbortController 所在的 Web API 层