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-age
和stale-while-revalidate
提供好的值。 通常,每次發送請求時使緩存無效並獲取新響應將是最佳選擇。 這實際上意味著根本沒有緩存。 但是使用 React 和 Hooks,我們可以做得更好。
API stale-while-revalidate
我們注意到 HTTP 的stale-while-revalidate
不適用於 API 調用等動態請求。
即使我們最終使用它,瀏覽器也會返回緩存或新響應,而不是兩者。 這不適用於 API 請求,因為我們希望每次發送請求時都有新的響應。 但是,等待新的響應會延遲應用程序的有意義的可用性。
那麼我們該怎麼辦?
我們實現了自定義緩存機制。 在其中,我們找到了一種同時返回緩存和新響應的方法。 在 UI 中,緩存的響應在可用時將替換為新的響應。 這就是邏輯的樣子:
- 當請求第一次發送到 API 服務器端點時,緩存響應然後返回。
- 下次發生相同的 API 請求時,立即使用緩存的響應。
- 然後,異步發送請求以獲取新響應。 當響應到達時,異步將更改傳播到 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 每頁返回六個項目:
添加 Stale-while-refresh 緩存
如果我們想為此添加 stale-while-refresh 緩存,我們需要將我們的應用程序邏輯更新為:
- 首次獲取請求的響應後,對其進行唯一的緩存。
- 如果找到請求的緩存,則立即返回緩存的響應。 然後,發送請求並異步返回新的響應。 此外,緩存此響應以備下次使用。
我們可以通過擁有一個唯一存儲緩存的全局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 方法。
這裡的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 比以前更加真實。
- 它有一個隨機的響應延遲。
- 它為相同的請求返回略有不同的數據。
鑑於此,當我們更改從主組件傳遞給Component
的page
道具時,我們可以看到 API 緩存在起作用。 嘗試在此 CodeSandbox 中每隔幾秒單擊一次Toggle按鈕,您應該會看到如下行為:
如果你仔細觀察,會發生一些事情。
- 當應用程序啟動並處於默認狀態時,我們會看到一個包含七個用戶的列表。 記下列表中的最後一個用戶,因為下次發送此請求時將隨機修改該用戶。
- 當我們第一次單擊 Toggle 時,它會等待一小段時間(400-700 毫秒),然後將列表更新到下一頁。
- 現在,我們在第二頁。 再次記下列表中的最後一個用戶。
- 現在,我們再次單擊 Toggle,應用程序將返回第一頁。 請注意,現在最後一個條目仍然是我們在步驟 1 中記下的同一用戶,然後它稍後會更改為新的(隨機)用戶。 這是因為,最初,緩存被顯示,然後實際響應開始了。
- 我們再次點擊 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 數據,我們使用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 在每次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
首先,讓我們縮小要移動到自定義鉤子中的邏輯。 如果你看前面的代碼,你就知道它是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 調用的默認值可能不同。 這就是為什麼我們讓它可定制的原因。
對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
網絡請求,從而使我們的自定義鉤子更加強大。 它背後的基本思想將保持不變。
- 在鉤子中,您調用一個異步函數,該函數在一段時間後返回一個值。
- 對異步函數的每個唯一調用都被正確緩存。
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
運行,即使沒有任何改變。 為了解決這個問題,我們使用 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 開發人員。