在《GraphQL 服务端实现实战》中,我们从 Schema 设计到性能调优构建了一套完整的 GraphQL 后端。服务端再强,终端体验最终仍取决于客户端如何请求、缓存与管理状态。本文聚焦 JavaScript/TypeScript 生态三大 GraphQL 客户端框架——Apollo Client、Relay 与 urql——从架构原理到生产实践逐一拆解,助你做出最契合场景的选型。
一、三大框架选型总表
| 维度 | Apollo Client | Relay | urql |
|---|---|---|---|
| 缓存策略 | 归一化缓存 + Type Policies | Fragment 级归一化 + 编译时校验 | 归一化 + Exchanges 可替换 |
| 包体积(gzip) | ~34 KB | ~30 KB + 编译插件 | ~6 KB核心 + Exchange |
| 学习曲线 | 中(生态丰富但概念多) | 高(Fragment 思维 + Babel 插件) | 低(API 直觉,可渐进深入) |
| 生态与周边 | 极丰富(DevTools、Codegen) | Meta 验证,社区较小 | 活跃,多框架通用 |
| TypeScript 支持 | 原生支持,Codegen 成熟 | 编译时类型安全 | 类型友好,需轻量配置 |
| 编译时优化 | 无 | 强(Babel 插件 + AST 校验) | 无 |
| 框架绑定 | React/Vue/Angular/Svelte | 仅 React | React/Vue/Svelte/Preact |
| 分页 | fetchMore + relayStylePagination | usePaginationFragment | relayPagination Exchange |
| 乐观更新 | optimisticResponse 原生支持 | optimisticUpdater + updater | optimisticResponse |
| SSR 支持 | cache.extract/restore | loadQuery + Suspense | ssrExchange 即开即用 |
速选建议:Apollo Client 适合多数中大型企业项目;Relay 适合超大规模、强类型约束场景;urql 适合追求轻量与多框架通用的团队。
二、Apollo Client 深度实战
2.1 InMemoryCache 与 Type Policies
Apollo 的核心优势在于可编程的归一化缓存。
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token');
return { headers: { ...headers, authorization: token ? `Bearer ${token}` : '' } };
});
export const client = new ApolloClient({
link: authLink.concat(createHttpLink({ uri: '/graphql' })),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ['filter', 'category'],
merge(existing = [], incoming, { args }) {
if (args?.page === 1) return incoming;
return [...existing, ...incoming];
},
read(existing, { args }) {
const start = ((args?.page ?? 1) - 1) * (args?.limit ?? 10);
return existing?.slice(start, start + (args?.limit ?? 10));
},
},
liveFeed: { merge: false }, // 实时数据不缓存
},
},
User: {
fields: {
isFollowingLocally: {
read(_, { readField }) { return readField('isFollowing'); },
},
},
},
},
}),
});
归一化缓存扫描 __typename + id 按实体存储,任何引用同一实体的组件自动同步。typePolicies 的 merge(写入)与 read(读取)是实现分页、排序的核心入口。
2.2 useQuery / useMutation / useSubscription
import { gql, useQuery, useMutation } from '@apollo/client';
const GET_POST = gql`
query GetPost($id: ID!) {
post(id: $id) {
id title content
author { id name avatar }
comments(first: 10) {
edges { node { id body createdAt } }
pageInfo { hasNextPage endCursor }
}
}
}
`;
function PostDetail({ id }: { id: string }) {
const { data, loading, error, refetch, fetchMore } = useQuery(GET_POST, {
variables: { id },
fetchPolicy: 'cache-and-network',
nextFetchPolicy: 'cache-first',
notifyOnNetworkStatusChange: true,
});
if (loading && !data) return <Skeleton />;
if (error) return <ErrorMessage error={error} />;
return (
<article>
<h1>{data.post.title}</h1>
<button onClick={() => refetch({ id })}>刷新</button>
<CommentList
comments={data.post.comments.edges}
onLoadMore={() => fetchMore({
variables: { after: data.post.comments.pageInfo.endCursor },
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult) return prev;
return {
post: {
...prev.post,
comments: {
...prev.post.comments,
edges: [...prev.post.comments.edges, ...fetchMoreResult.post.comments.edges],
pageInfo: fetchMoreResult.post.comments.pageInfo,
},
},
};
},
})}
/>
</article>
);
}
fetchPolicy 速查:cache-first(静态页)、network-only(支付页)、cache-and-network(内容首页)、no-cache(一次性导出)。
2.3 乐观更新
const ADD_COMMENT = gql`
mutation AddComment($postId: ID!, $body: String!) {
addComment(postId: $postId, body: $body) {
id body createdAt author { id name }
}
}
`;
function CommentForm({ postId }: { postId: string }) {
const [addComment] = useMutation(ADD_COMMENT, {
update(cache, { data }) {
cache.modify({
id: cache.identify({ __typename: 'Post', id: postId }),
fields: {
comments(existing = { edges: [] }) {
const ref = cache.writeFragment({
data: data.addComment,
fragment: gql`fragment NewComment on Comment { id body createdAt author { id name } }`,
});
return { ...existing, edges: [...existing.edges, { node: ref }] };
},
},
});
},
});
const handleSubmit = async (body: string) => {
await addComment({
variables: { postId, body },
optimisticResponse: {
addComment: {
__typename: 'Comment', id: `temp-${Date.now()}`, body,
createdAt: new Date().toISOString(),
author: { __typename: 'User', id: 'current-user', name: '我' },
},
},
});
};
return <form onSubmit={(e) => { e.preventDefault(); handleSubmit(body); }}>{/* ... */}</form>;
}
点击提交 → UI 立即渲染 optimisticResponse → 后台发送 Mutation → 返回真实数据 → 临时 ID 替换为真实 ID,订阅该 Comment 的组件无感知刷新。
2.4 本地状态管理:Reactive Variables
import { makeVar, useReactiveVar, InMemoryCache } from '@apollo/client';
export const sidebarCollapsedVar = makeVar(false);
function Sidebar() {
const collapsed = useReactiveVar(sidebarCollapsedVar);
return (
<aside className={collapsed ? 'w-16' : 'w-64'}>
<button onClick={() => sidebarCollapsedVar(!collapsed)}>切换</button>
</aside>
);
}
// 联动服务端数据
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
isSidebarCollapsed() { return sidebarCollapsedVar(); },
},
},
},
});
Reactive Variables 基于 Proxy,变更触发 useReactiveVar 重渲染,不写入 GraphQL 缓存图,适合 UI 开关、主题等纯本地状态。
三、Relay 深度实战
Relay 设计哲学:强制 Fragment 化、编译时校验、强类型安全。
3.1 环境初始化
npm install react-relay relay-runtime
npm install -D relay-compiler babel-plugin-relay graphql
import { Environment, Network, RecordSource, Store } from 'relay-runtime';
function fetchQuery(operation: any, variables: any) {
return fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: operation.text, variables }),
}).then(res => res.json());
}
export default new Environment({
network: Network.create(fetchQuery),
store: new Store(new RecordSource()),
});
3.2 Fragment 组件化与数据遮罩
import { graphql, useFragment, useLazyLoadQuery } from 'react-relay';
function AuthorCard({ userRef }: { userRef: any }) {
const user = useFragment(
graphql`fragment AuthorCard_user on User { name avatar(size: 64) bio }`,
userRef
);
return (
<div className="flex items-center gap-3">
<img src={user.avatar} className="w-10 h-10 rounded-full" />
<div><p className="font-medium">{user.name}</p><p className="text-sm text-gray-500">{user.bio}</p></div>
</div>
);
}
const PostDetailQuery = graphql`
query PostDetailQuery($id: ID!) {
post(id: $id) { title content ...AuthorCard_user }
}
`;
function PostDetail({ id }: { id: string }) {
const data = useLazyLoadQuery(PostDetailQuery, { id });
return (
<article>
<h1>{data.post.title}</h1>
<AuthorCard userRef={data.post} />
<div>{data.post.content}</div>
</article>
);
}
数据遮罩:子组件只能访问自己 Fragment 声明的字段,即使父查询返回了额外字段也无法读取,杜绝隐式依赖。
3.3 分页:usePaginationFragment
import { graphql, usePaginationFragment } from 'react-relay';
function CommentList({ postRef }: { postRef: any }) {
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment(
graphql`
fragment CommentList_post on Post
@refetchable(queryName: "CommentListPaginationQuery")
@argumentDefinitions(count: { type: "Int", defaultValue: 10 }, cursor: { type: "String" }) {
comments(first: $count, after: $cursor) @connection(key: "CommentList_comments") {
edges { node { id body createdAt ...CommentItem_comment } }
}
}
`,
postRef
);
return (
<div>
{data.comments.edges.map(({ node }) => <CommentItem key={node.id} commentRef={node} />)}
{hasNext && <button onClick={() => loadNext(10)} disabled={isLoadingNext}>{isLoadingNext ? '加载中...' : '加载更多'}</button>}
</div>
);
}
Relay 分页需 @connection、@refetchable、@argumentDefinitions 三者配合,usePaginationFragment 自动管理游标。
3.4 Relay Resolvers(客户端派生字段)
import { readFragment } from 'relay-runtime';
export function readTimeMinutes(postKey: any): number {
const post = readFragment(
graphql`fragment Post_readTimeMinutes on Post { content }`,
postKey
);
return Math.ceil(post.content.length / 200);
}
Resolvers 是函数级派生,强类型且编译时校验;Apollo read 函数更灵活但缺乏编译时担保。
四、urql 深度实战
urql 的核心理念:极小核心 + Exchange 可插拔管道。
4.1 初始化与多框架兼容
import { createClient, dedupExchange, fetchExchange } from 'urql';
import { cacheExchange } from '@urql/exchange-graphcache';
const client = createClient({
url: '/graphql',
exchanges: [
dedupExchange,
cacheExchange({
keys: { Country: (data) => data.code },
updates: {
Mutation: {
addComment(result, args, cache) {
cache.invalidate({ __typename: 'Query', posts: true });
},
},
},
}),
fetchExchange,
],
});
urql 核心不依赖 UI 框架,React / Vue / Svelte 绑定都是薄封装:
// React
import { useQuery } from 'urql';
const [result] = useQuery({ query: POSTS_QUERY, variables: { limit: 10 } });
// Svelte
import { operationStore, query } from '@urql/svelte';
const posts = operationStore(POSTS_QUERY, { limit: 10 });
query(posts);
4.2 自定义 Exchange
import { Exchange, Operation, ExecutionResult } from '@urql/core';
import { pipe, map } from 'wonka';
const timingExchange: Exchange = ({ forward }) => (ops$) => pipe(
ops$,
map((op: Operation) => { op.context.meta = { startTime: Date.now() }; return op; }),
forward,
map((result: ExecutionResult) => {
const dur = Date.now() - (result.operation.context.meta?.startTime || 0);
console.log(`[urql] ${result.operation.kind} ${dur}ms`); return result;
})
);
const client = createClient({
url: '/graphql',
exchanges: [dedupExchange, cacheExchange({}), timingExchange, fetchExchange],
});
官方 Exchange 速查:@urql/exchange-graphcache(归一化缓存)、@urql/exchange-retry(重试)、@urql/exchange-auth(Token 刷新)、@urql/exchange-persisted(APQ)。
4.3 分页 Exchange
import { relayPagination } from '@urql/exchange-graphcache/extras';
const cache = cacheExchange({
resolvers: { Query: { posts: relayPagination() } },
});
relayPagination() 自动管理 edges 与 pageInfo 合并,组件只需调用 executeQuery 传入下一页游标。
五、缓存策略深度对比
| 维度 | Apollo Client | Relay | urql Graphcache |
|---|---|---|---|
| 归一化方式 | 运行时 __typename + id | 编译时 Fragment 提取 | 运行时配置 keys |
| 手动干预 | cache.modify / evict | commitLocalUpdate | cache.invalidate / updateQuery |
| 实体共享 | 自动 | 自动(强 ID 约束) | 自动 |
| 订阅写入 | onSubscriptionData 手动 | 原生自动归一化 | 原生自动写入 |
| 离线支持 | 实验性 | 无 | 需自定义 Exchange |
六、乐观更新三框架实战对比
以"点赞"功能为例:
Apollo Client:
const [likePost] = useMutation(LIKE_POST, {
optimisticResponse: { likePost: { __typename: 'Post', id: postId, likeCount: count + 1, isLiked: true } },
update(cache, { data }) {
cache.writeFragment({ id: `Post:${postId}`, fragment: LikeFrag, data: data?.likePost });
},
});
Relay:
commit({
variables: { id: post.id },
optimisticResponse: { likePost: { id: post.id, likeCount: post.likeCount + 1, isLiked: true } },
optimisticUpdater(store) {
const rec = store.get(post.id);
if (rec) { rec.setValue(post.likeCount + 1, 'likeCount'); rec.setValue(true, 'isLiked'); }
},
});
urql:
executeMutation({ id: postId }, {
optimisticResponse: { likePost: { __typename: 'Post', id: postId, likeCount: count + 1, isLiked: true } },
});
| 维度 | Apollo | Relay | urql |
|---|---|---|---|
| 位置 | mutation options | commit config | mutation options |
| 回滚 | 自动 | 自动 | graphcache 自动 |
| 读缓存 | readFragment | store.get | cache.readQuery |
| 粒度控制 | 高 | 极高(updater) | 中等 |
七、SSR / SSG 支持
Apollo Client:
// getServerSideProps
const client = new ApolloClient({ ssrMode: true, link, cache });
await client.query({ query: GET_POST, variables: { id } });
return { props: { initialApolloState: client.cache.extract() } };
// 客户端:cache.restore(initialApolloState)
Relay:依赖 loadQuery + usePreloadedQuery,需序列化 Store 的 RecordSource,流程最复杂但 Suspense Streaming 集成最深。
urql:ssrExchange 最简洁:
const ssr = ssrExchange({ isClient: typeof window !== 'undefined' });
// 服务端:await client.query(...).toPromise(); return { props: { urqlState: ssr.extractData() } }
// 客户端:ssrExchange({ isClient: true, initialState: pageProps.urqlState })
SSR 体验排序:urql(极简)> Apollo(成熟文档)> Relay(配置最重)。
八、一句话总结
- Apollo Client:生态最完善、文档最详尽、企业级首选;缓存策略可无限定制,代价是概念繁多、包体积大。
- Relay:编译时类型安全与性能的天花板,Fragment 设计杜绝隐式依赖;但 React 独占、配置复杂,适合超大规模代码库。
- urql:核心 ~6KB、Exchange 架构灵活、多框架通用;对轻量团队和全栈应用极具吸引力,复杂定制需手写 Exchange。
FAQ
Q1:项目刚起步,GraphQL 经验不足,选哪个?
urql。API 最直觉化,无需理解 Fragment 或 Type Policies,先做 MVP 再逐步深入。
Q2:已有 REST API,想渐进迁移?
Apollo Client 的 RestLink 支持混合查询,允许渐进式迁移;urql 也可通过自定义 Exchange 转发 REST 请求。
Q3:Apollo 的 InMemoryCache 会内存泄漏吗?
长期运行的 SPA 中缓存可能无限增长。建议设置 merge 策略上限,或定期调用 client.cache.gc()。
Q4:Relay 编译时校验能替代服务端校验吗?
不能替代,而是互补。Relay Compiler 在构建时检查 Query 与本地 Schema 的一致性,将部分运行时错误提前到 CI 暴露。
Q5:三框架能否混用?
同一项目不建议混用,缓存层互不兼容。微前端架构下各子应用可独立选型。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。