Stale-while-revalidate 使用 React Hooks 获取数据:指南

已发表: 2022-03-11

利用 stale-while-revalidate HTTP Cache-Control扩展是一种流行的技术。 它涉及使用缓存(陈旧)资产(如果在缓存中找到),然后重新验证缓存并在需要时使用更新版本的资产更新它。 因此名称stale-while-revalidate

stale-while-revalidate工作原理

当第一次发送请求时,它会被浏览器缓存。 然后,当第二次发送相同的请求时,首先检查缓存。 如果该请求的缓存可用且有效,则缓存将作为响应返回。 然后,检查缓存是否陈旧,如果发现陈旧则更新。 缓存的陈旧性由Cache-Control标头中存在的max-age值以及stale-while-revalidate确定。

流程图跟踪过时的重新验证逻辑。它从一个请求开始。如果没有缓存,或者缓存无效,则发送请求,返回响应,并更新缓存。否则,返回缓存的响应,然后检查缓存是否过时。如果它是陈旧的,则发送一个请求并更新缓存。

这允许快速页面加载,因为缓存的资产不再位于关键路径中。 它们会立即加载。 此外,由于开发人员控制缓存的使用和更新频率,他们可以防止浏览器向用户显示过时的数据。

读者可能会想,如果他们可以让服务器在其响应中使用某些标头并让浏览器从那里获取它,那么使用 React 和 Hooks 进行缓存有什么需要呢?

事实证明,只有当我们想要缓存静态内容时,服务器和浏览器的方法才有效。 对动态 API 使用stale-while-revalidate怎么样? 在这种情况下,很难为max-agestale-while-revalidate提供好的值。 通常,每次发送请求时使缓存无效并获取新响应将是最佳选择。 这实际上意味着根本没有缓存。 但是使用 React 和 Hooks,我们可以做得更好。

API stale-while-revalidate

我们注意到 HTTP 的stale-while-revalidate不适用于 API 调用等动态请求。

即使我们最终使用它,浏览器也会返回缓存或新响应,而不是两者。 这不适用于 API 请求,因为我们希望每次发送请求时都有新的响应。 但是,等待新的响应会延迟应用程序的有意义的可用性。

那么我们该怎么办?

我们实现了自定义缓存机制。 在其中,我们找到了一种同时返回缓存和新响应的方法。 在 UI 中,缓存的响应在可用时将替换为新的响应。 这就是逻辑的样子:

  1. 当请求第一次发送到 API 服务器端点时,缓存响应然后返回。
  2. 下次发生相同的 API 请求时,立即使用缓存的响应。
  3. 然后,异步发送请求以获取新响应。 当响应到达时,异步将更改传播到 UI 并更新缓存。

这种方法允许即时的 UI 更新——因为每个 API 请求都被缓存了——但也允许 UI 中的最终正确性,因为新的响应数据会在可用时立即显示。

在本教程中,我们将逐步了解如何实现这一点。 我们将这种方法称为stale-while-refresh ,因为当 UI 获得新的响应时,它实际上会被刷新

准备工作:API

要启动本教程,我们首先需要一个从中获取数据的 API。 幸运的是,有大量可用的模拟 API 服务。 对于本教程,我们将使用 reqres.in。

我们获取的数据是带有page查询参数的用户列表。 这是获取代码的样子:

 fetch("https://reqres.in/api/users?page=2") .then(res => res.json()) .then(json => { console.log(json); });

运行此代码为我们提供以下输出。 这是它的非重复版本:

 { page: 2, per_page: 6, total: 12, total_pages: 2, data: [ { id: 7, email: "[email protected]", first_name: "Michael", last_name: "Lawson", avatar: "https://s3.amazonaws.com/uifaces/faces/twitter/follettkyle/128.jpg" }, // 5 more items ] }

您可以看到这就像一个真正的 API。 我们在响应中有分页。 page查询参数负责改变页面,我们数据集中一共有两个页面。

在 React 应用程序中使用 API

让我们看看我们如何在 React App 中使用 API。 一旦我们知道该怎么做,我们就会弄清楚缓存部分。 我们将使用一个类来创建我们的组件。 这是代码:

 import React from "react"; import PropTypes from "prop-types"; export default class Component extends React.Component { state = { users: [] }; componentDidMount() { this.load(); } load() { fetch(`https://reqres.in/api/users?page=${this.props.page}`) .then(res => res.json()) .then(json => { this.setState({ users: json.data }); }); } componentDidUpdate(prevProps) { if (prevProps.page !== this.props.page) { this.load(); } } render() { const users = this.state.users.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{users}</div>; } } Component.propTypes = { page: PropTypes.number.isRequired };

请注意,我们通过props获取page值,因为它经常发生在现实世界的应用程序中。 此外,我们还有一个componentDidUpdate函数,它会在每次this.props.page更改时重新获取 API 数据。

此时,它显示了一个包含六个用户的列表,因为 API 每页返回六个项目:

我们的 React 组件原型预览:六条居中的线,每条线的名称左侧都有一张照片。

添加 Stale-while-refresh 缓存

如果我们想为此添加 stale-while-refresh 缓存,我们需要将我们的应用程序逻辑更新为:

  1. 首次获取请求的响应后,对其进行唯一的缓存。
  2. 如果找到请求的缓存,则立即返回缓存的响应。 然后,发送请求并异步返回新的响应。 此外,缓存此响应以备下次使用。

我们可以通过拥有一个唯一存储缓存的全局CACHE对象来做到这一点。 为了唯一性,我们可以使用this.props.page值作为CACHE对象中的键。 然后,我们简单地编写上面提到的算法。

 import apiFetch from "./apiFetch"; const CACHE = {}; export default class Component extends React.Component { state = { users: [] }; componentDidMount() { this.load(); } load() { if (CACHE[this.props.page] !== undefined) { this.setState({ users: CACHE[this.props.page] }); } apiFetch(`https://reqres.in/api/users?page=${this.props.page}`).then( json => { CACHE[this.props.page] = json.data; this.setState({ users: json.data }); } ); } componentDidUpdate(prevProps) { if (prevProps.page !== this.props.page) { this.load(); } } render() { // same render code as above } }

由于缓存一找到就会返回,并且setState也返回了新的响应数据,这意味着我们可以无缝更新 UI,并且从第二个请求开始,应用程序不再需要等待时间。 这是完美的,简而言之,它是 stale-while-refresh 方法。

跟踪过时刷新逻辑的流程图。它从一个请求开始。如果已缓存,则使用缓存的响应调用 setState()。无论哪种方式,都会发送请求,设置缓存,并使用新的响应调用 setState()。

这里的apiFetch函数只不过是对fetch的一个包装器,因此我们可以看到实时缓存的优势。 它通过向 API 请求返回的users列表中添加一个随机用户来实现此目的。 它还增加了一个随机延迟:

 export default async function apiFetch(...args) { await delay(Math.ceil(400 + Math.random() * 300)); const res = await fetch(...args); const json = await res.json(); json.data.push(getFakeUser()); return json; } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

这里的getFakeUser()函数负责创建一个假用户对象。

通过这些更改,我们的 API 比以前更加真实。

  1. 它有一个随机的响应延迟。
  2. 它为相同的请求返回略有不同的数据。

鉴于此,当我们更改从主组件传递给Componentpage道具时,我们可以看到 API 缓存在起作用。 尝试在此 CodeSandbox 中每隔几秒单击一次Toggle按钮,您应该会看到如下行为:

显示启用缓存的切换页面的动画。具体内容在文章中有描述。

如果你仔细观察,会发生一些事情。

  1. 当应用程序启动并处于默认状态时,我们会看到一个包含七个用户的列表。 记下列表中的最后一个用户,因为下次发送此请求时将随机修改该用户。
  2. 当我们第一次单击 Toggle 时,它​​会等待一小段时间(400-700 毫秒),然后将列表更新到下一页。
  3. 现在,我们在第二页。 再次记下列表中的最后一个用户。
  4. 现在,我们再次单击 Toggle,应用程序将返回第一页。 请注意,现在最后一个条目仍然是我们在步骤 1 中记下的同一用户,然后它稍后会更改为新的(随机)用户。 这是因为,最初,缓存被显示,然后实际响应开始了。
  5. 我们再次点击 Toggle。 同样的现象也会发生。 上次缓存的响应会立即加载,然后获取新数据,因此我们可以看到最后一个条目的更新,这是我们在步骤 3 中记下的内容。

这就是我们正在寻找的 stale-while-refresh 缓存。 但是这种方法存在代码重复问题。 让我们看看如果我们有另一个带缓存的数据获取组件会怎样。 该组件显示的项目与我们的第一个组件不同。

将 Stale-while-refresh 添加到另一个组件

我们可以通过简单地从第一个组件中复制逻辑来做到这一点。 我们的第二个组件显示了一个猫列表:

 const CACHE = {}; export default class Component2 extends React.Component { state = { cats: [] }; componentDidMount() { this.load(); } load() { if (CACHE[this.props.page] !== undefined) { this.setState({ cats: CACHE[this.props.page] }); } apiFetch(`https://reqres.in/api/cats?page=${this.props.page}`).then( json => { CACHE[this.props.page] = json.data; this.setState({ cats: json.data }); } ); } componentDidUpdate(prevProps) { if (prevProps.page !== this.props.page) { this.load(); } } render() { const cats = this.state.cats.map(cat => ( <p key={cat.id} style={{ background: cat.color, padding: "4px", width: 240 }} > {cat.name} (born {cat.year}) </p> )); return <div>{cats}</div>; } }

如您所见,这里涉及的组件逻辑与第一个组件几乎相同。 唯一的区别在于请求的端点,它以不同的方式显示列表项。

现在,我们并排显示这两个组件。 您可以看到它们的行为相似:

显示两个并排组件切换的动画。

为了达到这个结果,我们不得不做大量的代码重复。 如果我们有多个这样的组件,我们将复制太多代码。

为了以不重复的方式解决它,我们可以使用一个高阶组件来获取和缓存数据并将其作为 props 传递。 这并不理想,但它会起作用。 但是如果我们必须在单个组件中执行多个请求,那么拥有多个高阶组件会很快变得丑陋。

然后,我们有了 render props 模式,这可能是在类组件中执行此操作的最佳方式。 它工作得很好,但话又说回来,它很容易出现“包装地狱”,并且有时需要我们绑定当前上下文。 这不是很好的开发人员体验,可能会导致挫败感和错误。

这就是 React Hooks 拯救世界的地方。 它们允许我们将组件逻辑封装在一个可重用的容器中,以便我们可以在多个地方使用它。 React Hooks 是在 React 16.8 中引入的,它们仅适用于函数组件。 在我们了解 React 缓存控制——特别是使用 Hooks 缓存内容之前——让我们先看看我们如何在函数组件中进行简单的数据获取。

函数组件中的 API 数据获取

为了在函数组件中获取 API 数据,我们使用useStateuseEffect挂钩。

useState类似于类组件的statesetState 。 我们使用这个钩子在函数组件中拥有状态的原子容器。

useEffect是一个生命周期钩子,您可以将其视为componentDidMountcomponentDidUpdatecomponentWillUnmount的组合。 传递给useEffect的第二个参数称为依赖数组。 当依赖数组发生变化时,作为第一个参数传递给useEffect的回调将再次运行。

以下是我们将如何使用这些钩子来实现数据获取:

 import React, { useState, useEffect } from "react"; export default function Component({ page }) { const [users, setUsers] = useState([]); useEffect(() => { fetch(`https://reqres.in/api/users?page=${page}`) .then(res => res.json()) .then(json => { setUsers(json.data); }); }, [page]); const usersDOM = users.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{usersDOM}</div>; }

通过将page指定为useEffect的依赖项,我们指示 React 在每次page更改时运行我们的 useEffect 回调。 这就像componentDidUpdate一样。 此外, useEffect总是第一次运行,所以它也像componentDidMount一样工作。

函数组件中的 Stale-while-refresh

我们知道useEffect类似于组件生命周期方法。 因此,我们可以修改传递给它的回调函数,以创建我们在类组件中拥有的 stale-while-refresh 缓存。 除了useEffect钩子外,一切都保持不变。

 const CACHE = {}; export default function Component({ page }) { const [users, setUsers] = useState([]); useEffect(() => { if (CACHE[page] !== undefined) { setUsers(CACHE[page]); } apiFetch(`https://reqres.in/api/users?page=${page}`).then(json => { CACHE[page] = json.data; setUsers(json.data); }); }, [page]); // ... create usersDOM from users return <div>{usersDOM}</div>; }

因此,我们在函数组件中使用了 stale-while-refresh 缓存。

我们可以对第二个组件做同样的事情,即将其转换为函数并实现 stale-while-refresh 缓存。 结果将与我们在课堂上的结果相同。

但这并不比类组件好,不是吗? 因此,让我们看看如何使用自定义钩子的强大功能来创建可以跨多个组件使用的模块化 stale-while-refresh 逻辑。

一个自定义的 Stale-while-refresh Hook

首先,让我们缩小要移动到自定义钩子中的逻辑。 如果你看前面的代码,你就知道它是useStateuseEffect部分。 更具体地说,这是我们想要模块化的逻辑。

 const [users, setUsers] = useState([]); useEffect(() => { if (CACHE[page] !== undefined) { setUsers(CACHE[page]); } apiFetch(`https://reqres.in/api/users?page=${page}`).then(json => { CACHE[page] = json.data; setUsers(json.data); }); }, [page]);

由于我们必须使其通用,我们必须使 URL 动态化。 所以我们需要有url作为参数。 我们还需要更新缓存逻辑,因为多个请求可以具有相同的page值。 幸运的是,当page包含在端点 URL 中时,它会为每个唯一请求生成一个唯一值。 所以我们可以只使用整个 URL 作为缓存的键:

 const [data, setData] = useState([]); useEffect(() => { if (CACHE[url] !== undefined) { setData(CACHE[url]); } apiFetch(url).then(json => { CACHE[url] = json.data; setData(json.data); }); }, [url]);

差不多就是这样。 将它包装在一个函数中之后,我们将拥有我们的自定义钩子。 看看下面。

 const CACHE = {}; export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); } // fetch new data apiFetch(url).then(newData => { CACHE[cacheID] = newData.data; setData(newData.data); }); }, [url]); return data; }

请注意,我们添加了另一个名为defaultValue的参数。 如果您在多个组件中使用此挂钩,则 API 调用的默认值可能不同。 这就是为什么我们让它可定制的原因。

newData对象中的data键也可以这样做。 如果您的自定义挂钩返回各种数据,您可能只想返回newData而不是newData.data并在组件端处理该遍历。

现在我们有了自定义钩子,它完成了刷新时过时缓存的繁重工作,下面是我们如何将它插入到我们的组件中。 请注意我们能够减少的大量代码。 我们的整个组件现在只有三个语句。 这是一个很大的胜利。

 import useStaleRefresh from "./useStaleRefresh"; export default function Component({ page }) { const users = useStaleRefresh(`https://reqres.in/api/users?page=${page}`, []); const usersDOM = users.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{usersDOM}</div>; }

我们可以对第二个组件做同样的事情。 它看起来像这样:

 export default function Component2({ page }) { const cats = useStaleRefresh(`https://reqres.in/api/cats?page=${page}`, []); // ... create catsDOM from cats return <div>{catsDOM}</div>; }

如果我们使用这个钩子,很容易看出我们可以节省多少样板代码。 代码看起来也更好。 如果您想查看整个应用程序的运行情况,请前往此 CodeSandbox。

添加加载指示器以useStaleRefresh

现在我们已经掌握了基础知识,我们可以向自定义钩子添加更多功能。 例如,我们可以在钩子中添加一个isLoading值,该值在发送唯一请求时为真,同时我们没有任何缓存可显示。

我们通过为isLoading设置一个单独的状态并根据钩子的状态设置它来做到这一点。 也就是说,当没有缓存的 Web 内容可用时,我们将其设置为true ,否则我们将其设置为false

这是更新的钩子:

 export default function useStaleRefresh(url, defaultValue = []) { const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // cacheID is how a cache is identified against a unique request const cacheID = url; // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); } // fetch new data apiFetch(url).then(newData => { CACHE[cacheID] = newData.data; setData(newData.data); setLoading(false); }); }, [url]); return [data, isLoading]; }

我们现在可以在我们的组件中使用新的isLoading值。

 export default function Component({ page }) { const [users, isLoading] = useStaleRefresh( `https://reqres.in/api/users?page=${page}`, [] ); if (isLoading) { return <div>Loading</div>; } // ... create usersDOM from users return <div>{usersDOM}</div>; }

请注意,完成此操作后,当第一次发送唯一请求并且不存在缓存时,您会看到“正在加载”文本。

显示已实现加载指示器的组件的动画。

使useStaleRefresh支持任何async函数

我们可以通过使其支持任何async功能而不仅仅是GET网络请求,从而使我们的自定义钩子更加强大。 它背后的基本思想将保持不变。

  1. 在钩子中,您调用一个异步函数,该函数在一段时间后返回一个值。
  2. 对异步函数的每个唯一调用都被正确缓存。

function.namearguments的简单连接将用作我们用例的缓存键。 使用它,这就是我们的钩子的外观:

 import { useState, useEffect, useRef } from "react"; import isEqual from "lodash/isEqual"; const CACHE = {}; export default function useStaleRefresh(fn, args, defaultValue = []) { const prevArgs = useRef(null); const [data, setData] = useState(defaultValue); const [isLoading, setLoading] = useState(true); useEffect(() => { // args is an object so deep compare to rule out false changes if (isEqual(args, prevArgs.current)) { return; } // cacheID is how a cache is identified against a unique request const cacheID = hashArgs(fn.name, ...args); // look in cache and set response if present if (CACHE[cacheID] !== undefined) { setData(CACHE[cacheID]); setLoading(false); } else { // else make sure loading set to true setLoading(true); } // fetch new data fn(...args).then(newData => { CACHE[cacheID] = newData; setData(newData); setLoading(false); }); }, [args, fn]); useEffect(() => { prevArgs.current = args; }); return [data, isLoading]; } function hashArgs(...args) { return args.reduce((acc, arg) => stringify(arg) + ":" + acc, ""); } function stringify(val) { return typeof val === "object" ? JSON.stringify(val) : String(val); }

如您所见,我们使用函数名称及其字符串化参数的组合来唯一标识函数调用并因此对其进行缓存。 这适用于我们的简单应用程序,但这种算法容易发生冲突和比较缓慢。 (使用不可序列化的参数,它根本不起作用。)因此对于现实世界的应用程序,适当的散列算法更合适。

这里要注意的另一件事是useRef的使用。 useRef用于在封闭组件的整个生命周期中持久化数据。 由于args是一个数组——它是 JavaScript 中的一个对象——每次使用钩子重新渲染组件都会导致args引用指针发生变化。 但是args是我们第一个useEffect中依赖列表的一部分。 所以args的改变可以让我们的useEffect运行,即使没有任何改变。 为了解决这个问题,我们使用 isEqual 对旧的和当前的args进行了深入比较,并且只有在args实际更改时才让useEffect回调运行。

现在,我们可以使用这个新的useStaleRefresh钩子,如下所示。 注意这里defaultValue的变化。 由于它是一个通用的钩子,我们不依赖于我们的钩子来返回响应对象中的data键。

 export default function Component({ page }) { const [users, isLoading] = useStaleRefresh( apiFetch, [`https://reqres.in/api/users?page=${page}`], { data: [] } ); if (isLoading) { return <div>Loading</div>; } const usersDOM = users.data.map(user => ( <p key={user.id}> <img src={user.avatar} alt={user.first_name} style={{ height: 24, width: 24 }} /> {user.first_name} {user.last_name} </p> )); return <div>{usersDOM}</div>; }

您可以在此 CodeSandbox 中找到整个代码。

不要让用户等待:通过 Stale-while-refresh 和 React Hooks 有效地使用缓存内容

我们在本文中创建的useStaleRefresh钩子是一个概念证明,它展示了 React Hooks 的可能性。 尝试使用代码,看看您是否可以将其放入您的应用程序中。

或者,您也可以尝试通过流行的、维护良好的开源库(如 swr 或 react-query)来利用 stale-while-refresh。 两者都是强大的库,并支持许多有助于 API 请求的功能。

React Hooks 改变了游戏规则。 它们允许我们优雅地共享组件逻辑。 这在以前是不可能的,因为组件状态、生命周期方法和渲染都被打包到一个实体中:类组件。 现在,我们可以为所有这些设置不同的模块。 这对于可组合性和编写更好的代码非常有用。 我正在为我编写的所有新 React 代码使用函数组件和钩子,我强烈推荐给所有 React 开发人员。

相关:使用 Redux Toolkit 和 RTK Query 创建 React 应用程序