Vue 3 与 Supabase 后端集成
概述
Supabase 是一个开源的 Firebase 替代方案,提供了完整的后端即服务(BaaS)功能,包括 PostgreSQL 数据库、认证、存储、实时订阅、边缘函数等。与 Firebase 不同,Supabase 基于 PostgreSQL 构建,提供了 SQL 查询能力和关系型数据库的强大功能。Vue 3 与 Supabase 的结合可以让开发者使用熟悉的 Vue 3 技术栈创建高性能、可扩展的全栈应用,同时享受 PostgreSQL 的强大功能。本集将详细介绍如何使用 Vue 3 和 Supabase 构建全栈应用,包括认证、数据库、存储等核心功能的集成。
核心知识点
1. Supabase 核心服务
- PostgreSQL 数据库:完整的 PostgreSQL 数据库,支持 SQL 查询和关系型数据模型
- 认证:支持邮箱密码、OAuth(Google、GitHub、Facebook 等)、短信等多种认证方式
- 实时订阅:基于 PostgreSQL 复制协议的实时数据订阅
- 存储:用于存储和提供用户生成的内容(图片、视频等)
- 边缘函数:在全球边缘节点运行的无服务器函数
- API:自动生成的 RESTful API 和 GraphQL API
2. Vue 3 + Supabase 项目初始化
创建 Supabase 项目
- 访问 Supabase 控制台
- 点击 "New Project",输入项目名称
- 选择一个区域
- 设置数据库密码
- 完成项目创建
安装 Supabase SDK
# 安装 Supabase JavaScript SDK
yarn add @supabase/supabase-js配置 Supabase SDK
// src/supabase.js
import { createClient } from '@supabase/supabase-js'
// Supabase 配置
const supabaseUrl = 'YOUR_SUPABASE_URL'
const supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY'
// 创建 Supabase 客户端
export const supabase = createClient(supabaseUrl, supabaseAnonKey)3. 认证功能集成
邮箱密码认证
<template>
<div class="auth-container">
<h2>注册</h2>
<form @submit.prevent="register">
<div class="form-group">
<label for="email">邮箱</label>
<input type="email" id="email" v-model="email" required />
</div>
<div class="form-group">
<label for="password">密码</label>
<input type="password" id="password" v-model="password" required />
</div>
<button type="submit">注册</button>
</form>
<h2>登录</h2>
<form @submit.prevent="login">
<div class="form-group">
<label for="login-email">邮箱</label>
<input type="email" id="login-email" v-model="loginEmail" required />
</div>
<div class="form-group">
<label for="login-password">密码</label>
<input type="password" id="login-password" v-model="loginPassword" required />
</div>
<button type="submit">登录</button>
</form>
<div v-if="user" class="user-info">
<h3>当前用户</h3>
<p>{{ user.email }}</p>
<p>用户 ID: {{ user.id }}</p>
<button @click="logout">退出登录</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { supabase } from '../supabase'
const email = ref('')
const password = ref('')
const loginEmail = ref('')
const loginPassword = ref('')
const user = ref(null)
// 监听认证状态变化
onMounted(() => {
// 获取当前用户
const currentUser = supabase.auth.user()
if (currentUser) {
user.value = currentUser
}
// 监听认证状态变化
supabase.auth.onAuthStateChange((event, session) => {
user.value = session?.user || null
console.log('认证状态变化:', event, session)
})
})
// 注册新用户
const register = async () => {
try {
const { user: newUser, error } = await supabase.auth.signUp({
email: email.value,
password: password.value
})
if (error) {
throw error
}
user.value = newUser
console.log('注册成功:', user.value)
} catch (error) {
console.error('注册失败:', error.message)
}
}
// 用户登录
const login = async () => {
try {
const { user: loggedInUser, error } = await supabase.auth.signIn({
email: loginEmail.value,
password: loginPassword.value
})
if (error) {
throw error
}
user.value = loggedInUser
console.log('登录成功:', user.value)
} catch (error) {
console.error('登录失败:', error.message)
}
}
// 用户退出
const logout = async () => {
try {
const { error } = await supabase.auth.signOut()
if (error) {
throw error
}
user.value = null
console.log('退出成功')
} catch (error) {
console.error('退出失败:', error.message)
}
}
</script>OAuth 认证(Google)
// 在 Vue 组件中
const googleSignIn = async () => {
try {
const { user, error } = await supabase.auth.signIn({
provider: 'google'
})
if (error) {
throw error
}
console.log('Google 登录成功:', user)
} catch (error) {
console.error('Google 登录失败:', error.message)
}
}4. 数据库操作
创建表
在 Supabase 控制台的 SQL Editor 中执行以下 SQL:
-- 创建帖子表
CREATE TABLE posts (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
author_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- 创建评论表
CREATE TABLE comments (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
post_id UUID REFERENCES posts(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW()
);
-- 创建索引
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_comments_post_id ON comments(post_id);查询数据
<template>
<div class="posts-container">
<h2>帖子列表</h2>
<div v-for="post in posts" :key="post.id" class="post">
<h3>{{ post.title }}</h3>
<p>{{ post.content }}</p>
<p class="post-meta">作者 ID: {{ post.author_id }} | {{ post.created_at }}</p>
<div class="comments">
<h4>评论</h4>
<div v-for="comment in post.comments" :key="comment.id" class="comment">
<p>{{ comment.content }}</p>
<p class="comment-meta">作者 ID: {{ comment.author_id }} | {{ comment.created_at }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { supabase } from '../supabase'
const posts = ref([])
// 获取所有帖子和评论(使用 JOIN)
const fetchPostsWithComments = async () => {
try {
// 查询帖子
const { data: postsData, error: postsError } = await supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
if (postsError) {
throw postsError
}
// 为每个帖子查询评论
for (const post of postsData) {
const { data: commentsData, error: commentsError } = await supabase
.from('comments')
.select('*')
.eq('post_id', post.id)
.order('created_at', { ascending: true })
if (commentsError) {
throw commentsError
}
post.comments = commentsData
}
posts.value = postsData
} catch (error) {
console.error('获取帖子失败:', error.message)
}
}
onMounted(() => {
fetchPostsWithComments()
})
</script>插入数据
// 插入新帖子
const createPost = async (title, content) => {
try {
const { data, error } = await supabase
.from('posts')
.insert([
{
title,
content,
author_id: supabase.auth.user().id
}
])
.single()
if (error) {
throw error
}
console.log('帖子创建成功:', data)
return data
} catch (error) {
console.error('创建帖子失败:', error.message)
throw error
}
}
// 插入评论
const createComment = async (postId, content) => {
try {
const { data, error } = await supabase
.from('comments')
.insert([
{
post_id: postId,
content,
author_id: supabase.auth.user().id
}
])
.single()
if (error) {
throw error
}
console.log('评论创建成功:', data)
return data
} catch (error) {
console.error('创建评论失败:', error.message)
throw error
}
}更新和删除数据
// 更新帖子
const updatePost = async (postId, updates) => {
try {
const { data, error } = await supabase
.from('posts')
.update(updates)
.eq('id', postId)
.single()
if (error) {
throw error
}
console.log('帖子更新成功:', data)
return data
} catch (error) {
console.error('更新帖子失败:', error.message)
throw error
}
}
// 删除帖子
const deletePost = async (postId) => {
try {
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId)
if (error) {
throw error
}
console.log('帖子删除成功')
return true
} catch (error) {
console.error('删除帖子失败:', error.message)
throw error
}
}5. 实时数据订阅
订阅帖子变化
<template>
<div class="realtime-posts-container">
<h2>实时帖子列表</h2>
<div v-for="post in posts" :key="post.id" class="post">
<h3>{{ post.title }}</h3>
<p>{{ post.content }}</p>
<p class="post-meta">{{ post.created_at }}</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { supabase } from '../supabase'
const posts = ref([])
let subscription = null
// 订阅帖子变化
const subscribeToPosts = () => {
// 先获取现有数据
supabase
.from('posts')
.select('*')
.order('created_at', { ascending: false })
.then(({ data, error }) => {
if (error) {
console.error('获取帖子失败:', error.message)
return
}
posts.value = data
})
// 创建实时订阅
subscription = supabase
.from('posts')
.on('INSERT', (payload) => {
console.log('插入帖子:', payload)
// 将新帖子添加到列表开头
posts.value.unshift(payload.new)
})
.on('UPDATE', (payload) => {
console.log('更新帖子:', payload)
// 更新列表中的帖子
const index = posts.value.findIndex(post => post.id === payload.new.id)
if (index !== -1) {
posts.value[index] = payload.new
}
})
.on('DELETE', (payload) => {
console.log('删除帖子:', payload)
// 从列表中删除帖子
posts.value = posts.value.filter(post => post.id !== payload.old.id)
})
.subscribe()
}
onMounted(() => {
subscribeToPosts()
})
onUnmounted(() => {
if (subscription) {
// 取消订阅
supabase.removeSubscription(subscription)
}
})
</script>6. 存储功能集成
上传文件
<template>
<div class="storage-container">
<h2>文件上传</h2>
<input type="file" @change="handleFileChange" />
<button @click="uploadFile" :disabled="!file">上传</button>
<div v-if="uploading" class="uploading">上传中...</div>
<div v-if="fileUrl" class="file-url">
<h3>文件 URL:</h3>
<a :href="fileUrl" target="_blank">{{ fileUrl }}</a>
<img v-if="isImage" :src="fileUrl" alt="上传的图片" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { supabase } from '../supabase'
const file = ref(null)
const uploading = ref(false)
const fileUrl = ref('')
const isImage = ref(false)
// 处理文件选择
const handleFileChange = (e) => {
file.value = e.target.files[0]
isImage.value = file.value.type.startsWith('image/')
}
// 上传文件
const uploadFile = async () => {
if (!file.value) return
try {
uploading.value = true
const fileName = `${Date.now()}-${file.value.name}`
const filePath = `uploads/${fileName}`
// 上传文件
const { data, error } = await supabase.storage
.from('public') // 存储桶名称
.upload(filePath, file.value, {
cacheControl: '3600',
upsert: false
})
if (error) {
throw error
}
// 获取文件 URL
const { publicURL, error: urlError } = supabase.storage
.from('public')
.getPublicUrl(filePath)
if (urlError) {
throw urlError
}
fileUrl.value = publicURL
console.log('文件上传成功,URL:', publicURL)
} catch (error) {
console.error('上传文件失败:', error.message)
} finally {
uploading.value = false
}
}
</script>
<style scoped>
.uploading {
margin: 10px 0;
color: #42b883;
}
.file-url img {
max-width: 200px;
max-height: 200px;
margin: 10px 0;
border-radius: 8px;
}
</style>下载和删除文件
// 下载文件
const downloadFile = async (filePath) => {
try {
const { data, error } = await supabase.storage
.from('public')
.download(filePath)
if (error) {
throw error
}
// 创建下载链接
const url = URL.createObjectURL(data)
const a = document.createElement('a')
a.href = url
a.download = filePath.split('/').pop()
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
console.log('文件下载成功')
} catch (error) {
console.error('下载文件失败:', error.message)
}
}
// 删除文件
const deleteFile = async (filePath) => {
try {
const { error } = await supabase.storage
.from('public')
.remove([filePath])
if (error) {
throw error
}
console.log('文件删除成功')
return true
} catch (error) {
console.error('删除文件失败:', error.message)
return false
}
}7. 边缘函数
创建边缘函数
- 安装 Supabase CLI
npm install -g supabase- 初始化 Supabase 项目
supabase init- 创建边缘函数
supabase functions new hello-world- 编写边缘函数代码
// supabase/functions/hello-world/index.ts
export default async (req: Request) => {
const { name } = await req.json()
return new Response(JSON.stringify({ message: `Hello ${name}!` }), {
headers: { 'Content-Type': 'application/json' },
})
}- 部署边缘函数
supabase functions deploy hello-world- 在 Vue 中调用边缘函数
// 调用边缘函数
const callEdgeFunction = async () => {
try {
const { data, error } = await supabase.functions.invoke('hello-world', {
body: { name: 'World' }
})
if (error) {
throw error
}
console.log('边缘函数响应:', data)
return data
} catch (error) {
console.error('调用边缘函数失败:', error.message)
return null
}
}最佳实践
1. 认证最佳实践
- 使用多种认证方式:提供邮箱密码和 OAuth 等多种登录选项
- 保护敏感路由:使用导航守卫保护需要认证的路由
- 处理认证状态:正确处理用户登录、注销和会话过期
- 使用 RLS:利用 PostgreSQL 的行级安全性(RLS)保护数据
- 验证用户输入:对所有用户输入进行验证
2. 数据库最佳实践
- 合理设计数据模型:使用关系型数据库的优势,设计合理的表结构
- 使用索引:为常用查询创建索引,提高查询性能
- 使用 RLS:配置行级安全性策略,确保数据安全
- 优化查询:避免不必要的 JOIN 和复杂查询,使用适当的过滤条件
- 使用事务:在需要保证数据一致性的场景下使用事务
- 批量操作:减少网络请求,使用批量插入和更新
3. 实时订阅最佳实践
- 合理使用订阅:只在需要实时数据时使用订阅
- 限制订阅范围:使用过滤条件限制订阅的数据范围
- 取消订阅:在组件卸载时取消订阅,避免内存泄漏
- 处理初始数据:先获取初始数据,再建立订阅
- 优化订阅性能:避免订阅大量数据,使用分页或过滤
4. 存储最佳实践
- 组织文件结构:使用合理的文件路径结构
- 限制文件大小和类型:在客户端和服务器端进行验证
- 使用适当的存储桶权限:根据文件类型设置不同的存储桶权限
- 生成缩略图:为图片生成不同尺寸的缩略图
- 使用 CDN:利用 Supabase 存储的 CDN 功能加速文件访问
5. 性能优化
- 使用缓存:合理使用缓存策略减少数据库查询
- 优化查询:使用索引和合理的查询条件
- 减少网络请求:合并相关请求,使用批量操作
- 使用分页:对大量数据使用分页加载
- 优化实时订阅:限制订阅的数据范围
6. 安全最佳实践
- 使用 RLS:配置严格的行级安全性策略
- 验证所有输入:对所有客户端输入进行验证和 sanitize
- 使用参数化查询:避免 SQL 注入
- 定期更新依赖:保持 Supabase SDK 和依赖的最新版本
- 遵循最小权限原则:只授予必要的权限
常见问题与解决方案
1. 问题:认证状态丢失
解决方案:
- 确保在应用启动时初始化 Supabase 客户端
- 使用
supabase.auth.user()获取当前用户 - 监听
onAuthStateChange事件处理认证状态变化 - 检查浏览器的第三方 cookie 设置
2. 问题:查询返回空数据
解决方案:
- 检查表名和字段名是否正确
- 检查 RLS 策略是否允许查询
- 检查查询条件是否正确
- 查看 Supabase 控制台的日志
3. 问题:实时订阅不工作
解决方案:
- 检查 RLS 策略是否允许订阅
- 检查订阅的表名和事件类型是否正确
- 检查网络连接是否正常
- 查看浏览器控制台的错误信息
- 确保已正确初始化订阅并在组件卸载时取消订阅
4. 问题:文件上传失败
解决方案:
- 检查存储桶名称是否正确
- 检查存储桶权限是否允许上传
- 检查文件大小是否超过限制
- 查看浏览器控制台的错误信息
- 检查 Supabase 控制台的存储日志
5. 问题:RLS 策略不生效
解决方案:
- 确保已启用 RLS(在 Supabase 控制台的表设置中)
- 检查 RLS 策略是否正确编写
- 测试 RLS 策略(使用 Supabase 控制台的 SQL Editor)
- 检查用户是否已登录
6. 问题:边缘函数调用失败
解决方案:
- 检查函数名称是否正确
- 检查函数是否已部署
- 检查函数代码是否有错误
- 查看 Supabase 控制台的边缘函数日志
- 检查函数的访问权限
进一步学习资源
官方文档
学习资源
示例项目
社区资源
工具和扩展
课后练习
练习 1:创建基础 Supabase 应用
- 创建 Supabase 项目
- 配置 Supabase SDK
- 实现邮箱密码认证
- 实现 Google OAuth 认证
练习 2:实现数据库操作
- 创建帖子和评论表
- 实现帖子的增删改查
- 实现评论的增删改查
- 使用 JOIN 查询关联数据
练习 3:实现实时订阅
- 订阅帖子变化
- 订阅评论变化
- 测试实时数据更新
- 实现订阅的优化
练习 4:实现存储功能
- 实现文件上传功能
- 实现文件下载功能
- 实现文件删除功能
- 显示图片预览
练习 5:配置 RLS 策略
- 为帖子表配置 RLS 策略(只允许作者修改自己的帖子)
- 为评论表配置 RLS 策略
- 测试 RLS 策略的效果
- 优化 RLS 策略
练习 6:创建边缘函数
- 安装 Supabase CLI
- 创建和部署边缘函数
- 在 Vue 应用中调用边缘函数
- 测试边缘函数的性能
练习 7:构建完整应用
- 创建一个包含认证、数据库、实时订阅、存储的完整应用
- 实现用户资料管理
- 实现帖子和评论功能
- 实现文件上传和显示
- 测试应用的性能和安全性
通过以上练习,你将掌握 Vue 3 与 Supabase 集成的核心技能,能够构建安全、高性能、可扩展的全栈应用。