Vue 3 + Supabase + TypeScript 完整开发实践标准

Vue 3 + Supabase + TypeScript 完整开发实践标准

1. 核心理念:关注点分离 (Separation of Concerns)

直接在 Vue 组件中执行数据库查询会将视图逻辑 (UI长什么样,如何响应用户交互)和数据逻辑(数据从哪里来,如何增删改查)耦合在一起。这在项目初期可能很方便,但随着应用的增长,会导致以下问题:

  • 难以维护:同一个查询可能散落在多个组件中。如果数据库表结构变更,你需要修改所有地方。
  • 难以复用:组件间的逻辑复用变得困难。
  • 难以测试:测试组件时,你不得不处理真实的数据库请求,而不是模拟(mock)数据,这让单元测试变得复杂。
  • 类型安全问题:虽然 Supabase 客户端本身支持泛型,但在每个组件中手动指定类型容易出错且繁琐。

因此,我们的核心实践是创建一个数据服务层(Data Service Layer) ,将所有与 Supabase 的交互都封装在这一层中,实现清晰的分层架构。

2. 推荐的项目结构

一个清晰的结构能让你的项目更易于理解和扩展。

bash 复制代码
src/
├── lib/
│   └── supabaseClient.ts    # 2. 初始化并导出唯一的 Supabase 客户端实例
├── types/
│   └── supabase.ts          # 1. 存放由 Supabase CLI 生成的数据库类型
├── services/
│   └── postService.ts       # 3. 针对特定数据表(如 posts)的 CRUD 服务
│   └── authService.ts       # (可选) 负责所有认证相关的服务
├── composables/
│   └── usePosts.ts          # 4. Vue Composable,连接服务和视图,管理状态
└── components/
    └── PostList.vue         # 5. Vue 组件,只负责UI和调用 Composable

2.1 Service 与 Composable 的职责分工

一个常见的问题是:"为什么每个表都需要一个 Service 文件和一个 Composable 文件?合并成一个不是更简单吗?"

答案是:将它们分开是遵循"关注点分离"原则的最佳实践。 这两者的职责有着本质的不同:

services/postService.ts 的职责:数据访问层
  • 唯一职责 :只负责和**数据源(Supabase)**直接通信。它知道如何对 posts 表进行增、删、改、查。
  • 与框架无关 :此文件是纯粹的 TypeScript/JavaScript,不依赖 Vue 的任何特性(如 ref, onMounted)。理论上,你可以把它直接拿到任何 JavaScript 项目中使用。
  • 高复用性 :应用中任何需要操作 posts 数据的地方(如 Pinia store、其他 composables)都可以调用 postService
  • 易于测试 :可以非常简单地对这个文件进行单元测试,只需模拟(mock)supabase 客户端,而不需要启动整个 Vue 应用。

可以把它想象成一个"数据仓库管理员",他只负责从仓库货架上取货和放货,不关心店面如何展示。

composables/usePosts.ts 的职责:状态管理/视图逻辑层
  • 唯一职责 :作为数据Vue 组件 之间的桥梁,并管理与 UI 相关的响应式状态
  • 依赖 Vue :它深度使用 Vue 的响应式系统(ref, reactive)来管理 loading(加载中)、error(错误信息)、posts(数据列表)等状态。
  • 处理 UI 逻辑 :例如,在成功创建一篇新文章后,它会立即将新文章 unshift 到响应式的 posts 数组中,以便 UI 能够立刻更新,这是纯粹的视图交互逻辑。
  • 为组件服务:它将所有复杂的异步操作、状态管理都封装好,然后给组件提供一个非常干净、易于使用的接口(API)。

可以把它想象成一个"店面经理",他从"仓库管理员"那里拿到货,然后决定如何把商品摆上货架、挂上"正在补货"的牌子,并处理顾客的交互。

虽然初期会多创建一个文件,但这种分层架构带来的长期好处(清晰、可维护、可测试)远远超过了这点"麻烦"。

3. 各部分详解与代码实现

下面我们将逐步讲解每个部分的作用,并提供完整的代码示例。

第一步: types/supabase.ts - 自动化类型定义

这是实现端到端类型安全的第一步。我们使用 Supabase CLI 从数据库 schema 自动生成 TypeScript 类型定义。

针对 Supabase 官方云平台

在你的项目终端中运行:

bash 复制代码
npx supabase gen types typescript --project-id "你的项目ID" > src/types/supabase.ts
针对自托管 (Self-Hosted) 或本地开发

如果您是自托管 Supabase 或在本地使用 supabase start 进行开发,您需要用 --db-url 参数替换 --project-id 来直接连接到您的数据库。

bash 复制代码
npx supabase gen types typescript --db-url "YOUR_DATABASE_URL" > src/types/supabase.ts

YOUR_DATABASE_URL 是一个标准的 PostgreSQL 连接字符串,格式如下: postgresql://<user>:<password>@<host>:<port>/<database_name>

例如,对于本地开发环境,命令通常是:

bash 复制代码
npx supabase gen types typescript --db-url "postgresql://postgres:postgres@localhost:54322/postgres" > src/types/supabase.ts

重要提示 :每当您修改了数据库的表结构(例如,添加/删除字段、创建新表或视图),都必须重新运行一次此命令,以确保您的 TypeScript 类型与数据库保持同步。

这条命令会生成一个 supabase.ts 文件,其中包含了所有表、列、函数和视图的 TypeScript 接口。

代码示例 (src/types/supabase.ts)

yaml 复制代码
// 这个文件由 `supabase gen types` 命令自动生成
export type Json =
  | string
  | number
  | boolean
  | null
  | { [key: string]: Json | undefined }
  | Json[]

export interface Database {
  public: {
    Tables: {
      posts: {
        Row: { // 用于 "select" 查询返回的数据类型
          id: number
          title: string
          content: string
          created_at: string
          author_id: string // 假设有一个外键关联到 profiles 表
        }
        Insert: { // 用于插入新行的数据类型
          id?: number
          title: string
          content: string
          created_at?: string
          author_id: string
        }
        Update: { // 用于更新行的数据类型
          id?: number
          title?: string
          content?: string
          created_at?: string
          author_id?: string
        }
      }
      profiles: { // 假设我们有一个 profiles 表
        Row: {
          id: string
          username: string
          avatar_url: string | null
        }
        Insert: {
          id: string
          username: string
          avatar_url?: string | null
        }
        Update: {
          id?: string
          username?: string
          avatar_url?: string | null
        }
      }
    }
    Views: {
      // ... 你的数据库视图类型
    }
    Functions: {
      // ... 你的数据库函数类型
    }
  }
}

第二步: lib/supabaseClient.ts - 客户端单例

这是整个应用与 Supabase 通信的唯一入口。在这里初始化客户端可以确保你不会意外地创建多个实例,并且应用了上一步生成的类型。

代码示例 (src/lib/supabaseClient.ts)

typescript 复制代码
import { createClient } from '@supabase/supabase-js'
import type { Database } from '@/types/supabase' // 导入上一步生成的类型

const supabaseUrl = 'YOUR_SUPABASE_URL'; // 替换为你的 Supabase URL
const supabaseAnonKey = 'YOUR_SUPABASE_ANON_KEY'; // 替换为你的 Supabase Key

// 使用生成的 Database 类型来创建客户端,以获得端到端的类型安全
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);

第三步: services/postService.ts - 数据服务层

这是架构的核心。服务层封装了所有对 posts 表的数据库操作,并集中处理错误。

代码示例 (src/services/postService.ts)

typescript 复制代码
import { supabase } from '@/lib/supabaseClient';
import type { Database } from '@/types/supabase';

// 从生成的类型中提取 Post 类型,方便在整个服务中使用
type Post = Database['public']['Tables']['posts']['Row'];
type PostInsert = Database['public']['Tables']['posts']['Insert'];

export const postService = {
  /**
   * 获取所有文章
   */
  async getAllPosts() {
    // query() 是类型安全的,Supabase 知道 'posts' 表的列和类型
    const { data, error } = await supabase
      .from('posts')
      .select('*')
      .order('created_at', { ascending: false });

    if (error) {
      console.error('Error fetching posts:', error);
    }
    return { data, error };
  },

  /**
   * 创建一篇新文章
   * @param postData - { title: string, content: string, author_id: string }
   */
  async createPost(postData: PostInsert) {
    const { data, error } = await supabase
      .from('posts')
      .insert(postData)
      .select()
      .single(); // .single() 确保我们返回的是单个对象而不是数组

    if (error) {
      console.error('Error creating post:', error);
    }
    return { data, error };
  },
};

3.1 多表联合查询的最佳实践

当需要查询关联数据时(例如,获取文章及其作者信息),我们有两种推荐的方法。

方法一:使用 .select() 进行资源嵌入 (Resource Embedding)

这是最常用、最直接的方法。前提是在 Supabase 数据库中已经设置好了表之间的外键关系。

Supabase 的 PostgREST API 允许你在 select 查询中,通过外键直接抓取关联表的数据。

第 1 步: 更新 services/postService.ts

我们添加一个新方法来获取带有作者信息的文章。

typescript 复制代码
// 在 postService.ts 中添加
import { supabase } from '@/lib/supabaseClient';
import type { Database } from '@/types/supabase';

// ... 已有的 Post, PostInsert 类型 ...

// 为联合查询定义一个新类型,这将帮助 Composable 和组件获得类型提示
export type PostWithAuthor = Post & {
  profiles: Database['public']['Tables']['profiles']['Row'] | null;
};

export const postService = {
  // ... 已有的 getAllPosts, createPost 方法 ...

  /**
   * 获取所有文章,并带上作者信息
   */
  async getPostsWithAuthors() {
    // SELECT *, profiles(*) 会抓取 posts 表的所有列
    // 以及其关联的 profiles 表的所有列
    const { data, error } = await supabase
      .from('posts')
      .select('*, profiles(*)') 
      .order('created_at', { ascending: false });

    if (error) {
      console.error('Error fetching posts with authors:', error);
    }
    // 注意:这里的 data 类型会被 TS 自动推断为 PostWithAuthor[]
    return { data, error };
  }
};

第 2 步: 在 composables/usePosts.ts 中使用新方法

ini 复制代码
import { postService, type PostWithAuthor } from '@/services/postService';
// ...
export function usePosts() {
  const posts = ref<PostWithAuthor[]>([]); // 使用联合查询的新类型
  const loading = ref(true);
  const error = ref<any>(null);

  const fetchPosts = async () => {
    loading.value = true;
    error.value = null;
    try {
      // 调用新的 service 方法
      const result = await postService.getPostsWithAuthors(); 
      if (result.error) throw result.error;
      if (result.data) {
        posts.value = result.data;
      }
    } catch (e) {
      error.value = e;
    } finally {
      loading.value = false;
    }
  };
  
  // ... 其他方法 ...

  onMounted(fetchPosts);

  return { posts, loading, error, /* ... */ };
}

第 3 步: 在组件中使用

现在组件可以轻松地访问关联数据。

xml 复制代码
<!-- 在 PostList.vue 中 -->
<li v-for="post in posts" :key="post.id">
  <h2>{{ post.title }}</h2>
  <!-- 直接访问 profiles 表的 username -->
  <p>作者:{{ post.profiles?.username || '匿名用户' }}</p> 
  <p>{{ post.content }}</p>
</li>
方法二:使用数据库视图 (Database View)

当查询逻辑非常复杂(例如,多重 JOIN、聚合函数如 COUNT, SUM 等),或者你想把这些复杂性从前端代码中完全移除时,数据库视图是最佳选择。

第 1 步: 在 Supabase SQL 编辑器中创建视图

sql 复制代码
-- 这个视图会连接 posts 和 profiles,并计算每篇文章的评论数(假设有 comments 表)
CREATE VIEW posts_with_details AS
SELECT
  p.*,
  pr.username,
  pr.avatar_url,
  (SELECT count(*) FROM comments c WHERE c.post_id = p.id) as comments_count
FROM
  posts p
LEFT JOIN
  profiles pr ON p.author_id = pr.id;

第 2 步: 重新生成类型

运行 npx supabase gen types ... 后,新创建的视图会自动出现在 types/supabase.ts 中。

typescript 复制代码
// types/supabase.ts 中会自动出现
// ...
    Views: {
      posts_with_details: {
        Row: { // 视图返回的数据类型
          id: number | null
          title: string | null
          // ... posts 表的其他列
          username: string | null
          avatar_url: string | null
          comments_count: number | null
        }
      }
    }
// ...

第 3 步: 在 Service 中查询视图

查询视图和查询普通表完全一样,非常简单。

csharp 复制代码
// 在 postService.ts 中添加
async getPostsFromView() {
  const { data, error } = await supabase
    .from('posts_with_details') // 直接查询视图名
    .select('*');
  
  if (error) {
    console.error('Error fetching from view:', error);
  }
  return { data, error };
}

结论

  • 对于标准的外键关联 ,优先使用方法一 (.select()) ,它非常直观且高效。
  • 对于复杂的、跨多个表的聚合查询 ,或者为了给前端提供一个极其简化的数据接口 ,强烈推荐使用方法二(数据库视图)

第四步: composables/usePosts.ts - 状态管理层

Composable 是连接数据服务UI组件 的桥梁。它负责调用服务、管理响应式状态(如 loading, error),并将这些状态和方法暴露给组件。

代码示例 (src/composables/usePosts.ts)

typescript 复制代码
import { ref, onMounted } from 'vue';
import { postService, type PostWithAuthor } from '@/services/postService';
import type { Database } from '@/types/supabase';

// 我们将使用上面定义的 PostWithAuthor 类型
export function usePosts() {
  const posts = ref<PostWithAuthor[]>([]);
  const loading = ref(true);
  const error = ref<any>(null);

  const fetchPosts = async () => {
    loading.value = true;
    error.value = null;
    try {
      // 调用获取关联数据的方法
      const result = await postService.getPostsWithAuthors();
      if (result.error) throw result.error;
      if (result.data) {
        posts.value = result.data;
      }
    } catch (e) {
      error.value = e;
    } finally {
      loading.value = false;
    }
  };
  
  const addPost = async (title: string, content: string, author_id: string) => {
    // 注意:创建文章后,返回的数据可能不包含完整的 author profile
    // 为了UI一致性,你可能需要再次调用 fetchPosts() 或手动构造 PostWithAuthor 对象
    const result = await postService.createPost({ title, content, author_id });
    if (result.data) {
        // 简单的做法是重新拉取整个列表以保证数据一致性
        await fetchPosts();
    }
    return result;
  };

  // 在 Composable 加载时自动获取数据
  onMounted(fetchPosts);

  // 将状态和方法暴露给组件
  return {
    posts,
    loading,
    error,
    fetchPosts, // 也可暴露刷新方法
    addPost,
  };
}

第五步: components/PostList.vue - 视图层

在遵循这种模式后,组件会变得极其简洁。它只负责调用 Composable 并根据其返回的状态渲染 UI,不包含任何数据获取的逻辑。

代码示例 (src/components/PostList.vue)

xml 复制代码
<script setup lang="ts">
import { usePosts } from '@/composables/usePosts';
import { ref } from 'vue';

// 组件逻辑非常干净:只调用 composable
const { posts, loading, error, addPost } = usePosts();

const newTitle = ref('');
const newContent = ref('');
// 假设 author_id 可以从用户状态中获取
const authorId = '...'; 

async function handleAddPost() {
  if (!newTitle.value || !newContent.value) return;
  await addPost(newTitle.value, newContent.value, authorId);
  newTitle.value = '';
  newContent.value = '';
}
</script>

<template>
  <div>
    <h1>文章列表</h1>
    
    <!-- 添加新文章的表单 -->
    <form @submit.prevent="handleAddPost">
        <input v-model="newTitle" placeholder="文章标题" />
        <textarea v-model="newContent" placeholder="文章内容"></textarea>
        <button type="submit">发布</button>
    </form>

    <!-- 根据状态显示不同UI -->
    <div v-if="loading">正在加载中...</div>
    <div v-else-if="error">加载失败:{{ error.message }}</div>
    <ul v-else-if="posts.length > 0">
      <li v-for="post in posts" :key="post.id">
        <h2>{{ post.title }}</h2>
        <p v-if="post.profiles">作者:{{ post.profiles.username }}</p>
        <p>{{ post.content }}</p>
      </li>
    </ul>
    <div v-else>还没有文章哦。</div>
  </div>
</template>

4. 总结

这个分层结构(Component -> Composable -> Service -> Supabase)是构建健壮、可扩展、易于维护的现代 Web 应用的黄金标准。

  • 组件 (Component) : 只关心 UI 渲染和用户交互。
  • 组合式函数 (Composable) : 负责管理与特定功能相关的响应式状态。
  • 服务 (Service) : 负责与外部(如 Supabase)通信,处理数据获取和错误。
  • 类型 (Types) : 提供端到端的类型安全保障。

遵循此规范,你的团队将能够高效协作,构建出高质量的 Vue.js 应用程序。

相关推荐
知其然亦知其所以然2 小时前
MySQL 社招必考题:如何优化 UNION 查询?
后端·mysql·面试
vker3 小时前
第 4 天:建造者模式(Builder Pattern)—— 创建型模式
java·后端·设计模式
一直_在路上3 小时前
Go语言在医疗IT中的MySQL高可用集群架构实践:从选型到百万QPS
后端
我不是混子3 小时前
MySQL中如何查看数据库容量大小、表容量大小、索引容量大小?
后端·mysql
似水流年流不尽思念3 小时前
Redis 如何配置 Key 的过期时间?它的实现原理?
后端
yunxi_053 小时前
RAG 项目中的向量化实战:让模型精准检索上传文档
后端·ai编程
程序员小富3 小时前
字节二面挂!面试官: Redis 内存淘汰策略 LRU 和传统 LRU 差异,我答懵了
后端
万粉变现经纪人3 小时前
如何解决 pip install 安装报错 ModuleNotFoundError: No module named ‘django’ 问题
ide·后端·python·django·beautifulsoup·pandas·pip
勇哥java实战分享3 小时前
聊聊五种 Redis 部署模式
后端