./read "Local-first 软件:下一代应用..."

Local-first 软件:下一代应用开发的设计理念

local-first_软件:下一代应用开发的设计理念.md2025-10-15
./meta --show-details
Published
2025年10月15日
Reading
21 min
Words
20,001
Status
PUBLISHED

Local-first 软件:下一代应用开发的设计理念

目录

  1. 什么是 Local-first
  2. 为什么需要 Local-first
  3. 核心原理与技术
  4. 应用场景
  5. 开发实战 Demo
  6. 技术栈选择
  7. 最佳实践
  8. 挑战与解决方案

1. 什么是 Local-first

1.1 概念理解

Local-first(本地优先) 是一种软件设计理念,它将用户数据优先存储在本地设备上,而非远程服务器。这不仅仅是"离线优先",而是一种更根本的架构转变。

想象一下:

  • 📝 你在飞机上写文档,不需要网络也能正常工作
  • 🚄 地铁信号不好时,你的待办事项应用依然流畅
  • 👥 和同事协作编辑文档,即使其中一人断网,大家的改动最终也能同步

这就是 Local-first 的魅力。

1.2 核心七原则

根据 Ink & Switch 的论文,Local-first 软件应具备以下特性:

原则说明传统云应用Local-first 应用
快速响应无需等待服务器❌ 每次操作都需等待✅ 立即响应
多设备支持跨设备同步数据✅ 支持✅ 支持
离线可用断网也能工作❌ 无法使用✅ 完全可用
协作能力多人实时编辑✅ 支持(需网络)✅ 支持(离线也可)
数据持久数据长期保存⚠️ 依赖服务商✅ 用户控制
用户拥有数据归用户所有❌ 存在平台上✅ 存在本地
隐私安全数据默认加密⚠️ 取决于服务商✅ 端到端加密

1.3 与传统架构的对比

传统云应用架构(星型结构)

        设备 A
           ↓ 读写
    中心服务器 ← 设备 B(读写)
           ↑ 读写
        设备 C

特点:所有数据必须经过中心服务器
问题:单点故障、网络依赖、延迟高

Local-first 应用架构(P2P 网状结构)

设备 A ←→ 设备 B
  ↕         ↕
设备 C ←→ 云端备份(可选)

特点:设备间直接同步,云端仅作备份
优势:离线可用、快速响应、无单点故障

架构对比总结

架构特性传统云应用Local-first 应用
数据流向设备 ↔ 服务器(星型)设备 ↔ 设备(网状)
单点故障服务器宕机 = 全部不可用任一设备可独立工作
网络要求必须在线离线完全可用
响应速度取决于网络延迟(50-500ms)本地操作(<10ms)

2. 为什么需要 Local-first

2.1 云应用的痛点

现代 Web 应用过度依赖服务器,导致:

性能问题

// 传统云应用:每次操作都需要往返服务器
async function saveTodo(todo) {
  setLoading(true);
  try {
    await fetch('/api/todos', {
      method: 'POST',
      body: JSON.stringify(todo)
    });
    // 等待 200-500ms... 用户体验受影响
  } catch (error) {
    // 网络差时直接失败
  } finally {
    setLoading(false);
  }
}

可用性问题

  • 🚫 网络故障 = 应用瘫痪
  • ⏱️ 服务器维护 = 用户等待
  • 🌍 跨国访问 = 延迟巨大

隐私与控制权问题

  • 数据被平台锁定,难以导出
  • 服务商倒闭或关停服务,数据丢失
  • 隐私数据存储在第三方服务器

2.2 Local-first 的优势

极致的用户体验

// Local-first:立即响应,后台同步
function saveTodo(todo) {
  // 1. 立即写入本地数据库(<10ms)
  db.todos.add(todo);

  // 2. UI 立即更新,无需 loading
  renderTodos();

  // 3. 后台异步同步到其他设备
  sync.queue(todo);
}

真正的离线支持

  • 飞机上、地铁里、咖啡厅信号差?照常工作
  • 网络恢复后自动同步,无需手动干预

用户拥有数据

  • 数据存储在用户设备上
  • 支持导出、备份、迁移
  • 不受平台限制

3. 核心原理与技术

3.1 CRDT(Conflict-free Replicated Data Types)

无冲突复制数据类型是 Local-first 的核心技术。

传统冲突解决

// 用户 A 和 B 同时编辑,谁后提交覆盖谁?
// A: title = "任务 A"
// B: title = "任务 B"
// 结果:一方的修改丢失 ❌

CRDT 冲突解决

// CRDT 自动合并冲突
// A: title = "任务 A", priority = 1
// B: title = "任务 B", completed = true
// 结果:{ title: "任务 B", priority: 1, completed: true } ✅
// 规则:Last-Write-Wins,根据时间戳合并

常见 CRDT 类型

类型用途示例
LWW-Register单值(最后写入获胜)标题、描述
G-Counter只增计数器点赞数、浏览量
OR-Set集合(添加删除)标签列表
Yjs Text协作文本编辑Google Docs 风格

3.2 同步协议

同步流程示意

时间线         设备 A                      设备 B
────────────────────────────────────────────────────
t1 [离线]    本地修改: title = "任务 A"   本地修改: status = "完成"
             版本: v1                     版本: v1'

t2 [上线]    发送变更 ────────────────→   接收变更
             接收变更 ←────────────────   发送变更

t3 [合并]    合并 B 的修改                合并 A 的修改
             版本: v2                     版本: v2
             {                            {
               title: "任务 A",             title: "任务 A",
               status: "完成"               status: "完成"
             }                            }

结果:✅ 最终一致性达成,无需人工介入

同步机制关键点

  1. 变更追踪:每个操作都记录时间戳和设备 ID
  2. 增量同步:只传输变更部分,不是全量数据
  3. 自动合并:CRDT 算法自动解决冲突
  4. 最终一致:所有设备最终达到相同状态

3.3 本地存储技术

浏览器端

  • IndexedDB:大容量键值存储(推荐)
  • SQLite WASM:完整的 SQL 数据库
  • OPFS(Origin Private File System):文件系统访问

移动端/桌面端

  • SQLite:成熟稳定
  • RocksDB:高性能键值存储
  • Realm:移动优先数据库

4. 应用场景

4.1 最适合的场景

协作编辑工具

  • Notion、Obsidian、Figma
  • 需要实时协作 + 离线编辑

项目管理工具

  • Linear、Height
  • 快速响应 + 离线查看

笔记应用

  • Logseq、Roam Research
  • 隐私优先 + 跨设备同步

创作工具

  • 绘图应用、音乐制作
  • 需要低延迟交互

开发者工具

  • Git、VS Code
  • 本地优先 + 可选同步

4.2 不太适合的场景

强一致性需求

  • 金融交易、库存管理
  • 需要即时确认的操作

大量实时数据

  • 股票行情、游戏服务器
  • 数据量巨大且需要中心化处理

强监管要求

  • 医疗记录、政务系统
  • 必须集中存储和审计

5. 开发实战 Demo

5.1 简单的待办事项应用

我们将构建一个支持离线编辑和多设备同步的待办应用。

技术栈

  • Yjs:CRDT 库
  • IndexedDB:本地存储
  • y-indexeddb:Yjs 持久化
  • y-webrtc:P2P 同步

项目结构

local-first-todo/
├── index.html
├── app.js
└── package.json

5.2 安装依赖

mkdir local-first-todo && cd local-first-todo
npm init -y
npm install yjs y-indexeddb y-webrtc

5.3 核心代码实现

package.json

{
  "name": "local-first-todo",
  "type": "module",
  "dependencies": {
    "yjs": "^13.6.10",
    "y-indexeddb": "^9.0.12",
    "y-webrtc": "^10.3.0"
  },
  "scripts": {
    "dev": "vite"
  },
  "devDependencies": {
    "vite": "^5.0.0"
  }
}

index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Local-first 待办事项</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
      max-width: 600px;
      margin: 40px auto;
      padding: 20px;
      background: #f5f5f5;
    }
    h1 { margin-bottom: 20px; color: #333; }
    .status {
      padding: 10px;
      border-radius: 8px;
      margin-bottom: 20px;
      font-size: 14px;
    }
    .online { background: #d4edda; color: #155724; }
    .offline { background: #fff3cd; color: #856404; }
    .input-group {
      display: flex;
      gap: 10px;
      margin-bottom: 20px;
    }
    input {
      flex: 1;
      padding: 12px;
      border: 2px solid #ddd;
      border-radius: 8px;
      font-size: 16px;
    }
    button {
      padding: 12px 24px;
      background: #007bff;
      color: white;
      border: none;
      border-radius: 8px;
      cursor: pointer;
      font-size: 16px;
    }
    button:hover { background: #0056b3; }
    .todos {
      background: white;
      border-radius: 8px;
      padding: 20px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
    .todo-item {
      display: flex;
      align-items: center;
      padding: 12px;
      border-bottom: 1px solid #eee;
    }
    .todo-item:last-child { border-bottom: none; }
    .todo-item input[type="checkbox"] {
      width: 20px;
      height: 20px;
      margin-right: 12px;
    }
    .todo-text {
      flex: 1;
      font-size: 16px;
    }
    .todo-text.completed {
      text-decoration: line-through;
      color: #999;
    }
    .delete-btn {
      padding: 6px 12px;
      background: #dc3545;
      font-size: 14px;
    }
    .delete-btn:hover { background: #c82333; }
    .sync-info {
      margin-top: 10px;
      font-size: 12px;
      color: #666;
    }
  </style>
</head>
<body>
  <h1>📝 Local-first 待办事项</h1>

  <div id="status" class="status offline">
    🔴 离线模式(数据保存在本地)
  </div>

  <div class="input-group">
    <input
      type="text"
      id="todoInput"
      placeholder="输入待办事项..."
      autocomplete="off"
    />
    <button onclick="addTodo()">添加</button>
  </div>

  <div class="todos" id="todoList"></div>

  <div class="sync-info">
    💡 提示:数据自动保存在本地,打开多个标签页可看到实时同步效果
  </div>

  <script type="module" src="/app.js"></script>
</body>
</html>

app.js

import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebrtcProvider } from 'y-webrtc';

// 1. 创建 Yjs 文档
const ydoc = new Y.Doc();

// 2. 本地持久化(IndexedDB)
const persistence = new IndexeddbPersistence('local-first-todo', ydoc);

// 3. P2P 同步(同一局域网或通过信令服务器)
const provider = new WebrtcProvider('local-first-todo-room', ydoc, {
  signaling: ['wss://signaling.yjs.dev'],
});

// 4. 获取共享数组
const todos = ydoc.getArray('todos');

// 5. 监听连接状态
provider.on('status', ({ connected }) => {
  const statusEl = document.getElementById('status');
  if (connected) {
    statusEl.className = 'status online';
    statusEl.textContent = '🟢 在线同步中(已连接到其他设备)';
  } else {
    statusEl.className = 'status offline';
    statusEl.textContent = '🔴 离线模式(数据保存在本地)';
  }
});

// 6. 监听数据变化并重新渲染
todos.observe(() => {
  renderTodos();
});

// 7. 等待本地数据加载完成
persistence.on('synced', () => {
  console.log('本地数据加载完成');
  renderTodos();
});

// 添加待办事项
window.addTodo = () => {
  const input = document.getElementById('todoInput');
  const text = input.value.trim();

  if (!text) return;

  // 立即写入 CRDT,自动同步到所有设备
  todos.push([{
    id: Date.now() + Math.random(), // 简易 ID 生成
    text,
    completed: false,
    createdAt: Date.now(),
  }]);

  input.value = '';
  input.focus();
};

// 切换完成状态
window.toggleTodo = (index) => {
  const todo = todos.get(index);
  todos.delete(index, 1);
  todos.insert(index, [{
    ...todo,
    completed: !todo.completed,
  }]);
};

// 删除待办
window.deleteTodo = (index) => {
  todos.delete(index, 1);
};

// 渲染待办列表
function renderTodos() {
  const list = document.getElementById('todoList');
  const items = todos.toArray();

  if (items.length === 0) {
    list.innerHTML = '<p style="text-align:center;color:#999;padding:20px">暂无待办事项</p>';
    return;
  }

  list.innerHTML = items.map((todo, index) => `
    <div class="todo-item">
      <input
        type="checkbox"
        ${todo.completed ? 'checked' : ''}
        onchange="toggleTodo(${index})"
      />
      <span class="todo-text ${todo.completed ? 'completed' : ''}">
        ${escapeHtml(todo.text)}
      </span>
      <button class="delete-btn" onclick="deleteTodo(${index})">删除</button>
    </div>
  `).join('');
}

// HTML 转义
function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

// 支持回车添加
document.addEventListener('DOMContentLoaded', () => {
  document.getElementById('todoInput').addEventListener('keypress', (e) => {
    if (e.key === 'Enter') {
      addTodo();
    }
  });
});

5.4 运行 Demo

# 安装 Vite
npm install -D vite

# 启动开发服务器
npx vite

# 打开 http://localhost:5173

测试离线功能

  1. 打开浏览器开发者工具
  2. 切换到 Network 标签,选择 "Offline"
  3. 继续添加待办事项,完全可用!
  4. 恢复网络后,打开多个标签页,数据自动同步

5.5 进阶:React + TypeScript 版本

使用 Zustand + Yjs

// store/todoStore.ts
import { create } from 'zustand';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

interface TodoStore {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  deleteTodo: (id: string) => void;
}

const ydoc = new Y.Doc();
const todosArray = ydoc.getArray<Todo>('todos');
const persistence = new IndexeddbPersistence('todos', ydoc);

export const useTodoStore = create<TodoStore>((set) => {
  // 监听 Yjs 变化
  todosArray.observe(() => {
    set({ todos: todosArray.toArray() });
  });

  return {
    todos: [],

    addTodo: (text) => {
      todosArray.push([{
        id: crypto.randomUUID(),
        text,
        completed: false,
      }]);
    },

    toggleTodo: (id) => {
      const index = todosArray.toArray().findIndex(t => t.id === id);
      if (index !== -1) {
        const todo = todosArray.get(index);
        todosArray.delete(index, 1);
        todosArray.insert(index, [{ ...todo, completed: !todo.completed }]);
      }
    },

    deleteTodo: (id) => {
      const index = todosArray.toArray().findIndex(t => t.id === id);
      if (index !== -1) {
        todosArray.delete(index, 1);
      }
    },
  };
});

React 组件

// components/TodoList.tsx
import { useTodoStore } from '@/store/todoStore';

export default function TodoList() {
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodoStore();
  const [input, setInput] = useState('');

  const handleAdd = () => {
    if (input.trim()) {
      addTodo(input);
      setInput('');
    }
  };

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
      />
      <button onClick={handleAdd}>添加</button>

      {todos.map(todo => (
        <div key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => toggleTodo(todo.id)}
          />
          <span>{todo.text}</span>
          <button onClick={() => deleteTodo(todo.id)}>删除</button>
        </div>
      ))}
    </div>
  );
}

6. 技术栈选择

6.1 CRDT 库对比

特点适用场景Star
Yjs性能最佳,生态丰富协作编辑、通用应用⭐ 14k+
Automerge易用,JSON-like API文档编辑、JSON 数据⭐ 12k+
gun.jsP2P 图数据库去中心化应用⭐ 18k+
LSEQ轻量级文本 CRDT简单文本编辑⭐ 200+

推荐选择

  • 新手入门:Yjs(文档好、案例多)
  • 需要完整框架:Electric SQL(Postgres + Local-first)
  • 移动端:Replicache(商业产品,性能优化好)

6.2 完整解决方案

⚡ Electric SQL

# 将 Postgres 转换为 Local-first 数据库
npx electric-sql init

# 自动生成客户端类型
npx electric-sql generate

特点

  • 基于 Postgres
  • 自动生成 TypeScript 类型
  • 内置权限控制
  • 支持 React Hooks

示例

import { useElectric } from '@/electric/ElectricProvider';

function Todos() {
  const { db } = useElectric();
  const { results: todos } = useLiveQuery(
    db.todos.liveMany()
  );

  const addTodo = async (text: string) => {
    await db.todos.create({
      data: { text, completed: false }
    });
  };

  return (
    // 自动响应式更新,离线可用
    <ul>
      {todos?.map(todo => <li key={todo.id}>{todo.text}</li>)}
    </ul>
  );
}

🔷 Replicache

  • 专为 Local-first 设计
  • 优秀的冲突解决
  • 商业支持
  • 适合生产环境

🟦 RxDB

  • 基于 RxJS 的响应式数据库
  • 支持多种存储引擎
  • 丰富的插件系统

7. 最佳实践

7.1 数据建模

❌ 错误示例

// 直接存储整个文档
const doc = {
  title: 'My Document',
  content: '大量文本...',
  comments: [...], // 嵌套数据
};

✅ 正确示例

// 拆分为多个 CRDT
const title = ydoc.getText('title');
const content = ydoc.getText('content');
const comments = ydoc.getArray('comments');

// 好处:
// 1. 减少冲突概率
// 2. 更细粒度的更新
// 3. 性能更好

7.2 同步策略

增量同步

// 不要每次都同步全量数据
provider.on('sync', (isSynced) => {
  if (isSynced) {
    // 仅同步变更部分
    const updates = Y.encodeStateAsUpdate(ydoc, lastSyncedState);
    sendToServer(updates);
  }
});

批量更新

// 批量操作减少同步次数
ydoc.transact(() => {
  todos.push([todo1]);
  todos.push([todo2]);
  todos.push([todo3]);
}); // 一次同步 ✅

// 而不是:
todos.push([todo1]); // 同步
todos.push([todo2]); // 同步
todos.push([todo3]); // 同步 ❌

7.3 错误处理

// 监听同步错误
provider.on('connection-error', (error) => {
  console.error('同步失败,将在后台重试', error);
  showNotification('当前离线,数据已保存到本地');
});

// 数据验证
todos.observe((event) => {
  event.changes.added.forEach((item) => {
    const todo = item.content.getContent()[0];
    if (!todo.text || typeof todo.text !== 'string') {
      console.error('无效的待办数据', todo);
      // 回滚或修复
    }
  });
});

7.4 性能优化

懒加载历史数据

// 只加载最近的数据
const recentTodos = todos.toArray().slice(-50);

// 旧数据按需加载
async function loadMore() {
  const olderTodos = await db.todos
    .where('createdAt')
    .below(oldestLoadedDate)
    .limit(50)
    .toArray();
}

定期压缩

// Yjs 会累积历史变更,定期压缩
setInterval(() => {
  const snapshot = Y.encodeStateAsUpdate(ydoc);
  const compressed = Y.mergeUpdates([snapshot]);
  // 保存压缩后的快照
}, 1000 * 60 * 60); // 每小时

8. 挑战与解决方案

8.1 常见挑战

挑战解决方案
数据冲突使用 CRDT,自动合并冲突
存储空间定期清理旧数据,分层存储
首次加载慢增量同步,懒加载
权限控制端到端加密 + 服务端验证
大文件同步分块传输,P2P 加速

8.2 权限与安全

端到端加密示例

import { encrypt, decrypt } from 'tweetnacl';

// 加密后再同步
const encryptedData = encrypt(
  JSON.stringify(todo),
  userPublicKey
);

todos.push([{
  ...todo,
  encryptedContent: encryptedData,
}]);

行级权限

// 仅同步用户有权限的数据
const userTodos = todos.toArray().filter(todo =>
  todo.ownerId === currentUser.id ||
  todo.sharedWith.includes(currentUser.id)
);

8.3 调试技巧

// 查看 CRDT 内部状态
console.log(Y.encodeStateAsUpdate(ydoc));

// 监听所有变更
ydoc.on('update', (update, origin) => {
  console.log('数据变更来源:', origin);
  console.log('变更内容:', update);
});

// 查看同步状态
provider.on('peers', (peers) => {
  console.log('已连接设备:', peers);
});

总结

Local-first 核心要点

  1. 数据优先存储在本地,服务器只是同步节点
  2. 使用 CRDT 实现自动冲突解决
  3. 离线完全可用,网络只影响同步
  4. 用户拥有数据,隐私和控制权归用户

适合你吗?

✅ 适合使用 Local-first 的情况

  • 需要离线功能的应用
  • 重视用户隐私和数据所有权
  • 需要极致响应速度
  • 支持实时协作

❌ 暂时不适合的情况

  • 强一致性金融系统
  • 大规模实时数据处理
  • 团队没有 CRDT 经验

学习路径

  1. 理解概念:阅读 Ink & Switch 论文
  2. 动手实践:完成本文的 Todo Demo
  3. 深入 CRDT:学习 Yjs 文档
  4. 生产实践:尝试 Electric SQL 或 Replicache
  5. 关注社区Local-first Web 社区

推荐资源

论文与文章

开源项目

真实案例


Local-first 不仅仅是一种技术,更是一种尊重用户、回归本质的软件哲学。在这个万物互联的时代,让我们的应用更快、更可靠、更尊重用户的数据主权。

开始你的 Local-first 之旅吧!🚀

comments.logDiscussion Thread
./comments --show-all

讨论区

./loading comments...