我是 tRPC 的忠实粉丝。从服务器导出类型并将它们导入客户端以在两者之间建立类型安全契约的想法,即使没有编译时步骤,也非常棒。对于所有参与 tRPC 的人,你们的工作非常棒。
也就是说,当我查看 tRPC 和 GraphQL 之间的比较时,我们似乎在比较苹果和橙子。
当您查看围绕 GraphQL 和 tRPC 的公共讨论时,这一点变得尤为明显。例如,看一下theo的这张图:
Theo深入解释了这张图,乍一看,它很有道理。 tRPC 不需要编译时步骤,开发人员体验令人难以置信,而且它比 GraphQL 简单得多。
但这真的是全貌,还是这种简单性是以其他东西为代价实现的?让我们通过使用 tRPC 和 GraphQL 构建一个简单的应用程序来找出答案。
让我们想象一个文件树,其中包含一个新闻提要页面、一个提要列表组件和一个提要项目组件。
src/pages/news-feed ├── NewsFeed.tsx ├── NewsFeedList.tsx └── NewsFeedItem.tsx
在提要页面的最顶部,我们需要一些关于用户、通知、未读消息等的信息。
当呈现 feed 列表时,我们需要知道 feed 项的数量,是否有另一个页面,以及如何获取它。
对于提要项,我们需要知道作者、内容、点赞数以及用户是否点赞。
如果我们要使用 tRPC,我们将创建一个“过程”来一次性加载所有这些数据。我们将在页面顶部调用此过程,然后将数据向下传播到组件。
我们的 feed 组件看起来像这样:
import { trpc } from '../utils/trpc' export function NewsFeed() { const feed = trpc.newsFeed.useQuery() return ( <div> <Avatar>{feed.user}</Avatar> <UnreadMessages> {feed.unreadMessages} unread messages </UnreadMessages> <Notifications> {feed.notifications} notifications </Notifications> <NewsFeedList feed={feed} /> </div> ) }
接下来,让我们看一下提要列表组件:
export function NewsFeedList({ feed }) { return ( <div> <h1>News Feed</h1> <p>There are {feed.items.length} items</p> {feed.items.map((item) => ( <NewsFeedItem item={item} /> ))} {feed.hasNextPage && ( <button onClick={feed.fetchNextPage}>Load more</button> )} </div> ) }
最后,提要项目组件:
export function NewsFeedItem({ item }) { return ( <div> <h2>{item.author.name}</h2> <p>{item.content}</p> <button onClick={item.like}>Like</button> </div> ) }
请记住,我们仍然是一个团队,所有的都是 TypeScript,一个代码库,而且我们仍在使用 tRPC。
让我们弄清楚渲染页面实际需要哪些数据。我们需要用户、未读消息、通知、提要项、提要项数、下一页、作者、内容、点赞数以及用户是否点赞。
我们在哪里可以找到所有这些的详细信息?要了解头像的数据要求,我们需要查看Avatar
组件。有未读消息和通知的组件,因此我们也需要查看这些组件。提要列表组件需要项目数、下一页和提要项目。提要项组件包含每个列表项的要求。
总之,如果我们想了解此页面的数据要求,我们需要查看 6 个不同的组件。同时,我们并不真正知道每个组件实际需要什么数据。每个组件都没有办法声明它需要什么数据,因为 tRPC 没有这样的概念。
请记住,这只是一页。如果我们添加相似但略有不同的页面会怎样?
假设我们正在构建新闻提要的变体,但我们不显示最新帖子,而是显示最受欢迎的帖子。
我们可以或多或少地使用相同的组件,只需进行一些更改。假设热门帖子有需要额外数据的特殊徽章。
我们应该为此创建一个新程序吗?或者我们可以在现有过程中再添加几个字段?
如果我们添加越来越多的页面,这种方法是否可以很好地扩展?这听起来不像是我们在使用 REST API 时遇到的问题吗?我们甚至已经为这些问题取了个响亮的名字,比如 Overfetching 和 Underfetching,我们甚至还没有谈到 N+1 问题。
在某些时候,我们可能决定将过程拆分为一个根过程和多个子过程。如果我们在根级别获取一个数组,然后对于数组中的每个项目,我们必须调用另一个过程来获取更多数据怎么办?
另一个开放可能是为我们程序的初始版本引入参数,例如trpc.newsFeed.useQuery({withPopularBadges: true})
。
这会奏效,但感觉就像我们开始重新发明 GraphQL 的功能一样。
现在,让我们将其与 GraphQL 进行对比。 GraphQL 有 Fragments 的概念,它允许我们声明每个组件的数据需求。 Relay 等客户端允许您在页面顶部声明单个 GraphQL 查询,并将子组件的片段包含到查询中。
这样,我们仍然在页面顶部进行一次获取,但框架实际上支持我们声明和收集每个组件的数据需求。
让我们看一下使用 GraphQL、Fragments 和 Relay 的同一个例子。出于懒惰的原因,代码不是 100% 正确,因为我使用 Copilot 编写它,但它应该非常接近真实应用程序中的样子。
import { graphql } from 'react-relay' export function NewsFeed() { const feed = useQuery(graphql` query NewsFeedQuery { user { ...Avatar_user } unreadMessages { ...UnreadMessages_unreadMessages } notifications { ...Notifications_notifications } ...NewsFeedList_feed } `) return ( <div> <Avatar user={feed.user} /> <UnreadMessages unreadMessages={feed.unreadMessages} /> <Notifications notifications={feed.notifications} /> <NewsFeedList feed={feed} /> </div> ) }
接下来,让我们看一下提要列表组件。提要列表组件为自己声明一个片段,并包含提要项组件的片段。
import { graphql } from 'react-relay' export function NewsFeedList({ feed }) { const list = useFragment( graphql` fragment NewsFeedList_feed on NewsFeed { items { ...NewsFeedItem_item } hasNextPage } `, feed ) return ( <div> <h1>News Feed</h1> <p>There are {feed.items.length} items</p> {feed.items.map((item) => ( <NewsFeedItem item={item} /> ))} {feed.hasNextPage && ( <button onClick={feed.fetchNextPage}>Load more</button> )} </div> ) }
最后,提要项目组件:
import { graphql } from 'react-relay' export function NewsFeedItem({ item }) { const item = useFragment( graphql` fragment NewsFeedItem_item on NewsFeedItem { author { name } content likes hasLiked } `, item ) return ( <div> <h2>{item.author.name}</h2> <p>{item.content}</p> <button onClick={item.like}>Like</button> </div> ) }
接下来,让我们创建一个新闻提要的变体,在提要项目上使用流行徽章。我们可以重用相同的组件,因为我们可以使用@include
指令有条件地包含流行的徽章片段。
import { graphql } from 'react-relay' export function PopularNewsFeed() { const feed = useQuery(graphql` query PopularNewsFeedQuery($withPopularBadges: Boolean!) { user { ...Avatar_user } unreadMessages { ...UnreadMessages_unreadMessages } notifications { ...Notifications_notifications } ...NewsFeedList_feed } `) return ( <div> <Avatar user={feed.user} /> <UnreadMessages unreadMessages={feed.unreadMessages} /> <Notifications notifications={feed.notifications} /> <NewsFeedList feed={feed} /> </div> ) }
接下来,让我们看看更新后的提要列表项会是什么样子:
import { graphql } from 'react-relay' export function NewsFeedItem({ item }) { const item = useFragment( graphql` fragment NewsFeedItem_item on NewsFeedItem { author { name } content likes hasLiked ...PopularBadge_item @include(if: $withPopularBadges) } `, item ) return ( <div> <h2>{item.author.name}</h2> <p>{item.content}</p> <button onClick={item.like}>Like</button> {item.popularBadge && <PopularBadge badge={item.popularBadge} />} </div> ) }
如您所见,GraphQL 非常灵活,允许我们构建复杂的 Web 应用程序,包括同一页面的变体,而无需重复太多代码。
此外,GraphQL Fragments 允许我们显式声明每个组件的数据需求,然后将其提升到页面顶部,然后在单个请求中获取。
tRPC 出色的开发人员体验是通过将两个截然不同的关注点合并到一个概念(API 实现和数据消费)中来实现的。
了解这是一种权衡很重要。天下没有免费的午餐。 tRPC 的简单性是以灵活性为代价的。
使用 GraphQL,您必须在模式设计上投入更多,但当您必须将应用程序扩展到许多但相关的页面时,这项投资就会得到回报。
通过将 API 实现与数据获取分开,可以更轻松地为不同的用例重用相同的 API 实现。
构建 API 时还有另一个重要方面需要考虑。您可能从一个专供您自己的前端使用的内部 API 开始,而 tRPC 可能非常适合这种用例。
但是你的努力的未来呢?您扩大团队的可能性有多大?其他团队甚至第 3 方是否有可能想要使用您的 API?
REST 和 GraphQL 在构建时都考虑到了协作。并非所有团队都会使用 TypeScript,如果您跨越公司边界,您将希望以易于理解和使用的方式公开 API。
有很多工具可以公开和记录 REST 和 GraphQL API,而 tRPC 显然不是为这个用例设计的。
因此,虽然开始使用 tRPC 很棒,但您很可能会在某个时候超过它,我认为 theo 在他的一个视频中也提到了这一点。
从 tRPC API 生成 OpenAPI 规范当然是可能的,工具是存在的,但是如果你正在建立一个最终依赖于将 API 暴露给第 3 方的业务,你的 RPC 将无法与设计良好的 REST 和GraphQL API。
如开头所述,我非常喜欢 tRPC 背后的想法。这是朝着正确方向迈出的重要一步,使数据获取更简单,对开发人员更友好。
另一方面,GraphQL、Fragments 和 Relay 是帮助您构建复杂 Web 应用程序的强大工具。同时,设置非常复杂,在掌握它之前需要学习很多概念。
虽然 tRPC 可以让您快速入门,但很可能在某个时候您会超出其架构。如果您今天决定押注 GraphQL 或 tRPC,您应该考虑您看到的项目在未来。数据获取要求有多复杂?会有多个团队使用您的 API 吗?您会将您的 API 公开给第 3 方吗?
综上所述,如果我们能够结合两全其美呢?结合了 tRPC 的简单性和 GraphQL 的强大功能的 API 客户端会是什么样子?我们能否构建一个纯 TypeScript API 客户端,为我们提供 Fragments 和 Relay 的强大功能,并结合 tRPC 的简单性?
想象一下,我们采用 tRPC 的思想并将它们与我们从 GraphQL 和 Relay 中学到的知识相结合。
这是一个小预览:
// src/pages/index.tsx import { useQuery } from '../../.wundergraph/generated/client' import { Avatar_user } from '../components/Avatar' import { UnreadMessages_unreadMessages } from '../components/UnreadMessages' import { Notifications_notifications } from '../components/Notifications' import { NewsFeedList_feed } from '../components/NewsFeedList' export function NewsFeed() { const feed = useQuery({ operationName: 'NewsFeed', query: (q) => ({ user: q.user({ ...Avatar_user.fragment, }), unreadMessages: q.unreadMessages({ ...UnreadMessages_unreadMessages.fragment, }), notifications: q.notifications({ ...Notifications_notifications.fragment, }), ...NewsFeedList_feed.fragment, }), }) return ( <div> <Avatar /> <UnreadMessages /> <Notifications /> <NewsFeedList /> </div> ) } // src/components/Avatar.tsx import { useFragment, Fragment } from '../../.wundergraph/generated/client' export const Avatar_user = Fragment({ on: 'User', fragment: ({ name, avatar }) => ({ name, avatar, }), }) export function Avatar() { const data = useFragment(Avatar_user) return ( <div> <h1>{data.name}</h1> <img src={data.avatar} /> </div> ) } // src/components/NewsFeedList.tsx import { useFragment, Fragment } from '../../.wundergraph/generated/client' import { NewsFeedItem_item } from './NewsFeedItem' export const NewsFeedList_feed = Fragment({ on: 'NewsFeed', fragment: ({ items }) => ({ items: items({ ...NewsFeedItem_item.fragment, }), }), }) export function NewsFeedList() { const data = useFragment(NewsFeedList_feed) return ( <div> {data.items.map((item) => ( <NewsFeedItem item={item} /> ))} </div> ) } // src/components/NewsFeedItem.tsx import { useFragment, Fragment } from '../../.wundergraph/generated/client' export const NewsFeedItem_item = Fragment({ on: 'NewsFeedItem', fragment: ({ id, author, content }) => ({ id, author, content, }), }) export function NewsFeedItem() { const data = useFragment(NewsFeedItem_item) return ( <div> <h1>{data.title}</h1> <p>{data.content}</p> </div> ) }
你怎么看?你会用这样的东西吗?您是否看到在组件级别定义数据依赖性的价值,或者您更愿意坚持在页面级别定义远程过程?我很想听听你的想法...
我们目前正处于为 React、NextJS 和所有其他框架构建最佳数据获取体验的设计阶段。如果您对此主题感兴趣,请在Twitter 上关注我以了解最新信息。
如果您想加入讨论并与我们讨论 RFC,请随时加入我们的Discord服务器。
下一个