作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Avi Aryan
Verified Expert in Engineering

Avi是一个精通Python的全栈开发人员, JavaScript, 他也是谷歌代码之夏的多次参与者.

Read More

PREVIOUSLY AT

Google
Share

Leveraging the stale-while-revalidate HTTP Cache-Control 扩展是一种流行的技术. 它涉及到使用缓存(陈旧)资产,如果它们在缓存中被发现, 然后重新验证缓存,并在需要时使用较新的资产版本更新它. Hence the name stale-while-revalidate.

How stale-while-revalidate Works

当第一次发送请求时,它被浏览器缓存. 然后,当第二次发送相同的请求时,首先检查缓存. 如果该请求的缓存可用且有效,则将缓存作为响应返回. 然后,检查缓存是否过期,如果发现过期则更新缓存. The staleness of a cache is determined by the max-age value present in the Cache-Control header along with stale-while-revalidate.

跟踪在重新验证时失效逻辑的流程图. It starts with a request. If it's not cached, 或者缓存无效, the request is sent, the response is returned, and the cache is updated. 否则,返回缓存的响应,然后检查缓存是否过期. 如果过期,则发送请求并更新缓存.

This allows for fast page loads,因为缓存的资源不再在关键路径上. They are loaded instantly. Also, 因为开发人员控制缓存的使用和更新频率, 它们可以防止浏览器向用户显示过于过时的数据.

读者们可能会这么想, 如果他们可以让服务器在其响应中使用特定的标头,并让浏览器从那里获取它, 那还用什么呢 React and Hooks for caching?

事实证明,只有当我们想要缓存静态内容时,服务器-浏览器方法才有效. What about using stale-while-revalidate for a dynamic API? 很难想出一个好的价值 max-age and stale-while-revalidate in that case. Often, 每次发送请求时,使缓存失效并获取新的响应将是最好的选择. 这实际上意味着根本没有缓存. But with React and Hooks, we can do better.

stale-while-revalidate for the API

We noticed that HTTP’s stale-while-revalidate 不能很好地处理像API调用这样的动态请求.

即使我们最终会使用它, 浏览器将返回缓存或新的响应, not both. 这对于API请求来说不太好,因为我们希望每次发送请求时都有新的响应. 然而,等待新的响应会延迟应用的可用性.

So what do we do?

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

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

这种方法允许即时的UI更新(因为每个API请求都被缓存),但也允许UI中的最终正确性,因为新的响应数据一旦可用就会显示出来.

在本教程中,我们将逐步了解如何实现这一点. 我们称这种方法为 stale-while-refresh since the UI is actually refreshed 当它得到新的响应时.

Preparations: The API

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

我们获取的数据是一个带有 page query parameter. 获取代码是这样的:

fetch("http://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: "michael.lawson@reqres.in",
      first_name: "Michael",
      last_name: "Lawson",
      avatar:
        "http://s3.amazonaws.com/uifaces/faces/twitter/follettkyle/128.jpg"
    },
    // 5 more items
  ]
}

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

在React应用中使用API

让我们看看如何在React应用中使用这个API. 一旦我们知道如何做到这一点,我们就会弄清楚缓存部分. 我们将使用一个类来创建组件. Here is the code:

从“React”导入React;
从“prop-types”中导入PropTypes;

导出默认类组件扩展React.Component {
  state = { users: [] };

  componentDidMount() {
    this.load();
  }

  load() {
    fetch(`http://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 => (
      

{user.first_name} {user.first_name} {user.last_name}

)); return
{users}
; } } Component.propTypes = { page: PropTypes.number.isRequired };

注意我们得到 page value via props,这在实际应用程序中经常发生. Also, we have a componentDidUpdate 函数,每次都重新获取API数据 this.props.page changes.

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

预览我们的React组件原型:六条居中线, 每个名字的左边都有一张照片.

添加刷新时过期缓存

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

  1. 在请求的响应第一次被获取后唯一地缓存它.
  2. 如果找到请求的缓存,立即返回缓存的响应. 然后,发送请求并异步返回新的响应. 另外,缓存此响应以备下次使用.

我们可以通过一个全球性的 CACHE 对象,该对象唯一地存储缓存. 对于唯一性,我们可以用 this.props.page value as a key in our CACHE object. 然后,我们简单地对上面提到的算法进行编码.

import apiFetch from "./apiFetch";

const CACHE = {};

导出默认类组件扩展React.Component {
  state = { users: [] };

  componentDidMount() {
    this.load();
  }

  load() {
    if (CACHE[this.props.page] !== undefined) {
      this.setState({users: CACHE[this ..props.page] });
    }
    apiFetch(`http://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() {
    //与上面相同的渲染代码
  }
}

因为缓存一找到就返回,并且新的响应数据由 setState as well, 这意味着我们有无缝的UI更新,并且从第二次请求开始,应用程序不再需要等待时间. 这是完美的,简而言之,它是过时的同时刷新方法.

跟踪刷新时过期逻辑的流程图. It starts with a request. 如果它被缓存,setState()将使用缓存的响应调用. 无论哪种方式,都发送请求,设置缓存,并调用setState()并提供新的响应.

The apiFetch 函数在这里只是一个包装 fetch 这样我们就能看到实时缓存的优势. 它通过向列表中添加一个随机用户来实现这一点 users 由API请求返回. 它还增加了一个随机延迟:

导出默认异步函数...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));
}

The getFakeUser() 函数在这里负责创建一个伪用户对象.

有了这些变化,我们的API比以前更加真实.

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

鉴于此,当我们改变 page prop passed to the Component 从我们的主组件中,我们可以看到API缓存的作用. Try clicking the Toggle 每隔几秒钟按一次按钮 this CodeSandbox 你应该看到这样的行为:

显示启用了缓存的切换页面的动画. 具体细节将在文章中描述.

如果你仔细观察,会发现一些事情.

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

这就是我们要找的刷新时过期缓存. 但是这种方法存在代码重复的问题. 让我们看看,如果我们有另一个带缓存的数据抓取组件,它将如何运行. 该组件显示的项与第一个组件不同.

向另一个组件添加过时刷新功能

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

const CACHE = {};

导出默认类Component2扩展React.Component {
  state = { cats: [] };

  componentDidMount() {
    this.load();
  }

  load() {
    if (CACHE[this.props.page] !== undefined) {
      this.setState({cats: CACHE[this ..props.page] });
    }
    apiFetch(`http://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 => (
      

{cat.name} (born {cat.year})

)); return
{cats}
; } }

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

现在,我们把这两个分量并排展示. You can see they behave similarly:

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

为了达到这个结果,我们必须进行大量的代码复制. 如果我们有这样的多个组件,我们就会复制太多的代码.

以一种不重复的方式解决它, 我们可以有一个高阶组件来获取和缓存数据,并将其作为道具传递下去. 这并不理想,但它会起作用. 但是如果我们必须在一个组件中处理多个请求, 有多个高阶分量会很快变得很难看.

Then, 我们有渲染道具模式, 在类组件中做到这一点的最好方法是什么. It works perfectly, but then again, 它很容易出现“包装器地狱”,有时需要我们绑定当前上下文. 这不是一个很好的开发体验,可能会导致挫败感和bug.

这就是React Hooks拯救世界的地方. 它们允许我们将组件逻辑封装在一个可重用的容器中,这样我们就可以在多个地方使用它. React Hooks 是在React 16中引入的吗.它们只适用于函数分量. 在我们开始学习React缓存控制之前, 用钩子缓存内容——让我们首先看看如何在函数组件中进行简单的数据抓取.

函数组件中的API数据获取

要从函数组件中获取API数据,我们使用 useState and useEffect hooks.

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

useEffect 是一个生命周期钩子,你可以把它想象成 combination of componentDidMount, componentDidUpdate, and componentWillUnmount. 第二个参数传递给 useEffect 被称为依赖数组. 当依赖项数组改变时,回调函数作为第一个参数传递给 useEffect is run again.

下面是我们如何使用这些钩子来实现数据抓取:

从“React”中导入React, {useState, useEffect};

组件({page}) {
  const [users, setUsers] = useState([]);

  useEffect(() => {
    fetch(`http://reqres.in/api/users?page=${page}`)
      .then(res => res.json())
      .then(json => {
        setUsers(json.data);
      });
  }, [page]);

  const usersDOM = users.map(user => (
    

{user.first_name} {user.first_name} {user.last_name}

)); return
{usersDOM}
; }

By specifying page as a dependency to useEffect,我们指示React每次都运行我们的useEffect回调 page is changed. This is just like componentDidUpdate. Also, useEffect 总是第一次运行,所以它就像 componentDidMount too.

在函数组件中刷新时失效

We know that useEffect 是否类似于组件生命周期方法. 因此,我们可以修改传递给它的回调函数,以创建我们在类组件中拥有的过时刷新缓存. 一切都保持不变,除了 useEffect hook.

const CACHE = {};

组件({page}) {
  const [users, setUsers] = useState([]);

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

  // ... create usersDOM from users

  return 
{usersDOM}
; }

因此,我们在函数组件中使用了过时时刷新缓存.

我们可以对第二个分量做同样的处理, that is, 将其转换为函数并实现刷新时过期缓存. The result 会和我们在课堂上学的一样吗.

但这并不比类组件好多少,不是吗? 那么,让我们来看看如何使用自定义钩子的强大功能来创建可跨多个组件使用的模块化过时时刷新逻辑.

一个自定义的过时时刷新钩子

首先,让我们缩小要移动到自定义钩子中的逻辑范围. 如果你看一下前面的代码,你就知道它是 useState and useEffect part. 更具体地说,这是我们想要模块化的逻辑.

const [users, setUsers] = useState([]);

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

由于我们必须使它泛型,我们将不得不使URL动态. So we need to have url as an argument. 我们还需要更新缓存逻辑,因为多个请求可以具有相同的缓存逻辑 page value. Luckily, when 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]);

That’s pretty much it. 在将其包装在函数中之后,我们将拥有自定义钩子. Have a look below.

const CACHE = {};

导出默认功能useStaleRefresh(url, defaultValue = []) {
  const [data, setData] = useState(defaultValue);

  useEffect(() => {
    // cacheID是如何根据唯一请求识别缓存
    const cacheID = url;
    //查看缓存并设置响应
    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 to it. 如果在多个组件中使用此钩子,则API调用的默认值可能不同. 这就是为什么我们让它可定制.

同样的方法也适用于 data key in the newData object. 如果您的自定义钩子返回各种数据,您可能只想返回 newData and not newData.data 然后在组件端处理遍历.

现在我们有了自定义钩子, 刷新时过期缓存的繁重工作由哪个来完成, 下面是我们如何将它插入到组件中. 请注意我们能够减少的代码量. 整个组件现在只有三条语句. That’s a big win.

导入usestalerfresh./useStaleRefresh";

组件({page}) {
  const users = useStaleRefresh(' http://reqres . net ').in/api/users?page=${page}`, []);

  const usersDOM = users.map(user => (
    

{user.first_name} {user.first_name} {user.last_name}

)); return
{usersDOM}
; }

我们可以对第二个分量做同样的处理. It will look like this:

导出默认功能Component2({page}) {
  const cats = useStaleRefresh(' http://reqres . net ').in/api/cats?page=${page}`, []);

  // ... create catsDOM from cats

  return 
{catsDOM}
; }

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

添加加载指示灯到 useStaleRefresh

现在我们已经掌握了基础知识,我们可以为自定义钩子添加更多特性. For example, we can add an isLoading 当发送一个唯一的请求时,钩子中的值为true,同时我们没有任何缓存要显示.

我们通过一个单独的状态来实现 isLoading 并根据钩子的状态进行设置. 也就是说,当没有缓存的网页内容可用时,我们将其设置为 true, otherwise we set it to false.

Here is the updated hook:

导出默认功能useStaleRefresh(url, defaultValue = []) {
  const [data, setData] = useState(defaultValue);
  const [isLoading, setLoading] = useState(true);

  useEffect(() => {
    // cacheID是如何根据唯一请求识别缓存
    const cacheID = url;
    //查看缓存并设置响应
    if (CACHE[cacheID] !== undefined) {
      setData(CACHE[cacheID]);
      setLoading(false);
    } else {
      //否则确保加载设置为true
      setLoading(true);
    }
    // fetch new data
    apiFetch(url).then(newData => {
      CACHE[cacheID] = newData.data;
      setData(newData.data);
      setLoading(false);
    });
  }, [url]);

  return [data, isLoading];
}

We can now use the new isLoading value in our components.

组件({page}) {
  const [users, isLoading] = useStaleRefresh()
    `http://reqres.in/api/users?page=${page}`,
    []
  );

  if (isLoading) {
    return 
Loading
; } // ... create usersDOM from users return
{usersDOM}
; }

Notice that with that done,当第一次发送唯一请求并且没有缓存时,您将看到“Loading”文本.

一个动画,显示实现了加载指示器的组件.

Making useStaleRefresh Support Any async Function

我们可以使我们的自定义钩子更强大,使它支持任何 async function rather than just GET network requests. 其背后的基本理念将保持不变.

  1. 在钩子中,调用一段时间后返回值的async函数.
  2. 对异步函数的每个唯一调用都被适当地缓存.

A simple concatenation of function.name and arguments 将作为我们用例的缓存键. 使用它,我们的钩子看起来是这样的:

从react中导入{useState, useEffect, useRef};
从“lodash/isEqual”中导入isEqual;
const CACHE = {};

导出默认函数useStaleRefresh(fn, args, defaultValue = []) {
  const prevArgs = useRef(null);
  const [data, setData] = useState(defaultValue);
  const [isLoading, setLoading] = useState(true);

  useEffect(() => {
    // args是一个深度比较的对象,可以排除错误的更改
    if (isEqual(args, prevArgs.current)) {
      return;
    }
    // cacheID是如何根据唯一请求识别缓存
    const cacheID = hashArgs.name, ...args);
    //查看缓存并设置响应
    if (CACHE[cacheID] !== undefined) {
      setData(CACHE[cacheID]);
      setLoading(false);
    } else {
      //否则确保加载设置为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) {
  返回类型val === "object" ? JSON.stringify(val):字符串(val);
}

As you can see, 我们使用函数名和它的字符串化参数的组合来唯一标识函数调用,从而缓存它. 这适用于我们的简单应用程序,但该算法容易产生冲突和缓慢的比较. (对于不可序列化的参数,它根本不起作用.)因此,对于现实世界的应用程序,一个合适的哈希算法更合适.

这里要注意的另一件事是 useRef. useRef 用于在封装组件的整个生命周期中持久化数据. Since args 是一个数组——在javascript中是一个对象——每次使用钩子重新渲染组件都会导致 args 要更改的引用指针. But args 在我们的第一个依赖列表的一部分 useEffect. So args changing can make our useEffect 即使什么都没变也要跑. 为了解决这个问题,我们对旧的和现在的进行了深入的比较 args using isEqual and only let the useEffect callback run if args actually changed.

Now, we can use this new useStaleRefresh hook as follows. Notice the change in defaultValue here. 因为它是一个通用钩子,所以我们不依赖钩子来返回 data 键入响应对象.

组件({page}) {
  const [users, isLoading] = useStaleRefresh()
    apiFetch,
    [`http://reqres.in/api/users?page=${page}`],
    { data: [] }
  );

  if (isLoading) {
    return 
Loading
; } const usersDOM = users.data.map(user => (

{user.first_name} {user.first_name} {user.last_name}

)); return
{usersDOM}
; }

您可以在中找到完整的代码 this CodeSandbox.

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

The useStaleRefresh 我们在本文中创建的hook是一个概念证明,它展示了React Hooks的可能性. 尝试使用这些代码,看看是否可以将其适合您的应用程序.

Alternatively, 您还可以尝试通过流行的, 维护良好的开源库 swr or react-query. Both are powerful libraries 并支持大量帮助处理API请求的特性.

React Hooks改变了游戏规则. 它们允许我们优雅地共享组件逻辑. 这在以前是不可能的,因为组件状态, lifecycle methods, 和呈现都被打包成一个实体:类组件. 现在,我们可以对它们都有不同的模块. 这对于可组合性和编写更好的代码非常有用. 我在编写的所有新React代码中都使用了函数组件和钩子, 我强烈推荐给所有React开发者.

Understanding the basics

  • What is stale cache?

    过期缓存是由于包含过期数据而不适合使用的缓存. 在HTTP上下文中,当缓存的max-age或s-maxage过期时,就会发生这种情况. 这类似于食物保存了很长时间就会变质,因此有了“不新鲜的贮藏”这个词.”

  • What is stale content?

    过期内容是指已经过期的内容. In the context of CDNs, 这意味着内容存在的时间超过了附带的生存时间(TTL)值. 所以它不适合使用,因此有了“陈腐”这个词.”

  • What is a React Hook?

    React Hook是一个函数,它允许我们钩入React函数组件的状态和生命周期方法. Because of this, 钩子允许我们跳过使用类组件,因为像componentDidMount这样的生命周期方法, componentDidUpdate, 和componentWillUnmount都可以通过它访问.

  • Cache-Control做什么?

    Cache-Control是一个HTTP头,它指定与请求关联的缓存. 它可以在请求头和响应头中独立定义. With Cache-Control, 需要指定像no-cache这样的指令, must-revalidate, 和max-age指定如何缓存请求的响应.

  • 什么是Cache-Control:必须重新验证?

    HTTP的cache - control中的must-revalidate指令指定一旦缓存过期, 在进一步重新验证之前,不得使用它. 通过联系缓存的原始服务器并检查是否存在更新的版本来进行重新验证. 如果有更新的版本,它将被获取并使用.

  • Is cached data important?

    缓存数据并不是严格意义上的重要数据,但它是可取的,因为它可以提高有效性能. 系统可以立即提供缓存数据,而加载新数据则需要时间. 缓存的数据占用磁盘空间,所以如果需要的话可以删除它.

聘请Toptal这方面的专家.
Hire Now
Avi Aryan

Avi Aryan

Verified Expert in Engineering

New Delhi, Delhi, India

2018年3月28日成为会员

About the author

Avi是一个精通Python的全栈开发人员, JavaScript, 他也是谷歌代码之夏的多次参与者.

Read More
作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

PREVIOUSLY AT

Google

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

世界级的文章,每周发一次.

订阅意味着同意我们的 privacy policy

Toptal Developers

Join the Toptal® community.