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 项目

  1. 访问 Supabase 控制台
  2. 点击 "New Project",输入项目名称
  3. 选择一个区域
  4. 设置数据库密码
  5. 完成项目创建

安装 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. 边缘函数

创建边缘函数

  1. 安装 Supabase CLI
npm install -g supabase
  1. 初始化 Supabase 项目
supabase init
  1. 创建边缘函数
supabase functions new hello-world
  1. 编写边缘函数代码
// 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' },
  })
}
  1. 部署边缘函数
supabase functions deploy hello-world
  1. 在 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. 官方文档

  2. 学习资源

  3. 示例项目

  4. 社区资源

  5. 工具和扩展

课后练习

练习 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 集成的核心技能,能够构建安全、高性能、可扩展的全栈应用。

« 上一篇 Vue 3与Firebase后端集成 - 构建全栈BaaS应用 下一篇 » Vue 3与Prisma ORM集成 - 类型安全全栈开发解决方案