你写的网页迟早要和服务器打交道:登录、发评论、拉取商品列表……而这一切都绕不开 HTTP 请求。本文带你从老牌的 XMLHttpRequest 一路走到现代的 fetch,亲手写出带错误处理、超时控制、还能取消的请求代码,学完你就能独立对接任意后端接口,再也不怕"为什么页面没数据"的灵魂拷问。
1. 为什么是 fetch
在 fetch 出现之前,浏览器发起请求的标准姿势是 XMLHttpRequest(简称 XHR)。它功能不弱,但 API 设计非常啰嗦:要手动监听事件、判断状态码、解析文本,代码稍微一多,回调嵌套就把人绕晕了。看一段 XHR 的典型写法:
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.github.com/users/octocat');
xhr.onload = function () {
if (xhr.status >= 200 && xhr.status < 300) {
console.log(JSON.parse(xhr.responseText).login);
}
};
xhr.onerror = function () {
console.error('网络错误');
};
xhr.send();
注意这段代码里:成功和失败分属两个回调,而且必须自己用 JSON.parse 解析文本。fetch 的出现就是为了解决这些痛点——它基于 Promise,返回值统一、错误处理统一,代码结构清晰得多,如今已是浏览器环境下的事实标准。
你可能还会看到 axios 这类第三方请求库。它们本质上是 fetch/XHR 的封装,提供拦截器、自动 JSON 转换等便利功能。但先掌握原生 fetch 很重要:一是零依赖、体积小;二是遇到问题你能看懂底层在发生什么;三是现代框架和浏览器 API 都以 fetch 的 Promise 模型为基准,理解了它,学什么库都快。
2. 第一个 GET 请求
fetch 的基本用法只有一句话:fetch(url) 会返回一个 Promise,拿到响应后用 response.json() 解析 JSON 数据。下面是请求 GitHub 用户信息的完整例子:
fetch('https://api.github.com/users/octocat')
.then(function (response) {
return response.json();
})
.then(function (data) {
console.log(data.login, data.public_repos);
})
.catch(function (err) {
console.error('请求失败:', err);
});
关键点:第一个 then 里拿到的是 Response 对象,必须先调用 json()(它本身也返回 Promise)才能得到数据;第二个 then 收到的就是解析好的对象了。任何一步出错,都会跳到 catch。如果你只需要文本,可以用 response.text(),处理 JSON 之外的接口时很常用。
Response 对象上还挂着不少有用信息:response.status 是状态码,response.headers 能读响应头,response.url 是最终请求地址——发生重定向后,这里能看到跳转的终点。调试时把这三样打出来,基本就能定位问题出在哪个环节。
3. 用 async/await 写得更顺
链式 then 能干活,但复杂逻辑里依然容易乱。用 async/await 可以把异步代码写成"看起来像同步"的样子。这里有一个新手最容易踩的坑:fetch 只有在网络层面失败时才会 reject,返回 404、500 它照样算"成功",所以必须自己检查状态码。
async function getUser(name) {
const response = await fetch('https://api.github.com/users/' + name);
if (!response.ok) {
throw new Error('HTTP 状态码: ' + response.status);
}
return await response.json();
}
getUser('octocat')
.then(function (user) {
console.log(user.login, '拥有', user.public_repos, '个公开仓库');
})
.catch(function (err) {
console.error('出错了:', err.message);
});
response.ok 是状态码在 200-299 之间的简写,不满足就主动 throw,把"业务错误"转成异常,统一交给 catch 处理。养成这个习惯,你的错误处理会清爽很多——所有失败路径都收敛到一处。
async/await 还有个配套好处:可以直接用 try/catch 包裹整段逻辑,不用 then/catch 混着写。如果你在一个 async 函数里连续请求多个接口,把 await 都放进同一个 try 里,任何一个失败都会立刻中断并跳进 catch,错误处理集中在一处。唯一要注意的是 await 只能用在 async 函数内部,顶层代码需要包一层 async 函数(比如立即执行函数 (async function () { ... })())。
4. POST 提交数据
GET 只能带 URL 参数,提交表单、创建资源要靠 POST。写 POST 请求时要手动指定 method、Content-Type 请求头,并用 JSON.stringify 把对象序列化成字符串放进 body:
async function createTodo(title) {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ title: title, completed: false })
});
if (!response.ok) {
throw new Error('提交失败: ' + response.status);
}
return await response.json();
}
createTodo('学会 fetch')
.then(function (todo) {
console.log('创建成功,新记录 id =', todo.id);
})
.catch(console.error);
最容易翻车的地方有两个:一是忘记设 Content-Type,后端解析不到 body;二是忘了 JSON.stringify,直接把对象塞进 body——对象会被转成 [object Object] 这种字符串发出去,后端收到一脸懵。
GET 请求要带参数时,别手动拼字符串——用 URLSearchParams 生成查询串,它会自动处理特殊字符的转义。比如搜索关键词里带个 & 或空格,手动拼接很容易把 URL 弄坏:
async function searchTodos(keyword, page) {
const params = new URLSearchParams({
q: keyword,
page: page,
limit: 10
});
const response = await fetch(
'https://jsonplaceholder.typicode.com/todos?' + params.toString()
);
return await response.json();
}
searchTodos('learn & write', 1).then(function (data) {
console.log('查询完成,结果', data.length, '条');
});
params.toString() 会把空格转成 %20、把 & 转成 %26,拼接出来的 URL 一定是合法的。这也是为什么说"永远别自己拼 URL"——编码规则太容易出错,交给标准 API 最稳。
另外注意,params.toString() 生成的是不带问号的查询串,拼接时要自己补一个 ?;如果要在现有 URL 上追加参数,更正规的做法是 new URL(url) 后用 url.searchParams.set() 修改,再读 url.toString(),这样连原有参数都不会弄丢。
5. 超时与取消请求
网络差的时候,一个请求可能卡住几十秒。给请求加超时、允许用户手动取消(比如切换页面时),是工程里必备的能力。做法是用 AbortController 生成一个 signal,通过 abort() 主动中断请求:
function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(function () {
controller.abort();
}, timeoutMs);
return fetch(url, { signal: controller.signal })
.finally(function () {
clearTimeout(timer);
});
}
fetchWithTimeout('https://jsonplaceholder.typicode.com/todos/1', 3000)
.then(function (response) {
return response.json();
})
.then(function (todo) {
console.log(todo.title);
})
.catch(function (err) {
console.error(err.name === 'AbortError' ? '请求超时' : err.message);
});
被中断的请求会抛出一个 name 为 AbortError 的异常,所以 catch 里要区分"超时"和"真出错"两种情况。finally 保证无论成功失败都会清掉定时器,避免定时器泄漏、请求结束后又误触发中断。
顺带一提,现代浏览器还提供了 AbortSignal.timeout(ms) 这个语法糖,一行就能生成带超时的信号:fetch(url, { signal: AbortSignal.timeout(3000) }),效果和上面的 fetchWithTimeout 等价。但自己封装的好处是可以同时支持"手动取消 + 超时"两种触发方式,切换页面时手动 abort,网络差时自动超时,两不误。
取消请求不只是超时要用。单页应用里,用户快速切换页面时,上一个页面的请求可能还在路上——等它返回后再去更新界面,就会遇到"组件已经卸载还在更新状态"的尴尬。工程上的标准做法:在页面卸载时调用 controller.abort(),请求就被干净地中断了。所以 AbortController 不只是"超时工具",更是页面生命周期管理的好帮手。养成"请求要么完成、要么被取消"的习惯,还能避免慢请求返回后覆盖新数据的问题。
6. 并发请求用 Promise.all
有时一个页面要同时请求好几个接口,串行等待太慢。Promise.all 让它们并行执行,全部完成后再统一处理,总耗时约等于最慢的那个请求:
async function loadDashboard() {
const [users, posts] = await Promise.all([
fetch('https://jsonplaceholder.typicode.com/users').then(function (r) {
return r.json();
}),
fetch('https://jsonplaceholder.typicode.com/posts').then(function (r) {
return r.json();
})
]);
console.log('加载完成:用户', users.length, '个,文章', posts.length, '篇');
}
loadDashboard().catch(function (err) {
console.error('任一请求失败:', err.message);
});
注意 Promise.all 是"一损俱损":只要其中一个 reject,整体就 reject,哪怕其他请求已经成功。如果希望单个失败不影响整体,可以考虑 Promise.allSettled——它会等所有请求结束,返回每个请求各自的结果和状态。
并发请求的另一种常见形态是"先拿 id 列表,再批量拉详情":用 ids.map(fetchDetail) 得到一个 Promise 数组,再交给 Promise.all 收尾,几行代码就能把串行循环变成并行请求。反过来,如果 B 接口依赖 A 接口的结果,那就老老实实 await 两次,别为了"看起来快"强行并行——那只会让代码难懂、错误难查。
7. 总结与练习
回顾一下:fetch 基于 Promise,比 XHR 简洁;GET 用 response.json() 解析数据,POST 要设置请求头和 body;永远检查 response.ok 并主动 throw;用 AbortController 实现超时与取消;Promise.all 处理并发。
给你三个练习:
- 写一个
request(config)工具函数,统一封装 GET/POST、超时和错误信息格式化,后续所有请求都走它。 - 用 GitHub API 做一个小页面:输入用户名,展示头像和仓库列表,请求期间显示 loading 状态。
- 把
Promise.all换成Promise.allSettled,观察部分接口失败时行为有什么不同。
💡 调试 fetch 请求时,打开浏览器 DevTools 的 Network 面板,能看到每个请求的状态码、耗时和响应体——90% 的"为什么没数据"问题都能在这里找到答案。