当项目复杂后,组件直接调用基于 axios 封装的异步 api 函数的方式,在组件需要调用多个 api 时会变得复杂起来,比如需要管理多个 loading 和 error 状态,这会导致产生非常多的 state 声明,还有请求取消、请求竞态等可能存在的问题也容易被忽略。
背景
获取数据的几种方式
- Fetch-on-render:先开始渲染组件,每个完成渲染的组件都可能在它们的 effects 或者生命周期中获取数据。这种方式经常导致“瀑布”问题(本该并行发出的请求无意中被串行发送出去)。
- Fetch-then-render:先尽早获取下一屏需要的所有数据,数据准备好后,渲染新的屏幕。但在数据拿到之前,我们什么事也做不了。
- Render-as-you-fetch:先尽早获取下一屏需要的所有数据,然后立刻渲染新的屏幕(在网络响应可用之前就开始)。在接收到数据的过程中,React 迭代地渲染需要数据的组件,直到渲染完所有内容为止。
有了 Suspense,我们不必等到数据全部返回才开始渲染。当数据没获取完毕,组件会处在挂起的状态,React 会跳过这个组件,去渲染组件树中其他的组件。
数据获取的分类
- 初始数据获取 initial data fetching:打开页面时期望能立刻能看到的数据,在 React 种通常实在 useEffect 或 componentDidMount 中发起这类数据
- 按需数据获取 data fetching on demand:用户和页面发生交互后再请求数据,在 React 中通常实在事件的回调函数种请求这些数据
除了处理数据正确性和加载态问题,当场景变得复杂时,还会面临一些棘手问题
- 错误处理要怎么实现
- 如何处理多个组件从同一接口获取数据?这些数据是否要缓存?缓存时间多久?
- 竞态问题要如何处理
- 请求取消要怎么做:手动取消、卸载取消
- 高级特性:乐观更新、请求轮询、错误重试、依赖刷新……
乐观更新定义:大多数请求都是成功的,因此我们针对更新操作,在请求回来之前,可以本地先进行状态更新,从而提高应用的响应性,提高用户体验。但如果请求真失败了,需要对状态进行回退。乐观更新其实并不可靠,如果在短时间内,用户多次变更,有的成功有的失败,这时候该恢复哪个版本其实是混乱的。
将数据加载提升到顶层组件,虽然在性能方面有很大的提升,但是对于应用架构和代码可读性来说简直就是噩梦。把所有的数据请求和大量的 props 放在一起,让我们得到了一个巨石组件。
RTK-Query
RTK-Query 是一个针对 Redux 应用程序的专用数据获取和缓存解决方案,它可以消除为管理数据编写任何 thunks 的需要。
基本代码展示
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
const apiSlice = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.xxx.com',
prepareHeaders(headers) {
headers.set('x-api-key', 'your_access_key');
return headers;
}
}),
endPoints(builder) {
return {
fetchBreeds: builder.query<Bread[], number|void>({
query(limit = 10) {
return `/breeds?limit=${limit}`
}
}),
fetchBreed: builder.query({
query: id => `/breeds/${id}`
}),
addBreed: builder.mutation({
query: data => ({
url: '/breeds',
method: 'POST',
// Include the entire object as the body of the request
body: data
})
}
}
})
export const { useFetchBreedsQuery, useFetchBreedQuery, useAddBreed } = apiSlice
export const store = configureStore({
reducer: { [apiSlice.reducerPath]: apiSlice.reducer},
middleware: getDefaultMiddleware =>
// 这很重要,用于关于管理缓存的生存时间和过期时间
getDefaultMiddleware().concat(apiSlice.middleware)
})
支持传递 queryFn
- 当使用 queryFn 时会忽略 baseQuery
- 通过设置 queryFn 为 noop,配合 invalidatesTag 实现重新获取其他数据
- 实现一次发送多个请求,这是 query 无法做到的
- transformResponse 不能作用于 queryFn
useLazyQuery 实现手动派发请求
- 第二参数 preferCacheValue 表示是否使用缓存
- 如果走的缓存,则不会触发 matchFulfilled 动作
onQueryStarted 字段
- 查询参数:queryParams
- 相关配置:包含 dispatch/getState/queryFulfilled 字段的对象
- 其中 queryFulfilled 可以实现手动更新缓存,而不是重新拉取
createEntityAdapter 复用 CRUD 逻辑
- adapter select 出来的数据是 readonly object
- upsert: update or insert if does not exist
- updateOne 和 updateMany 只能浅层合并更新,对于深层嵌套更新,推荐手动操作
注意事项
- 根据 endpoints 断定 isLoading 字段,并不考虑到参数的改变,此时需要选择 isFetching
- 逻辑拆分:通常推荐仅调用一次 createApi,通过 apiSlice.injectEndPoints 组合多个模块
扩展:extraReducers:addMatcher vs addCase
- addCase 是 addMatcher 的特殊化,用于和指定 type 匹配
- addMatcher 传递一个函数作为匹配条件,返回 true 则进入 reducer 逻辑
简单例子
const reducer = createReducer(initialState, (builder) => {
builder
.addCase(resetAction, () => initialState)
// matcher can be defined outside as a type predicate function
.addMatcher(isPendingAction, (state, action) => {
state[action.meta.requestId] = "pending";
})
.addMatcher(
// matcher can be defined inline as a type predicate function
(action): action is RejectedAction => action.type.endsWith("/rejected"),
(state, action) => {
state[action.meta.requestId] = "rejected";
}
)
// matcher can just return boolean and the matcher can receive a generic argument
.addMatcher<FulfilledAction>(
(action) => action.type.endsWith("/fulfilled"),
(state, action) => {
state[action.meta.requestId] = "fulfilled";
}
);
});
RTK-Query 优点
- 支持懒查询,此时返回一个执行函数,和 mutation 类似
- 自动生成 hooks,且 TS 支持度很高,虽然一旦报错,报错的描述性非常差
- 支持 mutation 返回 promise
- 支持 selector 派生属性
可能是一些问题
- 学习成本较高
- 编写 endpoints 感觉还挺繁琐的
- 当 api 需要传多个参数时,只能组合成一个对象进行传递,不能多个入参
查询与更新
从 apiSlice 中更新数据代码示例
// 乐观更新中手动更新缓存
async function onQueryStarted({ projectId, planId, partialPlan }, { dispatch, queryFulfilled }) {
// 请求刚开始时,直接更新缓存
const patchResult = dispatch(
apiSlice.util.updateQueryData('fetchPlans', projectId, (draft) => {
plansAdapter.updateOne(draft, { id: planId, changes: partialPlan });
}),
);
try {
await queryFulfilled;
} catch {
// 失败时回退
patchResult.undo();
}
},
createSelector 创建记忆化的选择器函数,只有在输入改变时才会重新计算
// todos/selectors.ts
export const selectFilteredTodos = createSelector(
selectTodos,
selectFilter,
(todos: TTodo[], filter: TFilter) => {
if (filter === 'all') {
return todos
}
return todos.filter(todo => todo.state === filter)
}
)
从 apiSlice 中查询数据
export const selectPlans = createSelector(
[
(state: RootState, projectId: string) =>
plansApi.endpoints.fetchPlans.select(projectId)(state).data ||
plansAdapter.getInitialState(),
],
(entityState) => plansAdapter.getSelectors().selectAll(entityState),
);
React-Query
Stale-while-revalidate 策略:表示重新请求的同时使用过期数据。
React-Query 解决的核心问题
- 核心对象 queryClient
- 提供自定义钩子 useQuery 和 useMutation 用于处理查询和更新请求,内部维护好 data/isLoading/isError 状态
- staleTime、cacheTime:配置数据过期时间、缓存持续时间
- enabled:实现相关查询、手动查询
- retry、retryDelay:失败重试
- structuralSharing:如果数据没有发生变化,则引用位置不变
- initialData、placeholderData:设置初始数据、占位数据
- keepPreviousData:优化分页查询体验(避免 success 和 loading 之间反复变化)
- select:数据选择,可实现类似 useSelector 效果
基础代码演示
import { useQuery } from 'react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnMount: false,
refetchOnWindowFocus: false,
},
},
});
export function useTodosQuery(onSuccess) {
return useQuery(['todos', 10], ({ queryKey }) => fetchTodos(queryKey[1]), { onSuccess });
}
export function useTodoQuery(id: string) {
return useQuery(['todo', id], () => fetchTodo(id))
}
export function useAddTodoMutation() {
return useMutation(
(todo) => addTodo(todo),
{
onSuccess(todo) {
queryClient.setQueryData(['todos'], (old = []) => [
...old,
todo,
]);
},
},
);
}
乐观更新代码示例
const mutation = useMutation(updateTodo, {
onMutate: async newTodo => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries(['todos', newTodo.id])
// Snapshot the previous value
const previousTodo = queryClient.getQueryData(['todos', newTodo.id])
// Optimistically update to the new value
queryClient.setQueryData(['todos', newTodo.id], newTodo)
// Return a context with the previous and new todo
return { previousTodo, newTodo }
},
// If the mutation fails, use the context we returned above
onError: (err, newTodo, context) => {
queryClient.setQueryData(
['todos', context.newTodo.id],
context.previousTodo
)
},
// Always refetch after error or success:
onSettled: newTodo => {
queryClient.invalidateQueries(['todos', newTodo.id])
}
})
可能存在的问题
- 当 query 需要处理副作用时,需要使用 onSuccess 配置,不支持 promise,感觉逻辑被割裂了。mutation 支持 mutate 和 mutateAsync 函数,其中 mutateAsync 返回 promise,方便用于处理副作用
- 在复杂应用中,服务端状态和客户端状态往往会互相作用,比如派生 state 此时会变得比较麻烦
- 由于 useQuery 功能强大,如 select 可以改变返回值类型,React-Query 已经尽可能的推断出具体的类型,但封装自定义的 hook 时,如果你想透传所有的 query options,你很快就会陷入类型问题。官方建议是:不允许创建传递所有查询选项的抽象,而是只提供那些您希望使用者覆盖的选项,否则,抽象可能太宽泛,您将需要处理大量泛型。
- TS 报错会很难定位,且有时候莫名报错
状态管理
社区有些讨论,认为使用 React-Query 后,可以不需要使用 Redux 之类的状态管理库了。第一次看到这句话,我是有点懵的,这不是一个数据请求库吗?怎么还和状态管理库扯上关系了呢。
之所以会有这样的说法主要在于 React-Query 提供的缓存功能,当你在多个组件使用使用相同的 key useQuery 时,它会仅获取一次数据,然后从缓存中返回它,有些像是一个 server-state 状态管理库。
对于绝大多数应用来说,在将所有异步代码迁移到 React Query 后,真正可全局访问的客户端状态通常非常小。在某些情况下,应用程序可能确实有大量的同步客户端状态(如视觉设计器或音乐制作应用程序),在这种情形下,你可能仍然需要一个客户端状态管理器。
总结
世界尚不存在这样的类库,仅通过自身就能提高应用程序的性能。它们只是让一些事情变得更容易,同时也让另外一些事情变得更困难。
刚使用这种 Query 库时,非常纠结一个问题:仅把它当做一个查询库使用还是状态管理库使用?一方面其的确具备了状态管理功能,但它的数据与查询更改相对而言变复杂了,当如果仅当做查询库使用,状态使用其他 state 去存,会在陷在数据一致性陷阱中。
明确一个原则:将状态从一个管理器复制到另一个管理器是糟糕的
- 将 props 复制到 state
- 将 cache 复制到 local state 或者 global state
使用 RTK-Query 后,各种场景都支持的不错,但成员反馈觉得太复杂了。自己也想有一个轻量的管理请求的封装,大概包含如下功能即可
- 支持手动执行和动态传参
- 手动取消与自动取消请求
- 处理好竞态请求
- 类型完善
调研发现 ahooks 提供的 useRequest 完美的支持了我的需求,提供了更多高级功能,且轻量、简单、灵活。只是对乐观更新差点点意思。
哎,还是没有从内心真正被哪个方案安利到!