useMutation({
mutationFn: updateTodo,
// make sure to _return_ the Promise from the query invalidation
// so that the mutation stays in `pending` state until the refetch is finished
onSettled: async () => {
return queryClient.invalidateQueries({ queryKey: ["todos"] })
},
})
标题经历了三次变化
*UI
?TL;DR
开发与用户进行交互的界面或工具时,面对异步状态需要等、慢、数据过时这些不可避免的事实下,怎么尽可能的提高 UX
具体到前端
TanStack Query/SWR/RTK Query
,它们与同步状态管理redux/jotai/zustand
有什么区别redux-thunk
实现一个异步状态管理(React Query API)前端?终端?
大前端?终端?Omni-FrontEnd?
同步、异步
同步代码
代码立即执行并完成,不需要等待其他任何操作
异步代码
需要 等待 某些操作的完成:锁、文件、网络 IO 等等
聚焦到前端最常见、常用的就是
fetch
-- 网络请求,所以就前端来说 异步状态 ≈ 服务端状态同理,同步状态 ≈ 客户端状态
差异
从四个方面去看:
同步
异步
终端的异步状态
所有程序员都会关心异步的写法(
async/await
)和组织(rxjs
),但也许只有end device
会(必须)去关心异步耗时、状态更新这些事情因为我们已经是链路的终点了,我们不关心,那就只能用户去关心,用户会去关心吗?(≈ 用户流失)
异步 == 等待 == 慢 == 体验不好
核心问题:用户体验
主题:在异步需要等、慢、数据过时这些不可避免的事实下,怎么尽可能的提高 UX
异步状态的挑战
要想提高异步的体验(UX、DX),我们大概要面对如下挑战:
本地、全局
开始之前有一点要明确
是应该这样?
还是这样?
前端视角
听到过的回答:
这个回答是经不起推敲的,产品是在不断迭代的,我们的判断仅限于当下这个时刻而已
V1: 自己 -- 放在本地
V2: 兄弟节点 -- 提升到父级
V3:跨节点 -- 提升到全局(root)
V4:需求全砍了,变回 V1 版本了 -- 再降下来?
单从开发维护角度来看,应该是全局的
非前端视角
跳出前端视角事情就更简单了,因为上面已经说过异步状态是共享所有权的,我们拥有的只是某个时刻的快照而已
从一致性角度看,快照可以是过时的,但不能是多版本的
也就是说同一份异步状态不管多少地方在用,都需要一种方式使其保持一致,答案很明显也是全局状态管理
异步状态管理
所以接下来使用
redux-thunk
来封装实现异步状态管理,看下为什么说异步状态会有如上的挑战,以及如何解决结构定义
下面应该是使用
redux-thunk
请求异步数据的最简代码,有两点值得注意:ASM
,与其他同步状态区分开queryKey
和具体要执行的请求函数queryFn
modal
单独写一遍请求逻辑,key 随modal
的定义在对应的文件中使用代码如下
上面的代码还是太啰嗦了,实际使用中只有
queryKey & queryFn
会变化,其他都是模版代码,所以再封装一个useQuery
这下用起来舒服多了
假如 N 个组件都在用这个数据,我们不想
queryKey
和queryFn
分散在各组件中,为了统一管理还需要再封一层(数据层),比如放在service/*.ts
最终组件里(视图层)直接调用
这也是最终的代码结构,后面会持续的修改
useQuery
的实现,但业务层要做的只有最后这两步请求状态管理
异步状态需要等、慢是不可避免的,但人机交互需要及时响应,我们需要从交互上告诉用户:你的操作我受理了,只是现在需要等待
也就是所有视图中发生异步状态的地方,要在视觉上反馈用户
作为状态管理要做的事情就是把异步过程状态暴露出来,方便视图层渲染:
loading/error/success
回到代码实现,这一步是很简单的,而且相信大家自己一定也都写过:请求过程中使用
status
记录状态在
useQuery
中派生出具体的变量方便外部使用:会有很多重复的代码
缓存管理
这可能是异步状态管理与同步状态管理最大的差异点了
queryKey: "taskList"
的问题如果大家在用同步状态管理异步数据,这应该就是正在使用的方式了,我们用一个
key
去承载一个接口返回的数据列表场景
queryKey: "taskList"
是它们的唯一标识吗?page=1
的数据渲染在了第二页里,算 BUG 吗?type=1|2
,对其中一个翻页,结果两个同时进行了loading
和结果更新,算 BUG 吗?这些问题大家多少应该都碰到过,解决方案也有很多,比如:
key
本质是什么?
/api/list?page=1
和/api/list?page=2
和/api/list?name=s
根本就不是同一种状态,但在代码开发上却用一个字段承接了N
种不同的数据把
N
种状态抽象成了1
种,抽象的代价就是会遇到各种问题换句话说,如果不做抽象,就不会有这些问题
数据缓存
还是列表场景,操作路径:
?page=1 -> ?page=2 -> ?page=1
(往返翻页)?page=1 -> ?page=1&s="React" -> ?page=1
(搜索后清空)可能的回答:
要看具体场景,看对数据实时性的要求;还要看请求数据的代价(请求耗时)
SWR
而且针对这个问题还有更好的回答:
SWR: stale-while-revalidate
SWR
是指在请求数据时,如果之前已经有缓存了原则是「有」总比「空」强(体验好),就算数据是过时的,也比没有数据强(更何况会在几百毫秒内(可能的)新数据就会到来
key
结论所以不管是从代码开发考虑,还是从数据缓存考虑
都应该具象
queryKey
,保证每一个key
对应一份数据具体来说就是对于
get
请求,我们应该把url + [query] + [body]
作为queryKey
,这样就可以标识唯一的数据源了实现 SWR
解决
key
的问题key
序列化现在的
key
变成了一个非基本类型,不能直接用作对象的key
,所以需要序列化(stringify
)操作多数情况
key
的组成都是传给后端的,所以可以直接用JSON.stringify
来序列化(React Query)键值对的顺序不同,序列化后的字符串也是不同的(但含义相同),所以需要排序
也可以直接使用
stable-hash
库(useSWR),它可以稳定序列化任意类型的值(Function/RegExp/BigInt/Symbol..
),包括循环引用(JSON.stringify
会直接报错)应用到代码中就是存
key
的时候调用一下hash
函数:key
变化时请求数据利用
useEffect
可以轻易做到(记得hash key
)React Query/useSWR
都建议使用这种方式监听key
变化以重新发起请求,而不是手动调用请求函数有人希望
useQuery
提供一个类似manualFetch
的返回值,以在事件发生的时候手动传入参数进行请求:单从封装的角度是不能提供手动函数的
后面会讲自动缓存更新,如果使用手动查询更改
key
,会产生更多的心智负担useQuery
里已经传入了一个key
(外部状态manualFetch
也会传入key
(内部状态manualFetch
数据源的状态被封装在了useQuery
内部官网建议手动请求?
React 官网 Sending a POST request - You Might Not Need an Effect 中:
论点:尽可能让事情发生在它产生的地方
但实际上这只是针对
变更
操作,对于查询
操作官方紧跟着就给出了说法:Fetching data - You Might Not Need an Effect
key
可能不是单一来源(url
)这也是为什么
React Query/useSWR
都给我们提供了用以变更的方法useMutation/useSWRMutation
,以使得查询和变更分开完善 SWR
key
的问题解决之后,其实已经实现了 SWR 的功能,回顾下目前的代码再看分页场景:
?page=1 -> ?page=2 -> ?page=1
key
都被单独保存了一份数据data
的动作,所以如果data
之前已经有值了,那么useSelector
很自然的就会获取到已有的值data
缓存时长
回顾下
SWR
的定义:但目前的代码里根本就没有过不过时的概念,不会出现「缓存没有过时」的情况
我们可以加入一个
staleTime
的配置,来控制异步数据的过期时间,如果数据还是新鲜的,就不会发起请求,直接返回缓存代码中,当数据请求成功后,记录一个时间
触发请求时,判断数据是否过期
默认情况下
staleTime
为0
,即立即过时如果希望数据在程序的运行期间都不过期,可以设置
staleTime: Infinity
useSWR
useSWR
中并没有staleTime
的概念,只有revalidateIfStale
&dedupingInterval
revalidateIfStale = true
: 即使存在陈旧数据,也自动重新验证revalidateIfStale: true === staleTime: 0
revalidateIfStale: false === staleTime: Infinity
dedupingInterval = 2000
: 删除一段时间内相同key
的重复请求staleTime
其实
HTTP SWR
中也是有staleTime
的概念的后面会讲到请求去重、自动更新和手动缓存失效
staleTime
的概念可以轻松的与这些概念结合,没有什么心智负担staleTime = Infinity
的情况下,手动缓存失效,会重新发起请求吗?revalidateIfStale
&dedupingInterval
就不是这样了dedupingInterval = Infinity
的情况下,手动缓存失效,会重新发起请求吗?数据更新
自动更新(Smart refetches)
所有的异步状态管理都会提供这些能力,使用得当可以让用户体验上升一个层级
refetchOnMount
目前的实现就是这样
refetchOnWindowFocus
功能的代码实现就是监听
focusvisibilitychange
重新发起请求目前只会在
queryKey
变化时,才会重新发起请求,现在我们需要加入另一个状态:isInvalidated
,true
表示需要重新请求refetchOnReconnect
同上,监听
online/offline
,不再赘述refetchInterval
定时轮询,窗口不可见时会停止轮询
refetchIntervalInBackground
:窗口不可见时依然轮询useSWR
:refreshInterval
+refreshWhenHidden
怎么使用得当?
!==
数据删除/无效 的场景不要使用(eg. 推荐流loading
(后面说手动更新
进行
变更
操作之后,明确知道数据源发生变化了,数据已经过时了Q: 需要重新发起请求(吗?)
A: 取决于当前页面中有没有组件在使用这个数据源
大家现在是怎么做的,在哪做的(视图层 or 数据层)?或者说是在组件里,还是在
redux
里应该都是在组件里:
如果放在
redux
里会有以下问题key
的问题,重新请求参数应该传什么?(要把参数也记到redux
中)dispatch({type: 'task/getList'})
会直接触发网络请求,无法判断是否有组件正在使用数据就是说因为代码原因,无法(不能简单的)把它抽象到数据层中,所以不得不在视图层做
在我们目前的实现中,可以轻松的解决这个问题,把逻辑都放在数据层中
只需要提供如下代码:
queryKey
是会被存下来的(为了避免干扰前面没写filters
精确控制具体的失效逻辑业务代码中如下使用:
为什么它有效?
使用了
useEffect
天然的订阅机制:通过useEffect
监听了状态的变化发送请求如果没有任何相关的
useEffect
存在,单纯的修改状态是没有意义的,什么都不会发生为什么部分匹配就可以了?
实际的业务场景中,很少在页面上同时存在接口路径相同,参数不同的视图(eg.
/api/list?type=1
、/api/list?type=2
)基于这样的提前:
invalidateQueries(['/api/list'])
效果等同于invalidateQueries(['/api/list', {page: 1}])
invalidateQueries(['/api/detail'])
效果等同于invalidateQueries(['/api/detail', {id: 1}])
而且如果真遇到这种场景,你就把参数传进去呗
手动设置缓存数据
有些后端接口实现中,会在
post/patch
的接口响应里就把最新的数据返回过来,而不用再去发起get
请求针对这种场景可以提供
setQueryData
手动更新缓存数据业务使用如下:
useSWR
中mutate(key)
等同于invalidateQueries
(默认精确匹配,可以传入函数来实现部分匹配mutate(key, data, options)
等同于setQueryData
内存和垃圾回收
事情都是两面的,抽象和具象各有优劣
把每一个
key
的数据都存下来,体验好了,内存也上去了直接看
React Query
是如何解决的如果一份数据已经没有任何组件在使用了,
gcTime
后回收它代码实现上,就是订阅模式配合定时器
依然是利用
useEffect
的特性,配合全局状态管理实现:「是否还在组件在使用数据」redux
中:useSWR
中并没有提供清理缓存的相关配置,但是它允许你完全 自定义缓存行为,所以可以自行实现相关功能请求合并(去重)
目前的同步全局状态管理中,如果大家要取一个全局的数据(比如
userInfo
),是用哪种方式取的?useSelector
取到后,利用props
向下不同的透传(props drilling
)useSelector
取我觉得在问废话,当时是 2 啊(不会真的有人用 1 吧 😱
我们知道
useQuery
的实现其实也只是一个有副作用的useSelector
而已我们希望对于使用者(视图层)来说,就把它当成
useSelector
,只管取数据、用数据就好了这些都是数据层的事情,视图层管好渲染就可以了
目前的实现如果多个组件同时挂载,是会同时发出多个请求的
只需要加入
loading
态的判断即可完成去重上面的代码控制了接口
loading
过程中的重复请求(取决于接口的速度,也许是几百毫秒)对于同步的组件树挂载,这已经足够了(面试题:useEffect 的调用时机和顺序
但如果遇到异步组件(lazy load),就还有可能发生重复请求,那应该怎么办呢?
staleTime
是你的好朋友丢弃/取消请求
有些场景请求的数据已经不可能再被使用了,此时需要忽略/丢弃/取消请求的结果
相信这些问题大家多少也遇到过
key
:前两种情况必须要去解决,不然就会有 BUG(弱网必现key
:可以不解决,是不会有 BUG 的。但考虑到缓存、GC 的原因,最好还是解决一下可以用
AbortController
优雅的实现相关逻辑fetch
,以实现请求取消( promise rejectaborted
属性实现丢弃逻辑具体到代码中,在每次请求时创建一个
AbortController
实例,并将其signal
传递给实际的执行者:queryFn
而使用者只需要在
queryFn
中使用signal
就可以了(绝大多数情况也是直接透传给fetch
用户体验
loading
这就是为什么
React Query/SWR
会为我们提供两个loading
变量:isLoading
: 请求中且没有数据可用isFetching/isValidating
: 请求中已有数据可用为了良好的用户体验,要准备两个
loading
效果loading
:首次请求时,没有数据可用于渲染loading
:数据更新(手动/自动)时,页面中已有数据渲染错误处理
对于初始请求(没有数据),没有什么可以值得讨论的,我们需要展示降级的视图或提示
对于数据更新的场景,如果你用的是
toast
错误提示,也还好但如果用的是渲染错误视图的方式,就要多考虑一下了,尤其是自动更新的场景:
refetchOnWindowFocus/refetchOnReconnect
,如果此类自动更新获取失败,可能会导致用户体验混乱优先展示错误还是陈旧的数据?这个问题没有明确的答案,取决于具体场景
对于一个库来说,要做的就是「同时将收到的错误和过时的数据返回给用户」(目前的代码实现就是这样)
现在,由你来决定显示什么:
乐观更新
在合适的场景里又是一个提升体验的大杀器
目前我们对
变更
操作的处理应该都是阻塞 UI:给用户一个loading/disable
,在此期间无法进行其他操作,直到接口响应(成功/失败在有业务校验的场景(eg 购物),这是合理的,因为会有很多因素导致失败(余额、商品数量、地址…),有些来自于用户输入,这是无法控制的
但有些场景(eg 聊天、评论),接口的成功/失败只取决于服务可用性,我们知道所有公司都对服务可用性有要求
交互流程大概如下,以列表新增为例:
unshift
新数据loading
opacity: 0.5
请求失败,可以做如下操作
toast
错误提示一旦发生乐观更新失败的场景,就关闭乐观更新模式,回退到阻塞模式
Via the Cache
React Query
提供了两种乐观更新的方式,先来看标准的:通过修改缓存数据实现Via the UI
很取巧但是更简单的方式,不会去修改缓存数据,利用
mutate + query
配合loading
直接在 UI 层做乐观更新首先数据层代码是这样的:
看起来好像什么都没有做:我们发起请求,并在完成后触发缓存失效更新数据,这是最常规的写法
诀窍在
onSettled
的return
,它返回了queryClient.invalidateQueries
我们知道
invalidateQueries
的作用是使缓存失效,但实际上它会返回一个promise
,缓存失效时如果触发了网络请求,promise
会在请求成功之后resolve
也就是说上面的代码等同于:
它把
mutate
和query
链接在了一起,变成了一个promise
链,当整个链条没有resolve
时,useMutation
也不会结束所以视图层我们可以直接访问
isPending
来展示乐观更新的状态非常的巧妙,如果请求成功了,
isPending
就会变成false
,这样就不会展示乐观更新的数据了,但同时最新的列表数据也已经请求回来并更新在了 UI 上如果请求失败了,
isPending
同样变为false
,相当于自动执行了回滚操作这是一个取巧且简单的方法,所以有着一些局限性:
isPending
只有一个渲染依赖优化
考虑如下场景:
我们知道
result
里的数据是会频繁变化的,比如当isFetching/error/isInvalidated...
变化时但这个组件只使用了
isLoading & data
,如果其他数据的变化导致了result
变化,进而导致组件重新渲染,这有必要吗?因为我们只使用了
isLoading & data
,所以其他数据的变化并不会导致重新渲染的组件有什么变化,所以这是没有必要的React Query 通过监听数据的
get
,实现了只会在使用的数据变化时,重新渲染组件结构共享优化
每次从后台请求回来的数据,即使数据完全没有变化,引用也全部都是新的了
考虑如下响应,新获取的数据中只
id=1
发生了变化,id=2
数据是没有变的React Query 会深度比较数据,并尽可能多地保留以前的状态(引用)
对于上面的响应,
id=1
会是一个新的引用,而id=2
则仍然是之前的引用More
Suspense
还可以接着列,但是没有必要了,详细的可以直接去看对应库的官网
就目前说的这些,已经完全可以说明同步、异步状态管理的不同了
回过头来再看:「数据获取很简单,异步状态管理不是」,也可以说「代码开发很简单,用户体验不是」
Ref