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

场景RESTGraphQL
简单 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-resolvers

2.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 config
pnpm 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/useMutation composable,与 Vue 3 完美集成
  • 类型安全:GraphQL Code Generator 从 schema 生成服务端和客户端类型
  • 分页:游标分页适合无限滚动,偏移分页适合传统分页器
  • 性能:DataLoader 解决 N+1、查询复杂度限制防止恶意查询