
Trong mười lăm năm qua, ngành công nghiệp phần mềm đã hoạt động dưới một kiến trúc thống trị duy nhất: mô hình máy khách-máy chủ đám mây tập trung. Nguồn đáng tin cậy của ứng dụng nằm trên một máy chủ cơ sở dữ liệu từ xa, trong khi máy khách (trình duyệt hoặc ứng dụng di động) hoạt động như một lớp trình bày mỏng, dành toàn bộ vòng đời của nó để chờ các yêu cầu HTTP được giải quyết.
Để che giấu độ trễ của kiến trúc khứ hồi này, các nhà phát triển frontend đã dành hàng ngàn giờ để tạo ra các giải pháp thay thế: khung tải (loading skeletons), lớp phủ quay tròn (spinner overlays) và các thay đổi UI lạc quan (optimistic UI mutations) thường xuyên bị hoàn tác khi tàu đi vào đường hầm hoặc tín hiệu di động bị mất.
Phần mềm ưu tiên cục bộ (Local-First Software), một mô hình được Martin Kleppmann và các nhà nghiên cứu tại Ink & Switch chính thức hóa, về cơ bản đã đảo ngược mối quan hệ này. Trong một ứng dụng ưu tiên cục bộ, nguồn đáng tin cậy chính nằm cục bộ trên thiết bị của người dùng (trong IndexedDB hoặc SQLite thông qua OPFS). Đồng bộ hóa mạng diễn ra không đồng bộ ở chế độ nền như một lớp truyền tải tùy chọn.
Trong hướng dẫn này, chúng tôi sẽ phân tích các nguyên tắc toán học của Kiểu dữ liệu sao chép không xung đột (CRDTs), triển khai một đường dẫn đồng bộ hóa ưu tiên cục bộ linh hoạt bằng cách sử dụng Yjs và IndexedDB, đồng thời xem xét cách xử lý cộng tác ngang hàng trong thế giới thực.
Một ứng dụng ưu tiên cục bộ tuân thủ bảy lý tưởng nền tảng:
[Traditional Cloud App vs Local-First Architecture]
Traditional Cloud Architecture (Central Source of Truth)
User Interaction ──► [Wait...] ──► Remote API Server ──► PostgreSQL
▲
Network Flake = Broken UI
Local-First Architecture (Local Source of Truth)
User Interaction ──► Local Disk (IndexedDB / SQLite OPFS) ──► Instant 0ms Render
│
▼ (Background Async Transport)
CRDT State Sync Layer (P2P WebRTC / WebSocket Relay)
Trở ngại kỹ thuật chính trong các hệ thống phân tán, ngoại tuyến là giải quyết xung đột. Nếu Người dùng A chỉnh sửa đoạn văn thứ nhất khi ngoại tuyến trên chuyến bay, và Người dùng B chỉnh sửa cùng đoạn văn đó khi ngoại tuyến trong văn phòng, làm thế nào hệ thống hợp nhất các chỉnh sửa của họ khi cả hai kết nối lại?
Các thuật toán truyền thống như Chuyển đổi Hoạt động (OT) - được sử dụng trong Google Docs thời kỳ đầu - yêu cầu một máy chủ tập trung, có thẩm quyền để sắp xếp tất cả các hoạt động một cách tuyến tính. Nếu máy chủ trung tâm không thể truy cập được, sự cộng tác sẽ ngừng lại.
Kiểu dữ liệu sao chép không xung đột (CRDTs) là các cấu trúc toán học được thiết kế để sao chép trên nhiều nút phân tán mà không cần điều phối tập trung. Chúng thỏa mãn ba thuộc tính toán học cốt lõi:
A \cdot B = B \cdot A (Thứ tự các bản cập nhật được nhận không quan trọng).(A \cdot B) \cdot C = A \cdot (B \cdot C) (Việc nhóm các gói đến không ảnh hưởng đến kết quả).A \cdot A = A (Áp dụng cùng một bản cập nhật nhiều lần tạo ra kết quả giống hệt nhau, loại bỏ lỗi gói trùng lặp).Dù các bản cập nhật nút đến đúng thứ tự, sai thứ tự hay bị trùng lặp qua một mạng không ổn định, mọi máy khách đều được đảm bảo về mặt toán học sẽ hội tụ về cùng một trạng thái chính xác.
Yjs là thư viện CRDT hiệu suất cao nhất trong hệ sinh thái JavaScript. Nó biểu diễn tài liệu dưới dạng một danh sách liên kết nội bộ của các vector trạng thái, đạt được dung lượng bộ nhớ và tốc độ thực thi nhanh hơn nhiều lần so với các CRDT JSON ban đầu.
npm install yjs y-indexeddb y-webrtc y-websocket
Chúng tôi khởi tạo một Y.Doc và liên kết nó ngay lập tức với bộ nhớ IndexedDB của trình duyệt bằng cách sử dụng y-indexeddb. Tất cả trạng thái được tải từ đĩa cục bộ trong vài mili giây:
// local-store.ts
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';
import { WebrtcProvider } from 'y-webrtc';
import { WebsocketProvider } from 'y-websocket';
export interface TaskItem {
id: string;
title: string;
completed: boolean;
updatedAt: number;
}
export class LocalFirstTaskStore {
doc: Y.Doc;
tasksMap: Y.Map<TaskItem>;
persistence: IndexeddbPersistence;
webrtcProvider: WebrtcProvider | null = null;
wsProvider: WebsocketProvider | null = null;
constructor(boardId: string) {
// 1. Instantiate the root CRDT Document
this.doc = new Y.Doc();
// 2. Bind to local IndexedDB (Persistence First)
this.persistence = new IndexeddbPersistence(`kanban-board-${boardId}`, this.doc);
// 3. Define shared state maps
this.tasksMap = this.doc.getMap<TaskItem>('tasks');
this.persistence.on('synced', () => {
console.log('Local IndexedDB loaded into memory successfully!');
});
// 4. Initialize Multi-Transport Network Providers
this.initNetworkSync(boardId);
}
private initNetworkSync(boardId: string) {
// P2P WebRTC Mesh: Direct browser-to-browser syncing over local LAN/STUN
this.webrtcProvider = new WebrtcProvider(`room-${boardId}`, this.doc, {
signaling: ['wss://signaling.yjs.dev', 'wss://y-webrtc-signaling-eu.herokuapp.com'],
});
// Central WebSocket Relay fallback (for reliable cross-firewall sync)
this.wsProvider = new WebsocketProvider(
'wss://demos.yjs.dev',
`room-${boardId}`,
this.doc
);
}
// --- CRUD Operations (All 100% Synchronous and Zero-Latency) ---
addTask(id: string, title: string) {
this.doc.transact(() => {
this.tasksMap.set(id, {
id,
title,
completed: false,
updatedAt: Date.now(),
});
});
}
toggleTask(id: string) {
const existing = this.tasksMap.get(id);
if (!existing) return;
this.doc.transact(() => {
this.tasksMap.set(id, {
...existing,
completed: !existing.completed,
updatedAt: Date.now(),
});
});
}
deleteTask(id: string) {
this.tasksMap.delete(id);
}
subscribe(callback: (tasks: TaskItem[]) => void) {
const observer = () => {
const items = Array.from(this.tasksMap.values());
callback(items);
};
this.tasksMap.observe(observer);
// Initial emission
observer();
return () => {
this.tasksMap.unobserve(observer);
};
}
destroy() {
this.webrtcProvider?.destroy();
this.wsProvider?.destroy();
this.persistence.destroy();
this.doc.destroy();
}
}
Việc sử dụng kho lưu trữ này bên trong các thành phần React không yêu cầu bất kỳ yêu cầu tìm nạp nào. Chúng tôi đăng ký trực tiếp với trình quan sát CRDT cục bộ:
'use client';
import { useEffect, useState, useMemo } from 'react';
import { LocalFirstTaskStore, TaskItem } from './local-store';
export function KanbanBoard({ boardId }: { boardId: string }) {
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [inputTitle, setInputTitle] = useState('');
const store = useMemo(() => new LocalFirstTaskStore(boardId), [boardId]);
useEffect(() => {
const unsubscribe = store.subscribe((updatedTasks) => {
setTasks(updatedTasks);
});
return () => {
unsubscribe();
store.destroy();
};
}, [store]);
function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!inputTitle.trim()) return;
store.addTask(crypto.randomUUID(), inputTitle.trim());
setInputTitle('');
}
return (
<div className="p-6 max-w-xl mx-auto space-y-4">
<h1 className="text-2xl font-bold">Offline-First Tasks</h1>
<form onSubmit={handleCreate} className="flex gap-2">
<input
type="text"
value={inputTitle}
onChange={(e) => setInputTitle(e.target.value)}
placeholder="New task (works offline)..."
className="flex-1 rounded border px-3 py-2 text-sm"
/>
<button type="submit" className="rounded bg-blue-600 px-4 py-2 text-white text-sm font-medium">
Add Task
</button>
</form>
<ul className="divide-y border rounded-xl overflow-hidden bg-white dark:bg-gray-900">
{tasks.map((task) => (
<li key={task.id} className="flex items-center justify-between p-3">
<span className={task.completed ? 'line-through text-gray-400' : ''}>
{task.title}
</span>
<div className="flex gap-2">
<button
onClick={() => store.toggleTask(task.id)}
className="text-xs px-2 py-1 rounded bg-gray-100 dark:bg-gray-800"
>
{task.completed ? 'Undo' : 'Done'}
</button>
<button
onClick={() => store.deleteTask(task.id)}
className="text-xs px-2 py-1 rounded bg-red-50 text-red-600"
>
Delete
</button>
</div>
</li>
))}
</ul>
</div>
);
}
Mặc dù ưu tiên cục bộ mang lại trải nghiệm người dùng tối ưu, nhưng nó cũng đặt ra những đánh đổi kiến trúc độc đáo:
prisma migrate sẽ cập nhật lược đồ trung tâm duy nhất. Trong ưu tiên cục bộ, hàng ngàn thiết bị máy khách có thể đang chạy các phiên bản mã từ sáu tháng trước. Các thay đổi lược đồ CRDT phải luôn tương thích ngược một cách nghiêm ngặt.AES-GCM-256) trước khi truyền. Máy chủ đồng bộ hóa định tuyến các blob được mã hóa mờ mà không bao giờ đọc dữ liệu người dùng.Cả hai đều là các triển khai CRDT hàng đầu trong ngành. Yjs được viết bằng JavaScript và được tối ưu hóa mạnh mẽ cho việc chỉnh sửa văn bản cộng tác theo thời gian thực và sử dụng bộ nhớ thấp. Automerge được triển khai bằng Rust (với các ràng buộc WebAssembly) và tập trung vào các cấu trúc tài liệu JSON phong phú, phiên bản du hành thời gian và các bằng chứng mật mã chính thức.
Có! Các kiến trúc lai sử dụng các công cụ như ElectricSQL, PowerSync hoặc Zero (của Rocicorp). Các công cụ này chạy một công cụ SQLite cục bộ trên máy khách và liên tục truyền các delta đồng bộ hóa hai chiều đến một cơ sở dữ liệu PostgreSQL trung tâm.
CRDTs theo dõi lịch sử hoạt động (tombstones). Nếu người dùng xóa 10.000 mục, các CRDTs đơn giản sẽ giữ lại các tombstones xóa mãi mãi. Yjs triển khai Nén Vector Trạng thái - khi tất cả các peer đang hoạt động đã xác nhận một ảnh chụp nhanh trạng thái, các tombstones sẽ được thu gom rác và hợp nhất thành một biểu diễn nhị phân nhỏ gọn.
Một nhóm phát triển đang xây dựng một trình soạn thảo tài liệu cộng tác mới. Họ đang tranh luận giữa kiến trúc client-server truyền thống với Operational Transformation (OT) để cộng tác theo thời gian thực và kiến trúc local-first tận dụng CRDTs. Kịch bản nào sau đây sẽ ủng hộ mạnh mẽ nhất việc chọn phương pháp CRDT local-first thay vì OT?
Làm sạch log AI CLI, tạo biểu thức cron, giải mã JWT và tính quyền chmod offline.

Điện toán biên nghe có vẻ như một từ ngữ tiếp thị sáo rỗng cho đến khi bạn thực sự triển khai thứ gì đó ở đó. Đây là ý nghĩa của nó đối với các nhà phát triển web, nơi nó thực sự hữu ích và những cạm bẫy tôi gặp phải mà không ai cảnh báo.
Đọc tiếp
Hướng dẫn toàn diện về lưu trữ trình duyệt hiệu suất cao: sqlite wasm, origin private file system (opfs) & web workers với kiến trúc cấp độ sản xuất và các ví dụ mã.
Đọc tiếp
Hướng dẫn chuẩn production toàn diện cho Next.js App Router: Ranh giới Server Components, caching nâng cao, streaming với Suspense, Server Actions, connection pooling và kiến trúc route cho enterprise.
Đọc tiếp