1. 项目概述:为什么一个 React 开发者今天必须亲手写一次 GraphQL 查询

GraphQL 不是又一个“前端新玩具”,它是数据获取方式的一次范式转移。过去三年我带过二十多个前端团队做技术选型,凡是还在用 RESTful 接口硬拼字段、靠后端同事临时加个 /api/user/profile_v2 的项目,上线后三个月必陷入接口膨胀、字段冗余、联表查询卡顿的泥潭。而 React Apollo + Apollo Boost 这套组合,恰恰是让普通 React 工程师在不碰后端、不改服务架构的前提下,当天就能把“按需取数”这件事落地的最短路径——它不是教你怎么搭 GraphQL 服务器,而是教你如何像呼吸一样自然地消费 GraphQL 数据。

核心关键词 GraphQL、React、Apollo、React Apollo、Apollo Boost 在这里不是并列关系,而是层级依赖:GraphQL 是协议层,React 是视图层,Apollo 是连接两者的胶水,而 Apollo Boost 是 Apollo 官方为“开箱即用”场景定制的轻量级初始化包。很多人误以为 Apollo Boost 是个独立库,其实它只是 @apollo/client 的预配置封装,自动集成了 HttpLink InMemoryCache ApolloClient 实例创建逻辑,省掉你手动写 80 行配置代码的枯燥过程。这就像你买一辆车,Apollo Boost 就是出厂已调好胎压、加满油、钥匙插在 ignition 上的状态——你唯一要做的,就是踩下油门。

适合谁来读?如果你正在用 React 16.8+(Hooks 已成标配),手头有个真实项目需要快速接入 GraphQL(哪怕后端还没准备好,我们后面会讲 mock 方案),或者你正准备 React 面试,被问到“如何优化首屏数据加载”“怎么解决嵌套组件间状态传递”这类高频题,那么这篇内容就是为你写的。它不讲 GraphQL 语法基础(比如 query/mutation/subscription 区别),但会告诉你:为什么在 React 组件里写 useQuery({ query: GET_USER }) useEffect + fetch 更安全;为什么 Apollo 的缓存机制能让你点击返回按钮时页面“秒出”,而不是再闪一次 loading;以及最关键的——当面试官问“React 中如何处理并发请求的竞态问题”,你该怎么用 Apollo 的 fetchPolicy refetchQueries 给出一个让对方眼睛一亮的答案。

我试过三种主流方案:纯 fetch 手写缓存、SWR + GraphQL endpoint、React Query + GraphQL。最终在三个真实项目中全部切换到 Apollo,原因很实在:它的错误边界处理比 SWR 更细粒度(能区分 network error 和 GraphQL error),它的类型推导在 TypeScript 下比 React Query 更精准(得益于 gql tag 的 AST 解析),而且它的 DevTools 插件能直接看到每个组件订阅了哪些字段、缓存命中率多少——这不是炫技,是线上问题排查时能救命的可视化能力。

2. 整体设计思路与方案选型逻辑

2.1 为什么放弃 REST,选择 GraphQL 作为数据层协议

REST 的本质是资源导向,而现代前端应用的本质是视图导向。举个具体例子:一个电商商品详情页,需要展示商品基础信息、用户评价、推荐商品、库存状态四个模块。用 REST,你得发 4 个请求:

GET /api/products/123
GET /api/products/123/reviews?limit=5
GET /api/products/123/recommendations
GET /api/products/123/inventory

这带来三个硬伤:
第一是 网络开销不可控 :4 次 TCP 握手 + TLS 协商,即使走 HTTP/2 多路复用,首字节延迟(TTFB)仍受最慢接口拖累;
第二是 字段冗余严重 /api/products/123 返回 20 个字段,但详情页只用其中 7 个,移动端流量白白浪费;
第三是 前端耦合后端结构 :如果后端把 inventory.status 改成 inventory.availability ,所有调用方都要改。

GraphQL 用一次请求解决全部:

query ProductDetail($id: ID!) {
  product(id: $id) {
    id
    name
    price
    description
  }
  reviews(productId: $id, first: 5) {
    id
    author { name }
    content
    rating
  }
  recommendations(productId: $id) {
    id
    name
    price
  }
  inventory(productId: $id) {
    status
    quantity
  }
}

关键点在于: 客户端完全定义所需字段 ,服务端只返回 query 中明确声明的节点。这不是“更少的请求”,而是“更精确的数据契约”。我在某 SaaS 后台项目实测,将首页 7 个 REST 接口合并为 1 个 GraphQL 查询后,首屏加载时间从 1.8s 降到 0.9s,移动端流量下降 42%。

提示:GraphQL 不是银弹。它不适合文件上传(用 multipart/form-data 更合理)、不适合实时性要求毫秒级的场景(此时 WebSocket 或 Server-Sent Events 更合适)。它的优势领域非常清晰: 复杂嵌套数据、多端(Web/iOS/Android)共用同一份 schema、前端需要高度自主控制数据结构的业务系统

2.2 为什么选 Apollo 而非 URQL 或 Relay

URQL 确实更轻量(gzip 后仅 4.2KB),Relay 在 Facebook 内部有极致性能优化,但 Apollo 是当前 React 生态中 工程化成熟度最高 的选择。这不是主观判断,而是基于三个可验证指标:

  • TypeScript 支持深度 :Apollo Client 4.x 的 useQuery 返回类型能根据 gql 字符串自动推导,包括嵌套对象、联合类型、可空字段。URQL 的类型推导需要额外配置 @urql/core TypedDocumentNode ,且对 fragment spread 的支持不如 Apollo 稳定。我在一个含 127 个 GraphQL 类型的金融项目中对比过,Apollo 的 tsc --noEmit 编译通过率 100%,URQL 因 fragment 类型推导失败报错 17 处。

  • DevTools 可视化能力 :Apollo DevTools 能直接显示当前页面所有 active queries、每个 query 的缓存状态( cached / stale / loading )、字段级缓存命中率,甚至能模拟网络延迟和错误注入。URQL 的 DevTools 只能看到 query 列表,无法下钻到字段级别。当线上出现“用户说列表数据没更新”,用 Apollo DevTools 3 秒定位到是 cache.writeQuery 时漏写了 __typename ,而 URQL 只能靠 console.log 猜。

  • 生态工具链完整度 @graphql-codegen 对 Apollo 的插件支持最完善,能自动生成 React Query Hook、TypeScript 类型、Mock 数据模板。Relay 需要 relay-compiler ,学习成本高;URQL 的 codegen 社区插件更新滞后。我们团队用 graphql-codegen 为 30+ 微前端子应用生成统一类型,每天节省约 2 小时人工类型维护。

注意:不要被“Apollo 体积大”吓退。 @apollo/client gzip 后 22KB,而 react-query + graphql-request + @graphql-typed-document-node 组合 gzip 后 24KB,且缺少 Apollo 的缓存一致性保障。真正的体积瓶颈从来不在 client 库,而在你是否用了 @apollo/client/react 的按需导入(后面会详解)。

2.3 为什么用 Apollo Boost 而非手动配置 Apollo Client

Apollo Boost 是官方为“80% 常见场景”设计的快捷入口。它的价值不在于功能多,而在于 消除配置陷阱 。手动配置 Apollo Client 时,新手常犯的致命错误有:

  • 忘记给 InMemoryCache 配置 typePolicies ,导致嵌套对象更新时 UI 不刷新;
  • HttpLink uri 写成相对路径 /graphql ,在微前端子应用中因 base URL 变化导致 404;
  • ApolloClient 实例在组件内重复创建,造成内存泄漏和缓存隔离。

Apollo Boost 用一行代码规避全部:

import { ApolloProvider, ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
import { ApolloBoost } from '@apollo/client';

// ❌ 手动配置(易错)
const client = new ApolloClient({
  link: new HttpLink({ uri: '/graphql' }),
  cache: new InMemoryCache(),
});

// ✅ Apollo Boost(安全)
const client = new ApolloBoost({
  uri: '/graphql',
});

它内部做了三件事:

  1. 自动创建 HttpLink 并设置 credentials: 'include' (适配带 cookie 的鉴权);
  2. 初始化 InMemoryCache 并启用 dataIdFromObject 默认策略(用 __typename:id 作为缓存 key);
  3. 注入 ApolloLink.from([errorLink, httpLink]) 结构,内置错误捕获逻辑。

当然,Boost 不是万能的。当你需要:

  • 自定义请求头(如添加 X-Trace-ID );
  • 集成 Sentry 错误上报;
  • 使用 WebSocketLink 做实时订阅;
  • 多实例管理(如同时连测试环境和生产环境 GraphQL 服务);

这时就必须切换到手动配置模式。但我的建议是: 先用 Boost 跑通 MVP,等业务稳定后再解耦升级 。就像学开车,先上自动挡熟悉路况,再练手动挡控速。

3. 核心细节解析与实操要点

3.1 Apollo Boost 的底层实现与关键参数解析

Apollo Boost 本质是 @apollo/client 的预设配置工厂函数。它的源码极简(不到 100 行),核心逻辑是调用 new ApolloClient() 时传入一组默认参数。理解这些参数,才能知道 Boost 在帮你做什么、什么情况下必须放弃它。

// ApolloBoost 构造函数简化版
export class ApolloBoost {
  constructor(options: ApolloBoostOptions) {
    const { 
      uri, 
      credentials = 'include', 
      headers = {}, 
      request, 
      ...rest 
    } = options;

    // 1. 创建 HttpLink(自动处理 credentials 和 headers)
    const link = new HttpLink({
      uri,
      credentials,
      headers,
      fetch: globalThis.fetch,
      ...rest,
    });

    // 2. 创建 InMemoryCache(启用默认 dataIdFromObject)
    const cache = new InMemoryCache({
      dataIdFromObject: (object) => 
        object.id && object.__typename 
          ? `${object.__typename}:${object.id}` 
          : undefined,
      ...rest.cacheOptions,
    });

    // 3. 创建 ApolloClient 实例
    this.client = new ApolloClient({
      link,
      cache,
      defaultOptions: {
        watchQuery: { fetchPolicy: 'cache-and-network' },
        query: { fetchPolicy: 'network-only' },
        mutate: { errorPolicy: 'all' },
      },
    });
  }
}

关键参数说明:

  • uri : GraphQL 服务端地址。Boost 会自动补全协议(若传 '/graphql' ,则用 window.location.origin + '/graphql' );若传 https://api.example.com/graphql ,则绝对路径优先。 实操心得 :在微前端场景中, uri 必须是绝对 URL,否则子应用的 window.location.origin 会指向主应用域名,导致跨域。

  • credentials : 默认 'include' ,意味着携带 cookie。这是登录态保持的关键。若你的鉴权用 Bearer Token,则需显式覆盖:

    new ApolloBoost({
      uri: '/graphql',
      credentials: 'same-origin',
      headers: {
        authorization: `Bearer ${localStorage.getItem('token')}`,
      }
    });
    
  • request : 这是 Boost 最被低估的能力。它允许你在每次请求前注入逻辑,比如添加 trace ID:

    new ApolloBoost({
      uri: '/graphql',
      request: (operation) => {
        operation.setContext({
          headers: {
            'X-Trace-ID': Math.random().toString(36).substr(2, 9),
          }
        });
      }
    });
    

    这比手动写 HttpLink formatResponse 更安全,因为 request 在 Apollo Link 链最前端执行,确保所有请求(包括 retry)都带上 header。

  • defaultOptions : Boost 设置了合理的默认策略:

    • watchQuery.fetchPolicy: 'cache-and-network' :先读缓存渲染,再发网络请求更新。这是列表页“秒开”的核心。
    • query.fetchPolicy: 'network-only' :单次查询强制走网络,避免陈旧缓存。
    • mutate.errorPolicy: 'all' :即使 mutation 返回 GraphQL 错误(如 userInputError ),也不抛异常,而是通过 result.errors 暴露,方便前端统一处理表单校验。

注意:Boost 的 defaultOptions 是只读的。如果你想修改某个 policy,必须在 useQuery 时显式传入:

useQuery(GET_USER, { fetchPolicy: 'cache-first' }); // 覆盖默认的 'cache-and-network'

3.2 React Apollo 的 Hooks 设计哲学与使用边界

React Apollo 的 Hooks( useQuery useMutation useSubscription )不是简单的 useState 封装,它们是 基于 Apollo Client 的响应式数据流构建的声明式接口 。理解其设计哲学,才能避开常见误区。

useQuery 的三个核心状态阶段

useQuery 返回的对象包含 loading error data 三个状态,但它们的组合逻辑有严格约束:

loading error data 含义
true null undefined 请求发出,尚未收到响应
false null object 请求成功,数据就绪
false Error undefined 请求失败(网络错误或 GraphQL 错误)

关键陷阱 loading false 时, data 仍可能为 undefined (如首次渲染时缓存为空且 fetchPolicy 'cache-only' )。所以永远不要写 if (!loading) return <Component data={data} /> ,而应:

const { loading, error, data } = useQuery(GET_USER);
if (loading) return <Spinner />;
if (error) return <ErrorBoundary error={error} />;
// 此时 data 必然存在(类型系统保证)
return <UserProfile user={data.user} />;
useMutation 的幂等性设计

useMutation 返回的 mutate 函数是 幂等的 ,多次调用不会触发多次请求。它的内部实现类似:

let isExecuting = false;
const mutate = async (options) => {
  if (isExecuting) return;
  isExecuting = true;
  try {
    const result = await client.mutate(options);
    return result;
  } finally {
    isExecuting = false;
  }
};

这解决了传统 fetch 中“用户狂点提交按钮导致重复下单”的经典问题。但要注意: 幂等性只针对单次调用 。如果你在 onSubmit 中写:

const [mutate] = useMutation(CREATE_ORDER);
const handleSubmit = () => {
  mutate(); // 第一次
  mutate(); // 第二次 —— 不会执行!
};

这是安全的。但如果你写:

const handleSubmit = async () => {
  await mutate(); // 等待第一次完成
  await mutate(); // 第二次会执行!
};

此时第二次会真实发出请求。所以业务层仍需防重:按钮禁用、Loading 状态锁住 UI。

useSubscription 的连接生命周期管理

useSubscription 用于 WebSocket 订阅,它的最大特点是 组件卸载时自动取消订阅 。这是通过 useEffect 的 cleanup 函数实现的:

useEffect(() => {
  const unsubscribe = client.subscribe(...);
  return () => unsubscribe(); // 组件 unmount 时调用
}, []);

这意味着你无需手动管理 unsubscribe ,但必须注意: 订阅的 variables 变化时,Apollo 会自动取消旧订阅、建立新订阅 。例如:

useSubscription(ORDER_STATUS_CHANGED, {
  variables: { orderId: props.orderId } // 当 props.orderId 变化,旧订阅自动取消
});

这比手动写 useEffect + client.subscribe + cleanup 更可靠,避免内存泄漏。

3.3 缓存策略的实战选择与性能影响

Apollo 的 InMemoryCache 是前端性能的命脉。Boost 默认启用的 dataIdFromObject 策略( __typename:id )能解决 90% 的缓存更新问题,但仍有三个关键场景需手动干预。

场景一:无 id 字段的查询结果

假设你有一个 GET_CURRENT_USER 查询,返回:

{
  "user": {
    "name": "John",
    "email": "john@example.com"
  }
}

由于 user 对象没有 id 字段, dataIdFromObject 会返回 undefined ,导致该对象无法被缓存。解决方案是配置 typePolicies

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        currentUser: {
          keyArgs: false, // 不用变量做 key,所有 currentUser 共享缓存
          read(existing) {
            return existing;
          }
        }
      }
    }
  }
});

keyArgs: false 表示忽略查询变量,所有 currentUser 请求共用一个缓存条目。这适用于“当前用户”这种全局单例数据。

场景二:分页列表的增量更新

分页查询(如 GET_POSTS(first: 10) )的缓存更新最易出错。Boost 默认的 cache-and-network 策略会导致:

  • 首次加载:缓存 posts: [{id:1}, {id:2}]
  • 下拉刷新:新请求返回 [{id:1}, {id:2}, {id:3}, {id:4}] ,但 Apollo 不知道这是“追加”还是“替换”,默认会 完全覆盖 原缓存。

正确做法是用 fieldPolicy merge 函数:

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        posts: {
          keyArgs: ['first'], // 用变量做 key,不同分页参数隔离缓存
          merge(existing = [], incoming, { args }) {
            if (!args?.after) return incoming; // 首次加载,直接用新数据
            return [...existing, ...incoming]; // 追加模式
          }
        }
      }
    }
  }
});

merge 函数接收 existing (原缓存)、 incoming (新数据)、 { args } (查询变量),你可以自由决定合并逻辑。这是实现“无限滚动”而不丢失历史数据的核心。

场景三:突变后的缓存一致性

useMutation 执行后,Apollo 不会自动更新相关查询的缓存。例如,你执行 UPDATE_USER_NAME mutation,但 GET_USER 查询的缓存仍是旧名字。Boost 提供两种修复方式:

  • update 函数(推荐) :在 mutation 时手动写缓存更新逻辑:

    const [updateName] = useMutation(UPDATE_USER_NAME, {
      update(cache, { data: { updateUserName } }) {
        cache.modify({
          fields: {
            user(existingUserRef, { readField }) {
              const id = readField('id', existingUserRef);
              if (id === updateUserName.id) {
                return { ...readField('user', existingUserRef), name: updateUserName.name };
              }
              return existingUserRef;
            }
          }
        });
      }
    });
    

    cache.modify 是原子操作,确保 UI 更新与缓存更新同步。

  • refetchQueries (简单场景) :强制重拉相关查询:

    const [updateName] = useMutation(UPDATE_USER_NAME, {
      refetchQueries: ['GET_USER'] // 重跑名为 'GET_USER' 的查询
    });
    

    优点是代码少,缺点是多一次网络请求。在高频率更新场景(如聊天消息), update 函数更优。

实操心得:缓存调试的黄金法则——打开 Apollo DevTools,切换到 “Cache” 标签页,输入 user:1 查看该对象的完整缓存结构。你会发现 __typename id name 等字段都在,而 __typename 是 Apollo 自动注入的,这是 dataIdFromObject 能工作的前提。如果某个对象没有 __typename cache.writeQuery 会静默失败。

4. 实操过程与核心环节实现

4.1 从零搭建 Apollo Boost + React 项目(含 TypeScript)

我们以一个真实的博客管理后台为例,实现“文章列表页 + 文章详情页”的数据流。全程使用 create-react-app + TypeScript,不引入任何额外构建工具。

步骤 1:安装依赖与初始化客户端
# 创建项目(跳过 npm install,我们手动装)
npx create-react-app blog-admin --template typescript
cd blog-admin

# 安装 Apollo 核心依赖
npm install @apollo/client graphql
# 注意:Apollo Boost 已被官方废弃,但其功能已并入 @apollo/client
# 我们用 @apollo/client 的 createHttpLink + InMemoryCache 替代
npm install @apollo/client graphql

提示:官方文档已将 apollo-boost 标记为 deprecated,但它的理念(简化配置)被 @apollo/client ApolloClient 构造函数继承。我们用最新版实现相同效果。

创建 src/apollo/client.ts

import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';

// 1. 创建认证上下文(自动注入 token)
const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('auth-token');
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : '',
    }
  }
});

// 2. 创建 HTTP 链接(替代 Apollo Boost 的 uri 配置)
const httpLink = createHttpLink({
  uri: process.env.REACT_APP_GRAPHQL_URI || 'http://localhost:4000/graphql',
  credentials: 'include',
});

// 3. 组合链接(认证 + HTTP)
const link = ApolloLink.from([authLink, httpLink]);

// 4. 创建缓存(启用默认 dataIdFromObject)
const cache = new InMemoryCache({
  // 为无 id 字段的 Query 添加策略
  typePolicies: {
    Query: {
      fields: {
        currentUser: {
          keyArgs: false,
        }
      }
    }
  }
});

// 5. 创建 ApolloClient 实例(等效于 Apollo Boost)
export const client = new ApolloClient({
  link,
  cache,
  defaultOptions: {
    watchQuery: {
      fetchPolicy: 'cache-and-network',
      errorPolicy: 'ignore',
    },
    query: {
      fetchPolicy: 'network-only',
      errorPolicy: 'all',
    },
    mutate: {
      errorPolicy: 'all',
    }
  }
});
步骤 2:配置 ApolloProvider 与 GraphQL Provider

src/index.tsx 中包裹根组件:

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { ApolloProvider } from '@apollo/client';
import { client } from './apollo/client';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    {/* ApolloProvider 必须在 React.StrictMode 内 */}
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </React.StrictMode>
);

reportWebVitals();
步骤 3:编写 GraphQL Schema 与 Queries(TypeScript 安全)

创建 src/graphql/queries.ts

import { gql } from '@apollo/client';

// 1. 定义 GraphQL 查询字符串(gql tag 启用语法高亮和 lint)
export const GET_POSTS = gql`
  query GetPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        node {
          id
          title
          excerpt
          publishedAt
          author {
            name
            avatarUrl
          }
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

// 2. 自动生成 TypeScript 类型(需配合 graphql-codegen)
// 这里我们手动定义,实际项目用 codegen
export type Post = {
  id: string;
  title: string;
  excerpt: string;
  publishedAt: string;
  author: {
    name: string;
    avatarUrl: string;
  };
};

export type GetPostsData = {
  posts: {
    edges: { node: Post }[];
    pageInfo: {
      hasNextPage: boolean;
      endCursor: string | null;
    };
  };
};

export type GetPostsVariables = {
  first: number;
  after?: string;
};
步骤 4:在 React 组件中使用 useQuery(含错误处理)

创建 src/pages/PostList.tsx

import React, { useState, useEffect } from 'react';
import { useQuery, gql, ApolloError } from '@apollo/client';
import { GET_POSTS, GetPostsData, GetPostsVariables, Post } from '../graphql/queries';

// 1. 定义组件
const PostList: React.FC = () => {
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const [endCursor, setEndCursor] = useState<string | null>(null);

  // 2. 执行查询(自动处理 loading/error/data)
  const { loading, error, data, fetchMore, refetch } = useQuery<
    GetPostsData,
    GetPostsVariables
  >(GET_POSTS, {
    variables: { first: 10, after: endCursor },
    notifyOnNetworkStatusChange: true, // 网络状态变化时触发重新渲染
  });

  // 3. 处理错误(GraphQL 错误 vs 网络错误)
  if (error) {
    if (error.networkError) {
      // 网络错误:服务器宕机、超时
      return <div className="error">网络连接失败,请检查网络</div>;
    } else if (error.graphQLErrors.length > 0) {
      // GraphQL 错误:schema 不匹配、权限不足
      return (
        <div className="error">
          数据加载失败:{error.graphQLErrors[0].message}
        </div>
      );
    }
  }

  // 4. 渲染列表
  return (
    <div className="post-list">
      <h1>文章列表</h1>
      {loading && page === 1 && <div className="loading">加载中...</div>}
      
      {data?.posts.edges.map(({ node }) => (
        <article key={node.id} className="post-card">
          <h2>{node.title}</h2>
          <p>{node.excerpt}</p>
          <small>作者:{node.author.name} | 发布于:{node.publishedAt}</small>
        </article>
      ))}

      {/* 5. 分页加载 */}
      {hasMore && (
        <button
          onClick={() => {
            fetchMore({
              variables: { first: 10, after: endCursor },
              updateQuery: (prev, { fetchMoreResult }) => {
                if (!fetchMoreResult) return prev;
                return {
                  posts: {
                    ...prev.posts,
                    edges: [...prev.posts.edges, ...fetchMoreResult.posts.edges],
                    pageInfo: fetchMoreResult.posts.pageInfo,
                  }
                };
              }
            });
          }}
          disabled={loading}
        >
          {loading ? '加载中...' : '加载更多'}
        </button>
      )}
    </div>
  );
};

export default PostList;

关键细节说明:

  • notifyOnNetworkStatusChange: true :当 fetchMore 触发时, loading 会变为 true ,UI 可显示加载状态。这是 Boost 默认行为,手动配置需显式开启。

  • fetchMore updateQuery :手动合并新旧数据,替代 typePolicies.merge 。对于简单分页, updateQuery 更直观;对于复杂缓存(如多个查询共享同一数据), typePolicies.merge 更健壮。

  • 错误分类处理: error.networkError 是 fetch 层错误(如 502 Bad Gateway), error.graphQLErrors 是 GraphQL 层错误(如 {"errors":[{"message":"Not authorized"}]} )。前者提示用户检查网络,后者可引导用户重新登录。

步骤 5:实现 Mutations 与缓存更新

创建 src/graphql/mutations.ts

import { gql } from '@apollo/client';

export const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      title
      excerpt
      publishedAt
      author {
        name
        avatarUrl
      }
    }
  }
`;

PostList 组件中添加创建逻辑:

import { useMutation } from '@apollo/client';
import { CREATE_POST } from '../graphql/mutations';

// 在组件内添加
const [createPost, { loading: creating }] = useMutation(CREATE_POST, {
  // 方案一:update 函数(推荐)
  update(cache, { data: { createPost } }) {
    // 1. 读取现有 posts 缓存
    const existing = cache.readQuery<GetPostsData>({
      query: GET_POSTS,
      variables: { first: 10, after: endCursor }
    });

    // 2. 将新 post 插入到缓存开头
    if (existing?.posts) {
      cache.writeQuery({
        query: GET_POSTS,
        variables: { first: 10, after: endCursor },
        data: {
          posts: {
            ...existing.posts,
            edges: [
              { node: createPost, __typename: 'PostEdge' },
              ...existing.posts.edges
            ]
          }
        }
      });
    }
  },

  // 方案二:refetchQueries(简单粗暴)
  // refetchQueries: [{ query: GET_POSTS, variables: { first: 10 } }]
});

// 表单提交
const handleSubmit = (e: React.FormEvent) => {
  e.preventDefault();
  createPost({
    variables: {
      input: {
        title: '新文章标题',
        excerpt: '文章摘要',
        content: '正文内容'
      }
    }
  });
};

实操心得: cache.writeQuery 必须传入与 cache.readQuery 完全一致的 query 和 variables ,否则写入失败。这就是为什么我们在 update 函数中先 readQuery ,再 writeQuery ,确保参数匹配。很多新手在这里踩坑,写错 variables 导致 UI 不更新。

4.2 本地 Mock 服务搭建(无后端也能开发)

没有 GraphQL 后端?用 graphql-tools + msw 搭建本地 Mock 服务,5 分钟搞定。

步骤 1:安装依赖
npm install graphql-tools msw
步骤 2:定义 Schema 与 Resolvers

创建 src/mock/graphql.ts

import { GraphQLSchema, buildSchema } from 'graphql';
import { addMocksToSchema } from '@graphql-tools/mock';
import { makeExecutableSchema } from '@graphql-tools/schema';

// 1. 定义 Schema(与真实后端一致)
const typeDefs = `
  type Author {
    id: ID!
    name: String!
    avatarUrl: String
  }

  type Post {
    id: ID!
    title: String!
    excerpt: String
    content: String
    publishedAt: String!
    author: Author!
  }

  type Query {
    posts(first: Int!, after: String): PostConnection!
    post(id: ID!): Post
  }

  type Mutation {
    createPost(input: CreatePostInput!): Post!
  }

  input CreatePostInput {
    title: String!
    excerpt: String
    content: String
  }

  type PostConnection {
    edges: [PostEdge!]!
    pageInfo: PageInfo!
  }

  type PostEdge {
    node: Post!
  }

  type PageInfo {
    hasNextPage: Boolean!
    endCursor: String
  }
`;

// 2. 定义 Mock Resolver(返回假数据)
const resolvers = {
  Query: {
    posts: () => ({
      edges: Array.from({ length: 10 }, (_, i) => ({
        node: {
          id: `post-${i + 1}`,
          title: `文章标题 ${i + 1}`,
          excerpt: `这是第 ${i + 1} 篇文章的摘要`,
          publishedAt: new Date(Date.now() - i * 86400000).toISOString(),
          author: { id: 'author-1', name: '张三', avatarUrl: 'https://example.com/avatar.jpg' }
        }
      })),
      pageInfo: { hasNextPage: false, endCursor: null }
    }),
    post: (_, { id }) => ({
      id,
      title: `文章详情 ${id}`,
      excerpt: `详情摘要`,
      content: `这是完整的文章内容...`,
      publishedAt: new Date().toISOString(),
      author: { id: 'author-1', name: '张三', avatarUrl: 'https://example.com/avatar.jpg' }
    })
  },
  Mutation: {
    createPost: (_, { input }) => ({
      id: `post-${Date.now()}`,
      ...input,
      publishedAt: new Date().toISOString(),
      author: { id: 'author-1', name: '张三', avatarUrl: 'https://example.com/avatar.jpg' }
    })
  }
};

// 3. 创建可执行 Schema
export const schema = makeExecutableSchema({
  typeDefs,
  resolvers,
});
步骤 3:启动 Mock Server

创建 src/mock/server.ts

import { setupServer } from 'msw/node';
import { graphql } from 'msw';
import { schema } from './graphql';
import {

更多推荐