Better

Ethan的博客,欢迎访问交流

React 数据获取优化

当项目复杂后,组件直接调用基于 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 完美的支持了我的需求,提供了更多高级功能,且轻量、简单、灵活。只是对乐观更新差点点意思。

哎,还是没有从内心真正被哪个方案安利到!



留言