GraphQL客户端的核心范式是声明式数据获取——组件声明自己需要什么数据,框架负责获取和更新。这与REST的命令式获取(fetch('/api/users/1').then(render))有本质区别。
在React中,这个范式通过hooks实现:
function UserPostsPage({ userId }: { userId: string }) { // 声明:我需要这个用户和他的帖子 const { data, loading, error } = useQuery(GET_USER_WITH_POSTS, { variables: { userId }, // 策略:先返回缓存,同时更新 fetchPolicy: 'cache-and-network', // 下次回来时仍然有数据 returnPartialData: true, }); if (error) return <ErrorBanner error={error} />; if (loading && !data) return <LoadingSkeleton />; return ( <div> <UserInfo user={data.user} /> <PostList posts={data.user.posts} /> </div> ); }
注意 loading 和 data 的独立处理——cache-and-network 策略下,首次访问时loading为true且data为空,但缓存命中后loading为true但data不为空(正在后台更新)。
良好的组件化策略是:一个组件对应一个Fragment。Fragment定义组件的数据需求,组件只关心渲染逻辑。
UserProfilePage ├── UserInfo (fragment: UserInfo_user) │ ├── Avatar (fragment: Avatar_user) │ ├── UserStats (fragment: UserStats_user) │ └── FollowButton (mutation: toggleFollow) ├── UserPosts (fragment: UserPosts_user) │ └── PostCard (fragment: PostCard_post) │ ├── PostContent │ ├── LikeButton (mutation: toggleLike) │ └── CommentList (fragment: CommentList_post) │ └── CommentItem (fragment: CommentItem_comment) └── UserSidebar ├── RecentActivity (fragment: RecentActivity_user) └── RelatedUsers (fragment: RelatedUsers_user)
父组件的Fragment引用子组件的Fragment,形成一棵Fragment树。Apollo Client在执行时自动把这棵Fragment树合并成一个高效的查询。
GraphQL的客户端缓存天然适合管理远程数据。但对于纯本地状态(如UI开关、模态框状态、表单临时数据),用React的useState或useReducer更合适。
推荐的分层策略:
不要把所有状态都塞进Apollo缓存——它适合管理API数据,不适合管理UI状态。
查询只在顶层组件发,数据通过props传递。子组件不发请求,避免瀑布式请求。
用Fragment定义数据契约。每个组件有一个对应的Fragment,明确声明自己需要什么数据。
加载和错误状态标准化。定义统一的Loading和Error组件,而不是每个组件各自处理。
Mutation与乐观更新配对。所有Mutation都考虑是否需要乐观更新。高频操作(点赞、关注)需要,低频操作(创建帖子)不需要。
分页组件化。用 usePaginationFragment 或自定义hook封装分页逻辑,让页面组件不需要关心cursor管理。
订阅按需激活。只在组件挂载时启动Subscription,卸载时取消。避免全局订阅导致的内存泄漏。
把前面的原则拼成一个完整的用户主页:
function UserProfilePage({ userId }) { // 顶层组件发查询,子组件不发请求 const { data, loading } = useUserProfileQuery({ variables: { userId }, fetchPolicy: 'cache-and-network', }); if (loading && !data) return <PageSkeleton />; return ( <div> <UserHeader user={data.user} /> <UserPosts posts={data.user.posts} /> </div> ); } // 子组件只通过 props 拿数据,不发请求 function UserPosts({ posts }) { return ( <section> {posts.map(post => <PostCard key={post.id} post={post} />)} </section> ); }
注意 UserPosts 没有 useQuery,它的数据由父组件注入。如果要让 UserPosts 也能独立测试,就给它一个对应的 Fragment 并在父查询里展开——这样既保持"一次请求",又让每个组件的数据需求显式可见。
客户端组件测试的关键是隔离网络。Apollo Client 提供 MockedProvider,用内存 mock 替换真实请求:
import { MockedProvider } from '@apollo/client/testing'; const mocks = [ { request: { query: GET_USER, variables: { userId: '1' } }, result: { data: { user: { id: '1', name: '林远' } } }, }, ]; render( <MockedProvider mocks={mocks}> <UserProfilePage userId="1" /> </MockedProvider> ); await screen.findByText('林远');
测试覆盖的重点:加载态、错误态(mocks 里不提供对应请求会触发 error)、数据渲染、Mutation 的乐观更新回滚。这些场景在真实网络下难以稳定复现,mock 之后每个都能精确控制。
组件化做过头也会带来性能问题,三条红线值得记住:
很多团队把 GraphQL 用成了"高级 REST",问题往往出在三个旧习惯上:
习惯在组件里到处发请求。REST 时代每个组件自己 fetch 是常态,GraphQL 下这会变成请求瀑布。迁移的关键动作是砍掉子组件的 useQuery,改成父组件一次查询加 Fragment 展开。
习惯在客户端拼装数据。REST 返回扁平的资源,前端经常要自己关联 userId 和用户信息。GraphQL 下这些关联应该在 Schema 和解析器里完成——客户端拿到的就是"帖子带作者"的完整结构,拼装逻辑从几十行变成零。
习惯忽略字段级错误。REST 要么整个成功要么整个失败,GraphQL 是部分成功。旧习惯会让人只检查"请求是否成功",漏掉"哪些字段为 null"——这正是第四节的错误处理模式要解决的问题。
把这三个习惯改过来,GraphQL 客户端的代码量通常能明显下降,因为框架替你管理了缓存、去重和更新,你只需要声明数据和渲染。
GraphQL的声明式特性与React的设计哲学高度一致。把数据获取逻辑从组件中剥离出来,让组件只做"渲染",是GraphQL客户端开发的核心范式。