Извлечение устаревших данных при повторной проверке с помощью React Hooks: руководство

Опубликовано: 2022-03-11

Популярным приемом является использование расширения HTTP Cache-Control устаревания при повторной проверке. Он включает в себя использование кешированных (устаревших) ресурсов, если они обнаружены в кеше, а затем повторную проверку кеша и обновление его более новой версией ресурса, если это необходимо. Отсюда и название stale-while-revalidate .

Как работает stale-while-revalidate

Когда запрос отправляется в первый раз, он кэшируется браузером. Затем, когда тот же запрос отправляется второй раз, сначала проверяется кеш. Если кеш этого запроса доступен и действителен, кеш возвращается в качестве ответа. Затем кеш проверяется на устаревание и обновляется, если он оказывается устаревшим. Устаревание кеша определяется значением max-age в заголовке Cache-Control вместе с stale-while-revalidate .

Блок-схема, отслеживающая логику устаревания при повторной проверке. Он начинается с запроса. Если он не кэширован или кэш недействителен, запрос отправляется, возвращается ответ и кэш обновляется. В противном случае возвращается кешированный ответ, после чего кеш проверяется на устаревание. Если он устарел, отправляется запрос и обновляется кеш.

Это позволяет быстро загружать страницы, поскольку кэшированные ресурсы больше не находятся на критическом пути. Они загружаются моментально. Кроме того, поскольку разработчики контролируют частоту использования и обновления кеша, они могут запретить браузерам показывать пользователям чрезмерно устаревшие данные.

Читатели могут подумать, что если они могут заставить сервер использовать определенные заголовки в своих ответах и ​​позволить браузеру брать их оттуда, то зачем использовать React и хуки для кэширования?

Оказывается, серверно-браузерный подход хорошо работает только тогда, когда мы хотим кэшировать статический контент. Как насчет использования stale-while-revalidate для динамического API? В этом случае трудно найти хорошие значения для max-age и stale-while-revalidate . Часто наилучшим вариантом будет аннулирование кеша и получение нового ответа каждый раз при отправке запроса. Фактически это означает полное отсутствие кэширования. Но с React и Hooks мы можем добиться большего.

stale-while-revalidate для API

Мы заметили, что stale-while-revalidate HTTP плохо работает с динамическими запросами, такими как вызовы API.

Даже если мы в конечном итоге его используем, браузер вернет либо кеш, либо свежий ответ, а не то и другое одновременно. Это не очень хорошо сочетается с запросом API, поскольку мы хотели бы получать свежие ответы каждый раз, когда запрос отправляется. Однако ожидание свежих ответов задерживает значимое удобство использования приложения.

Так что же нам делать?

Мы реализуем собственный механизм кэширования. В рамках этого мы находим способ вернуть как кеш, так и свежий ответ. В пользовательском интерфейсе кэшированный ответ заменяется свежим ответом, когда он доступен. Вот как будет выглядеть логика:

  1. Когда запрос отправляется на конечную точку сервера API в первый раз, кэшируйте ответ, а затем возвращайте его.
  2. В следующий раз, когда произойдет тот же запрос API, немедленно используйте кешированный ответ.
  3. Затем отправьте запрос асинхронно, чтобы получить новый ответ. Когда придет ответ, асинхронно распространите изменения в пользовательском интерфейсе и обновите кеш.

Этот подход позволяет мгновенно обновлять пользовательский интерфейс, поскольку каждый запрос API кэшируется, а также обеспечивает правильную корректность пользовательского интерфейса, поскольку свежие данные ответов отображаются сразу же, как только они становятся доступными.

В этом уроке мы увидим пошаговый подход к тому, как это реализовать. Мы будем называть этот подход устаревшим при обновлении, поскольку пользовательский интерфейс фактически обновляется , когда он получает свежий ответ.

Подготовка: 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 отвечает за смену страницы, а всего у нас в наборе данных две страницы.

Использование API в приложении React

Давайте посмотрим, как мы используем API в приложении React. Как только мы узнаем, как это сделать, мы разберемся с частью кэширования. Мы будем использовать класс для создания нашего компонента. Вот код:

 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 };

Обратите внимание, что мы получаем значение page через props , как это часто бывает в реальных приложениях. Кроме того, у нас есть функция componentDidUpdate , которая обновляет данные API каждый раз, когда изменяется this.props.page .

На данный момент он показывает список из шести пользователей, потому что API возвращает шесть элементов на страницу:

Предварительный просмотр прототипа нашего компонента React: шесть линий по центру, каждая с фотографией слева от имени.

Добавление кэширования устаревших данных при обновлении

Если мы хотим добавить к этому кэширование устаревших данных при обновлении, нам нужно обновить логику нашего приложения, чтобы:

  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 , это означает, что у нас есть плавные обновления пользовательского интерфейса и больше нет времени ожидания в приложении, начиная со второго запроса. Это идеально, и в двух словах это метод устаревшего обновления.

Блок-схема, отслеживающая логику устаревания при обновлении. Он начинается с запроса. Если он кэширован, setState() вызывается с кэшированным ответом. В любом случае запрос отправляется, кеш устанавливается, и setState() вызывается с новым ответом.

Функция apiFetch здесь не что иное, как оболочка над fetch , чтобы мы могли видеть преимущества кэширования в режиме реального времени. Это делается путем добавления случайного пользователя в список users , возвращаемый запросом API. Он также добавляет к нему случайную задержку:

 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. Он возвращает немного разные данные для тех же запросов.

Учитывая это, когда мы меняем свойство page , переданное Component из нашего основного компонента, мы можем увидеть кэширование API в действии. Попробуйте нажимать кнопку Toggle каждые несколько секунд в этой CodeSandbox, и вы должны увидеть такое поведение:

Анимация, показывающая страницу переключения с включенным кэшированием. Особенности описаны в статье.

Если присмотреться, происходит несколько вещей.

  1. Когда приложение запускается и находится в состоянии по умолчанию, мы видим список из семи пользователей. Обратите внимание на последнего пользователя в списке, так как это пользователь будет случайным образом изменен при следующей отправке этого запроса.
  2. Когда мы нажимаем Toggle в первый раз, он ждет некоторое время (400-700 мс), а затем обновляет список до следующей страницы.
  3. Итак, мы на второй странице. Снова обратите внимание на последнего пользователя в списке.
  4. Теперь мы снова нажимаем Toggle, и приложение вернется на первую страницу. Обратите внимание, что теперь последней записью является тот же пользователь, которого мы записали на шаге 1, а затем он меняется на нового (случайного) пользователя. Это связано с тем, что сначала показывался кеш, а затем начинался фактический ответ.
  5. Мы снова нажимаем на Toggle. Происходит такое же явление. Кэшированный ответ из последнего раза загружается мгновенно, а затем извлекаются новые данные, и поэтому мы видим последнее обновление записи из того, что мы записали на шаге 3.

Вот оно, кэширование устаревших данных при обновлении, которое мы искали. Но этот подход страдает от дублирования кода. Давайте посмотрим, как это будет, если у нас есть еще один компонент для выборки данных с кэшированием. Этот компонент показывает элементы иначе, чем наш первый компонент.

Добавление устаревшего во время обновления к другому компоненту

Мы можем сделать это, просто скопировав логику из первого компонента. Наш второй компонент показывает список кошек:

 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>; } }

Как видите, логика компонента здесь почти такая же, как и в первом компоненте. Единственная разница заключается в запрошенной конечной точке и в том, что она по-разному отображает элементы списка.

Теперь мы покажем оба этих компонента рядом. Вы можете видеть, что они ведут себя одинаково:

Анимация, показывающая переключение с двумя рядом расположенными компонентами.

Чтобы добиться такого результата, нам пришлось много дублировать код. Если бы у нас было несколько таких компонентов, мы бы дублировали слишком много кода.

Чтобы решить эту проблему без дублирования, у нас может быть компонент более высокого порядка для извлечения и кэширования данных и передачи их в качестве реквизита. Это не идеально, но это сработает. Но если бы нам пришлось выполнять несколько запросов в одном компоненте, наличие нескольких компонентов более высокого порядка очень быстро стало бы уродливым.

Затем у нас есть шаблон render props, который, вероятно, является лучшим способом сделать это в компонентах класса. Он работает отлично, но опять же, он склонен к «адской обертке» и время от времени требует от нас привязки текущего контекста. Это не лучший опыт разработчика и может привести к разочарованию и ошибкам.

Именно здесь React Hooks спасают положение. Они позволяют нам помещать логику компонентов в многоразовый контейнер, чтобы мы могли использовать его в нескольких местах. React Hooks были представлены в React 16.8 и работают только с функциональными компонентами. Прежде чем мы перейдем к управлению кешем React, в частности к кэшированию контента с помощью хуков, давайте сначала посмотрим, как мы делаем простую выборку данных в функциональных компонентах.

Получение данных API в функциональных компонентах

Чтобы получить данные API в функциональных компонентах, мы используем useState и useEffect .

useState аналогичен state компонентов класса и setState . Мы используем этот хук, чтобы иметь атомарные контейнеры состояния внутри функционального компонента.

useEffect — это хук жизненного цикла, и вы можете думать о нем как о комбинации componentDidMount , componentDidUpdate и componentWillUnmount . Второй параметр, передаваемый в 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 запускать наш обратный вызов useEffect каждый раз при изменении page . Это похоже на componentDidUpdate . Кроме того, useEffect всегда запускается в первый раз, поэтому он также работает как componentDidMount .

Устаревшие при обновлении в функциональных компонентах

Мы знаем, что useEffect похож на методы жизненного цикла компонентов. Таким образом, мы можем изменить переданную ей функцию обратного вызова, чтобы создать кэширование устаревших данных при обновлении, которое мы имели в компонентах класса. Все осталось прежним, кроме хука 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

Во-первых, давайте сузим логику, которую мы хотим переместить в пользовательский хук. Если вы посмотрите на предыдущий код, то поймете, что это часть useState и useEffect . Точнее говоря, это логика, которую мы хотим разделить на модули.

 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 может отличаться, если вы используете этот хук в нескольких компонентах. Вот почему мы сделали его настраиваемым.

То же самое можно сделать для ключа data в объекте newData . Если ваш пользовательский хук возвращает различные данные, вы можете просто вернуть 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 и устанавливая его в соответствии с состоянием хука. То есть, когда кешированный веб-контент недоступен, мы устанавливаем для него значение 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.name и arguments будет работать как ключ кэша для нашего варианта использования. Используя это, вот как будет выглядеть наш хук:

 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 работать, даже если ничего не изменилось. Чтобы противостоять этому, мы проводим глубокое сравнение между старыми и текущими args , используя isEqual, и разрешаем выполнение обратного вызова useEffect только в том случае, если args действительно изменились.

Теперь мы можем использовать этот новый хук 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. Обе являются мощными библиотеками и поддерживают множество функций, которые помогают с запросами API.

React Hooks меняет правила игры. Они позволяют нам элегантно разделять логику компонентов. Раньше это было невозможно, потому что состояние компонента, методы жизненного цикла и отрисовка были упакованы в одну сущность: компоненты класса. Теперь у нас могут быть разные модули для всех из них. Это отлично подходит для компоновки и написания лучшего кода. Я использую функциональные компоненты и хуки для всего нового кода React, который я пишу, и я настоятельно рекомендую это всем разработчикам React.

Связанный: Создание приложений React с помощью Redux Toolkit и RTK Query