GraphQL 的神话

2025-06-08

GraphQL 的神话

人们常说 GraphQL 解决了数据获取不足过度获取的问题。但事实真的如此吗?理论上,这听起来很有希望。然而,在实践中,你可能会遇到一堆新问题,即使是最复杂的框架也难以解决。

将所有内容放在一个请求中的诱惑

想象一下,你正在享用自助餐,有人建议你,

只需一次性将您可能需要的所有食物装进盘子即可;这样可以节省您的行程!

听起来很高效,对吧?这和 GraphQL 的建议类似:

将您需要的数据打包到单个请求中以避免获取不足,并且我会让您准确指定您想要防止过度获取的内容。

让我们遵循建议!
为了在 React 应用程序中实现这一点,我们可能会将数据获取逻辑提升到最高级别,并将这个巨大的数据对象传递给我们的展示组件。

以下是我们使用HasuraGraphQL-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
};
Enter fullscreen mode Exit fullscreen mode

现在,我们不再拥有整洁、模块化的组件及其自己的数据查询,而是拥有一个庞大的整体对象——带有一个充满随机空值的临时模式

第一步:我们刚刚牺牲了主机托管!不过好的一面是,我们还有展示组件🎉

寻求有意义的模式

你说得对:“为什么这个模式是临时的?这是个技能问题。我们不能创建一些有意义的实体吗?”
一种方法是创建像ProjectStatusFragmentHouseholdIdentityFragment和 这样的片段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) => {}
Enter fullscreen mode Exit fullscreen mode

紧密依赖不仅仅指组件使用或导入的内容。在软件开发中,依赖关系指的是“这部分代码感知到哪些信息?”当一段代码感知到特定信息时,它就会负责在该信息发生变化时做出响应。这意味着我们的HouseholdList组件不仅仅是使用数据;它与查询结果的具体结构紧密相关。因此,查询中的任何更改都会触发组件高级 API 的更改。

它是紧耦合的吗?绝对是。
它容易测试吗?一点也不。

展示组件并非免费。它们依赖于父组件来处理诸如数据获取之类的职责和副作用。通过将这种责任从组件本身转移出去,我们引入了重复。每次我们在不同的上下文中复用这些组件时,都必须在其父组件中复制相同的数据获取逻辑。

在这种情况下,我们得到的是两种最糟糕的结果:我们没有获得展示组件的好处,但我们仍然付出了代价。

别忘了,我们的数据里充斥着空值。更大的问题是,仅仅因为 I/O 不可靠,我们的组件就应该接受可空值吗?

以下是下一课:

将原始查询结果作为 props 传递,将我们的组件与 I/O 的不可预测性耦合在一起。

寻找有意义的界面

为了理清这个乱局,我们可以尝试创建一些有意义的、解耦的接口。我们将繁琐的数据映射到每个组件所需的数据上,拥抱抽象。

但问题在于:良好的抽象与仅仅查询您需要的方法相冲突。

为什么?

让我们尝试创建一个Project实体和一个映射器函数:

type Project = {
  id: string;
  name: string;
  dueDate?: Date;
}

function toProject(data: X): Project { /* ... */ }
Enter fullscreen mode Exit fullscreen mode

但是 是什么X?如果我们假设它是Project从 GraphQL 模式生成的类型,那就麻烦了。考虑这个查询:

const { data } = useQuery(gql`{ projects { id, dueDate } }`);
Enter fullscreen mode Exit fullscreen mode

这些数据缺少映射到我们实体所需的字段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);
}
Enter fullscreen mode Exit fullscreen mode

但是等等——我们不就是通过上下文耦合MyChildrenusePageQuery吗?这不透明,因为我们是通过依赖注入来实现的,但我们马上就会讲到。我们有一个更大的问题,因为ApolloClient提供了缓存ApolloProvider,所以我们在这里添加了冗余层。

简化我们的代码,我们可以写:

const Page = () => {
  usePageQuery();
  return <MyChildren />;
};

const MyChildren = () => {
  const { data } = usePageQuery({ fetchPolicy: "cache-only" });
  // Component logic
};
Enter fullscreen mode Exit fullscreen mode

现在你看到我了!上下文并不能解决我们的根本问题;它只会掩盖它。

“即取即渲染”的挑战

很多情况下,我们并不需要预先获取所有数据即可开始渲染。当我们将所有数据合并成一个庞大的请求时,应用程序的某些部分在数据到达后立即渲染会变得更加困难。

是的,我们可以使用类似的指令@defer,但实现它们会给客户端和服务器增加复杂性。

此外,有时我们需要针对不同的数据采用不同的策略。例如,我们可能希望在服务器上渲染部分数据,在客户端渲染其余数据。在这种情况下,我们需要将查询拆分成至少两个单独的查询。(我是不是漏掉了动态数据和静态数据?)

const Page = () => {
  const serverQuery = useServerPageQuery();
  const clientQuery = useClientPageQuery({ ssr: false });
  /* ... */
}

Enter fullscreen mode Exit fullscreen mode

缓存失效:隐藏的野兽

当我们修改数据时,我们需要更新缓存。有时,乐观更新和手动操作缓存并不可行。最安全的方法通常是重新获取。

但是对于我们的一体化查询,重新获取意味着再次获取整个数据集——这是一项繁重、低效的操作。

有解决方案吗?或许有,但这需要复杂的基础设施,超出了大多数应用开发者的承受能力。我们讨论的是能够智能管理部分缓存失效的系统。

追求零的代价——过度获取和获取不足

让我们来计算一下争取零过度获取和获取不足的成本:

  • 耦合展示组件
  • 无需共置
  • 低信噪比:大量生成的类型和空处理使我们的代码库变得混乱。
  • 复杂的渲染策略
  • 缓存管理的噩梦

灭霸表情包

值得吗?

现实检验

在实践中,许多团队放弃了编写精简、包罗万象的查询的理想。相反,他们选择更小、可重用的数据获取钩子,例如useUseruseCommentsuseWhatever。他们还利用片段来提高可重用性,并在 GraphQL 模式中定义内聚实体。

但 GraphQL 的主要卖点不正是它是一种客户端查询语言——允许我们以所需的形式请求数据吗?然而,在实践中,我们更像是把它当成一个简单的 SDK,直接发出数据请求。这难道不是在复制 RPC 或 REST 调用可以实现的功能,却增加了复杂性吗?

是的,我承认 GraphQL 本身并不坏——它确实比其他解决方案更有效地解决了某些问题。它提供了灵活性、强类型和统一的数据获取接口。然而,作为应用程序开发者,我认为在采用 GraphQL 之前,我们应该重新思考一下它究竟能给我们带来什么好处。

如果您是像 Facebook 这样的科技巨头,有能力构建和维护充分利用 GraphQL 潜力所需的复杂框架,那么请务必利用它。

然而,对于大多数中小型企业来说,在缺乏必要资源的情况下采用 GraphQL 会带来复杂性和挫败感。根据我的经验,这往往会导致数据管理混乱,而不是精简。

鏂囩珷鏉ユ簮锛�https://dev.to/asafaeirad/the-myth-of-graphql-20fl
PREV
反腐败层模式整合策略:反腐败层:结论
NEXT
使用 i18next 实现 React 应用国际化