点开商品详情时,页面要读取价格和库存;提交评论时,页面要把文字交给服务器;等待订单出库时,页面还希望在不刷新的情况下收到最新进度。浏览器里的 JavaScript 之所以能完成这些工作,靠的是网络通信。
网络请求最容易写成“复制一段 fetch,能跑就算完成”。可一旦遇到 404 没进 catch、响应不是 JSON、请求一直不结束、CORS 报错,或者旧请求覆盖了新结果,只会背语法就很难继续排查。
这一章会把一次通信从头拆开:先读懂 HTTP 请求与响应,再建立 API 和 JSON 的数据契约;接着用 Promise、async/await、XHR 与 Fetch 组织异步流程;最后处理错误、超时、取消、跨域和实时更新。读完后,你不只会“发请求”,还会知道每一步为什么这样写。
本文同时区分浏览器与 Node.js 环境。XHR、CORS、EventSource 和浏览器 WebSocket 示例应在浏览器中运行;标有控制台输出的通用示例可在现代浏览器或 Node.js v25.2.1 中运行。
完成本章后,你应该能够:
then、catch、finally 和 async/await;把浏览器想成顾客,把服务器想成后厨。顾客不能只喊一句“给我东西”,而要说明要哪道菜、想做什么、有没有附加要求;后厨也不能只回一句“好了”,而要告诉顾客处理结果和真正交付的内容。
HTTP 就是双方共同遵守的表达格式。客户端先发送请求,服务器再返回响应。JavaScript 可以发起请求和读取响应,但 HTTP 本身并不属于 JavaScript。

下面是一份便于阅读的概念报文。真实的 HTTP/2、HTTP/3 在线路上的编码方式并不长这样,但这些字段仍然存在。
POST /api/books?source=homepage HTTP/1.1
Host: shop.example
Accept: application/json
Content-Type: application/json
Authorization: Bearer token-value
{"title":"现代 JavaScript","price":68}这份请求可以拆成四部分:
POST 表达“提交这份数据”的意图。/api/books 是路径,source=homepage 是查询参数。Accept 说明希望收到什么格式;Content-Type 说明请求体是什么格式。常见方法可以这样理解:
“幂等”表示同一个请求执行一次和执行多次,对服务器最终状态的预期影响相同。它不代表每次响应一定相同,也不代表请求没有日志、计费或限流等附带影响。
方法只是协议语义和接口约定,不是权限。客户端即使发出 DELETE,服务器仍要验证身份、权限、参数和资源状态。浏览器 Fetch 也不允许给 GET 或 HEAD 设置请求体;查询条件应放在 URL 中。

下面在本地构造一个 Request,不访问网络也能观察请求结构:
const request = new Request("https://api.example.test/books?tag=js", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "JavaScript 入门",
available: true,
}),
});
console.log(request.method);
console.log
POST
js
application/json
JavaScript 入门 true服务器收到请求后,会返回类似下面的响应:
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/books/42
Cache-Control: no-store
{"id":42,"title":"现代 JavaScript","price":68}201 表示创建成功,Location 指出新资源的位置,Content-Type 说明响应体是 JSON。响应体才是业务数据;状态码和响应头则告诉客户端应该怎样理解这份数据。
状态码按第一位分组:
几个容易混淆的状态值得单独记住:
401 Unauthorized 通常表示尚未提供有效身份凭证;403 Forbidden 通常表示服务器知道请求者是谁,但不允许这项操作;404 Not Found 表示当前地址下找不到资源,也可能被服务端用于隐藏资源是否存在;409 Conflict 常用于版本、唯一性或状态冲突;422 Unprocessable Content 常用于格式可读、但字段校验不通过;204 No Content 表示成功但没有响应体,此时不要调用 response.json()。请求头与响应头的名称不区分大小写。response.headers.get("Content-Type") 和 response.headers.get("content-type") 会读取同一个字段,但字段值本身仍要按对应规范解释。
状态码与响应体承担不同职责。不要因为响应体里出现 success: false 就忽略状态码,也不要因为状态是 200 就假定数据结构一定正确。客户端通常要同时验证 HTTP 状态和业务数据。
API 是程序之间约定好的入口。对于一个图书服务,GET /api/books 可以读取列表,POST /api/books 可以创建记录,GET /api/books/42 可以读取编号为 42 的图书。
一个端点不只是 URL。更准确地说,它通常由“HTTP 方法 + URL + 输入格式 + 输出格式 + 错误规则”共同定义。同一条路径使用不同方法,可能对应完全不同的操作。
假设创建图书的接口约定如下:
调用方需要遵守字段名和类型,服务端也需要稳定地返回约定结构。前端把 price 传成字符串、服务端突然把 title 改成 bookName,都可能让通信在语法正确的情况下仍然失败。
路径参数和查询参数的职责也不同:
/api/books/42 中的 42 用来定位一个具体资源;/api/books?category=javascript&page=2 中的参数用来筛选、排序或分页;构造查询字符串时不要手动拼接用户输入,使用 URLSearchParams 可以正确编码空格、中文和特殊字符:
const params = new URLSearchParams({
keyword: "异步 JavaScript",
page: "2",
pageSize: "20",
});
const url = "/api/books?" + params.toString();
console.log(url);/api/books?keyword=%E5%BC%82%E6%AD%A5+JavaScript&page=2&pageSize=20JSON 是跨语言的数据格式。服务端可以用 Java、Go、Python 或 JavaScript 实现,只要双方都按 JSON 规则读写即可。
合法 JSON 能表示:
null;JSON 的对象键和字符串必须使用双引号。它不能直接表示函数、undefined、Symbol、BigInt、注释或循环引用。
{
"id": 42,
"title": "现代 JavaScript",
"available": true,
"tags": ["http", "json"],
"publisher": {
"name": "示例出版社",
"city": null
}
}
JSON.stringify() 把可序列化的 JavaScript 值转换为 JSON 文本;JSON.parse() 做相反的工作:
const book = {
title: "JavaScript 入门",
tags: ["http", "json"],
available: true,
};
const text = JSON.stringify(book);
const parsed = JSON.parse(text);
console.log(typeof text);
console.log(text);
console.logstring
{"title":"JavaScript 入门","tags":["http","json"],"available":true}
http / json解析外部数据时要准备好处理语法错误:
const responseText = '{"title":"缺少结尾花括号"';
try {
const data = JSON.parse(responseText);
console.log(data.title);
} catch (error) {
console.log("响应不是合法 JSON");
}响应不是合法 JSONJSON.stringify() 不是无损复制工具。对象属性中的 undefined 和函数会被忽略,数组中的这类值会变成 null,BigInt 和循环引用默认会抛错。网络契约应只使用明确支持的数据类型。
网络通信的耗时不可预测。服务器可能 30 毫秒返回,也可能数秒后才返回。JavaScript 不能让页面主线程停在那里干等,否则按钮、滚动和动画都会失去响应。
运行环境会负责等待网络结果,JavaScript 则继续执行当前同步代码。结果准备好后,相应任务才有机会进入事件循环并继续处理。这种“先发起、稍后接结果”的模式,需要 Promise 来表达。
Promise 有三种状态:
pending:尚未完成;fulfilled:成功完成,并带有一个值;rejected:失败,并带有一个原因。Promise 一旦从 pending 变为 fulfilled 或 rejected,就不会再次改变。then() 处理成功值,catch() 处理拒绝原因,finally() 无论成功失败都会执行,适合恢复按钮或关闭加载提示。
function waitForBook() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ id: 42, title: "异步编程" });
}, 20);
});
}
console.log("1. 请求已经发起");
waitForBook()
.then((book) => {
1. 请求已经发起
2. 页面继续响应
3. 收到:异步编程
4. 编号:42
5. 请求流程结束then() 会返回一个新的 Promise。回调返回普通值时,下一个 then() 收到该值;回调返回 Promise 时,链会等待它;回调抛错时,后续链会寻找最近的拒绝处理器。
async 函数始终返回 Promise。函数里 return value 等价于返回一个 fulfilled Promise;抛出错误则得到 rejected Promise。await 只能在异步函数或支持顶层 await 的模块中使用。
async function getBook() {
return { id: 42, title: "异步编程" };
}
async function showBook() {
try {
const book = await getBook();
console.log(book.title);
} catch (error) {
console.error("读取失败", error);
} finally {
console.log(
异步编程
加载状态已结束await 暂停的是当前异步函数后面的步骤,不会冻结整个 JavaScript 线程。其他事件、渲染和已经排队的任务仍然可以继续。
如果几个请求互不依赖,逐个 await 会制造不必要的串行等待:
// 串行:后一个请求要等前一个完成后才发起
const profile = await fetchProfile();
const orders = await fetchOrders();
// 并发发起:两项互不依赖时通常更快
const [profileData, orderData] = await Promise.all([
fetchProfile(),
fetchOrders(),
]);Promise.all() 保持输入顺序,但其中一个 Promise 拒绝时会立刻让组合 Promise 拒绝。它不会自动取消其他已经发出的请求。若希望收集每一项的成功或失败结果,可以使用 Promise.allSettled()。
AJAX 是 Asynchronous JavaScript and XML 的缩写,描述“页面不整体刷新,也能异步和服务器交换数据”的技术方式,它不是一门语言。名字里虽然有 XML,今天交换 JSON 更常见。XMLHttpRequest,简称 XHR,是 Fetch 普及前最常见的浏览器请求接口。
下面把 XHR 包装成 Promise。代码必须在浏览器中运行:
function getBookListWithXHR(url) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("GET", url);
xhr.responseType = "json";
xhr.timeout = 5000;
xhr.setRequestHeader("Accept", "application/json");
XHR 的步骤是:
new XMLHttpRequest() 创建请求对象;open() 设置方法和地址,此时还没有发送;load、error、timeout、abort 等事件;send() 真正发出请求。load 只代表响应传输完成,不等于业务成功,所以仍然要检查 status。旧代码也常监听 readystatechange,并在 readyState === 4 时处理结果;4 表示这次操作完成,同样不保证状态是 2xx。
当 responseType 是 "json" 时,浏览器会替 XHR 解析正文。无效 JSON 不一定触发 error 事件;请求可能以 2xx 和 load 结束,而 xhr.response 是 null。合法的 JSON null 也可能得到同样的值,所以示例按照 API 契约验证顶层必须为数组,每一项还要有整数 id 和长度受限的字符串 title。若接口允许顶层 null,又必须区分它与语法错误,可以改收文本,再用 JSON.parse() 和 try/catch 自行解析。
新项目通常优先使用 Fetch,不过 XHR 仍可能出现在旧系统、封装库以及需要传统上传进度事件的代码中。看到它时,先找 open、send、状态判断和四类结束事件,阅读会容易很多。
XHR 和 Fetch 都受浏览器同源策略与 CORS 约束。换成 XHR 不会绕过目标服务器缺失的跨源许可。
fetch(url, options) 发起请求并立即返回 Promise。这个 Promise fulfilled 后得到 Response 对象;随后还要异步读取响应体。
因此,一次 JSON 请求通常有两个等待点:
const response = await fetch("/api/books"); // 等响应头
const books = await response.json(); // 读取并解析响应体
Response.ok 在状态为 200 到 299 时是 true。Fetch 收到 404、422 或 500 时,Promise 通常仍然 fulfilled,因为服务器确实返回了响应;要把它转入 catch,需要主动抛错。
async function loadBooks() {
const response = await fetch("/api/books", {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error("读取图书失败,HTTP " + response.status);
}
return response.json();
}
loadBooks()
.
响应体默认只能消费一次。调用 response.json() 后再调用 response.text() 会失败,因为流已经读完。确实需要读取两份时,应在消费前调用 response.clone(),但普通业务代码通常只需选择一种解析方式。
Content-Type 描述你发送的请求体;Accept 描述你希望服务器返回的媒体类型。它们相关,但不是一回事。
async function createBook(book) {
const response = await fetch("/api/books", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(book),
});
if (!response.ok) {
const message =
如果接口返回 204,直接调用 response.json() 会因为空响应体而失败。接口也可能在错误时返回 HTML 或纯文本,因此完整请求工具还需要验证内容类型,这会在下一节实现。
默认情况下,Fetch 对同源请求会携带同源凭据;跨源请求若需要 cookie,要显式设置 credentials: "include",并要求服务器返回匹配的 CORS 响应头。不要把身份令牌打印到日志或写进公开示例。
下面的实验把 200、404、500 和网络失败放在同一条处理流程中。它使用本地模拟,不会访问外部 API。
“请求失败”至少可能发生在四个层次:
把这些层次混在一个“失败了”里,界面就无法给出有用提示。HTTP 错误应保留状态码;解析错误要说明返回格式异常;取消与超时不应该伪装成服务器 500。
下面的函数先读取一次文本,再根据状态与内容类型决定怎么处理。这样既能保留错误正文,也不会重复消费响应流。
class HttpError extends Error {
constructor(status, body) {
super("HTTP " + status);
this.name = "HttpError";
this.status = status;
this.body = body;
}
}
async function requestJSON(url, options = {}) {
const
生产项目还可以对返回数据做结构校验。例如“图书列表必须是数组,每一项都要有数字 id 和字符串 title”。JSON 能成功解析,只能证明语法有效,不能证明数据符合业务契约。
Fetch 没有一个通用的 timeout 数字选项。标准做法是给它传入 AbortSignal,再由 AbortController 触发取消。
function startJSONRequest(url, options = {}, timeoutMs = 5000) {
const controller = new AbortController();
let reason = "用户取消了请求";
const timerId = setTimeout(() => {
reason = "请求超过 " + timeoutMs + " 毫秒";
controller.abort();
}, timeoutMs);
这里的超时是客户端自己设定的等待上限,与服务器返回的 408 Request Timeout 或网关返回的 504 Gateway Timeout 不是同一件事。前者通常表现为取消,后两者是带状态码的 HTTP 响应。
取消表示客户端不再等待或读取这次响应,并不保证服务器已经停止处理。比如创建订单的请求在服务器完成写入后才被取消,订单仍可能已经创建。因此,涉及写操作时还要使用幂等键、查询最终状态等服务端机制。
当搜索框连续发请求时,可以取消上一次请求,避免旧结果覆盖新结果:
let currentController;
async function searchBooks(keyword) {
currentController?.abort();
currentController = new AbortController();
const params = new URLSearchParams({ keyword });
const response = await fetch("/api/search?" + params, {
signal: currentController.signal,
});
if (!response.ok) {
throw
不要无条件重试所有请求。GET 等幂等读取在网络抖动或 503 时可以配合退避策略重试;POST 可能重复创建记录,只有接口提供幂等保证时才应自动重试。遇到 429 或 503 还应留意 Retry-After。
浏览器把“协议、主机、端口”三者都相同的页面视为同源。例如:
CORS 是服务器通过响应头告诉浏览器:“哪些 Origin 对应的页面脚本可以读取我的响应”。最常见的响应头是:
Access-Control-Allow-Origin: https://shop.example它不是前端单方面能打开的开关。页面无法通过增加一个请求头来给自己授权;许可必须由目标服务器返回。
浏览器为了避免泄露跨源信息,通常不会把被拦截响应的状态和正文交给 JavaScript。此时调用方常只看到笼统的 TypeError,详细原因要结合 Console、Network 和服务端日志判断。
部分跨源请求可以直接发送,通常被称为 CORS safelisted request。使用 PUT、PATCH、DELETE,添加 Authorization 或自定义请求头,或者发送 Content-Type: application/json,通常会先触发预检。
预检请求使用 OPTIONS,大致会询问:
OPTIONS /api/books HTTP/1.1
Origin: https://shop.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization服务器允许后,会在预检响应中声明可接受的 Origin、方法和请求头。浏览器才继续发送真正请求。
下面的代码只模拟判断逻辑,不代表浏览器的全部 CORS 实现:
const request = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer token-value",
},
};
const simpleMethods = new Set(["GET", "HEAD", "POST"]);
const safelistedNames = new Set([
"accept",
"accept-language",
需要预检: true跨源请求要携带 cookie 时,前端通常要写:
const response = await fetch("https://api.example/account", {
credentials: "include",
});服务器则要返回与页面 Origin 匹配的 Access-Control-Allow-Origin,并返回:
Access-Control-Allow-Credentials: true带凭据时,Access-Control-Allow-Origin 不能使用通配符 *。cookie 自身的 SameSite、Secure、域和路径规则也必须允许发送;CORS 配置正确不代表 cookie 一定会出现。
mode: "no-cors" 通常不是修复办法。它会限制可发送的内容,并让 JavaScript 得到 opaque response:状态、响应头和正文基本都不可读。
CORS 不是身份验证,也不是保护服务器免受请求的防火墙。非浏览器客户端不执行浏览器同源策略,而且某些跨源请求本来就能发出。服务端仍必须验证身份、权限、CSRF 风险和输入数据。
下面的交互可以修改方法、URL、请求头和请求体,再观察模拟报文。它不会向真实服务器发送数据。
普通 Fetch 是“一次请求,对应一次响应”。如果页面要持续获取订单进度、通知、日志或聊天消息,就要选择更合适的通信方式。

轮询适合几秒或几十秒检查一次状态。请求完成后再安排下一次,比固定 setInterval 更容易避免上一个请求未结束、下一个又开始:
let stopped = false;
async function pollOrder(orderId) {
while (!stopped) {
try {
const order = await requestJSON("/api/orders/" + orderId);
renderOrder(order);
if (order.status === "finished") {
break;
}
} catch (error) {
页面切到后台、网络失败或服务端返回 429 时,应降低频率或暂停。对很多客户端做高频轮询,会产生大量没有新数据的请求。
浏览器用 EventSource 接收 SSE:
const progressOutput = document.querySelector("#progress");
const orderOutput = document.querySelector("#order-status");
const events = new EventSource("/api/orders/A1024/events");
function parseEventData(event, label) {
try {
return JSON.parse(event.data);
} catch {
服务器要以 Content-Type: text/event-stream 持续发送文本事件。EventSource 对断线重连和事件编号有内置支持,但通信主要是服务器到客户端;客户端提交操作仍可使用 Fetch。
event.data 是外部文本。JSON.parse() 只能证明 JSON 语法有效,不能证明 percent、id 或 status 符合契约。示例先捕获解析错误,再检查字段类型和值域,最后通过 textContent 显示;不可信文本不能直接拼进 innerHTML。
SSE 仍是 HTTP 响应流,跨源连接同样要满足 CORS。跨源连接要携带 cookie 时,可使用 new EventSource(url, { withCredentials: true }),服务端也要返回对应的凭据许可。原生 EventSource 不方便附加任意自定义请求头;需要认证时,优先使用安全 cookie 或专门设计的短期凭证,不要把长期密钥直接暴露在 URL 中。
浏览器建立 WebSocket 后,双方都能主动发消息:
const messageList = document.querySelector("#messages");
const socket = new WebSocket("wss://chat.example/socket");
function parseChatMessage(data) {
if (typeof data !== "string") {
throw new TypeError("只接受文本消息");
}
let message;
try {
message
连接打开前不能直接发送业务消息。实际项目还要设计认证、消息类型、心跳、重连、顺序、重复消息和限流。HTTPS 页面通常应使用 wss://,服务端也应验证握手里的 Origin,不能因为连接已升级就跳过权限检查。
WebSocket 消息同样是不可信输入。即使连接已经认证,其他用户生成的消息也可能包含恶意文本;解析后仍要校验消息类型、必需字段、长度和值域。向 DOM 输出时优先使用 textContent 或显式创建节点,不能把 message.text 直接赋给 innerHTML。
WebSocket 握手不等同于 Fetch 的 CORS 流程,也不会自动替服务器阻止所有外站连接。浏览器会发送 Origin,服务端必须主动核对允许的站点。
选择时先问三个问题:
如果五秒刷新一次就够,用轮询往往更省心;持续单向事件优先考虑 SSE;真正高频双向交互再使用 WebSocket。
下面的交互会根据频率、方向和部署条件给出教学建议,不会建立真实连接。
请求失败时,先收集事实,再改代码。浏览器开发者工具的 Network 面板能看到请求是否发出、实际 URL、方法、请求头、请求体、状态、响应头、响应正文和耗时。
确认请求有没有出现。若没有,检查事件是否触发、代码是否提前抛错、表单是否刷新了页面。
核对方法、完整 URL、查询参数和请求体。特别留意重复斜杠、漏掉编码、undefined 被拼进地址,以及 JSON 字段类型。
查看状态码。401 查登录凭据,403 查权限,404 查路径和资源,409 或 422 查业务字段,500 以上结合服务端日志。
命令行或 Node.js 能读取同一个 API,而浏览器不能读取时,重点检查 CORS、cookie 和浏览器安全策略。浏览器和非浏览器客户端所处的安全边界不同。
排查时不要截图或复制完整的 Authorization、cookie、密码、身份证号等敏感数据。需要共享报文时先脱敏,只保留与问题有关的字段。
这一实践把请求、JSON、错误、取消和界面状态放进同一个小项目。页面要读取图书、创建图书,并允许用户取消正在进行的刷新。
先约定后端行为:
每个图书对象至少包含:
{
"id": 42,
"title": "网络通信",
"price": 59
}<form id="book-form">
<label>
书名
<input id="title" name="title" required minlength="2" />
</label>
<label>
价格
<input id="price" name="price" type="number" min=
const form = document.querySelector("#book-form");
const titleInput = document.querySelector("#title");
const priceInput = document.querySelector("#price");
const submitButton = document.querySelector("#submit");
const reloadButton = document.querySelector("#reload");
const cancelButton
使用 textContent 而不是把服务端文本拼进 innerHTML,可以避免把未知字符串当成 HTML 执行。
复用前面的 requestJSON,再用控制器保证同一时间只有一次列表刷新:
let listController;
async function loadBooks() {
listController?.abort();
const controller = new AbortController();
listController = controller;
setStatus("正在读取图书……");
reloadButton.disabled = true;
cancelButton.disabled = false;
try {
const books = await requestJSON("/api/books"
局部变量 controller 标记这一次刷新。只有它仍是最新控制器时,代码才更新列表和按钮;这样旧请求即使稍晚进入 catch 或 finally,也不会覆盖新请求的界面状态。
form.addEventListener("submit", async (event) => {
event.preventDefault();
const book = {
title: titleInput.value.trim(),
price: Number(priceInput.value),
};
if (book.title.length < 2 || !Number.isFinite(book.price) || book.price < 0) {
setStatus
不要只测试“服务器正常”的情况。逐项确认:
401、409、422、500 会显示不同且可理解的提示;<img> 等文本时只显示文字,不会被当作 HTML;Content-Type、请求体和状态码都符合契约。进阶挑战:让服务端提供 /api/books/events 的 SSE 端点。当其他用户创建图书时推送 book-created 事件,当前页面收到事件后只刷新一次列表。若没有实时服务端,保留手动刷新即可。
async/await 让等待关系更清楚,但不会把并发操作自动变成串行或自动取消。load 与 Fetch 的 fulfilled 都不等于 HTTP 成功,始终要检查状态码。no-cors 不是通用修复方案。查看响应的 Content-Type 与原始正文。期待 JSON 却收到 HTML 时,常见原因是代理错误页、登录跳转或服务端异常。
检查 Console 中的 CORS、混合内容、证书和取消信息。预检会显示为独立的 OPTIONS 请求。
最后检查并发与界面状态。确认旧请求不会覆盖新请求,提交按钮会在 finally 中恢复,组件卸载后不会继续写入页面。