IndexedDB

IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。该 API 使用索引实现对数据的高性能搜索。虽然 Web Storage 在存储较少量的数据很有用,但对于存储更大量的结构化数据来说力不从心。而 IndexedDB 提供了这种场景的解决方案。

IndexedDB 就是浏览器提供的本地数据库,它可以被网页脚本创建和操作。IndexedDB 允许储存大量数据,提供查找接口,还能建立索引。这些都是 LocalStorage 所不具备的。就数据库类型而言,IndexedDB 不属于关系型数据库(不支持 SQL 查询语句),更接近 NoSQL 数据库

IndexedDB 具有以下特点。

  • 键值对储存:IndexedDB 内部采用对象仓库( object store )存放数据。所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以“键值对”的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误。

  • 异步:IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。

  • 支持事务:IndexedDB 支持事务( transaction ),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。这和 MySQL 等数据库的事务类似。

  • 同源限制:IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库。

  • 储存空间大:这是 IndexedDB 最显著的特点之一。IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限。

  • 支持二进制储存:IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。

IndexedDB 主要使用在于客户端需要存储大量的数据的场景下:

  • 数据可视化等界面,大量数据,每次请求会消耗很大性能。

  • 即时聊天工具,大量消息需要存在本地。

  • 其它存储方式容量不满足时,不得已使用 IndexedDB

IndexedDB 重要概念

数据库:IDBDatabase 对象

数据库是一系列相关数据的容器。每个域名(严格的说,是协议 + 域名 + 端口)都可以新建任意多个数据库。

IndexedDB 数据库有版本的概念。同一个时刻,只能有一个版本的数据库存在。如果要修改数据库结构(新增或删除表、索引或者主键),只能通过升级数据库版本完成。

对象仓库:IDBObjectStore 对象

每个数据库包含若干个对象仓库( object store )。它类似于关系型数据库的表格。

索引:IDBIndex 对象

对象仓库保存的是数据记录。每条记录类似于关系型数据库的行,但是只有主键和数据体两部分。主键用来建立默认的索引,必须是不同的,否则会报错。主键可以是数据记录里面的一个属性,也可以指定为一个递增的整数编号。


{ id: 1, text: 'foo' }

上面的对象中,id 属性可以当作主键。

数据体可以是任意数据类型,不限于对象。

为了加速数据的检索,可以在对象仓库里面,为不同的属性建立索引。

在关系型数据库当中也有索引的概念,我们可以给对应的表字段添加索引,以便加快查找速率。在 IndexedDB 中同样有索引,我们可以在创建 store 的时候同时创建索引,在后续对 store 进行查询的时候即可通过索引来筛选,给某个字段添加索引后,在后续插入数据的过成功,索引字段便不能为空。

事务:IDBTransaction 对象

数据记录的读写和删改,都要通过事务完成。事务对象提供 error、abortcomplete 三个事件,用来监听操作结果。

操作请求:IDBRequest 对象

是 IndexedDB 中所有异步操作的返回值对象。每当您执行数据库操作(如打开数据库、添加、读取、更新、删除数据等),都会返回一个 IDBRequest 对象,用于处理操作的成功或失败。

指针:IDBCursor 对象

游标是 IndexedDB 数据库新的概念,大家可以把游标想象为一个指针,比如我们要查询满足某一条件的所有数据时,就需要用到游标,我们让游标一行一行的往下走,游标走到的地方便会返回这一行数据,此时我们便可对此行数据进行判断,是否满足条件。

主键集合:IDBKeyRange 对象

是一个表示一段连续键值范围的 JavaScript 对象。它不是一个包含所有键的数组,而是定义了一个边界条件。当你使用 IDBKeyRange 在对象存储或索引上打开游标或调用 get() / getAll() 方法时,数据库只会返回那些主键落在这个范围内的数据记录。

IndexedDB实操

创建连接数据库

/**
 * 打开数据库
 * @param {object} dbName 数据库的名字
 * @param {string} storeName 仓库名称
 * @param {string} version 数据库的版本
 * @return {object} 该函数会返回一个数据库实例
 */
function openDB(dbName, version = 1) {
    return new Promise((resolve, reject) => {
        var db; // 存储创建的数据库
        // 打开数据库,若没有则会创建
        const request = indexedDB.open(dbName, version);

        // 数据库打开成功回调
        request.onsuccess = function (event) {
            db = event.target.result; // 存储数据库对象
            console.log("数据库打开成功");
            resolve(db);
        };

        // 数据库打开失败的回调
        request.onerror = function (event) {
            console.log("数据库打开报错");
        };

        // 数据库有更新时候的回调
        request.onupgradeneeded = function (event) {
            // 数据库创建或升级的时候会触发
            console.log("onupgradeneeded");
            db = event.target.result; // 存储数据库对象
            var objectStore;
            // 创建存储库
            objectStore = db.createObjectStore("stu", {
                keyPath: "stuId", // 这是主键
                autoIncrement: true // 实现自增
            });
            // 创建索引,在后面查询数据的时候可以根据索引查
            objectStore.createIndex("stuId", "stuId", { unique: true });
            objectStore.createIndex("stuName", "stuName", { unique: false });
            objectStore.createIndex("stuAge", "stuAge", { unique: false });
        };
    });
}

关闭数据库

/**
 * 关闭数据库
 * @param {object} db 数据库实例
 */
function closeDB(db) {
  db.close();
  console.log("数据库已关闭");
}

删除数据库

/**
 * 删除数据库
 * @param {object} dbName 数据库名称
 */
function deleteDBAll(dbName) {
  console.log(dbName);
  let deleteRequest = window.indexedDB.deleteDatabase(dbName);
  deleteRequest.onerror = function (event) {
    console.log("删除失败");
  };
  deleteRequest.onsuccess = function (event) {
    console.log("删除成功");
  };
}

插入数据

/**
 * 新增数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} data 数据
 */
function addData(db, storeName, data) {
    var request = db
        .transaction([storeName], "readwrite") // 事务对象 指定表格名称和操作模式("只读"或"读写")
        .objectStore(storeName) // 仓库对象
        .add(data);

    request.onsuccess = function (event) {
        console.log("数据写入成功");
    };

    request.onerror = function (event) {
        console.log("数据写入失败");
    };
}

读取数据

通过主键读取数据

/**
 * 通过主键读取数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} key 主键值
 */
function getDataByKey(db, storeName, key) {
    return new Promise((resolve, reject) => {
        var transaction = db.transaction([storeName]); // 事务
        var objectStore = transaction.objectStore(storeName); // 仓库对象
        var request = objectStore.get(key); // 通过主键获取数据

        request.onerror = function (event) {
            console.log("事务失败");
        };

        request.onsuccess = function (event) {
            console.log("主键查询结果: ", request.result);
            resolve(request.result);
        };
    });
}

仓库对象也提供了 getAll 方法, 能够查询整张表的数据内容。

/**
 * 通过主键读取数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} key 主键值
 */
function getDataByKey(db, storeName, key) {
    return new Promise((resolve, reject) => {
        ...
        var request = objectStore.getAll(); // 通过主键获取数据
        ...
    });
}

还可以通过指针来进行查询

/**
 * 通过游标读取数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 */
function cursorGetData(db, storeName) {
    return new Promise((resolve, reject) => {
        let list = [];
        var store = db
            .transaction(storeName, "readwrite") // 事务
            .objectStore(storeName); // 仓库对象
        var request = store.openCursor(); // 指针对象
        // 游标开启成功,逐行读数据
        request.onsuccess = function (e) {
            var cursor = e.target.result;
            if (cursor) {
                // 必须要检查
                list.push(cursor.value);
                cursor.continue(); // 遍历了存储对象中的所有内容
            } else {
                resolve(list)
            }
        };
    })
}

过仓库对象的 openCursor 方法开启了一个指针,这个指针会指向数据表的第一条数据,之后指针逐项进行偏移从而遍历整个数据表。

但是更多的场景是我们压根儿就不知道某一条数据的主键。例如我们要查询学生姓名为“张三”的学生数据,对于我们来讲,我们知道的信息只有学生姓名“张三”。

此时我们就可以通过索引来查询数据。

/**
 * 通过索引读取数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} indexName 索引名称
 * @param {string} indexValue 索引值
 */
function getDataByIndex(db, storeName, indexName, indexValue) {
    return new Promise((resolve, reject) => {
        var store = db.transaction(storeName, "readwrite").objectStore(storeName);
        var request = store.index(indexName).get(indexValue);
        request.onerror = function () {
            console.log("事务失败");
        };
        request.onsuccess = function (e) {
            var result = e.target.result;
            resolve(result);
        };
    })
}

如果我们想要查询出索引中满足某些条件的所有数据,可以将索引和游标结合起来。

/**
 * 通过索引和游标查询记录
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} indexName 索引名称
 * @param {string} indexValue 索引值
 */
function cursorGetDataByIndex(db, storeName, indexName, indexValue) {
    return new Promise((resolve, reject) => {
        let list = [];
        var store = db.transaction(storeName, "readwrite").objectStore(storeName); // 仓库对象
        var request = store
            .index(indexName) // 索引对象
            .openCursor(IDBKeyRange.only(indexValue)); // 指针对象
        request.onsuccess = function (e) {
            var cursor = e.target.result;
            if (cursor) {
                // 必须要检查
                list.push(cursor.value);
                cursor.continue(); // 遍历了存储对象中的所有内容
            } else {
                resolve(list)
            }
        };
        request.onerror = function (e) { };
    })
}

IDBKeyRange 对象代表数据仓库(object store)里面的一组主键。根据这组主键,可以获取数据仓库或索引里面的一组记录。

IDBKeyRange 可以只包含一个值,也可以指定上限和下限。它有四个静态方法,用来指定主键的范围。

  • IDBKeyRange.lowerBound( ):指定下限。

  • IDBKeyRange.upperBound( ):指定上限。

  • IDBKeyRange.bound( ):同时指定上下限。

  • IDBKeyRange.only( ):指定只包含一个值。

下面是一些代码实例。

// All keys ≤ x
var r1 = IDBKeyRange.upperBound(x);

// All keys < x
var r2 = IDBKeyRange.upperBound(x, true);

// All keys ≥ y
var r3 = IDBKeyRange.lowerBound(y);

// All keys > y
var r4 = IDBKeyRange.lowerBound(y, true);

// All keys ≥ x && ≤ y
var r5 = IDBKeyRange.bound(x, y);

// All keys > x &&< y
var r6 = IDBKeyRange.bound(x, y, true, true);

// All keys > x && ≤ y
var r7 = IDBKeyRange.bound(x, y, true, false);

// All keys ≥ x &&< y
var r8 = IDBKeyRange.bound(x, y, false, true);

// The key = z
var r9 = IDBKeyRange.only(z);

IndexedDB 分页查询不像 MySQL 分页查询那么简单,没有提供现成的 API,如 limit 等,所以需要我们自己实现分页。

/**
 * 通过索引和游标分页查询记录
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} indexName 索引名称
 * @param {string} indexValue 索引值
 * @param {number} page 页码
 * @param {number} pageSize 查询条数
 */
function cursorGetDataByIndexAndPage(
    db,
    storeName,
    indexName,
    indexValue,
    page,
    pageSize
) {
    return new Promise((resolve, reject) => {
        var list = [];
        var counter = 0; // 计数器
        var advanced = true; // 是否跳过多少条查询
        var store = db.transaction(storeName, "readwrite").objectStore(storeName); // 仓库对象
        var request = store
            // .index(indexName) // 索引对象
            // .openCursor(IDBKeyRange.only(indexValue)); // 按照指定值分页查询(配合索引)
            .openCursor(); // 指针对象
        request.onsuccess = function (e) {
            var cursor = e.target.result;
            if (page > 1 && advanced) {
                advanced = false;
                cursor.advance((page - 1) * pageSize); // 跳过多少条
                return;
            }
            if (cursor) {
                // 必须要检查
                list.push(cursor.value);
                counter++;
                if (counter < pageSize) {
                    cursor.continue(); // 遍历了存储对象中的所有内容
                } else {
                    cursor = null;
                    resolve(list);
                }
            } else {
                resolve(list);
            }
        };
        request.onerror = function (e) { };
    })
}

这里用到了 IndexedDB 的一个 APIadvance

该函数可以让我们的游标跳过多少条开始查询。假如我们的额分页是每页 5 条数据,现在需要查询第 2 页,那么我们就需要跳过前面 5 条数据,从第 6 条数据开始查询,直到计数器等于 5,那么我们就关闭游标,结束查询。

更新数据

IndexedDB 更新数据较为简单,直接使用 put 方法,值得注意的是如果数据库中没有该条数据,则会默认增加该条数据,否则更新。

/**
 * 更新数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {object} data 数据
 */
function updateDB(db, storeName, data) {
    return new Promise((resolve, reject) => {
        var request = db
            .transaction([storeName], "readwrite") // 事务对象
            .objectStore(storeName) // 仓库对象
            .put(data);

        request.onsuccess = function () {
            resolve({
                status: true,
                message: "更新数据成功"
            })
        };

        request.onerror = function () {
            reject({
                status: false,
                message: "更新数据失败"
            })
        };
    })
}

删除数据

通过主键删除

/**
 * 通过主键删除数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {object} id 主键值
 */
function deleteDB(db, storeName, id) {
    return new Promise((resolve, reject) => {
        var request = db
            .transaction([storeName], "readwrite")
            .objectStore(storeName)
            .delete(id);

        request.onsuccess = function () {
            resolve({
                status: true,
                message: "删除数据成功"
            })
        };

        request.onerror = function () {
            reject({
                status: true,
                message: "删除数据失败"
            })
        };
    })
}

有时候拿不到主键值,只能只能通过索引值删除

/**
 * 通过索引和游标删除指定的数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} indexName 索引名
 * @param {object} indexValue 索引值
 */
function cursorDelete(db, storeName, indexName, indexValue) {
    return new Promise((resolve, reject) => {
        var store = db.transaction(storeName, "readwrite").objectStore(storeName);
        var request = store
            .index(indexName) // 索引对象
            .openCursor(IDBKeyRange.only(indexValue)); // 指针对象
        request.onsuccess = function (e) {
            var cursor = e.target.result;
            var deleteRequest;
            if (cursor) {
                deleteRequest = cursor.delete(); // 请求删除当前项
                deleteRequest.onsuccess = function () {
                    console.log("游标删除该记录成功");
                    resolve({
                        status: true,
                        message: "游标删除该记录成功"
                    })
                };
                deleteRequest.onerror = function () {
                    reject({
                        status: false,
                        message: "游标删除该记录失败"
                    })
                };
                cursor.continue();
            }
        };
        request.onerror = function (e) { };
    })
}