编码母题 / P0 / 前端与专项架构 / 04

只提交最新请求的资源加载器

先读清题意与边界,独立尝试,再展开思路并对照可运行的参考代码。

重要程度必练难度中等预计完成30–40 分钟

难度按 LeetCode 简单/中等/困难三档估计;工程题并非 LeetCode 官方题目。用时指独立编码并检查主要边界的参考时间,因经验而异。

题目是什么

参数变化时启动新请求并取消旧请求。旧请求即使不响应取消而晚到,也不能覆盖界面;手动 cancel 后同样不得提交。

输入输出示例

搜索词从 a 变成 ab,a 的响应晚于 ab 到达时,页面仍只显示 ab 的结果。

练习起手式与验证

复制代码后补全空方法,再运行下方三组验证;复杂题所需的辅助类型与输入准备一并放在代码块中。

/*
 * 题目:只提交最新请求的资源加载器
 * 要求:参数变化时启动新请求并取消旧请求。旧请求即使不响应取消而晚到,也不能覆盖界面;手动
 * cancel 后同样不得提交。
 * 示例:搜索词从 a 变成 ab,a 的响应晚于 ab 到达时,页面仍只显示 ab 的结果。
 */
class LatestRequest {
  constructor(loader) {}
  cancel() {}
  async run(input, onSuccess) {}
}

(async () => {
  const pending = new Map();
  const signals = new Map();
  const committed = [];
  const latest = new LatestRequest((key, { signal }) => {
    signals.set(key, signal);
    return new Promise((resolve, reject) => pending.set(key, { resolve, reject }));
  });
  const first = latest.run("old", (value) => committed.push(value));
  const second = latest.run("new", (value) => committed.push(value));
  console.log(signals.get("old").aborted); // 预期:true
  pending.get("new").resolve("new result");
  console.log(await second); // 预期:{ status: "committed", value: "new result" }
  pending.get("old").resolve("old result");
  console.log(await first); // 预期:{ status: "stale" }
})().catch(console.error);

解题思路

展开思路、不变量与常见边界

关键切入点:AbortSignal 尽力停止底层工作;递增版本号才是晚到结果能否提交的最终判断。

核心模型:每轮分配递增版本号。AbortSignal 尽力停止底层工作,版本号决定 结果是否仍有提交资格;两者不能互相替代,因为 loader 可能忽略取消信号。 不变量:只有当前版本且未取消的成功结果调用 onSuccess;过期成功或失败均 返回 {status:"stale"};当前失败向调用方抛出。每次请求 O(1) 额外状态。 迁移:搜索建议、路由切换、分页筛选和组件卸载。缓存与请求去重是独立语义。

参考代码与验证

“关键机制”注释用于解释实现,下载链接提供未经页面注释修改的原始 JS 文件。

// 关键机制:AbortSignal 尽力停止底层工作;递增版本号才是晚到结果能否提交的最终判断。
class LatestRequest {
  constructor(loader) {
    if (typeof loader !== "function") throw new TypeError("loader must be a function");
    this.loader = loader;
    this.version = 0;
    this.controller = null;
  }

  cancel() {
    this.version += 1;
    this.controller?.abort();
    this.controller = null;
  }

  async run(input, onSuccess) {
    if (typeof onSuccess !== "function")
      throw new TypeError("onSuccess must be a function");
    this.cancel();
    const version = this.version;
    const controller = new AbortController();
    this.controller = controller;
    try {
      const value = await this.loader(input, { signal: controller.signal });
      if (version !== this.version || controller.signal.aborted)
        return { status: "stale" };
      onSuccess(value);
      return { status: "committed", value };
    } catch (error) {
      if (version !== this.version || controller.signal.aborted)
        return { status: "stale" };
      throw error;
    } finally {
      if (this.controller === controller) this.controller = null;
    }
  }
}

// 三组以上可直接运行的验证;异步题使用独立执行块。
(async () => {
  const pending = new Map();
  const signals = new Map();
  const committed = [];
  const latest = new LatestRequest((key, { signal }) => {
    signals.set(key, signal);
    return new Promise((resolve, reject) => pending.set(key, { resolve, reject }));
  });
  const first = latest.run("old", (value) => committed.push(value));
  const second = latest.run("new", (value) => committed.push(value));
  console.log(signals.get("old").aborted); // 预期:true
  pending.get("new").resolve("new result");
  console.log(await second); // 预期:{ status: "committed", value: "new result" }
  pending.get("old").resolve("old result");
  console.log(await first); // 预期:{ status: "stale" }
  console.log(committed); // 预期:["new result"]

  const cancelled = latest.run("cancel", (value) => committed.push(value));
  latest.cancel();
  pending.get("cancel").reject(new Error("aborted"));
  console.log(await cancelled); // 预期:{ status: "stale" }
  console.log(committed); // 预期:["new result"]

  const failure = latest.run("fail", () => {});
  pending.get("fail").reject(new Error("current failure"));
  console.log(await Promise.resolve(failure).then(() => false, () => true)); // 预期:true(拒绝)


})().catch(console.error);

复杂度分析

时间复杂度:O(1)/请求,不计网络。每次启动只更新版本号、取消旧控制器并登记回调。

空间复杂度:O(1) 内部状态。实例只保留当前版本和当前控制器;旧 Promise 由调用方持有。

下载原始 JS 文件