$ ls ~yifei/notes/

使用 useSWR hook 做状态管理

Posted on:

Last modified:

什么是状态管理

UI 状态和数据状态是两个东西,然而人们总是把这两个东西混淆在一起。Redux 等工具实际上是 一个 UI 状态工具,但是人们总用它(通过 thunk) 来请求数据,这是错误的。

在 Redux 等工具的 readme 中一般都是提供的 Counter 或者 TODO list 这个 demo, 这是非常误导 人的,因为这两个 demo 只是本地的 UI 状态管理。到了实际使用的过程中,大多数状态是数据状态, 需要使用 thunk 等 middleware 来获取后端数据并管理,非常丑陋。

UI 状态指的是前端的一些状态,比如说是否使用暗黑模式,某个状态栏是否显示等等,和后端的 数据库无关。数据状态指的是从后端数据库中加载的一些数据,比如说用户名,当前的文章,评论 等等。如果前端进行了更新,也需要写回到后端数据库中。

  • 对于 UI 状态管理来说,redux, mobx, 甚至包括 useReducer 等工具都是非常合适的,但是他们 真的不是数据状态管理工具。
  • 对于服务端数据的获取和缓存,需要使用单独的网络请求工具,而不是所谓的状态管理工具, useSWR 和 ReactQuery 是非常适合的工具。

按照前面的划分,实际上 SWR 也算一个(隐式)状态管理工具,它相当于使用了 API 的路径作为了 key, 然后把整个状态存储到了一个类似 kv 字典的结构当中。

一旦把 UI 状态和(后端)数据状态这两个概念分清了,那么解决起来就一下子开朗了。我们可以 使用 redux/useReducer 管理前端程序自身的状态,而获取数据都通过 SWR 来实现。

UI 状态的管理

React 本来的思想就是纵向切分成一个一个的小组件,而不是像 html/css/js 这样横向切分。react 中每个组件都是可以独立使用的,自己获取数据,自己渲染自己的数据。而 redux 的做法又倒了回去, 变成了中心化的状态管理,每个组件实际上就弱化成了只是一个 view 组件,也就是说只有渲染是 独立的,而数据的获取和管理又变成了统一的一个整体。

统一的状态管理当然有好处,那就是不会有(太多)重复数据,也有统一的约束管理。但是坏处也很 明显:给人的心智负担太大了。所有页面的数据都放在一起,然而除了登录信息,或者程序的 皮肤等少数信息之外,一个页面里面用到的数据在另一个页面中是用不到的。每次想要改某个页面, 除了在 views/components 目录中改组件之外,还要再翻到 reducers 中找对应的文件,还要 再翻到 combineReducer 等等一系列操作。这样的操作相当于不是只把当前要改的页面放到脑袋里, 而是总要把整个应用都放在脑袋里,简直要爆炸了。

一个比较好的切分边界是 page, 也就是一个物理页面。每个 react-router 中一个路由顶级元素中的 UI 状态几乎不会需要和另一个组件分享。

一个应用的数据并不能简单地用全局数据或者局部数据简单地概括,而是既有全局数据,又有局部数据。 redux 这种一根筋的全局数据显然是不合适的。我们应该采用一种混合的方式,让局部的归局部, 全局的归全局。

另外一个需要注意的地方就是,URL 实际上是一个隐形的 ui store, 比如说一个搜索页面,它的 URL 是:

/search?q=foo&start=2020-01-01&end=2020-02-01&sort=date

那么构建这个页面的关键状态:query, start, end, sort 其实已经都在 URL 里面了,没必要再去存储一个状态在内存中。

另外,如果使用 redux 做状态管理的话,我们还需要使用 useEffect 钩子在生命周期的适当地方 dispatch 拉取数据的代码。而使用 swr 只需要无脑 useSWR 就好了。

最终方案

综上所述,我们的最终方案就是:

  1. 使用 useContext 和 useReducer 创建一个全局状态(或者 redux/mobx),用来需要全局存储的信息
  2. 有内部状态的复杂组件,比如 form, 也可以使用 useState 或者 useReducer 管理自己的状态
  3. 使用 SWR 加载数据,每个需要数据的地方直接 useSWR 就可以了

swr

注意其中的第二个参数数据请求函数接收的参数是 useSWR 的第一个参数 key.

import useSWR from 'swr'

function Profile() {
  const { data, error } = useSWR('/api/user', async (url) => {
    const res = await fetch(url)
    return res.json()
  })

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

特别注意,对于 useSWR 来说,判断错误和有没有 data 非常重要。 一般都是先请求出错一次然后第二次 render 才有数据,它就是这么设计的,直接使用 data 做渲染很容易引起白屏。

生产环境中,则使用这种方式复用对同一个 API 的访问更好:

function useUser(id) {
  const { data, error } = useSWR(`/api/user/${id}`, async (url) => {
    const res = await fetch(url)
    return res.json()
  })

  return {
    user: data,
    isLoading: !error && !data,
    isError: error,
  }
}

function Avatar({ id }) {
  const { user, isLoading, isError } = useUser(id)

  if (isLoading) return <Spinner />
  if (isError) return <Error />
  return <img src={user.avatar} />
}

如果需要除了路径外的其他参数,可以传递一个数组作为 key, 比如说需要拼接 URL 参数的时候

const { data: user } = useSWR(['/api/users', id], async (url, id) => {
  // 在这里再拼接 url
  const u = `${url}/${id}`
  await fetch(u, id)
})

这里需要注意下,fetcher 的参数如果是 url 字符串就会作为唯一的参数,如果是一个数组会自动 展开。但是一般情况下,useSWR 已经可以从闭包中获取其他参数了,没有必要写出来。

export default function useArticles({page, page_size}) {
  // 使用 page, page_size 作为 key 的一部分
  return useSWR(["/api/articles", page, page_size], async (url) => {
    // 注意这里的 page 和 page_size 实际上是从闭包中获取的
    const res = await axios.get(url, {params:{page,page_size}})
    return res.data
  })
}

useSWR 的官方文档不知道是为了简洁还是什么原因,似乎更推荐你使用固定的 fetcher 函数,但是 就我的使用体验而言,每次写一个回调函数字面量还是更方便一些。 就像上面这个例子一样,通过把参数传递进去,在直接传递给 axios,这样避免了自己使用 new URLSearchParams() 拼接 URL 的苦恼。

更新数据

当 POST/PUT/DELETE 请求的时候,会触发数据的修改,这时候应该通过 mutate 同步触发本地的 数据修改。mutate 函数可以使用全局的,也可以使用 useSWR 返回的已绑定的 mutate 函数。

mutate 函数的签名是 mutate(key, mutator, revalidate), 第一个参数是 key,和 useSWR 一样,第二个是更新,可以是一个对象或者函数,第三个是 revalidate 属性,是否触发重新验证,默认是 true。

mutator 函数接受的值是当前 key 的缓存值。

如果先在本地更新数据,然后再发送请求,也就是所谓的『乐观更新』,好处是前端响应比较快,坏处是如果失败还需要把数据改回来。如果先发送请求后改数据,好处是不需要在验证,坏处是响应比较慢。

在局部更新数据的一个例子:

import useSWR, { mutate } from 'swr'

function Profile () {
  const { data, mutate: bindedMutate} = useSWR('/api/user', fetcher)  // 局部的 mutate, 已经和 key 绑定

  return (
    <div>
      <h1>My name is {data.name}.</h1>
      <button onClick={async () => {
        const newName = data.name.toUpperCase()

        // 第一类,自己发送请求,自己更新数据

        // 方法 1.1,完全手工更新本地数据,乐观更新
        // 立即更新本地数据,false 表示禁用重新验证
        mutate('/api/users/me', { ...user, name: newName }, false)
        await postUpdateUsername(newName)

        // 方法 1.2, 更新后触发一次 GET 验证,悲观更新
        // 向 API 发送请求更新源,响应的结果我们不关心,只要没错误就行
        await putUser({name: newName})
        // 触发重新验证(重新 GET 请求)以更新本地数据
        mutate('/api/users/me') // or bindedMutate()  // 或者使用已绑定的 mutate

        // 方法 1.3,如果 PUT 已经返回了更新后的数据,那么可以直接使用 PUT 返回值手工更新,悲观更新
        const newUser = await putUser({name: newName})  // 发送 PUT 请求更新用户名
        mutate('/api/users/me', newUser, false)

        // 方法 1.4 把刚才的两步合二为一,直接使用 PUT 返回的 promise 作为 mutator, 悲观更新
        const newUser = {...user, name: newName}
        mutate('/api/users/me', putUser(newUser))

        // 第二类,在 mutator 中更新数据,悲观更新
        // 如果更新的是局部数据,API 没有返回全部数据,可以在 mutator 函数中自行处理后返回完整数据
        // 注意 motator 函数的参数是 /api/todos 的缓存值
        mutate('/api/todos', async todos => {
            // 把 ID 为 1 的更新为 completed,该 API 返回更新后的数据
            const updatedTodo = await fetch('/api/todos/1', {
                method: 'PATCH',
                body: JSON.stringify({ completed: true })
            })
            // 筛选列表,返回更新后的 item
            const filteredTodos = todos.filter(todo => todo.id !== '1')
            return [...filteredTodos, updatedTodo]
        })
      }}>Uppercase my name!</button>
    </div>
  )
}

退出登录的例子:

import useSWR, { mutate } from 'swr'

function App () {
  return (
    <div>
      <Profile />
      <button onClick={() => {
        // 删除 Cookies,或者可以使用 js-cookie 这个库。如果是存在 localStorage 中的 token,也需要删除
        document.cookie = 'token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
        // 告诉所有具有该 key 的 SWR 重新验证,将会导致得不到登录信息
        mutate('/api/users/me')
      }}>Logout</button>
    </div>
  )
}

条件请求

当程序还没加载好,或者前置条件还没满足的时候,发送一个不合法的空请求是没有意义的,而且会 耗费服务器资源,可以通过把 key 设为 null 或者抛出异常来避免。

function useProjects() {
  const { data: user } = useUserSettings();
  // The following request depends on the previous request
  // accessing user.id when user is undefined will throw an error
  return useSWR(() => '/api/projects?uid=' + user.id, fetcher);
}

总结

使用 redux-thunk 的时候:

  1. 从后端获取数据:在 useEffect 中 dispatch 一个 thunk,然后通过 redux-thunk 发送请求,再 dispatch 一个数据更新前端 UI。
  2. 数据提交到后端:回调函数中 dispatch 一个 POST 请求的 thunk, 然后再 dispatch 一个数据更新前端 UI。

使用 swr 的时候:

  1. 从后端获取数据:使用 useSWR 实现本地缓存和验证。
  2. 数据提交到后端:编写回调函数的时候使用 mutate 同步更新本地缓存。

我们可以看到 swr 的优点在于:

不需要使用两个 dispatch,一次 thunk,一次更新本地 UI 状态。不需要在 useEffect 中使用,想在哪儿用在哪儿用,想在哪个组件用,在哪个组件用。 mutate 更新的定制性更强,可以按需更新本地状态。

最重要的一点,我们无需手工设计本地的状态树,而是只在前端做一份后端状态的缓存,后端依然是 Single source of truth。 像 redux 这种设计一个前端的状态树,纯属把后端的轮子重新造了一遍,更加麻烦。有了 swr,两个头疼的难题——设计状态树和同步状态都迎刃而解了。

参考

  1. https://dev.to/g_abud/why-i-quit-redux-1knl
  2. https://react-query.tanstack.com/comparison
  3. https://github.com/vercel/next.js/discussions/14601?sort=top
  4. https://github.com/vercel/swr/discussions/587
  5. 和本文思路完全一致
  6. https://juliangaramendy.dev/blog/managing-remote-data-with-swr
  7. https://www.zhihu.com/question/63726609/answer/934233429
  8. https://www.zhihu.com/question/63726609/answer/212357616
  9. https://zhuanlan.zhihu.com/p/339586913
  10. https://frontend-digest.com/dependent-and-conditional-data-fetching-with-useswr-b5178a85185
  11. https://zhuanlan.zhihu.com/p/89570321
  12. https://ahooks.js.org/zh-CN/hooks/use-request/index
  13. https://zhuanlan.zhihu.com/p/158562847
  14. https://www.zhihu.com/question/446297870/answer/1761099244

© 2016-2022 Yifei Kong. Powered by ynotes

All contents are under the CC-BY-NC-SA license, if not otherwise specified.

Opinions expressed here are solely my own and do not express the views or opinions of my employer.