如何优雅地解决请求覆盖
May 07, 2022
前言
在 React 框架中,我们尝尝会使用 useEffect 来重复发送请求,当遇到请求覆盖时,又该如何优雅地解决呢?
场景复现
假设我们正在开发一个 Todo 系统,它具备如下功能:
-
默认展示 id=1 Todo 内容。
-
用户能根据 id 进行搜索,系统会保留搜索记录,当页面发生刷新,会展示上一次搜索的 Todo 内容。
根据以上场景,复现出简化版代码:
js
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 发生改变,都会执行 fetchTodouseEffect(() => {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;
js
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 发生改变,都会执行 fetchTodouseEffect(() => {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,也有可能会发生请求覆盖,例如第一个请求由于不可抗力响应特别慢。
js
// 从 sessionStorage 中读取历史搜索记录useEffect(() => {const id = sessionStorage.getItem("id");setTimeout(() => {id && setId(parseInt(id));}, 500);}, []);
js
// 从 sessionStorage 中读取历史搜索记录useEffect(() => {const id = sessionStorage.getItem("id");setTimeout(() => {id && setId(parseInt(id));}, 500);}, []);
我们需要一种优雅且高效的解决方案。
AbortController
好在 Web API 提供了 AbortController
,我在之前的文章 使用 AbortController 取消 Fetch 请求和事件监听 有过介绍,它能:
- 取消 fetch 请求
- 取消事件监听
- 取消定时器
因此正确的做法是当请求历史搜索记录 Todo=2 时,取消上一次请求 Todo=1,具体优化代码如下:
js
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 发生改变,都会执行 fetchTodouseEffect(() => {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>);}
js
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 发生改变,都会执行 fetchTodouseEffect(() => {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 中那样使用。
js
const controller = new AbortController();axios.get("/foo/bar", {signal: controller.signal,}).then(function (response) {//...});// cancel the requestcontroller.abort();
js
const controller = new AbortController();axios.get("/foo/bar", {signal: controller.signal,}).then(function (response) {//...});// cancel the requestcontroller.abort();
官方文档参考: