Vue 3 与 GraphQL Yoga 后端

概述

GraphQL Yoga 是一个现代化的 GraphQL 服务器,基于 Envelop 和 GraphQL.js 构建,提供了高性能、易用性和强大的功能集。与传统的 REST API 相比,GraphQL 允许客户端精确请求所需的数据,减少网络传输,提高开发效率。Vue 3 与 GraphQL Yoga 的结合可以让开发者在全栈应用中享受 GraphQL 的灵活性和类型安全。本集将详细介绍如何使用 Vue 3 和 GraphQL Yoga 构建全栈应用,包括 GraphQL Schema 设计、Resolver 实现和前端集成。

核心知识点

1. GraphQL 基础概念

  • GraphQL Schema:定义 API 的类型系统和操作(查询、变更、订阅)
  • Resolver:处理 GraphQL 查询的函数,返回请求的数据
  • Query:用于获取数据的只读操作
  • Mutation:用于修改数据的写操作
  • Subscription:用于实时数据推送的操作
  • Type System:包括对象类型、标量类型、枚举类型、接口、联合类型等

2. GraphQL Yoga 特点

  • 高性能:基于 Envelop 架构,支持插件系统和优化
  • 易用性:简单的 API,快速搭建 GraphQL 服务器
  • 类型安全:自动生成 TypeScript 类型
  • 支持多种数据源:可以与任何数据库或 API 集成
  • 实时支持:内置对 GraphQL 订阅的支持
  • 插件系统:丰富的插件生态,支持认证、限流、监控等

3. 项目初始化

创建后端项目

# 创建后端目录
mkdir my-graphql-yoga-backend
cd my-graphql-yoga-backend

# 初始化 Node.js 项目
npm init -y

# 安装依赖
npm install graphql-yoga graphql @prisma/client
npm install -D prisma typescript ts-node @types/node

# 初始化 Prisma(如果需要与数据库集成)
npx prisma init

配置 TypeScript

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "skipLibCheck": true,
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": "src",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

4. 实现 GraphQL Schema

定义 Schema

# src/schema.graphql
type User {
  id: ID!  
  name: String
  email: String! @unique
  posts: [Post!]! @relation(name: "AuthorPosts")
  comments: [Comment!]! @relation(name: "AuthorComments")
  createdAt: DateTime! @default(now())
  updatedAt: DateTime! @updatedAt
}

type Post {
  id: ID!  
  title: String!
  content: String
  published: Boolean! @default(value: false)
  author: User! @relation(name: "AuthorPosts")
  comments: [Comment!]! @relation(name: "PostComments")
  createdAt: DateTime! @default(now())
  updatedAt: DateTime! @updatedAt
}

type Comment {
  id: ID!  
  content: String!
  post: Post! @relation(name: "PostComments")
  author: User! @relation(name: "AuthorComments")
  createdAt: DateTime! @default(now())
  updatedAt: DateTime! @updatedAt
}

type Query {
  # 获取所有帖子
  posts: [Post!]!
  # 获取单个帖子
  post(id: ID!): Post
  # 获取所有用户
  users: [User!]!
  # 获取当前用户
  me: User
}

type Mutation {
  # 创建用户
  createUser(data: CreateUserInput!): User!
  # 登录
  login(email: String!, password: String!): AuthPayload!
  # 创建帖子
  createPost(data: CreatePostInput!): Post!
  # 更新帖子
  updatePost(id: ID!, data: UpdatePostInput!): Post!
  # 删除帖子
  deletePost(id: ID!): Post!
  # 创建评论
  createComment(data: CreateCommentInput!): Comment!
}

type Subscription {
  # 订阅新帖子
  postCreated: Post!
}

type AuthPayload {
  token: String!
  user: User!
}

input CreateUserInput {
  name: String
  email: String!
  password: String!
}

input CreatePostInput {
  title: String!
  content: String
  published: Boolean
}

input UpdatePostInput {
  title: String
  content: String
  published: Boolean
}

input CreateCommentInput {
  postId: ID!
  content: String!
}

scalar DateTime

5. 实现 Resolvers

基本 Resolver 实现

// src/resolvers.ts
import { Resolvers } from './generated/graphql'
import { prisma } from './prisma'
import { Context } from './context'
import { sign, verify } from 'jsonwebtoken'
import { hash, compare } from 'bcryptjs'

const resolvers: Resolvers<Context> = {
  Query: {
    // 获取所有帖子
    posts: async (_, __, { prisma }) => {
      return prisma.post.findMany({
        include: { author: true, comments: { include: { author: true } } }
      })
    },
    
    // 获取单个帖子
    post: async (_, { id }, { prisma }) => {
      return prisma.post.findUnique({
        where: { id },
        include: { author: true, comments: { include: { author: true } } }
      })
    },
    
    // 获取所有用户
    users: async (_, __, { prisma }) => {
      return prisma.user.findMany({ include: { posts: true, comments: true } })
    },
    
    // 获取当前用户
    me: async (_, __, { user }) => {
      if (!user) {
        throw new Error('Not authenticated')
      }
      return prisma.user.findUnique({ 
        where: { id: user.id },
        include: { posts: true, comments: true }
      })
    }
  },
  
  Mutation: {
    // 创建用户
    createUser: async (_, { data }, { prisma }) => {
      const hashedPassword = await hash(data.password, 10)
      return prisma.user.create({
        data: {
          ...data,
          password: hashedPassword
        }
      })
    },
    
    // 登录
    login: async (_, { email, password }, { prisma }) => {
      const user = await prisma.user.findUnique({ where: { email } })
      if (!user) {
        throw new Error('Invalid credentials')
      }
      
      const isValid = await compare(password, user.password)
      if (!isValid) {
        throw new Error('Invalid credentials')
      }
      
      const token = sign({ id: user.id }, process.env.JWT_SECRET || 'secret')
      
      return { token, user }
    },
    
    // 创建帖子
    createPost: async (_, { data }, { prisma, user }) => {
      if (!user) {
        throw new Error('Not authenticated')
      }
      
      return prisma.post.create({
        data: {
          ...data,
          author: { connect: { id: user.id } }
        },
        include: { author: true }
      })
    },
    
    // 更新帖子
    updatePost: async (_, { id, data }, { prisma, user }) => {
      if (!user) {
        throw new Error('Not authenticated')
      }
      
      const post = await prisma.post.findUnique({ where: { id } })
      if (!post) {
        throw new Error('Post not found')
      }
      
      if (post.authorId !== user.id) {
        throw new Error('Not authorized')
      }
      
      return prisma.post.update({
        where: { id },
        data,
        include: { author: true, comments: { include: { author: true } } }
      })
    },
    
    // 删除帖子
    deletePost: async (_, { id }, { prisma, user }) => {
      if (!user) {
        throw new Error('Not authenticated')
      }
      
      const post = await prisma.post.findUnique({ where: { id } })
      if (!post) {
        throw new Error('Post not found')
      }
      
      if (post.authorId !== user.id) {
        throw new Error('Not authorized')
      }
      
      return prisma.post.delete({
        where: { id },
        include: { author: true }
      })
    },
    
    // 创建评论
    createComment: async (_, { data }, { prisma, user }) => {
      if (!user) {
        throw new Error('Not authenticated')
      }
      
      return prisma.comment.create({
        data: {
          content: data.content,
          post: { connect: { id: data.postId } },
          author: { connect: { id: user.id } }
        },
        include: { author: true, post: true }
      })
    }
  },
  
  Subscription: {
    // 订阅新帖子
    postCreated: {
      subscribe: async (_, __, { pubsub }) => {
        return pubsub.subscribe('POST_CREATED')
      },
      resolve: (payload) => payload
    }
  }
}

export default resolvers

6. 配置上下文和认证

创建上下文

// src/context.ts
import { PrismaClient } from '@prisma/client'
import { PubSub } from 'graphql-yoga'
import { verify } from 'jsonwebtoken'

const prisma = new PrismaClient()
const pubsub = new PubSub()

export type Context = {
  prisma: PrismaClient
  pubsub: PubSub
  user: { id: string } | null
}

export async function createContext({ request }: any) {
  const token = request.headers.authorization?.replace('Bearer ', '')
  let user = null
  
  if (token) {
    try {
      const decoded = verify(token, process.env.JWT_SECRET || 'secret') as { id: string }
      user = decoded
    } catch (error) {
      // 无效令牌,继续执行
    }
  }
  
  return {
    prisma,
    pubsub,
    user
  }
}

7. 创建 GraphQL Yoga 服务器

主应用入口

// src/index.ts
import { createYoga } from 'graphql-yoga'
import { createServer } from 'http'
import { schema } from './schema'
import { createContext } from './context'

// 创建 Yoga 实例
const yoga = createYoga({
  schema,
  context: createContext,
  graphiql: true, // 启用 GraphiQL 界面
  cors: {
    origin: '*', // 允许所有来源,生产环境应限制
    credentials: true
  }
})

// 创建 HTTP 服务器
const server = createServer(yoga)

const PORT = process.env.PORT || 4000

server.listen(PORT, () => {
  console.log(`GraphQL Yoga server is running on http://localhost:${PORT}/graphql`)
  console.log(`GraphiQL is available at http://localhost:${PORT}/graphql`)
})

生成 Schema

// src/schema.ts
import { makeExecutableSchema } from '@graphql-tools/schema'
import typeDefs from './schema.graphql'
import resolvers from './resolvers'

// 创建可执行 Schema
export const schema = makeExecutableSchema({
  typeDefs,
  resolvers
})

8. 前端 Vue 3 应用集成

创建 Vue 3 项目

# 创建前端目录
mkdir my-graphql-yoga-frontend
cd my-graphql-yoga-frontend

# 使用 Vite 创建 Vue 3 项目
yarn create vite . -- --template vue-ts

安装依赖

# 安装 Apollo Client 和其他依赖
yarn add @apollo/client graphql pinia vue-router

配置 Apollo Client

// src/utils/apollo.ts
import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'

// HTTP 链接
const httpLink = createHttpLink({
  uri: 'http://localhost:4000/graphql'
})

// 认证链接
const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem('token')
  return {
    headers: {
      ...headers,
      authorization: token ? `Bearer ${token}` : ''
    }
  }
})

// 创建 Apollo Client 实例
export const apolloClient = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
  connectToDevTools: true
})

配置 Vue 应用

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHistory } from 'vue-router'
import { DefaultApolloClient } from '@vue/apollo-composable'
import { apolloClient } from './utils/apollo'
import App from './App.vue'
import PostsPage from './components/PostsPage.vue'
import PostDetailPage from './components/PostDetailPage.vue'
import LoginPage from './components/LoginPage.vue'

const app = createApp(App)
const pinia = createPinia()

// 配置路由
const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/', component: PostsPage },
    { path: '/posts/:id', component: PostDetailPage },
    { path: '/login', component: LoginPage }
  ]
})

app.use(pinia)
app.use(router)

// 提供 Apollo Client
app.provide(DefaultApolloClient, apolloClient)

app.mount('#app')

创建 GraphQL 查询

# src/graphql/queries.ts
import { gql } from '@apollo/client'

// 获取所有帖子
export const GET_POSTS = gql`
  query GetPosts {
    posts {
      id
      title
      content
      published
      createdAt
      author {
        id
        name
        email
      }
      comments {
        id
        content
        createdAt
        author {
          id
          name
        }
      }
    }
  }
`

// 获取单个帖子
export const GET_POST = gql`
  query GetPost($id: ID!) {
    post(id: $id) {
      id
      title
      content
      published
      createdAt
      author {
        id
        name
        email
      }
      comments {
        id
        content
        createdAt
        author {
          id
          name
        }
      }
    }
  }
`

// 登录
export const LOGIN = gql`
  mutation Login($email: String!, $password: String!) {
    login(email: $email, password: $password) {
      token
      user {
        id
        name
        email
      }
    }
  }
`

// 创建帖子
export const CREATE_POST = gql`
  mutation CreatePost($data: CreatePostInput!) {
    createPost(data: $data) {
      id
      title
      content
      published
      createdAt
      author {
        id
        name
      }
    }
  }
`

创建 Vue 组件

<template>
  <div class="posts-container">
    <h1>帖子列表</h1>
    
    <div v-if="loading" class="loading">加载中...</div>
    
    <div v-else-if="error" class="error">{{ error.message }}</div>
    
    <div v-else class="posts-list">
      <div v-for="post in posts" :key="post.id" class="post">
        <h2>{{ post.title }}</h2>
        <p class="post-meta">
          作者: {{ post.author.name }} | 
          {{ new Date(post.createdAt).toLocaleString() }}
        </p>
        <p>{{ post.content }}</p>
        <div class="post-status">
          <span v-if="post.published" class="published">已发布</span>
          <span v-else class="draft">草稿</span>
        </div>
        <div class="comments-count">
          评论: {{ post.comments.length }}
        </div>
        <router-link :to="`/posts/${post.id}`" class="read-more">
          查看详情
        </router-link>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable'
import { GET_POSTS } from '../graphql/queries'

const { result, loading, error } = useQuery(GET_POSTS)

const posts = computed(() => result.value?.posts || [])
</script>

<style scoped>
.posts-container {
  max-width: 800px;
  margin: 0 auto;
  padding: 20px;
}

.loading {
  text-align: center;
  color: #42b883;
}

.error {
  text-align: center;
  color: #ff4d4f;
}

.posts-list {
  display: flex;
  flex-direction: column;
  gap: 20px;
}

.post {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 20px;
  background-color: #fff;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.post h2 {
  margin-top: 0;
  color: #333;
}

.post-meta {
  color: #666;
  font-size: 14px;
  margin-bottom: 15px;
}

.post-status {
  margin: 15px 0;
}

.published {
  color: #52c41a;
  font-weight: bold;
}

.draft {
  color: #faad14;
  font-weight: bold;
}

.comments-count {
  color: #666;
  font-size: 14px;
  margin: 10px 0;
}

.read-more {
  display: inline-block;
  margin-top: 10px;
  color: #42b883;
  text-decoration: none;
  font-weight: bold;
}

.read-more:hover {
  text-decoration: underline;
}
</style>

9. GraphQL 订阅

后端配置

// src/resolvers.ts (更新 Mutation.createPost)
createPost: async (_, { data }, { prisma, user, pubsub }) => {
  if (!user) {
    throw new Error('Not authenticated')
  }
  
  const post = await prisma.post.create({
    data: {
      ...data,
      author: { connect: { id: user.id } }
    },
    include: { author: true }
  })
  
  // 发布事件
  pubsub.publish('POST_CREATED', post)
  
  return post
}

前端订阅

<template>
  <div class="subscriptions-container">
    <h2>实时帖子</h2>
    <div v-for="post in newPosts" :key="post.id" class="post">
      <h3>{{ post.title }}</h3>
      <p>作者: {{ post.author.name }}</p>
      <p>{{ post.content }}</p>
      <p>{{ new Date(post.createdAt).toLocaleString() }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useSubscription } from '@vue/apollo-composable'
import { gql } from '@apollo/client'

const POST_CREATED = gql`
  subscription PostCreated {
    postCreated {
      id
      title
      content
      createdAt
      author {
        id
        name
      }
    }
  }
`

const newPosts = ref([])
let subscription = null

onMounted(() => {
  const { subscribeToMore } = useSubscription(POST_CREATED)
  
  subscription = subscribeToMore({
    next: (data) => {
      newPosts.value.push(data.data.postCreated)
    }
  })
})

onUnmounted(() => {
  if (subscription) {
    subscription.unsubscribe()
  }
})
</script>

<style scoped>
.subscriptions-container {
  max-width: 800px;
  margin: 20px auto;
  padding: 20px;
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  background-color: #f9f9f9;
}

.post {
  margin: 10px 0;
  padding: 15px;
  background-color: #fff;
  border-radius: 4px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
</style>

最佳实践

1. Schema 设计

  • 使用有意义的命名:类型、字段和操作使用清晰、描述性的名称
  • 遵循 GraphQL 最佳实践:使用复数形式的查询字段,单数形式的变更字段
  • 定义明确的类型:避免使用 String 等通用类型,使用更具体的类型
  • 使用输入类型:将复杂的输入参数组织为输入类型
  • 添加描述:使用描述字段和类型,生成更好的文档

2. Resolver 实现

  • 保持 Resolver 简洁:每个 Resolver 只做一件事
  • 使用 DataLoader 避免 N+1 查询:对于批量查询使用 DataLoader 缓存结果
  • 错误处理:使用适当的错误类型和消息
  • 认证和授权:在 Resolver 中检查用户权限
  • 日志记录:添加适当的日志,便于调试和监控

3. 性能优化

  • 使用查询复杂度分析:限制查询的深度和复杂度
  • 分页查询:对大量数据使用分页
  • 缓存策略:配置适当的缓存策略
  • 批量操作:支持批量查询和变更
  • 使用索引:为数据库查询添加适当的索引

4. 类型安全

  • 使用 TypeScript:在前端和后端都使用 TypeScript
  • 自动生成类型:使用 GraphQL Code Generator 自动生成 TypeScript 类型
  • 验证输入:对所有输入进行验证
  • 使用 GraphQL 类型系统:利用 GraphQL 的类型系统进行验证

5. 安全最佳实践

  • 认证和授权:实现强大的认证和授权机制
  • 限制查询复杂度:防止恶意查询
  • 验证输入:防止注入攻击
  • 使用 HTTPS:在生产环境中使用 HTTPS
  • 保护敏感数据:避免在查询中暴露敏感数据

6. 开发工作流

  • 使用 GraphiQL 或 Playground:用于测试和探索 GraphQL API
  • 自动化测试:编写单元测试和集成测试
  • 持续集成:集成 CI/CD 流程
  • 监控和日志:添加监控和日志记录
  • 文档生成:自动生成 API 文档

常见问题与解决方案

1. 问题:N+1 查询问题

解决方案

  • 使用 DataLoader 库缓存和批量处理请求
  • 在 Resolver 中一次性获取关联数据
  • 优化数据库查询,使用 JOIN 或包含关联数据

2. 问题:查询性能问题

解决方案

  • 使用查询复杂度分析限制查询深度和复杂度
  • 实现分页查询
  • 优化数据库索引
  • 使用缓存策略
  • 监控查询性能,优化慢查询

3. 问题:类型错误

解决方案

  • 使用 TypeScript 和自动生成的类型
  • 确保 Resolver 返回的类型与 Schema 定义一致
  • 检查 GraphQL Code Generator 配置
  • 使用 GraphiQL 测试查询,查看错误信息

4. 问题:认证和授权问题

解决方案

  • 在上下文中间件中验证令牌
  • 在 Resolver 中检查用户权限
  • 使用 GraphQL Shield 等库实现细粒度的授权
  • 确保敏感操作需要认证

5. 问题:订阅不工作

解决方案

  • 确保服务器支持 WebSocket
  • 检查订阅 resolver 配置
  • 检查客户端连接配置
  • 查看服务器日志,了解连接问题
  • 确保发布和订阅使用相同的事件名称

6. 问题:CORS 问题

解决方案

  • 在 GraphQL Yoga 配置中启用 CORS
  • 配置允许的来源、方法和头信息
  • 在生产环境中限制允许的来源
  • 检查浏览器控制台的 CORS 错误信息

进一步学习资源

  1. 官方文档

  2. 学习资源

  3. 工具和库

  4. 示例项目

  5. 社区资源

课后练习

练习 1:创建基础 GraphQL Yoga 服务器

  • 初始化 Node.js 项目
  • 安装 GraphQL Yoga 和依赖
  • 定义简单的 Schema(User 和 Post)
  • 实现基本的 Resolver
  • 启动服务器,使用 GraphiQL 测试

练习 2:实现完整的 CRUD 操作

  • 定义包含 Query、Mutation 和 Subscription 的 Schema
  • 实现用户认证和授权
  • 实现帖子和评论的 CRUD 操作
  • 添加订阅支持
  • 测试所有操作

练习 3:与数据库集成

  • 安装 Prisma 并配置数据库
  • 定义 Prisma Schema
  • 生成 Prisma Client
  • 在 Resolver 中使用 Prisma Client 访问数据库
  • 测试数据库操作

练习 4:前端 Vue 3 集成

  • 创建 Vue 3 + TypeScript 项目
  • 安装 Apollo Client
  • 配置 Apollo Client 和路由
  • 实现帖子列表和详情页面
  • 实现登录和创建帖子功能

练习 5:实现订阅

  • 在后端添加订阅支持
  • 在前端实现订阅组件
  • 测试实时数据推送
  • 优化订阅性能

练习 6:性能优化

  • 实现 DataLoader 解决 N+1 查询问题
  • 添加查询复杂度分析
  • 实现分页查询
  • 配置缓存策略

练习 7:安全配置

  • 实现认证和授权
  • 限制查询复杂度
  • 配置 CORS
  • 添加输入验证
  • 保护敏感数据

通过以上练习,你将掌握 Vue 3 与 GraphQL Yoga 集成的核心技能,能够构建高性能、类型安全的全栈 GraphQL 应用。

« 上一篇 Vue 3与Prisma ORM集成 - 类型安全全栈开发解决方案 下一篇 » Vue 3与Redis缓存集成 - 高性能全栈缓存解决方案