•11 min read

Xây dựng ứng dụng web ưu tiên cục bộ với CRDT: Kiến trúc Yjs & IndexedDB hoàn chỉnh

Xây dựng ứng dụng web ưu tiên cục bộ với CRDT: Kiến trúc Yjs & IndexedDB hoàn chỉnh

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.


Audio Briefing
0:00 / 0:00

Các nguyên tắc cốt lõi của phần mềm ưu tiên cục bộ

Một ứng dụng ưu tiên cục bộ tuân thủ bảy lý tưởng nền tảng:

  1. Đọc và ghi không độ trễ: Mọi tương tác (nhấp, gõ, sắp xếp lại) đều thay đổi bộ nhớ đĩa cục bộ ngay lập tức. Người dùng không bao giờ thấy biểu tượng quay tròn tải.
  2. Đồng bộ hóa liền mạch đa thiết bị: Công việc được tạo trên máy tính xách tay sẽ đồng bộ hóa liền mạch với điện thoại thông minh hoặc máy tính để bàn khi có kết nối.
  3. Mạng tùy chọn: Ứng dụng hoạt động 100% ở chế độ máy bay hoàn toàn mà không bị giảm chức năng.
  4. Cộng tác theo mặc định: Hai hoặc nhiều người dùng có thể đồng thời chỉnh sửa cùng một tài liệu mà không ghi đè lên đóng góp của nhau.
  5. Tuổi thọ dữ liệu: Nếu công ty cung cấp máy chủ đồng bộ hóa đám mây phá sản hoặc ngừng hoạt động, dữ liệu của người dùng vẫn hoàn toàn có thể truy cập được trên đĩa cục bộ của họ mãi mãi.
[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)

Advertisement

Toán học của CRDTs: Tại sao thứ tự hợp nhất không quan trọng

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:

  1. Tính giao hoán: A \cdot B = B \cdot A (Thứ tự các bản cập nhật được nhận không quan trọng).
  2. Tính kết hợp: (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ả).
  3. Tính lũy đẳng: 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.


Triển khai sản xuất với Yjs và IndexedDB

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.

Bước 1: Cài đặt các dependency

npm install yjs y-indexeddb y-webrtc y-websocket

Bước 2: Khởi tạo tài liệu cục bộ & lớp lưu trữ bền vững

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();
  }
}

Tích hợp với React 19

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>
  );
}

Advertisement

Thực tế sản xuất & Đánh đổi kỹ thuật

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:

  1. Giới hạn lưu trữ: Mobile Safari theo truyền thống giới hạn IndexedDB ở 1GB trừ khi được người dùng cấp quyền rõ ràng. Đối với các ứng dụng nặng về phương tiện, hãy lưu trữ các tệp nhị phân (ảnh, video) trong bộ lưu trữ đối tượng đám mây (S3) và giữ các tham chiếu siêu dữ liệu CRDT cục bộ.
  2. Phát triển lược đồ & Di chuyển: Trong các cơ sở dữ liệu đám mây truyền thống, việc chạy 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.
  3. Mã hóa đầu cuối: Vì các relay đồng bộ hóa chỉ đơn thuần định tuyến các vector trạng thái CRDT nhị phân, bạn có thể dễ dàng mã hóa các tải trọng dữ liệu trên máy khách bằng Web Crypto (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ác câu hỏi thường gặp

Sự khác biệt giữa Yjs và Automerge là gì?

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 ứng dụng ưu tiên cục bộ có thể hoạt động với các cơ sở dữ liệu quan hệ truyền thống không?

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.

Yjs ngăn các vector trạng thái phát triển vô hạn như thế nào?

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.


Bạn cũng có thể thích

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement