了解 SWR 的運作機制,How this async state manager works ?
近期公司內的一個大型前端專案想要進行 redux 的 refactor,因為現在的寫法需要寫很多重複的 boilerplate code,尤其是在串接後端 API 的時候寫起來並不是那麼舒服,因此團隊成員開始進行可行方案的 survey 與分享。
在某次公司內部技術分享時,同事介紹了 react-query 這個用來做 data-fetching 與 caching 的 react hooks library,透過這次分享我了解到一個蠻重要的觀念:狀態 (state) 在前端專案裡面是到處可見且不可或缺的,而這些狀態基本上又可以歸類為兩大類
- UI State : 用來控制畫面或互動的狀態,舉例來說 modal 的 isOpen 就屬於一種 UI State。
- Asynchronous State (Server State) : 這種狀態指的是實際上「擁有」data 的是 server 端,前端只是暫存狀態,像是存一份 cache 或是一份 snapshot 的概念,最常見的就是透過 API data fetching 所存取的資料。
最近非常火紅的 react-query、由 Next.js 團隊開發的 SWR、包含在 Redux Toolkit 中並由 Redux 團隊開發的 RTK query 其實都是用來管理 Asynchronous State 的 Async State Manager,這些工具的出現打破了原本將兩種 State 混合管理的習慣,讓程式更好維護,同時也幫忙實作了一些功能,例如 cache 與 revalidate 機制、非同步渲染、依賴請求(指一個 request 必須等待另一個 request 完成後才能開始執行)…等等,不只提升了 UX,也提升了 DX。所以說這麼好的東西,不看看 source code 了解一下背後實作機制這樣對嗎!?🤣
稍微比較了一下 react-query、SWR、RTK query 三種相似的函式庫,以功能豐富性來說是 react-query > RTK query > SWR,因為我主要想理解的是 cache revalidate 相關的功能,對於這三種函式庫來說都是基本功能,於是我決定選擇 Source Code 看起來最精簡的 SWR 來研究一下運作機制,主要會著重在 cache 與 revalidate 的部分。
(建議在往下閱讀前,要先大致了解 SWR 的使用方式與想要解決的問題,如果不知道這些函式庫要解決什麼問題的讀者可以參考這篇文章,如果讀者習慣的框架不是 React,其他前端框架其實也有推出類似的函式庫,例如 vue-query,背後的機制應該都是差不多的。)
從需求出發
這是我第一次嘗試撰寫 open source 專案 source code 解析的文章,一般來說開源專案的 source code 往往很龐大,要直接貼 code 講解並不容易也不一定好理解。於是我想嘗試用另外一種方式來分享自己看完 SWR source code 後的理解,首先會先從開發上的需求出發,慢慢打造 SWR 的簡易版本,雖然程式碼會跟實際的 SWR Source Code 有非常大的差異,但重點在於釐清它實作的邏輯與想法。
Data Fetching
最一開始就有提到 SWR 這類工具其實可以看作是一種 Async State Manager,負責管理與 data fetching 相關的 state。我們先假設沒有 SWR 這個工具可以用,如果要實作 API data fetching 的功能,在 react 中會是怎麼寫呢?
這個寫法我想大家應該都不陌生,透過 useState 自行管理 data、error 與 loading state。哎呦,不錯呦!看起來還算簡潔,不過很快你會發現一個問題,大多數處理 API fetching 的邏輯都差不多(同樣需要處理 data、loading、error 的狀態),熟悉 React 的讀者會知道可以把一些通用的邏輯抽成一個 customized hook,避免一直在實作重複的邏輯。
之後就可以在不同的 component 中使用這個 hooks ,例如
看起來不錯,可以在不同 component 裡複用相同的邏輯,不過其實我們可以讓這個 hooks 變得更加泛用。
自定義 fetcher & Global Fetcher Config
注意到上面的 useFetch hooks,會發現我們使用了瀏覽器預設的 fetch API 來做資料的抓取,不過有時候你可能習慣使用的是其他 data fetching 的 library,例如說 axios,因此我們可以修改一下剛剛的 useFetch hooks,讓使用者可以傳入自己想要使用的 fetcher。此外,如果每一次都要手動傳入 fetcher 好像也挺麻煩的,通常在一個專案中會使用的 fetcher 大部分都會是相同的才對,所以我可以在使用 global config 實作一個預設的設定,如果使用者有傳入 fetcher,就使用他所傳入的,如果使用者沒有傳入則使用預設的 fetcher。這個 Global Config 可以透過 React 的 Context API 來達成。
依賴請求 Dependent Fetching
有時候我們會遇到 network requests 之間是有依賴性的,例如
其實資料請求的依賴可以看作是一個「有向無環圖(DAG)」,有的請求依賴於其他請求,有的則沒有其他依賴
最高效的方式應該是盡可能的達到並行的請求,並在依賴的資料回傳時立即發起請求。舉例來說,今天 c 這個請求需要請求 a 與請求 b 回傳的資料作為 input parameters,這時候我們可能會這樣子寫
Promise.all([fetchA, fetchB]).then([a, b] => fetchC(a, b));
想像一下今天依賴關係在複雜一點,a, b, c, d 四個請求中 a依賴於 d,c 依賴於 b 與 a,程式碼寫起來應該會非常不易讀且不直覺,雖然可以透過一些方法例如 promise、 async/await、observable、Saga 等方式來解決依賴請求的問題,但是每次都要自己定義依賴關係還是有點不太方便。
理想狀況下,我們會希望客製化的 useFetch hook 在處理依賴請求時可以像以下一樣簡潔,不用特別寫什麼邏輯就能解決 API 依賴的問題,能夠做到在依賴的資料回傳時才立即發出請求。
當前版本的 useFetch 如果在請求 a 的時候 d 的資料還沒回傳所以會是 undefined,就會導致 throw error 並渲染失敗,我們應該如何改進這個問題呢?
我們可以依賴 React 的 re-render 機制。
上面的 code 有兩個重點,第一個是將原本只接受傳入字串的 url key 變成也能傳入 function 的形式,並且使用 try catch 在函式執行發生錯誤時(以我們假設的狀況就是依賴的請求尚未準備好)將 key 指定回空字串。
第二個重點是利用 react 的 re-render 與 effect dependencies 機制,將 key 放到 useEffect 的 dependencies list 裡,假設我們的乞求是先 b 依賴於 a,所以 a 會先發出請求,b 則會因為 key 會被轉成空字串而不會發起請求。當 a 請求回傳時,會去做 setState (上面那段 code 的 setData(newData)),這會造成頁面 re-render,此時 b 請求需要的 a dependency 已經準備好了,因為 key 改變的關係,會重新執行 useEffect 裡面的程式碼,也就是實際去抓取資料,這樣就解決了依賴請求的問題在使用時,如果有依賴於其他請求的需求,我們只要用 function 的方式傳入 URL key 就可以了,非常直覺的寫法。
Cache & Stale-While-Revalidate
其實這個 section 應該才算是本篇文章的核心重點, 也許你從來沒聽過 Stale-While-Revalidate 這個詞,但是它其實就是 「SWR」的全名喔!除了 SWR 以外,react-query 與 RTK query 其實背後也有實作這個機制,可以說這個機制其實是這些套件的核心,首先先來了解一下它的概念吧!
Stale-While-Revalidate
stale-while-revalidate
其實源自於 HTTP Cache-Control
header 的一個屬性
Cache-Control: max-age=1, stale-while-revalidate=59<秒數>
它的主要概念為:當第一次發出 request 時,瀏覽器會將回傳的資料存到快取裡,當之後又有相同的 request 時,瀏覽器會優先返回快取的版本,讓使用者可以迅速得到資料或是看到畫面,優化了使用者的體驗,並在 background 驗證快取的資料是不是已經過期,如果需要更新就會抓取最新資料並更新快取,當下次又有請求時就可以拿到剛剛更新過並存到快取的資料。
以上面的例子來說,在第一次請求後,在 1 秒內的其他請求都會直接從快取取得資料,不會做任何 revalidation,而在 1–60 秒的區間如果有請求發生,除了回傳快取版本以外,還會在「background」去 revalidate 快取的資料,並在資料有更動時更新快取。而如果是超過 1 分鐘後的請求,就會直接以同步的方式發起網路請求抓取最新資料。
這麼做的好處有因為快取機制頁面的載入速度會很快,同時也能控制不會得到過於老舊的頁面或資料,可以說是在效能與資料新鮮度之間做一個平衡。
stale-while-revalidate for API data fetching
也許你會覺得很奇怪,既然 HTTP caching 的 cache-control 可以做到 stale-while-revalidate 並交給瀏覽器來管理,為什麼還需要透過 SWR 這種套件在程式的 memory 中再維護一份 cache 呢?
這個問題問得很好,原因在於 HTTP Caching 的方式比較適合管理靜態的資源 ,至於動態的 API,要得出一個合理的 max-age 與 stale-while-revalidate 的時間是一個不容易的問題,也因為 API 資料經常變動的特性,其實不使用快取也許會更適合。
如果真的針對 dynamic data 使用 HTTP 的 stale-while-revalidate,我們只能得到 cache data 或是重新抓取的 fresh data 其中之一,但這對於前端應用來說並不是最好的狀況,因為我們會希望使用者看到的資料盡量都是最新的,但是等待伺服器重新回應新的資料代表著增加頁面延遲的可能性,那我們還有沒有更好的做法?這就是 SWR 這些 library 可以做到的事啦!
策略是這樣的:
- 當第一次發出 API 請求時,將 response cache 起來
- 當之後有請求且可以在快取找到資料時,「立即」回傳快取的版本,並在 background 非同步發起 revalidation 請求,並在資料回來時「更新畫面」與快取。
與 HTTP Cache-Control header 的版本最大的不同大概就是在 revalidation 後可以透過 react 的機制來使頁面重新渲染,讓使用者可以自動獲取更動後的資料,實作起來大概是以下這個樣子:
這種方式可以避免在抓取新資料時使用者只能看到一片空白或是 loading state (第一次請求還沒有 Cache 時除外),而是可以看到之前被 cache 的舊資料,讓使用者體驗更好,並在新資料回傳時 re-render,達到希望資料是最新的需求。
(想看 react hooks 實作的超簡易版本可以參考我在鐵人賽上的文章)
在 SWR 中這些是如何實現的?
首先來看看 cache 的部分,在 SWR 中是使用 ES6 的 Map 來實現 in memory 的 cache,以 { key: value } 的形式儲存 network request 的回傳結果,詳細 source code 可以看 SWR 的 src/utils/cache.ts。
接下來是 revalidate 的部分,當然 SWR Source Code 是有點複雜的,因此這邊我只會大概說明相關的概念,在這之前得先了解幾個 SWR 在實作上有趣的小技巧,這對於理解 revalidate 流程會比較有幫助。
useIsomorphicLayoutEffect
一般來說 revalidate 會放在 useEffect 這樣的 life-cycle method 裡面,讓我們可以指定某些狀況下會重新執行這個 life cycle 裡的程式。在 SWR 中有上面這一段 code,在 client side 的時候預設是使用 useLayoutEffect 的。會這麼做是因為 useLayoutEffect 的執行時機點是在瀏覽器 paint screen 之前,而 useEffect 則是在瀏覽器畫面更新後才會去執行,因此使用 useLayoutEffect 可以讓抓資料、revalidate、處理 cache 的時機點再往前一些。
softRevalidate
回想一下我們前面撰寫的 custom hook useFetch,如果分別在兩個不同的 component 使用它,並指定抓取同一個 API endpoint 的資料
按照先前的寫法,因為沒有特別處理,雖然它們抓取的是同一個 API endpoints,卻會發出兩次 network request,看起來有點沒有效率,當 component 層級一深也會出現 waterfall 的狀況。
SWR 針對這點做了一些特別的處理,並命名為 softRevalidate。
簡單來說就是當 dedupe 設定為 true 時,SWR 會將短時間內相同 key 的 request 合併成一次,避免重複觸發相同 request 的狀況。
至於實際上 revalidate 實作了些什麼,建議直接閱讀 source code 中 use-swr.ts 中的 validate 函數。
rIC & rAF
rIC 與 rAF 指的分別是「requestIdleCallback」與 「requestAnimationFrame」這兩個 Web API。
React 16 重構成 fiber 架構後,fiber reconciler 的「異步、可中斷」其實就是透過 rIC 與 rAF 來依照任務的優先級做調度,高優先級的任務塞進 requestAnimationFrame,低優先級的任務則塞進 requestIdleCallback 中。
一般來說頁面渲染時的一個 frame 大概包含以下工作
而 requestIdleCallback 會在 frame 與下一個 frame 的空擋執行,這其實代表沒有辦法保證丟進 rIC 的 callback 一定會被執行,如果網頁的性能沒有處理好,出現嚴重的掉幀狀況,也許就沒有多餘的時間可以給 rIC 執行,所以一般來說 rIC 的 callback 會是一些優先級沒有那麼高的任務。
所以說…rIC 跟 rAF 跟今天的主題 SWR 又有什麼關係?
前面有提到,SWR 實踐了 stale-while-revalidate 的策略,當快取有資料時,會直接回傳快取的資料給使用者,再在背後執行 data revalidate,而這個 revalidate 相較起來其實是優先級沒有那麼高的任務,不希望它導致 render 的過程或是其他任務被 block 住,因此 SWR 很聰明的利用了 rIC 來做 revalidate。
當第一次請求或是沒有快取中沒有資料時,會立即去執行 revalidate,而當快取有資料時 revalidate 則視為優先度較低的任務,因此會延緩它的執行。
眼尖的你會發現怎麼 source code 中使用的是 rAF 而不是剛剛提的 rIC ?
其實一開始 SWR 是使用 rIC 來執行 revalidate 的,直到這個 issue談到有些 browser extension 會意外地導致 rIC 沒辦法順利執行 revalidate 的任務,最後在這個 pull request將 rIC 替換成一樣不會 block rendering 的 rAF (雖然我自己認為以 revalidate 這樣優先層級較低的任務來說,使用 rIC 比較合乎情理)
從上圖的 frame 流程來看,這樣就會確保 revalidate 一定會執行到了。
另外由於 rAF 並不是所有瀏覽器都有支援,所以 SWR 使用 setTimeout 當作 Polyfill。
Window Focus Refetching
各位應該有看過一些網站可以在使用者從其他 tab 甚至其他瀏覽器視窗重新回到網站(重新 focus)時去抓取最新的資料並更新畫面。透過 SWR ,我們也可以輕鬆做到這個功能。它背後的機制其實不難理解,在剛剛我們已經了解 revalidate 大致上的機制,focus refetching 其實概念就是使用 event listener,當監聽到 focus 事件時就去執行 validation
window.addEventListener('focus', revalidate, false);
當然實際上的程式碼沒有這麼簡單,這邊只是為了方便讓各位了解它背後的概念。
會提到 Window Focus Refetching 這個功能其實是想告訴各位許多功能的本質都是通過不同的觸發條件與時機來執行 revalidation,在了解 revalidation 的機制後,我們其實可以依照需求組合或創造出不同的 revalidation 場景,比方說在頁面滾動的時候去做 revalidation 或是 Interval polling 都是可以做到的,這邊就交給各位自行發揮與研究啦!
各位還記得一開始實作的 useFetch custom hooks 嗎?看到這裡各位應該有足夠的概念可以幫它再實作 cache 與 revalidate 機制,變成一個極簡版的 useSWR 啦!這邊就不實作囉!(筆者很明顯在偷懶,請各位不要戳破)
總結一下 SWR 一些值得研究的實作技巧
- 依靠 React 的 setState 來控制 revalidate 後 re-render 的觸發(這部分本篇文章沒有深入探討,詳情可以參考這篇文章)
- 同樣是依靠 React 的機制來做到 dependent request
- 用 JS 的 Map 來實現 cache (相較於 react-query 使用 array 來當作快取我覺得直覺許多)
- 將相同 key 的請求合併為一個,避免發出許多相同的 request 或造成 waterfall 的狀況
- 利用 requestAnimationFrame 來執行 revalidate,避免 block rendering
本篇結論
雖然我想公司團隊最後應該會選擇功能最豐富的 react-query 或是綁在我們現在就有在使用的 Redux-Toolkit 裡的 RTK-query 來當作 async state manager,不過這次去看 SWR 的 source code 還是讓我很有收穫,了解到 stale-while-revalidate 這樣的快取策略與 React Hooks 結合後,可以讓使用者先看到快取的資料,並在背後進行不會 block rendering 的 revalidation ,最後再透過 re-render 機制更新頁面上的資料,確保資料的新鮮度,同時也提升了使用者體驗。
有機會的話,非常建議可以閱讀 SWR 的 source code,相比其他專案,它的 source code 量級小了非常多,理解起來相對會比較容易,尤其更推薦 React 開發者可以研究一下,當你研究後會發現 SWR 身為一個與 React 高耦合的 library,在許多小細節上充分利用了 React 的一些特性,看懂後會覺得收獲滿滿啊!