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 DateTime5. 实现 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 resolvers6. 配置上下文和认证
创建上下文
// 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 错误信息
进一步学习资源
官方文档
学习资源
工具和库
- GraphQL Code Generator
- DataLoader
- GraphQL Shield
- Prisma - 数据库 ORM
示例项目
社区资源
课后练习
练习 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 应用。