如何优雅地解决请求覆盖

1,225 阅读3分钟

前言

在 React 框架中,我们尝尝会使用 useEffect 来重复发送请求,当遇到请求覆盖时,又该如何优雅地解决呢?

场景复现

假设我们正在开发一个 Todo 系统,它具备如下功能:

  1. 默认展示 id=1 Todo 内容。

  2. 用户能根据 id 进行搜索,系统会保留搜索记录,当页面发生刷新,会展示上一次搜索的 Todo 内容。

根据以上场景,复现出简化版代码:

import { useCallback, useEffect, useState } from "react";

function App() {
  const [id, setId] = useState(1);
  const [content, setContent] = useState("");
  const [loading, setLoading] = useState(false);

  const searchIdNo2 = () => sessionStorage.setItem("id", "2");

  const clearHistory = () => sessionStorage.clear();

  const fetchTodo = useCallback(async () => {
    // API 服务来自 http://jsonplaceholder.typicode.com/

    try {
      setLoading(true);

      const json = await (
        await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)
      ).json();

      setContent(JSON.stringify(json, undefined, 2));

      console.log(`id = ${id} Todo:${JSON.stringify(json, undefined, 2)}`);
    } catch (error) {
      console.log(error);
    } finally {
      setLoading(false);
    }
  }, [id]);

  // 从 sessionStorage 中读取历史搜索记录
  useEffect(() => {
    const id = sessionStorage.getItem("id");
    if (id) {
      setId(parseInt(id));
    }
  }, []);

  // 默认执行一次 fetchTodo,此后每当 id 发生改变,都会执行 fetchTodo
  useEffect(() => {
    fetchTodo();
  }, [fetchTodo]);

  return (
    <div style={{ margin: 20 }}>
      <button onClick={searchIdNo2}>模拟搜索过 id=2 Todo 的行为</button>
      <button onClick={clearHistory}>清空搜索记录</button>
      {loading ? (
        <p>Loading...</p>
      ) : (
        <>
          <p>{`id = ${id} Todo:`}</p>
          <pre>{content}</pre>
        </>
      )}
    </div>
  );
}

export default App;

我们点击 模拟搜索过 id=2 Todo 的行为 的按钮后,再刷新页面,预期是得到 id=2 的 Todo.

但重复刷新页面多次,会偶现得到 id=1 Todo 的结果,这显然是一个 Bug,后一次的请求被前一次覆盖了。

事出必有因,快速地定位问题是一名专业程序员所要必备的能力。

首先我们去分析代码的执行顺序:

结论是 fetchTodo 2 调用顺序在 fetchTodo 1 之后,既然代码逻辑没有漏洞,又涉及网络请求,只得从网络层面入手。

我们打开 Chrome DevTool 的 Network Panel,过滤类型选择 Fetch/XHR,对比两个请求从发出到返回的耗时:

由于 V8 引擎的加持,JavaScript 的代码执行速度很快,两个请求近乎同时发出,但由于网络和 Server/DB 原因,返回的时间是不同的

例如图示的情况:

  • Todo 1 耗时 301.78ms.
  • Todo 2 耗时 295.41ms.

Todo 1 耗时比 Todo 2 多,返回得慢,自然覆盖了 Todo 2 的结果,即 请求覆盖

有一种比较低级的解决方案是认为延迟请求的时间,但请求延迟会给用户带来卡顿、内容闪烁的负向体验,而且延时的量始终无法精准确定,在 1% 的场景下,即使你设置了 1000ms、2000ms,也有可能会发生请求覆盖,例如第一个请求由于不可抗力响应特别慢。

// 从 sessionStorage 中读取历史搜索记录
useEffect(() => {
  const id = sessionStorage.getItem("id");
  setTimeout(() => {
    id && setId(parseInt(id));
  }, 500);
}, []);

我们需要一种优雅且高效的解决方案。

AbortController

好在 Web API 提供了 AbortController,我在之前的文章 使用 AbortController 取消 Fetch 请求和事件监听 有过介绍,它能:

  • 取消 fetch 请求
  • 取消事件监听
  • 取消定时器

因此正确的做法是当请求历史搜索记录 Todo=2 时,取消上一次请求 Todo=1,具体优化代码如下:

function App() {
  const [id, setId] = useState(1);
  const [content, setContent] = useState("");
  const [loading, setLoading] = useState(false);
  const [controller, setController] = useState(new AbortController());

  const searchIdNo2 = () => sessionStorage.setItem("id", "2");

  const clearHistory = () => sessionStorage.clear();

  const fetchTodo = useCallback(async () => {
    // API 服务来自 http://jsonplaceholder.typicode.com/

    try {
      setLoading(true);

      const json = await (
        await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
          signal: controller.signal,
        })
      ).json();

      setContent(JSON.stringify(json, undefined, 2));

      console.log(`id = ${id} Todo:${JSON.stringify(json, undefined, 2)}`);
    } catch (error) {
      console.log(error);
    } finally {
      setLoading(false);
    }
  }, [id]);

  // 从 sessionStorage 中读取历史搜索记录
  useEffect(() => {
    const id = sessionStorage.getItem("id");
    if (id) {
      setId(parseInt(id));
      setController(new AbortController());
      controller.abort();
    }
  }, []);

  // 默认执行一次 fetchTodo,此后每当 id 发生改变,都会执行 fetchTodo
  useEffect(() => {
    fetchTodo();
  }, [fetchTodo]);

  return (
    <div style={{ margin: 20 }}>
      <button onClick={searchIdNo2}>模拟搜索过 id=2 Todo 的行为</button>
      <button onClick={clearHistory}>清空搜索记录</button>
      {loading ? (
        <p>Loading...</p>
      ) : (
        <>
          <p>{`id = ${id} Todo:`}</p>
          <pre>{content}</pre>
        </>
      )}
    </div>
  );
}

经过优化后,观察 Network 面板不再发起 id=1 Todo 请求,说明第一个请求被取消了。

通常我们维护的项目,axios 依旧是首选的前端请求库而不是原生 fetch.

诚然,前端从不会停下向前的步伐,axios 从 v0.22.0 开始支持 AbortController 取消请求的特性,就像在 fetch API 中那样使用。

const controller = new AbortController();

axios
  .get("/foo/bar", {
    signal: controller.signal,
  })
  .then(function (response) {
    //...
  });
// cancel the request
controller.abort();

官方文档参考: