Sử dụng IndexedDB để lưu trữ dữ liệu phía client

Khi làm việc với IndexedDB, một trong những nhu cầu phổ biến là tìm kiếm dữ liệu dựa trên trường tên (name). Để thực hiện điều này hiệu quả, bước đầu tiên và quan trọng nhất là tạo chỉ mục cho trường cần truy vấn, sau đó sử dụng chỉ mục này để thực thi các thao tác đọc.

Mã nguồn hoàn chỉnh (bao gồm khởi tạo cơ sở dữ liệu, tạo chỉ mục và truy vấn theo tên)

Dưới đây là ví dụ đầy đủ về cách thiết lập IndexedDB, thêm dữ liệu và thực hiện tìm kiếm chính xác theo tên:

// Lớp tiện ích hỗ trợ thao tác với IndexedDB
class IndexedDBManager {
  constructor(databaseName, storeName, version = 1) {
    this.databaseName = databaseName;
    this.storeName = storeName;
    this.version = version;
    this.databaseInstance = null;
  }

  // 1. Khởi tạo cơ sở dữ liệu (tạo object store và index cho trường 'name')
  async initializeDatabase() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.databaseName, this.version);

      request.onupgradeneeded = (event) => {
        this.databaseInstance = event.target.result;
        if (!this.databaseInstance.objectStoreNames.contains(this.storeName)) {
          const store = this.databaseInstance.createObjectStore(this.storeName, {
            keyPath: 'id',
            autoIncrement: true
          });
          store.createIndex('nameFieldIndex', 'name', { unique: false });
          console.log('Tạo thành công object store và chỉ mục cho trường tên');
        }
      };

      request.onsuccess = (event) => {
        this.databaseInstance = event.target.result;
        resolve(this.databaseInstance);
      };

      request.onerror = (event) => {
        reject(new Error('Không thể mở cơ sở dữ liệu: ' + event.target.error));
      };
    });
  }

  // 2. Thêm bản ghi mới vào cơ sở dữ liệu
  async insertRecord(record) {
    if (!this.databaseInstance) await this.initializeDatabase();
    return new Promise((resolve, reject) => {
      const transaction = this.databaseInstance.transaction(this.storeName, 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const operation = store.add(record);

      operation.onsuccess = () => resolve('Thêm bản ghi thành công');
      operation.onerror = (event) => reject(new Error('Lỗi khi thêm bản ghi: ' + event.target.error));
    });
  }

  // 3. Truy vấn chính xác theo tên
  async findRecordsByName(targetName) {
    if (!this.databaseInstance) await this.initializeDatabase();
    return new Promise((resolve, reject) => {
      const transaction = this.databaseInstance.transaction(this.storeName, 'readonly');
      const store = transaction.objectStore(this.storeName);
      const nameIndex = store.index('nameFieldIndex');
      const searchRequest = nameIndex.getAll(targetName);

      searchRequest.onsuccess = (event) => {
        resolve(event.target.result);
      };

      searchRequest.onerror = (event) => {
        reject(new Error('Truy vấn thất bại: ' + event.target.error));
      };
    });
  }
}

// ------------------- Ví dụ sử dụng -------------------
(async () => {
  const dbManager = new IndexedDBManager('userDataDB', 'userStore');
  await dbManager.initializeDatabase();

  // Thêm vài bản ghi mẫu
  await dbManager.insertRecord({ name: 'Nguyễn Văn A', age: 25, phone: '0987654321' });
  await dbManager.insertRecord({ name: 'Trần Thị B', age: 30, phone: '0912345678' });
  await dbManager.insertRecord({ name: 'Nguyễn Văn A', age: 28, phone: '0909876543' }); 

  // Tìm kiếm theo tên
  const results = await dbManager.findRecordsByName('Nguyễn Văn A');
  console.log('Kết quả tìm kiếm:', results);
  // Output: [{id:1, name:'Nguyễn Văn A', age:25, phone:'0987654321'}, {id:3, name:'Nguyễn Văn A', age:28, phone:'0909876543'}]
})();

Giải thích logic cốt lõi

  • Tạo chỉ mục: Trong giai đoạn onupgradeneeded, gọi phương thức createIndex('nameFieldIndex', 'name', { unique: false }) để tạo chỉ mục cho trường tên.
  • Các API chính để truy vấn:
    • index.getAll(name): Trả về tất cả các bản ghi khớp với tên đã cho.
    • index.get(name): Chỉ trả về bản ghi đầu tiên khớp với tên.
  • Loại giao dịch:
    • Sử dụng readonly cho các thao tác đọc để tối ưu hiệu năng.
    • Bắt buộc dùng readwrite cho các thao tác ghi như thêm, sửa, xóa.

Mở rộng: Tìm kiếm gần đúng theo tên

Nếu bạn muốn tìm kiếm gần đúng (ví dụ: nhập "Nguyễn" sẽ trả về tất cả người có họ Nguyễn), hãy áp dụng kỹ thuật phạm vi (range query):

// Phương thức tìm kiếm gần đúng (thêm vào lớp IndexedDBManager)
async searchByNamePrefix(prefix) {
  if (!this.databaseInstance) await this.initializeDatabase();
  return new Promise((resolve, reject) => {
    const transaction = this.databaseInstance.transaction(this.storeName, 'readonly');
    const store = transaction.objectStore(this.storeName);
    const nameIndex = store.index('nameFieldIndex');
    const range = IDBKeyRange.bound(prefix, prefix + '\uffff');
    const request = nameIndex.getAll(range);

    request.onsuccess = (event) => resolve(event.target.result);
    request.onerror = (event) => reject(new Error('Tìm kiếm gần đúng thất bại: ' + event.target.error));
  });
}

// Gọi hàm: Tìm tất cả người có họ bắt đầu bằng 'Nguyễn'
// const fuzzyResults = await dbManager.searchByNamePrefix('Nguyễn');
// console.log('Kết quả tìm kiếm gần đúng:', fuzzyResults);

Ví dụ đơn giản hơn

<!DOCTYPE html>
<html lang="vi">
<head>
  <meta charset="UTF-8">
  <title>Minh họa CRUD IndexedDB</title>
</head>
<body>
  <button id="btnAdd">Thêm dữ liệu</button>
  <button id="btnQuery">Truy vấn toàn bộ</button>
  <script>
    const dbOpenRequest = indexedDB.open('simpleDB', 1);

    dbOpenRequest.onerror = function(event) {
      console.error('Mở cơ sở dữ liệu thất bại:', event.target.errorCode);
    };

    dbOpenRequest.onsuccess = function(event) {
      const db = event.target.result;

      document.getElementById('btnAdd').onclick = function() {
        const tx = db.transaction(['sampleStore'], 'readwrite');
        const objStore = tx.objectStore('sampleStore');
        const newData = { id: 1, name: 'Phạm Văn C', age: 22 };
        const addReq = objStore.add(newData);
        addReq.onsuccess = () => console.log('Dữ liệu được thêm thành công');
        addReq.onerror = (e) => console.error('Lỗi thêm dữ liệu:', e.target.errorCode);
      };

      document.getElementById('btnQuery').onclick = function() {
        const tx = db.transaction(['sampleStore'], 'readonly');
        const objStore = tx.objectStore('sampleStore');
        const getAllReq = objStore.getAll();
        getAllReq.onsuccess = (e) => console.log('Toàn bộ dữ liệu:', e.target.result);
        getAllReq.onerror = (e) => console.error('Lỗi truy vấn:', e.target.errorCode);
      };
    };

    dbOpenRequest.onupgradeneeded = function(event) {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('sampleStore')) {
        const sampleStore = db.createObjectStore('sampleStore', { keyPath: 'id' });
        sampleStore.createIndex('name', 'name', { unique: false });
      }
    };
  </script>
</body>
</html>

Lưu trữ và hiển thị ảnh từ IndexedDB

<!DOCTYPE html>
<html lang="vi">
<head>
  <meta charset="UTF-8">
  <title>Lưu trữ ảnh trong IndexedDB</title>
  <style>
    .preview { border: 1px solid #ccc; min-height: 300px; margin-top: 20px; }
    img { max-width: 100%; display: none; }
  </style>
</head>
<body>
  <input type="file" id="imagePicker" accept="image/*">
  <button id="saveImageBtn">Lưu ảnh</button>
  <button id="loadLastImageBtn">Hiển thị ảnh cuối cùng</button>
  <div class="preview">
    <img id="loadedImage">
  </div>
  <p id="statusMessage">Sẵn sàng</p>

  <script>
    const DB_NAME = "ImageStorage";
    const STORE_NAME = "images";
    const DB_VERSION = 1;
    let databaseConnection = null;

    function updateStatus(text) {
      document.getElementById('statusMessage').textContent = text;
    }

    function initDatabase() {
      return new Promise((resolve, reject) => {
        const req = indexedDB.open(DB_NAME, DB_VERSION);
        req.onupgradeneeded = (event) => {
          const db = event.target.result;
          if (!db.objectStoreNames.contains(STORE_NAME)) {
            db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
            updateStatus("Đã tạo object store");
          }
        };
        req.onsuccess = (event) => {
          databaseConnection = event.target.result;
          updateStatus("Kết nối cơ sở dữ liệu thành công");
          resolve(databaseConnection);
        };
        req.onerror = (event) => {
          updateStatus(`Lỗi cơ sở dữ liệu: ${event.target.error}`);
          reject(event.target.error);
        };
      });
    }

    async function saveImageToFile() {
      const file = document.getElementById('imagePicker').files[0];
      if (!file) {
        updateStatus("Vui lòng chọn một tệp hình ảnh");
        return;
      }

      try {
        const buffer = await new Promise((resolve, reject) => {
          const reader = new FileReader();
          reader.onload = () => resolve(reader.result);
          reader.onerror = () => reject(reader.error);
          reader.readAsArrayBuffer(file);
        });

        const tx = databaseConnection.transaction([STORE_NAME], 'readwrite');
        const store = tx.objectStore(STORE_NAME);

        const imageData = {
          fileName: file.name,
          fileType: file.type,
          fileSize: file.size,
          dateSaved: new Date(),
          binaryData: buffer
        };

        const saveRequest = store.add(imageData);
        saveRequest.onsuccess = () => updateStatus(`Lưu thành công ID: ${saveRequest.result}`);
        saveRequest.onerror = (event) => updateStatus(`Lưu thất bại: ${event.target.error}`);

      } catch (err) {
        updateStatus(`Lỗi: ${err.message}`);
      }
    }

    async function loadLastSavedImage() {
      const tx = databaseConnection.transaction([STORE_NAME], 'readonly');
      const store = tx.objectStore(STORE_NAME);
      const cursorRequest = store.openCursor(null, 'prev');

      cursorRequest.onsuccess = (event) => {
        const cursor = event.target.result;
        if (cursor) {
          showStoredImage(cursor.value);
        } else {
          updateStatus("Không tìm thấy ảnh nào trong cơ sở dữ liệu");
        }
      };

      cursorRequest.onerror = (event) => {
        updateStatus(`Tải ảnh thất bại: ${event.target.error}`);
      };
    }

    function showStoredImage(record) {
      const blob = new Blob([record.binaryData], { type: record.fileType });
      const url = URL.createObjectURL(blob);

      const imgElement = document.getElementById('loadedImage');
      imgElement.src = url;
      imgElement.style.display = 'block';
      imgElement.alt = `Ảnh đã tải: ${record.fileName}`;

      imgElement.onload = () => URL.revokeObjectURL(url);
      updateStatus(`Đã tải: ${record.fileName} (${formatFileSize(record.fileSize)})`);
    }

    function formatFileSize(sizeInBytes) {
      const units = ['B', 'KB', 'MB', 'GB'];
      let size = sizeInBytes;
      let unitIndex = 0;
      while (size >= 1024 && unitIndex < units.length - 1) {
        size /= 1024;
        unitIndex++;
      }
      return `${size.toFixed(2)} ${units[unitIndex]}`;
    }

    window.addEventListener('DOMContentLoaded', async () => {
      updateStatus("Khởi tạo cơ sở dữ liệu...");
      try {
        await initDatabase();
        document.getElementById('saveImageBtn').addEventListener('click', saveImageToFile);
        document.getElementById('loadLastImageBtn').addEventListener('click', loadLastSavedImage);
        updateStatus("Sẵn sàng: Chọn ảnh rồi nhấn Lưu");
      } catch (err) {
        updateStatus(`Khởi tạo thất bại: ${err.message}`);
      }
    });
  </script>
</body>
</html>

Ứng dụng trong dự án UniApp với Vue 3

Tạo file groupChatDb.js trong thư mục utils:

/**
 * Lớp tiện ích IndexedDB dành riêng cho UniApp H5 + Vue3
 * Chỉ chạy ổn định trên môi trường trình duyệt web
 */
class UniIndexedDBWrapper {
    constructor(dbName = 'defaultDB', storeName = 'defaultStore', version = 1) {
        this.dbName = dbName;
        this.storeName = storeName;
        this.version = version;
        this.dbInstance = null;
        this.isSupported = this.checkEnvironmentSupport();
    }

    checkEnvironmentSupport() {
        // #ifdef H5
        return !!window.indexedDB;
        // #endif
        // #ifndef H5
        uni.showToast({ title: 'Môi trường không hỗ trợ IndexedDB', icon: 'none' });
        return false;
        // #endif
    }

    openDatabase() {
        return new Promise((resolve, reject) => {
            if (!this.isSupported) {
                reject(new Error('Môi trường không hỗ trợ IndexedDB'));
                return;
            }
            const request = window.indexedDB.open(this.dbName, this.version);
            request.onupgradeneeded = (event) => {
                this.dbInstance = event.target.result;
                if (!this.dbInstance.objectStoreNames.contains(this.storeName)) {
                    const objectStore = this.dbInstance.createObjectStore(this.storeName, {
                        keyPath: 'fid',
                        autoIncrement: true
                    });
                    objectStore.createIndex('id', 'id', {unique: false});
                    objectStore.createIndex('conversationId', 'conversationId', {unique: false});
                }
            };
            request.onsuccess = (event) => {
                this.dbInstance = event.target.result;
                resolve('Mở cơ sở dữ liệu thành công');
            };
            request.onerror = (event) => {
                reject(new Error(`Mở cơ sở dữ liệu thất bại: ${event.target.error}`));
            };
        });
    }

    closeDatabase() {
        if (this.dbInstance) {
            this.dbInstance.close();
            this.dbInstance = null;
        }
    }

    deleteDatabase() {
        return new Promise((resolve, reject) => {
            if (!this.isSupported) {
                reject(new Error('Môi trường không hỗ trợ IndexedDB'));
                return;
            }
            this.closeDatabase();
            const deleteRequest = window.indexedDB.deleteDatabase(this.dbName);
            deleteRequest.onsuccess = () => resolve('Xóa cơ sở dữ liệu thành công');
            deleteRequest.onerror = (event) => reject(new Error(`Xóa thất bại: ${event.target.error}`));
            deleteRequest.onblocked = () => reject(new Error('Xóa bị chặn do còn kết nối đang mở'));
        });
    }

    executeTransaction(mode, callback) {
        return new Promise((resolve, reject) => {
            if (!this.dbInstance) {
                reject(new Error('Cơ sở dữ liệu chưa được mở'));
                return;
            }
            try {
                const transaction = this.dbInstance.transaction(this.storeName, mode);
                const objectStore = transaction.objectStore(this.storeName);
                resolve(callback(objectStore));
            } catch (error) {
                reject(error);
            }
        });
    }

    queryByConversationId(convId, page = 1, limit = 10) {
        return this.executeTransaction('readonly', (store) => {
            return new Promise((resolve, reject) => {
                const index = store.index('conversationId');
                const range = IDBKeyRange.only(convId);
                const countRequest = index.count(range);

                countRequest.onsuccess = () => {
                    const totalCount = countRequest.result;
                    const totalPages = Math.ceil(totalCount / limit);
                    let records = [];
                    const startIndex = (page - 1) * limit;
                    const endIndex = page * limit;

                    if (page > totalPages) {
                        resolve({ list: [], totalCount, currentPage: page, totalPages, hasNextPage: false });
                        return;
                    }

                    let currentIndex = 0;
                    const cursorRequest = index.openCursor(range, 'prev');

                    cursorRequest.onsuccess = (event) => {
                        const cursor = event.target.result;
                        if (cursor) {
                            if (currentIndex >= startIndex && currentIndex < endIndex) {
                                records.push(cursor.value);
                            }
                            currentIndex++;
                            if (currentIndex >= endIndex) {
                                cursor.continue = () => {};
                                finalizeResult();
                                return;
                            }
                            cursor.continue();
                        } else {
                            finalizeResult();
                        }
                    };

                    cursorRequest.onerror = (event) => reject(new Error(`Truy vấn phân trang thất bại: ${event.target.error}`));

                    const finalizeResult = () => {
                        resolve({
                            list: this.processContent(records),
                            totalCount,
                            currentPage: page,
                            totalPages,
                            hasNextPage: page < totalPages
                        });
                    };
                };

                countRequest.onerror = (event) => reject(new Error(`Đếm tổng số bản ghi thất bại: ${event.target.error}`));
            });
        });
    }

    processContent(items) {
        if (!Array.isArray(items)) return items || [];
        const boolFields = ['joinGroups', 'leaveGroups', 'atmosphere', 'mute', 'revoke', 'revokeEdit', 'chat', 'violation', 'longPress'];
        return items.map(item => {
            const newItem = { ...item };
            try {
                if (newItem.contentJson === "NULL" || newItem.contentJson === null) {
                    newItem.contentJson = null;
                } else if (typeof newItem.contentJson === 'string' && newItem.contentJson.trim()) {
                    newItem.contentJson = JSON.parse(newItem.contentJson);
                }
            } catch (parseError) {
                console.error(`Parse contentJson lỗi: ${newItem.contentJson}`, parseError);
            }

            boolFields.forEach(field => {
                const val = newItem[field];
                if (val === "NULL" || val === null || val === undefined) {
                    newItem[field] = false;
                } else if (val === 0 || val === "0") {
                    newItem[field] = false;
                } else if (val === 1 || val === "1") {
                    newItem[field] = true;
                } else {
                    newItem[field] = false;
                }
            });

            return newItem;
        });
    }

    addNewRecord(data) {
        return this.executeTransaction('readwrite', (store) => {
            return new Promise((resolve, reject) => {
                const request = store.add(data);
                request.onsuccess = (event) => resolve({ message: 'Thêm thành công', insertedId: event.target.result });
                request.onerror = (event) => reject(new Error(`Thêm thất bại: ${event.target.error}`));
            });
        });
    }

    removeRecordsByConversationId(convId) {
        return this.executeTransaction('readwrite', (store) => {
            return new Promise((resolve, reject) => {
                const index = store.index('conversationId');
                const range = IDBKeyRange.only(convId);
                const cursorRequest = index.openCursor(range);
                let deletedCount = 0;

                cursorRequest.onsuccess = (event) => {
                    const cursor = event.target.result;
                    if (cursor) {
                        cursor.delete();
                        deletedCount++;
                        cursor.continue();
                    } else {
                        if (deletedCount === 0) {
                            resolve(`Không có bản ghi nào thuộc conversationId=${convId}`);
                        } else {
                            resolve(`Đã xóa ${deletedCount} bản ghi`);
                        }
                    }
                };

                cursorRequest.onerror = (event) => reject(new Error(`Xóa hàng loạt thất bại: ${event.target.error}`));
            });
        });
    }
}

export default new UniIndexedDBWrapper();

Sử dụng trong component:

import indexedDBUtil from '@/utils/groupChatDb'

const dbResult = await indexedDBUtil.openDatabase()
const deletionResult = await indexedDBUtil.removeRecordsByConversationId(groupId)

Thẻ: IndexedDB JavaScript client-storage Vue3 UniApp

Đăng vào ngày 1 tháng 10 lúc 04:43