Xây dựng ứng dụng web Offline-First với IndexedDB và Dexie.js: Hướng dẫn lưu trữ cục bộ và đồng bộ nền

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Bối cảnh & Lý do

Hầu hết các ứng dụng web đều mặc định rằng thiết bị luôn có kết nối internet ổn định. Nhưng chỉ cần người dùng bước vào thang máy, lên máy bay, hoặc đi qua khu vực mất sóng di động, giả định đó sụp đổ hoàn toàn. Vòng tròn tải trang quay vô tận. Dữ liệu form chưa kịp lưu biến mất. Các lệnh gọi API thất bại với hàng loạt thông báo lỗi mạng khó hiểu. Việc xây dựng frontend xem hiện tượng rớt mạng như một ngoại lệ hiếm gặp sẽ tạo ra những phần mềm thiếu ổn định và gây ức chế cho người dùng.

Mô hình phát triển offline-first đảo ngược thứ tự ưu tiên đó. Ứng dụng xem bộ nhớ cục bộ trên trình duyệt là nguồn dữ liệu chuẩn (source of truth). Khi ai đó tạo hóa đơn hoặc cập nhật tồn kho, thay đổi sẽ được ghi tức thì vào ổ đĩa của thiết bị. Giao diện người dùng cập nhật ngay lập tức—không cần chờ phản hồi mạng. Quá trình đồng bộ hóa diễn ra hoàn toàn ở chế độ nền. Nếu kết nối ổn định, dữ liệu sẽ đồng bộ lên máy chủ trong vài mili-giây. Nếu mất mạng, các thay đổi (mutations) sẽ nằm chờ trong hàng đợi cục bộ có đánh chỉ mục cho đến khi thiết bị trực tuyến trở lại.

Trình duyệt cung cấp một vài tùy chọn lưu trữ cục bộ, nhưng khả năng của chúng rất khác nhau. API localStorage quen thuộc chặn (block) luồng xử lý chính của JavaScript, giới hạn dung lượng khoảng 5MB cho mỗi origin, và chỉ lưu được chuỗi ký tự thuần. Điều này khiến nó không phù hợp với các tập dữ liệu lớn.

API IndexedDB nguyên bản giải quyết trọn vẹn những hạn chế này. Nó hoạt động bất đồng bộ, hỗ trợ transaction và có thể lưu trữ hàng gigabyte dữ liệu nhị phân cũng như JSON có cấu trúc. Trên các trình duyệt nhân Chromium, IndexedDB có thể sử dụng tới 60% dung lượng đĩa trống, mang đến không gian rộng rãi cho hàng chục ngàn bản ghi offline.

Tuy nhiên, IndexedDB thuần lại có một nhược điểm: cú pháp API rất dài dòng và phức tạp. Việc sử dụng các callback sự kiện cũ như onsuccess, onerror và onupgradeneeded biến các thao tác CRUD thông thường thành hàng chục dòng mã boilerplate phòng thủ lỗi. Các lỗi transaction không được bắt chuẩn có thể âm thầm thất bại và khiến các kết nối cơ sở dữ liệu bị treo lơ lửng.

Dexie.js ra đời để giải quyết triệt để vấn đề này. Với kích thước chỉ khoảng 24KB khi nén gzip, Dexie cung cấp một lớp trừu tượng (abstraction) tinh gọn, dựa trên Promise để bao bọc IndexedDB. Bạn có được khả năng truy vấn trực quan, quản lý vòng đời transaction tích hợp sẵn và quản lý phiên bản schema dễ dàng mà không làm suy giảm hiệu năng của engine trình duyệt gốc.

Cài đặt

Thiết lập dự án của bạn với Dexie và một thư viện tạo UUID phía client. Việc sinh ID ở client cho phép frontend gán mã định danh thực thể cố định ngay lập tức. Điều này giúp ngăn chặn xung đột khóa chính khi đồng bộ hàng loạt thay đổi offline về cơ sở dữ liệu phía backend như PostgreSQL hoặc MongoDB.

# Sử dụng npm
npm install dexie uuid
npm install --save-dev @types/uuid

# Sử dụng pnpm
pnpm add dexie uuid
pnpm add -D @types/uuid

Tính năng Service Worker Background Sync yêu cầu ngữ cảnh bảo mật (secure context). Hãy đảm bảo server phát triển cục bộ của bạn chạy trên localhost hoặc qua giao thức HTTPS. Trình duyệt sẽ từ chối đăng ký các handler đồng bộ của service worker qua HTTP thông thường.

Cấu hình

Kiến trúc offline của chúng ta dựa vào hai thành phần chính: schema IndexedDB quản lý các bản ghi cục bộ song song với hàng đợi outbox, và một sync engine xử lý các yêu cầu đang chờ khi có kết nối mạng trở lại.

1. Định nghĩa Database Schema với Dexie

Tạo file cấu hình cơ sở dữ liệu tại db.ts. Chúng ta sẽ định nghĩa hai bảng (store): inventory để lưu cache các bản ghi nghiệp vụ và syncQueue làm hàng đợi transactional outbox.

import Dexie, { Table } from 'dexie';

export interface InventoryItem {
  id: string;
  name: string;
  sku: string;
  quantity: number;
  updatedAt: number;
}

export interface OutboxMutation {
  id: string;
  action: 'CREATE' | 'UPDATE' | 'DELETE';
  endpoint: string;
  payload: Record<string, unknown>;
  timestamp: number;
  retryCount: number;
}

export class AppDatabase extends Dexie {
  inventory!: Table<InventoryItem, string>;
  syncQueue!: Table<OutboxMutation, string>;

  constructor() {
    super('AppOfflineDatabase');

    // Trong Dexie, trường đầu tiên được đánh chỉ mục luôn là khóa chính.
    // Các thuộc tính được đánh chỉ mục bổ sung được liệt kê phân tách bằng dấu phẩy.
    this.version(1).stores({
      inventory: 'id, sku, updatedAt',
      syncQueue: 'id, timestamp, retryCount'
    });
  }
}

export const db = new AppDatabase();

2. Triển khai Outbox Repository Pattern

Hiện tượng gián đoạn mạng có thể xảy ra bất cứ lúc nào. Để tránh trạng thái giao diện bị lệch pha so với hàng đợi đồng bộ, hãy gói cả hai thao tác cập nhật vào trong một transaction Dexie nguyên tử (atomic). Cả hai thao tác cùng thành công, hoặc không có thao tác nào được lưu.

import { v4 as uuidv4 } from 'uuid';
import { db, InventoryItem, OutboxMutation } from './db';
import { triggerSyncProcess } from './syncService';

export async function saveInventoryItem(
  itemData: Omit<InventoryItem, 'id' | 'updatedAt'>
): Promise<InventoryItem> {
  const now = Date.now();
  const newItem: InventoryItem = {
    ...itemData,
    id: uuidv4(),
    updatedAt: now
  };

  const mutation: OutboxMutation = {
    id: uuidv4(),
    action: 'CREATE',
    endpoint: '/api/v1/inventory',
    payload: newItem as unknown as Record<string, unknown>,
    timestamp: now,
    retryCount: 0
  };

  // Ghi kép nguyên tử: cập nhật bảng UI cục bộ và đẩy yêu cầu đồng bộ vào hàng đợi
  await db.transaction('rw', [db.inventory, db.syncQueue], async () => {
    await db.inventory.put(newItem);
    await db.syncQueue.put(mutation);
  });

  // Kích hoạt đồng bộ ngay lập tức nếu client hiện đang online
  triggerSyncProcess();

  return newItem;
}

3. Xây dựng Background Synchronization Engine

Tạo file syncService.ts. Module này sẽ kiểm tra outbox, gửi các yêu cầu HTTP trong hàng đợi theo thứ tự thời gian và xóa bỏ bản ghi khi máy chủ xác nhận thành công.

import { db } from './db';

let isSyncing = false;
const MAX_RETRIES = 5;

export async function triggerSyncProcess(): Promise<void> {
  // Kiểm tra kết nối nhanh; tránh tiêu tốn tài nguyên khi đang offline
  if (!navigator.onLine || isSyncing) {
    return;
  }

  isSyncing = true;

  try {
    // Xử lý các bản ghi đang chờ theo cơ chế FIFO để bảo toàn thứ tự thay đổi
    const pendingMutations = await db.syncQueue
      .orderBy('timestamp')
      .toArray();

    for (const mutation of pendingMutations) {
      try {
        const response = await fetch(mutation.endpoint, {
          method: mutation.action === 'CREATE' ? 'POST' : 'PUT',
          headers: {
            'Content-Type': 'application/json',
            'X-Idempotency-Key': mutation.id
          },
          body: JSON.stringify(mutation.payload)
        });

        if (response.ok) {
          // Máy chủ đã chấp nhận thay đổi; xóa bản ghi khỏi hàng đợi cục bộ
          await db.syncQueue.delete(mutation.id);
        } else if (response.status >= 400 && response.status < 500) {
          // Lỗi 4xx cho thấy payload không hợp lệ hoặc sai lệch schema.
          // Xóa bản ghi để tránh tắc nghẽn hàng đợi (head-of-line blocking).
          console.error(`Lỗi xác thực không thể khắc phục (${response.status}) trên mutation ${mutation.id}`);
          await db.syncQueue.delete(mutation.id);
        } else {
          // Lỗi 5xx chỉ sự cố tạm thời ở máy chủ; tăng bộ đếm số lần thử lại
          await handleSyncFailure(mutation.id, mutation.retryCount);
        }
      } catch (networkError) {
        // Đóng socket hoặc mất kết nối; tạm dừng xử lý hàng đợi
        console.warn('Mất kết nối mạng giữa chừng khi đồng bộ. Tạm dừng hàng đợi.', networkError);
        break;
      }
    }
  } finally {
    isSyncing = false;
  }
}

async function handleSyncFailure(id: string, currentRetries: number): Promise<void> {
  if (currentRetries + 1 >= MAX_RETRIES) {
    console.error(`Mutation ${id} đã thử lại thất bại 5 lần. Đưa vào diện cách ly.`);
    await db.syncQueue.delete(id);
  } else {
    await db.syncQueue.update(id, { retryCount: currentRetries + 1 });
  }
}

// Tự động tiếp tục đồng bộ mỗi khi giao diện mạng kết nối trở lại
window.addEventListener('online', () => {
  console.info('Đã khôi phục kết nối. Đang giải phóng hàng đợi outbox...');
  triggerSyncProcess();
});

4. Đăng ký Service Worker Background Sync

Trình duyệt sẽ đóng ngữ cảnh thực thi JavaScript khi người dùng rời khỏi trang hoặc đóng tab. API SyncManager khắc phục điều này bằng cách ủy quyền tác vụ thử lại trực tiếp cho tiến trình nền của service worker.

Lưu ý rằng mức độ hỗ trợ giữa các trình duyệt là khác nhau. Các trình duyệt Chromium (Chrome, Edge, Brave) hỗ trợ đầy đủ Background Sync API. Safari và Firefox hiện chưa hỗ trợ. Mã nguồn phía client nên kiểm tra tính năng SyncManager và chuyển hướng dự phòng mượt mà sang lắng nghe sự kiện online chuẩn trên window.

export async function registerBackgroundSyncTask(tag: string): Promise<void> {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const registration = await navigator.serviceWorker.ready;
    try {
      // Ủy quyền việc chạy lại outbox cho tiến trình nền ServiceWorker
      await (registration as any).sync.register(tag);
      return;
    } catch (err) {
      console.warn('Đăng ký background sync thất bại. Chuyển sang cơ chế dự phòng trên trang.', err);
    }
  }

  // Cơ chế dự phòng cho Safari, Firefox hoặc các webview không được hỗ trợ
  triggerSyncProcess();
}

Kiểm thử & Giám sát

Đừng bao giờ mặc định luồng offline hoạt động trơn tru cho đến khi bạn kiểm thử nó dưới điều kiện mô phỏng rớt gói tin và kiểm tra dung lượng lưu trữ cục bộ tiêu thụ.

1. Kiểm tra trạng thái IndexedDB và mạng trong DevTools

  1. Mở Chrome DevTools (F12 hoặc Cmd+Option+I) và chọn tab Application.
  2. Mở rộng mục Storage > IndexedDB > AppOfflineDatabase. Đảm bảo rằng cả hai bảng inventory và syncQueue đều tồn tại.
  3. Chuyển sang tab Network. Đổi chế độ điều tiết (throttling) từ No throttling thành Offline.
  4. Lưu một mặt hàng tồn kho mới trong ứng dụng. Bản ghi sẽ hiển thị ngay lập tức trên UI và được ghi vào cả hai bảng trong IndexedDB. DevTools sẽ không hiển thị bất kỳ yêu cầu mạng nào bị lỗi vì không có lệnh fetch nào được gửi bừa bãi vào hư không.
  5. Chuyển thiết lập Network trở lại Online (hoặc Fast 4G). Quan sát bảng syncQueue được dọn sạch trong vòng 500ms khi các yêu cầu HTTP POST thành công với mã trạng thái 200 hoặc 201 trong Network log.

2. Mô phỏng sự kiện Background Sync

Bạn không cần phải bật/tắt công tắc Wi-Fi vật lý để kiểm thử service worker. Trong DevTools, vào tab Application > Service Workers. Tìm trường nhập liệu Sync, nhập sync tag bạn đã đăng ký (ví dụ: inventory-sync), và nhấp vào Trigger Sync. DevTools sẽ phát một sự kiện đồng bộ giả lập trực tiếp vào worker script của bạn.

3. Giám sát hạn ngạch bộ nhớ và Telemetry

Các thiết bị di động có dung lượng bộ nhớ flash hạn hẹp có thể tự động xóa phân vùng IndexedDB tạm thời mà không cảnh báo trước. Bạn có thể bảo vệ ứng dụng bằng cách kiểm tra mức sử dụng hạn ngạch và yêu cầu trình duyệt cấp quyền lưu trữ lâu dài (persistent storage).

export async function auditStorageHealth(): Promise<void> {
  if (!navigator.storage?.estimate) {
    return;
  }

  const { usage, quota } = await navigator.storage.estimate();
  const usedMb = ((usage ?? 0) / (1024 * 1024)).toFixed(2);
  const quotaMb = ((quota ?? 0) / (1024 * 1024)).toFixed(2);
  const percentUsed = usage && quota ? ((usage / quota) * 100).toFixed(1) : '0';

  console.info(`Bộ nhớ cục bộ: Đã dùng ${usedMb} MB trên tổng số ${quotaMb} MB khả dụng (${percentUsed}%).`);

  // Yêu cầu cấp quyền lưu trữ lâu dài để trình duyệt không tự ý xóa cache IndexedDB khi thiếu dung lượng đĩa
  if (navigator.storage.persist) {
    const isPersistent = await navigator.storage.persist();
    console.info(`Đã cấp quyền lưu trữ lâu dài: ${isPersistent}`);
  }
}

Việc kết hợp Dexie.js với worker đồng bộ outbox biến đường truyền mạng chập chờn từ một lỗi nghiêm trọng thành một chi tiết xử lý ngầm. Giao diện của bạn luôn phản hồi tức thì, các thay đổi dữ liệu vẫn tồn tại qua những lần khởi động lại trình duyệt, và backend luôn nhận được dữ liệu sạch sẽ, không trùng lặp.

Share: