likes
comments
collection
share

更好一点的useSWR

作者站长头像
站长
· 阅读数 3

问题是缓存粒度

让我们设想一个业务场景:

一个用户列表页, 显示所有用户的基本信息, 接口是/api/users, 返回的数据格式大致如下:

type ListData<T> = {
    offset: number, 
    limit: number,
    records: Array<T>
    total: number
}

对应的页面可能是这样的:

type User = {
    id: number, 
    name: string, 
    profile: Record<string, unknown>
};
const UserList = () => {
    const { data, error, isLoading } = useSWR<ListData<User>>('/api/users', fetcher);
    
    if (error) return "An error has occurred.";
    if (isLoading) return "Loading...";
    
    return <div>{data.records.map(user=>(<p>{user.name}</p>))}</div>
}

访问这个页面时一个缓存被建立, key即为api/users, value为ListData<User>类型, 包含多个User对象。

这也是useSWR使用最多的一种场景,第一次loading结束后, 如果UserList组件刷新了, 或者这时有另外一个消费同样数据的组件也Mount进来, 因为缓存的存在, useSWR会直接(同步的)返回缓存数据, 同时默默的开始重新验证(刷新)缓存。

但下面的场景就存在一些不完美的地方了。

如果我们此时希望点击其中一个User进入他的详情页, 接口是/api/users/<user_id>, 返回的数据即User, 页面如下:

const UserDetail: FC<{id: number}> = ({id}) => {
    const { data, error, isLoading } = useSWR<User>(`/api/users/${id}`, fetcher);
    
    if (error) return "An error has occurred.";
    if (isLoading) return "Loading...";
    
    return <div>Hello, {data.name}</div>
}

此时, 详情页的这个User数据依然是需要重新请求的, 页面也会经历loading状态,因为在useSWR管理的缓存中是不存在key为/api/users/${id}的数据的, 它会认为这是一个新的数据, 在内存中新建缓存并请求后端数据。

但实际在此之前, 在内存中我们有没有这个User的数据呢?

有的, 就在key为api/users的那个缓存里,但因为cache key不同,useSWR无法识别,复用它。可以看到,useSWR的缓存粒度是到Cache key层面的, 也就是根据后端API, 一个API对应一个缓存。


上面提到的都是读取缓存时遇到的问题, 与之相对的, 在前端主动更改缓存, 也就是useSWR的mutate时也存在因为缓存粒度而带来的问题, 我们再看一个例子:

一个简单的todo list, 使用类似/api/todos的接口获取所有数据并渲染:

更好一点的useSWR

在点击某一条todo的完成按钮时, 我们需要怎么做?

一般来说更新某条todo状态的后端接口类似patch /api/todos/<todo_id>, 那么如果更新完后执行:

mutate('/api/todos/<todo_id>')

todos页面将不会得到更新, 如果执行

mutate('/api/todos')

todos页面会更新, 但这个key对应的缓存是todo list, 除了我们修改的todo外, 其他几条todo信息的请求将是浪费的。

关于这点useSWR给出的方案是 populateCache :

const updateTodo = () => fetch('/api/todos/1', { 
    method: 'PATCH', 
    body: JSON.stringify({ completed: true })
}) 

mutate('/api/todos', updateTodo, { 
    populateCache: (updatedTodo, todos) => { 
        // filter the list, and return it with the updated item 
        const filteredTodos = todos.filter(todo => todo.id !== '1') 
        return [...filteredTodos, updatedTodo] 
    }, 
    // Since the API already gives us the updated information, 
    // we don't need to revalidate here. 
    revalidate: false
})

类似于乐观更新, 需要自己把某条todo数据合并到todo list里面去,然后再去mutate list cache, 并且乐观的相信服务器一定会把数据照顾好, 那就先不去重新验证缓存了。

不过每次这样写的时候我都会觉得很繁琐, 这不对劲, 我只改了一个数据, 我就想更新这个数据, 现在要我自己去把这个数据捡出来然后更新整个list...

如果可以使用/api/todos接口获取数据, 然后直接修改某条todo,并且也只需要在这条todo的缓存上执行mutate, /api/todos也能获得更新...是不是更自然呢。

尝试优化

上面的两个问题, 我觉得本质上是useSWR缺乏一种缓存间的关系信息, 也就是, 对于一个缓存, 他可能是由多个缓存组成的, 这些缓存可以称为他的依赖, 他本身可能也是另外一个缓存的组成部分, 那么他也是那个缓存的依赖

依赖关系可以让useSWR在更新组件时获得信息:如果某个缓存发生了变化, 就代表所有依赖它的缓存也发生了变化

回顾上面的例子, key = api/users对应的缓存可以看作是一组key = /api/users/<user_id>的缓存组合成的, 这些/api/users/<user_id>缓存就是api/users缓存的依赖, 那么:

  1. api/users缓存在请求远端数据后, 实际填充的是对应的一组/api/users/<user_id>缓存
  2. 任何一个/api/users/<user_id>缓存改变了, api/users缓存也将改变

至于缺乏的关系信息, 我想无外乎就两种方式来获得:

  1. 预制几种数据结构模板, 通过识别key来确认模板
  2. 提供全局或单次使用的配置项,由用户提供信息

基于这些认知和useSWR, 我写了一个用起来更加顺手的替代品: useFetch (github.com)

更好的useSWR

在获取关系信息方面, 我自己使用的版本选择了第一种, 预制数据结构的方式, 但如果要开源出来就不得不考虑现实世界中哪怕仅仅是RESTFul API, 也会有多种多样的数据格式, 所以开源的版本就是提供了配置项来获取数据关系了,这里我使用的是一个生成器函数, 每次yeild返回一组关系数据:

{
    path: string  // 原数据中的路径信息
    cacheKey: string  // 关联的依赖缓存
}

参照上面的案例, 使用useFetch后的代码大概是这样:

type ListData<T> = {
    offset: number, 
    limit: number,
    records: Array<T>
    total: number
}
type User = {
    id: number, 
    name: string, 
    profile: Record<string, unknown>
};
const UserList = () => {
    const { data, error, isLoading } = useSWR<ListData<User>>('/api/users', fetcher, {
        relation: function* (data) {
            for (let i = 0, l = data.records.length; i < l; i += 1) {
              yield { path: `records.${i}`, cacheKey: `users/${data.records[i].id}` };
            }
        }
    });
    
    if (error) return "An error has occurred.";
    if (isLoading) return "Loading...";
    
    return <div>{data.records.map(user=>(<p>{user.name}</p>))}</div>
}

这样修改, user list页面载入后, 内存中会包含所有的/api/users/<user_id>缓存以及一个空的/api/users缓存, 后者只会记录其依赖的真实缓存:

更好一点的useSWR 似乎还有一点节省内存的好处呢😂

有了这样的缓存结构, 上面的两个问题就都可以解决了。