React Query、SWR 的 cache strategy & 淺談 source code

👉 前言

繼上一篇介紹 React Query 的基本使用方法後,接著想探索 React Query 以及和它相似的 SWR 這兩個 library 的 cache strategy,還有從 source code 看兩者的 cache 如何實踐,以及從 SWR 的 source code 中觀察優化 UX、DX 的小細節。

👉 Cache Strategy

stale-while-revalidate

React Query caching is automatic out of the box. It uses a stale-while-revalidate in-memory caching strategy (popularized by HTTP RFC 5861) and a very robust query deduping strategy to always ensure a query’s data is always readily available, only cached when it’s needed, even if that query is used multiple times across your application and updated in the background when possible.

React Query 以及 SWR 的 cache strategy 是基於 stale-while-revalidate 的概念實踐,甚麼是 stale-while-revalidate 呢?還記得有個 HTTP header 叫做 Cache-Control 嗎?至少我是不太記得,那時候對 Cache-Control 最普遍認知的是裡面有個叫 max-age 的屬性。

舉例來說,假設今天 header 設置了 Cache-Control: max-age: 60,代表的意思就是「我的資源在這 60 秒內取用它的話,就會從 cache 拿」,也就是說在這 60 秒內這份資料都會是新鮮的,要重複使用到它的話,只要從 cache 取出就好,不需要再跟 server 拿,但過了 60 秒後就必須要再跟 server 要資料,因為資料已經過期了。

stale-while-revalidate 則是 Cache-Control 的 extension(spec 可參考 HTTP RFC 5861)我們把剛剛的舉例再延伸,這次將 Cache-Control 設置成 Cache-Control: max-age=1, stale-while-revalidate=59,意思代表的是「過了 1 秒後我的資料會過期,但沒關係,在之後的 59 秒我還是要從 cache 拿,只是會用非同步、背景更新的方式去 revalidate,等到 60 秒以後,我的資料真正的過期了,才會用同步的方式 revalidate cache」

不夠清楚的話有下面圖例:

stale-while-revalidate

此圖取自於 Keeping things fresh with stale-while-revalidate

以上面圖例來說:

  • 0 - 1 秒時,資料是新鮮的的,就是 max-age: 1 預計會有的行為。
  • 1 - 59 秒時,資料已經過期了,但因為 stale-while-revalidate: 59 的關係,表示我不介意繼續用過期的 cache,但同時我會在背景去 revalidate 我的 cache
  • 60 秒後,代表我資料真正的過期了,就走一般的同步 revalidate 方式

和 React Query、SWR 的關聯是甚麼呢?

剛剛有說到這兩個 Library 都是採用 stale-while-revaliate 的 cache strategy,我們可以從這兩個 library 在操作時的行為來看出 stale-while-revaliate 概念的實踐,這時候又要到點到我們之前的 demo:

Star Wars Demo

ezgif-6-01cf903e8474

可以發現到在切換「Planets」和「People」的時候,第一次會有 loading 的文字顯示,但在這之後就可以很快速的切換頁面,並且資料已經呈現出來了,這當中的運作流程是:

  1. 第一次 render 本來就沒有資料,所以 call API 拿資料。
  2. 呈現 loading 文字給使用者看。
  3. 第二次 render 時,因為有上一次 call API 的資料 cache,所以先呈現 cache 給使用者看。
  4. 如果資料和上一次 call API 時相同,那就不會變動;如果資料不同,那就變動畫面。

也就是說使用者在短期內切換畫面時,體感上會覺得挺流暢的,因為他不會每切換一次畫面就看到 loading,使用體驗就會變得比較好。

從 React Query 和 SWR 「有 cache 就先端給使用者,並且在背景默默更新 cache」的行為來看,和 stale-while-revalidate 的概念很一致。

👉 React Query、SWR Cache 的實踐

目前只是從這兩套 library 的一小角來窺探如何實踐,真正的邏輯更為複雜,對 source code 的理解也未必正確,如有誤也麻煩大力糾錯,感恩感恩。

React Query

React Query - getQueries

我會先從 QueryCache 這個 class 的 getQueries method 開始看,因為 getQueries 是從 cache 中撈出 query 的方法,可以從中看到兩件事,第一是 cache 的資料結構,也是我們主要想了解的;第二個是順便了解怎麼它是怎麼取出 query 的。

先看 getQueries 最後一行:

return this.queriesArray.filter(predicateFn)

從這行 code 可以推測 cache 的資料結構是 array,達成了主要目的,可以先休息了,呼。

休息完後繼續看下去,會看到取出的方式是透過 filter 的 predicateFn callback 得到想要的 query,因此我們再看到 predicateFn

let predicateFn: QueryPredicateFn

if (typeof predicate === 'function') {
  predicateFn = predicate as QueryPredicateFn
} else {
  const { exact, active, stale } = options || {}
  const resolvedConfig = this.getResolvedQueryConfig(predicate)

  predicateFn = query => {
    // Check query key if needed
    if (!anyKey) {
      if (exact) {
        // Check if the query key matches exactly
        if (query.queryHash !== resolvedConfig.queryHash) {
          return false
        }
      } else {
        // Check if the query key matches partially
        if (!deepIncludes(query.queryKey, resolvedConfig.queryKey)) {
          return false
        }
      }
    }

    // Check active state if needed
    if (typeof active === 'boolean' && query.isActive() !== active) {
      return false
    }

    // Check stale state if needed
    if (typeof stale === 'boolean' && query.isStale() !== stale) {
      return false
    }

    return true
  }
}

predicateFn 可以看到它大概做了幾件事:

  1. 檢查 query key 的 hash 是否相符
  2. 檢查狀態是否為 active
  3. 檢查狀態是否為 stale

經過層層檢查後才會到 return true。

另外,從 query.queryHash 也能夠大概推知 query 的 key 會經過 hash 後放到 queriesArray 裡。

SWR

接著輪到 SWR 了。

swr/src/cache.ts

先看 SWR 的 class Cache 裡面的 constructor:

constructor(initialData: any = {}) {
  this.__cache = new Map(Object.entries(initialData)) // 這行!
  this.__listeners = []
}

可以發現和 React Query Cache 的 Array 不同,SWR 的 Cache 是用 Map 這個資料結構來實現的。用 Map 來實踐我覺得蠻合理的,因為無論 React Query、SWR 都會把 key 經過 hash 後當作那筆資料的 unique identifier,而這個 unique identifier 就會對應到相對應的 data,這種 key-value pairs 的結構就蠻適合使用到 Map

接著繼續往下看其他 Cache 的 source code:

get(key: keyInterface): any {
  const [_key] = this.serializeKey(key)
    return this.__cache.get(_key)
  }

set(key: keyInterface, value: any): any {
  const [_key] = this.serializeKey(key)
  this.__cache.set(_key, value)
  this.notify()
}

keys() {
  return Array.from(this.__cache.keys())
}

has(key: keyInterface) {
  const [_key] = this.serializeKey(key)
  return this.__cache.has(_key)
}

clear() {
  this.__cache.clear()
  this.notify()
}

delete(key: keyInterface) {
  const [_key] = this.serializeKey(key)
  this.__cache.delete(_key)
  this.notify()
}

可以發現到這邊無論 get、set 或 clear 等等,其實都是 Map 內建的 function,更詳細的內容可以看 MDN - Map

👉 從 SWR source code 看增進 UX、DX 的小細節

接著再從 SWR 的 source code 挖掘它做了什麼增進 UX 和 DX 的小細節,這邊的小細節是挑我有興趣的部分講。

useIsomorphicEffect

// React currently throws a warning when using useLayoutEffect on the server.
// To get around it, we can conditionally useEffect on the server (no-op) and
// useLayoutEffect in the browser.
const useIsomorphicLayoutEffect = IS_SERVER ? useEffect : useLayoutEffect

這邊會發現在 Client Side Render 時,SWR 使用 useLayoutEffect,在 Server Side Render 時使用 useEffect;從註解會看到這樣做的原因是在 Server Side Render 的時候使用 useLayoutEffect 會有警告,因此才做了這邊三元運算子的判斷。

至於為甚麼要用 useLayoutEffect 呢?要先從 React Hooks 的執行時機說起:

此圖來自於 donavon/hook-flow

從上圖可以看到 useLayoutEffect 的執行時機在 browser paints screen 之前;useEffect 則是在瀏覽器畫面更新後才會執行,也因此統一改成 useLayoutEffect 後,執行時機就會更提早一些,讓 SWR 撈資料、處理資料的時機更提前。

不過這邊我是有點疑惑的,根據 React 官方文件的說法:

Tip
Unlike componentDidMount or componentDidUpdate, effects scheduled with useEffect don’t block the browser from updating the screen. This makes your app feel more responsive. The majority of effects don’t need to happen synchronously. In the uncommon cases where they do (such as measuring the layout), there is a separate useLayoutEffect Hook with an API identical to useEffect.

提示
與 componentDidMount 或 componentDidUpdate 不同,使用 useEffect 安排的 effect 不會阻止瀏覽器更新螢幕。這使你的應用程式感覺起來響應更快。大多數 effect 不需要同步發生。在少見的需要同步發生的情況下(例如測量 layout),有另外一個 useLayoutEffect Hook,它的 API 與 useEffect 相同。

useEffect 安排在 render 完後執行的原因是如果 side effect 執行過久的話,就會 block 瀏覽器更新螢幕,所以大多數應用情境官方會比較推薦使用 useEffect,因為不會阻止瀏覽器更新螢幕。

也因為如此,我會比較疑惑的地方是 SWR 改成使用 useLayoutEffect 不會有 side effect 執行過久導致 block UI paints 的狀況嗎?

我猜依然是因為基於 stale-while-revalidate 這個核心思想的關係,使用者會先看到 cache ,背景會默默更新 data;因為使用者會先看到 cache,畫面上至少有東西,所以也比較不用擔心useLayoutEffect 執行過久導致瀏覽器更新螢幕被 block 的問題。

softRevalidate

const softRevalidate = () => revalidate({ dedupe: true })

這算是比較增進 DX 的部份。

一般來說我們在寫 React Hook 時,會想要把重複的邏輯抽出去變成 custom hook,fetch data 的邏輯也不例外,通常會寫成像是 useFetch 的 custom hook,程式碼會像是這樣:

function useFetch(url) {
  const [data, setData] = useState()
  const [isLoading, setIsLoading] = useState(false)
  const [isError, setIsError] = useState(false)

  useEffect(() => {
    setIsLoading(true)
      try {
        fetch(url)
          .then((res) => res.json())
          .then((json) => setData(json))
      } catch {
        setIsError(true)
      }
        setIsLoading(false)
  }, [url])

  return [data, isLoading, isError]
}

接著假設我們有兩個 Component,Component A 和 Component B,並且都用到了 useFetch 這個 hook:

function ComponentA() {
  const [data, isLoading, isError] = useFetch('https://swapi.dev/api/people/1')
}

function ComponentB() {
  const [data, isLoading, isError] = useFetch('https://swapi.dev/api/people/1')
}

並且在同一個頁面裡面都使用到了這兩個 Component:

function App() {
  return (
    <>
      <ComponentA/>
      <ComponentB/>
    </>
  )
}

這時候會發生一個問題,明明是要用同一份 API 的資料,但是卻 call 了同一支 API 兩次,也就是發了兩次拿取同樣資料的 request,造成不必要的資源浪費。

但 React Query 和 SWR 不會發生這種狀況,當 query 的 key 相同的時候,就會去掉重複的 request,只會 call 一次 API。

SWR 這段程式碼就是在做這件事情:

const softRevalidate = () => revalidate({ dedupe: true })

dedupe 設為 true 之後,就會去掉「短時間」內重複的 request,不過這個「短時間」為多少呢?在 SWR 的預設為 2 秒,因此 2 秒內重複的 request 都會被併為一次,避免不必要的 request。

rIC - requestIdleCallback

⚠️ 目前 rIC 已經在 2020/11/2 Replace rIC with rAF #744 的 PR 被改成 rAF

雖然原本 rIC 已經被改成 rAF 了,但還是先從修改前的 source code 來談吧,待會再來看看為何被改成 rAF。

先看看 source code:

// polyfill for requestIdleCallback
const rIC = IS_SERVER
  ? null : window['requestAnimationFrame'] || (f => setTimeout(f, 1))

//...中間略

useIsomorphicLayoutEffect(() => {
  //...中間略
  
  // trigger a revalidation
  if (
    config.revalidateOnMount ||
    (!config.initialData && config.revalidateOnMount === undefined)
  ) {
    if (typeof latestKeyedData !== 'undefined') {
    // delay revalidate if there's cache
    // to not block the rendering
      rIC(softRevalidate)
    } else {
      softRevalidate()
    }
  }
}, [key, revalidate])

從註解可以看到,當 typeof latestKeyedData !== 'undefined' 時,也就是當我們有 cache 的時候,會執行 rIC 這個 function,而 rIC 代表的就是 Web APIs 中的 requestIdleCallback,所以我們現在可以探討的另一個問題是 - 「為什麼要用 requestIdleCallback

為甚麼要用 requestIdleCallback?先從瀏覽器談起

談這個問題之前,我們先談談 JavaScript 與瀏覽器的相處模式,了解一下前因後果。

JavaScript 是 single thread 的,瀏覽器是 muti thread 的,瀏覽器除了處理 JavaScript 的 thread 以外,還會有 UI thread、Network thread、Main thread 等等。

瀏覽器繪製每一個 frame 的流程會像是這樣,會是 JavaScript 先執行,UI thread 再繪製畫面:

圖片取自於 轉譯效能

為什麼是 JavaScript 先執行呢?因為 JavaScript 會涉及到 DOM 的操作,如果今天 JavaScript 和 UI thread 同時在運作的話,畫面可能就會產生非預期的結果,因此 JavaScript 的執行和 UI thread 是不能同時來的,UI thread 會先等 JavaScript 執行完再繪製畫面。

也因為有執行先後順序的關係,假設 JavaScript 執行時間過久,UI thread 就會在那邊苦苦等待,觸發的事件就會等不到回應,使用者就會覺得這網頁很慢。

不過「執行時間過久」好像不太精確,怎麼樣才算是太久呢?目前大部分裝置都是 60 fps,也就是每一秒 60 個 frame,經過換算後,將 1 秒 / 60 (frames) 後, 可以得知一個 frame 的時間為 16.67ms(四捨五入),而一個 frame 除了 JavaScript 要執行以外,瀏覽器還有其他例行任務要做,所以 JavaScript 每個 frame 都要在 10ms 內完成,如果超過這時間就會掉幀,可能導致用戶覺得網頁使用起來不太流暢。

講了這麼多,終於可以說 requestIdleCallback 能夠用來做甚麼了!

這邊來偷偷複習一下上面那張圖的流程:
JavaScript -> Style -> Layout -> Paint -> Composite

requestIdleCallback 的用處就是在「每一個 frame 的空檔」執行 callback,要是 JavaScript 執行時間太久會導致掉幀,那我們把某些沒那麼優先的任務放在每個 frame 之間執行,就可以減少 JavaScript 執行時間,進而避免掉幀的可能性,網頁使用起來會慢的風險就降低了!簡單來說,requestIdleCallback 就是一個善用空閒時間的 Web API。

requestIdleCallback 也提供了一些彈性的使用空間,像是可以設置 deadline,來判斷這個 frame 中還有多少空閒時間(timeRemaining)來完成任務;以及有 timeout 這個參數能夠設置,表示超過這個時間就會讓瀏覽器停止手邊工作,強制執行任務。

另外要特別注意的是,requestIdleCallback 最好是用來進行一些 JavaScript 的運算等等的事情,不要涉及到 DOM 的操作,原因是如果在 requestIdleCallback 操作到 DOM,又會觸發瀏覽器去進行 Layout、Paint 的工作,導致 frame 的執行時間無法預期。

如果涉及 DOM 的操作時,建議使用 requestAnimationFrame,也就是待會要提到的 rAF。

繞了這麼久,所以為甚麼要用 requestIdleCallback?

前情提要結束了,我們再回頭看這段程式碼

if (typeof latestKeyedData !== 'undefined') {
  // delay revalidate if there's cache
  // to not block the rendering
  rIC(softRevalidate)
} else {
  softRevalidate()
}

typeof latestKeyedData !== 'undefined' 的時候,就代表我們目前是有 cache 的,代表使用者「目前畫面上可以看到內容」,因為我們把 cache 的資料先端上去給使用者看了。所以 revalidate 這件事就可以稍微延後,先不要 block 住 rendering,而是在每一個 frame 有空檔的時候執行。

從 SWR 的 source code 就能發現到,很多程式碼上的小細節,都圍繞著「stale-while-revalidate」這個核心概念在建構這套 library。

為何被改成 rAF?

承接上一段,requestAnimationFrame 會在 frame 的最初執行,用於優化瀏覽器的動畫效能、或是操作 DOM 的任務,延續上一張圖,requestAnimationFrame 執行時機如下:

圖片來源:Making a Silky Smooth Web

修改為 rAF 的起因是一個 issue,連結在此:
requestIdleCallback blocked by browser extensions

Data isn’t revalidating when certain browser extensions are installed. One I know for certain is UI.Vision RPA. It appears to hold up requestIdleCallback from ever being called.

看起來原因是 rIC 會因為安裝了某些 browser extensions 導致被 block 住,沒辦法 revalidate。

暫時性的解法是設置 timeout,因為剛剛有提過,設置 timeout 可以在超過預定的時間後,讓瀏覽器強制執行 rIC 的 callback。

不過維護者提議可以將 requestIdleCallback 改為 requestAnimationFrame,因此在 Replace rIC with rAF #744 這份 PR 就被改成 rAF 了。

至於維護者更深入的考量,以及這個 bug 更深入的起因,因為本人功力尚淺還無法解釋,如果有人能幫忙解答那我會非常感謝 QQ

👉 結語

這次從 React Query、SWR 的官方文件長了一些知識,知道了 stale-while-revalidate 這個 cache strategy。

另外,這也是首次用不一樣的角度去看 source code,以往沒有那麼常去看 source code,去深入了解一個 library 是如何實踐的;通常是遇到 bug、或是單純想了解架構才有機會翻 source code,但這次不太一樣,是出於好奇心,想知道怎麼做的,才開始這趟翻找 source code 的旅程,並從 library 的核心理念開始探究起「為何是這樣寫?」。不過同時也深深感受到了功力的不足,導致部分的解讀帶有不確定性,抑或是不太了解維護者的改動出自何種原因,看 source code 的速度也不是很快,這部分還需要多加努力 QQ

最後,如果文章有任何錯誤也麻煩不吝指正 🧐

👉 References

最後的最後,感謝無數無私的開發者分享所學,以下列出的參考資料大大幫助這篇文章的構成。

vercel/swr

tannerlinsley/react-query

Using requestIdleCallback

requestIdleCallback — 善用空閑時間

深入剖析 React Concurrent

轉譯效能

最佳化 JavaScript 執行

如何提升動畫效能?

Window.requestAnimationFrame()