深入淺出 useSWR 原理

原文鏈接:https://zhuanlan.zhihu.com/p/93824106 作者:飛冰-chenbin92

本文主要是基於 SWR 源碼對其原理進行分析,但並不會直接從源碼開始,而是從實際需求場景一步一步推導進而實現 SWR 的功能,如果不瞭解 SWR 是什麼,可以先看上一篇《SWR:最具潛力的 React Hooks 請求庫》或者直接看 SWR 的官方介紹文檔。

本文完整示例代碼 github/swr-source-code

目錄

  • 需求場景
  • 簡易模型
  • 功能迭代
    • 自定義請求
    • 全局配置項
    • 依賴請求
    • 數據驗證
    • 其他功能
  • 原理分析
  • 總結

PS:源碼分析部分較爲詳細導致篇幅過程,文章閱讀約需要 30 分鐘,如對細節不關心可以直接跳到最後看原理分析和總結部分。

需求場景

隨着 React Hooks 的浪潮,各種基於 Hooks 的方案越來越多,其中主要包含 狀態管理、數據請求、通用功能的封裝 等等。而 數據請求 是日常業務開發中最常見的需求,那麼在 Hooks 模式下,我們應該如何請求數據,先來看下面的一個簡單示例。

產品需求: 首頁通過接口獲取 github trending 項目列表,然後點擊列表項可查看單個項目的信息。

程序實現: 接到需求後一頓操作,無非就是在數據請求時需要顯示 loading 效果,數據獲取完成時展示列表數據,以及考慮請求錯誤後的容錯處理,穩健如飛的擼出了如下代碼:

// 首頁列表實現
const Home = () => {
  // 設置初始數據
  const [data, setData] = useState([]) 
  // 設置初始狀態
  const [isLoading, setIsLoading] = useState(false)
  // 設置初始錯誤值
  const [isError, setIsError] = useState(false)

  useEffect(() => {
    // 定義 fetchFn 
    const fetchData = async () => {
      setIsLoading(true)
      try {
        const result = await fetch('api/data')
        setData(result)
      } catch(error) {
        setIsError(true)
      }
      setIsLoading(false)
    }
    // 調用接口
    fetchData()
  }, [])
  
  return (
     <div className='hero'>
        <h1 className='title'>Trending Projects</h1>
        {isError && <div>Something went wrong ...</div>}
        <div>
            {
              isLoading ? 'loading...' :
              data.map(project =>
                <p key={project}><Link href='/[user]/[repo]' as={`/${project}`}><a>{project}</a></Link></p>
              )
            }
        </div>
    </div>
  )
}

獲取項目詳情實現與上面基本一樣,基礎代碼如下:

// 項目詳情實現
const project = () => {
  const [data, setData] = useState([]) 
  const [isLoading, setIsLoading] = useState(false)
  const [isError, setIsError] = useState(false)

  useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true)
      try {
        // 獲取的 API 帶了 id 參數
        const result = await fetch(`api/data?id=${id}`)
        setData(result)
      } catch(error) {
        setIsError(true)
      }
      setIsLoading(false)
    }

    fetchData()
  }, [id])
  
  return ()
}

如上面的例子所示,代碼看上去很簡潔,一個純函數包含了數據請求時的請求狀態、容錯處理、數據更新,視圖渲染,以及使用了 React 的 useEffectuseState 兩個 Hooks API,很好的滿足了場景需求。

這看上去很好,但你可能存在一些疑惑,從示例代碼可以看到獲取項目列表和項目詳情的 數據請求部分的代碼 基本上是一樣的,同樣的代碼重複寫兩遍,這顯然是不能接受的,基於此通常的做法是對其進行一層抽象封裝,實現邏輯的複用,具體如下。

簡易模型

基於重複的數據請求代碼,對比發現只是 API 和初始數據值的不同,其他如設置 dataisLoadingisError 的邏輯都是一樣,可以先將其進行一層抽象封裝以便進行復用,簡易模型如下:

import { useState, useEffect } from 'react'
import fetch from 'isomorphic-unfetch'

/**
 * 對 fetch 進行封並返回 isLoading、isError、data 三個值
 * @param {*} url 請求的 API 地址
 * @param {*} initialData 初始化數據
 */
function useFetch(url, initialData) {
  const [data, setData] = useState(initialData)
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false)

  useEffect(() => {
    const fetchData = async () => {
      setIsError(false)
      setIsLoading(true)

      try {
        const result = await fetch(url)
        const newData = await result.json()
        setData(newData)
      } catch(error) {
        setIsError(false)
      }

      setIsLoading(false)
    }

    fetchData()
  }, [])

  return [data, isLoading, isError]
}

export default useFetch;

然後修改我們的業務代碼如下,這時視圖層只需要一行代碼即可完成數據的請求,並返回了 dataisLoadingisError 三個值,渲染處理邏輯完全一致。

// 首頁列表實現
const Home = () => {
  const [data, isLoading, isError] = useFetch('api/data', []);
  
  return (
    // render jsx
  )
}

// 項目詳情實現
const project = () => {
  const [data, isLoading, isError] = useFetch(`api/data?id=${id}`, []);
  
  return (
    // render jsx
  )
}

至此我們的 useFetch API 形式如下,接收 urlinitialData 作爲參數,返回 dataisLoadingisError 三個值。

功能迭代

上面的代碼看起來應該是不錯了,通過 useFetch 的封裝,在具體的視圖中只需要調用 useFetch 傳入對應的 API 地址和初始數據,即可正常工作,然而實際的業務場景並不都是如此,接下來將逐步對它進行功能迭代,滿足常見的業務開發需求。

自定義請求

上面實現的 useFetch 是將 fetch 的實現邏輯進行了內置,且默認使用了 isomorphic-unfetch 這個庫,在實際業務中,你可能習慣了使用 axios,也可能需要對 fetch 的邏輯進行定製,那麼現有的 useFetch 顯然就不能滿足要求,這時我們可以考慮將 fetch 邏輯通過參數的形式進行傳入,外層可以自定義獲取數據的行爲,如果不傳遞則默認爲 undefined

import { useState, useEffect } from 'react'

// 支持傳入 fetcher 用於自定義請求
function useFetch(url, fetcher) {
  const [data, setData] = useState(undefined)
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false)

  useEffect(() => {
    const fetchData = async () => {
      setIsError(false)
      setIsLoading(true)

      try {
        // 這裏直接調用外部傳進來的 fetcher,並使用 url 作爲參數
        const newData = await fetcher(url);
        setData(newData)
      } catch(error) {
        setIsError(false)
      }

      setIsLoading(false)
    }

    fetchData()
  }, [url])

  return [data, isLoading, isError]
}

export default useFetch;

這時在組件層獲取數據的方式,可自定義請求函數如下:

import fetch from 'isomorphic-unfetch'

const customFetch = async (...args) => {
  const res = await fetch(...args)
  return await res.json()
}

const Home = () => {
  // useFetch 的第二個參數可以使用自定義的 customFetch
  const [data, isLoading, isError] = useFetch('api/data', customFetch);
  return ()
}

可以看到,useFetch 現在可以接收一個函數用於獲取數據,且該函數的唯一參數爲 useFetch 的第一個參數 url,這意味着可以使用你喜歡的任何請求庫來獲取數據。

全局配置項

我們已經可以通過自定義 fetcher 獲取數據,但每個調用處都需要重複的去傳遞 fetcher,因此可以考慮將其統一配置,在調用時可以直接使用該默認配置,也可以自定義配置來覆蓋,爲此需要一個全局配置的方式。

在 React 中全局配置數據共享最簡單的就是通過 Context 方式,這裏我們選擇使用 useContext 來實現 useFetch 的全局配置功能。

import { createContext } from 'react'

const useFetchConfigContext = createContext({})
useFetchConfigContext.displayName = 'useFetchConfigContext'

export default useFetchConfigContext;

useFetch 改造如下:

import { useState, useEffect } from 'react'

function useFetch(url, fetcher, options = {}) {
  const [data, setData] = useState(undefined)
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false)

  // 通過 useContext 獲取 useFetchConfigContext 的全局配置
  const config = Object.assign(
    {},
    useContext(useFetchConfigContext),
    options
  )

  let fn = fetcher
  if(typeof fn === 'undefined') {
    // 使用全局配置的 fetcher
    fn = config.fetcher
  }

  useEffect(() => {
    // ...
  }, [url])

  return [data, isLoading, isError]
}

// 導出 useFetchConfig 
const useFetchConfig = useFetchConfigContext.Provider;
export { useFetchConfig };

export default useFetch;

現在組件層獲取數據的方式如下,在全局統一配置 fetcher,然後在調用 useFetch 的組件中只需要傳入對應的 url 即可:

  • 全局配置
import React from 'react'
import App from 'next/app'
import { useFetchConfig as UseFetchConfig } from '../libs/useFetch'
import fetch from '../libs/fetch';

class MyApp extends App {
  render() {
    const { Component, pageProps } = this.props
    return (
      /* 通過 useFetchConfig 配置全局的 useFetch 參數 */
      <UseFetchConfig value={{ fetcher: fetch }}>
        <Component {...pageProps} />
      </UseFetchConfig>
    )
  }
}

組件調用

const Home = () => {
  const [data, isLoading, isError] = useFetch('api/data');
  return (
    // jsx code
  )
}

至此,我們提供了一個全局配置來代替每個調用 useFetch 時的重複邏輯,現在我們的 useFetch 功能如下:

依賴請求

除了自定義請求和全局配置,實際業務中另外一類常見需求就是請求之間的依賴,如 B 依賴 A 的返回結果作爲請求參數,通常的寫法如下:

const { data: a } = await fetch('/api/a')
const { data: b } = await fetch(`/api/b?id=${a.id}`)

那麼在 useFetch 的模式下該如何處理這類需求,當 /api/a 接口未正常返回結果時 a 的值爲 undefined ,在 /api/b 接口中調用 a.id 就會直接拋出異常,導致頁面渲染失敗。

那這是否意味我們可以假設當調用接口時 url 這個參數拋出異常,也就意味着它的依賴還沒有準備就緒,暫停這個數據的請求;等到依賴項準備就緒時,然後對就緒的數據發起新的一輪請求,以此來解決依賴請求的問題。

而依賴項準備就緒的時機也就是在任一請求完成時,如上面的 /api/a 請求完成時 useFetch 會通過 setState 觸發重新渲染,同時 /api/b?id=${a.id} 得到更新,只需要將該 url 作爲 useEffect 的依賴項即可自動監聽並觸發新一輪的請求。其示意圖如下:

通過上面的分析, useFetch 處理依賴請求的邏輯主要分爲以下三步:

1.約定參數 url 可以是一個函數並且該函數返回一個字符串作爲請求的唯一標識符;
2.當調用該函數拋出異常時就意味着它的依賴還沒有就緒,將暫停這個請求;
3.在依賴的請求完成時,通過 setState 觸發重新渲染,此時 url 會被更新,同時通過 useEffect 監聽 url 是否有改變觸發新一輪的請求。

const Home = () => {
  // A 和 B 兩個並行請求,且 B 依賴 A 請求
  const { data: a } = useFetch('/api/a')
  const { data: b } = useFetch(() => `/api/b?id=${a.id}`)

  return ()
}

const useFetch = (url, fetcher, options) => {
  
  const getKeyArgs = _key => {
    let key
    if (typeof _key === 'function') {
      // 核心所在:
      // 當 url 拋出異常時意味着它的依賴還沒有就緒則暫停請求
      // 也就是將 key 設置爲空字符串
      try {
        key = _key()
      } catch (err) {
        key = ''
      }
    } else {
      // convert null to ''
      key = String(_key || '')
    }
    return key
  }

  const key = getKeyArgs(url)

  useEffect(() => {
    const [data, setData] = useState(undefined)
    const fetchData = async () => {
      try {
        const newData = await fn(key);
        setData(newData)
      } catch(error) {
        // 
      }
    },

    fetchData()
    
  // 核心所在
  // 當 A 請求完成時通過 setData 觸發 UI 重新渲染
  // 繼而當 url 更新時觸發 B 的新一輪請求
  }, [key])

  return {}
}

如 SWR 官方文檔所描述,允許獲取依賴於其他請求的數據,且可以 確保最大程度的並行(avoiding waterfalls),其原理主要是通過約定 key 爲一個函數進行 try {} 處理 ,並巧妙的結合 React 的 UI = f(data) 模型來觸發請求,以此確保最大程度的並行。

SWR also allows you to fetch data that depends on other data. It ensures the maximum possible parallelism (avoiding waterfalls), as well as serial fetching when a piece of dynamic data is required for the next data fetch to happen.

當然在依賴請求過程中,我們可能需要對 useFetch 有更多的控制權,比如設置請求的超時時間,以及請求超時需要觸發回調,請求成功/失敗的回調等。我們可以通過添加第三個參數 options 進行傳入,完整實現如下:

function useFetch(url, fetcher, options = {}) {
  // 從 useContext 獲取全局配置和默認配置進行合併,繼而和 useFetch 的 options 進行合併
  // 優先級分別是 useFetch > useFetchConfigContext > defaultConfig
  const config = Object.assign(
    {},
    defaultConfig,
    useContext(useFetchConfigContext),
    options
  )

  const key = getKeyArgs(url)

  // 通過 options 設置初始值
  const [data, setData] = useState(options.initialData)
  const [isLoading, setIsLoading] = useState(false);
  const [isError, setIsError] = useState(false)

  const fetchData = useCallback(
    async () => {
      if(!key) return false
      setIsLoading(true)

      let loading = true

      let newData

      try {
        // 請求超時觸發 onLoadingSlow 回調函數
        if (config.loadingTimeout) {
          setTimeout(() => {
            if (loading) config.onLoadingSlow(key, config)
          }, config.loadingTimeout)
        }

        newData = await fn(key);

        // 觸發請求成功時的回調函數
        config.onSuccess && config.onSuccess(newData, key, config)

        // 批量更新
        unstable_batchedUpdates(() => {
          setData(newData)
          setIsLoading(false)
        })
      } catch(error) {
        unstable_batchedUpdates(() => {
          setIsError(true)
          setIsLoading(false)
        })

        // 觸發請求失敗時的回調函數
        config.onError && config.onError(error, key, config)
      }

      loading = false
      return true
    },
    // eslint-disable-next-line
    [key]
  )

  useEffect(() => {
    fetchData()
  }, [fetchData])

  return {data, isLoading, isError}
}

相關技術點:

unstable_batchedUpdates

在 React 中某些場景下如果多次調用 setState 則會導致多次的 render,但有些 setState 的渲染是沒有必要的,如上述實現代碼的 setData(data)setIsLoading(false) ,因此 react 提供了 unstable_batchedUpdates API 用來批量處理。

// 示例來源:Does React keep the order for state updates
import { unstable_batchedUpdates } from 'react-dom';

promise.then(() => {
  // Forces batching
  ReactDOM.unstable_batchedUpdates(() => {
    this.setState({a: true}); // Doesn't re-render yet
    this.setState({b: true}); // Doesn't re-render yet
    this.props.setParentState(); // Doesn't re-render yet
  });
  // When we exit unstable_batchedUpdates, re-renders once
});

useCallback

對事件句柄進行緩存,如 useState 的第二個返回值是更新函數 setState,但是每次都是返回新的,使用 useCallback 可以讓它使用上次的函數。useCallback 接收內聯回調函數以及依賴項數組作爲參數,該回調函數僅在某個依賴項改變時纔會更新。

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  
  // a, b 更新時纔會調用回調函數
  [a, b]
);

到這裏 useFetch 的 API 形式如下,已經支持傳入請求的 url 、自定義的 fetcher 、以及一些可選的配置項。

數據驗證

React 團隊在前不久的 React Conf 上發佈了關於 Concurrent 模式的實驗性文檔,如果說 React Hooks 目的是提高開發體驗,那麼 Concurrent 模式則專注於提升用戶體驗。同樣對於一個基於 React Hook 的請求庫而言,除了提供強大的功能之外,提升用戶體驗也是需要考慮的能力之一。

stale-while-revalidate 是 HTTP 的緩存策略值之一,其核心就是允許客戶端先使用緩存中不新鮮的數據,然後在後臺異步重新驗證更新緩存,等下次使用的時候數據就是新鮮的了,旨在通過緩存提高用戶體驗。

如上圖所示,在 useFetch 中也可以借鑑這種緩存機制,如在請求之前先從緩存返回數據(stale),然後在異步發送請求,最後當數據返回時更新緩存並觸發 UI 的重新渲染,從而提高用戶體驗。這裏以鼠標聚焦頁面時先從緩存獲取數據然後異步請求更新爲例,來看看具體的實現。

基於上面的分析,我們首先需要將所有請求的數據結果在內存中進行緩存,來模擬 stale-while-revalidate 的緩存效果,可以利用 ES6 的 new Map() 來實現,以 {[key]: [value]} 的形式記錄請求的數據結果,設計如下:

const __cache = new Map()

function cacheGet(key) {
  return __cache.get(key) || undefined
}

function cacheSet(key, value) {
  return __cache.set(key, value)
}

function cacheClear() {
  __cache.clear()
}

同時,需要記錄異步請求的集合,用來根據不同的 key觸發對應的驗證函數, 以 {[key]: [revalidate]} 的形式進行記錄請求集合,設計如下:

// 記錄併發的請求函數集合
const CONCURRENT_PROMISES = {}

// 記錄聚焦的驗證函數集合
const FOCUS_REVALIDATORS = {}

// 記錄緩存中的驗證函數集合
const CACHE_REVALIDATORS = {}

完整的代碼實現如下:

基於 stale-while-revalidate 的思想, 這裏將 useFetch 命名爲 useSWR ,同時將原有的 isLoading 命名爲 isValidating ,將數據請求函數 fetchData 命名爲 revalidate .

// 基於 stale-while-revalidate 的思想, 這裏將 useFetch 命名爲 useSWR
function useSWR(url, fetcher, options = {}) {
  // 約定 `key` 是發送請求的唯一標識符
  // key 可以被改變,當改變時觸發請求
  const [key] = getKeyArgs(url)
  
  // `keyErr`是錯誤對象的緩存鍵
  const keyErr = getErrorKey(key)
  
  const config = Object.assign(
    {},
    defaultConfig,
    useContext(useFetchConfigContext),
    options
  )

  let fn = fetcher
  if(typeof fn === 'undefined') {
    // 使用全局的 fetcher
    fn = config.fetcher
  }

  // 通過 useState 設置 data | error  的初始值,優先從緩存獲取數據(stale data)
  const [data, setData] = useState(cacheGet(key) || undefined)
  const [error, setError] = useState(cacheGet(keyErr) || undefined)

  // 基於 stale-while-revalidate 的思想,這裏將 isLoading 命名爲 isValidating
  const [isValidating, setIsValidating] = useState(false);

  // useRef 可以用來存儲任何會改變的值,解決了在函數組件上不能通過實例去存儲數據的問題。另外還可以 useRef 來訪問到改變之前的數據
  const unmountedRef = useRef(false)
  const keyRef = useRef(key)
  const errorRef = useRef(error)
  const dataRef = useRef(data)

  // 基於 stale-while-revalidate 的思想,這裏將 fetchData 命名爲 revalidate
  // 當依賴項 key 變化時 useCallback 會重新執行
  const revalidate = useCallback(
    async () => {
      if(!key) return false
      if (unmountedRef.current) return false
      let loading = true
      try {
        setIsValidating(true)

        // 請求超時觸發 onLoadingSlow 回調函數
        if (config.loadingTimeout) {
          setTimeout(() => {
            if (loading) config.onLoadingSlow(key, config)
          }, config.loadingTimeout)
        }

        // 將請求記錄到 CONCURRENT_PROMISES 對象
        CONCURRENT_PROMISES[key] = fn(key)

        // 執行請求
        const newData = await CONCURRENT_PROMISES[key]

        // 請求成功時的回調
        config.onSuccess(newData, key, config)

        // 將請求結果存儲到緩存 cache 中
        cacheSet(key, newData)
        cacheSet(keyErr, undefined)
        keyRef.current = key

        // 批量更新
        unstable_batchedUpdates(() => {
          setIsValidating(false)

          if (typeof errorRef.current !== 'undefined') {
            setError(undefined)
            errorRef.current = undefined
          }

          // 數據改變調用 setData 更新觸發 UI 渲染
          setData(newData)
          dataRef.current = newData
        })
      } catch(err) {
        delete CONCURRENT_PROMISES[key]

        cacheSet(keyErr, err)
        keyRef.current = key

        // 請求出錯設置錯誤值
        if (errorRef.current !== err) {
          errorRef.current = err

          unstable_batchedUpdates(() => {
            setIsValidating(false)
            setError(err)
          })
        }

        // 請求失敗時的回調
       config.onError(err, key, config)
      }

      loading = false
      return true
    },
    [key]
  )

  useEffect(() => {
    // 在 key 更新之後,我們需要將其標記爲 mounted
    unmountedRef.current = false

    // 當組件掛載(hydrated)後,獲取緩存數據進行更新,並觸發重新驗證
    const currentHookData = dataRef.current
    const latestKeyedData = cacheGet(key)

    // 如果 key 已更改或緩存已更新,則更新狀態
    if (
      keyRef.current !== key ||
      !deepEqual(currentHookData, latestKeyedData)
    ) {
      setData(latestKeyedData)
      dataRef.current = latestKeyedData
      keyRef.current = key
    }

    // revalidate with deduping
    const softRevalidate = () => revalidate()

    // 觸發驗證
    if (
      typeof latestKeyedData !== 'undefined' &&
      window['requestIdleCallback']
    ) {
      // 如果有緩存則延遲重新驗證,優先使用緩存數據進行渲染
      window['requestIdleCallback'](softRevalidate)
    } else {
      // 沒有緩存則執行驗證
      softRevalidate()
    }

    // 每當窗口聚焦時,重新驗證
    let onFocus
    if (config.revalidateOnFocus) {
      // 節流:避免快速切換標籤頁重複調用
      onFocus = throttle(softRevalidate, config.focusThrottleInterval)
      if (!FOCUS_REVALIDATORS[key]) {
        FOCUS_REVALIDATORS[key] = [onFocus]
      } else {
        FOCUS_REVALIDATORS[key].push(onFocus)
      }
    }

    // 註冊全局緩存的更新監聽函數
    const onUpdate = (
      shouldRevalidate = true,
      updatedData,
      updatedError
    ) => {
      // 批量更新
      unstable_batchedUpdates(() => {
        if (
          typeof updatedData !== 'undefined' &&
          !deepEqual(dataRef.current, updatedData)
        ) {
          setData(updatedData)
          dataRef.current = updatedData
        }

        if (errorRef.current !== updatedError) {
          setError(updatedError)
          errorRef.current = updatedError
        }
        keyRef.current = key
      })

      if (shouldRevalidate) {
        return softRevalidate()
      }
      return false
    }

    // 將更新函數添加到監聽隊列
    if (!CACHE_REVALIDATORS[key]) {
      CACHE_REVALIDATORS[key] = [onUpdate]
    } else {
      CACHE_REVALIDATORS[key].push(onUpdate)
    }

    return () => {
      // 省略清除副作用的相關邏輯
     }
  }, [key, revalidate])

  return {data, isValidating, error}
}

相關技術點:

requestIdleCallback

在 React 16 實現了新的調度策略(Fiber),新的調度策略提到的異步、可中斷,其實就是基於瀏覽器的 requestIdleCallback 和 requestAnimationFrame 兩個API,在 useSWR 中也是使用了 requestIdleCallback 這個 API,其作用就是在前瀏覽器處於空閒狀態的時候執相對較低的任務,也即傳給 requestIdleCallback 的回調函數,可以看到在 useSWR 中驗證函數作爲 requestIdleCallback 的回調函數,如果有緩存則延遲重新驗證,優先使用緩存數據進行渲染。

// 觸發驗證
if (
  typeof latestKeyedData !== 'undefined' &&
  window['requestIdleCallback']
) {
  // 如果有緩存則延遲重新驗證,優先使用緩存數據進行渲染
  window['requestIdleCallback'](softRevalidate)
} else {
  // 沒有緩存則執行驗證
  softRevalidate()
}

聚焦驗證

有了上面的分析,聚焦驗證就很好實現了,主要是通過判斷窗口的可見性來觸發驗證請求:

判斷窗口是否可見:

export default function isDocumentVisible() {
  if (
    typeof document !== 'undefined' &&
    typeof document.visibilityState !== 'undefined'
  ) {
    return document.visibilityState !== 'hidden'
  }
  // always assume it's visible
  return true
}
綁定監聽窗口狀態觸發驗證:

// Focus revalidate
let eventsBinded = false
if (typeof window !== 'undefined' && window.addEventListener && !eventsBinded) {
  const revalidate = () => {
    if (!isDocumentVisible() || !isOnline()) return

    // eslint-disable-next-line
    for (let key in FOCUS_REVALIDATORS) {
      if (FOCUS_REVALIDATORS[key][0]) FOCUS_REVALIDATORS[key][0]()
    }
  }
  window.addEventListener('visibilitychange', revalidate, false)
  window.addEventListener('focus', revalidate, false)
  // only bind the events once
  eventsBinded = true
}

其他功能

在 useSWR 官網中,可以看到還有其他諸如支持 Interval polling、Suspense Mode、Local Mutation 等的能力,但瞭解其原理之後我們知道其本質都是通過不同的條件和時機來觸發驗證進行實現的,這裏不再一一分析。另外有趣的是由於 useSWR 是 nextjs 的相關團隊出品,其也支持了 SSR 能力,以及做了針對服務端的一些特殊處理,如在 useSWR 中判斷是服務端的時候使用的 useLayoutEffectSuppense 模式盡在客戶端使用等。

  // 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

  // mounted (client side rendering)
  useIsomorphicLayoutEffect(() => {
  
    // suspense
  if (config.suspense) {
    if (IS_SERVER)
      throw new Error('Suspense on server side is not yet supported!')
  })

核心原理

下面是基於源碼畫的一張 SWR 的流程圖,用來彙總上述的源碼分析過程,鏈接其核心主要分三個階段:

1.在調用 useSWR() 時獲取緩進行數據初始化階段;
2.在 useIsomorphicLayoutEffect 時如果有緩存則優先使用緩存數據,然後異步調用 revalidate 進行數據驗證並更新緩存數據階段;
3.在 useIsomorphicLayoutEffect 時調用 unstable_batchedUpdates 渲染視圖階段。

總結

通過上面的分析,相比社區的現有請求類庫,useSWR 除了提供常見的功能之外,其核心在於借鑑了 stale-while-revalidate 緩存的思想,並與 React Hooks 進行結合,優先從緩存中獲取數據保證的 UI 的快速渲染, 然後在後臺異步重新驗證更新緩存,一旦緩存得到更新,利用 setState 的機制又會重新觸發 UI 的渲染,這意味着組件將得到一個不斷地自動更新的數據流,來確保數據的新鮮性。

另外,更強大的是由於 useSWR 緩存的是所有異步請求的數據,本質上相當於擁有了 Global Store 的能力,間接的提供了一種 狀態管理 的方案;而事實上,useSWR 除了異步請求數據之外,也可以通過同步的方式往緩存中寫入數據,滿足組件之間的狀態同步需求。目前官方還未將這一能力在其文檔釋放出來,但 @偏右悄悄地已經提交了一個示例 local-state-sharing 演示其可行性。這意味着在某些場景下,我們也許並不需要諸如 Redux / mobx /immer/ unstated-next / icestore/ React Context/… 等等狀態管理庫。

在未來,也許我們可以這樣玩,將 數據請求狀態管理 合二爲一,大致的腦圖如下:

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章