深入SWR 设计与源码分析

412次阅读  |  发布于2年以前

1 前言

SWRNext.jsReact SSR框架)背后的同一团队创建。号称最牛逼的React 数据请求库

SWR:是stale-while-revalidate的缩写 ,源自 HTTP Cache-Control 协议中的 stale-while-revalidate 指令规范。也算是HTTP缓存策略的一种,这种策略首先消费缓存中旧(stale)的数据,同时发起新的请求(revalidate),当返回数据的时候用最新的数据替换运行的数据。数据的请求和替换的过程都是异步的,对于用户来说无需等待新请求返回时才能看到数据。

SWR的缓存策略:

举个官网的简单列子

import useSWR from 'swr'

function Profile() {
  const { data, error, isValidating, mutate } = useSWR('/api/user', fetcher)

  if (error) return <div>failed to load</div>
  if (!data) return <div>loading...</div>
  return <div>hello {data.name}!</div>
}

这个例子是前端较为基础的请求,通过使用useSWR实现了简单明了的请求,当然它还有很多更强大的功能。

2 基础用法

2.1 useSWR

const { data, error, isValidating, mutate } = useSWR(key, fetcher, options)

2.1.1 参数

useSWR 接受三个参数:一个 key 、一个异步请求函数 fetch 和一个 config 配置 。

  // 有条件的请求
const { data } = useSWR(shouldFetch ? '/api/data' : null, fetcher)

// ...或返回一个 falsy 值
const { data } = useSWR(() => shouldFetch ? '/api/data' : null, fetcher)

// ... 或在 user.id 未定义时抛出错误
const { data } = useSWR(() => '/api/data?uid=' + user.id, fetcher)

2.1.2 返回值

可以使用 useSWRConfig() 所返回的 mutate 函数,来广播重新验证的消息给其他的 SWR hook(*)。使用同一个 key 调用 mutate(key) 即可。以下示例显示了当用户点击 “注销” 按钮时如何自动重新请求登录信息

import useSWR, { useSWRConfig } from 'swr'

function App () {
  const { mutate } = useSWRConfig()
  return (
    <div>
      <Profile />
      <button onClick={() => {
        // 将 cookie 设置为过期
        document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'

        // 告诉所有具有该 key 的 SWR 重新验证
        mutate('/api/user')
      }}>
        Logout
      </button>
    </div>
  )
}

通常情况下 mutate 会广播给同一个 cache provider 下面的 SWR hooks 。如果没有设置 cache provider ,即会广播给所有的 SWR hooks

2.2 核心特性

通过 SWR 官网介绍,SWR 有以下比较亮点特性

3 核心功能拆解实现

虽然 SWR特性很多,功能很强大,能极大提升用户体验,但是 SWR 却使用很简洁的思路完成上述所有的功能,并通过一个hook useSWR 即可拥有几乎全部功能。即功能强大,api使用却十分简单,开发体验十分喜人。

上面说的 SWR 更多是功能特性,体验优化。那么站在技术的角度,SWR 又是扮演什么角色?

下面我们拆解他的功能,按照 SWR 一样的思路代码实现相同

3.1 hooks 请求库

useSWR 是一个 react hook,通过这个 hook 你可以获取数据,并在数据获取后,触发页面重新渲染,这是 hook 基本特性,平平无奇,react``useState + promise 就能轻松实现。

function useSwr(key, fetcher) {
  const [state, setState] = useState({})
  const revalidate = useCallback(async () => {
    try {
      const result = await fetcher(key)
      setState({
        error: undefined,
        data: result
      })
    } catch (error) {
      setState({
        ...state,
        error
      })
    }
  })

  useEffect(() => {
    revalidate()
  }, [key])
  return { data, error }
}

有点简单,也的确没亮点,虽然 SWR 里面允许 keystringarray 甚至 function。但觉得这些都不是亮点。但是.... SWR 有一个小操作,有点按需更新的意思

function App() {
  // const { data, error, isValidating } = useSWR('/api/user', fetcher)
  const { data } = useSWR('/api/user', fetcher)
  return <div>hello {data.name}!</div>
}

useSWR 会返回 data, error, isValidating,只要有一个变化页面就会重新渲染。可页面只用到 data, 是否可以 仅仅 data 更改时候才触发重新渲染呢?

SWR 做了一个操作,有点 vue mvvm 模型的意思(settergetter 在脑海里琅琅上口)。ReactsetState会触发更新,直接使用肯定不行,SWR 就封装了一下,SWR 是在 state.js里面实现该逻辑

function useStateWithDeps(state) {
  const stateRef = useRef(state)

  //用于存储哪些属性被订阅
  const stateDependenciesRef = useRef({
    data: false,
    error: false,
    isValidating: false
  })

  const rerender = useState({})[1]

  const setState = useCallback((payload) => {
    let shouldRerender = false

    const currentState = stateRef.current
    for (const k in payload) {
      // 是否有变化
      if (currentState[k] !== payload[k]) {
        currentState[k] = payload[k]

        // 是否有被使用
        if (stateDependenciesRef.current[k]) {
          shouldRerender = true
        }
      }
    }

    if (shouldRerender && !unmountedRef.current) {
      rerender({})
    }
  })
  useEffect(() => {
    stateRef.current = state
  })

  return [stateRef, stateDependenciesRef.current, setState]
}

// 如果单纯设计 stateDependenciesRef,可以把setter、getter 写在 useStateWithDeps 里面。但use 并没有直接暴露 stateDependenciesRef,而是暴露 useSwr。所以把数据劫持放在 useSwr

function useSwr(key, fetcher) {
  //......

  const [stateRef, stateDependencies, setState] = useStateWithDeps({
    data,
    error,
    isValidating
  })

  return {
    get data() {
      stateDependencies.data = true
      return data
    },
    get error() {
      stateDependencies.error = true
      return error
    },
    get isValidating() {
      stateDependencies.isValidating = true
      return isValidating
    }
  }
}

3.2 全局状态管理

SWR 可不是简单管理一个组件的状态,而是组件之间相同 key 直接的数据是可以保持同步刷新,牵一发而动全身。ReactuseState 使用就是只会触发使用组件的重新渲染,即谁用我,我就更新谁。那么如何做到组件之间,一个地方修改,所有地方都能触发重新渲染。

下面演示,精简版的 React 全局状态库数据管理的实现。SWR 底层逻辑与之不谋而合

import { useState, useEffect } from 'react'
//全局数据存储
let data = {}
//发布订阅机制
const listeners = []
function broadcastState(state) {
  data = {
    ...data,
    ...state,
  }
  listeners.forEach((listener) => listener(data))
}

const useData = () => {
  const [state, setState] = useState(data)
  function handleChange(payload) {
    setState({
      ...state,
      ...payload,
    })
    broadcastState(payload)
  }

  useEffect(() => {
    listeners.push(handleChange)
    return () => {
      listeners.splice(listeners.indexOf(handleChange), 1)
    }
  }, [])
  return [state, handleChange]
}

export default useData

3.3 数据缓存

在上面讲到全局状态时候,我们定义了一个 data 存储了数据,在 SWR 底层,则是采用一个 weakMap 存储数据,道理相似。

SWR 是一个请求库,对于数据存储,并不是直接存储 Data, 而是存储 Promise<Data>

充分利用promise 状态一旦更改就不会变的特性,也十分适合异步数据请求

//区分 key,可以理解为安置 key 管理
const globalState = new Map({
  //更新数据事件,即上文中的 listeners
  STATE_UPDATERS: {}, //[key:callbacks]
  //重新获取数据事件
  EVENT_REVALIDATORS: {}, //[key:callbacks]
  // 异步数据请求缓存,缓存的是 promise
  FETCH: {}, //[key:callbacks]
})

function useSwr(key, fetcher) {
  //...

  const [stateRef, stateDependencies, setState] = useStateWithDeps(cacheInfo)
  //获取数据函数
  const revalidate = async () => {
    if (cache) {

    } else {
      // 没有 await
      const fetch=fetcher(...args)
      setCache(key,fetch)
    }

  }
}

3.4 自动化重新数据验证(轮询、断网重连、页面聚焦等)

3.4.1 轮询数据

即间隔固定时间,重新发送请求,更新数据

function useSwr(key,fetcher) {
  //...

  // Polling
  useEffect(() => {
    let timer
    function next() {
      timer = setTimeout(execute, interval)
    }
    function execute() {
      revalidate().then(next)
    }

    next()

    return () => {
      if (timer) {
        clearTimeout(timer)
        timer = -1
      }
    }
  }, [interval])
}

3.4.2 断网重连、页面聚焦重新请求

也是如同上文的的全局状态管理,在使用 useSwr 时候把重新获取数据的函数(事件)推送到全局的数据存储里面,然后订阅浏览器事件,并从全局数据存储里面读取事件执行

//subscribe-key.js
function subscribeCallback(events, callback) {
  events.push(callback)
  return () => {
    const index = events.indexOf(callback)
    // 释放事件
    if (index >= 0) {
      // O(1): faster than splice
      events[index] = events[events.length - 1]
      events.pop()
    }
  }
}

 // useSwr.js
function useSwr(key, fetcher) {
  //...

  //获取数据函数
  const revalidate = async () => {
    //...fetcher()
  }

  useEffect(() => {
    // 更新数据,推入队列,确保其他组件更新数据,能通过 broadcastState 触发当前组件更新
    const onStateUpdate = () => {
      //... setState()
    }
    // 重新刷新数据,在一些网络恢复、聚焦时候执行
    const onRevalidate = () => {
      //... revalidate()
    }
    const unsubUpdate = subscribeCallback(key, STATE_UPDATERS, onStateUpdate)
    const unsubEvents = subscribeCallback(key, EVENT_REVALIDATORS, onRevalidate)
    return () => {
      unsubUpdate()
      unsubEvents()
    }
  }, [key, revalidate])

  //...
}

浏览器订阅事件如下

useEffect 统一监听浏览器事件即可

// web-preset.js
const onWindowEvent =window.addEventListener
const onDocumentEvent = document.addEventListener.bind(document)
const offWindowEvent =window.removeEventListener.bind(window)
const offDocumentEvent =document.removeEventListener.bind(document)

const initFocus = (callback) => {
  // 页面重新聚焦 重新获取数据
  onDocumentEvent('visibilitychange', callback)
  onWindowEvent('focus', callback)
  return () => {
    offDocumentEvent('visibilitychange', callback)
    offWindowEvent('focus', callback)
  }
}

const initReconnect = (callback) => {
  // 网络恢复,重新获取数据
  const onOnline = () => {
    online = true
    callback()
  }
  // nothing to revalidate, just update the status
  const onOffline = () => {
    online = false
  }
  onWindowEvent('online', onOnline)
  onWindowEvent('offline', onOffline)
  return () => {
    offWindowEvent('online', onOnline)
    offWindowEvent('offline', onOffline)
  }
}

3.5 其他

3.5.1 全局配置

useSWR(key, fetcher, options)options支持需要配置属性,那么如果期望在某个范围内,所有的hook,共用一套配置如何实现呢。SWR 提供一个组件叫 SwrConfig

import useSWR, { SWRConfig } from 'swr'

function App () {
  return (
    <SWRConfig 
      value={{
        refreshInterval: 3000,
        fetcher: (resource, init) => fetch(resource, init).then(res => res.json())
      }}
    >
      <Dashboard />
    </SWRConfig>
  )
}

Dashboard 下所有的 useSwr 共用 value 作为配置。

组件提供全局配置的 provider,子组件都共用这个配置,是一种很常见组件的设计思路。主要思路就是利用 react.createContext 提供 ProviderConsumer 能力,不过现在使用 useContext,使用上会比 Consumer 好太多了。

const SWRConfigContext = createContext({})

const ConfigProvider = (props) => {
  // mergeConfigs 会处理中间件 merge逻辑
  // 必须继承上一个 provider SWRConfig 的配置 进行 merge
  const extendedConfig = mergeConfigs(useContext(SWRConfigContext), value)

  return createElement(
    SWRConfigContext.Provider,
    mergeObjects(props, {
      value: extendedConfig, // swr 一些运算处理的配置
    })
  )
}
export const useSWRConfig = () => {
  return mergeConfigs(defaultConfig, useContext(SWRConfigContext))
}

export const SWRConfig = OBJECT.defineProperty(ConfigProvider, 'default', {
  value: defaultConfig,
})

然后在使用中就可以使用全局配置

const fallbackConfig = useSWRConfig()

// 格式化用户入参
const [key, fn, _config] = normalize(args)

const config = mergeConfigs(fallbackConfig, _config)

3.5.2 中间件-洋葱模型

SWR 也支持中间件,让你能够在 SWR hook 之前和之后执行代码。

useSWR(key, fetcher, { use: [a, b, c] })

中间件执行的顺序是 a → b → c,如下所示:

enter a
  enter b
    enter c
      useSWR()
    exit  c
  exit  b
exit  a

那么 swr 是如何实现洋葱模型的呢?代码简单只有10行不到的代码。就是实现一个 compose 逻辑,然后通过函数执行栈一层层嵌套即可,这里有个注意点就是,从最后一个开始嵌套,然后从第一个开始执行。逐层释放执行栈,则刚好是完美洋葱模型的执行顺序。

一个中间件格式如下:

接受上一个 useSwr 这个hook,返回一个新的 hook很符合 compose 函数的思想呀

// Apply middleware
let next = hook //原始的中间件
const { use } = config //中间件列表
if (use) {
  for (let i = use.length; i-- > 0; ) {
    next = use[i](next)
  }
}

return next(key, fn || config.fetcher, config)

3.5.3 请求时序问题处理

这个其实逻辑很简单,但却很关键,所以也在这说明一下

假设我们对一个 key,发了2个请求req1req2。发出的顺序和数据返回数据如下

//   req1------------------>res1        (current one)
        //        req2---------------->res2
因为 req2 发出的事件比较晚,那么我们页面展示的数据应该

因为 req2 发出的事件比较晚,那么我们页面展示的数据应该是以 res2。即始终只更新最晚一次请求的返回值,即 req2 的返回值(这里就算 res2返回更早也是展示 res2,取决于请求事件)

function useSwr(key, fetcher) {
  const revalidate = async () => {
    FETCH[key] = [currentFetcher(...fnArgs), getTimestamp()]
    ;[newData, startAt] = FETCH[key]
    newData = await newData

    //...

    // 当请求数据返回时候,发现staryAt 不一致,说明有其他同 key 请求已经 发出去
    if (!FETCH[key] || FETCH[key][1] !== startAt) {
      //!(FETCH[key] && FETCH[key][1] == startAt)
      if (shouldStartNewRequest) {
        if (isCurrentKeyMounted()) {
          getConfig().onDiscarded(key)
        }
      }
      return false
    }
  }
}

这里有一个容易疑惑的点就是为何只是判断 startAt 不相等就放弃当前数据更改呢?这是因为 FETCH 是全局缓存,是用 map 存储,实时更新。且 FETCH[key] 始终只存一个请求,一旦不等就说明在此之后有相同的 key 请求被发出。

startAt 这个变量是存储在当前组件的作用域里面,而 FETCH 全局缓存,所有组件共享的数据

3.5.4 工具函数

SWR 里面还有需要工具函数可以学习

3.5.4.1 hash与深比较

SWR 中的hash.js用于哈希 keydata,形成一个字符串,并在深比较函数 compare 通过哈希后字符串判断数据是否有变化,是否需要重新请求、重新渲染

3.5.4.2 参数格式化处理

SWRkey 格式可以是 function / array / null ,也是在统一的 normalize.js 里做处理,如果是 falsy 值,则表示不发请求

4 源码分析

SWR 还有许多 options 配置和功能,比如上轮询间隔、是否启用缓存、是否开重复请求去除、错误重试、超时重试、支持 ssr 等。这些都不影响主流逻辑,下面我们按照上面拆解的核心功能,查看 SWR 源码。

4.1 目录结构

SWR 对把逻辑拆分到一个个文件,通过文件名以及我们上面的分析,很容易猜出文件中的逻辑

├── constants
│   └── revalidate-events.ts
├── index.ts
├── types.ts
├── use-swr.ts
└── utils
    ├── broadcast-state.ts // 组件状态修改通知其他组件渲染
    ├── cache.ts // 缓存,缓存事件:如重新请求、网络恢复等事件
    ├── config-context.ts //全局配置 react context
    ├── config.ts
    ├── env.ts 
    ├── global-state.ts //缓存,搭配 cache 使用
    ├── hash.ts // 对数据hash,形成字符串,用于深比较
    ├── helper.ts
    ├── merge-config.ts
    ├── mutate.ts // 更改缓存
    ├── normalize-args.ts // 格式化入参
    ├── resolve-args.ts //初始化操作,是一个 hoc 逻辑,
    ├── serialize.ts // hash 
    ├── state.ts // 属性按需触发重新渲染 
    ├── subscribe-key.ts // 添加事件订阅
    ├── timestamp.ts
    ├── use-swr-config.ts
    ├── web-preset.ts //浏览器事件:聚焦、网络状态变更
    └── with-middleware.ts //中间件

4.2 核心源码

4.2 核心源码

核心流程图

src/use-swr.ts


function useSwr(args) {
  //...

  const fallbackConfig = useSWRConfig()

  // 格式化用户入参
  const [key, fn, _config] = normalize(args)

  const config = mergeConfigs(fallbackConfig, _config)

  // 读取全局缓存,如数据缓存(promise)、事件缓存
  const [EVENT_REVALIDATORS, STATE_UPDATERS, MUTATION, FETCH] =
    SWRGlobalState.get(cache)

  //当前 key 读取存储,有缓存优先使用缓存数据
  const cached = cache.get(key)
  const data = isUndefined(cached) ? fallback : cached

  const info = cache.get(keyInfo) || {}
  const error = info.error

  //按需更新
  const [stateRef, stateDependencies, setState] = useStateWithDeps({
    data,
    error,
    isValidating,
  })

  //获取数据函数
  const revalidate = async () => {
    const shouldStartNewRequest = !FETCH[key] || !opts.dedupe
    if (!shouldStartNewRequest) {

    } else {
      // 没有 await
      FETCH[key] = [currentFetcher(...fnArgs), getTimestamp()]
    }

    //...
    ;[newData, startAt] = FETCH[key]
    newData = await newData

    //...
    finishRequestAndUpdateState()

    //...

    broadcastState()
  }

  useEffect(() => {
    // 更新数据,推入队列,确保其他组件更新数据,能通过 broadcastState 触发当前组件更新
    const onStateUpdate = () => {
      //... setState()
    }
    // 重新刷新数据,在一些网络恢复、聚焦时候执行
    const onRevalidate = () => {
      //... revalidate()
    }
    const unsubUpdate = subscribeCallback(key, STATE_UPDATERS, onStateUpdate)
    const unsubEvents = subscribeCallback(key, EVENT_REVALIDATORS, onRevalidate)
    return () => {
      unsubUpdate()
      unsubEvents()
    }
  }, [key, revalidate])

  return {
    get data() {
      stateDependencies.data = true
      return data
    },
    get error() {
      stateDependencies.error = true
      return error
    },
    get isValidating() {
      stateDependencies.isValidating = true
      return isValidating
    },
  }
}

5 总结

SWR是 一个很轻的 hook 请求库,能在提升用户体验的前提下,也保证很好的开发体验和很低的开发成本。设计理念也很 React,核心功能的实现逻辑也很简单。通过分析SWR 源码,学习核心功能的实现方式,能有效提升代码逻辑思维。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8