最近后台常收到类似问题——“项目里还在用XMLHttpRequest,是不是该换了?”“JS Fetch到底怎么学最快?”这类问题其实反映了前端技术迁移过程中的普遍困惑。坦白讲,在AJAX刚兴起的年代,XMLHttpRequest是被依赖的核心工具。但放在今天,它那套事件驱动的写法,就像是翻老黄历打电话——能打通,但效率低、容易出错。作为日常和一些前端新人打交道的人,我个人强烈建议:与其在旧工具里反复磨合,不如花几天时间系统掌握JS Fetch,用一套更现代、更简洁的方案接管你的网络请求。这篇文章我会按照“7天速成”的节奏,把Fetch从基础语法到真实项目里的封装思路、避坑技巧都过一遍。
这篇内容面向的是有一定HTML/CSS/JavaScript基础、但对网络请求还不熟悉的新手,也适合那些已经用XMLHttpRequest写了不少代码、想平滑切换到现代写法的开发者。我会尽量用直白的语言解释原理,并给出可直接运行的代码示例。你不需要提前了解新的框架,只要浏览器控制台能打开,就能跟着一步步验证。
1. 为什么我们要正式告别XMLHttpRequest
1.1 让新手痛苦的三个核心问题
理解为什么要换工具,比急着写代码更重要。XMLHttpRequest这套接口被人诟病已久,主要痛点集中在三块:回调嵌套、状态管理混乱、写法不直观。
回调嵌套是最明显的问题。比如第一个请求的结果要作为第二个请求的参数,用XMLHttpRequest写,你就得在第一个请求的onload回调里再发第二个请求,这种层层嵌套维护起来很痛苦。哪怕你用Promise去包裹它,也还是要自己处理像onreadystatechange这种底层事件,给每个状态码写判断逻辑。
再一个问题是状态管理。XMLHttpRequest里有readyState四个状态(0到4),加上status状态码(200、404、500等),新手往往分不清。换句话说,同样是“拿到东西”,你可能要同时判断四五个条件,才能确定“这次请求到底成功没有”。而Fetch把这件事大大简化了,我们等会儿会看到。
1.2 Fetch的设计逻辑:请求与数据通道
Fetch API设计的核心思路,是让你用一套统一的流程描述网络请求:发起请求、等待响应、处理数据。它返回一个Promise对象,原生支持async/await。整段代码看起来就像在按顺序读一个文档,而不是在处理一堆相互嵌套的“电话回铃”。
下面放一个最基础的对比。假设我们要向某个接口请求JSON数据:
javascript复制// 用 XMLHttpRequest 的旧写法
const xhr = new XMLHttpRequest();
xhr.open('GET', '/api/user');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(JSON.parse(xhr.responseText));
}
};
xhr.send();
// 用 Fetch 的新写法
fetch('/api/user')
.then(res => res.json())
.then(data => console.log(data));
不用我多说,你也能感受到两者在代码量上的差距。Fetch让你把关注点从“请求状态”转移到“数据本身”。当然,这不是说Fetch在所有方面都碾压旧方案,它在某些场景下也需要额外处理,比如请求取消和超时。但整体上,对于绝大多数前端业务,Fetch已经足够应对了。
注意:这里的“告别XMLHttpRequest”不是让你立刻把所有老代码全换掉,而是说新写的代码、新维护的项目,完全可以理直气壮地用Fetch。旧的代码只要稳定运行,不影响业务,不急着动它。
2. 第1-2天:建立起基础的请求能力
2.1 第一个GET请求:从URL到数据
这两天的任务只有一个:把GET请求玩熟。我们先从最简单的脚本开始。你可以直接在浏览器控制台里试,或者在本地新建一个HTML文件,用相对路径请求一个接口。
javascript复制// 最基本的GET请求
fetch('https://api.example.com/posts')
.then(function (response) {
return response.json();
})
.then(function (data) {
console.log(data);
})
.catch(function (error) {
console.error('请求失败:', error);
});
这里有几个新手容易忽略的细节。第一,fetch接受的第一个参数是资源地址,它会返回一个“响应对象”(Response对象),而不是直接返回解析好的数据。你要在then里调用response.json(),这会再返回一个Promise,拿到真正的数据。第二,fetch默认的请求方式是GET,所以这段代码不需要额外传配置参数。
2.2 消化返回值:Response对象里藏了什么
很多新手卡在这一步,因为他们不知道Response对象有哪些处理数据的方法。除了json(),还有text()、blob()、arrayBuffer()、formData()。我用一个表格来整理这几个方法,方便你对照:
| 方法 | 适用场景 | 返回内容 |
|---|---|---|
| response.json() | 接口返回JSON | 解析后的JavaScript对象 |
| response.text() | 返回纯文本 | 字符串 |
| response.blob() | 图片、文件下载 | 二进制大对象 |
| response.arrayBuffer() | 音频、视频等二进制流 | ArrayBuffer缓冲区 |
| response.formData() | 表单数据 | FormData对象 |
举个例子好了。如果接口返回的是一张图片,你用response.json()去解析就肯定会报错。正确做法是:
javascript复制fetch('https://api.example.com/image')
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
document.querySelector('img').src = url;
});
这段代码背后的逻辑是:浏览器拿到的是经过网络传输的原始字节流,Response对象提供了不同“视角”来读取它。我之前见过不少人用错了方法,结果在控制台看到“Unexpected token”这类报错,其实大概率就是json()和text()用混了。
2.3 用async/await理顺异步逻辑
第2天一定要学会async/await。因为Promise的.then链虽然比回调干净,但当逻辑变复杂时,多个.then来回穿插,读起来还是不够直观。async/await能让你用近乎同步代码的方式,写出异步请求。
javascript复制// 用 async/await 改写
async function loadPosts() {
try {
const response = await fetch('https://api.example.com/posts');
const data = await response.json();
console.log(data);
} catch (error) {
console.error('请求出错:', error);
}
}
loadPosts();
有人可能会问:到底什么时候用.then,什么时候用await?我的建议是,新手阶段直接用await,配合try/catch,逻辑最清晰。等你理解了异步的本质,再看业务代码里别人写的.then也不迟。有一点需要特别注意:await只能在async函数里使用,如果是在顶层运行,某些环境不支持顶层await,需要再包一层async函数。
3. 第3-4天:把核心配置学透,绕开最容易踩的坑
3.1 POST请求与JSON的配合
第3天开始,我们终于要上传数据了。Fetch发POST请求的写法,核心变化在于第二个参数——一个配置对象。这里就出现了一个新手最容易踩的坑:忘记设置Content-Type。很多人在后端接口收不到参数,折腾半天发现是请求头不对。
javascript复制// POST请求,发送JSON数据
async function createPost() {
const newPost = {
title: '我的第一篇文章',
content: '内容内容内容'
};
try {
const response = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(newPost)
});
if (!response.ok) {
throw new Error('创建失败,状态码:' + response.status);
}
const result = await response.json();
console.log('创建成功:', result);
} catch (error) {
console.error('出错了:', error);
}
}
createPost();
这里有几个关键点。body不会自动帮你转JSON,必须用JSON.stringify处理。headers里的Content-Type要写成application/json,告诉后端你发的是什么格式的数据。而如果你要传表单数据,代码就是另一种写法了,body直接用FormData对象,不需要手动设置Content-Type,浏览器会自动帮你带上multipart/form-data。
3.2 错误处理为什么必须“两条腿走路”
这可能是7天里价值最高的一节。很多新手理想化地认为,只要请求出问题就会自动进catch。现实是:fetch只会在网络层失败时(比如断网、域名解析失败、连接被拒绝)触发catch。如果服务器返回404、500、403这种状态码,fetch不会报错,Promise照样是成功完成的,response.ok会是false。
这句话请反复记三遍:fetch不会因为HTTP错误状态而reject。我们来写一个判断逻辑:
javascript复制async function getUser() {
const response = await fetch('https://api.example.com/user/123');
if (!response.ok) {
// 服务器返回了非2xx状态码
switch (response.status) {
case 404:
console.error('用户不存在');
break;
case 403:
console.error('没有权限访问');
break;
default:
console.error('请求失败,状态码:', response.status);
}
return null;
}
const user = await response.json();
return user;
}
这样处理后,你才能针对不同的HTTP状态码,给用户展示对应的错误提示。我见过不少项目里,前端把所有的请求错误都笼统地归为“网络异常”,结果用户明明是因为登录过期(返回401)被拒,却看到“网络异常”的提示,这体验就很差了。
3.3 请求头配置与Token注入
真实项目里,几乎每个请求都会带鉴权信息(比如Token)。如果每次发请求都要手动写一遍headers,那你的代码会膨胀得非常厉害。第4天的主题正是解决这个问题:把公共的请求头配置抽出来。
javascript复制// 统一管理请求头
function authHeaders(extraHeaders = {}) {
const token = localStorage.getItem('token') || '';
return {
'Authorization': token,
'Content-Type': 'application/json',
...extraHeaders
};
}
async function getOrders() {
const response = await fetch('https://api.example.com/orders', {
headers: authHeaders()
});
const data = await response.json();
return data;
}
注意这里的展开运算符...extraHeaders,目的是允许某个具体请求覆盖默认请求头。这个小小的设计,能让你后续扩展的时候不用把所有代码抄一遍。
4. 第5-6天:进阶实战——用Fetch处理复杂业务场景
4.1 封装一个可复用的请求模块
学到第5天,你应该已经不满足于每个页面单独写一遍fetch了。在公司项目里,通常会把fetch封装成一个公共的请求方法,统一处理基地址、超时、错误提示、状态码判断。我提供一个经过简化的方案,你可以按这个思路去扩展:
javascript复制// request.js
const BASE_URL = 'https://api.example.com';
async function request(url, options = {}) {
// 组合完整地址
const fullUrl = url.startsWith('http') ? url : BASE_URL + url;
// 默认配置与用户配置合并
const config = {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
},
...options
};
// 如果用户传了body,自动转JSON
if (config.body && typeof config.body === 'object') {
config.body = JSON.stringify(config.body);
}
try {
const response = await fetch(fullUrl, config);
if (!response.ok) {
// 统一处理业务错误
const errorInfo = await response.json().catch(() => null);
throw new Error(errorInfo?.message || '请求失败');
}
// 判断有没有返回体内容
const text = await response.text();
return text ? JSON.parse(text) : null;
} catch (error) {
console.error('请求异常:', error);
throw error;
}
}
// 导出使用
export default request;
我在这个封装里做了几件关键的事:第一,把BaseURL集中管理,以后如果切换环境,只需改一处;第二,把JSON序列化这个步骤丢给封装处理,调用方直接传对象,省得每个人都不会忘记JSON.stringify;第三,统一把HTTP错误转换成业务异常抛出,调用方只需要catch一次。
有个细节值得注意:我先把res.text()读了一遍,再JSON.parse。这是因为接口有时会返回空内容,直接res.json()会因为空字符串解析失败而报错。先转text再判断,就能规避这个情况。
4.2 拦截器思路:在请求前后统一做事情
用过主流请求库的朋友肯定对拦截器很熟。简单说,就是在请求发出去之前,以及响应返回之后,插入一段统一逻辑。比如请求前自动加Token,响应后如果发现401就跳转登录页。用fetch实现一个轻量拦截器,思路是给request方法传入两个钩子函数:
javascript复制// 增强版拦截器封装
function createFetchInterceptor({
beforeRequest = (config) => config,
afterResponse = (response) => response
} = {}) {
return async function (url, options = {}) {
// 请求前拦截
const finalConfig = beforeRequest({ url, options });
// 发送请求
const response = await fetch(finalConfig.url, finalConfig.options);
// 响应后拦截
return afterResponse(response);
};
}
const fetchWithInterceptor = createFetchInterceptor({
beforeRequest({ url, options }) {
const token = localStorage.getItem('token');
return {
url,
options: {
...options,
headers: {
...(options.headers || {}),
'Authorization': token ? `Bearer ${token}` : ''
}
}
};
},
afterResponse(response) {
if (response.status === 401) {
// 跳转登录页
window.location.href = '/login';
}
return response;
}
});
这里的核心原理是“函数注入”:我们把“请求前要做什么”和“响应后要做什么”作为回调函数传入。这样真实的业务代码不用关心Token和401这些通用逻辑,只管调用。
4.3 并发请求与性能优化
在实际页面里,经常需要同时请求多个接口,比如页面的头部信息、列表数据、详情信息互不依赖。如果一个个等,加载时间会累加;如果同时发出去,总耗时只取决于最慢的那个接口。fetch原生支持这种并发模型,直接借助Promise.all。
javascript复制async function loadPageData() {
try {
const [userInfo, listData, bannerData] = await Promise.all([
fetch('/api/user').then(res => res.json()),
fetch('/api/list').then(res => res.json()),
fetch('/api/banner').then(res => res.json())
]);
console.log('用户信息:', userInfo);
console.log('列表数据:', listData);
console.log('轮播图:', bannerData);
} catch (error) {
console.error('并发请求失败:', error);
}
}
Promise.all的语义是“全部成功才算成功,任何一个失败就走catch”。如果你的业务是所有接口缺一不可,那用它正合适。但如果你希望个别接口失败不影响整体页面,可以考虑Promise.allSettled,它会等你所有请求都结束,再告诉你各自的结果。
4.4 上传下载与进度显示
第6天做个小挑战:文件上传和进度显示。用XHR做上传进度,你需要监听xhr.upload.onprogress事件。用fetch呢?好消息是fetch也支持上传文件,坏消息是fetch标准没有直接提供上传进度事件。不过我们可以借助XMLHttpRequest来做上传进度的功能,或者用fetch加ReadableStream来做下载进度。
我在这里演示一个基于fetch响应体的下载进度方案。因为Response.body是一个ReadableStream,你可以通过读取流的进度来计算下载百分比:
javascript复制async function downloadWithProgress(url) {
const response = await fetch(url);
if (!response.ok) throw new Error('下载失败');
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
let loaded = 0;
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
if (total) {
const percent = Math.round((loaded / total) * 100);
console.log('下载进度:', percent + '%');
}
}
// 合并所有二进制块
const blob = new Blob(chunks);
return blob;
}
这个写法在生产环境有实际价值,比如你想在页面上展示一个真实的进度条,而不是让用户干等。至于上传进度,目前主流项目里还是绕不开XHR的upload.onprogress事件。我的建议是:不要为了用fetch而去掉进度条,上传场景你可以继续用XHR,或者封装两者组合,让业务代码看不出差异。
5. 第7天:常见问题排查与避坑清单
5.1 高频问题速查
7天学完,你大概率会遇到下面这些情况。我把它们整理成一个表格,方便你排查:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 请求直接catch,网络没有请求发出 | 跨域被拦、地址拼错、URL协议错误 | 打开控制台Network面板确认状态,检查代理配置 |
| response.json()报错 | 返回内容不是合法JSON,比如是纯文本 | 先改为response.text()看内容 |
| 后端收不到POST参数 | 没有设置Content-Type为application/json | headers里加上该字段,确保body已JSON.stringify |
| 页面刷新后Token丢失 | 用了sessionStorage | 改为localStorage或cookie |
| 请求返回401/403,但没任何提示 | 没有判断response.ok | 按5.1.2节的方式补充状态判断 |
| 接口总是返回缓存数据 | 浏览器默认强缓存可能命中 | 添加Cache-Control头部或用POST请求绕过 |
5.2 兼容性与降级方案
虽然Fetch已经是很成熟的标准了,但极少数老旧的浏览器环境(比如若干年前的旧版本系统内置浏览器)可能不支持。这个问题在企业内部系统里偶尔会出现。一个简单实用的做法是:在入口文件里判断fetch是否存在,如果不存在,就动态引入一个基于XHR的polyfill。
javascript复制// 简易环境检测
if (!window.fetch) {
console.warn('当前环境不支持fetch,加载备用请求模块');
// 动态引入polyfill文件
const script = document.createElement('script');
script.src = '/polyfills/fetch-polyfill.min.js';
document.head.appendChild(script);
}
考虑到现在大部分用户都在更新较新的浏览器环境中,这个兼容问题不必过度担心,但作为前端开发者,你要知道怎么兜底。
5.3 缓存控制与性能细节
关于缓存,我分享一个亲测的小技巧。如果你希望GET请求结果不被浏览器缓存,可以在请求头里加Cache-Control: no-cache。但注意,Fetch里的缓存策略经常受到服务端配置影响。更稳妥的方案是在URL后面拼接一个时间戳或随机数,像这样:
javascript复制const timestamp = Date.now();
const response = await fetch(`/api/posts?ts=${timestamp}`);
但这种做法理论上不利于缓存命中。如果你的接口允许缓存,就不能乱加时间戳,否则你每次请求都绕开缓存,服务器压力上去了,页面反而变慢。合理的做法是让后端设置好Cache-Control的max-age,前端只需要用默认行为就好。
6. 从学习到上手的最后一步
写到这里,7天的路径基本就走完了:从第1天认识fetch的基本结构,到第4天掌握请求头、错误处理,再到第6天做并发、上传下载。在这个过程中你可能也已经发现了,fetch不是那种“看一眼就会”的API,它需要你结合异步编程、接口语义、生产环境需求去反复练习。
我个人在实际操作中的体会是:如果你能自己动手封装一个包含拦截器、统一错误处理、并发管理的请求模块,那说明你对fetch的Understanding已经超过多数人。过一段时间再回头看,真正让你成长的不只是学会一个函数怎么调用,而是你理解了浏览器网络请求这条链路上,哪些环节容易出问题、应该在哪里兜底、怎么设计才方便维护。
最后再分享一个日常习惯:建议在调试fetch请求时,不要只盯着控制台看结果,把浏览器的Network面板打开,观察请求头、请求体、响应体,以及耗时。看得多了,你自然对HTTP请求有了手感。技术迭代很快,但“看清数据怎么流动”这个能力,任何时候都不过时。
