为什么我放弃使用 RTK Query

2,389 阅读11分钟

事情的起因是前一阵子我在做 react 几个请求库的横向对比,就接触到了 RTK Query,心里想着既然背靠 redux toolkit 这座大山,应该还是值得信赖的,正好手头有一个新的个人项目要启动,就顺道试用一下吧。

但是没想到,一脚踏入泥潭,最终挣扎了个把月之后,我决定彻底放弃 RTK Query,将已经实现的数十个接口重写回 react-query。虽然重构本身就是一件很痛苦的事情,但是在逐步移除 RTK Query 代码的时候我竟然心中暗爽,可以说这个东西给我带来的心理阴影真的挺深刻的。

为了让大家少踩坑,我决定把这段经历写出来。由于最终是选择用 react-query 重写,所以本文的主要内容会集中于 RTK Query 的缺点 以及 RTK Query 和 react-query 的对比 上。

OK,废话少说,现在开始。

简单介绍下 RTK Query

如果你曾经用过 useSWR、react-query 或者 ahook 的 useRequest 的话,那你应该知道它们都是属于一种叫做“数据缓存管理库”的东西,通过在前端业务逻辑和后端接口调用之间建立一个中间层,由此对后端接口的调用、请求数据的缓存进行管理,从而为前端带来更好更快的开发和使用体验。

而 RTK Query 就是 redux-toolkit 团队发布的一个数据缓存管理库。由于 redux 的无人不知无人不晓,RTK Query 自然吸引了很多人的目光。RTK Query 直接包含在 redux-toolkit 包内部,只要你项目里使用了 redux-toolkit,那你大概率可以直接使用而无需安装。并且 RTK Query 正是使用了 redux 进行的缓存管理。

官网文档在这里:RTK Query Overview | Redux Toolkit (redux-toolkit.js.org),有兴趣的可以自己看一下。

RTK Query 都有哪些问题?

ok,大致了解了 RTK Query 之后,我们就来看一下它都有哪些让我难以接受的问题:

高昂的迁移成本:RTK Query 的 API 设计导致了无法复用已经写好的接口请求封装,只能从头重建。

高昂的学习成本:需要重新学习一整套 api,包括如何封装核心请求方法,如何构建 api。

更麻烦的调试:这个现在说不太直观,下面细讲的时候有例子大家一看便知。

复杂的缓存更新:由于 RTK Query 使用 redux 管理缓存,所以在更新缓存时需要用到 redux 的 dispatch 那一套逻辑。

详细讲讲这些问题

1、迁移难

如果用过我上面提到的那些 react-query、useSWR、useRequest 的话,那你应该能发现它们都是几乎相同的编写方式:基于代理模式,接受一个异步函数,进行包装来添加请求缓存相关的功能。

import { useQuery} from '@tanstack/react-query'

/** 获取数据的接口封装 */
const getTodos = async () => { /** ... **/ }

const query = useQuery({ queryKey: ['todos'], queryFn: getTodos })

这种方式的灵活性很高,我不需要关心你接口是怎么请求的,我不管你请求接口时需要做什么操作,你只要能返回数据就行,因为我作为一个数据缓存层,只关心你的数据,而不关心你如何获取这些数据。

这就带来了很多好处,比如前端项目里基本都会对接口请求进行封装(一个接口封装成一个函数),所以说可以直接把这些封装好的函数传递给 react-query,也就是说 可以复用之前的接口调用而无需重新开发

并且这些请求 hook 和接口请求函数之间是一对一的关系,比如完全可以做到今天写的两个接口用了 react-query,之前写的老接口保持不变。即 可以无缝使用 react-query 进行开发而无需额外基础重构

总结一下就是这样的:

image.png

这两个好处就导致了开发者几乎不需要付出任何额外成本就能享受到其带来的好处。

那么 RTK Query 是怎么做的呢?它采用了一个大而全的 api 方案,你需要 基于它提供的方法从头搭建所有的接口请求

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

export const pokemonApi = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
  endpoints: (builder) => ({
    getPokemonByName: builder.query({
      query: (name) => `pokemon/${name}`,
    }),
    // 其他的接口请求...
  }),
})

export const { useGetPokemonByNameQuery } = pokemonApi

上面是 RTK Query 官网的基础示例,可以看到它提供了一个 endpoints 方法,可以在这个方法通过提供的 builder 对象声明式的构建你要用到的接口。

如果你想在自己项目里使用,你需要先在你的核心请求方法上再包装一层,使其可以接入 RTK Query 的 baseQuery,然后再研究 builder 的 api,并一个个的把你曾经的接口封装翻译成 RTK Query 的写法:

image.png

这种方案导致了 RTK Query 很难用在已经成熟的项目上,因为需要重构现有接口带来的测试成本是无法接受的,更别提重构本身需要花费的成本了。

如果你说我要搞新项目,没有迁移成本(像我一样),那也会导致一个很严重的后果:你的 service 层(接口封装)和 RTK Query 强耦合,以后这个项目就和 RTK Query 绑在一起了,这很大程度上增加了项目的技术负担。

并且“重新封装核心请求方法”这个看似简单的任务也是暗坑遍布,一开始我是基于 RTK Query 内置的核心请求方法(fetchBaseQuery)进行的封装,但是却处处碰壁,举个简单的例子:这个内置封装里暴露了 prepareHeaders 方法来修改请求的 header,但是这个方法里却无法获取到请求的具体参数,就导致了没法实现防重放攻击。

2、写法麻烦

有人可能会说,如果 RTK Query 可以让我的接口开发更加快速,一两行代码就可以实现一个接口封装,那不也是能极大的提升开发效率么?

这么说是没错,但是我们看一下 RTK Query 和 react-query 两者实现相同接口所需的代码量:

react-query

import { requestGet, requestPost } from './base'
import { LoginReqData, LoginResp } from '@/types/user'
import { useQuery, useMutation } from 'react-query'

/** 查询用户信息 */
export const useQueryUserInfo = (enabled: boolean) => {
    return useQuery('userInfo', () => {
        return requestGet<LoginResp>('user/getInfo')
    }, { enabled })
}

/** 登录 */
export const useLogin = () => {
    return useMutation((data: LoginReqData) => {
        return requestPost<LoginResp>('user/login', data)
    })
}

/** 创建管理员账号 */
export const useCreateAdmin = () => {
    return useMutation((data: LoginReqData) => {
        return requestPost('user/createAdmin', data)
    })
}

RTK query

import { baseApi } from './base'
import { AppResponse } from '@/types/global'
import { LoginReqData, LoginResp } from '@/types/user'

const extendedApi = baseApi.injectEndpoints({
    endpoints: (build) => ({
        /** 查询用户信息 */
        getUserInfo: build.query<AppResponse<LoginResp>, void>({
            query: () => 'user/getInfo'
        }),
        /** 登录 */
        postLogin: build.mutation<AppResponse<LoginResp>, LoginReqData>({
            query: body => ({
                url: 'user/login',
                method: 'POST',
                body
            })
        }),
        /** 创建管理员账号 */
        createAdmin: build.mutation<AppResponse, LoginReqData>({
            query: body => ({
                url: 'user/createAdmin',
                method: 'POST',
                body
            })
        }),
    })
})

export const {
    useLazyGetUserInfoQuery,
    usePostLoginMutation,
    useCreateAdminMutation
} = extendedApi

平心而论,除了 RTK query 嵌套的更深一点、让刚接触项目的人更懵一点之外,我没看到 RTK query 有哪些别的好处。

并且随着前端项目的工程化规范化,后续是有可能接入诸如 openapi 等 service 层代码自动生成工具的。这样自动生成的接口函数就可以直接用在 react-query 上,而 RTK Query 就没办法很好的与之协作。从这个角度看,RTK Query 反而是拖累开发效率的那一个。

3、缓存更新难

对请求数据缓存的管理和更新是这种缓存请求库的核心功能,对于 react-query 来说,缓存这个东西非常的具象化。你明确的知道有一个对象(queryClient),这个对象提供了几个 api 分别可以对缓存进行增删改查。

下面就是个 react-query 实现悲观更新的例子:

export const useUpdateTag = () => {
    return useMutation((data: TagUpdateReqData) => {
        return requestPost('tag/update', data)
    }, {
        onSuccess: (resp, data) => {
            const oldData = queryClient.getQueryData('tagList')
            const newData = oldData.map(i => i.id === data.id ? { ...i, ...data } : i)
            queryClient.setQueryData('tagList', newData)
        }
    })
}

可以看到开发思路非常明确通顺:

监听请求的 onSuccess > 成功后获取老缓存 > 编辑缓存 > 然后把缓存塞回去。

那么用 RTK Query 是怎么做的呢?

export const tagApi = baseApi.injectEndpoints({
    endpoints: (build) => ({
        updateTag: build.mutation<AppResponse<string>, TagUpdateReqData>({
            query: data => ({
                url: 'tag/update',
                method: 'POST',
                body: data
            }),
            async onQueryStarted({ id, ...newTag }, { dispatch, queryFulfilled }) {
                const { data: updatedPost } = await queryFulfilled
                if (updatedPost.code !== STATUS_CODE.SUCCESS) return

                dispatch(
                    tagApi.util.updateQueryData('getTagList', undefined, (draft) => {
                        if (!draft) return
                        const targetTag = draft.find(item => item.id === id)
                        if (!targetTag) return
                        Object.assign(targetTag, newTag)
                    })
                )
            }
        }),
    })
})

export const { useUpdateTagMutation } = tagApi

首先,监听 onQueryStarted 回调 > 从参数里解构出 dispatch 和 queryFulfilled > 等待 queryFulfilled 执行完成(这个 promise 就是请求的异步结构) > 调用 tagApi.util.updateQueryData 并给第三个参数传递一个回调方法,可以通过入参 draft 拿到指定的缓存 > 编辑缓存,并合并到入参 draft 上 > 把 tagApi.util.updateQueryData 生成的 action 传递给上一步解构出的 dispatch,触发缓存更新。

说实话我并不是很想面对这一坨东西,单不说这个逻辑绕了几个圈,就说这个嵌套层级已经可以难受死一大片前端开发了。

如果你想写一个 help 方法来封装这一堆繁琐的更新操作,那么恭喜你,一脚踩进了另一个大坑里。

RTK Query 通过极高难度的类型体操,让你 updateQueryData 方法里取到的 draft 可以自动推导出对应缓存的类型。不得不说这一点确实很强,也是 react-query 欠缺的。但是如果你的 help 方法也想拥有这个特性的话,那你就要配合它来一场更高难度的类型体操。或者说你可以直接 any 大法扔掉这个好特性,如果你心里不会难受的话。

在我产生这个念头并付诸实践的两个小时之后,我放弃了。我开始怀疑我在干什么,就算花了一个下午的时间实现了这个封装,那又有什么收获呢?可以把一个接口请求的缓存更新从十行节省到了四行?而隔壁的 react-query 本身就只需要三行就能实现这个功能?

RRVHATjpg

这也成为压垮我的最后一根稻草,之后我就着手把代码迁移回之前使用的 react-query。

你可能会觉得,哪有手动更新缓存的,直接把对应的 tag 或者 cache key 作废掉不就行了么,让请求库自己去拉取新数据。

确实是这样的,但是这并不代表永远用不到手动更新缓存这个功能。

4、调试难

现在让我们回到一个简单 RTK Query 例子:

const extendedApi = baseApi.injectEndpoints({
    endpoints: (build) => ({
        /** 查询用户信息 */
        getUserInfo: build.query<AppResponse<LoginResp>, void>({
            query: () => 'user/getInfo'
        }),
    })
})

export const {
    useLazyGetUserInfoQuery,
} = extendedApi

可以看到,在内部声明完接口之后,就可以直接解构导出对应的请求 hook,并且 RTK query 还会十分贴心的帮你在前后加上 use 和 Query / Mutation。这看起来省了很多事,但实际上让开发体验更糟糕了。

原因在于,由于 export / import 和 ts 的类型支持,我们在代码编辑器里可以通过 CTRL + 点击来快速跳转到对应的函数声明。如果你是使用的 react-query 开发,那点击后就可以直接抵达具体的接口封装实现。但是 RTK query 不行:

login5.gif

可以看到,我们在组件里点击跳转后,只能跳到 export 的解构导出处,想要查看实际的接口实现,你需要小心翼翼的选中中间的接口名字(记得避开前后的 use 和 Query),然后搜索,再然后跳转,这样才能看到具体的接口实现。

这个问题导致实际开发调试的体验很糟糕,每次跳转的时候我的心情都像是吃了()一样。

另外,在手动更新缓存的时候,如果你想打印一下缓存是什么样的,那么就可以看到:

image.png

是的,你看不到实际的缓存内容,而是一个 Proxy 对象。经常使用 react toolkit 的同学应该很熟悉这个缺陷。因为 react toolkit 引入了一个小工具,让你可以实现:“看起来直接修改了状态,实际上却是重新创建了一份新状态”。而 RTK Query 又基于 react toolkit,于是就也出现了这个毛病。

5、其他

在一开始的时候,当我看到 RTK Query 直接使用 redux store 来存储缓存的时候,我还觉得这会给开发带来很多优势,比如可以更简单快捷的组合和读写缓存。但是实际上并不是这样,相反,和 redux toolkit 的集成只带来了负面收益:

  • 有存在感的地方只在刚开始接入的时候:需要多搞一些配置
  • 没法直接用存在 toolkit 里的缓存(虽然可以访问到,但是数据结构明显不是随便让外部使用的)

并且我们甚至还没有提到 RTK Query 本身用于构建接口请求的 builder api 的学习成本。

除此之外,我们可以看一下 RTK Query 官网上提供的和其他请求库的横向对比:Comparison with Other Tools | Redux Toolkit (redux-toolkit.js.org)

看完之后你就会发现,确实看不到 RTK Query 有什么优势。

总结

总结一下,无论从设计上、还是从实际开发体验上,RTK Query 都称不上是优秀,甚至我觉得及格都有些勉强。需要学习更多的 api,使用更复杂的语法,更难受的调试、需要重写整个接口层实现,包括基础的接口封装。

希望本文的总结可以帮助大家少走弯路,这样也算我时间没有白花了。