vlambda博客
学习文章列表

深入SWR 设计与源码分析

1 前言

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

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

SWR的缓存策略:

  • 接受一个缓存key,同一个 key 在缓存有效期内发起的请求,会走 SWR 策略

  • 在一定时间内,同一个key发起多个请求,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 配置 。

  • key: 请求的唯一key string(或者是 function / array / null) 是数据的唯一标识符,标识数据请求,通常是 API URL,并且 fetch 接受 key 作为其参数。

    • key为函数function或者null:可以用来有条件地请求数据实现按需请求,当函数跑出错误或者falsy值时,SWR将不会发起请求。

    • // 有条件的请求
      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)
    • 依赖请求场景:当需要一段动态数据才能进行下一次数据请求时,它可以确保最大程度的并行性(avoiding waterfalls)以及串行请求。

    • function MyProjects () {
      const { data: user } = useSWR('/api/user')
      const { data: projects } = useSWR(() => '/api/projects?uid=' + user.id)
      // 传递函数时,SWR 会用返回值作为 `key`。
      // 如果函数抛出错误或返回 falsy 值,SWR 会知道某些依赖还没准备好。
      // 这种情况下,当 `user`未加载时,`user.id` 抛出错误

      if (!projects) return 'loading...'
      return 'You have ' + projects.length + ' projects'
      }
  • fetcher(args): 返回数据的异步函数,接受 key 做参数并返回数据,你可以使用原生的 fetch 或 Axios 之类的工具。

  • config

2.1.2 返回值

  • data: 通过 fetcher 用给定的 key 获取的数据(如未完全加载,返回 undefined,这时可以用来做一些loading态)

  • errorfetcher 抛出的错误(或者是undefined

  • isValidating: 是否有请求或重新验证加载

  • mutate(data?, shouldRevalidate?): 更改缓存数据的函数,可以在数据更改发起数据重新验证的场景

可以使用 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 又是扮演什么角色?

  • 纯纯的 hook 请求库

  • 全局状态管理

  • 数据缓存

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

下面我们拆解他的功能,按照 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 里面允许 key 是 stringarray 甚至 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 直接的数据是可以保持同步刷新,牵一发而动全身。React的 useState 使用就是只会触发使用组件的重新渲染,即谁用我,我就更新谁。那么如何做到组件之间,一个地方修改,所有地方都能触发重新渲染。

下面演示,精简版的 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 提供 Provider 、Consumer 能力,不过现在使用 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 发出的事件比较晚,那么我们页面展示的数据应该是以 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 参数格式化处理

SWR 的key 格式可以是 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 源码,学习核心功能的实现方式,能有效提升代码逻辑思维。