更好一点的useSWR
问题是缓存粒度
让我们设想一个业务场景:
一个用户列表页, 显示所有用户的基本信息, 接口是/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
的接口获取所有数据并渲染:
在点击某一条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
缓存的依赖, 那么:
api/users
缓存在请求远端数据后, 实际填充的是对应的一组/api/users/<user_id>
缓存- 任何一个
/api/users/<user_id>
缓存改变了,api/users
缓存也将改变
至于缺乏的关系信息, 我想无外乎就两种方式来获得:
- 预制几种数据结构模板, 通过识别key来确认模板
- 提供全局或单次使用的配置项,由用户提供信息
基于这些认知和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
缓存, 后者只会记录其依赖的真实缓存:
似乎还有一点节省内存的好处呢😂
有了这样的缓存结构, 上面的两个问题就都可以解决了。
转载自:https://juejin.cn/post/7314190028648071203