引言
当接口从 REST 转向 GraphQL,前端的「取数」就不该再散落在组件里:谁发起请求、结果缓存在哪、什么事件该让缓存失效、订阅推送怎么和本地状态合并——这些问题需要一个统一的数据层来回答。在 Vite 项目里,这个数据层通常是 Apollo Client、urql 或 TanStack Query 三选一,再配合 graphql-codegen 把 schema 变成类型。
本文从三条路线的选型讲起,逐步搭建客户端初始化与环境变量配置,用 graphql-codegen 生成类型安全的 hooks,讲清开发期的 server.proxy 转发与 MSW Mock、查询与变更的组件封装、归一化缓存与失效策略、graphql-ws 订阅与实时数据、按需加载与代码分割,最后给出构建期 schema 校验、CI 门禁与高频陷阱的排查清单。
前置:配置与环境变量、开发期代理转发。组件与目录结构见 React 应用架构模式:目录结构、状态管理与性能优化。
目录
- 1. 数据层选型:Apollo、urql 与 TanStack Query
- 2. 与 Vite 的集成与客户端初始化
- 3. graphql-codegen 与类型安全
- 4. 开发期代理与 Mock 方案
- 5. 查询与变更的组件封装
- 6. 缓存归一化与失效策略
- 7. 订阅与实时数据
- 8. 按需加载与代码分割
- 9. 构建期 schema 校验与 CI
- 10. 常见陷阱与落地清单
1. 数据层选型:Apollo、urql 与 TanStack Query
1.1 三条路线
| 方案 | 定位 | 适合 |
|---|---|---|
| Apollo Client | 全功能 GraphQL 客户端,自带归一化缓存 | 中大型应用、订阅与复杂缓存 |
| urql | 轻量可组合,插件式扩展 | 体积敏感、想自己拼装能力 |
| TanStack Query + 轻客户端 | 用通用请求缓存管理 GraphQL | 已重度使用 TanStack 生态 |
1.2 选型要点与本文主线
选型先问三个问题:要不要归一化缓存、要不要订阅、愿不愿意为体积买单。Apollo 三项都强但包体最大;urql 用 exchanges 把能力拆成可插拔模块;TanStack Query 以 query key 为中心,不做实体归一化。本文以 Apollo 为主线,缓存与 key 章节再给出另两者的对照写法。
实体复用多、订阅多、团队大 → Apollo Client
体积敏感、想自己控制链路 → urql
已有 TanStack 生态、按 key 缓存即可 → TanStack Query
记忆:选型先问归一化缓存、订阅、体积三件事——Apollo 全功能但重、urql 可组合、TanStack Query 按 key 缓存不归一化。
2. 与 Vite 的集成与客户端初始化
2.1 依赖安装
npm i @apollo/client graphql graphql-ws
npm i -D @graphql-codegen/cli @graphql-codegen/client-preset
graphql 是 peer 依赖;graphql-ws 用于订阅传输;codegen 工具全部装进 devDependencies,不进产物。
2.2 客户端初始化
// src/lib/apollo.ts
import { ApolloClient, InMemoryCache, HttpLink, split } from '@apollo/client'
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { getMainDefinition } from '@apollo/client/utilities'
import { createClient } from 'graphql-ws'
const httpLink = new HttpLink({ uri: import.meta.env.VITE_GRAPHQL_ENDPOINT })
const wsLink = new GraphQLWsLink(createClient({
url: import.meta.env.VITE_GRAPHQL_WS_ENDPOINT,
}))
const link = split(({ query }) => {
const def = getMainDefinition(query)
return def.kind === 'OperationDefinition' && def.operation === 'subscription'
}, wsLink, httpLink)
export const client = new ApolloClient({ link, cache: new InMemoryCache() })
2.3 环境变量
Vite 只把 VITE_ 前缀变量注入客户端,端点务必走环境变量:开发态指向 /graphql 走代理,生产态指向真实域名。类型可补进 src/vite-env.d.ts 的 ImportMetaEnv,让 import.meta.env.VITE_GRAPHQL_ENDPOINT 具备字符串类型。
记忆:GraphQL 客户端不需要 Vite 插件,只需
graphql与graphql-ws两个依赖——端点用import.meta.env.VITE_*注入,开发态指代理、生产态指真实域名。
3. graphql-codegen 与类型安全
3.1 为什么必须用 codegen
手写 GraphQL 请求的类型等于放弃 schema 带来的最大红利。graphql-codegen 读取远端或本地 schema、扫描代码里的查询文档,为每个操作生成精确到字段的返回类型:schema 里字段改名或类型变更,生成类型就会报错,问题在编译期即暴露。
3.2 配置与生成
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: 'https://api.example.com/graphql',
documents: ['src/**/*.{ts,tsx}'],
generates: { './src/gql/': { preset: 'client' } },
}
export default config
日常开发用 npx graphql-codegen --config codegen.ts --watch 监听查询文档变更并增量生成。
3.3 使用生成的 hooks
配合 client-preset 生成的 graphql() 函数,查询与类型一次到位:
import { graphql } from '@/gql'
import { useQuery } from '@apollo/client'
const UserQuery = graphql(`
query UserDetail($id: ID!) {
user(id: $id) { id name }
}
`)
export function useUser(id: string) {
const { data, loading, error } = useQuery(UserQuery, { variables: { id } })
return { user: data?.user, loading, error }
}
data?.user 的字段完全由 schema 推导,拼错字段名会在 tsc 阶段直接报错。
记忆:codegen 把 schema 变成类型——
schema指远端、documents指源码里的查询,生成graphql()标签函数与精确返回类型,字段拼错在编译期就暴露。
4. 开发期代理与 Mock 方案
4.1 用 server.proxy 转发
开发态把 /graphql 代理到后端,前端代码里的端点保持相对路径,天然规避跨域:
// vite.config.ts
export default defineConfig({
server: {
proxy: {
'/graphql': {
target: 'http://localhost:4000',
changeOrigin: true,
ws: true, // 同时代理订阅的 WebSocket
},
},
},
})
ws: true 是关键——没有它,查询能通而订阅会连接失败。
4.2 Mock 与本地 schema
后端未就绪时用 MSW 在 Service Worker 层拦截 GraphQL 请求,前端无需改一行代码:
// src/mocks/handlers.ts
import { graphql, HttpResponse } from 'msw'
export const handlers = [
graphql.query('UserDetail', ({ variables }) =>
HttpResponse.json({ data: { user: { id: variables.id } } })),
]
启动时用 import.meta.env.DEV && import.meta.env.VITE_USE_MOCK === 'true' 包住 await import('./mocks/browser') 的动态导入,保证 Mock 代码不进生产产物;而 MSW 只能伪造响应、无法校验查询合法性,因此更稳的做法是把 schema.graphql 拉到本地,让 codegen 与编辑器插件都基于同一份契约。
记忆:开发态用
server.proxy转发/graphql并开ws: true才能连订阅;后端没就绪就用 MSW 在 SW 层拦截,且用import.meta.env.DEV包住动态导入避免进产物。
5. 查询与变更的组件封装
5.1 把查询收进自定义 hook
组件不该直接接触 useQuery 的原始返回,把取数细节收进 hook,组件只消费领域数据:
// src/features/user/useUsers.ts
import { graphql } from '@/gql'
import { useQuery } from '@apollo/client'
const UsersQuery = graphql(`
query Users($page: Int!) {
users(page: $page) { id name }
}
`)
export function useUsers(page = 1) {
return useQuery(UsersQuery, { variables: { page } })
}
5.2 变更与乐观更新
变更后要让 UI 立刻响应,用 optimisticResponse 先写缓存,等真实响应回来再校正:
const [createUser] = useMutation(CreateUserMutation, {
optimisticResponse: ({ name }) => ({
createUser: { __typename: 'User', id: 'temp', name },
}),
update(cache, { data }) {
cache.modify({ fields: { users: () => data!.createUser } })
},
})
把 loading / error 也收进 hook 层、统一返回 { data, loading, error, refetch },组件只关心渲染、不关心网络状态机,避免每个组件重复写骨架屏判断。
记忆:查询与变更都收进自定义 hook——变更用
optimisticResponse先写缓存保证即时反馈,再用update在真实响应回来后校正。
6. 缓存归一化与失效策略
6.1 归一化缓存是什么
Apollo 的 InMemoryCache 按 __typename + id 把对象存成一张扁平表,同一个 User:1 被列表页和详情页同时引用时,任何一处更新都会让两处同步刷新。前提是 schema 里的实体类型必须有 id 字段。
列表页 User:1 ─┐
├─→ 缓存里的同一条记录 → 改一处,两处都刷新
详情页 User:1 ─┘
6.2 失效的三种手段
| 手段 | 写法 | 适用 |
|---|---|---|
| 自动更新 | 归一化后按 id 同步 | 有 id 的实体 |
| 手动改缓存 | cache.modify / writeQuery | 列表增删、分页 |
| 重新拉取 | refetchQueries / refetch | 强一致、结构复杂 |
await createUser({ variables: { name }, refetchQueries: ['Users'] })
6.3 TanStack Query 的 key 设计
不用归一化缓存时,缓存同步全靠 query key 的组织。key 要按「资源 + 参数」分层,失效时用前缀匹配批量失效:
// key 结构:['users', { page }]
queryClient.invalidateQueries({ queryKey: ['users'] }) // 失效所有分页
归一化(Apollo / urql) → 按实体 id 同步,缓存是「表」
key 化(TanStack Query)→ 按 query key 同步,缓存是「映射」
记忆:Apollo 靠
__typename + id做归一化,同一实体自动同步;没有归一化就用 query key 分层失效——列表增删手动改缓存,强一致场景直接 refetch。
7. 订阅与实时数据
7.1 订阅传输与缓存合并
GraphQL 订阅不能用普通 HTTP 长连接,标准传输协议是 graphql-ws。第 2 章的 split 已把订阅路由到 GraphQLWsLink,服务端用 useServer 在同一个 WebSocketServer 上挂载 schema。订阅拿到的新数据不会自动写进归一化缓存,需在 onData 里手动合并:
useSubscription(MESSAGE_ADDED, {
onData: ({ client, data }) => {
const msg = data.data?.messageAdded
if (!msg) return
client.cache.updateQuery({ query: MessagesQuery }, (prev) =>
prev ? { messages: [...prev.messages, msg] } : prev,
)
},
})
7.2 断线重连与退避
生产环境必须处理断线:graphql-ws 的 retryAttempts 配合指数退避,避免服务端重启时客户端雪崩式重连。
createClient({
url: import.meta.env.VITE_GRAPHQL_WS_ENDPOINT,
retryAttempts: 10,
shouldRetry: () => true,
})
记忆:订阅走
graphql-ws的 WebSocket,且split按 operation 类型分流;订阅结果要手动updateQuery合并进缓存,并配retryAttempts做断线重连。
8. 按需加载与代码分割
8.1 让查询随路由懒加载
把每个路由的查询文档与组件一起放进动态 import,Vite 会为它们各自产出一个 chunk:
const UserDetail = lazy(() => import('./features/user/UserDetail'))
8.2 避免文档进主包与产物核对
codegen 生成的 graphql() 标签函数如果被顶层模块引用,会把整段查询文档字符串打进主包;把查询定义与使用它的组件放在同一个懒加载模块里,文档字符串才会跟着 chunk 走。
✅ 查询文档写在被 lazy 引入的模块内 → 文档进该 chunk
❌ 所有查询集中在一个 graphql.ts 且被入口引用 → 文档全进主包
npx vite build
ls -la dist/assets/*.js | head # 看 chunk 数量与体积
grep -l "query Users" dist/assets/*.js # 确认文档落在预期 chunk
记忆:查询文档要和它的组件待在同一个懒加载模块里——集中定义再被入口引用,会把所有查询字符串都塞进主包,代码分割就白做了。
9. 构建期 schema 校验与 CI
9.1 为什么要在 CI 里校验
schema 是前后端的契约,一旦后端改了字段而前端没跟上,问题只会在运行时爆发。把 codegen 的 --check 放进 CI,schema 漂移会在合并前被拦下:
npx graphql-codegen --config codegen.ts --check
9.2 CI 配置与类型检查串联
# .github/workflows/graphql.yml
name: graphql-check
on: [pull_request]
jobs:
codegen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npx graphql-codegen --config codegen.ts --check
再把它与 tsc --noEmit 串成一条链,schema 变更导致的类型断裂就会被一次性暴露在 PR 上。
记忆:CI 里跑
graphql-codegen --check把 schema 漂移拦在合并前——它配合tsc --noEmit,能把后端改字段引发的前端断裂提前到 PR 阶段。
10. 常见陷阱与落地清单
10.1 高频陷阱表
| 现象 | 原因 | 处理 |
|---|---|---|
| 订阅连不上 | 代理未开 ws | proxy 加 ws true |
| 列表更新后详情不刷新 | 实体缺 id,未归一化 | schema 补 id |
| 主包体积暴涨 | 查询文档集中定义被入口引用 | 文档随懒加载模块走 |
| schema 变更线上才炸 | CI 未跑 codegen check | 加 check 步骤 |
10.2 落地清单
□ 端点走 import.meta.env.VITE_GRAPHQL_* 而非硬编码
□ 订阅与查询用 split 分流,代理开启 ws: true
□ 实体类型带 id,启用归一化缓存
□ codegen 产物已提交,CI 跑 --check,Mock 用 DEV 包住动态导入
□ 变更后明确失效策略,订阅配断线重连与退避
10.3 一句话总结
契约靠 codegen(类型即文档)
缓存靠归一化(id 即同步)
实时靠订阅(ws 即通道)
回归靠 CI(check 即门禁)
记忆:GraphQL 数据层的稳定来自四件事——codegen 管契约、归一化管缓存同步、
graphql-ws管实时、CI 的--check管回归;四者缺一,问题就会从编译期漏到线上。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。