$ ls ~yifei/notes/

SWR 才是真正的数据状态管理工具

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 putUser(newName)

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

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

        // 第二类,在 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.