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 应用程序。