陌上人如玉
公子世无双

7.10 GraphQL / Apollo / urql

7.10 GraphQL / Apollo / urql

一句话:GraphQL 的客户端价值不在「少请求几个接口」,而在「声明式地描述『这个组件需要哪些字段』」+「规范化缓存让『改一个实体,所有引用它的地方自动更新』」;而它的坑集中在「两级错误(network vs graphQL)」「缓存配置(否则不更新或更新错)」「分页与缓存的配合」三处。

一、定位:GraphQL 与 REST 的取舍

维度 REST GraphQL
取数据的粒度 ⚠️ 服务端定(要么多了、要么少了) ⭐ 客户端声明字段(不过度取、不欠取)
请求次数 ⚠️ 「多个资源」= 多个请求(或自定义聚合接口) ⭐ 一次查询拿多个资源
类型与契约 OpenAPI/Swagger(可选的、容易漂移) ⭐ schema 是强制的(且能生成类型)
版本演进 ⚠️ /v2、字段兼容靠约定 ⭐ 加字段不影响老客户端(天然渐进)
缓存 ⭐ HTTP 缓存天然可用(GET + Cache-Control) ⚠️ 通常 POST → HTTP 缓存失效(要靠客户端缓存/持久化查询)
复杂度 ⭐ 低(HTTP 语义直接) ⚠️ 高(schema/resolver/N+1/缓存/鉴权都要专门做)
适合 ⭐ 资源边界清晰、CDN 缓存重要、团队小 ⭐ 前端需要灵活组合字段、多端(Web/iOS/Android)共用、频繁演进
⭐⭐⭐ 常见的「真实结论」(面试里最有说服力的说法):

   · **GraphQL 不会自动更快**——它只是「把『取哪些字段』的决定权交给客户端」。
     如果服务端没做(DataLoader/查询复杂度限制),GraphQL 反而更慢。
   · ⭐ **它最大的收益是「前端迭代速度」**:
     新增一个字段/一个新页面,**不需要后端加接口**(只要 schema 里有)。
   · ⚠️ **它最大的代价是「服务端复杂度」**:
     N+1、鉴权(按字段/按类型)、查询复杂度限制、缓存策略。
   · ⭐ **对「BFF 层」最合适**:让 GraphQL 只做「前端聚合」,数据源仍是 REST/RPC。

二、核心概念:一次看懂

# ============ schema(服务端定义,是「契约」) ============
type User {
  id: ID!
  name: String!
  email: String
  # ⭐ 「按需加载」的关系(客户端不写它,服务端就不会查)
  orders(first: Int, after: String): OrderConnection!
}

# ⭐ Relay 风格的分页(cursor 分页的「标准形态」)
type OrderConnection {
  edges: [OrderEdge!]!
  pageInfo: PageInfo!
}
type OrderEdge { node: Order!, cursor: String! }
type PageInfo { hasNextPage: Boolean!, endCursor: String }

# ⭐ Query:读
type Query {
  me: User
  user(id: ID!): User
  orders(filter: OrderFilter, first: Int, after: String): OrderConnection!
}

# ⭐ Mutation:写
type Mutation {
  refundOrder(id: ID!, amount: Int!): RefundPayload!
}

# ⭐ Subscription:实时(见 7.14)
type Subscription {
  orderUpdated(id: ID!): Order!
}
# ============ 客户端查询:⭐ 关键在「fragment」 ============
# ⭐ fragment:让「字段集合」可复用(也便于「缓存更新时按类型处理」)
fragment UserBasic on User {
  id
  name
  # ⚠️ 注意:`__typename` 是「规范化缓存」的关键(Apollo 会自动加)
  __typename
}

fragment OrderRow on Order {
  id
  amount
  status
  createdAt
  __typename
}

query OrdersPage($first: Int!, $after: String, $filter: OrderFilter) {
  orders(first: $first, after: $after, filter: $filter) {
    edges {
      cursor
      node {
        ...OrderRow            # ⭐ 复用 fragment
      }
    }
    pageInfo { hasNextPage endCursor }
  }
}
⭐⭐⭐ 三个「必须理解」的概念:

① 【`__typename` 与「规范化缓存」】⭐
   Apollo 的 `InMemoryCache` 按「**`__typename` + `id`**」把数据**存成一个「实体表」**:
     User:1 → { name: 'Ada', email: '...' }
     Order:9 → { amount: 100, status: 'paid' }
   ⭐ 于是「**任何查询里出现 User:1,读的都是同一份数据**」→
     你「改一处」,「所有引用它的地方自动更新」(⭐ 这是 GraphQL 客户端最强的能力)。
   ⚠️ 前提是「有 `id`(或配置了 `keyFields`)」——否则它只能按「查询路径」存(退化成文档缓存)。

② 【`fragment` 不只是「复用」】
   ⭐ 它还是「**按类型做缓存更新的抓手**」:
     cache.modify({ fields: { orders: ... } })
     或「用 fragment 的字段集合做「乐观更新」的形态约定」。

③ 【`variables` 与「缓存 key」】
   `OrdersPage(first: 20, after: null)` 与 `OrdersPage(first: 20, after: 'c1')`
   ⭐ 是**两个不同的缓存条目**(因为 variables 参与 key)——这是「分页能共存」的原因,
     也是「为什么 `fetchMore` 需要 `merge` 策略才能把两页拼起来」的原因。

三、客户端缓存:规范化 vs 文档缓存

⭐⭐⭐ 两种客户端缓存的根本差异(决定了「怎么更新」):

【规范化缓存(Apollo InMemoryCache / urql graphcache)】
   存储:按「实体」(`__typename:id`)存字段
   ✅ 「改一个实体 → 所有引用它的查询都更新」(⭐ 无需手动失效)
   ✅ 「多个查询共享同一个实体的数据」
   ⚠️ 代价:需要「正确配置」(`keyFields`、`typePolicies`);
     内存开销大;⚠️ 「同一个实体在不同查询里要求不同字段」时会**合并**(可能不是你想要的)
   ⭐ 适合:**实体关系复杂、同一个数据在多处出现**(如「用户信息出现在 5 个页面」)

【文档缓存(urql 默认)】
   存储:按「查询字符串 + variables」存**整个结果**
   ✅ 简单可预测(「我看到的缓存就是那次请求的完整结果」)
   ✅ 内存小、不会「跨查询合并」
   ⚠️ 代价:⭐ 「改了 A 查询的数据,B 查询的缓存不会自动更新」→
     需要「显式失效/重新执行」(urql 用 `requestPolicy: 'cache-and-network'` 等)
   ⭐ 适合:**数据相对独立、以「页面为单位」的取数**
// ============ Apollo 客户端配置(含「易错的几项」) ============
import { ApolloClient, InMemoryCache, HttpLink, ApolloLink, from } from '@apollo/client';

const httpLink = new HttpLink({
  uri: '/graphql',
  // ⭐ 让「GET 查询」能被 CDN/浏览器缓存(⚠️ 只对「无副作用的 query」开启)
  useGETForQueries: false,
  credentials: 'same-origin',
});

/** ⭐ 鉴权中间件 */
const authLink = new ApolloLink((operation, forward) => {
  const token = getToken();
  operation.setContext(({ headers = {} }) => ({
    headers: { ...headers, ...(token ? { authorization: `Bearer ${token}` } : {}) },
  }));
  return forward(operation);
});

/** ⭐ 性能上报(⭐ 把「慢查询 + 操作名」上报,是 GraphQL 项目最重要的可观测性) */
const metricsLink = new ApolloLink((operation, forward) => {
  const start = performance.now();
  const name = operation.operationName;

  return forward(operation).map((result) => {
    const elapsed = performance.now() - start;
    // ⚠️ 只上报「慢的」与「出错的」(避免把监控打爆)
    if (elapsed > 800 || result.errors?.length) {
      reportGraphQL({ name, elapsed, errors: result.errors?.map((e) => e.message) });
    }
    return result;
  });
});

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        // ⭐⭐⭐ 分页:`fetchMore` 之后「怎么把新一页拼进缓存」
        //    ⚠️ 不配它 → fetchMore 的结果会「覆盖」而不是「追加」
        orders: {
          keyArgs: ['filter'],              // ⭐ 「filter 变了 → 视为不同的列表」
          merge(existing, incoming, { args }) {
            // ⭐ 第一页(没有 after)→ 直接替换(避免「筛选变了但旧数据还在」)
            if (!args?.after) return incoming;

            // ⭐ 后续页 → 追加
            const existingEdges = existing?.edges ?? [];
            const incomingEdges = incoming?.edges ?? [];
            return {
              ...incoming,
              edges: [...existingEdges, ...incomingEdges],
            };
          },
        },
      },
    },
    // ⭐ 如果「某些类型没有 id」,要告诉缓存「用哪个字段当 key」
    OrderItem: {
      keyFields: ['orderId', 'skuId'],      // ⭐ 复合 key
    },
    // ⚠️ 有些类型「不该被规范化」(如「只属于某个查询的派生数据」)
    //    用 keyFields: false 让它「按路径存」
    PageInfo: { keyFields: false },
  },
});

export const apolloClient = new ApolloClient({
  link: from([authLink, metricsLink, httpLink]),
  cache,
  // ⭐ 默认取数策略(⭐ 强烈建议显式选,不要用默认)
  defaultOptions: {
    watchQuery: {
      // 'cache-first'(默认)| 'cache-and-network'(先给缓存再刷新,⭐ 最常用)| 'network-only' | 'cache-only' | 'no-cache'
      fetchPolicy: 'cache-and-network',
      // ⭐ 缓存在「这么多毫秒内」算「新鲜」(避免「每次挂载都请求」)
      nextFetchPolicy: 'cache-first',
    },
    mutate: {
      // ⚠️ 默认 'none':GraphQL 错误会被当成「异常抛出」;
      //    'all' 则「把错误放进 result.errors 而不抛」——视团队约定选
      errorPolicy: 'none',
    },
  },
  // ⭐ 开发期打印缓存(排查「为什么没更新」的利器)
  connectToDevTools: process.env.NODE_ENV !== 'production',
});

四、最关键的三件事:错误、更新、分页

4.1 两级错误(最容易混)

⭐⭐⭐ GraphQL 的错误有「两层」,处理方式完全不同:

① 【networkError(网络层)】
   请求本身失败:断网、超时、5xx、CORS、401(网关拒绝)
   → ⭐ 这时 **没有 `data`**,应该「重试 / 提示网络问题」
   ⚠️ HTTP 状态码有意义(401/403/500)

② 【graphQLErrors(GraphQL 层)】
   ⭐ HTTP 状态码通常是 **200**,但响应体里是 `{ data: null, errors: [...] }`
   典型:字段级权限不足、参数校验失败、业务规则拒绝、resolver 抛错
   → ⚠️ 这时**可能有部分 data**(⭐ GraphQL 允许「部分成功」)
   ⭐ `errors[i].extensions.code` 是「业务错误码」的常见载体
     (如 `UNAUTHENTICATED`、`FORBIDDEN`、`BAD_USER_INPUT`)

⚠️ 混为一谈的后果:
   · 「按 HTTP 状态码判断业务错误」→ ⚠️ 全是 200,判断不出来
   · 「把业务错误当成网络错误」→ 用户看到「网络异常,请重试」(⭐ 其实是「余额不足」)
// ✅ 一个「正确分类错误」的封装
import { ApolloError } from '@apollo/client';

export type DataError =
  | { kind: 'network'; message: string; status?: number; retryable: true }
  | { kind: 'unauthenticated'; message: string; retryable: false }
  | { kind: 'forbidden'; message: string; retryable: false }
  | { kind: 'validation'; message: string; fieldErrors?: Record<string, string>; retryable: false }
  | { kind: 'business'; code: string; message: string; retryable: false }
  | { kind: 'unknown'; message: string; retryable: true };

export function classifyError(error: ApolloError | undefined): DataError | null {
  if (!error) return null;

  // ⭐ ① 网络层错误(没有 graphQLErrors)
  if (error.networkStatusCode) {
    const status = error.networkStatusCode;
    if (status === 401) return { kind: 'unauthenticated', message: '登录已过期', retryable: false };
    if (status === 403) return { kind: 'forbidden', message: '无权访问', retryable: false };
    return {
      kind: 'network',
      message: status >= 500 ? '服务暂时不可用' : `请求失败(${status})`,
      status,
      retryable: status >= 500 || status === 408,     // ⭐ 5xx/超时「可重试」
    };
  }

  // ⭐⭐ ② GraphQL 层错误(HTTP 200 + errors 数组)
  const gqlError = error.graphQLErrors?.[0];
  if (gqlError) {
    const code = (gqlError.extensions?.code as string | undefined) ?? 'INTERNAL_SERVER_ERROR';

    if (code === 'UNAUTHENTICATED') return { kind: 'unauthenticated', message: '登录已过期', retryable: false };
    if (code === 'FORBIDDEN') return { kind: 'forbidden', message: gqlError.message || '无权操作', retryable: false };
    if (code === 'BAD_USER_INPUT') {
      return {
        kind: 'validation',
        message: gqlError.message,
        // ⭐ 服务端可以把「字段级错误」放在 extensions 里(与表单库集成,见 1.13)
        fieldErrors: gqlError.extensions?.fieldErrors as Record<string, string> | undefined,
        retryable: false,
      };
    }
    return { kind: 'business', code, message: gqlError.message, retryable: false };
  }

  return { kind: 'unknown', message: error.message, retryable: true };
}

// ✅ 用起来(区分「可重试」与「该提示用户」)
function useOrdersWithError() {
  const { data, error, refetch } = useQuery(ORDERS_QUERY);

  const classified = classifyError(error);

  if (classified?.kind === 'unauthenticated') {
    // ⭐ 跳登录(而不是「提示网络错误」)
    return { redirectToLogin: true };
  }

  return { data, error: classified, retry: classified?.retryable ? () => void refetch() : undefined };
}

4.2 「更新」:三档手段

⭐⭐⭐ 数据变更后的「更新」有三档(从「最省心」到「最精确」):

① 【`refetchQueries`】——最简单粗暴
   await refund({ variables, refetchQueries: ['OrdersPage'] });
   ✅ 好写;✅ 保证「与服务端一致」
   ⚠️ 代价:**多一次网络请求**(⭐ 而且可能「闪一下」)

② 【`update` 函数】——精确更新缓存
   await refund({ variables, update(cache, { data }) { /* 手写缓存变更 */ } });
   ✅ 不发额外请求;✅ 精确
   ⚠️ 需要「手写缓存逻辑」(容易写错;但可测)

③ 【`optimisticResponse` + `update`】——乐观更新
   await refund({ variables, optimisticResponse: {...}, update(cache) {...} });
   ✅ ⭐ UI 立刻反映(体感最好);✅ 失败会自动回滚(Apollo 会「重放」乐观结果)
   ⚠️ 需要「乐观结果」与「真实结果」形态一致(否则回滚时会「跳一下」)

⭐ 实践建议:
   · 「写操作影响的范围小、且能预测」→ ⭐ **乐观更新 + update**
   · 「影响范围大、逻辑复杂」(如「批量操作」)→ **refetchQueries**(省心优先)
   · ⭐ 两者可组合:「先乐观更新(立刻反馈)+ 成功后 refetch(保一致)」
// ✅ 「乐观更新 + 精确缓存更新」的完整写法(一个典型场景:列表里改一行的状态)
const [refundOrder] = useMutation(REFUND_ORDER_MUTATION, {
  // ⭐⭐ 乐观响应:结构与服务端返回的完全一致(否则回滚时会「跳一下」)
  optimisticResponse: (vars) => ({
    refundOrder: {
      __typename: 'RefundPayload',
      order: {
        __typename: 'Order',
        id: vars.id,                       // ⭐ 必须有 id(规范化缓存靠它定位实体)
        status: 'refunding',               // ⭐ 乐观状态
      },
    },
  }),

  // ⭐⭐⭐ 关键:告诉缓存「怎么改」
  //    Apollo 的「规范化缓存」会自动处理「refundOrder.order」这个实体的更新
  //    —— 但它**不会**自动更新「列表里的排序/分页结构」,
  //       所以下面演示「两种更新方式」
  update(cache, { data }) {
    const order = data?.refundOrder?.order;
    if (!order) return;

    // ⭐ 方式 A:改「按 id 缓存的实体」(所有引用它的地方都会更新)
    cache.modify({
      id: cache.identify(order),           // ⭐ 用 __typename + id 生成缓存 key
      fields: {
        status: () => order.status,
      },
    });

    // ⭐ 方式 B:改「某个查询的列表」(如「把这一行从『待处理』列表里移除」)
    cache.modify({
      fields: {
        pendingOrders(existing = {}, { readField }) {
          const edges = existing.edges ?? [];
          return {
            ...existing,
            edges: edges.filter((edge: unknown) => {
              const nodeRef = readField<{ id: string }>('node', edge as never);
              // ⭐ readField 是「读缓存的正确方式」(不要直接摸 edge.node)
              return readField<string>('id', nodeRef as never) !== order.id;
            }),
          };
        },
      },
    });
  },

  // ⚠️ 兜底:如果「乐观更新 + update」覆盖不到的地方(如「统计数字」)
  refetchQueries: ['OrderStats'],
});

4.3 分页(fetchMore + merge)

// ✅ 一个「无限滚动 + cursor 分页」的完整实现
const ORDERS_QUERY = gql`
  query OrdersPage($first: Int!, $after: String) {
    orders(first: $first, after: $after) {
      edges { cursor node { id amount status } }
      pageInfo { hasNextPage endCursor }
    }
  }
`;

function OrderInfiniteList() {
  const { data, loading, fetchMore, networkStatus, error } = useQuery(ORDERS_QUERY, {
    variables: { first: 20, after: undefined },
    // ⭐ 让「fetchMore 期间」也能拿到 loading(networkStatus)
    notifyOnNetworkStatusChange: true,
  });

  const edges = data?.orders.edges ?? [];
  const pageInfo = data?.orders.pageInfo;
  const isLoadingMore = networkStatus === NetworkStatus.fetchMore;

  // ⭐ 用 IntersectionObserver 触发「加载更多」(见 1.14)
  const sentinelRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const el = sentinelRef.current;
    if (!el || !pageInfo?.hasNextPage || isLoadingMore) return;

    const io = new IntersectionObserver((entries) => {
      if (!entries[0]?.isIntersecting) return;
      io.disconnect();                     // ⭐ 触发一次即可(下一次由「新的 sentinel」再观察)

      void fetchMore({
        variables: { first: 20, after: pageInfo.endCursor },
        // ⭐⭐⭐ 关键:告诉 Apollo「新一页怎么和旧的合并」
        //    —— 在多数情况下,「typePolicies 里的 merge」已经处理了,
        //       所以这里可以省略;但如果「merge 策略满足不了」,就在这里写
        updateQuery(previous, { fetchMoreResult }) {
          if (!fetchMoreResult) return previous;
          return {
            orders: {
              ...fetchMoreResult.orders,
              edges: [...previous.orders.edges, ...fetchMoreResult.orders.edges],
              // ⚠️ pageInfo 用「新的」(否则 hasNextPage 永远是 true)
              pageInfo: fetchMoreResult.orders.pageInfo,
            },
          };
        },
      });
    }, { rootMargin: '400px' });          // ⭐ 提前 400px 触发(体验更好)

    io.observe(el);
    return () => io.disconnect();
  }, [fetchMore, pageInfo?.hasNextPage, pageInfo?.endCursor, isLoadingMore]);

  if (loading && edges.length === 0) return <ListSkeleton rows={6} />;
  if (error) return <ErrorState error={classifyError(error)} />;

  return (
    <div>
      <ul>
        {edges.map(({ node }) => (
          <li key={node.id}>
            {node.id} —— ¥{node.amount} —— {node.status}
          </li>
        ))}
      </ul>

      {/* ⭐ 哨兵元素(进入视口 → 加载下一页) */}
      <div ref={sentinelRef} aria-hidden style={{ height: 1 }} />

      {isLoadingMore && <Spinner />}
      {!pageInfo?.hasNextPage && edges.length > 0 && <p>没有更多了</p>}
    </div>
  );
}

五、完整实现:一个 GraphQL 数据层(230 行)

// graphql-layer.ts —— 一个「可直接用」的 GraphQL 数据层封装
import {
  ApolloClient, InMemoryCache, ApolloLink, HttpLink, from, gql,
  useQuery, useMutation, type TypedDocumentNode,
} from '@apollo/client';
import { onError } from '@apollo/client/link/error';

/** ============ 1. 错误链接:统一处理「鉴权失败 / 服务端错误」 ============ */
interface GraphQLLayerOptions {
  uri: string;
  getToken: () => string | null;
  onUnauthenticated: () => void;
  onSlowQuery?: (info: { name: string; elapsed: number }) => void;
  slowThresholdMs?: number;
}

export function createGraphQLLayer(options: GraphQLLayerOptions) {
  const { uri, getToken, onUnauthenticated, onSlowQuery, slowThresholdMs = 800 } = options;

  // ⭐ ① 错误链接(⚠️ 注意它「只能观察」,不能改结果——改结果要用 ApolloLink.map)
  const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
    // ⭐ GraphQL 层:统一处理「未认证」
    if (graphQLErrors?.some((e) => e.extensions?.code === 'UNAUTHENTICATED')) {
      // ⚠️ 防抖:多个并发查询同时 401 时「只跳一次登录」
      onUnauthenticated();
      return;
    }
    // ⭐ 网络层:只记录(真正的「用户提示」交给组件决定)
    if (networkError) {
      console.warn(`[graphql] ${operation.operationName} 网络错误`, networkError);
    }
  });

  // ⭐ ② 鉴权 + 慢查询上报
  const authLink = new ApolloLink((operation, forward) => {
    const token = getToken();
    operation.setContext(({ headers = {} } = {}) => ({
      headers: { ...headers, ...(token ? { authorization: `Bearer ${token}` } : {}) },
    }));

    const start = performance.now();
    return forward(operation).map((result) => {
      const elapsed = performance.now() - start;
      if (elapsed > slowThresholdMs || result.errors?.length) {
        onSlowQuery?.({ name: operation.operationName, elapsed });
      }
      return result;
    });
  });

  /** ============ 3. 缓存(⭐ 分页 merge 与 keyFields 是重点) ============ */
  const cache = new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          // ⭐ 通用「Relay 风格分页」的 merge 策略(可以抽成一个 helper 复用)
          ...relayPagination('orders', ['filter']),
          ...relayPagination('users', ['role']),
        },
      },
      // ⭐ 没有 id 的类型:要么给复合 key,要么「不规范化」
      OrderItem: { keyFields: ['orderId', 'skuId'] },
      PageInfo: { keyFields: false },
      // ⚠️ 有些字段「不该进缓存」(如「一次性 token」「随机数」)
      //    用 keyFields: false + read: () => undefined 更彻底
    },
  });

  const client = new ApolloClient({
    link: from([errorLink, authLink, new HttpLink({ uri, credentials: 'same-origin' })],
    cache,
    defaultOptions: {
      watchQuery: { fetchPolicy: 'cache-and-network', nextFetchPolicy: 'cache-first' },
      query: { fetchPolicy: 'network-only' },       // ⭐ useQuery 之外的 client.query 用「直连」
    },
  });

  return { client, cache, gql };
}

/** ⭐⭐ 「Relay 风格分页」的 merge 策略(可复用) */
function relayPagination(fieldName: string, keyArgs: string[]) {
  return {
    [fieldName]: {
      // ⭐ keyArgs:这些参数「不同」→ 视为「不同的列表」(分开缓存)
      keyArgs,
      merge(existing: { edges?: unknown[] } | undefined, incoming: { edges?: unknown[] }, { args }) {
        // ⭐ 没有 after(第一页,或「筛选变了」)→ 直接替换
        if (!args?.after) return incoming;
        return { ...incoming, edges: [...(existing?.edges ?? []), ...(incoming?.edges ?? [])] };
      },
    },
  };
}
/** ============ 4. 业务 Hooks:把「错误分类 + 重试 + 乐观更新」封装起来 ============ */
export function useOrdersQuery(variables: { first: number; after?: string; filter?: OrderFilter }) {
  const { client } = getGraphQLLayer();          // 单例取得
  const result = useQuery(ORDERS_QUERY, { variables });

  return {
    ...result,
    // ⭐ 统一分类后的错误(组件不需要自己解析 ApolloError)
    error: useMemo(() => classifyError(result.error), [result.error]),
    // ⭐ 只在「可重试」时给 retry
    retry: classifyError(result.error)?.retryable
      ? () => { void result.refetch(); }
      : undefined,
    client,
  };
}

export function useRefundOrder() {
  const [mutate, state] = useMutation(REFUND_ORDER_MUTATION);

  const refund = useCallback(
    async (id: string, amount: number) => {
      try {
        await mutate({
          variables: { id, amount },
          // ⭐ 乐观:立刻改 UI
          optimisticResponse: {
            refundOrder: {
              __typename: 'RefundPayload',
              order: { __typename: 'Order', id, status: 'refunding' },
            },
          },
          // ⭐ 精确更新缓存(不改「列表结构」时,规范化缓存已经够用)
          update(cache, { data }) {
            const order = data?.refundOrder?.order;
            if (!order) return;
            cache.modify({
              id: cache.identify(order),
              fields: { status: () => order.status },
            });
          },
          // ⭐ 兜底:涉及「统计/聚合」时重新拉一次
          refetchQueries: ['OrderStats'],
        });
        return { ok: true as const };
      } catch (err) {
        // ⭐ 失败时「乐观结果会被自动回滚」(Apollo 会重放乐观层)
        return { ok: false as const, error: classifyError(err as never) };
      }
    },
    [mutate]
  );

  return { refund, loading: state.loading };
}
/** ============ 5. 组件侧:干净得「不像在写 GraphQL」 ============ */
function OrderDetail({ orderId }: { orderId: string }) {
  const { data, loading, error, retry } = useOrdersQuery({ first: 20, filter: { id: orderId } });
  const { refund, loading: refunding } = useRefundOrder();

  const [notice, setNotice] = useState<string | null>(null);

  const onRefund = async () => {
    const res = await refund(orderId, 100);
    if (!res.ok) {
      // ⭐ 组件只关心「给用户看什么」
      setNotice(
        res.error?.kind === 'validation' ? res.error.message :
        res.error?.kind === 'forbidden' ? '你没有退款权限' :
        '退款失败,请稍后重试'
      );
    }
  };

  if (loading) return <Skeleton />;

  // ⭐ 错误分类后,「该重试的给重试、该跳登录的跳登录、该提示的提示」
  if (error) {
    if (error.kind === 'unauthenticated') return <Navigate to="/login" replace />;
    return (
      <div role="alert">
        {error.message}
        {retry && <button onClick={retry}>重试</button>}
      </div>
    );
  }

  return (
    <div>
      {/* ...渲染 data... */}
      <button onClick={onRefund} disabled={refunding}>
        {refunding ? '退款中…' : '退款'}
      </button>
      {notice && <p role="alert">{notice}</p>}
    </div>
  );
}

六、本篇特有的坑

// ① 用 HTTP 状态码判断业务错误(GraphQL 业务错误也是 200)
if (res.status === 400) { /* 业务错误 */ }                    // ❌ GraphQL 里拿不到
// ✅ 看 `errors[].extensions.code`

// ② 把「业务错误」当成「网络错误」(提示「网络异常」而实际是「余额不足」)
// ✅ 分类处理(见 classifyError)

// ③ 以为「GraphQL 错误一定没有 data」(其实可以「部分成功」)
if (error) return <ErrorState />;                              // ⚠️ 可能有部分 data 可用
// ✅ 判断「关键字段是否缺失」再决定「整页报错」还是「局部降级」

// ④ 分页没配 `merge`(fetchMore 覆盖而不是追加)
// ⚠️ 症状:加载第二页后「只剩第二页」
// ✅ typePolicies 里配 `merge`(或用 `updateQuery`)

// ⑤ `merge` 没处理「第一页/筛选变化」(旧数据残留)
merge: (existing, incoming) => ({ ...incoming, edges: [...existing.edges, ...incoming.edges] }),  // ❌
// ✅ `if (!args?.after) return incoming;`(第一页直接替换)

// ⑥ `keyArgs` 没配(「不同筛选条件的列表」互相污染)
// ⚠️ 症状:切了筛选,看到上一个筛选的数据
// ✅ keyArgs: ['filter']

// ⑦ 实体没有 `id` 或没配 `keyFields`(规范化缓存退化)
// ⚠️ 症状:「改了 A 处,B 处没更新」
// ✅ 给实体加 id,或配 `keyFields`(复合 key)

// ⑧ 把「不该缓存的东西」放进缓存(token、随机数、一次性数据)
// ✅ `keyFields: false` 或干脆用 `client.query({ fetchPolicy: 'no-cache' })`

// ⑨ 乐观更新的形态与真实结果不一致(回滚时「跳一下」)
optimisticResponse: { refundOrder: { ok: true } }              // ⚠️ 与真实结构不同
// ✅ 完全按 schema 的返回形态写(含 `__typename`)

// ⑩ 「每次挂载都重新请求」(fetchPolicy 默认值不合适)
// ✅ `cache-and-network`(先给缓存再刷新)或 `cache-first`(缓存在就不请求)

// ⑪ 「缓存了旧数据」但用户期望「每次都是新的」(如「余额」)
// ✅ 对该查询 `fetchPolicy: 'network-only'` 或用 `pollInterval`(轮询)

// ⑫ 突变后「只改了缓存但没改『列表顺序/分页结构』」(UI 不一致)
// ✅ 用 `update` 精确改(并用 `readField` 读缓存,而不是直接摸对象)

// ⑬ `refetchQueries` 用「字符串名」(改名就会静默失效)
refetchQueries: ['OrdersPage']                                  // ⚠️ 字符串容易失配
// ✅ 用「引用」:`refetchQueries: [{ query: ORDERS_QUERY, variables }]`

// ⑭ N+1 查询(服务端每个字段单独查库)—— ⭐ 常见于「列表 + 每行的关联数据」
// ✅ 服务端用 **DataLoader**(批处理 + 缓存);⭐ 或让客户端「一次查完需要的字段」

// ⑮ 查询复杂度没限制(客户端能构造「爆炸性查询」)
// ✅ 服务端做「深度限制 + 复杂度评分 + 超时」(⭐ 否则是 DoS 面)

// ⑯ 鉴权只做在「Query 层」(字段级/类型级没做)
// ⚠️ 症状:用户能查到「不该看的字段」(如「别人的邮箱」)
// ✅ 「字段级/类型级」鉴权(在 resolver 或 schema directive 层)

// ⑰ 把「GraphQL 用于『公开 API』」(外部开发者/多语言客户端)
// ⚠️ GraphQL 的强类型收益只在「TS 同仓」时最大化;对外要给 schema 文档 + 版本策略
// ✅ 公开 API 常优先 REST/OpenAPI(⭐ 更通用、缓存更容易)

// ⑱ 「大 query 字符串」每次请求都发(请求体积大)
// ✅ **Persisted Queries(APQ)**:客户端发 hash,服务端查表(⭐ 还能「白名单化」)
//    ⚠️ 注意「hash 不匹配 → 第二次请求补发完整 query」的机制

// ⑲ 「缓存未命中」时以为「没请求」(其实是「命中空数据」)
// ⚠️ `data` 为 undefined 与「空列表」是两件事
// ✅ 用 `networkStatus` 与 `data === undefined` 判断

// ⑳ 「开发期没接 DevTools」(排查「为什么没更新」靠猜)
// ✅ Apollo DevTools(看缓存树 + 每个查询的缓存来源)
// ⑬ 的完整处理(refetchQueries 用「引用」)
const [refundOrder] = useMutation(REFUND_ORDER_MUTATION, {
  refetchQueries: ({ id }) => [
    // ⭐ 用「查询引用 + 参数」→ 类型安全、改名不会失配
    { query: ORDERS_QUERY, variables: { first: 20, filter: { id } } },
    { query: ORDER_STATS_QUERY },
    // ⭐ 也可以「按『缓存里的某个字段』失效」(更精确)
    // { query: ORDERS_QUERY, variables: ... }
  ],
  // ⚠️ `awaitRefetchQueries`:让「mutation 的 loading」等到 refetch 完成
  //    ✅ 好处:UI 会「等数据齐了再收起 loading」(不会「先收起再闪」)
  //    ⚠️ 代价:loading 时间变长
  awaitRefetchQueries: true,
});
// ⑨ 的完整处理(乐观更新的形态必须与 schema 一致)
// schema: refundOrder(id: ID!, amount: Int!): RefundPayload!
// type RefundPayload { order: Order!, receiptId: String! }

optimisticResponse: {
  refundOrder: {
    __typename: 'RefundPayload',
    // ⭐ 乐观阶段「还不知道 receiptId」→ 用一个「占位值」(与服务端类型一致)
    receiptId: 'optimistic',
    order: {
      __typename: 'Order',
      id,                       // ⭐ 必须有(规范化缓存靠它定位)
      status: 'refunding',
    },
  },
},
// ⭐ 这样「真实结果回来」时,Apollo 会用真实值「替换」乐观层(不会有「跳变」)

七、面试延伸

  1. 「GraphQL 相比 REST 有什么优势?什么时候不该用?」
    优势:① ⭐ 客户端声明字段(不过度取、不欠取);② ⭐ 一次查询取多个资源(减少请求数);③ ⭐ schema 是强制契约(能生成类型,且「加字段不影响老客户端」→ 天然渐进);④ ⭐ 客户端规范化缓存(改一个实体,所有引用它的地方自动更新)。不该用(或需谨慎):① ⭐ 服务端复杂度高(N+1、字段级鉴权、查询复杂度限制都要专门做)——如果团队做不到,GraphQL 会比 REST 更慢;② CDN/HTTP 缓存重要的场景(GraphQL 通常 POST,HTTP 缓存失效,要靠持久化查询/客户端缓存补);③ 对外公开 API(多语言客户端、需要极强的通用性与缓存)——REST/OpenAPI 更稳;④ ⭐ 「GraphQL 不会自动更快」——它只是「把取哪些字段的决定权交给客户端」,它最大的收益是「前端迭代速度」(新增字段不用后端加接口),最合适的位置是「BFF 层」(前端聚合,数据源仍是 REST/RPC)。

  2. 「Apollo 的规范化缓存和 urql 的文档缓存有什么区别?」
    ⭐ 存储单位不同:规范化缓存(Apollo InMemoryCache / urql graphcache)按「实体(__typename:id)」存字段 → ⭐ 「改一个实体,所有引用它的查询都自动更新」(不需要手动失效),但代价是「需要正确配置(keyFields/typePolicies)+ 内存开销 + 会跨查询合并字段」;文档缓存(urql 默认)按「查询字符串 + variables」存整个结果 → ✅ 简单可预测、内存小、不会跨查询合并,但 ⚠️ 「改了 A 查询的数据,B 查询的缓存不会自动更新」(需要显式失效或重新执行)。选型:实体关系复杂、同一数据在多处出现(⭐ 如「用户信息出现在 5 个页面」)→ 规范化缓存;以「页面为单位」取数、数据相对独立 → 文档缓存够用(更简单)。

  3. 「GraphQL 的错误怎么处理?」
    ⭐ 关键是「分两层」:① networkError(网络层)——请求本身失败(断网/超时/5xx/CORS/401),此时没有 data,HTTP 状态码有意义,✅ 应该「重试或提示网络问题」;② ⭐ graphQLErrors(GraphQL 层)——HTTP 状态码通常是 200,响应体是 { data, errors },代表「字段级权限不足/参数校验失败/业务规则拒绝/resolver 抛错」,⚠️ 可能有部分 data(GraphQL 允许部分成功),⭐ 业务错误码常在 errors[].extensions.code(如 UNAUTHENTICATED/FORBIDDEN/BAD_USER_INPUT)。混为一谈的后果:① 「按 HTTP 状态码判断业务错误」→ 判断不出来(全是 200);② 「把业务错误当网络错误」→ 用户看到「网络异常,请重试」(实际是「余额不足」)。实践建议:写一个 classifyError 把错误分成「可重试/不可重试 + 该跳登录/该提示/该映射到表单字段」,组件只消费分类结果。

  4. 「分页在 Apollo 里怎么配?」
    要配两处:① ⭐ typePolicies 里的 keyArgs + merge——keyArgs 声明「哪些变量不同算不同列表」(如 ['filter'],否则「切换筛选会污染缓存」),merge 声明「新一页怎么和旧的拼」(⭐ 必须先判断 if (!args?.after) return incoming;——「第一页/筛选变化」时直接替换,否则旧数据会残留);② ⭐ fetchMore(或用「游标 + updateQuery」)触发下一页。⚠️ 三个常见错误:① 没配 merge → fetchMore 的结果覆盖而不是追加(症状:「加载第二页后只剩第二页」);② merge 没处理「第一页」→ 切筛选后旧数据还在;③ pageInfo 用了旧的 → hasNextPage 永远是 true(无限加载)。另外:cursor 分页(Relay 风格)比 offset 分页更稳——因为「插入/删除数据」时 offset 分页会出现「重复项/跳过项」。

  5. 「缓存更新有哪几种做法?」
    三档(从省心到精确):① refetchQueries——最简单(写操作后重新拉指定查询),✅ 保证与服务端一致,⚠️ 多一次网络请求(且可能「闪一下」);⭐ 建议用「引用」而不是字符串(refetchQueries: [{ query: ORDERS_QUERY, variables }],否则改名会静默失效);② ⭐ update 函数——精确改缓存,✅ 不发额外请求,⚠️ 要手写缓存逻辑(⭐ 用 cache.identify 定位实体、用 readField 读缓存,不要直接摸对象引用);③ ⭐ optimisticResponse + update——乐观更新(UI 立刻反映,⭐ 失败会自动回滚)。实践组合:⭐ 「乐观更新(立刻反馈)+ update(精确改缓存)+ 少量 refetchQueries(兜住『统计/聚合』这类缓存改不到的地方)」。⚠️ 乐观结果的形态必须与 schema 一致(含 __typename 与 id),否则回滚时会「跳一下」。

  6. 「GraphQL 的性能问题有哪些?」
    四类:① ⭐ N+1 查询(服务端)——「列表里每一行的关联数据都单独查库」(⭐ 典型:orders { user { name } } 会「1 次查订单 + N 次查用户」),✅ 用 DataLoader(批处理 + 请求级缓存),或让客户端「一次查完需要的字段」;② ⭐ 查询复杂度没限制——客户端能构造「爆炸性查询」(深嵌套 + 大列表 + 重复字段),✅ 服务端做「深度限制 + 复杂度评分 + 超时 + 分页强制」(否则是 DoS 面);③ ⭐ 请求体积——「大 query 字符串」每次请求都发,✅ 用 Persisted Queries(APQ)(客户端发 hash,服务端查表,⭐ 还能做「查询白名单」提升安全性);④ 缓存失效与过度请求——fetchPolicy 配得不当(如「每次挂载都 network-only」),✅ 用 cache-and-network + nextFetchPolicy: 'cache-first' + 合理的 pollInterval。另外:「HTTP 层缓存」在 GraphQL 里基本失效(POST)→ 要靠「CDN + 持久化查询(GET)」或客户端缓存。

  7. 「什么情况下该用 Apollo、什么情况下用 urql?」
    判据是「你需要多少能力」:选 Apollo——① 需要 ⭐ 规范化缓存(实体在多处出现,希望「改一处全更新」);② 需要「乐观更新 + 复杂的缓存更新」;③ 需要「成熟的 DevTools」(看缓存树、查「数据从哪来」);④ 团队愿意接受较大的体积与学习成本。选 urql——① 想要 ⭐ 更小、更简单、更可组合(它是「插件式」的:exchanges 组成管线);② 数据「以页面为单位」(文档缓存够用),或愿意用 graphcache 加规范化;③ 需要「极致的可定制」(交换链让你能插入任意逻辑)。另外一条现实考量:⭐ 生态与文档(Apollo 的教程/答案远多于 urql)——团队熟悉度往往比「体积小 20KB」更重要。

  8. 「GraphQL 和『数据层选择』(React Query / SWR)怎么配合?」
    ⭐ 它们是「互补」而不是「二选一」,但要注意「不要重复实现缓存」:① Apollo/urql 自带客户端缓存(规范化/文档)——所以「用 Apollo 的 useQuery」时不需要再套 React Query(否则「两层缓存」会让你困惑「数据从哪来」);② 但如果用「轻量 GraphQL 客户端」(如 graphql-request,它没有缓存) → ⭐ 可以配 React Query(把 GraphQL 当成「另一种 fetcher」),此时由 React Query 负责缓存/失效/乐观更新;③ ⭐ 判断依据:「谁的缓存更懂你的数据」——如果「实体关系复杂、需要跨查询更新」→ Apollo 的规范化缓存;如果「以 queryKey 为单位、需要细粒度的失效与乐观更新控制」→ React Query。⚠️ 混用的两个坑:① 两层缓存导致「双重失效」困惑;② queryKey 与 GraphQL 的 variables 重复维护(容易不一致)。

一句话速记

GraphQL 的客户端价值是「声明式取字段 + 规范化缓存(改一个实体、所有引用它的地方自动更新)」;⚠️ 它不会自动更快——最大的收益是「前端迭代速度」(加字段不用后端加接口),最适合做 BFF,而最大的代价是服务端复杂度(N+1 靠 DataLoader、查询复杂度限制防 DoS、字段级鉴权、持久化查询 APQ);⭐ 错误必须分两层:networkError(无 data、HTTP 码有意义、可重试) vs graphQLErrors(HTTP 200、可能有部分 data、业务码在 extensions.code)——⚠️ 混为一谈会导致「按状态码判业务错」(判不出来)或「把业务错提示成网络异常」;⭐ 两种缓存:规范化(按 __typename:id 存 → 跨查询自动更新,但需配 keyFields/typePolicies) vs 文档(按「query+variables」存整个结果 → 简单可预测,但改 A 查询不会更新 B 查询);⭐ 分页要配两处:typePolicies 的 keyArgs(不同筛选算不同列表)+ merge(⚠️ **必须先处理「第一页/筛选变化 → 直接替换」)** 与 fetchMore;⭐ 缓存更新三档:refetchQueries(省心但多一次请求,⚠️ 要用「查询引用」而不是字符串名)→ update(精确,用 cache.identify + readField)→ optimisticResponse + update(⭐ 立刻反馈 + 失败自动回滚,⚠️ 乐观结果形态必须与 schema 一致含 __typename/id);Apollo vs urql 看「需要多少能力」(规范化缓存/DevTools/生态 vs 更小更可组合),Apollo 与 React Query 不要叠两层缓存。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.10 GraphQL / Apollo / urql

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫