Skip to content

Fetch、取消与竞态:请求成功不等于结果可用 ​

用户先查“闭包”,紧接着查“原型”。第二次请求先回来,第一次却在最后回来。如果每次拿到结果就刷新页面,界面会退回旧查询。这不是网速问题,而是程序没有定义谁有资格更新界面。

请求在哪一层失败,决定应该检查什么 ​

一次“读取标题”可以拆成五步。把所有失败显示为“网络异常”,会让 JSON 拼写错误也被误诊为断网。

步骤得到什么典型失败
取得响应Response 与响应头网络中断、取消、浏览器拒绝暴露响应
检查状态判断业务是否接受 HTTP 状态404、500 等状态需主动处理
读取正文逐步到来的响应体数据连接中断、读取期间取消、正文已被消费
解析格式JSON 值正文是 HTML 错误页、JSON 不合法
校验结构本业务需要的字段JSON 合法但 title 缺失或类型不符

例如服务器返回 200 和字符串 null:网络正常、HTTP 正常、JSON 语法也正常,却不能把 null 当成有 title 的对象。Content-Type 可以帮助诊断,但单凭它也不能保证正文符合契约。

js
async function readJSON(url, signal) {
  const response = await fetch(url, { signal });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data = await response.json();
  if (!data || typeof data !== 'object' || typeof data.title !== 'string') {
    throw new TypeError('响应缺少文本标题');
  }
  return data;
}

第一句 await 主要等到响应可用,不代表正文全部读取完;第二次 await 才包括读取和 JSON 解析。204 没有正文,若接口约定它表示成功,就应先针对该状态处理,而不是无条件 json 后把解析失败误报成服务器失败。

响应体是一条消费路径,不是每次都自动倒回起点的字符串。现代浏览器控制台或支持 Response 的 Node .mjs 中运行:

js
const response = new Response('{"title":"事件"}');
console.log(response.bodyUsed); // false
console.log((await response.json()).title); // 事件
console.log(response.bodyUsed); // true
try { await response.text(); }
catch (error) { console.log(error.name); } // TypeError

需要两种读取用途时应在第一次消费前 clone;这会分出两个读取分支,慢分支可能积压数据,所以对巨大响应并非零成本。如果只是调试,先读成一种格式再记录需要的部分,往往比完整克隆两份更合适。

不用外部服务器也能观察 HTTP 分支 ​

下面示例在现代浏览器控制台或支持这些 Web API 的 Node 环境运行。它验证处理逻辑,不声称真的发送了 HTTP 请求。

js
async function decode(response) {
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data = await response.json();
  if (!data || typeof data !== 'object' || typeof data.title !== 'string') {
    throw new TypeError('缺少标题');
  }
  return data.title;
}
console.log(await decode(new Response('{"title":"闭包"}'))); // 闭包
try {
  await decode(new Response('找不到', { status: 404 }));
} catch (error) {
  console.log(error.message); // HTTP 404
}

真实调用 readJSON('/api/topics') 则需要同源服务器提供该路径,并返回约定 JSON。一个示意路径不是现成接口;排查时先在网络面板检查实际 URL、状态、响应体,再猜 JavaScript 的逻辑。

取消省资源,版本号保证界面顺序 ​

下面是可嵌入页面的函数定义。调用方提供真实 URL 和渲染函数,不依赖未声明的全局 UI 对象。

js
function createLatestLoader(render, showError, request = fetch) {
  let revision = 0;
  let active;
  return {
    async load(url) {
      const mine = ++revision;
      active?.abort();
      const controller = new AbortController();
      active = controller;
      try {
        const response = await request(url, { signal: controller.signal });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const data = await response.json();
        if (mine === revision) render(data);
      } catch (error) {
        if (mine === revision && !controller.signal.aborted) {
          showError(error);
        }
      }
    },
    dispose() {
      revision += 1;
      active?.abort();
    }
  };
}

每次 load 获得自己的编号;只有编号仍是最新的调用才能更新界面,包括展示错误。dispose 也增加编号,所以组件被移除后,旧结果不能偷偷回来渲染。

AbortController 只能请求协作取消。它不保证服务器已经停止工作,更不会撤销已提交的数据修改。因此“取消付款请求”不等于“取消付款”。编号检查解决正确性,取消减少不再需要的工作,职责不同。

signal 一旦 aborted 就不会复原,下一轮创建新 controller。读取响应体过程中也可能发生取消,不能只包住第一句 fetch。

用可控完成顺序证明旧响应没有更新权限 ​

把上一节 createLatestLoader 的定义和下面代码放在同一 .mjs 文件运行。这里注入一个人工响应器,它故意不响应取消,借此检查版本号能否独立保护界面;这不是网络测试,也不要求真实服务端。

js
const pending = new Map();
const shown = [];
const failures = [];
const request = url => new Promise((resolve, reject) => {
  pending.set(url, { resolve, reject });
});
const loader = createLatestLoader(
  data => shown.push(data.title),
  error => failures.push(error.message),
  request
);
const older = loader.load('old');
const newer = loader.load('new');
pending.get('new').resolve(new Response('{"title":"新结果"}'));
await newer;
pending.get('old').resolve(new Response('{"title":"旧结果"}'));
await older;
console.log(shown); // ['新结果']
console.log(failures); // []
loader.dispose();

load('old') 保存编号 1,load('new') 把当前编号改成 2,并取消旧 controller。因为替身不支持取消,旧工作仍能成功;但恢复后发现 1 不等于 2,就不发布。若删去 mine === revision,输出会出现两个结果,最后一个反而是旧结果。

错误提示也有同样的竞争。把旧响应那行换成 pending.get('old').reject(new Error('旧请求失败')),已显示的新结果不应被旧错误遮住。dispose 则再次增加编号,使离开页面前启动的请求也失去发布资格。

捕获到的取消不一定只能靠 error.name === 'AbortError' 识别:abort 可以携带自定义 reason。本实现检查属于本次调用的 signal.aborted,把它作为这次操作已经取消的事实;若产品区分超时与主动取消,还要记录相应 reason 并制定提示规则。不能根据任意异常都显示“已取消”,也不能把取消当作写入已经撤销。

CORS 与 credentials 不能靠客户端单方面批准 ​

同源比较协议、主机和端口。跨源 fetch 要遵守 CORS:服务器返回允许的源等响应头;部分请求先进行预检。CORS 主要控制浏览器脚本能否读取响应,不是服务端身份认证,也不能单独防止所有跨站请求。

credentials 默认 same-origin;跨源携带凭据通常需 include,并且服务器明确允许凭据和具体源,Cookie 自身的 SameSite 等规则仍生效。允许源为 * 不能与凭据读取方案随意混用。

mode: 'no-cors' 得到的通常是 opaque 响应,脚本不能自由读取状态与正文;它不是“关闭跨域报错”的魔法按钮。遇到 CORS 错误应核对服务器配置和请求设计。

对于 POST JSON,既要 JSON.stringify,也要设置正确 Content-Type;FormData 则通常不要手动填写 multipart 的 Content-Type,浏览器需要生成 boundary。重试有副作用的请求还要考虑幂等性,不能看到网络失败就盲目再提交。

练习 ​

  1. 为什么 404 可能跳过 catch,而无效 JSON 又进入 catch?

  2. 如果旧请求已拿到结果但尚未渲染,只做 abort 足够吗?请解释编号检查的作用。

  3. 人工响应器完全不支持abort时,哪条判断仍保护最新结果?把旧响应改为reject,预期错误列表是什么?

  4. 使用本节人工响应器写一个“离开页面”的测试:启动 load 后立即 dispose,再让该响应成功完成。shown 与 failures 都必须为空。另起一次实验让响应拒绝,结果仍相同;测试不能依赖定时等待。

查看本节答案

用心记录每一步成长