超香的react data fetching library - SWR,與嘗試解釋它背後的實作原理

2020-05-24

SWR 介紹

  • 是美國Vercel公司出的一個 library, react Hook base,Vercel 公司在 2020 年 4 月從以前的名字ZEIT改名,它們就是做 react 社群裡很有名的 Server-side-rendering 框架 - next.js,還有Now這個用來幫助 web 開發者快速部署 web app 的服務的新創公司。
  • SWR就是一個 custom Hook,export 出來的 API 也很簡單,就useSWR, mutate , trigger這三個。它想要簡化 react app 裡,打 API,取資料,更新各處有用到相同的資料的 UI 這樣的事
  • 它有一套 cache 機制,會將前一次 fetch 過 API 的結果儲存起來,在每次再打 API 之前,就能先 return 就 cache 裡的資料,等到 API response 後,再更新畫面,在適當的時機並且用得好的話還能夠減少向後端 API 的請求次數。
  • 它使用用一個key的概念來作為辨識,就算在不同的 component 中useSWR,如果給的key相同,就會拿到同樣的資料。
  • key在最簡單的使用情境裡會用rest API路徑來做辨別,再稍微複雜一點時就是把 打 API 時的請求參數(request parameter)也作為最後產生 key 的一個依據,所以代表如果請求參數不同,key 就會不同,這是使用時可以自己控制的。i.e. 使用時可以把請求參數做為 key 的依據,也可以不考慮。
  • 有一套手動讓畫面上用到相同的 key(bound mutate 的情況) 或是特定的 key(global mutate 的情況)的時候,使其他 component 用新的資料 re-render 的機制,非常強勁,本文將會嘗試解說它是如何做到這樣。

往下看之前,必須先知道的知識

  • 讀者必須對 react hooks 已經蠻熟悉,常常在使用。
  • 知道 react hooks 裡的 stale closure 的狀況,如果沒聽過這個詞的話,可以參考一樣是本站的這篇文章,裡面很詳細地介紹了 react hook 的 stale closure 的特徵跟為什麼會有 stale closure

TL;DR

  • useSWR 的快取機制是把它放在一個在該模組中的 ES6-Map 裡(所以還是算是 private,並沒有被 export 出來),每次必須要 return 值出去之前,先用key去該 Map 裡找是否有上一次的資料,有的話就先 return 上一次的值。 這一點跟一般的 custom Hook 不同,以前人們寫的 customHook 通常會用useState, useReducer...去作為資料的承載者,然後利用對應的 setState 來讓用了這個 custom Hook 的 component re-render(重新執行),以讓該 component 拿到新的結果,
    但是用一個共同的 Map 是有道理的,因為如果不這樣做的話,在不同的 component 裡useSWR,然後 key 相同的話就沒辦法拿到同樣的資料了。

  • useSWR 有一個叫mutate的 API,它可以讓使用者手動指定用了哪個 key 的那些 useSWR 呼叫的 component,使他們接到新的資料,並 re-rerender,以用新的資料來更新畫面,有點像是 redux 那樣,有新的資料時讓各處有用到那個新的資料的地方,一起更新。
    這也是一個殺手級的功能,SWR用了一個很聰明的useStateuseCallback的巧妙組合技,很輕鬆地就實作出了這樣好像是 通知(notify) 的功能。而且要能夠做到這樣,很重要的一個條件是 react Hooks 有 stale closure 這樣的特性。這一部分是我這篇文章裡會花最多篇幅去解釋的,所以如果讀者有興趣的話,不妨繼續閱讀下去,一定會讓您有些收穫。

Dive to Code.....

SWR 的源碼目前還不算太複雜,主要的東西也幾乎都寫在同一個檔案裡,在這裡可以看到 src/use-swr.ts, 雖然還沒很複雜但是這檔案也有快 800 行,因為它的註解寫得還蠻詳細的,不過要全部看過還是有點累,所以我已經寫了一個較簡單的版本,在 codesandbox 上,只有 80 行不到,當然它就沒有涵蓋到原版的全部功能,只是稍微簡單地實作了快取通知的功能,但是這已經可以讓我們學到很多了! 如果先懂得我下面想講的概念,再去看 SWR 的 source code 絕對是如虎添翼啊啊啊啊

但是注意本文寫作時間是在 2020/05/22,SWR 0.2.2 版,無法保證未來的實作機制是不是會有所改變

在上面的實作中,我們按下其中一個按鈕,onClick 裡是 Math.random 出一個亂數,然後使用useSWR(key, fetcher)之後得到的mutate,透過mutate將新的值傳入,就會讓其他二個地方一起被更新,因為它們都使用了相同的 key: /123, 但是在 code 中我們看不到傳統的上到下的 state 傳遞,也沒有redux, 也沒有Context這是怎麼做到的呢?

import React, { useEffect, useState, useCallback } from 'react'

const cache = new Map() // 用來放快取資料的Map
const REVALIDATORS = new Map() // 用來放資料更新時 要通知的component的Map,  revalidators 指的就是那些對應的key有新的資料時需要被 re-render的component們
let REVALIDATOR_ID = 0

const getRevalidator_id = () => REVALIDATOR_ID
const changeRevalidator_id = () => {
  REVALIDATOR_ID += 1
}

const iteratorAndNotifySubscribers = key => {
  const subscribers = REVALIDATORS.get(key)
  const keys = Object.keys(subscribers)
  keys.forEach(k => subscribers[k] && subscribers[k]()) // 這個subscribers[k]  拿出來的就是 setState,執行了每個同樣的key的 useSWR之後的component,我們call它的setState 就能讓那些component re-render,而它們re-render再次執行useSWR時就會從cache裡用key取到新的值
}

const notifySubscribers = async (key, fetcherOrData) => {
  if (typeof fetcherOrData === 'function') {
    const fetcher = fetcherOrData
    const data = await fetcher()
    cache.set(key, data)
    iteratorAndNotifySubscribers(key)
  } else {
    const data = fetcherOrData
    cache.set(key, data)
    iteratorAndNotifySubscribers(key)
  }
}

const useSWR = (key, fetcher) => {
  const _key = key
  const makeReRender = useState(null)[1] // 每次新執行一次 useSWR(...) 就會利用 useState得到一個 setState,因為useState是return一個array,[1] 就能取到setter, 我們沒有要真的用到[0]的state,所以不需要拿到state
  const dispatch = useCallback(() => {
    // 把這個state setter放進useCallback裡,它就會被useCallback造成的 stale closure `封住`,setter就不會再被變動,這是之後我們要做notify時要使用的
    // ({}) 是非常關鍵的一手,它會讓我們 dispatch() 時 就一定會讓這個 useSWR的component re-render
    makeReRender({}) // key point
  }, [key])

  const boundMutate = useCallback(
    // boundMutate, 使用時不用給key, 因為key已經被 useCallback的stale closure封住在裡面,不會再改變。而data是在呼叫 boundMutate(data) 時傳入,所以會是變動的值
    data => {
      notifySubscribers(key, data)
    },
    []
  )

  useEffect(() => {
    const _revalidator_id = getRevalidator_id() + 1
    const subscribers = REVALIDATORS.get(_key)
    if (!subscribers) {
      const initSubScribersHolder = Object.create(null)
      initSubScribersHolder[_revalidator_id] = dispatch // 把setState放進對應的 key 和revalidator_id的位置,因為會有多個component用到同樣的key,所以必須是 {key: {id: dispatch}}  這樣的兩層HahsTable (第二層是用object)
      REVALIDATORS.set(_key, initSubScribersHolder)
    } else {
      subscribers[_revalidator_id] = dispatch
    }
    changeRevalidator_id()
    return () => {
      // coponent要從畫面上消失時,要將它從通知清單裡清除掉
      const subscribers = REVALIDATORS.get(_key) // 注意這個_key也是被stale closure抓進去,這就讓我們在清除的時候不必再去想要怎麼得到key,在宣告clear function的當下 它就已經被抓進去了
      subscribers[_revalidator_id] = null // 這個_revalidator_id也是,宣告的當下被抓進 clear function裡,而且不可能再被改變
    }
  }, [])

  useEffect(() => {
    notifySubscribers(key, fetcher)
  }, [key])

  return {
    data: cache.get(key),
    mutate: boundMutate,
  }
}

export { useSWR }

notify 機制的實作關鍵

每次某個 component 裡執行第一次 useSWR(...)時 就會呼叫 useState 得到一個 setState,這邊取名叫 makeReRender

const makeReRender = useState(null)[1]

把它 makeReRender 用 useCallback 包起來,利用 stale closure 的特性,讓這個 function 是個 constant, makeReRender({}) 傳進去的{}是非常關鍵的,沒有它不行,沒有它的話,就沒辦法 make that component that invoke useSWR re-render 了。 原碼 Line197

const dispatch = useCallback(() => {
  makeReRender({}) // key point
}, [key])

利用呼叫useSWR時傳入的 key,把每次第一次呼叫 useSWR 時得到的setState存在一個 Map 裡,因為可能有很多個 component 用到同樣的 key,這 Map 必須是兩層的,

useEffect(() => {
  const _revalidator_id = getRevalidator_id() + 1
  const subscribers = REVALIDATORS.get(_key)
  if (!subscribers) {
    const initSubScribersHolder = Object.create(null)
    initSubScribersHolder[_revalidator_id] = dispatch // 把setState放進對應的 key 和revalidator_id的位置,因為會有多個component用到同樣的key,所以必須是 {key: {id: dispatch}}  這樣的兩層HahsTable (第二層是用object)
    REVALIDATORS.set(_key, initSubScribersHolder)
  } else {
    subscribers[_revalidator_id] = dispatch
  }
  changeRevalidator_id()
  return () => {
    // coponent要從畫面上消失時,要將它從通知清單裡清除掉
    const subscribers = REVALIDATORS.get(_key) // 注意這個_key也是被stale closure抓進去,這就讓我們在清除的時候不必再去想要怎麼得到key,在宣告clear function的當下 它就已經被抓進去了
    subscribers[_revalidator_id] = null // 這個_revalidator_id也是,宣告的當下被抓進 clear function裡,而且不可能再被改變
  }
}, [])

當外部呼叫mutate(data)的時候,利用 key,把對應的setState取出來,然後呼叫
(下面 code 裡最後一個(),所以subscribers[k]就是某個setState),

data 並沒有傳入到 setState 裡,因為在呼叫這裡之前就已經把 cache 裡的 data 更新,我們只是要利用useState之後會讓 component 再次執行的機制, 使各個 component 再次執行useSWR,流程中就會從 cache 裡把新的值取出來,然後 return 到 component 上 這流程類似源碼的 Line59

const iteratorAndNotifySubscribers = key => {
  const subscribers = REVALIDATORS.get(key)
  const keys = Object.keys(subscribers)
  keys.forEach(k => subscribers[k] && subscribers[k]()) // 這個subscribers[k]  拿出來的就是 setState,執行了每個同樣的key的 useSWR之後的component,我們call它的setState 就能讓那些component re-render,而它們re-render再次執行useSWR時就會從cache裡用key取到新的值
}

to sum up

  • 把各個 setState function 存在一個 Map 裡
  • 在資料有變動時(自己手動發動 mutate 或 trigger 來告訴 SWR 資料有變動),更新快取裡的資料,抓出對應的setState,發動,使其觸動 component 的 re-render(源碼在這邊還有做很多更細微的操作,如打 API 的時候在 fetch 完之前,先更新isValidating,讓各個 component 知道現在正在打 API 中 Line227
  • 各個有用到同樣的 key 的 component 們就會因為再次執行useSWR而拿到新的資料,更新 return 的 UI 描述。

reference

response