独立开发 Day1:30岁 Java 老兵,用 Supabase + 原生 JS 撸了一个“人生进度条”

46 阅读5分钟

Screenshot 2026-01-01 at 20.27.34.png

大家好,我是牧云。

前段时间,我告别了朝九晚五的打工生涯,决定换一种活法,试一试 独立开发者 (Indie Hacker) 这条路。

2026 年伊始,我正式开启了我的独立开发之旅。 这是我转型后的第一个“练手”项目:Life Grid(人生进度条)

为什么做这个?

作为一个理科生,我习惯量化一切。人的一生按80岁算,大约只有 4160 周。我希望能有一个极简的工具,把这些时间可视化出来,警醒自己 "Make it count"

技术约束

作为独立开发的第一战,我给自己定下了几个硬性技术约束:

  1. No Server: 不买云服务器,不写一行 Spring Boot/Node.js 后端代码。
  2. No Build: 不用 Webpack/Vite,回归最纯粹的 Vanilla JS (原生 HTML/CSS/JS)
  3. Local-First: 必须支持离线使用,数据优先存本地,登录后自动同步云端。

最终,我选择了 Supabase 作为 BaaS (Backend as a Service) 解决方案,配合 PostgreSQL 的 RLS (行级安全策略) ,仅用 400行代码 就实现了全套的鉴权、存储和多端同步。

本文将分享在这个项目中的架构思考,特别是**“离线优先”的数据同步策略**。


🏗️ 架构设计:Backendless 的艺术

1. 数据库设计 (PostgreSQL)

既然没有后端 API 层,数据库的设计必须足够“扁平”且“安全”。

我在 Supabase 上建了一张极简的表 milestones:

SQL

create table public.test_milestones (
  id bigint generated by default as identity not null,
  created_at timestamp with time zone not null default now(),
  user_id uuid not null default auth.uid(), -- 核心:绑定当前登录用户
  week_index integer not null, -- 第几周 (0-4159)
  content text null, -- 笔记内容
  constraint test_milestones_pkey primary key (id),
  constraint test_milestones_user_id_week_index_key unique (user_id, week_index) -- 联合唯一索引
) tablespace pg_default;

2. 安全策略 (RLS - Row Level Security)

作为习惯了后端开发思维的人,最大的心理障碍是:“直接在前端连数据库,安全吗?”

答案是:只要配置好 RLS,比你自己写的 API 还安全。

Supabase 允许我们在数据库层面定义访问策略。我开启了 RLS,并配置了如下 Policy:

  • SELECT / INSERT / UPDATE / DELETE:

    • 条件:auth.uid() = user_id

这就意味着,虽然前端代码是公开的,但数据库引擎会在执行 SQL 时自动校验: “你只能操作 user_id 等于你当前 Token ID 的数据” 。这从根源上解决了越权访问问题。


🧩 核心难点:Local-First 同步策略

这是本项目最烧脑的部分。

为了极致的用户体验,我设计了如下交互逻辑:

  1. 游客模式 (Guest): 用户打开即用,数据存 localStorage,不强迫登录。
  2. 登录模式 (User): 用户输入邮箱(Magic Link)登录,数据上云。

问题来了: 当用户在“游客模式”写了笔记,然后登录了账号,如何保证本地的笔记不丢,且能正确合并到云端?

我设计了一个 “Server-Wins 但兼顾本地新增” 的合并策略。

状态机逻辑:

  1. Init: 读取 localStorage 渲染,此时是离线数据。

  2. Login: 检测到 onAuthStateChange 事件。

  3. Sync (关键步骤):

    • Pull: 拉取云端数据 (CloudData)。
    • Diff: 遍历本地数据 (LocalData)。如果发现 “本地有记录,但云端该位置为空” ,判定为新增数据
    • Push: 将这些新增数据 Upsert 到云端。
    • Merge: 重新拉取/合并数据,若有冲突,以云端数据为准 (Server Wins)。
    • Cache: 将最终结果回写到 localStorage,保持一致性。

核心代码实现 (Vanilla JS)

JavaScript

// 核心同步函数
async function syncAndLoadData() {
    try {
        // 1. 获取本地“私房钱” (未登录时的数据)
        const localRaw = localStorage.getItem('lifeGridMilestones');
        const localData = localRaw ? JSON.parse(localRaw) : {};
        
        // 2. 拉取云端数据
        const { data: cloudData, error } = await supabaseClient
            .from('test_milestones')
            .select('week_index, content');
        
        if (error) throw error;

        // 3. 构建索引 Map
        const cloudMap = new Map();
        cloudData.forEach(row => cloudMap.set(row.week_index, row.content));

        // 4. 【关键】找出本地新增的数据 (Local has, Cloud miss)
        const updatesToUpload = [];
        for (const [key, content] of Object.entries(localData)) {
            const weekIdx = parseInt(key);
            // 只有云端没记录时,才上传本地数据,防止覆盖云端已有的历史
            if (!cloudMap.has(weekIdx) && content) {
                updatesToUpload.push({
                    user_id: currentUser.id,
                    week_index: weekIdx,
                    content: content
                });
            }
        }

        // 5. 批量上传新增数据
        if (updatesToUpload.length > 0) {
            console.log("🚀 Syncing local notes to cloud...", updatesToUpload);
            await supabaseClient
                .from('test_milestones')
                .upsert(updatesToUpload, { onConflict: 'user_id, week_index' });
            
            // 乐观更新:直接把上传的数据补到 cloudMap 里,避免二次请求
            updatesToUpload.forEach(item => cloudMap.set(item.week_index, item.content));
        }

        // 6. 最终合并与渲染
        milestones = {};
        for (const [weekIdx, content] of cloudMap.entries()) {
            milestones[weekIdx] = content;
        }

        // 双写缓存
        localStorage.setItem('lifeGridMilestones', JSON.stringify(milestones));
        renderGrid();

    } catch (err) {
        console.error("Sync Error:", err);
        // 降级处理:云端挂了就读本地
        loadLocalOnly(); 
    }
}

🎨 UI/UX:极简主义与磨砂质感

虽然是练手项目,但 UI 不能凑合。

我没有引入 Tailwind CSS,而是手写了不到 100 行 CSS。

核心交互是一个模态弹窗 (Modal) ,为了营造“原生 App”的高级感,我使用了 backdrop-filter: blur(5px) 来实现磨砂玻璃效果,并配合 transform: scale() 做了微动效。

CSS

/* 磨砂玻璃弹窗 */
.modal-overlay {
    background: rgba(255, 255, 255, 0.1);
    backdrop-filter: blur(5px);
    -webkit-backdrop-filter: blur(5px); /* 兼容 Safari */
    transition: opacity 0.3s ease;
}

.modal-card {
    background: var(--bg-color);
    box-shadow: 0 10px 30px rgba(0,0,0,0.15);
    /* 弹窗弹起时的回弹效果 */
    transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

ScreenRecording_01-01-2026 23-03-30_1.gif


🚀 总结与开源

从庞大的微服务架构,回归到极简的 index.html + Supabase,这种掌控感久违了。

对于独立开发者来说,Tech Stack 越轻,跑得越快。

这个项目是我的起点。目前 V1.0 版本已完全开源,欢迎大家 Star 或 Fork,打造属于你自己的“人生进度条”。

GitHub 仓库地址:

[github.com/muyunlee202…]

体验地址:

[muyunlee2025.github.io/life-grid/]

如果你对 独立开发、Supabase 实战、AI 应用 感兴趣,欢迎关注我的掘金账号。

下一篇,我准备研究一下 Animated Drawings,试试能不能用 AI 复活我儿子的涂鸦画作。


Peace & Code.