跳到主要内容

Supabase 封装与实践

1. 架构:Service + Hooks + React Query

为了实现最高效的封装,我们采用三层架构:

  1. Service 层:纯函数,调用 Supabase SDK,负责处理分页、连表等逻辑。
  2. Hooks 层:基于 React Query (TanStack Query) 封装 Service,管理 Loading、缓存和错误。
  3. UI 层:直接调用自定义 Hooks,极致简洁。

2. 第 0 步:初始化客户端 (Client)

在封装之前,确保你有一个统一的客户端连接文件。

// lib/supabaseClient.ts (或 lib/supabase/index.ts)
import { createClient } from "@supabase/supabase-js";
import { Database } from "./types/database.types"; // 推荐生成类型

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);

3. 第一步:封装通用 API 方法 (Service - TS 版)

利用 TypeScript 泛型确保存访数据从表名到字段名都有全自动补全。

// lib/supabase/services.ts
import { supabase } from "../supabaseClient";
import type { Database } from "../types/database.types";

type Tables = Database["public"]["Tables"];

/**
* 基础 CRUD 封装
*/

// 1. 获取单条基础数据 (默认 select *)
export const getOne = async <T extends keyof Tables>(
tableName: T,
id: string | number
) => {
const { data, error } = await supabase
.from(tableName)
.select("*")
.eq("id", id)
.single();

if (error) throw error;
return data as Tables[T]["Row"];
};

// 2. 获取详情 (支持自定义连表 select 字符串)
export const getDetail = async <T extends keyof Tables, R = any>(
tableName: T,
id: string | number,
select = "*"
) => {
const { data, error } = await supabase
.from(tableName)
.select(select)
.eq("id", id)
.single();

if (error) throw error;
return data as R;
};

// 2. 插入数据
export const create = async <T extends keyof Tables>(
tableName: T,
payload: Tables[T]["Insert"]
) => {
const { data, error } = await supabase
.from(tableName)
.insert(payload)
.select()
.single();

if (error) throw error;
return data as Tables[T]["Row"];
};

// 3. 更新数据
export const update = async <T extends keyof Tables>(
tableName: T,
id: string | number,
payload: Tables[T]["Update"]
) => {
const { data, error } = await supabase
.from(tableName)
.update(payload)
.eq("id", id)
.select()
.single();

if (error) throw error;
return data as Tables[T]["Row"];
};

// 4. 删除数据
export const remove = async <T extends keyof Tables>(
tableName: T,
id: string | number
) => {
const { error } = await supabase.from(tableName).delete().eq("id", id);

if (error) throw error;
return true;
};

/**
* 高级查询:分页与连表
*/

// 分页列表查询
export const getPagedData = async <T extends keyof Tables>(
tableName: T,
{ page = 1, pageSize = 10, select = "*", orderField = "created_at" }
) => {
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;

const { data, count, error } = await supabase
.from(tableName)
.select(select, { count: "exact" })
.order(orderField as string, { ascending: false })
.range(from, to);

if (error) throw error;
return {
data: data as any[], // 连表查询结果较难用泛型完全覆盖,建议在调用处断言
total: count || 0,
};
};

4. 实战案例:如何使用这些封装 API?

以下展示了在一个管理后台或博客页面中,如何调用上述 Service 方法。

场景:管理文章 (CRUD + 分页)

import * as db from "@/lib/supabase/services";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; // 假设在 React 组件中使用

// 你的业务类型断言
interface Post {
id: string;
title: string;
content: string;
author_id: string;
status: "draft" | "published";
created_at: string;
author: {
id: string;
username: string;
avatar_url?: string;
};
comments?: Array<{
id: string;
content: string;
created_at: string;
user: { username: string };
}>;
}

// 1. 列表分页 (带作者连表)
const loadPosts = async (page: number) => {
const { data, total } = await db.getPagedData("posts", {
page,
pageSize: 10,
select: "*, author:profiles(username)",
});
return { posts: data as Post[], total };
};

// 2. 获取详情 (带作者和评论连表)
const loadPostDetail = async (id: string) => {
const data = await db.getDetail<"posts", Post>(
"posts",
id,
`
*,
author:profiles(id, username, avatar_url),
comments(
id, content, created_at,
user:profiles(username)
)
`
);
return data;
};

// 3. 创建新文章
const handleCreate = async () => {
const newPost = await db.create("posts", {
title: "新文章标题",
content: "内容...",
author_id: "user_uuid_example", // 替换为实际的用户ID
});
console.log("创建成功:", newPost.id);
return newPost;
};

// 4. 更新文章
const handleUpdate = async (id: string) => {
await db.update("posts", id, {
title: "修改后的标题",
status: "published",
});
console.log("更新成功:", id);
};

// 5. 删除文章
const handleDelete = async (id: string) => {
if (confirm("确认删除?")) {
await db.remove("posts", id);
console.log("删除成功:", id);
}
};

统一的 Query 调用

// hooks/usePosts.ts
export function usePosts(page: number) {
return useQuery({
queryKey: ["posts", page],
queryFn: () => loadPosts(page),
keepPreviousData: true, // 翻页时保持旧数据,体验更好
});
}

// hooks/usePostDetail.ts
export function usePostDetail(postId: string) {
return useQuery({
queryKey: ["post", postId],
queryFn: () => loadPostDetail(postId),
enabled: !!postId, // 只有当 postId 存在时才执行查询
});
}

统一的 Mutation 调用

// hooks/usePostActions.ts
export function usePostActions() {
const queryClient = useQueryClient();

const createMutation = useMutation({
mutationFn: (newPostPayload: {
title: string;
content: string;
author_id: string;
}) => handleCreate(), // 实际项目中会传入 payload
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }),
});

const updateMutation = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: Partial<Post> }) =>
handleUpdate(id), // 实际项目中会传入 payload
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
queryClient.invalidateQueries({ queryKey: ["post", variables.id] }); // 刷新详情页
},
});

const deleteMutation = useMutation({
mutationFn: (id: string) => handleDelete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }),
});

return { createMutation, updateMutation, deleteMutation };
}

5. UI 层的最终呈现 (极简调用)

// 假设这是在一个 React 组件中
import { usePosts, usePostDetail, usePostActions } from "./hooks/usePosts"; // 导入你的 hooks

function PostListPage() {
const [page, setPage] = useState(1);
const { data, isLoading, isError, error } = usePosts(page);
const { createMutation } = usePostActions();

if (isLoading) return <p>加载中...</p>;
if (isError) return <p>错误: {error?.message}</p>;

return (
<div>
<h1>文章列表</h1>
{data?.posts.map((post) => (
<PostItem key={post.id} post={post} />
))}
<button onClick={() => setPage((p) => p + 1)}>下一页</button>
<button
onClick={() =>
createMutation.mutate({
title: "新帖",
content: "内容",
author_id: "user_uuid_example",
})
}
>
快速发帖
</button>
</div>
);
}

function PostItem({ post }: { post: Post }) {
const { deleteMutation, updateMutation } = usePostActions();
const [isEditing, setIsEditing] = useState(false);
const [newTitle, setNewTitle] = useState(post.title);

const handleSave = () => {
updateMutation.mutate({ id: post.id, payload: { title: newTitle } });
setIsEditing(false);
};

return (
<li>
{isEditing ? (
<input value={newTitle} onChange={(e) => setNewTitle(e.target.value)} />
) : (
<span>
{post.title} - 作者: {post.author.username}
</span>
)}

<button onClick={() => setIsEditing(!isEditing)}>
{isEditing ? "取消" : "编辑"}
</button>
{isEditing && <button onClick={handleSave}>保存</button>}

<button
onClick={() => deleteMutation.mutate(post.id)}
disabled={deleteMutation.isLoading}
>
{deleteMutation.isLoading ? "删除中..." : "删除"}
</button>
</li>
);
}

function PostDetailPage({ postId }: { postId: string }) {
const { data: post, isLoading, isError, error } = usePostDetail(postId);

if (isLoading) return <p>加载文章详情...</p>;
if (isError) return <p>错误: {error?.message}</p>;
if (!post) return <p>文章未找到。</p>;

return (
<div>
<h1>{post.title}</h1>
<p>作者: {post.author.username}</p>
<p>{post.content}</p>
<h2>评论</h2>
{post.comments?.map((comment) => (
<div key={comment.id}>
<p>
{comment.content} - {comment.user.username}
</p>
</div>
))}
</div>
);
}

6. 核心建议

  1. Select 语法:尽量按需选取字段,特别是连表时通过 author:profiles(...) 这种别名语法让返回数据结构更扁平。
  2. 错误边界:React Query 的 onError 可以结合全局 Toast 组件实现统一的消息提醒。
  3. Realtime 结合:如果需要实时同步,可以在 useEffect 中监听 Supabase 变更,然后手动调用 queryClient.invalidateQueries 触发 React Query 重新抓取,这是最稳妥的实时方案。
  4. 预加载 (Prefetching):在列表页悬停或翻页前,利用 queryClient.prefetchQuery 提前加载下一页数据,实现“秒开”体验。

参考视频/资源: