出发点
我们使用 Google Docs 来协作处理文档、电子表格和演示文稿;在 Figma 中,我们可以多人进行用户界面设计;我们使用 Slack 与同事沟通;我们在 Trello 中跟踪任务
我们大量使用云应用进行协作,这些应用通过网络浏览器或移动 App 访问,数据存储在远程服务器上。
优势:
局限:
There is no cloud, it’s just someone else’s computer.
在网络应用出现之前,我们有一些我们可以称之为 “老式”的应用:在你的本地计算机上运行的程序,在本地磁盘上读写文件
今天我们仍然在使用这种类型的应用程序:文本编辑器和集成开发环境、Git 和其他版本控制系统,以及许多专业软件,如图形应用程序或 CAD 软件都属于这种类型
传统的本地应用程序将数据存储在用户的设备上,赋予用户对数据的完全控制权。用户可以自由地存档、备份、编辑或删除这些数据,无需依赖外部服务。
我们既希望有云应用提供的方便的跨设备访问和实时协作,也希望有 “老式”软件体现的对你自己数据的拥有权
下定义
我们相信,数据所有权和实时协作并不相悖。我们有可能创建具有云应用所有优点的软件,同时也允许你保留对你创建的数据、文档和文件的完全所有权。
我们把这种类型的软件称为本地优先软件 Local-First software ,因为它优先使用本地存储(你的电脑中内置的磁盘)和本地网络(如你的家庭 WiFi)而不是远程数据中心的服务器。
- 在云计算应用程序中,服务器上的数据被视为数据的主要的、权威的副本;
- 如果客户端有一份数据的副本,它只是一个从属于服务器的缓冲区。任何数据修改都必须发送到服务器上,否则就 “没有发生”
- 在本地优先的应用程序中,我们交换了这些角色:我们把你本地设备上的数据副本—你的笔记本电脑、平板电脑或手机—视为主要副本。服务器仍然存在,但它们持有你的数据的次要副本,以协助从多个设备访问
技术体现
数据所有权
数据所有权的核心是将用户数据存储在本地设备上。这通常通过嵌入式数据库实现,如 SQLite 或 LevelDB。这些数据库直接集成到应用程序中,允许高效的本地数据存储和检索
数据同步是另一个关键。这通常采用双向同步协议,如 CouchDB 使用的协议。同步过程中,客户端和服务器交换数据差异,而不是整个数据集
这种方法既高效又能处理网络中断
CRDT 技术
CRDTs as Conflict-free Replicated Data Types,无冲突复制数据类型

两个设备最初拥有相同的待办事项列表。在设备 1 上,使用 .push() 方法将一个新的项目添加到列表中,该方法将新的项目附加到列表的末尾
同时,第一个项目在设备 2 上被标记为完成。在两个设备进行通信后,CRDT 自动合并状态,使两个变化都生效
本地数据库与同步引擎结合的全栈响应式架构
全栈响应式架构的基础是响应式编程模型。这种模型将数据视为流,任何数据变化都会自动传播到系统的其他部分
在前端,这通常通过响应式状态管理库实现,如 RxJS 或 MobX。这些库提供了可观察的数据结构,UI 组件可以订阅这些结构的变化
后端同样采用响应式模式。响应式数据库(如 RxDB)提供了可观察的查询结果。当底层数据发生变化时,这些查询结果会自动更新
实时通信通常通过 WebSocket 或类似协议实现。这允许服务器主动推送数据到客户端,实现双向实时通信
整个系统采用事件驱动的架构。数据变化被视为事件,这些事件触发一系列反应,包括 UI 更新、数据同步和业务逻辑执行
简易代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145
| class LocalFirstApp { constructor() { this.data = {}; this.syncQueue = []; this.isOnline = navigator.onLine; this.lastSyncTime = 0; this.serverUrl = "https://api.example.com"; this.user = null;
window.addEventListener("online", () => this.handleOnline()); window.addEventListener("offline", () => this.handleOffline()); }
setItem(key, value) { this.data[key] = value; this.saveToLocalStorage(); this.addToSyncQueue({ type: "set", key, value }); if (this.isOnline) { this.sync(); } }
getItem(key) { return this.data[key]; }
removeItem(key) { delete this.data[key]; this.saveToLocalStorage(); this.addToSyncQueue({ type: "remove", key }); if (this.isOnline) { this.sync(); } }
saveToLocalStorage() { localStorage.setItem("appData", JSON.stringify(this.data)); }
loadFromLocalStorage() { const storedData = localStorage.getItem("appData"); if (storedData) { this.data = JSON.parse(storedData); } }
addToSyncQueue(operation) { operation.timestamp = Date.now(); operation.id = this.generateUniqueId(); this.syncQueue.push(operation); this.saveToLocalStorage(); }
generateUniqueId() { return Date.now().toString(36) + Math.random().toString(36).substr(2); }
encryptData(data) { return btoa(JSON.stringify(data)); }
decryptData(encryptedData) { return JSON.parse(atob(encryptedData)); }
saveToLocalStorage() { const encryptedData = this.encryptData({ data: this.data, syncQueue: this.syncQueue, lastSyncTime: this.lastSyncTime, }); localStorage.setItem("appData", encryptedData); }
loadFromLocalStorage() { const encryptedData = localStorage.getItem("appData"); if (encryptedData) { const decryptedData = this.decryptData(encryptedData); this.data = decryptedData.data; this.syncQueue = decryptedData.syncQueue; this.lastSyncTime = decryptedData.lastSyncTime; } }
async login(username, password) { try { const response = await fetch(`${this.serverUrl}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); if (response.ok) { const userData = await response.json(); this.user = userData; this.saveToLocalStorage(); return true; } } catch (error) { console.error("Login failed:", error); } return false; }
logout() { this.user = null; this.data = {}; this.syncQueue = []; this.saveToLocalStorage(); }
handleOnline() { this.isOnline = true; this.sync(); }
handleOffline() { this.isOnline = false; }
init() { this.loadFromLocalStorage(); if (this.isOnline) { this.sync(); } } }
|
测试使用用例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| const app = new LocalFirstApp();
app.init();
async function useApp() { const loggedIn = await app.login("username", "password"); if (loggedIn) { console.log("登录成功");
app.setItem("user_profile", { name: "Alice", age: 30, email: "alice@example.com", });
const userProfile = app.getItem("user_profile"); console.log("用户资料:", userProfile);
app.setItem("user_profile", { ...userProfile, age: 31, });
app.setItem("preferences", { theme: "dark", notifications: true, });
app.removeItem("old_data");
if (app.isOnline) { await app.sync(); }
app.logout(); console.log("已注销"); } else { console.log("登录失败"); } }
useApp().catch(console.error);
window.addEventListener("online", () => { console.log("设备重新上线,正在同步数据..."); });
window.addEventListener("offline", () => { console.log("设备离线,将在恢复连接后同步数据"); });
|