首页 / 资讯中心 / 文章详情

Supabase实战:用后端即服务快速搭建任务看板,从建表到实时订阅的完整指南

Supabase实战:用后端即服务快速搭建任务看板,从建表到实时订阅的完整指南 ★ FEATURED ARTICLE
“后端不用写”这种事我以前是不信的。直到我用 Supabase 把一个小型内部工具的后端只用了半天就搭完才意识到这套“后端即服务”的路子对独立开发者和前端团队来说确实能省下大量造轮子的时间。Supabase 这个名字这两年讨论度很高简单说它是个开源版的 Firebase 替代品但底层不是文档数据库而是直接给你一个完整的 PostgreSQL还顺手把数据库的实时订阅、用户认证、文件存储、边缘函数都集成好了。这篇文章我会以一个实际演示项目为主线从建表、CRUD、实时订阅、用户登录到文件上传一步步带你跑通整个流程同时把 RLS 这一层权限控制的坑也拆开讲清楚。如果你正打算给 Next.js、Vue 或者小程序做后端或者想找个自部署方案的参考这篇内容应该能帮你少走几条弯路。1. 内容整体设计与思路拆解1.1 为什么我选 Supabase 而不是自己写后端我先说下这个演示项目的背景我要做一个团队共享的“任务看板”成员可以登录、创建任务、修改任务状态还能上传附件。传统做法是写 Node.js/Go 服务提供 REST API再接一个 MySQL/PG 数据库同时要管用户密码加密、Token 生成、权限校验一套下来少说两三周。Supabase 的做法是把数据库、认证、实时推送这些底座能力直接以 SDK 形式给出来前端拿到的是一堆“让代码自动运行”的服务而不是要自己维护的服务器进程。这里有个关键认知Supabase 不是一个“低代码平台”它不限制你写高级逻辑。它给你的是一套托管好的 Postgres 实例外加官方封装好的客户端 SDK。Postgres 本身是当今功能最强大的开源数据库所以 Supabase 的起点非常高——你能用 SQL 写存储过程、触发器、视图也可以用 REST API 直接操作表还能通过 Realtime实时功能监听表变化。对我来说选它最大的动力是团队里前端技术栈是 React但我不想再维护一套后端 CI/CD、API 网关这类基础设施Supabase 把基础设施的部分接管了我只需要关注业务数据表和权限规则。1.2 核心功能一览及适用场景Supabase 的功能模块可以分成四大部分数据库Database基于 PostgreSQL提供了可视化表格界面、SQL 编辑器、自动生成 REST API还支持 Row Level Security 行级安全策略。身份认证Auth支持邮箱密码、手机验证码、邮箱魔法链接以及 Google、GitHub、微信等 OAuth 登录管理用户会话和 Token 刷新。实时能力Realtime通过 WebSocket 订阅数据库表的 INSERT / UPDATE / DELETE 事件也能订阅 Postgres Changes。非常适合聊天、协作编辑、实时看板这类场景。存储Storage基于 S3 协议的对象存储可以上传图片、文档、音视频并提供带权限约束的临时访问签名 URL。边缘函数Edge Functions基于 Deno 的 serverless 函数可以写一些需要跑在服务端的逻辑比如处理 Webhook、调用第三方 API。适合它的场景很明确内部工具、MVP 产品、原型演示、中小型 SaaS 的起步阶段。如果你的项目涉及非常复杂的事务逻辑、大量二进制流处理或者已经在用一套成熟的微服务架构Supabase 未必是唯一解但做一个“能上线、可迭代”的应用它绝对够用。1.3 和 Firebase 的横向对比很多人拿 Supabase 和 Firebase 比较我两个都用过说下个人感受。Firebase 的 Firestore 是 NoSQL 文档数据库上手快但数据结构一旦复杂多表关联就非常难受而 Supabase 直接是关系型数据库有外键、有事务天然适合业务逻辑成体系的项目。另外 Supabase 是开源项目意味着你能自己部署数据都在你的服务器上这对很多公司来说是很重要的考量点。但 Supabase 在国内的默认访问速度不一定理想而且新手起步时会觉得它“太像数据库”了——需要懂 SQL、懂表关系、懂权限规则不像 Firebase 的规则那样常见的 allow read/write 比较简单。我的态度是如果你本身熟悉 SQL选 Supabase 会如鱼得水如果你完全没有后端概念Firebase 可能更轻松但深水区还是需要知识积累。2. 核心细节解析与实操要点2.1 项目创建与连接参数演示的第一步去 supabase.com 注册一个账号创建一个新项目。需要填项目名称、数据库密码和 Region。这里注意Region 一定要选离你用户最近的区域但国内访问没有特别近的节点可以选 Singapore 这类网络相对稳定的位置。创建完成后进入项目 Dashboard左侧菜单有 Table Editor、SQL Editor、Auth、Storage、Edge Functions对应不同模块。项目创建好后你在Project Settings → API Keys里能找到两个关键信息URLhttp://xxxx.supabase.co和 anon key匿名公钥。anon key 会在客户端的createClient初始化时用到别看它叫“匿名”它内部包含 JWT可以在不登录状态下访问数据库的公开数据——真正限制你要不要暴露数据靠的是 RLS 策略。import { createClient } from supabase/supabase-js const supabase createClient( https://your-project.supabase.co, your-anon-key )提示anon key 是公开的所以千万不要用它来当“私密钥匙”。任何权限过滤都要依赖数据库层面的 RLS 策略来完成不要依赖 client 端隐藏。2.2 建表前的数据建模思路既然底层是 Postgres就按照关系型数据库的习惯来建模。任务看板的核心表是tasks和profiles。profiles用来扩展auth.users里的用户资料比如昵称、头像等。任务表字段可以包括id、title、description、status、assignee_id、creator_id、created_at、updated_at、due_date、attachments。这里有一个新手常犯的错误直接把用户邮箱作为外键存放在业务表里。邮箱这是可变的而且auth.users里的 email 字段并不适合直接关联。正确做法是使用auth.uid()获取当前登录用户的 UUID 作为关联。所以assignee_id和creator_id都应该是 UUID 类型并参考auth.users (id)外键。在 Supabase 的表编辑器里可以直接新建表也可以用 SQL。我更推荐把建表语句保存在 SQL 文件里方便在另一个项目里复现。先打开SQL Editor执行以下脚本-- 创建个人资料表关联 auth.users create table if not exists public.profiles ( id uuid references auth.users (id) on delete cascade primary key, display_name text, avatar_url text, created_at timestamptz default now() ); alter table public.profiles enable row level security; -- 任务表 create table if not exists public.tasks ( id uuid primary key default gen_random_uuid(), title text not null, description text, status text not null default todo check (status in (todo, in_progress, done)), assignee_id uuid references public.profiles (id), creator_id uuid references public.profiles (id), due_date date, created_at timestamptz default now(), updated_at timestamptz default now() ); alter table public.tasks enable row level security; -- 自动更新 updated_at create or replace function public.handle_updated_at() returns trigger language plpgsql as $$ begin new.updated_at now(); return new; end; $$; create trigger tasks_set_updated_at before update on public.tasks for each row execute function public.handle_updated_at(); -- 为新用户创建 profile 的触发器 create or replace function public.handle_new_user() returns trigger language plpgsql security definer set search_path public as $$ begin insert into public.profiles (id, display_name, avatar_url) values (new.id, new.raw_user_meta_data-display_name, new.raw_user_meta_data-avatar_url); return new; end; $$; create trigger on_auth_user_created after insert on auth.users for each row execute procedure public.handle_new_user();这段 SQL 里包含了两个触发器一个是自动更新任务的修改时间另一个是在新用户注册后自动往profiles表插一条记录。这一步非常重要否则你会发现用户登录后用户信息表是空的——前端还得手动补插容易出错。2.3 RLS 策略的必要性建表语句里我特别加了enable row level security。很多人会问Supabase 不是自带 API 吗为什么还要做这么一层因为为了让客户端直接使用 anon key 访问数据库Supabase 的 REST API 是“直通”行级数据的如果没有 RLS任何拿到 anon key 的人都能读取甚至修改所有表的数据。这是致命的。RLS 可以理解为 Postgres 在查询执行前加了一道“条件过滤”。比如任务列表的 RLS我希望登录用户只能看到“自己是创建者或指派对象”的任务那就得写这样的策略create policy 用户可以查看与自己相关的任务 on public.tasks for select using ( auth.uid() creator_id or auth.uid() assignee_id );同样写操作也要定义策略。例如只有创建者能更新任务以及只有创建者或管理员能删除任务。这里的“管理员”我们可以用 profiles 表的一个role字段来定义但为了演示我一直保持简单——“创建者或执行者都可更新”。要注意的策略语法中auth.uid()返回当前用户 UUID如果用户未登录这个函数会返回 NULL策略就会自然失效也保证了数据安全。2.4 安装 SDK 与环境变量演示项目我直接用 Vite React。在项目根目录安装官方包npm install supabase/supabase-js然后建议把 URL 和 anon key 放到.env.local文件中避免把密钥硬编码到源码里VITE_SUPABASE_URLhttps://your-project.supabase.co VITE_SUPABASE_ANON_KEYyour-anon-key注意Vite 项目读取环境变量需要以VITE_前缀开头。然后在src/lib/supabase.js中初始化客户端。import { createClient } from supabase/supabase-js const supabaseUrl import.meta.env.VITE_SUPABASE_URL const supabaseAnonKey import.meta.env.VITE_SUPABASE_ANON_KEY export const supabase createClient(supabaseUrl, supabaseAnonKey)这里要提醒一个细节supabase-js默认会建议使用autoRefreshToken它会根据 JWT 过期时间自动刷新。开发模式下如果浏览器缓存了旧的本地状态可能出现登录失效半天不清醒的情况保留默认设置基本没问题但如果你做的是 React Native 或小程序务必看一下文档里关于 AsyncStorage 的接入配置。3. 实操过程与核心环节实现3.1 基础 CRUD 操作演示咱们先在 UI 里做最基础的增删改查。先看查询任务列表不仅要把 tasks 表查出来还要把 assignee 和 creator 的 profile 信息一次性 join 出来。Supabase 的查询语法挺直观const { data, error } await supabase .from(tasks) .select(*, assignee:assignee_id(display_name, avatar_url), creator:creator_id(display_name, avatar_url)) .order(created_at, { ascending: false }); if (error) console.error(error);这里用了别名语法assignee:assignee_id(...)表示把assignee_id外键关联到 profiles 表并选择display_name和avatar_url字段。返回结果里会多出assignee和creator两个对象方便前端渲染人名和头像。新增任务的时候注意要把creator_id设置为当前登录用户的 ID通过supabase.auth.getUser()来获取const { data: userData } await supabase.auth.getUser(); const userId userData.user.id; const { data, error } await supabase .from(tasks) .insert([ { title: 开发登录页面, description: 使用 Supabase Auth, status: todo, creator_id: userId, assignee_id: userId } ]) .select();钩子点insert后加.select()这样才能返回插入后的完整行数据包括默认生成的 id 和 created_at很多新手会忽略这个导致刚刚插入后无法拿 ID 做下一步操作。更新和删除也很简单// 更新状态 const { error } await supabase .from(tasks) .update({ status: in_progress }) .eq(id, taskId); // 删除 const { error } await supabase .from(tasks) .delete() .eq(id, taskId);如果操作报 403 或 42501大概率是 RLS 策略没写好。做更新操作时会触发using和with check两个条件简单理解是using是“能否操作原有数据”with check是“插入/更新后的新值能否满足条件”。两个条件都要过。3.2 实时订阅实现看板自动刷新实时功能是一个亮点。在任务看板中多人同时操作页面上如果手动刷新很蠢。用 Supabase 的channel来订阅任务表的变化const channel supabase .channel(public:tasks) .on(postgres_changes, { event: *, schema: public, table: tasks }, (payload) { console.log(变化: , payload); // 根据 payload.new 或 payload.old 更新本地状态 }) .subscribe();这样只要任何客户端对 tasks 表做了 INSERT、UPDATE、DELETE都会实时推送到订阅者。需要恢复旧事件时在 useEffect 里useEffect(() { const channel supabase .channel(schema-db-changes) .on(postgres_changes, { event: *, schema: public, table: tasks }, handleChange) .subscribe(); return () { supabase.removeChannel(channel); }; }, []);注意订阅前建议先拉取一次全量数据再用实时事件做增量更新避免丢数据。我做这个小项目时专门写了一个applyChange函数如果是 INSERT把 payload.new 加入 stateUPDATE替换对应 id 的数据DELETE从 state 里移除。如果直接“每次都重新查询全表”会频繁触发数据库压力尤其在多人使用时。我建议实时订阅只在天真无邪的场景里用。付费功能限制方面Supabase 的免费层级对 Realtime 并发连接数有限制默认 200 个在线连接左右如果是在国内公网服务器上用注意一下长连接被防火墙切断的问题我会在避坑部分详细说。3.3 Auth 登录注册功能与用户状态管理Supabase Auth 用起来很直接注册const { data, error } await supabase.auth.signUp({ email: userexample.com, password: password123, options: { data: { display_name: 张三 } } });如果项目里开启了邮件确认用户会收到一封确认邮件。SignUp 成功后默认情况不会自动创建 session而是返回一个只有 user 没有 session 的对象。这在很多新手演示里容易造成困惑——明明注册成功了为什么前端登录状态不对所以要提示用户去邮箱确认或者在后端的 Auth 设置里关闭“确认邮箱”选项才能实现注册即登录。登录const { data, error } await supabase.auth.signInWithPassword({ email: userexample.com, password: password123 });登录成功后data.session里有 access_token我们会把它存储到本地Supabase 客户端会自动处理后续的请求带上 Authorization 头。封装一个简单的 AuthContext 来监听登录状态supabase.auth.getSession().then(({ data }) { setSession(data.session); }); supabase.auth.onAuthStateChange((_event, session) { setSession(session); });手动登出const { error } await supabase.auth.signOut();一个有趣的点是如果你想在服务端渲染框架里读用户信息可以用getUser()代替getSession()getUser会向 Auth 服务发送请求验证 token更安全。但在客户端getSession更快因为 token 就在本地不过它可能有篡改风险我们后续在 RLS 里都依赖auth.uid()来验真所以问题不大。3.4 用户资料实时联动刚才建表时我们创建了一个触发器注册后自动把用户数据放进profiles表那前端怎么读取当前用户资料可以这样const { data: profile, error } await supabase .from(profiles) .select(*) .eq(id, userId) .single();而任务列表中的assignee_id关联到 profiles 表后我们就能拿到 assignee 的名字和头像。如果头像的更新是实时同步的还可以订阅 profiles 表的变化达到类似“用户头像更新后全端同步”的效果。3.5 存储模块附件上传与公开/私密访问任务看板里我支持上传图片附件。Supabase Storage 很方便创建 bucket 时选择公开或私有。对于“脏文件 用户头像”这类内容我建议私有 bucket因为后续还要接 RLS 控制谁能访问。创建 bucket 可以在 Dashboard 里点也可以通过 SDKconst { data, error } await supabase.storage.createBucket(attachments, { public: false, // 私有 allowedMimeTypes: [image/*, application/pdf], fileSizeLimit: 10 * 1024 * 1024 // 10MB });上传文件的核心 APIconst filePath ${userId}/${Date.now()}-${file.name}; const { error } await supabase.storage .from(attachments) .upload(filePath, file, { cacheControl: 3600, upsert: false });上传成功后如果需要私有 bucket 内的文件预览不能直接用getPublicUrl而要生成一个临时签名 URLconst { data } await supabase.storage .from(attachments) .createSignedUrl(filePath, 60 * 60); // 一小时有效 // data.signedUrl 就是带限时 token 的地址对于私有 bucket 的访问Supabase 有storage.objects表的 RLS 可以配置比如“只有任务参与者可以下载附件”。我写了一个简单的策略允许创建该附件的用户读取create policy 允许用户访问自己的附件 on storage.objects for select to authenticated using (bucket_id attachments and owner auth.uid());这里的owner是 storage.objects 表自动记录的上传者 ID有了这层即使生成了签名 URL别人也无法绕过策略访问。3.6 边缘函数用 Deno 写点服务端逻辑有时候我们需要在服务端调一些外部 API 或者生成数据那就用 Edge Functions。Supabase 的 CLI 可以本地写函数然后部署上去。先安装 CLInpm install -g supabase supabase login supabase link --project-ref your-project-ref创建函数时在项目目录执行supabase functions new hello-world生成的functions/hello-world/index.ts模板长这样import { serve } from https://deno.land/std0.168.0/http/server.ts serve(async (req) { const { name } await req.json() return new Response(JSON.stringify({ message: Hello ${name}! }), { headers: { Content-Type: application/json } }) })部署supabase functions deploy hello-world我可以在这里实现“任务导出为 PDF”之类的需求但是要注意Edge Functions 默认是匿名可访问的如果你的函数里需要拿到当前用户身份要到 Authorization 头里解析 supabase JWT。官方提供了supabase-js在 Deno 环境下的用法我建议用createClient配合auth.getUser()校验用户身份不要在函数里盲目信任外部参数。但在这一步我只做一个相对简单的“获取公开任务数量”函数用来验证函数调用链路。前端要用 fetch 直接打函数的 URL并附带上 anon key 作为apikey头const res await fetch(https://your-project.supabase.co/functions/v1/hello-world, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${supabaseAnonKey} }, body: JSON.stringify({ name: Supabase User }) });记得设置Authorization头时如果是登录用户可以带上 access_token否则就用 anon key。很多人在本地测试时没部署 Edge Function直接打 URL 会 404记得确认函数已经部署成功。4. 常见问题与排查技巧实录4.1 “明明设置了 RLS外层服务怎么还能查数据”这个问题非常典型。你在建表时启用了 RLS但客户端 SDK 依然能读取所有数据通常是两种原因。第一你建表时没启用 RLS只写了 policy 但没有执行enable row level security。RLS 默认对表所有者是放行的所以当 Supabase 的 API 内部是服务角色去访问时就会绕过所有策略——服务角色相当于“超级管理员”专用于后端服务不建议前端使用。第二你的 policy 是给anon角色创建的而不是authenticated。如果表里存的是售货数据要么强制用户登录要么给 anon 角色写策略。在 Supabase 的 SQL 编辑器可以用这个查询来检查表的 RLS 是否打开select tablename from pg_tables where schemaname public;更直观的是 Dashboard 的 Table Editor 里表行旁边会看到绿标/红标标识 RLS 状态。4.2 数据库连接时好时坏实时通道经常断开Supabase 的 Realtime 用的是 WebSocket部分企业网络环境里长连接可能被闲置超时切断。如果你发现channel掉线后不能自动重连可以考虑优化订阅方式或者做一个“心跳”逻辑在 channel 事件里监听presence变化或者定期执行一个轻量的数据库查询来保持连接活性。但更稳妥的是把 Realtime 和 REST 结合——先保证重要数据通过普通 HTTP 拉取实时只是辅助这样即使断线关键功能也不会瘫痪。我遇到过的问题本地开发时很顺畅一部署到公网Realtime 就隔几分钟断一次。原因可能是服务器侧的 NAT、代理设了空闲超时。可以改用心跳包的方式强制续命setInterval(() { supabase.channel(heartbeat).send({ type: broadcast, event: ping, payload: {} }) }, 30000)但这会增加成本务必在正式环境按需使用。最省心的做法是仅在需要协作功能的界面才订阅实时不要把页面所有数据都依赖实时。4.3 认证流程中的“注册后不登录”问题很多朋友用signUp后直接跳转到用户主页结果发现data.session是 null。刚才说过这是因为你开启了“邮件确认”。如果希望用户注册后免登录在 Supabase Dashboard 的 Authentication → Providers → Email 里关闭 “Confirm email” 即可。但在生产环境我强烈建议保留确认邮件一是防止垃圾注册二是校验邮箱有效性否则有人随便输入假邮箱就能注册账号。另外用邮箱密码登录时如果密码错误会返回错误信息Invalid login credentials这是正常的但是如果用户输入邮箱大小写不一致可能也导致登录失败。建议在登录表单里对邮箱做.trim().toLowerCase()处理并且在注册时存小写邮箱。4.4 查询性能优化与常见坑Postgres 本身很强但如果没有加索引用户量大起来查询会变慢。演示阶段无所谓但正式建议给外键字段加索引create index tasks_assignee_idx on public.tasks (assignee_id); create index tasks_creator_idx on public.tasks (creator_id); create index tasks_status_idx on public.tasks (status);另一个大坑是select(*)会把大字段也查出来比如 description 很长、附件路径很多。尽量只 select 所需字段。还有如果在select里关联了profiles表注意只取必要的字段否则一个任务列表可能查询数百行网络传输慢。4.5 本地开发中的 Migrations 管理Supabase 不只是线上服务它支持通过 CLI 把数据库 schema 用 migration 文件管理起来。在项目的supabase/migrations目录下创建 SQL 文件然后执行supabase db push就能把结构变更同步到远端数据库。强烈建议从一开始就用版本化 SQL而不是直接在线改表因为团队里需要环境同步否则 A 的本地改完B 的数据库还是一头雾水。不过官方 CLI 需要 Docker 支持本地服务如果你不想装 Docker也可以直接在 Dashboard 的 SQL 编辑器执行并保存脚本。但写成 migration 最大的好处是你的整个建表过程可以被代码审查、回滚、复制。5. 经验与心得几个被你忽略的细节5.1 数据库函数和触发器是真正的“秘密武器”有人觉得 Supabase 无非是一个“表单直连数据库的工具”其实它背后的 Postgres 能力极其强大。就拿自动给新用户生成 profile 来说那是我最满意的策略之一。所有依赖数据库确保“数据完整性”的逻辑不要放在应用代码里而是放在数据库触发器里。这样即使用户通过移动 App、小程序、管理后台多个入口注册都能保持一致。再比如任务更新的updated_at如果用应用层代码设置那每次要重写代码但用触发器就一劳永逸。5.2 培养“先设 RLS再写业务”的习惯在 Supabase 项目里所有跟数据有关的操作的第一步就是设置 RLS。如果表的 RLS 没建好就别碰业务逻辑。我见过不少团队在初期用 anon key 调接口数据裸奔了几个月才发现等用户量上来再补策略时已经晚了不少。建议刚启一个表立即空写一个极端保守的策略“只允许 authenticated 且 uid 匹配”的策略后面再放宽。5.3 善用 Dashboard 的查询日志遇 到问题不要瞎猜Supabase Dashboard 的 Logs 面板可以看数据库、Auth、Realtime 的日志。点击某个请求还能看到 SQL 本体这对排查“为什么这条查询被拒绝”非常有效。比如你会看到一行日志里带着new row violates row-level security policy就知道是 RLS 的with check没通过而不是代码逻辑 bug。5.4 一个用于演示的完整代码结构最后把演示项目的目录结构写出来算是一个可抄作业的参考src/ components/ TaskCard.jsx TaskList.jsx AuthForm.jsx lib/ supabase.js hooks/ useAuth.js useTasks.js pages/ Dashboard.jsx Login.jsx任务列表页的流程挂载时拉取任务然后订阅 tasks 表变化按钮触发更新、删除时调用 SDK 对应方法附件上传先调用 Storage 拿到文件路径再更新任务表的 attachments 字段。整体代码量很轻但功能完整。我自己在本地搭这套东西的时候最大的体验是Supabase 把“做后端”这件事的门槛降到极致同时又留有足够的深度。免费层级的限制对个人项目来说绝对够用如果以后用户增长可以平滑切到按量付费或自托管生态和服务稳定度也越来越好。如果你正处在“想做个自己作品但不想写一堆服务器代码”的阶段我建议直接拿这个演示项目开刀跑一遍下来后端的基本功力也就练出来了。
阅读完成 · 觉得有帮助?
咨询建站