Vite 项目的 GraphQL 数据层:Apollo、urql、codegen 与缓存失效实战

在 Vite 项目中搭建 GraphQL 数据层的完整实践:Apollo Client、urql 与 TanStack Query 三条路线的选型对比、客户端初始化与环境变量配置、graphql-codegen 生成类型安全 hooks、开发期代理与 MSW Mock、查询与变更的封装与乐观更新、归一化缓存与失效策略、graphql-ws 订阅与实时数据,以及构建期 schema 校验、CI 门禁与高频陷阱清单。

引言

当接口从 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

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 高频陷阱表

现象原因处理
订阅连不上代理未开 wsproxy 加 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 管回归;四者缺一,问题就会从编译期漏到线上。


延伸阅读

继续阅读

探索更多技术文章

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

全部文章 返回首页

「vite」更多文章

  1. Vite 中的 3D 与 WebGL 工程化:Three.js、模型纹理压缩与渲染性能治理
  2. Vite 项目部署平台适配实战:Vercel、Netlify、Cloudflare Pages 与自建方案
  3. Vite 桌面应用实战:Electron 与 Tauri 的工程化落地