嘿,朋友。如果你正在写前端代码,那“发请求”肯定是你每天干得最多的事。以前我们靠 XMLHttpRequest (XHR) 摸爬滚打,后来有了 jQuery 简化了这一切,现在呢?fetch 成了原生标配,axios 成了第三方库里的霸主。

但这里有个坑:很多人以为换了工具就万事大吉,结果线上 Bug 不断,或者代码难维护得像一团乱麻。 今天咱们不聊那些枯燥的定义,我就用这几年的实战经验,带你把 AJAX 的请求方式扒开揉碎了看,顺便把那些让人头大的性能和错误处理问题也一并解决掉。

先聊聊:为什么我们还在纠结“请求方法”?

你可能会问:“不就是 axios.get 吗?有什么好讲的?”

问题在于,场景变了

几年前,我们主要做的是后端渲染为主的页面,请求简单,错误了刷新页面就行。但现在,无论是 Vue、React 还是 Svelte,前端应用越来越复杂,接口调用频繁、并发多、取消请求、拦截器、加载状态管理……这些需求让一个简单的“发请求”变成了系统工程。

选错了工具,或者用错了姿势,轻则用户体验卡顿,重则内存泄漏、页面崩溃。所以,搞清楚 fetchaxios 的本质区别,以及在不同场景下如何优雅地封装,才是正经事。


一、原生派:fetch API 详解

fetch 是浏览器原生的 API,基于 Promise。它的出现是为了替代那个古老又丑陋的 XMLHttpRequest

1. 基本用法

fetch('/api/user/profile')
  .then(response => {
    if (!response.ok) {
      throw new Error('Network response was not ok');
    }
    return response.json();
  })
  .then(data => {
    console.log('User:', data);
  })
  .catch(error => {
    console.error('Fetch error:', error);
  });

注意这个细节: fetch 只有当网络故障(如 DNS 解析失败、断网)时才会 reject。如果服务器返回 404 或 500,fetch 依然会 resolve,并且你需要手动检查 response.ok 或者 response.status。这一点经常让新手踩坑。

2. 发送 POST 请求

fetch('/api/user', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    // 如果你有 token,记得加在这里
    'Authorization': 'Bearer ' + token
  },
  body: JSON.stringify({
    name: '张三',
    age: 25
  })
})
.then(res => res.json())
.then(data => console.log(data))
.catch(err => console.error(err));

3. 取消请求:AbortController

这是 fetch 相比旧 XHR 的一大进步。以前想取消请求?没门。现在可以用 AbortController

const controller = new AbortController();
const signal = controller.signal;

const request = fetch('/api/long-data', { signal })
  .then(res => res.json())
  .then(data => console.log(data));

// 比如用户点击了“取消”按钮,或者组件卸载了
button.onclick = () => {
  controller.abort();
};

request.catch(err => {
  if (err.name === 'AbortError') {
    console.log('请求已取消');
  }
});

为什么这很重要? 想象一下,用户在搜索框输入内容,每次按键都发请求。如果他输入“javascript”,我们会发出 10 多个请求。如果不取消之前的请求,最后一个响应的数据可能覆盖前面的,导致显示错乱,还浪费带宽。


二、第三方派:axios 的优势在哪?

axios 不是浏览器原生的,它需要引入。但它之所以能火,是因为它解决了很多 fetch 的痛点。

1. 自动 JSON 转换

fetch 时,你得手动 .json()axios 会自动把响应体解析成 JSON。

axios.get('/api/user')
  .then(response => {
    console.log(response.data); // 直接拿到对象,不用 .json()
  });

2. 请求/响应拦截器

这是 axios 最强大的功能之一。你可以在请求发出前或响应回来后,统一处理一些逻辑,比如加 Token、统一错误处理、显示 Loading 等。

const instance = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000
});

// 请求拦截器
instance.interceptors.request.use(
  config => {
    const token = localStorage.getItem('token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截器
instance.interceptors.response.use(
  response => {
    // 统一处理业务状态码
    if (response.data.code !== 200) {
      return Promise.reject(new Error(response.data.message));
    }
    return response.data;
  },
  error => {
    if (error.response) {
      switch (error.response.status) {
        case 401:
          // 跳登录页
          window.location.href = '/login';
          break;
        case 403:
          console.log('无权限');
          break;
        default:
          console.log('其他错误');
      }
    }
    return Promise.reject(error);
  }
);

你看,有了拦截器,你的每个业务代码里就不需要重复写这些逻辑了。

3. 更好的错误处理

axios 会抛出 HTTP 状态码相关的错误,比如 404、500,它会直接 reject,不需要你手动判断 response.ok


三、fetch vs axios:核心对比

特性 fetch axios
原生支持 ✅ 浏览器内置 ❌ 需引入
自动 JSON 解析 ❌ 需手动 .json() ✅ 自动
请求拦截 ❌ 无原生支持 ✅ 强大拦截器
响应拦截 ❌ 无原生支持 ✅ 强大拦截器
取消请求 AbortController CancelToken (旧) / AbortController (新)
浏览器兼容性 现代浏览器 需要 Polyfill (IE 不支持)
文件大小 0KB ~13KB (gzipped)
类型支持 需手动定义 原生 TypeScript 支持好

我的建议:

  • 如果你在做轻量级项目,或者不想引入额外依赖,用 fetch
  • 如果你在做中大型应用,需要统一拦截、复杂错误处理、Token 管理,用 axios
  • 现代前端工程化(Vue/React 项目),几乎都用 axios,因为它的封装能力太强了。

四、实战:如何封装一个健壮的请求模块

不管用 fetch 还是 axios直接在组件里写请求代码是大忌。代码会重复,错误处理会混乱。我们需要封装。

下面是一个基于 axios 的完整封装示例,你可以直接拿去用。

1. 创建 axios 实例

// utils/request.js
import axios from 'axios';

const request = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // Vite 环境变量
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
});

// 请求拦截器
request.interceptors.request.use(
  config => {
    // 添加 Token
    const token = localStorage.getItem('auth_token');
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    
    // 添加请求时间戳,用于去重或日志
    config.metadata = { startTime: Date.now() };
    
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);

// 响应拦截器
request.interceptors.response.use(
  response => {
    const { data } = response;
    
    // 假设后端统一返回 { code: 200, data: ..., msg: 'success' }
    if (data.code === 200) {
      return data;
    }
    
    // 业务错误
    throw new Error(data.msg || '请求失败');
  },
  error => {
    const { response } = error;
    
    if (response) {
      switch (response.status) {
        case 401:
          // 未授权,跳转登录
          localStorage.removeItem('auth_token');
          window.location.href = '/login';
          break;
        case 403:
          alert('您没有权限访问此资源');
          break;
        case 404:
          console.error('接口不存在');
          break;
        case 500:
          console.error('服务器错误');
          break;
        default:
          console.error('网络错误', response.status);
      }
    } else if (error.code === 'ERR_CANCELED') {
      console.log('请求已取消');
    } else {
      console.error('未知错误', error.message);
    }
    
    return Promise.reject(error);
  }
);

export default request;

2. 封装 API 模块

// apis/user.js
import request from '@/utils/request';

export function getUserProfile() {
  return request.get('/user/profile');
}

export function updateUserProfile(data) {
  return request.put('/user/profile', data);
}

export function searchUsers(keyword) {
  return request.get('/user/search', { params: { keyword } });
}

3. 在组件中使用

<!-- Vue 示例 -->
<script setup>
import { ref, onMounted } from 'vue';
import { getUserProfile, searchUsers } from '@/apis/user';

const user = ref(null);
const searchResult = ref([]);

onMounted(async () => {
  try {
    const res = await getUserProfile();
    user.value = res.data;
  } catch (error) {
    // 拦截器已经处理了大部分错误,这里可以补充组件级的错误提示
    console.log('获取用户信息失败');
  }
});

async function handleSearch(keyword) {
  const res = await searchUsers(keyword);
  searchResult.value = res.data.list;
}
</script>

你看,组件里干净得像什么都没发生过。 这就是封装的价值。


五、常见问题与“坑”

1. 跨域问题(CORS)

前端开发最头疼的问题之一。记住:CORS 是后端配置的问题,前端只能想办法规避或提醒后端。

  • 开发环境:使用 Vite/Webpack 的代理配置。
// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: path => path.replace(/^\/api/, '')
      }
    }
  }
};
  • 生产环境:联系后端配置 Nginx 反向代理,或在服务器端设置 Access-Control-Allow-Origin 头。

2. 重复请求

用户快速点击按钮,或者搜索框快速输入,会导致多个请求并发。如果顺序乱了,数据显示就会错。

解决方案:请求去重

// 简单的请求缓存/去重示例
const pendingRequests = new Map();

request.interceptors.request.use(config => {
  const key = config.url + JSON.stringify(config.params);
  
  // 如果已有相同请求,取消它
  if (pendingRequests.has(key)) {
    const controller = pendingRequests.get(key);
    controller.abort();
    pendingRequests.delete(key);
  }
  
  // 创建新的 AbortController
  const controller = new AbortController();
  config.signal = controller.signal;
  pendingRequests.set(key, controller);
  
  return config;
});

request.interceptors.response.use(
  response => {
    const key = response.config.url + JSON.stringify(response.config.params);
    pendingRequests.delete(key);
    return response;
  },
  error => {
    const key = error.config?.url + JSON.stringify(error.config?.params);
    if (key && pendingRequests.has(key)) {
      pendingRequests.delete(key);
    }
    return Promise.reject(error);
  }
);

3. 文件上传

fetchaxios 都支持 FormData,但要注意 Content-Type

const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('title', '我的图片');

axios.post('/upload', formData, {
  headers: {
    'Content-Type': 'multipart/form-data'
    // 不要手动设置 Content-Type,浏览器会自动加上 boundary
  },
  onUploadProgress: progressEvent => {
    const percent = Math.round(
      (progressEvent.loaded * 100) / progressEvent.total
    );
    console.log(`上传进度: ${percent}%`);
  }
});

注意: axiosonUploadProgress 是原生支持的,fetch 没有这个功能,你需要自己实现或者用第三方库。


六、性能优化技巧

请求发得快,不代表用户体验好。以下是几个关键的优化点:

1. 请求合并(Debouncing)

搜索框是最典型的场景。用户输入“javascript”,会触发 10 次请求。我们用防抖(Debounce)来减少请求次数。

import { debounce } from 'lodash-es';

const searchUsers = debounce(async (keyword) => {
  const res = await getUser(keyword);
  result.value = res.data;
}, 300); // 300ms 内只发最后一次请求

2. 预加载(Prefetching)

在用户可能需要的地方,提前发出请求。比如,鼠标悬停在链接上时,预加载目标页面的数据。

// 简单预加载示例
const prefetchLink = document.querySelector('#preview-link');

prefetchLink.addEventListener('mouseover', () => {
  fetch('/api/preload-data').then(res => res.json()).then(data => {
    window.__preloadData = data;
  });
});

3. 请求取消(Cancellation)

如前所述,使用 AbortController 取消不再需要的请求。这对于单页应用(SPA)的路由跳转非常有用。

// 在 Vue 的 onUnmounted 中取消请求
import { onUnmounted } from 'vue';

let controller;

function fetchData() {
  controller = new AbortController();
  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => {
      // 处理数据
    });
}

onUnmounted(() => {
  if (controller) {
    controller.abort();
  }
});

4. 缓存策略

对于不常变化的数据,使用缓存。可以简单地用内存缓存,也可以结合浏览器缓存(cache 属性)。

// 使用浏览器缓存
fetch('/api/static-data', {
  cache: 'force-cache' // 优先使用缓存
});

// 或者自己实现内存缓存
const cache = new Map();

async function cachedFetch(url) {
  if (cache.has(url)) {
    const { data, timestamp } = cache.get(url);
    if (Date.now() - timestamp < 5 * 60 * 1000) { // 5分钟缓存
      return data;
    }
  }
  
  const res = await fetch(url);
  const data = await res.json();
  cache.set(url, { data, timestamp: Date.now() });
  
  return data;
}

5. 并行与串行

根据业务逻辑选择 Promise.all 还是串行请求。

// 并行:多个独立请求同时发出
const [user, orders, settings] = await Promise.all([
  fetch('/api/user').then(res => res.json()),
  fetch('/api/orders').then(res => res.json()),
  fetch('/api/settings').then(res => res.json())
]);

// 串行:依赖前一个请求的结果
const user = await fetch('/api/user').then(res => res.json());
const profile = await fetch(`/api/profile/${user.id}`).then(res => res.json());

注意: 浏览器对同一域名的并发请求数量有限制(通常是 6 个)。太多并发请求会导致排队,反而降低性能。


七、给小朋友也能听懂的比喻

如果上面的技术术语让你头晕,咱们换个说法。

想象你在餐厅吃饭:

  • fetch 就像是你自己站起来,走到服务员那里,说:“我要点菜。” 然后你等,等服务员回来,你自己检查菜对不对。如果服务员没回来(网络超时),你得自己判断怎么办。如果服务员说“做不了”(404),你得自己处理。

  • axios 就像是有个专属管家。你告诉管家:“帮我点菜,要加辣,还要记得带上我的会员卡。” 管家会统一处理所有细节:加 Token(会员卡)、处理错误(如果菜做不了,管家会告诉你原因)、自动把菜端到你面前(自动解析 JSON)。你只需要坐着吃就行。

  • 拦截器 就像管家的工作流程。每个请求出去前,管家都会检查一遍(加 Token);每个请求回来时,管家