Vue 3 与 Prisma ORM 集成

概述

Prisma 是一个现代化的 ORM(对象关系映射)工具,用于 Node.js 和 TypeScript 应用,提供了类型安全、自动生成的数据库客户端,支持 PostgreSQL、MySQL、SQLite 等多种数据库。与传统 ORM 相比,Prisma 提供了更直观的数据模型定义、自动生成的类型安全 API、强大的查询能力和良好的开发者体验。Vue 3 与 Prisma 的结合可以让开发者在全栈应用中享受类型安全和高效的数据库操作。本集将详细介绍如何使用 Vue 3 和 Prisma ORM 构建全栈应用,包括数据模型设计、API 实现和前端集成。

核心知识点

1. Prisma 基础概念

  • Prisma Schema:使用 Prisma 自己的 DSL(领域特定语言)定义数据模型
  • Prisma Client:自动生成的类型安全数据库客户端,基于 Prisma Schema
  • Prisma Migrate:用于数据库迁移,将数据模型变更应用到数据库
  • Prisma Studio:可视化数据库管理工具,用于查看和编辑数据

2. 项目初始化

创建后端项目

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

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

# 安装 Prisma CLI 和依赖
npm install -D prisma
npm install @prisma/client

# 初始化 Prisma
npx prisma init

配置数据库连接

# .env
DATABASE_URL="postgresql://postgres:password@localhost:5432/myprismadb?schema=public"

3. 数据模型设计

定义 Prisma Schema

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  name      String?
  email     String   @unique
  password  String
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id        Int      @id @default(autoincrement())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  comments  Comment[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  post      Post     @relation(fields: [postId], references: [id])
  postId    Int
  author    User     @relation(fields: [authorId], references: [id])
  authorId  Int
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

创建数据库迁移

# 创建迁移
npx prisma migrate dev --name init

# 生成 Prisma Client
npx prisma generate

4. 实现后端 API

安装 Express 和其他依赖

npm install express cors body-parser bcryptjs jsonwebtoken
npm install -D @types/express @types/cors @types/bcryptjs @types/jsonwebtoken ts-node typescript

配置 TypeScript

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

创建 Prisma Client 实例

// src/prisma.ts
import { PrismaClient } from '@prisma/client'

export const prisma = new PrismaClient()

实现认证中间件

// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express'
import jwt from 'jsonwebtoken'

interface AuthRequest extends Request {
  user?: any
}

export const auth = (req: AuthRequest, res: Response, next: NextFunction) => {
  try {
    const token = req.header('Authorization')?.replace('Bearer ', '')
    if (!token) {
      throw new Error()
    }
    const decoded = jwt.verify(token, process.env.JWT_SECRET || 'secret')
    req.user = decoded
    next()
  } catch (error) {
    res.status(401).json({ message: 'Please authenticate' })
  }
}

实现 API 路由

// src/routes/posts.ts
import express from 'express'
import { prisma } from '../prisma'
import { auth } from '../middleware/auth'

const router = express.Router()

// 获取所有帖子
router.get('/', async (req, res) => {
  const posts = await prisma.post.findMany({
    include: {
      author: { select: { id: true, name: true, email: true } },
      comments: { include: { author: { select: { id: true, name: true } } } }
    }
  })
  res.json(posts)
})

// 创建帖子
router.post('/', auth, async (req: any, res) => {
  const { title, content, published } = req.body
  const post = await prisma.post.create({
    data: {
      title,
      content,
      published: published || false,
      authorId: req.user.id
    },
    include: { author: { select: { id: true, name: true } } }
  })
  res.status(201).json(post)
})

// 获取单个帖子
router.get('/:id', async (req, res) => {
  const { id } = req.params
  const post = await prisma.post.findUnique({
    where: { id: parseInt(id) },
    include: {
      author: { select: { id: true, name: true, email: true } },
      comments: { include: { author: { select: { id: true, name: true } } } }
    }
  })
  if (!post) {
    return res.status(404).json({ message: 'Post not found' })
  }
  res.json(post)
})

// 更新帖子
router.put('/:id', auth, async (req: any, res) => {
  const { id } = req.params
  const { title, content, published } = req.body
  
  // 检查帖子是否存在且属于当前用户
  const existingPost = await prisma.post.findUnique({
    where: { id: parseInt(id) }
  })
  
  if (!existingPost) {
    return res.status(404).json({ message: 'Post not found' })
  }
  
  if (existingPost.authorId !== req.user.id) {
    return res.status(403).json({ message: 'Not authorized' })
  }
  
  const updatedPost = await prisma.post.update({
    where: { id: parseInt(id) },
    data: { title, content, published },
    include: { author: { select: { id: true, name: true } } }
  })
  
  res.json(updatedPost)
})

// 删除帖子
router.delete('/:id', auth, async (req: any, res) => {
  const { id } = req.params
  
  // 检查帖子是否存在且属于当前用户
  const existingPost = await prisma.post.findUnique({
    where: { id: parseInt(id) }
  })
  
  if (!existingPost) {
    return res.status(404).json({ message: 'Post not found' })
  }
  
  if (existingPost.authorId !== req.user.id) {
    return res.status(403).json({ message: 'Not authorized' })
  }
  
  await prisma.post.delete({ where: { id: parseInt(id) } })
  res.json({ message: 'Post deleted' })
})

export default router

实现主应用入口

// src/index.ts
import express from 'express'
import cors from 'cors'
import bodyParser from 'body-parser'
import postsRouter from './routes/posts'
import authRouter from './routes/auth'

const app = express()
const PORT = process.env.PORT || 3000

app.use(cors())
app.use(bodyParser.json())

app.use('/api/posts', postsRouter)
app.use('/api/auth', authRouter)

app.get('/', (req, res) => {
  res.json({ message: 'Welcome to Prisma API' })
})

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`)
})

5. 前端 Vue 3 应用集成

创建 Vue 3 项目

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

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

安装依赖

# 安装 Axios 和其他依赖
yarn add axios pinia vue-router

配置 Axios 实例

// src/utils/axios.ts
import axios from 'axios'

const apiClient = axios.create({
  baseURL: 'http://localhost:3000/api',
  headers: {
    'Content-Type': 'application/json'
  }
})

// 请求拦截器
apiClient.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

export default apiClient

创建 Pinia Store

// src/stores/post.ts
import { defineStore } from 'pinia'
import apiClient from '../utils/axios'

export interface Post {
  id: number
  title: string
  content: string
  published: boolean
  author: {
    id: number
    name: string
    email?: string
  }
  comments: Array<{
    id: number
    content: string
    author: {
      id: number
      name: string
    }
  }>
  createdAt: string
  updatedAt: string
}

export const usePostStore = defineStore('post', {
  state: () => ({
    posts: [] as Post[],
    loading: false,
    error: null as string | null
  }),
  
  actions: {
    async fetchPosts() {
      this.loading = true
      this.error = null
      try {
        const response = await apiClient.get('/posts')
        this.posts = response.data
      } catch (error: any) {
        this.error = error.message
        console.error('Error fetching posts:', error)
      } finally {
        this.loading = false
      }
    },
    
    async createPost(postData: { title: string; content: string; published?: boolean }) {
      this.loading = true
      this.error = null
      try {
        const response = await apiClient.post('/posts', postData)
        this.posts.push(response.data)
        return response.data
      } catch (error: any) {
        this.error = error.message
        console.error('Error creating post:', error)
        throw error
      } finally {
        this.loading = false
      }
    }
  }
})

创建 Vue 组件

<template>
  <div class="posts-container">
    <h1>帖子列表</h1>
    
    <!-- 加载状态 -->
    <div v-if="loading" class="loading">加载中...</div>
    
    <!-- 错误信息 -->
    <div v-else-if="error" class="error">{{ error }}</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">
          <h3>评论 ({{ post.comments.length }})</h3>
          <div v-for="comment in post.comments" :key="comment.id" class="comment">
            <p>{{ comment.content }}</p>
            <p class="comment-author">- {{ comment.author.name }}</p>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue'
import { usePostStore } from '../stores/post'

const postStore = usePostStore()
const { posts, loading, error, fetchPosts } = postStore

onMounted(() => {
  fetchPosts()
})
</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 {
  margin-top: 20px;
  padding-top: 20px;
  border-top: 1px solid #e0e0e0;
}

.comments h3 {
  margin-top: 0;
  color: #333;
  font-size: 16px;
}

.comment {
  margin: 10px 0;
  padding: 10px;
  background-color: #f5f5f5;
  border-radius: 4px;
}

.comment-author {
  color: #666;
  font-size: 14px;
  margin: 5px 0 0 0;
  text-align: right;
}
</style>

6. 数据库迁移和部署

创建数据库迁移

# 创建新的迁移(当修改 schema 后)
npx prisma migrate dev --name add-new-field

# 生成新的 Prisma Client
npx prisma generate

生产环境部署

# 生成生产迁移
npx prisma migrate deploy

# 生成 Prisma Client
npx prisma generate

# 启动应用
node dist/index.js

7. Prisma Studio

启动 Prisma Studio

npx prisma studio

Prisma Studio 将在浏览器中打开,默认地址为 http://localhost:5555,用于可视化管理数据库。

最佳实践

1. 数据模型设计

  • 使用有意义的命名:模型、字段和关系使用清晰、描述性的名称
  • 定义适当的索引:为常用查询字段添加索引,提高查询性能
  • 使用关系型数据库优势:合理设计关系,避免冗余数据
  • 使用可选字段:对于非必填字段使用 ? 标记为可选
  • 定义默认值:为适当的字段添加默认值(如 @default(now()) 用于时间戳)

2. 查询优化

  • 使用 select 字段:只查询需要的字段,减少数据传输
  • **合理使用 include**:只在需要时包含关联数据
  • 避免 N+1 查询:使用 includeselect 一次性获取关联数据
  • 使用分页:对大量数据使用分页查询
  • 使用索引:为常用查询字段添加索引

3. 类型安全

  • 使用 TypeScript:在前端和后端都使用 TypeScript,享受端到端的类型安全
  • 使用自动生成的类型:使用 Prisma Client 自动生成的类型,避免手动定义
  • 验证输入数据:在前端和后端都验证用户输入
  • 使用 Zod 或 Joi:在后端使用验证库验证请求数据

4. 安全最佳实践

  • 加密密码:使用 bcryptjs 等库加密用户密码
  • 使用 JWT 认证:实现安全的用户认证
  • 验证权限:确保用户只能访问和修改自己的数据
  • 使用参数化查询:Prisma 自动使用参数化查询,避免 SQL 注入
  • 限制数据库权限:为应用使用的数据库用户授予最小必要权限

5. 开发工作流

  • 使用 Prisma Studio:开发过程中使用 Prisma Studio 管理数据
  • 编写迁移:每次修改数据模型时创建新的迁移
  • 生成 Prisma Client:每次修改 schema 后重新生成 Prisma Client
  • 测试迁移:在部署前测试迁移
  • 使用环境变量:使用环境变量管理数据库连接字符串等敏感信息

6. 部署最佳实践

  • 使用环境变量:在生产环境中使用环境变量配置数据库连接
  • 运行迁移:在启动应用前运行数据库迁移
  • 生成 Prisma Client:在生产环境中重新生成 Prisma Client
  • 监控数据库:监控数据库性能和查询
  • 备份数据:定期备份数据库

常见问题与解决方案

1. 问题:Prisma Client 无法生成

解决方案

  • 确保 Prisma Schema 文件语法正确
  • 确保数据库连接字符串正确
  • 运行 npx prisma generate 重新生成
  • 检查 Node.js 版本是否符合要求

2. 问题:迁移失败

解决方案

  • 检查数据库连接是否正常
  • 检查迁移文件是否有语法错误
  • 检查数据库中是否已有冲突的数据
  • 使用 npx prisma migrate reset 重置数据库(注意:会删除所有数据)

3. 问题:关系查询返回空数据

解决方案

  • 确保关系定义正确
  • 确保使用了 includeselect 来包含关联数据
  • 检查数据库中是否有相关数据
  • 检查外键约束是否正确

4. 问题:前端无法访问后端 API

解决方案

  • 确保后端服务器正在运行
  • 检查 CORS 配置是否允许前端域名
  • 检查 API 路由是否正确
  • 检查浏览器控制台的错误信息

5. 问题:TypeScript 类型错误

解决方案

  • 确保已安装所有必要的类型定义
  • 确保 Prisma Client 已正确生成
  • 检查类型定义是否与 API 响应匹配
  • 运行 tsc --noEmit 检查类型错误

6. 问题:性能问题

解决方案

  • 优化查询,只查询需要的字段
  • 添加适当的索引
  • 使用分页查询
  • 避免 N+1 查询
  • 监控数据库查询性能

进一步学习资源

  1. 官方文档

  2. 学习资源

  3. 示例项目

  4. 社区资源

  5. 工具和扩展

课后练习

练习 1:创建基础 Prisma 应用

  • 初始化 Prisma 项目
  • 配置数据库连接
  • 定义简单的数据模型(User 和 Post)
  • 生成 Prisma Client
  • 启动 Prisma Studio 查看数据

练习 2:实现 API 路由

  • 创建 Express 服务器
  • 实现用户认证 API(注册、登录)
  • 实现帖子 CRUD API
  • 实现评论 API
  • 测试 API 路由

练习 3:前端集成

  • 创建 Vue 3 + TypeScript 项目
  • 配置 Axios 实例
  • 创建 Pinia Store 管理数据
  • 实现帖子列表和详情页面
  • 实现创建和编辑帖子功能

练习 4:数据库迁移

  • 修改数据模型(添加新字段或关系)
  • 创建新的数据库迁移
  • 应用迁移到数据库
  • 生成新的 Prisma Client
  • 测试修改后的功能

练习 5:查询优化

  • 优化 API 查询,只返回需要的字段
  • 添加适当的索引
  • 实现分页查询
  • 测试查询性能

练习 6:类型安全

  • 在前端和后端都使用 TypeScript
  • 使用 Prisma 自动生成的类型
  • 实现输入数据验证
  • 测试类型安全

练习 7:部署应用

  • 配置环境变量
  • 编写生产环境启动脚本
  • 部署到生产环境
  • 测试生产环境功能

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

« 上一篇 Vue 3与Supabase后端集成 - 开源BaaS全栈解决方案 下一篇 » Vue 3与GraphQL Yoga后端 - 高性能GraphQL全栈解决方案