常常說 GraphQL 可以解決 under-fetching 和 over-fetching 的問題。但真的是這樣嗎?理論上聽起來很有前景,但實際上,你可能會用一堆新的問題來交換,這些問題甚至是最複雜的框架也難以解決。
一次性请求,所有东西的诱惑想象你在一个吃到饱自助餐的地方,有人给了你一些建议,
想吃的都放满你的盘子吧,这样就不用一趟趟地跑了。
听起来效率很高,对吧?这就像 GraphQL 建议的那样。
尽量在单一请求中包含你需要的所有数据以避免数据不足,并且你可以非常具体地指定所需内容以避免获取过多的数据。
让我们按照这个建议来做!
要在React应用程序中做到这一点,我们可以把数据获取逻辑提升到最顶层,并将这个大规模的数据对象传递给展示组件。
这是我们在使用Hasura和GraphQL-Codegen进行查询时得到的一个数据模式示例。
export type ProjectQuery = {
__typename?: 'query_root',
project_by_pk?: {
__typename?: 'project',
id: string,
name: string,
description?: string | null, // 项目描述可以为空
status: SchemaTypes.ProjectStatusEnum, // 项目状态
start_date?: string | null, // 开始日期可以为空
due_date?: string | null, // 截止日期可以为空
created_at: string,
updated_at: string,
households: Array<{
__typename?: 'household_project',
household: {
__typename?: 'household',
id: string,
name: string,
status: SchemaTypes.HouseholdStatusEnum, // 居民户状态
severity: SchemaTypes.HouseholdSeverityEnum, // 居民户严重程度
code?: string | null, // 居民户编码可以为空
created_at: string,
updated_at: string,
members_count?: number | null // 成员数量可以为空
}
}>
} | null
};
切换到全屏模式,退出全屏
现在,我们不再是整洁、模块化的组件及其自己的数据查询,而是变成了一个庞大的单一结构——带有临时构建的、充满随机空值的结构。
寻找有意义的框架第一步:我们刚刚放弃了 co-location!不过有个好消息是,我们现在有展示组件 🎉
你说得没错:“为什么模式是随意的?这确实是一个技能问题。我们不能创建一些有意义的实体吗?”
一种方法是创建比如 ProjectStatusFragment
,HouseholdIdentityFragment
和 HouseholdMembersFragment
等片段,并在整个团队中强制使用这些片段。
不过,等等——我们每次都需要这些片段背后的所有数据吗?片段的目的是为了能够重复使用,但这可能导致过度抓取数据,这与 GraphQL 的主要承诺相违背。
在客户端只查你需要的信息,不多也不少。
在现实世界里,用例多种多样,无穷无尽。要创建有意义的片段而不过度获取数据,这需要我们创建无数的片段。这样做既不实际也不高效。因此,我们会采用灵活的模式,让每个用例自行决定所需的数据。
这又把我们带回了原点,让我们明白了一个道理:
空值难题每一层抽象和复用都会导致过多的数据抓取,这违反了 GraphQL 承诺的高效数据获取。
为什么我们的数据中有这么多随机出现的空值?这背后的原因在于GraphQL关于空值的处理设计决策。
太长不看
在 GraphQL 中,每个字段和每个类型默认都是可为空的。这意味着,默认情况下,每个字段都是可为空的。这样,如果某个字段失败,只会导致该字段返回 "null",而不是导致整个请求失败。
这意味着我们的模式遍布着可选字段,导致数据结构中空值遍布。这并不一定是个糟糕的设计选择,但这是我们使用GraphQL时的实际情况。
回到核心问题现在,没有遇到任何技能问题,我们却有一个庞大的数据集,它的模式是临时且不完整的。我们需要将这些数据传递给我们的展示层组件,但我们该怎么传递这些数据呢?
选项 1:支撑钻孔
一种选择是 prop 透传。但要在不失理智的情况下传递这种数据模式其实并不可行。
让我们看看展示组件的目的:它们是无副作用的、松耦合的,因此可重用的和易于测试的。通过传递这个庞大的、类型松散的对象,我们将组件紧密地绑定到了一个特定的查询结构上。
type Props = {
households: Array<{
__typename?: 'household_project',
household: {
__typename?: 'household',
id: string,
name: string,
status: SchemaTypes.HouseholdStatusEnum,
severity: SchemaTypes.HouseholdSeverityEnum,
code?: string | null,
created_at: string,
updated_at: string,
members_count?: number | null
}}>
} | null
}
const 家庭列表组件 = ({ households }: Props) => {}
全屏进入 全屏退出
紧密的依赖不仅是指组件使用或导入的内容。在软件开发中,依赖意味着 "这段代码知道什么信息?" 当一段代码知道特定的信息时,它就需要在该信息变化时作出反应。这意味着我们的 HouseholdList
组件不仅仅是在使用数据;它还与我们的查询结果的具体结构紧密相连。因此,查询的任何更改都会导致我们组件的高层次 API 发生变化。
它是不是緊密耦合的? 絕對。
它是不是容易測試? 一點兒也不。
展示型组件需要依赖。它们依赖于父组件来管理职责和副作用,比如数据获取。通过将这些责任从组件本身转移出去,我们引入了重复。每次我们在不同的场景中重用这些组件时,都需要在它们的父组件中重复同样的数据获取逻辑。
在这种情况下,我们两边都不占便宜:我们既没有享受到展示型组件的好处,仍然要承担其成本。
还有我们也不能忘记,我们的数据到处都是空值。更大的问题是,我们的组件是否也应该仅仅因为I/O不太可靠就接受空值?
接下来是下一堂课:
将原始查询结果直接传递给组件,会让组件与数据读写的不确定性绑定在一起。
寻找有意义的用户界面
为了理清这个混乱的局面,我们可能会尝试创建有意义的、相对独立的接口。我们会将庞大的数据映射到每个组件所需的格式,拥抱抽象的概念。
但这里有个关键点:良好的抽象与_只需获取所需_的方法产生了冲突。
zh: 为啥?
让我们试着建立一个 Project
对象和一个映射器函数:
// 原英文代码
type Project = {
id: string;
name: string;
dueDate?: Date;
}
function toProject(data: X): Project { /* ... */ }
全屏模式,点击这里进入。全屏模式,点击这里退出。
但什么是 X
?如果我们假设它是从我们的 GraphQL 架构生成的 Project
类型,我们就麻烦大了。考虑此查询:
const { data } = useQuery(gql`{ 项目们 { id, 截止日期 } }`);
切换到全屏模式。切换回正常模式。
这个数据缺少映射到我们Project
实体所必需的字段。我们无法可靠地将部分数据映射到完整的Project
实体,而不会导致运行时错误或状态不一致。
选项 2:使用上下文
好的,也许打造有意义的界面可能不太现实,但我们可以避免 prop 数据直接传递导致污染,彻底避免 prop drilling。“啊哈!那我们就用 React 的 Context API 吧!” 我们就通过 Context 来传递数据吧。
const Page = () => {
const 查询结果 = usePageQuery();
return (
<MyProvider value={查询结果}>
<MyChildren />
</MyProvider>
);
}
const 子组件 = () => {
const { data, loading, error } = useContext(MyProvider);
}
切换到全屏模式 退出全屏
但等等,别急——我们不是通过上下文将 MyChildren
与 usePageQuery
关联起来了吗?我们通过依赖注入实现这一点,但这不够透明易懂,我们稍后再来讨论这一点。更大的问题是这样的,因为 ApolloClient
通过 ApolloProvider
提供了一个缓存,我们在这里增加了冗余的层次。
我们可以这样简化代码:
[此处无代码]
const Page = () => {
usePageQuery();
return <MyChildren />;
};
const MyChildren = () => {
const { data } = usePageQuery({ fetchPolicy: "cache-only" });
// 组件的逻辑
};
// 提示:usePageQuery 是一个用于获取页面查询的钩子。
// fetchPolicy: "cache-only" 表示只从缓存中获取数据,不从服务器请求。
切换到全屏 退出全屏
现在你看得见我了!环境并不能解决我们根本的问题;它只是掩盖了问题的本质。
边请求边渲染:渲染即取的挑战
在许多情况下,我们并不需要一开始就拥有所有数据就可以开始渲染。当我们把所有内容组合成一个巨大的请求时,这使得在数据到达时,我们难以立即渲染应用程序的某个部分。
是的,我们可以使用如 @defer
这样的指令,但这会使客户端和服务器更复杂。
此外,有时候我们需要为不同的数据使用不同的策略。例如,我们可能希望在服务器上渲染部分数据,在客户端处理剩下的数据。在这种情况下,我们需要把查询分成至少两个查询。(刚才说的是动态和静态数据吗🤔)
const Page = () => {
const serverQuery = useServerPageQuery(); // 服务器端查询
const clientQuery = useClientPageQuery({ ssr: false }); // 客户端查询
/* ... */
}
全屏切换 退出全屏
缓存过期:隐藏的怪物当我们变更数据时,需要更新缓存。然而,有时候乐观地更新和手动调整缓存并不实际。在这种情况下,最安全的做法通常是重新获取数据。
但是使用我们的一站式查询时,重新抓取意味着再次抓取整个数据集——这是一项繁重且低效的操作。
有没有解决方案?或许有,但这需要一种超越大多数应用程序开发者通常需要实现的复杂基础设施。我们这里谈的是能够智能处理部分缓存失效管理的系统。
追逐零获取的代价:过度抓取与抓取不足让我们来计算一下尽量减少过度取用和不足取用的成本。
- 耦合的展示组件
- 不共处
- 信噪比低:大量生成的类型和空值处理让我们的代码库变得非常杂乱。
- 复杂的渲染方法
- 缓存管理噩梦:复杂的缓存策略和频繁的缓存失效使得代码维护变得极为困难。
值得做吗?
来一次现实检验在实践中,许多团队放弃了追求最小化且全面覆盖的查询的理想,转而选择使用较小且可重用的数据获取钩子,比如 useUser
,useComments
和 useWhatever
。他们还会使用片段来促进重用,并在 GraphQL 架构中定义一致的实体。
但 GraphQL 的主要卖点不就是它是一种客户端查询语言,允许我们按我们所需的形状请求数据吗?然而在实践中,我们却更像在使用一个 SDK 来进行数据请求。这不就是在增加复杂性的情况下复制 RPC 或 REST 调用的功能吗?
确实,我认识到GraphQL本质上并不是坏事——它在解决某些问题上确实比其他解决方案更有效。它提供了灵活性、强类型以及统一的数据获取接口。然而,作为应用开发者,我认为在采用GraphQL之前,我们有必要重新思考从使用GraphQL中真正获得了什么。
如果你是类似 Facebook 这样的科技巨头,有能力构建和维护能充分挖掘 GraphQL 潜力的复杂框架,那么当然可以利用它。
不过,对于大多数中小企业而言,没有足够的资源就采用 GraphQL 会导致复杂和挫败。根据我的经验,这通常只会导致一团糟,而不是简洁高效的数据管理。
共同學習,寫下你的評論
評論加載中...
作者其他優質文章