Ajax 实战:fetch 请求成功却显示旧数据?9 项实验讲清错误处理、取消与结果乱序

Ajax 是通过异步 HTTP 请求获取数据,再更新页面局部内容的一种开发方式。今天从一个搜索框讲起:用户先输入 a,再输入 ajax;两个请求都成功,页面却最终显示 a 的结果。问题出在“谁最后返回就让谁更新页面”的逻辑。

本文用本机 HTTP 接口和可控制完成顺序的 Promise,分别验证 HTTP 错误、JSON 解析、业务与结构校验、响应体取消和旧结果覆盖。2026-09-14 已在 Codex 内置 Chromium 浏览器运行九项检查;服务端使用 Node.js v26.8.2。没有访问真实业务网站,没有做跨域、吞吐或浏览器兼容性测试。

先把 Ajax 的四步拆开

一次搜索可以分成:输入发生变化;JavaScript 发出请求;读取并检查响应;把结果渲染到页面。Ajax 不要求地址栏变化,也不要求传输格式一定是 XML。下文采用 fetch 和 JSON,维护旧项目时也会遇到 XMLHttpRequest。

页面上的 loading 属于应用状态,不属于某一个 HTTP 状态码。两个搜索同时在途时,一个旧请求结束,未必有资格把整个搜索框的 loading 设为 false。

假设接口约定返回 {“ok”:true,“items”:[“ajax”]}。这里的 ok 和 items 是本文自定义业务契约,不是所有 Ajax 接口都采用的字段。

接收结果时检查什么

本地 /http500 返回状态 500,但 await fetch 仍得到 Response。getItems 因此显式检查 response.ok。/badjson 返回 200 和损坏 JSON,失败出现在读取正文时。/business 返回合法 JSON 和 ok:false;/shape 则返回 ok:true、items:null。这些情况分别在不同检查处被拒绝。

所以“请求成功”需要说清楚:拿到了响应、解析出了对象、业务允许继续、字段满足页面需要,是不同的结果。本例仅验证 items 是数组,没有检查每个元素;真实产品还要按自己的接口约定校验元素。

取消请求与允许更新页面是两件事

本地 /partial 先发送状态与一部分正文,然后保持响应未完成。浏览器取得 Response 后调用 abort,再读取 json,检查得到 AbortError。这说明拿到响应头与读完整个响应体之间也存在异步阶段。

但是一次 abort 不是自动清理所有后续逻辑的开关。旧任务可能已经读完响应,并转入另一个不支持该信号的异步处理。本文故意让测试中的旧 Promise 忽略取消,验证即使旧任务最终完成,也不能覆盖新搜索。

解决方法是给每次搜索分配递增版本。新搜索启动时先增加版本并请求取消旧任务。旧任务的成功分支、错误分支,以及 finally 对 loading 的更新,都必须检查版本。三个地方少守一个,都可能出现旧内容、旧错误或提前消失的加载状态。

这是一种“只展示最近一次搜索”的策略,适用于当前条件对应的单个结果区域。它不适用于必须累积每页结果的翻页任务,也不能替代服务端事务或写操作的重复执行控制。

完整复现文件

把下面四个文件放进同一个目录,运行 node ajax-server.mjs。终端会打印一个仅监听 127.0.0.1 的地址,在浏览器打开它,点击“运行 9 项检查”。端口由系统分配,不必硬编码本文运行时的端口。

不需要安装第三方包。服务器只公开代码中列出的三个静态资源和实验接口,不会把整个工作目录作为静态目录。/partial 的五秒定时器仅作连接兜底清理;乱序验证通过 Promise 的显式 resolve/reject 控制,不靠网络快慢猜测。

ajax-core.mjs

export async function getItems(url, signal) {
  const response = await fetch(url, { signal });
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const data = await response.json();
  if (data.ok !== true) throw new Error('BUSINESS_REJECTED');
  if (!Array.isArray(data.items)) throw new Error('INVALID_ITEMS');
  return data.items;
}

// 每个搜索组件创建一个实例;patch 应同步、无异常地更新状态。
export function createLatestSearch(load, patch) {
  let version = 0;
  let previous;
  return async function search(query) {
    const mine = ++version;
    previous?.abort();
    const controller = new AbortController();
    previous = controller;
    patch({ loading: true, error: null });
    try {
      const items = await load(query, controller.signal);
      if (mine === version) patch({ items });
    } catch (error) {
      if (mine === version && !controller.signal.aborted) {
        patch({ error: error.message });
      }
    } finally {
      if (mine === version) patch({ loading: false });
    }
  };
}

ajax-tests.mjs

import { getItems, createLatestSearch } from './ajax-core.mjs';

const assert = (value, message) => { if (!value) throw new Error(message); };
const deferred = () => {
  let resolve, reject;
  const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
  return { promise, resolve, reject };
};
async function rejects(work, check) {
  try { await work(); } catch (error) {
    assert(check(error), `Unexpected error: ${error}`);
    return;
  }
  throw new Error('Expected rejection');
}

export async function runTests(base, log) {
  const pass = message => log(`PASS ${message}`);
  const response = await fetch(`${base}/http500`);
  assert(response.status === 500 && !response.ok, 'status');
  await response.text();
  await rejects(() => getItems(`${base}/http500`), e => e.message === 'HTTP 500');
  pass('HTTP 500 resolves fetch; application checks response.ok');

  await rejects(() => getItems(`${base}/badjson`), e => e.name === 'SyntaxError');
  pass('HTTP 200 can still fail JSON parsing');
  await rejects(() => getItems(`${base}/business`), e => e.message === 'BUSINESS_REJECTED');
  pass('valid JSON can still report a business failure');
  await rejects(() => getItems(`${base}/shape`), e => e.message === 'INVALID_ITEMS');
  pass('success flag does not validate the items field');
  assert((await getItems(`${base}/ok`))[0] === 'ajax', 'items');
  pass('successful response produces expected items');

  const controller = new AbortController();
  const partial = await fetch(`${base}/partial`, { signal: controller.signal });
  assert(partial.status === 200, 'headers received');
  controller.abort();
  await rejects(() => partial.json(), e => e.name === 'AbortError');
  pass('abort after headers rejects unfinished body reading');

  const old = deferred(), fresh = deferred();
  let naive;
  const oldJob = old.promise.then(value => { naive = value; });
  const newJob = fresh.promise.then(value => { naive = value; });
  fresh.resolve('new'); await newJob;
  old.resolve('old'); await oldJob;
  assert(naive === 'old', 'reproduce stale overwrite');
  pass('controlled reverse completion reproduces stale overwrite');

  for (const failure of [false, true]) {
    const a = deferred(), b = deferred();
    const state = { items: [], error: null, loading: false };
    let oldSignal;
    // 故意不响应取消,模拟已经进入其他异步处理的旧任务。
    const search = createLatestSearch((q, signal) => {
      if (q === 'old') oldSignal = signal;
      return q === 'old' ? a.promise : b.promise;
    }, update => Object.assign(state, update));
    const pa = search('old'), pb = search('new');
    assert(oldSignal.aborted, 'old cancellation requested');
    if (failure) a.reject(new Error('old failure')); else a.resolve(['old']);
    await pa;
    assert(state.loading && state.error === null && state.items.length === 0,
      'stale completion must not update items/error/loading');
    b.resolve(['new']); await pb;
    assert(!state.loading && state.items[0] === 'new' && !state.error, 'new result');
    pass(`version guard ignores stale ${failure ? 'failure' : 'success'} and finally`);
  }
  log('9 checks passed: 6 local HTTP checks + 3 controlled Promise checks');
}

ajax-lab.html

<!doctype html><meta charset="utf-8"><title>Ajax 本地实验</title>
<style>body{font:17px/1.6 system-ui;margin:40px;max-width:1100px}button{padding:12px 18px}pre{white-space:pre-wrap;background:#f4f6f8;padding:20px}h1{font-size:28px}</style>
<h1>Ajax:HTTP、取消与结果乱序</h1>
<p>仅访问本机同源接口。六项 HTTP 检查与三项受控 Promise 检查,不测试跨域或真实网站。</p>
<button id="run">运行 9 项检查</button><pre id="output">等待运行</pre>
<script type="module">
import { runTests } from './ajax-tests.mjs';
const button = document.querySelector('#run'), output = document.querySelector('#output');
button.onclick = async () => {
  button.disabled = true; output.textContent = '';
  try { await runTests(location.origin, line => { output.textContent += line + '\n'; }); }
  catch (error) { output.textContent += `FAIL ${error.stack}`; }
  finally { button.disabled = false; }
};
</script>

ajax-server.mjs

import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
const files = new Map([
  ['/', ['ajax-lab.html', 'text/html; charset=utf-8']],
  ['/ajax-tests.mjs', ['ajax-tests.mjs', 'text/javascript']],
  ['/ajax-core.mjs', ['ajax-core.mjs', 'text/javascript']],
]);
const server = createServer(async (req, res) => {
  const path = new URL(req.url, 'http://localhost').pathname;
  res.setHeader('Cache-Control', 'no-store');
  if (files.has(path)) {
    const [file, type] = files.get(path);
    res.setHeader('Content-Type', type);
    res.end(await readFile(new URL(file, import.meta.url)));
    return;
  }
  res.setHeader('Content-Type', 'application/json');
  if (path === '/partial') {
    res.write('{"ok":');
    const timer = setTimeout(() => res.destroy(), 5000);
    res.once('close', () => clearTimeout(timer));
    return;
  }
  const bodies = {
    '/http500': '{"ok":false}', '/badjson': '{broken',
    '/business': '{"ok":false}', '/shape': '{"ok":true,"items":null}',
    '/ok': '{"ok":true,"items":["ajax"]}',
  };
  res.statusCode = path === '/http500' ? 500 : path in bodies ? 200 : 404;
  res.end(bodies[path] ?? '{"ok":false}');
});
server.listen(0, '127.0.0.1', () => {
  console.log(`Ajax lab: http://127.0.0.1:${server.address().port}`);
});

本次浏览器输出

前六项访问本机同源 HTTP 接口;后三项是浏览器中的受控 Promise 检查。后三项不代表测量了真实网络的乱序概率。

PASS HTTP 500 resolves fetch; application checks response.ok
PASS HTTP 200 can still fail JSON parsing
PASS valid JSON can still report a business failure
PASS success flag does not validate the items field
PASS successful response produces expected items
PASS abort after headers rejects unfinished body reading
PASS controlled reverse completion reproduces stale overwrite
PASS version guard ignores stale success and finally
PASS version guard ignores stale failure and finally
9 checks passed: 6 local HTTP checks + 3 controlled Promise checks

无保护的例子明确让 new 先完成、old 后完成,最终变量变回 old。带版本保护的两项则分别让旧任务成功和失败,并在新任务仍等待时检查:items 不变、error 仍为空、loading 仍为 true。新任务完成后才展示 new 并停止 loading。

接到真实搜索框前还要做的事

每个独立搜索组件应持有自己的 createLatestSearch 实例,不能让所有组件共享一个版本号。patch 在本例中同步更新对象;接框架时要保证状态更新方式符合框架约定,也不要让渲染异常混进请求错误。

如果再加防抖,要确定版本代表“最新输入”还是“最后一次真正发出的请求”。本文没有防抖,每次 search 调用就代表一个新意图。若用户已经输入新条件、但防抖计时尚未结束,应在输入变化时就使旧任务失效,否则这段间隔仍可能短暂显示旧结果。空输入和组件卸载也要有明确的失效与清理策略,这份实验没有实现完整组件生命周期。

排查时可在开发者工具 Network 中观察对应请求的参数、状态与响应内容,再对照页面当前输入。若只看最后一个 200,很容易漏掉响应属于旧条件的问题。跨域读不到响应属于另一组配置问题;不要把 no-cors 当作读取任意跨域 JSON 的修复方案。本文全部同源,不将这些文档边界写成已测试结论。

参考资料与说明

本文由 AI 辅助组织与撰写,代码已在本机浏览器实际运行并记录输出。示例是范围明确的教学实验,不是生产系统效果或收益证明。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值