GraphQL 客户端状态管理:Apollo Client、Relay 与 urql 深度对比

三大 GraphQL 客户端框架对比:Apollo Client 生态王者、Relay 的编译时优化、urql 的轻量可插拔。含缓存策略、本地状态管理、乐观更新、服务端渲染完整代码示例。

在《GraphQL 服务端实现实战》中,我们从 Schema 设计到性能调优构建了一套完整的 GraphQL 后端。服务端再强,终端体验最终仍取决于客户端如何请求、缓存与管理状态。本文聚焦 JavaScript/TypeScript 生态三大 GraphQL 客户端框架——Apollo ClientRelayurql——从架构原理到生产实践逐一拆解,助你做出最契合场景的选型。

一、三大框架选型总表

维度Apollo ClientRelayurql
缓存策略归一化缓存 + Type PoliciesFragment 级归一化 + 编译时校验归一化 + Exchanges 可替换
包体积(gzip)~34 KB~30 KB + 编译插件~6 KB核心 + Exchange
学习曲线中(生态丰富但概念多)高(Fragment 思维 + Babel 插件)低(API 直觉,可渐进深入)
生态与周边极丰富(DevTools、Codegen)Meta 验证,社区较小活跃,多框架通用
TypeScript 支持原生支持,Codegen 成熟编译时类型安全类型友好,需轻量配置
编译时优化强(Babel 插件 + AST 校验)
框架绑定React/Vue/Angular/Svelte仅 ReactReact/Vue/Svelte/Preact
分页fetchMore + relayStylePaginationusePaginationFragmentrelayPagination Exchange
乐观更新optimisticResponse 原生支持optimisticUpdater + updateroptimisticResponse
SSR 支持cache.extract/restoreloadQuery + SuspensessrExchange 即开即用

速选建议: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 按实体存储,任何引用同一实体的组件自动同步。typePoliciesmerge(写入)与 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() 自动管理 edgespageInfo 合并,组件只需调用 executeQuery 传入下一页游标。


五、缓存策略深度对比

维度Apollo ClientRelayurql Graphcache
归一化方式运行时 __typename + id编译时 Fragment 提取运行时配置 keys
手动干预cache.modify / evictcommitLocalUpdatecache.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 } },
});
维度ApolloRelayurql
位置mutation optionscommit configmutation options
回滚自动自动graphcache 自动
读缓存readFragmentstore.getcache.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,需序列化 StoreRecordSource,流程最复杂但 Suspense Streaming 集成最深。

urqlssrExchange 最简洁:

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:三框架能否混用?
同一项目不建议混用,缓存层互不兼容。微前端架构下各子应用可独立选型。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「GraphQL」更多文章

  1. gRPC-Web 与 GraphQL 混合架构:微服务通信分层实战
  2. GraphQL 订阅、SSE 与 WebSocket 实时推送实战
  3. GraphQL 服务端实战:Apollo Server、GraphQL Yoga 与 Pothos 选型