GraphQL 的神话
人们常说 GraphQL 解决了数据获取不足和过度获取的问题。但事实真的如此吗?理论上,这听起来很有希望。然而,在实践中,你可能会遇到一堆新问题,即使是最复杂的框架也难以解决。
将所有内容放在一个请求中的诱惑
想象一下,你正在享用自助餐,有人建议你,
只需一次性将您可能需要的所有食物装进盘子即可;这样可以节省您的行程!
听起来很高效,对吧?这和 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
};
现在,我们不再拥有整洁、模块化的组件及其自己的数据查询,而是拥有一个庞大的整体对象——带有一个充满随机空值的临时模式。
第一步:我们刚刚牺牲了主机托管!不过好的一面是,我们还有展示组件🎉
寻求有意义的模式
你说得对:“为什么这个模式是临时的?这是个技能问题。我们不能创建一些有意义的实体吗?”
一种方法是创建像ProjectStatusFragment
、HouseholdIdentityFragment
和 这样的片段HouseholdMembersFragment
,并在整个团队中强制使用它们。
但是等等——我们每次都需要这些片段背后的所有数据吗?片段本来应该是可重用的,但重用性可能会导致过度获取,这与 GraphQL 的主要承诺相矛盾:
在客户端上准确查询您需要的内容 — 不多也不少。
在现实世界中,用例是无限的。为了创建有意义的片段而不造成过度获取,我们需要创建无限数量的片段。这既不实际也不高效。因此,我们默认采用灵活的模式,让每个用例自行决定所需的数据。
这让我们回到原点,并得到一个教训:
每个抽象层和可重用性都会引入数据过度获取,这与 GraphQL 的核心承诺相矛盾。
空值问题
为什么我们的数据中会有这么多随机空值?答案在于GraphQL 关于可空性的设计决策:
TL;DR
在 GraphQL 中,每个字段和每个类型默认都是可空的。... 通过将每个字段默认为可空,任何字段失败都可能导致该字段返回“null”,而不是请求完全失败。
这意味着我们的 schema 中充斥着可选字段,最终导致数据结构中充满了空值。这未必是一个糟糕的设计选择,但在使用 GraphQL 时,这却是现实。
回归核心问题
现在,我们没有任何技能问题,但却面临海量数据、临时且不完整的模式。我们需要将这些数据传递给展示组件,但该如何传递呢?
方案一:支柱钻孔
一种选择是 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 HouseholdList = ({ households } : Props) => {}
紧密依赖不仅仅指组件使用或导入的内容。在软件开发中,依赖关系指的是“这部分代码感知到哪些信息?”当一段代码感知到特定信息时,它就会负责在该信息发生变化时做出响应。这意味着我们的HouseholdList
组件不仅仅是使用数据;它与查询结果的具体结构紧密相关。因此,查询中的任何更改都会触发组件高级 API 的更改。
它是紧耦合的吗?绝对是。
它容易测试吗?一点也不。
展示组件并非免费。它们依赖于父组件来处理诸如数据获取之类的职责和副作用。通过将这种责任从组件本身转移出去,我们引入了重复。每次我们在不同的上下文中复用这些组件时,都必须在其父组件中复制相同的数据获取逻辑。
在这种情况下,我们得到的是两种最糟糕的结果:我们没有获得展示组件的好处,但我们仍然付出了代价。
别忘了,我们的数据里充斥着空值。更大的问题是,仅仅因为 I/O 不可靠,我们的组件就应该接受可空值吗?
以下是下一课:
将原始查询结果作为 props 传递,将我们的组件与 I/O 的不可预测性耦合在一起。
寻找有意义的界面
为了理清这个乱局,我们可以尝试创建一些有意义的、解耦的接口。我们将繁琐的数据映射到每个组件所需的数据上,拥抱抽象。
但问题在于:良好的抽象与仅仅查询您需要的方法相冲突。
为什么?
让我们尝试创建一个Project
实体和一个映射器函数:
type Project = {
id: string;
name: string;
dueDate?: Date;
}
function toProject(data: X): Project { /* ... */ }
但是 是什么X
?如果我们假设它是Project
从 GraphQL 模式生成的类型,那就麻烦了。考虑这个查询:
const { data } = useQuery(gql`{ projects { id, dueDate } }`);
这些数据缺少映射到我们实体所需的字段Project
。我们无法可靠地将部分数据映射到完整实体,否则可能会出现运行时错误或状态不一致。
选项 2:使用 Context
好吧,也许设计有意义的接口是不可能的,但我们可以通过完全避免 prop 钻取来防止组件接口被污染。“啊哈!我们要用 React 的 Context API!”我们设置一个提供程序,并通过 context 传递数据!
const Page = () => {
const query = usePageQuery();
return (
<MyProvider value={query}>
<MyChildren />
</MyProvider>
);
}
const MyChildren = () => {
const { data, loading , error } = use(MyProvider);
}
但是等等——我们不就是通过上下文耦合MyChildren
到usePageQuery
吗?这不透明,因为我们是通过依赖注入来实现的,但我们马上就会讲到。我们有一个更大的问题,因为ApolloClient
提供了缓存ApolloProvider
,所以我们在这里添加了冗余层。
简化我们的代码,我们可以写:
const Page = () => {
usePageQuery();
return <MyChildren />;
};
const MyChildren = () => {
const { data } = usePageQuery({ fetchPolicy: "cache-only" });
// Component logic
};
现在你看到我了!上下文并不能解决我们的根本问题;它只会掩盖它。
“即取即渲染”的挑战
很多情况下,我们并不需要预先获取所有数据即可开始渲染。当我们将所有数据合并成一个庞大的请求时,应用程序的某些部分在数据到达后立即渲染会变得更加困难。
是的,我们可以使用类似的指令@defer
,但实现它们会给客户端和服务器增加复杂性。
此外,有时我们需要针对不同的数据采用不同的策略。例如,我们可能希望在服务器上渲染部分数据,在客户端渲染其余数据。在这种情况下,我们需要将查询拆分成至少两个单独的查询。(我是不是漏掉了动态数据和静态数据?)
const Page = () => {
const serverQuery = useServerPageQuery();
const clientQuery = useClientPageQuery({ ssr: false });
/* ... */
}
缓存失效:隐藏的野兽
当我们修改数据时,我们需要更新缓存。有时,乐观更新和手动操作缓存并不可行。最安全的方法通常是重新获取。
但是对于我们的一体化查询,重新获取意味着再次获取整个数据集——这是一项繁重、低效的操作。
有解决方案吗?或许有,但这需要复杂的基础设施,超出了大多数应用开发者的承受能力。我们讨论的是能够智能管理部分缓存失效的系统。
追求零的代价——过度获取和获取不足
让我们来计算一下争取零过度获取和获取不足的成本:
- 耦合展示组件
- 无需共置
- 低信噪比:大量生成的类型和空处理使我们的代码库变得混乱。
- 复杂的渲染策略
- 缓存管理的噩梦:
值得吗?
现实检验
在实践中,许多团队放弃了编写精简、包罗万象的查询的理想。相反,他们选择更小、可重用的数据获取钩子,例如useUser
、useComments
和useWhatever
。他们还利用片段来提高可重用性,并在 GraphQL 模式中定义内聚实体。
但 GraphQL 的主要卖点不正是它是一种客户端查询语言——允许我们以所需的形式请求数据吗?然而,在实践中,我们更像是把它当成一个简单的 SDK,直接发出数据请求。这难道不是在复制 RPC 或 REST 调用可以实现的功能,却增加了复杂性吗?
是的,我承认 GraphQL 本身并不坏——它确实比其他解决方案更有效地解决了某些问题。它提供了灵活性、强类型和统一的数据获取接口。然而,作为应用程序开发者,我认为在采用 GraphQL 之前,我们应该重新思考一下它究竟能给我们带来什么好处。
如果您是像 Facebook 这样的科技巨头,有能力构建和维护充分利用 GraphQL 潜力所需的复杂框架,那么请务必利用它。
然而,对于大多数中小型企业来说,在缺乏必要资源的情况下采用 GraphQL 会带来复杂性和挫败感。根据我的经验,这往往会导致数据管理混乱,而不是精简。
鏂囩珷鏉ユ簮锛�https://dev.to/asafaeirad/the-myth-of-graphql-20fl