You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
constdispatch=useDispatch();constqueryKey="taskList";useEffect(()=>{dispatch(fetchAsyncState({
queryKey,queryFn: ()=>fetch("/api/list").then((res)=>res.json()),}),);},[]);const{ data }=useSelector((state)=>state.ASM[queryKey]);
/** * Hashes the value into a stable hash. */exportfunctionhashKey(queryKey: QueryKey|MutationKey): string{returnJSON.stringify(queryKey,(_,val)=>isPlainObject(val)
? Object.keys(val).sort().reduce((result,key)=>({ ...result,[key]: val[key]}),{}asany)
: val,);}
When you choose whether to put some logic into an event handler or an Effect, the main question you need to answer is what kind of logic it is from the user’s perspective. If this logic is caused by a particular interaction, keep it in the event handler. If it’s caused by the user seeing the component on the screen, keep it in the Effect.
You don’t need to move this fetch to an event handler.
您不需要将此获取移至事件处理程序。
This might seem like a contradiction with the earlier examples where you needed to put the logic into the event handlers! However, consider that it’s not the typing event that’s the main reason to fetch. Search inputs are often prepopulated from the URL, and the user might navigate Back and Forward without touching the input.
这可能看起来与前面的示例相矛盾,在前面的示例中您需要将逻辑放入事件处理程序中!
但是,请考虑到 input 事件并不是获取数据的主要原因。
搜索输入通常是从 URL 预先填充的,用户可以在不触摸输入的情况下导航后退和前进。
It doesn’t matter where page and query come from. While this component is visible, you want to keep results synchronized with data from the network for the current page and query. This is why it’s an Effect.
page 和 query 来自哪里并不重要。虽然此组件可见,但您希望使 results 与当前 page 和 query 的网络数据保持同步。这就是为什么它是一个 useEffect
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 finishedonSettled: async()=>{returnqueryClient.invalidateQueries({queryKey: ["todos"]});},});
标题经历了三次变化
*UI
?TL;DR
开发与用户进行交互的界面或工具时,面对异步状态需要等、慢、数据过时这些不可避免的事实下,怎么尽可能的提高 UX
具体到前端
TanStack Query/useSWR/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
中派生出具体的变量方便外部使用:为什么不直接在
fetchAsyncState
写isLoading/isError/isSuccess
呢?缓存管理
这可能是异步状态管理与同步状态管理最大的差异点了
要聊缓存,必须先要明确
queryKey
的含义,这很重要queryKey: "taskList"
的问题如果大家在用同步状态管理异步数据,这应该就是正在使用的方式了,有遇到什么 BUG 吗?
/api/list?page=1
和/api/list?page=2
和/api/list?name=s
算是同一种状态吗?queryKey: "taskList"
是它们的唯一标识吗?page=1
的数据渲染在了第二页里,算 BUG 吗?loading
和结果更新,算 BUG 吗?这些问题大家多少应该都碰到过,解决方案也有很多,比如:
key
本质是什么?
因为代码开发的原因,把
N
种状态抽象成了1
种(用一个字段承接了N
种不同的数据),抽象的代价就是会遇到各种问题换句话说,如果不做抽象,就不会有这些问题
数据缓存
还是列表场景,操作路径:
?page=1 -> ?page=2 -> ?page=1
(往返翻页)?page=1&s="" -> ?page=1&s="React" -> ?page=1&s=""
(搜索后清空)Q: 频繁重复的获取第一页(初始)的数据,是否有必要?
A: 要看具体场景,看对数据实时性的要求;还要看请求数据的代价(请求耗时)
但使用抽象
key
的方式,其实是没有选择的,因为每次请求成功后,之前的数据就已经被丢弃了,下次只能重新请求获取数据SWR
而且针对这个问题还有更好的回答:
SWR: stale-while-revalidate
SWR
是指在请求数据时,如果之前已经有缓存了原则是「有」总比「空」强(体验好),就算数据是过时的,也比没有数据强(更何况会在几百毫秒内(可能的)新数据就会到来
key
结论所以不管是从代码开发考虑,还是从数据缓存考虑
都应该去具象
queryKey
,保证每一个key
对应一份数据具体来说就是就是对于
get
请求,我们应该把url + [query] + [body]
作为queryKey
,这样就可以标识唯一的数据源了实现 SWR
就目前的代码,只要把
key
的问题解决,自然就实现了SWR
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 query
)这也是为什么
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
的概念的Cache-Control: max-age=604800, stale-while-revalidate=86400
后面会讲到请求去重、自动更新和手动缓存失效
staleTime
的概念可以轻松的与这些概念结合,没有什么心智负担staleTime = Infinity
的情况下,手动缓存失效,会重新发起请求吗?revalidateIfStale
&dedupingInterval
就不是这样了dedupingInterval = Infinity
的情况下,手动缓存失效,会重新发起请求吗?请求合并(去重)
讲两个大家熟悉的,轻松过渡一下
目前的同步全局状态管理中,如果大家要取一个全局的数据(比如
userInfo
),是用哪种方式取的?useSelector
取到后,利用props
向下不同的透传(props drilling
)useSelector
取我觉得在问废话,当时是 2 啊(不会真的有人用 1 吧 😱
我们知道
useQuery
的实现其实也只是一个有副作用的useSelector
而已我们希望对于使用者(视图层)来说,就把它当成
useSelector
只管取数据、用数据就好了这些都是数据层的事情,视图层管好渲染就可以了
目前的实现如果多个组件同时挂载,是会同时发出多个请求的
只需要加入
loading
态的判断即可完成去重抽象
key
这样写就会有问题,因为可能会有不同参数的请求进来上面的代码控制了接口
loading
过程中的重复请求(取决于接口的速度,也许是几百毫秒)对于同步的组件树挂载,这已经足够了(面试题:useEffect 的调用时机和顺序
但如果遇到异步组件(lazy load),就还有可能发生重复请求,那应该怎么办呢?
staleTime
是你的好朋友丢弃/取消请求
有些场景请求的数据已经不可能再被使用了,此时需要把忽略/丢弃/取消请求的结果
相信这些问题大家多少也遇到过
key
:对于前两种情况必须要去解决,不然就会有 BUG(弱网必现1
个字段在接受了N
种数据,但是网络是没有时序性保证的(先发的请求不一定是先响应的)key
:可以不解决,是不会有 BUG 的。但考虑到缓存、GC 的原因,最好还是解决一下可以用
AbortController
优雅的实现相关逻辑fetch
,以实现请求取消aborted
自行实现丢弃逻辑具体到代码中,在每次请求时创建一个
AbortController
实例,并将其signal
传递给实际的执行者:queryFn
而使用者只需要在
queryFn
中使用signal
就可以了(绝大多数情况也是直接透传给fetch
数据更新
自动更新(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?page=1
、/api/list?page=2
)基于这样的提前:
invalidateQueries(['/api/list'])
效果等同于invalidateQueries(['/api/list', {page: 1, name: 's'}])
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
中并没有提供清理缓存的相关配置,但是它允许你完全 自定义缓存行为,所以可以自行实现相关功能用户体验
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
The text was updated successfully, but these errors were encountered: