
一句话: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 会用真实值「替换」乐观层(不会有「跳变」)
七、面试延伸
- 「GraphQL 相比 REST 有什么优势?什么时候不该用?」
优势:① ⭐ 客户端声明字段(不过度取、不欠取);② ⭐ 一次查询取多个资源(减少请求数);③ ⭐ schema 是强制契约(能生成类型,且「加字段不影响老客户端」→ 天然渐进);④ ⭐ 客户端规范化缓存(改一个实体,所有引用它的地方自动更新)。不该用(或需谨慎):① ⭐ 服务端复杂度高(N+1、字段级鉴权、查询复杂度限制都要专门做)——如果团队做不到,GraphQL 会比 REST 更慢;② CDN/HTTP 缓存重要的场景(GraphQL 通常 POST,HTTP 缓存失效,要靠持久化查询/客户端缓存补);③ 对外公开 API(多语言客户端、需要极强的通用性与缓存)——REST/OpenAPI 更稳;④ ⭐ 「GraphQL 不会自动更快」——它只是「把取哪些字段的决定权交给客户端」,它最大的收益是「前端迭代速度」(新增字段不用后端加接口),最合适的位置是「BFF 层」(前端聚合,数据源仍是 REST/RPC)。 -
「Apollo 的规范化缓存和 urql 的文档缓存有什么区别?」
⭐ 存储单位不同:规范化缓存(ApolloInMemoryCache/ urqlgraphcache)按「实体(__typename:id)」存字段 → ⭐ 「改一个实体,所有引用它的查询都自动更新」(不需要手动失效),但代价是「需要正确配置(keyFields/typePolicies)+ 内存开销 + 会跨查询合并字段」;文档缓存(urql 默认)按「查询字符串 + variables」存整个结果 → ✅ 简单可预测、内存小、不会跨查询合并,但 ⚠️ 「改了 A 查询的数据,B 查询的缓存不会自动更新」(需要显式失效或重新执行)。选型:实体关系复杂、同一数据在多处出现(⭐ 如「用户信息出现在 5 个页面」)→ 规范化缓存;以「页面为单位」取数、数据相对独立 → 文档缓存够用(更简单)。 -
「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把错误分成「可重试/不可重试 + 该跳登录/该提示/该映射到表单字段」,组件只消费分类结果。 -
「分页在 Apollo 里怎么配?」
要配两处:① ⭐typePolicies里的keyArgs+merge——keyArgs声明「哪些变量不同算不同列表」(如['filter'],否则「切换筛选会污染缓存」),merge声明「新一页怎么和旧的拼」(⭐ 必须先判断if (!args?.after) return incoming;——「第一页/筛选变化」时直接替换,否则旧数据会残留);② ⭐fetchMore(或用「游标 +updateQuery」)触发下一页。⚠️ 三个常见错误:① 没配merge→fetchMore的结果覆盖而不是追加(症状:「加载第二页后只剩第二页」);②merge没处理「第一页」→ 切筛选后旧数据还在;③pageInfo用了旧的 →hasNextPage永远是true(无限加载)。另外:cursor 分页(Relay 风格)比 offset 分页更稳——因为「插入/删除数据」时 offset 分页会出现「重复项/跳过项」。 -
「缓存更新有哪几种做法?」
三档(从省心到精确):①refetchQueries——最简单(写操作后重新拉指定查询),✅ 保证与服务端一致,⚠️ 多一次网络请求(且可能「闪一下」);⭐ 建议用「引用」而不是字符串(refetchQueries: [{ query: ORDERS_QUERY, variables }],否则改名会静默失效);② ⭐update函数——精确改缓存,✅ 不发额外请求,⚠️ 要手写缓存逻辑(⭐ 用cache.identify定位实体、用readField读缓存,不要直接摸对象引用);③ ⭐optimisticResponse+update——乐观更新(UI 立刻反映,⭐ 失败会自动回滚)。实践组合:⭐ 「乐观更新(立刻反馈)+update(精确改缓存)+ 少量refetchQueries(兜住『统计/聚合』这类缓存改不到的地方)」。⚠️ 乐观结果的形态必须与 schema 一致(含__typename与id),否则回滚时会「跳一下」。 -
「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)」或客户端缓存。 -
「什么情况下该用 Apollo、什么情况下用 urql?」
判据是「你需要多少能力」:选 Apollo——① 需要 ⭐ 规范化缓存(实体在多处出现,希望「改一处全更新」);② 需要「乐观更新 + 复杂的缓存更新」;③ 需要「成熟的 DevTools」(看缓存树、查「数据从哪来」);④ 团队愿意接受较大的体积与学习成本。选 urql——① 想要 ⭐ 更小、更简单、更可组合(它是「插件式」的:exchanges组成管线);② 数据「以页面为单位」(文档缓存够用),或愿意用graphcache加规范化;③ 需要「极致的可定制」(交换链让你能插入任意逻辑)。另外一条现实考量:⭐ 生态与文档(Apollo 的教程/答案远多于 urql)——团队熟悉度往往比「体积小 20KB」更重要。 -
「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 码有意义、可重试) vsgraphQLErrors(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 不要叠两层缓存。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!