React Query
最佳特性之一是你可以在组件树中的任何位置使用查询:你的 <ProductTable>
组件可以在其需要的地方自带数据获取:
1 | function ProductTable() { |
它使得 ProductTable
组件解耦且独立:它负责读取自己的依赖:产品数据。如果这些数据已经在缓存中,那么很好,我们只需要读取它。如果没有,我们就去获取数据。我们可以在 React Server Components
中看到类似的模式。它们也允许我们在组件内部获取数据。不再需要在有状态和无状态,或智能和哑组件之间进行任意划分。
所以在需要的地方直接在组件内部获取数据是非常有用的。我们可以直接把 ProductTable
组件移动到应用程序中的任何位置,它都能正常工作。
自包含性
要让一个组件是自主的,这意味着它必须处理查询数据不可用(尚未加载)的情况,特别是:加载和错误状态。这对我们的 <ProductTable>
组件来说并不是什么大问题,因为通常,当它第一次加载时,它实际上会显示 <SkeletonLoader />
。
但是在很多其他情况下,我们只是想从查询的某些部分读取一些信息,而我们知道查询已经在树的上方使用过。例如,我们可能有一个包含登录用户信息的 userQuery
:
1 | export const useUserQuery = (id: number) => { |
我们可能会在组件树的早期使用这个查询,以检查登录用户的权限,并且它可能进一步决定我们是否可以看到页面。这是我们希望在页面上的任何地方都能获取的重要信息。
现在在树的更下方,我们可能有一个想要显示 userName
的组件,这个信息我们可以通过 useCurrentUserQuery
钩子获取:
1 | function UserNameDisplay() { |
TypeScript
不会允许我们这样做,因为data
可能是未定义的。但我们知道得更好 - 它不可能是未定义的,因为在我们的情况下,如果查询尚未在树的上方初始化,UserNameDisplay
就不会被渲染。
我们要让 TypeScript
直接使用 data!.userName
吗,因为我们知道它会被定义?我们是要安全起见使用 data?.userName
(在这里可能,但在其他情况下可能不容易实现)?我们是否只需添加一个保护:if (!data) return null
?还是我们要在调用 useCurrentUserQuery
的所有25个地方添加正确的加载和错误处理?
隐含的依赖
我们的问题来自于我们有一个隐含的依赖:一个只存在于我们头脑中的依赖,在我们对应用程序结构的知识中,但它在代码中并不可见。
尽管我们知道可以安全地调用 useCurrentUserQuery
而无需检查数据是否未定义,但任何静态分析都无法验证这一点。自己可能在3个月后也不再记得这一点。
最危险的是,现在可能是对的,但将来可能不再正确。我们可能会决定在应用程序的其他地方渲染另一个 UserNameDisplay
实例,在那里我们可能没有用户数据缓存,或者我们可能有条件地缓存用户数据,例如,如果我们之前访问过不同的页面。
这与 <ProductTable>
组件完全相反:它变得容易出错并且难以重构。我们不会期望 UserNameDisplay
组件因为移动了一些看似不相关的组件而崩溃…
明确依赖关系
解决方案当然是让依赖关系明确。而使用 React Context 是最好的方法:
React Context
关于 React Context
存在一些误解,我们先把这些弄清楚:React Context
不是一个状态管理器。当它与 useState
或 useReducer
结合使用时,可能看起来是个不错的状态管理解决方案:React Context
是一个依赖注入工具。它允许你定义你的组件需要哪些“东西”,并且由任何父组件负责提供这些信息。
这在概念上与属性传递(prop-drilling)
相同,属性传递是通过多个层级传递属性的过程。Context
允许你做同样的事情:获取一些值并将其作为属性传递给子组件,只是你可以省去几个中间层级:
使用 context
,你只需跳过中间层。在useCurrentUserQuery
示例中,它可以帮助我们明确依赖关系:不再在所有需要跳过数据可用性检查的组件中直接读取 useCurrentUserQuery
,而是从React Context
中读取。而这个context
将由实际进行第一次检查的父组件填充:
1 | const CurrentUserContext = React.createContext<User | null>(null) |
在这里,我们获取currentUserQuery
并将其结果数据放入 React Context
中(通过提前消除加载和错误状态)。然后我们可以在子组件中安全地从该 context
中读取,例如 UserNameDisplay
组件:
1 | function UserNameDisplay() { |
这样,我们就明确了隐含的依赖关系(我们知道数据已经在树的上方被获取)。每当有人查看UserNameDisplay
时,他们会知道需要从 CurrentUserContextProvider
提供数据。这是你在重构时可以记住的事情。如果你改变了 Provider
的渲染位置,你也会知道这将影响所有使用该 context
的子组件。这是你无法知道的,当一个组件只是使用查询时——因为查询通常在整个应用程序中是全局的,数据可能存在也可能不存在。
TypeScript
TypeScript
依然不太喜欢这种方式,因为 React Context
设计上也适用于没有 Provider
的情况,在这种情况下它会给你 Context
的默认值,在我们的例子中是 null
。由于我们不希望 useCurrentUserContext
在不在 Provider
中时工作,我们可以在自定义钩子中添加一个不变量:
1 | export const useCurrentUserContext = () => { |
这种方法确保了如果我们在错误的位置意外访问 useCurrentUserContext
,我们会快速失败并得到一个良好的错误信息。这样,TypeScript
将为我们的自定义钩子推断出User
类型的值,因此我们可以安全地使用它并访问其属性。
状态同步
你可能会想:这不是“状态同步”吗——将一个值从 React Query
复制到另一种状态分发方法?
来源依然是查询。除了 Provider
之外,没有其他方法可以改变context
值,Provider
将始终反映查询的最新数据。这里没有任何东西被复制,也没有任何东西可能会不同步。从 React Query
作为属性传递数据给子组件也不是“状态同步”,而且由于context
类似于属性传递,这也不是“状态同步”。
请求瀑布
没有什么是没有缺点的,这种技术也不例外。具体来说,它可能会产生网络瀑布,因为你的组件树会在 Provider
处停止渲染,因此子组件不会被渲染,无法发出网络请求,即使它们是无关的。
考虑这种方法用于子树中必需的数据:用户信息是一个很好的例子,因为如果没有这些数据我们可能不知道该渲染什么。
Suspense
谈到 Suspense:
是的,你可以用React Suspense
实现类似的架构,并且它也有同样的缺点:潜在的请求瀑布,
一个问题是,在当前的主要版本(v4)中,对查询使用 suspense: true 不会对 data 进行类型收窄,因为还有其他方式可以禁用查询并使其不运行。
然而,自 v5 起,有一个显式的useSuspenseQuery
钩子,在组件渲染时数据被保证是已定义的。这样,我们可以这样做:
1 | function UserNameDisplay() { |
这样 TypeScript
就会对它满意。🎉