GraphQL 全栈集成
Nuxt 的 REST API(Nitro server routes)在大多数场景够用,但当 API 消费者需要灵活查询、避免过度获取(over-fetching)、或者前端需要精确控制返回字段时,GraphQL 就有了用武之地。本章介绍如何在 Nuxt4 中搭建 GraphQL 全栈——服务端用 GraphQL Yoga 集成 Nitro,客户端用 urql 或 Villus 查询,并实现游标分页、实时订阅等高级功能。
1. GraphQL vs REST 选型
1.1 何时选 GraphQL
| 场景 | REST | GraphQL |
|---|---|---|
| 简单 CRUD | ✅ 更简单 | 过度设计 |
| 前端需要灵活字段 | 多个端点或 ?fields= | ✅ 天然支持 |
| 多实体关联查询 | N+1 请求或 BFF 聚合 | ✅ 一次查询 |
| 移动端节省带宽 | 返回多余字段 | ✅ 精确返回 |
| 实时更新 | WebSocket 自建 | ✅ Subscription |
| 文件上传 | ✅ multipart 简单 | 需要额外处理 |
1.2 推荐组合
服务端:GraphQL Yoga + Drizzle ORM + Nitro
客户端:urql / Villus + Vue composables
工具链:GraphQL Code Generator(类型安全)
2. 服务端集成:GraphQL Yoga + Nitro
2.1 安装依赖
pnpm add graphql graphql-yoga
pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-resolvers2.2 Schema 定义
# server/graphql/schema.graphql
type Query {
videos(first: Int = 20, after: String, category: String): VideoConnection!
video(id: ID!): Video
me: User
}
type Mutation {
createVideo(input: CreateVideoInput!): Video!
likeVideo(id: ID!): Video!
}
type Video {
id: ID!
title: String!
description: String
thumbnail: String
duration: Int
viewCount: Int!
author: User!
category: Category
createdAt: String!
}
type User {
id: ID!
name: String!
avatar: String
videos(first: Int = 10): [Video!]!
}
type Category {
id: ID!
name: String!
}
input CreateVideoInput {
title: String!
description: String
categoryId: ID!
}
type VideoConnection {
edges: [VideoEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type VideoEdge {
node: Video!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}2.3 Resolver 实现
// server/graphql/resolvers.ts
import type { Resolvers } from './generated/types'
export const resolvers: Resolvers = {
Query: {
videos: async (_parent, { first, after, category }, ctx) => {
const limit = Math.min(first ?? 20, 50)
let query = ctx.db.select().from(videosTable).orderBy(desc(videosTable.createdAt)).limit(limit + 1)
if (after) {
const cursor = decodeCursor(after)
query = query.where(lt(videosTable.createdAt, cursor))
}
if (category) {
query = query.where(eq(videosTable.categoryId, category))
}
const rows = await query
const hasNextPage = rows.length > limit
const edges = rows.slice(0, limit).map(row => ({
node: row,
cursor: encodeCursor(row.createdAt),
}))
return {
edges,
pageInfo: {
hasNextPage,
endCursor: edges.at(-1)?.cursor ?? null,
},
totalCount: await getVideoCount(ctx.db, category),
}
},
video: async (_parent, { id }, ctx) => {
return ctx.db.query.videos.findFirst({ where: eq(videosTable.id, id) })
},
me: async (_parent, _args, ctx) => {
return ctx.user
},
},
Mutation: {
createVideo: async (_parent, { input }, ctx) => {
if (!ctx.user) throw new Error('Unauthorized')
const [video] = await ctx.db.insert(videosTable).values({
...input,
authorId: ctx.user.id,
}).returning()
return video
},
likeVideo: async (_parent, { id }, ctx) => {
if (!ctx.user) throw new Error('Unauthorized')
await ctx.db.update(videosTable)
.set({ likeCount: sql`like_count + 1` })
.where(eq(videosTable.id, id))
return ctx.db.query.videos.findFirst({ where: eq(videosTable.id, id) })
},
},
Video: {
author: async (parent, _args, ctx) => {
return ctx.db.query.users.findFirst({ where: eq(usersTable.id, parent.authorId) })
},
},
}
function encodeCursor(date: string): string {
return Buffer.from(date).toString('base64')
}
function decodeCursor(cursor: string): string {
return Buffer.from(cursor, 'base64').toString('utf-8')
}2.4 挂载到 Nitro
// server/api/graphql.ts
import { createSchema, createYoga } from 'graphql-yoga'
import { resolvers } from '../graphql/resolvers'
import { readFileSync } from 'fs'
import { resolve } from 'path'
const typeDefs = readFileSync(resolve('./server/graphql/schema.graphql'), 'utf-8')
const yoga = createYoga({
schema: createSchema({ typeDefs, resolvers }),
graphqlEndpoint: '/api/graphql',
context: async ({ request }) => {
const user = await getUserFromRequest(request)
return { db, user }
},
})
export default defineEventHandler(async (event) => {
const response = await yoga.handleNodeRequestAndResponse(
event.node.req,
event.node.res,
{ req: event.node.req, res: event.node.res }
)
return response
})GraphQL Playground 自动可用:访问 http://localhost:3000/api/graphql。
3. 客户端集成:urql
3.1 安装和配置
pnpm add @urql/vue graphql// plugins/urql.ts
import urql, { cacheExchange, fetchExchange } from '@urql/vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(urql, {
url: '/api/graphql',
exchanges: [cacheExchange, fetchExchange],
})
})3.2 查询 Composable
<script setup lang="ts">
import { useQuery } from '@urql/vue'
const { data, fetching, error } = useQuery({
query: `
query Videos($first: Int, $after: String) {
videos(first: $first, after: $after) {
edges {
node {
id
title
thumbnail
viewCount
author { name avatar }
}
cursor
}
pageInfo { hasNextPage endCursor }
totalCount
}
}
`,
variables: { first: 20 },
})
</script>
<template>
<div v-if="fetching">加载中...</div>
<div v-else-if="error">{{ error.message }}</div>
<div v-else>
<VideoCard
v-for="edge in data?.videos.edges"
:key="edge.node.id"
:video="edge.node"
/>
</div>
</template>3.3 Mutation
<script setup lang="ts">
import { useMutation } from '@urql/vue'
const likeResult = useMutation(`
mutation LikeVideo($id: ID!) {
likeVideo(id: $id) {
id
likeCount
}
}
`)
async function handleLike(videoId: string) {
await likeResult.executeMutation({ id: videoId })
}
</script>4. 类型安全:GraphQL Code Generator
4.1 配置
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: './server/graphql/schema.graphql',
documents: ['app/**/*.vue', 'app/**/*.ts'],
generates: {
'./server/graphql/generated/types.ts': {
plugins: ['typescript', 'typescript-resolvers'],
config: {
contextType: '../context#GraphQLContext',
},
},
'./app/graphql/generated/': {
preset: 'client',
plugins: [],
},
},
}
export default configpnpm graphql-codegen --config codegen.ts生成的类型让 resolver 和客户端查询都有完整类型提示。
5. 游标分页实战
5.1 无限滚动加载
<script setup lang="ts">
import { useQuery } from '@urql/vue'
const after = ref<string | null>(null)
const allEdges = ref<VideoEdge[]>([])
const { data, fetching } = useQuery({
query: VIDEOS_QUERY,
variables: computed(() => ({ first: 20, after: after.value })),
})
watch(data, (newData) => {
if (newData?.videos.edges) {
allEdges.value.push(...newData.videos.edges)
}
})
function loadMore() {
if (data.value?.videos.pageInfo.hasNextPage) {
after.value = data.value.videos.pageInfo.endCursor
}
}
</script>
<template>
<div>
<VideoCard v-for="edge in allEdges" :key="edge.node.id" :video="edge.node" />
<button
v-if="data?.videos.pageInfo.hasNextPage"
:disabled="fetching"
@click="loadMore"
>
{{ fetching ? '加载中...' : '加载更多' }}
</button>
</div>
</template>5.2 游标 vs 偏移分页
| 特性 | 游标分页 | 偏移分页 |
|---|---|---|
| 数据一致性 | ✅ 新增数据不影响 | ❌ 可能重复/跳过 |
| 性能 | ✅ O(1) 查询 | ❌ OFFSET 大时慢 |
| 跳转到第 N 页 | ❌ 不支持 | ✅ 支持 |
| 适用场景 | 无限滚动 | 传统分页器 |
6. 性能优化
6.1 DataLoader 解决 N+1
// server/graphql/loaders.ts
import DataLoader from 'dataloader'
export function createLoaders(db: DB) {
return {
userLoader: new DataLoader(async (ids: readonly string[]) => {
const users = await db.select().from(usersTable).where(inArray(usersTable.id, [...ids]))
const map = new Map(users.map(u => [u.id, u]))
return ids.map(id => map.get(id) ?? null)
}),
}
}
// resolver 中使用
Video: {
author: (parent, _args, ctx) => ctx.loaders.userLoader.load(parent.authorId),
}6.2 查询复杂度限制
import { createYoga } from 'graphql-yoga'
import { useDisableIntrospection } from '@graphql-yoga/plugin-disable-introspection'
const yoga = createYoga({
plugins: [
process.env.NODE_ENV === 'production' ? useDisableIntrospection() : {},
],
// 限制查询深度
parserAndValidationCache: true,
})本章小结
- 选型:REST 适合简单 CRUD;GraphQL 适合灵活查询、多实体关联、移动端节省带宽
- 服务端:GraphQL Yoga + Nitro,schema-first 定义 + TypeScript resolver
- 客户端:urql 提供
useQuery/useMutationcomposable,与 Vue 3 完美集成 - 类型安全:GraphQL Code Generator 从 schema 生成服务端和客户端类型
- 分页:游标分页适合无限滚动,偏移分页适合传统分页器
- 性能:DataLoader 解决 N+1、查询复杂度限制防止恶意查询