深入理解 IndexedDB:在浏览器中存储 PB 级数据的事务性 API 实战

各位同仁、技术爱好者们,大家好!

今天,我们将深入探讨一个在现代Web开发中至关重要的API——IndexedDB。随着Web应用复杂性的日益增加,以及对离线工作能力、高性能数据处理的需求不断提升,浏览器内置的存储机制面临着前所未有的挑战。传统的 localStorage 容量有限、同步阻塞,早已无法满足存储大量结构化数据的要求。Web SQL Database 因缺乏标准化而逐渐被废弃。在这种背景下,IndexedDB 脱颖而出,成为Web平台存储大规模数据的首选方案。

我们的主题是“深入理解 IndexedDB:在浏览器中存储 PB 级数据的事务性 API 实战”。当然,在单个浏览器实例中直接存储 PB 级别的数据在现实中是不可能的,这通常会受到用户设备物理存储空间、浏览器配额管理等因素的限制。然而,这里的“PB 级”更多地是强调 IndexedDB 在设计上的强大扩展性、处理复杂数据结构的能力、以及其事务性保证在高并发、大数据量场景下的稳定性和可靠性。它为开发者提供了足够的能力去构建能够存储数GB甚至更多数据的富客户端应用,而这在传统Web技术中是难以想象的。

我们将从IndexedDB的基本概念入手,逐步深入到其核心API、事务模型、索引机制,并通过丰富的代码示例进行实战演练,最终探讨其在高负载场景下的最佳实践和性能优化策略。


1. 为什么选择 IndexedDB?Web 存储的演进与挑战

在深入IndexedDB之前,我们有必要回顾一下Web存储的历史及其面临的挑战。

1.1 传统 Web 存储的局限性

  • Cookie: 最初用于存储会话信息,容量极小(通常4KB),且每次HTTP请求都会携带,增加了网络流量负担。不适合存储大量数据。
  • localStoragesessionStorage 提供了键值对存储,容量相对较大(5-10MB),API简单易用。但它们是同步的,这意味着在读写操作时会阻塞浏览器主线程,对性能敏感的Web应用来说是致命的。此外,它们只能存储字符串,存储复杂对象需要手动序列化和反序列化。
  • Web SQL Database: 曾经是一个很有前景的方案,提供了SQL接口,但它基于SQLite,没有得到W3C的标准化,因此被废弃,不建议在新项目中使用。

1.2 IndexedDB 的诞生与核心优势

为了解决上述问题,W3C推出了IndexedDB。它是一个低级的API,用于在客户端存储大量的结构化数据。其核心优势包括:

  • NoSQL / 对象存储: IndexedDB不是关系型数据库,而是基于JavaScript对象的键值对存储。它可以直接存储和检索复杂的JavaScript对象,无需手动序列化。
  • 异步非阻塞: 所有操作都是异步的,通过事件或Promise(通过包装库)来处理结果,不会阻塞浏览器主线程,保证了用户界面的流畅性。
  • 事务性: 所有数据操作都发生在事务内部,确保了数据的完整性和一致性(ACID特性)。即使操作失败,数据也能回滚到事务开始前的状态。
  • 索引支持: 允许为对象存储中的属性创建索引,从而实现高效的查询和检索,尤其是在大数据集下。
  • 大容量存储: 存储容量远超localStorage,通常可达数百MB甚至数GB,具体取决于用户的磁盘空间和浏览器策略。这为构建大型离线应用提供了可能。
  • 事件驱动: 操作结果通过事件(onsuccess, onerror, onupgradeneeded等)通知。
  • 同源策略: 数据严格遵守同源策略,不同源的Web应用无法访问彼此的IndexedDB数据,保障了安全性。

2. IndexedDB 核心概念与架构

理解IndexedDB的架构是高效使用的前提。它围绕以下几个核心概念构建:

2.1 数据库(Database)

IndexedDB的顶级容器。一个源(origin,即域名+端口+协议)可以拥有多个数据库。每个数据库都有一个名称和一个版本号。

2.2 对象存储(Object Store)

类似于关系型数据库中的“表”,但它存储的是JavaScript对象。每个对象存储都有一个名称,并且定义了如何存储和检索对象。

  • 主键(Key): 每个对象存储中的记录都必须有一个唯一的键。这个键可以是对象的一个属性(keyPath),也可以是自动生成的(autoIncrement)。
  • 值(Value): 实际存储的JavaScript对象。

2.3 索引(Index)

为了高效地查询对象存储中的数据,可以为对象存储中的特定属性创建索引。索引允许你通过非主键属性来快速查找记录。

  • 索引键(Index Key): 索引所基于的对象属性。
  • 唯一性(Unique): 索引是否要求索引键的值是唯一的。
  • 多值(MultiEntry): 如果索引键是一个数组,是否为数组中的每个元素都创建一个索引条目。

2.4 事务(Transaction)

所有对IndexedDB的读写操作都必须在一个事务中进行。事务确保了操作的原子性、一致性、隔离性和持久性(ACID)。

  • 事务模式(Mode):
    • readonly:只读事务,用于读取数据。
    • readwrite:读写事务,用于读取、添加、更新和删除数据。
    • versionchange:版本变更事务,特殊事务,仅在数据库版本升级时使用,用于创建、删除对象存储和索引。

2.5 光标(Cursor)

光标用于遍历对象存储或索引中的记录。当需要检索一系列数据而不是单个记录时,光标非常有用。它支持按键范围和方向进行遍历。

2.6 请求(Request)

IndexedDB的所有异步操作都返回一个 IDBRequest 对象。这个对象是一个事件目标,通过监听其 onsuccessonerror 事件来处理操作结果。


3. IndexedDB 实战:从入门到高级操作

现在,我们通过一系列代码示例,逐步掌握IndexedDB的实际操作。为了代码的简洁性和可读性,我们将使用Promise来封装原生的IndexedDB事件回调,这在现代JavaScript开发中是常见的实践。

首先,我们定义一个简单的辅助函数来封装打开数据库的逻辑,并返回一个Promise。

// db.js - IndexedDB 辅助函数

/**
 * 打开或创建 IndexedDB 数据库
 * @param {string} dbName 数据库名称
 * @param {number} version 数据库版本号
 * @param {function(IDBDatabase, IDBVersionChangeEvent)} upgradeCallback 升级回调函数
 * @returns {Promise<IDBDatabase>} 数据库实例的Promise
 */
function openDatabase(dbName, version, upgradeCallback) {
    return new Promise((resolve, reject) => {
        const request = indexedDB.open(dbName, version);

        request.onupgradeneeded = (event) => {
            const db = event.target.result;
            console.log(`Database upgrade needed from version ${event.oldVersion} to ${event.newVersion}`);
            if (upgradeCallback) {
                upgradeCallback(db, event);
            }
        };

        request.onsuccess = (event) => {
            const db = event.target.result;
            console.log(`Database '${dbName}' opened successfully, version: ${db.version}`);
            resolve(db);
        };

        request.onerror = (event) => {
            console.error(`Error opening database '${dbName}':`, event.target.error);
            reject(event.target.error);
        };
    });
}

/**
 * 执行事务操作
 * @param {IDBDatabase} db 数据库实例
 * @param {string[]} storeNames 涉及的对象存储名称数组
 * @param {'readonly' | 'readwrite'} mode 事务模式
 * @param {function(IDBTransaction): Promise<any>} operationCallback 事务操作回调函数
 * @returns {Promise<any>} 事务操作结果的Promise
 */
function performTransaction(db, storeNames, mode, operationCallback) {
    return new Promise((resolve, reject) => {
        const transaction = db.transaction(storeNames, mode);

        transaction.oncomplete = () => {
            // console.log('Transaction completed successfully.');
            resolve(); // operationCallback 应该在内部 resolve/reject
        };

        transaction.onerror = (event) => {
            console.error(`Transaction failed for stores [${storeNames.join(', ')}]:`, event.target.error);
            reject(event.target.error);
        };

        transaction.onabort = (event) => {
            console.warn(`Transaction aborted for stores [${storeNames.join(', ')}]:`, event.type);
            reject(new Error('Transaction aborted'));
        };

        // 执行具体的数据库操作
        // 注意:operationCallback 必须返回一个Promise,并在其内部处理具体的数据操作
        // 且其resolve/reject应该被捕获,以避免未处理的Promise拒绝
        try {
            operationCallback(transaction).then(resolve).catch(reject);
        } catch (e) {
            reject(e);
        }
    });
}

// 封装一个通用的CRUD辅助类
class IndexedDBService {
    constructor(dbName, version) {
        this.dbName = dbName;
        this.version = version;
        this.db = null;
    }

    async init(upgradeCallback) {
        if (!this.db) {
            this.db = await openDatabase(this.dbName, this.version, upgradeCallback);
        }
        return this.db;
    }

    async add(storeName, data) {
        return performTransaction(this.db, [storeName], 'readwrite', (transaction) => {
            const store = transaction.objectStore(storeName);
            return new Promise((resolve, reject) => {
                const request = store.add(data);
                request.onsuccess = (event) => resolve(event.target.result); // result is the key
                request.onerror = (event) => reject(event.target.error);
            });
        });
    }

    async put(storeName, data) {
        return performTransaction(this.db, [storeName], 'readwrite', (transaction) => {
            const store = transaction.objectStore(storeName);
            return new Promise((resolve, reject) => {
                const request = store.put(data);
                request.onsuccess = (event) => resolve(event.target.result); // result is the key
                request.onerror = (event) => reject(event.target.error);
            });
        });
    }

    async get(storeName, key) {
        return performTransaction(this.db, [storeName], 'readonly', (transaction) => {
            const store = transaction.objectStore(storeName);
            return new Promise((resolve, reject) => {
                const request = store.get(key);
                request.onsuccess = (event) => resolve(event.target.result);
                request.onerror = (event) => reject(event.target.error);
            });
        });
    }

    async delete(storeName, key) {
        return performTransaction(this.db, [storeName], 'readwrite', (transaction) => {
            const store = transaction.objectStore(storeName);
            return new Promise((resolve, reject) => {
                const request = store.delete(key);
                request.onsuccess = () => resolve();
                request.onerror = (event) => reject(event.target.error);
            });
        });
    }

    async clear(storeName) {
        return performTransaction(this.db, [storeName], 'readwrite', (transaction) => {
            const store = transaction.objectStore(storeName);
            return new Promise((resolve, reject) => {
                const request = store.clear();
                request.onsuccess = () => resolve();
                request.onerror = (event) => reject(event.target.error);
            });
        });
    }

    async getAll(storeName, indexName = null, query = null, count = undefined) {
        return performTransaction(this.db, [storeName], 'readonly', (transaction) => {
            const store = transaction.objectStore(storeName);
            const target = indexName ? store.index(indexName) : store;
            return new Promise((resolve, reject) => {
                const request = target.getAll(query, count);
                request.onsuccess = (event) => resolve(event.target.result);
                request.onerror = (event) => reject(event.target.error);
            });
        });
    }

    async count(storeName, indexName = null, query = null) {
        return performTransaction(this.db, [storeName], 'readonly', (transaction) => {
            const store = transaction.objectStore(storeName);
            const target = indexName ? store.index(indexName) : store;
            return new Promise((resolve, reject) => {
                const request = target.count(query);
                request.onsuccess = (event) => resolve(event.target.result);
                request.onerror = (event) => reject(event.target.error);
            });
        });
    }

    async iterateCursor(storeName, indexName = null, range = null, direction = 'next', callback) {
        return performTransaction(this.db, [storeName], 'readonly', (transaction) => {
            const store = transaction.objectStore(storeName);
            const target = indexName ? store.index(indexName) : store;
            return new Promise((resolve, reject) => {
                const request = target.openCursor(range, direction);
                const results = [];
                request.onsuccess = (event) => {
                    const cursor = event.target.result;
                    if (cursor) {
                        const shouldContinue = callback(cursor.value, cursor.key, cursor.primaryKey);
                        if (shouldContinue === false) { // 允许回调函数中止迭代
                            resolve(results);
                            return;
                        }
                        results.push(cursor.value);
                        cursor.continue();
                    } else {
                        resolve(results);
                    }
                };
                request.onerror = (event) => reject(event.target.error);
            });
        });
    }
}

// 导出供其他文件使用
export { IndexedDBService, IDBKeyRange };

有了这些辅助函数,我们可以更清晰地编写业务逻辑。

3.1 打开数据库与版本管理

数据库的创建和结构定义是在 onupgradeneeded 事件中完成的。这个事件在以下两种情况下触发:

  1. 数据库首次创建。
  2. indexedDB.open() 调用时指定的版本号大于当前数据库的版本号。
// main.js
import { IndexedDBService } from './db.js';

const DB_NAME = 'MyAppData';
const DB_VERSION = 1; // 数据库版本号

// 定义数据库升级逻辑
const upgradeLogic = (db, event) => {
    switch (event.oldVersion) {
        case 0: // 数据库首次创建
            console.log('Creating object stores and indexes for version 1...');
            const usersStore = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
            usersStore.createIndex('email', 'email', { unique: true });
            usersStore.createIndex('username', 'username', { unique: true });

            const productsStore = db.createObjectStore('products', { keyPath: 'productId' });
            productsStore.createIndex('category', 'category', { unique: false });
            productsStore.createIndex('price', 'price', { unique: false });
            break;
        // case 1: // 假设未来升级到版本2
        //     console.log('Upgrading database to version 2...');
        //     const newStore = db.createObjectStore('orders', { keyPath: 'orderId' });
        //     newStore.createIndex('userId', 'userId');
        //     break;
    }
};

const dbService = new IndexedDBService(DB_NAME, DB_VERSION);

async function initializeDB() {
    try {
        await dbService.init(upgradeLogic);
        console.log('IndexedDB initialized successfully.');
    } catch (error) {
        console.error('Failed to initialize IndexedDB:', error);
    }
}

initializeDB();

在上面的 upgradeLogic 中,我们创建了两个对象存储:usersproducts

  • users 存储以 id 作为主键,并自动递增。同时为 emailusername 创建了唯一索引。
  • products 存储以 productId 作为主键。为 categoryprice 创建了非唯一索引。

3.2 基本 CRUD 操作

现在我们可以使用 IndexedDBService 来执行常见的增删改查操作。

3.2.1 添加数据 (add)

add() 方法用于向对象存储中添加新记录。如果尝试添加具有已存在主键的记录,它将抛出错误。

async function addData() {
    try {
        // 添加用户
        const user1 = { username: 'alice', email: '[email protected]', age: 30 };
        const user2 = { username: 'bob', email: '[email protected]', age: 25 };
        const userId1 = await dbService.add('users', user1);
        const userId2 = await dbService.add('users', user2);
        console.log(`User Alice added with ID: ${userId1}`);
        console.log(`User Bob added with ID: ${userId2}`);

        // 添加产品
        const product1 = { productId: 'P001', name: 'Laptop', category: 'Electronics', price: 1200 };
        const product2 = { productId: 'P002', name: 'Mouse', category: 'Electronics', price: 25 };
        const product3 = { productId: 'P003', name: 'Keyboard', category: 'Electronics', price: 75 };
        const product4 = { productId: 'P004', name: 'Book', category: 'Books', price: 20 };
        await dbService.add('products', product1);
        await dbService.add('products', product2);
        await dbService.add('products', product3);
        await dbService.add('products', product4);
        console.log('Products added.');

        // 尝试添加重复 email 的用户,会报错
        // await dbService.add('users', { username: 'charlie', email: '[email protected]', age: 40 });

    } catch (error) {
        console.error('Error adding data:', error);
    }
}
// initializeDB().then(addData); // 在初始化后调用

3.2.2 更新/插入数据 (put)

put() 方法用于更新现有记录,如果记录不存在则插入新记录。

async function updateData() {
    try {
        // 更新用户 Bob 的年龄
        const bob = await dbService.get('users', 2); // 假设 Bob 的 id 是 2
        if (bob) {
            bob.age = 26;
            await dbService.put('users', bob);
            console.log('User Bob updated.');
        }

        // 插入一个新产品 (如果 productId 不存在) 或更新现有产品
        const product5 = { productId: 'P005', name: 'Monitor', category: 'Electronics', price: 300 };
        await dbService.put('products', product5);
        console.log('Product P005 added/updated.');

        const updatedProduct1 = { productId: 'P001', name: 'Gaming Laptop', category: 'Electronics', price: 1500 };
        await dbService.put('products', updatedProduct1);
        console.log('Product P001 updated.');

    } catch (error) {
        console.error('Error updating data:', error);
    }
}
// initializeDB().then(addData).then(updateData);

3.2.3 获取数据 (get)

get() 方法通过主键检索单个记录。

async function getData() {
    try {
        // 通过 id 获取用户 Alice
        const alice = await dbService.get('users', 1); // 假设 Alice 的 id 是 1
        console.log('Retrieved Alice:', alice);

        // 通过 productId 获取产品 P001
        const product = await dbService.get('products', 'P001');
        console.log('Retrieved Product P001:', product);

        // 获取一个不存在的记录
        const nonExistentUser = await dbService.get('users', 99);
        console.log('Non-existent user (should be undefined):', nonExistentUser);

    } catch (error) {
        console.error('Error getting data:', error);
    }
}
// initializeDB().then(addData).then(updateData).then(getData);

3.2.4 删除数据 (delete)

delete() 方法通过主键删除单个记录。

async function deleteData() {
    try {
        // 删除产品 P002
        await dbService.delete('products', 'P002');
        console.log('Product P002 deleted.');

        // 尝试获取 P002 验证是否删除成功
        const deletedProduct = await dbService.get('products', 'P002');
        console.log('Retrieved P002 after deletion (should be undefined):', deletedProduct);

    } catch (error) {
        console.error('Error deleting data:', error);
    }
}
// initializeDB().then(addData).then(updateData).then(getData).then(deleteData);

3.2.5 清空对象存储 (clear)

clear() 方法删除对象存储中的所有记录。

async function clearStore() {
    try {
        // 清空 products 存储
        await dbService.clear('products');
        console.log('Products store cleared.');

        // 验证 products 存储是否为空
        const allProducts = await dbService.getAll('products');
        console.log('All products after clear (should be empty array):', allProducts);

    } catch (error) {
        console.error('Error clearing store:', error);
    }
}
// initializeDB().then(addData).then(clearStore);

3.3 高级查询:使用索引和光标

当需要查询大量数据,或者需要根据非主键属性进行筛选、排序、分页时,索引和光标是必不可少的。

3.3.1 使用 getAll 和索引进行查询

getAll() 方法可以获取对象存储或索引中的所有记录,或者通过 IDBKeyRange 过滤后的记录。

import { IndexedDBService, IDBKeyRange } from './db.js';
// ... (初始化 dbService)

async function queryDataWithIndex() {
    try {
        // 首先确保有数据
        await addData(); // 假设 addData 已经存在并添加了数据

        console.log('n--- Querying with Indexes ---');

        // 通过索引获取特定 email 的用户
        const aliceByEmail = await dbService.getAll('users', 'email', '[email protected]');
        console.log('Users with email [email protected]:', aliceByEmail);

        // 通过索引获取特定 category 的产品
        const electronicsProducts = await dbService.getAll('products', 'category', 'Electronics');
        console.log('All Electronics products:', electronicsProducts);

        // 获取价格在 50 到 200 之间的产品 (使用索引和范围查询)
        const priceRange = IDBKeyRange.bound(50, 200); // 包含 50 和 200
        const productsInPriceRange = await dbService.getAll('products', 'price', priceRange);
        console.log('Products with price between 50 and 200:', productsInPriceRange);

        // 获取所有用户
        const allUsers = await dbService.getAll('users');
        console.log('All users:', allUsers);

    } catch (error) {
        console.error('Error querying data with index:', error);
    }
}
// initializeDB().then(queryDataWithIndex);

3.3.2 使用光标进行遍历和高级过滤

光标提供了更细粒度的控制,允许你在遍历过程中处理数据,或者实现更复杂的过滤逻辑。

import { IndexedDBService, IDBKeyRange } from './db.js';
// ... (初始化 dbService)

async function iterateWithCursor() {
    try {
        // 确保有数据
        await addData();

        console.log('n--- Iterating with Cursors ---');

        // 遍历所有用户,按 ID 升序
        console.log('All users (via cursor):');
        await dbService.iterateCursor('users', null, null, 'next', (user) => {
            console.log(`  ID: ${user.id}, Username: ${user.username}, Age: ${user.age}`);
        });

        // 遍历年龄大于 25 岁的用户 (使用光标和条件判断)
        console.log('Users older than 25 (via cursor):');
        await dbService.iterateCursor('users', null, IDBKeyRange.lowerBound(0), 'next', (user) => {
            if (user.age > 25) {
                console.log(`  ID: ${user.id}, Username: ${user.username}, Age: ${user.age}`);
            }
            return true; // 继续遍历
        });

        // 遍历所有 Electronics 类别的产品,按 price 降序
        console.log('Electronics products (price descending via index cursor):');
        const electronicsRange = IDBKeyRange.only('Electronics');
        await dbService.iterateCursor('products', 'category', electronicsRange, 'prev', (product) => {
            console.log(`  Name: ${product.name}, Price: ${product.price}`);
        });

        // 模拟分页:获取前2个产品
        console.log('First 2 products (via cursor with limit):');
        let count = 0;
        await dbService.iterateCursor('products', null, null, 'next', (product) => {
            if (count < 2) {
                console.log(`  ${product.name}`);
                count++;
                return true; // 继续
            }
            return false; // 停止遍历
        });

    } catch (error) {
        console.error('Error iterating with cursor:', error);
    }
}
// initializeDB().then(iterateWithCursor);

IDBKeyRange 的使用

IDBKeyRange 对象用于定义查询的键范围。

  • IDBKeyRange.only(value): 匹配精确的 value
  • IDBKeyRange.lowerBound(lower, [open]): 匹配大于或等于 lower 的键。如果 opentrue,则不包含 lower
  • IDBKeyRange.upperBound(upper, [open]): 匹配小于或等于 upper 的键。如果 opentrue,则不包含 upper
  • IDBKeyRange.bound(lower, upper, [lowerOpen], [upperOpen]): 匹配在 lowerupper 之间的键。lowerOpenupperOpen 分别控制是否包含边界值。

这些在 db.js 中已经通过 import { IDBKeyRange } from './db.js'; 暴露出来,可以直接使用。

3.4 数据库版本升级与数据迁移

数据库的版本管理是IndexedDB的关键特性之一。当应用的数据模型发生变化时(例如,添加新的对象存储,修改现有对象存储的索引),就需要升级数据库版本。这正是 onupgradeneeded 事件发挥作用的地方。

// 假设这是我们的 v1 数据库升级逻辑
// const upgradeLogic = (db, event) => { ... } 见 3.1 节

// 模拟升级到版本 2
const DB_VERSION_V2 = 2;

const upgradeLogicV2 = (db, event) => {
    switch (event.oldVersion) {
        case 0: // 从零开始,创建v1结构
            console.log('Creating object stores and indexes for version 1...');
            const usersStoreV1 = db.createObjectStore('users', { keyPath: 'id', autoIncrement: true });
            usersStoreV1.createIndex('email', 'email', { unique: true });
            usersStoreV1.createIndex('username', 'username', { unique: true });

            const productsStoreV1 = db.createObjectStore('products', { keyPath: 'productId' });
            productsStoreV1.createIndex('category', 'category', { unique: false });
            productsStoreV1.createIndex('price', 'price', { unique: false });
            // 注意:这里没有 break,以便继续执行到下一个 case(如果旧版本是0)
            // 这是 onupgradeneeded 的常见模式,允许多版本升级路径
        case 1: // 从版本1升级到版本2
            console.log('Upgrading database from version 1 to 2: Adding "orders" store and new index for "users".');
            // 创建新的对象存储
            const ordersStore = db.createObjectStore('orders', { keyPath: 'orderId', autoIncrement: true });
            ordersStore.createIndex('userId', 'userId', { unique: false });
            ordersStore.createIndex('orderDate', 'orderDate', { unique: false });

            // 修改现有对象存储 (例如,在 users 存储中添加一个新索引)
            // 需要先获取到对象存储的引用
            const usersStoreV2 = event.currentTarget.transaction.objectStore('users');
            usersStoreV2.createIndex('age', 'age', { unique: false }); // 添加一个按年龄的索引

            // 假设我们还要迁移数据,例如给所有旧产品增加一个默认的 `stock` 字段
            // 注意:数据迁移必须在同一个 versionchange 事务中完成
            // 且只能在 onupgradeneeded 事件中访问旧版本的对象存储 (event.target.transaction.objectStore())
            const productsStoreForMigration = event.currentTarget.transaction.objectStore('products');
            productsStoreForMigration.openCursor().onsuccess = (e) => {
                const cursor = e.target.result;
                if (cursor) {
                    const product = { ...cursor.value }; // 复制对象以避免直接修改cursor.value
                    if (product.stock === undefined) {
                        product.stock = 100; // 默认库存
                        cursor.update(product); // 更新记录
                    }
                    cursor.continue();
                } else {
                    console.log('Product data migration for "stock" field completed.');
                }
            };
            break;
    }
};

async function upgradeDB() {
    console.log(`Attempting to upgrade database to version ${DB_VERSION_V2}`);
    const dbServiceV2 = new IndexedDBService(DB_NAME, DB_VERSION_V2);
    try {
        await dbServiceV2.init(upgradeLogicV2);
        console.log('Database upgraded to version 2 successfully.');

        // 验证新存储和索引
        const orderId = await dbServiceV2.add('orders', { userId: 1, orderDate: new Date(), items: [{ productId: 'P001', qty: 1 }] });
        console.log(`Added order with ID: ${orderId}`);

        const usersOlderThan25 = await dbServiceV2.getAll('users', 'age', IDBKeyRange.lowerBound(25, true));
        console.log('Users older than 25 (via new age index):', usersOlderThan25);

        const productsWithStock = await dbServiceV2.getAll('products');
        console.log('Products after migration (should have stock field):', productsWithStock);

    } catch (error) {
        console.error('Failed to upgrade IndexedDB:', error);
    }
}

// 首次运行,数据库版本为 1
// initializeDB().then(() => {
//     // 之后,可以尝试调用 upgradeDB 来升级
//     upgradeDB();
// });

重要提示:

  • onupgradeneeded 事件只在版本号发生变化时触发。
  • 在这个事件的回调函数中,你可以创建、删除对象存储和索引。
  • 你也可以在这里进行数据迁移,例如从旧的数据结构转换到新的数据结构。数据迁移必须使用 event.currentTarget.transaction.objectStore('storeName') 来访问旧的对象存储,因为此时数据库处于特殊的 versionchange 事务模式。
  • onupgradeneeded 内部,不能直接执行普通的 get, put 等操作,只能执行与 schema 变更相关的操作(createObjectStore, deleteObjectStore, createIndex, deleteIndex)以及通过光标进行数据迁移。
  • 为了确保平滑升级,最好为每个版本定义清晰的升级路径(case oldVersion: ...)。

4. IndexedDB 的高级考量与性能优化

4.1 事务的生命周期与错误处理

事务是IndexedDB的基石。理解其生命周期和错误处理至关重要。

  • 事务开始: db.transaction(['store1', 'store2'], 'mode') 创建事务。
  • 请求执行: 在事务内,对对象存储的各种操作(add, get, put 等)都会创建 IDBRequest
  • 请求结果: 每个 IDBRequest 都有 onsuccessonerror 事件。
  • 事务完成: 当事务中的所有操作都成功完成,且没有新的操作被添加到事务中时,事务会自动提交,并触发 transaction.oncomplete
  • 事务失败: 任何一个 IDBRequest 失败(触发 onerror),或者事务被显式中止(transaction.abort()),整个事务都会回滚,触发 transaction.onerrortransaction.onabort

最佳实践:

  • 尽可能使用短事务: 长事务会占用数据库资源,可能阻塞其他事务,影响性能。只在一个事务中包含必要的原子性操作。
  • 统一错误处理: 使用 Promise 包装器可以更好地集中处理错误。
  • 谨慎处理 onerror 单个请求的 onerror 会导致整个事务中止。如果某些错误是可接受的(例如,add 操作尝试添加重复键),需要特殊处理。

4.2 Web Workers 与大数据处理

对于存储 PB 级数据而言,即使是异步操作,大量的数据处理(如导入、导出、复杂聚合)仍然可能消耗显著的CPU时间,从而影响主线程的响应性。
解决方案: 将 IndexedDB 操作放在 Web Worker 中执行。Web Worker 在独立于主线程的后台线程中运行,可以执行耗时的计算和I/O操作,而不会阻塞用户界面。

// worker.js (一个 Web Worker 文件)
import { IndexedDBService } from './db.js'; // 导入相同的 IndexedDBService

const DB_NAME = 'MyAppData';
const DB_VERSION = 2; // 使用最新的版本
const dbService = new IndexedDBService(DB_NAME, DB_VERSION);

self.onmessage = async (event) => {
    const { type, payload } = event.data;

    try {
        if (!dbService.db) {
            // Worker 内部也需要初始化数据库,但 upgradeCallback 在 Worker 中可能不被调用
            // 因为升级通常由主线程在页面加载时处理
            await dbService.init();
        }

        switch (type) {
            case 'BULK_ADD_PRODUCTS':
                const products = payload.products;
                // 使用单个事务批量添加
                await dbService.performTransaction(dbService.db, ['products'], 'readwrite', (transaction) => {
                    const store = transaction.objectStore('products');
                    const promises = products.map(product => {
                        return new Promise((resolve, reject) => {
                            const request = store.add(product);
                            request.onsuccess = () => resolve();
                            request.onerror = (e) => reject(e.target.error);
                        });
                    });
                    return Promise.all(promises); // 等待所有添加操作完成
                });
                self.postMessage({ type: 'BULK_ADD_PRODUCTS_SUCCESS', message: `Added ${products.length} products.` });
                break;

            case 'GET_ALL_ELECTRONICS':
                const electronics = await dbService.getAll('products', 'category', 'Electronics');
                self.postMessage({ type: 'GET_ALL_ELECTRONICS_SUCCESS', data: electronics });
                break;

            // 其他耗时操作...
        }
    } catch (error) {
        console.error('Worker error:', error);
        self.postMessage({ type: 'ERROR', error: error.message });
    }
};
// main.js (主线程)
import { IndexedDBService } from './db.js';
// ... (初始化 dbService)

const myWorker = new Worker('worker.js');

myWorker.onmessage = (event) => {
    const { type, data, message, error } = event.data;
    if (type === 'BULK_ADD_PRODUCTS_SUCCESS') {
        console.log('Worker:', message);
    } else if (type === 'GET_ALL_ELECTRONICS_SUCCESS') {
        console.log('Worker fetched electronics:', data);
    } else if (type === 'ERROR') {
        console.error('Error from worker:', error);
    }
};

async function performHeavyOperationsInWorker() {
    // 模拟大量产品数据
    const largeProducts = Array.from({ length: 10000 }, (_, i) => ({
        productId: `WP${i}`,
        name: `Worker Product ${i}`,
        category: i % 2 === 0 ? 'Electronics' : 'Books',
        price: Math.random() * 1000,
        stock: Math.floor(Math.random() * 500)
    }));

    myWorker.postMessage({ type: 'BULK_ADD_PRODUCTS', payload: { products: largeProducts } });

    // 几秒后,请求获取数据
    setTimeout(() => {
        myWorker.postMessage({ type: 'GET_ALL_ELECTRONICS' });
    }, 2000);
}

// initializeDB().then(performHeavyOperationsInWorker);

4.3 存储配额与持久性

IndexedDB的存储容量虽然大,但并非无限。浏览器会为每个源分配一个存储配额。当达到配额上限时,尝试写入数据会失败。
此外,浏览器可能会在存储空间不足时清除非持久化的数据。

  • 检查配额: 使用 navigator.storage.estimate() API 可以估算当前源的存储使用量和可用配额。
  • 请求持久化存储: 使用 navigator.storage.persist() API 可以向用户请求持久化存储权限。一旦获得,浏览器就不会在存储压力下清除你的数据。
async function checkStorageQuota() {
    if (navigator.storage && navigator.storage.estimate) {
        const { usage, quota } = await navigator.storage.estimate();
        console.log(`Used storage: ${usage / (1024 * 1024)} MB`);
        console.log(`Total quota: ${quota / (1024 * 1024)} MB`);
        console.log(`Available: ${(quota - usage) / (1024 * 1024)} MB`);

        const isPersisted = await navigator.storage.persisted();
        console.log(`Storage is persisted: ${isPersisted}`);

        if (!isPersisted) {
            const result = await navigator.storage.persist();
            console.log(`Persist request result: ${result ? 'Granted' : 'Denied'}`);
        }
    } else {
        console.warn('StorageManager API not supported.');
    }
}
// checkStorageQuota();

4.4 性能优化策略

  • 合理设计索引: 为常用查询字段创建索引,但不要过度创建,因为索引会增加写入操作的开销和存储空间。
  • 批量操作: 将多个相关的写入操作合并到一个事务中,减少事务开销。
  • 避免大量同步操作: 始终使用异步API,并将耗时计算移至Web Worker。
  • 使用 IDBKeyRange 进行高效范围查询: 避免全表扫描。
  • 光标优化: 尽可能使用索引光标,并且在不需要时及时中止光标迭代。
  • 数据结构优化: 存储扁平化、非冗余的数据,减少对象大小。对于大文本或二进制数据,考虑分块存储。

5. IndexedDB 与其他客户端存储的比较

特性 Cookie localStorage Web SQL Database (废弃) IndexedDB
容量 极小 (4KB) 较小 (5-10MB) 较大 (数十MB到GB) 极大 (数百MB到GB,取决于设备和浏览器配额)
数据类型 字符串 字符串 关系型数据 (SQL) 结构化数据 (JavaScript 对象)
API 类型 同步 同步 异步 (SQL回调) 异步 (事件/Promise)
事务支持 有 (SQL事务) 有 (ACID 事务)
查询能力 简单键值对 简单键值对 强大 (SQL查询) 强大 (索引、光标、键范围)
标准化 RFC W3C Web Storage 无 (W3C不推荐) W3C Indexed Database API
用途 会话管理,少量配置 用户偏好,不敏感的少量数据 不推荐新项目 大规模结构化数据,离线应用,高性能数据缓存

6. IndexedDB 的典型应用场景

  • 渐进式Web应用 (PWA): 作为离线数据存储的核心,使PWA在无网络环境下也能正常工作。
  • 大型数据缓存: 缓存API响应、图片、视频或其他应用资产,加速应用加载和响应。
  • 用户生成内容: 在用户离线时,临时存储用户创建的内容(如草稿、笔记),待网络恢复后同步到服务器。
  • 复杂客户端数据管理: 例如在线图片编辑器、图表工具、本地数据库管理工具等,需要在浏览器中处理和存储大量结构化数据。
  • 游戏状态保存: 存储游戏进度、用户设置等。
  • 数据分析与日志: 离线收集和存储用户行为数据或日志,然后批量上传。

7. 最佳实践与注意事项

  • 始终处理错误: onsuccessonerror 回调是必须的。使用 Promise 封装能让错误处理更优雅。
  • 理解事务: 事务是原子性的。一个事务中的任何一个请求失败,整个事务都会回滚。
  • 版本管理: 仔细规划 onupgradeneeded 逻辑,确保数据库结构升级和数据迁移的平滑进行。
  • 异步特性: IndexedDB是完全异步的,不要试图以同步方式操作它。
  • 避免阻塞主线程: 对于大数据量操作,考虑使用 Web Worker。
  • 资源释放: 虽然浏览器通常会管理数据库连接,但当不再需要数据库实例时,db.close() 仍然是一个好习惯。
  • 浏览器兼容性: 现代浏览器对IndexedDB的支持良好,但仍需注意特定API在不同浏览器版本间的细微差异。

IndexedDB 作为浏览器端存储大规模结构化数据的首选方案,其强大的事务性、异步非阻塞特性和灵活的索引机制,为构建高性能、离线优先的现代Web应用奠定了坚实基础。通过深入理解并熟练运用其核心API和最佳实践,开发者能够有效管理客户端数据,显著提升用户体验,开启Web应用的新篇章。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注