DTeam 技术日志

Doer、Delivery、Dream

IndexedDB 快速入门

冯宇 Posted at — Aug 7, 2021 阅读

注:如果你只是想快速体验一下 IndexedDB,建议直接去使用 JsStore 。它基本上完全隐藏了 IndexedDB 繁琐的技术细节,提供的接口也更加人性化,同时还有更高级的特性(如 sql join、upsert、webworker 等)。

IndexedDB 简介

作为前端开发工程师,你可能已经习惯了使用一些前端的存储方案,如 LocalStorage, Session Storage, Cookie 之类等等。而这些存储在存储结构简单、数据量小的场景中非常好用,但是遇到大规模结构化存储的场景中就不那么好用了。为此,诞生了一种新的存储标准 IndexedDB,用于在前端解决大规模量结构化数据存储的场景。如服务端数据缓存至前端加速,前端日志缓存记录等。

本文将介绍 IndexedDB 快速入门的相关内容,至于高级使用,仍建议详细阅读 IndexedDB 的官方文档

性能评测可参考: https://zhuanlan.zhihu.com/p/104536473

IndexedDB 的兼容性

如今 IndexedDB 几乎在现代化的浏览器都已经实现,因此可以放心大胆的在产品环境使用。截至本文撰写时间,当前 IndexedDB 的浏览器兼容性参考如下:

IndexedDB 兼容性概览

数据来源: https://caniuse.com/indexeddb

IndexedDB 的存储容量限制

IndexedDB 的最大存储容量通常是动态的,通常全局限制为当前磁盘空间的 50%,如浏览器安装分区容量为 500GB,则 IndexedDB 最大可能占用空间为 250GB —— 这对于前端存储来几乎是足够的

还有另一个限制称为组限制——这被定义为全局限制的 20%,但它至少有 10 MB,最大为 2GB。 每个源都是一组(源组)的一部分。 每个 eTLD+1 域都有一个组。 例如:

在这个组中,mozilla.org、www.mozilla.org和joe.blogs.mozilla.org可以聚合使用最多20%的全局限制。 firefox.com 单独最多使用 20%。

达到限制后有两种不同的反应:

组限制也称为“硬限制”:它不会触发源回收。 全局限制是一个“软限制”,因为其有可能释放一些空间并且这个操作可能持续。

IndexedDB 安全性

IndexedDB 使用同源原则,这意味着它把存储空间绑定到了创建它的站点的源(典型情况下,就是站点的域或是子域),所以它不能被任何其他源访问。

IndexedDB 简易示例

IndexedDB 的 API 是基于事件响应全异步性的,使用的时候需要监听对应的事件完成操作。一个基本的创建数据库的操作实例如下:

// 如果被打开的数据库不存在,则会自动创建
var request = window.indexedDB.open("MyTestDatabase");

request.onerror = function (event) {
  alert("Why didn't you allow my web app to use IndexedDB?!");
};

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

  // 在不使用 db 的时候尽可能 close掉,否则无法打开一个更高版本的 db,表现形式为 hang 死,无法接收到 onsuccess 事件
  // 注意 close() 函数不会立即中断已经在运行中的 transaction
  // 但是不能再用这个实例创建新的transaction,否则会抛异常
  db.close();
};

数据库的版本号

open() 函数可以传一个可选的 version 参数指定版本号,当 version 比当前 db.version 高时,会触发 onupgradeneeded 事件,同时,会更新 db.version 属性:

var request = window.indexedDB.open("MyTestDatabase", 2);

// 该事件仅在较新的浏览器中实现了
request.onupgradeneeded = function (event) {
  // 保存 IDBDataBase 接口
  var db = event.target.result;

  // 为该数据库创建一个对象仓库
  // 注意 createObjectStore() 只能在 onupgradeneeded 事务中运行
  var objectStore = db.createObjectStore("name", { keyPath: "myKey" });
};

为了防止在其他标签页中打开高版本的数据库 hang 住的情况,一种做法可以参考如下:

var openReq = mozIndexedDB.open("MyTestDatabase", 2);

openReq.onblocked = function (event) {
  // 如果其他的一些页签加载了该数据库,在我们继续之前需要关闭它们
  alert("请关闭其他由该站点打开的页签!");
};

openReq.onupgradeneeded = function (event) {
  // 其他的数据已经被关闭,一切就绪
  db.createObjectStore(/* ... */);
  useDatabase(db);
};

openReq.onsuccess = function (event) {
  var db = event.target.result;
  useDatabase(db);
  return;
};

function useDatabase(db) {
  // 当由其他页签请求了版本变更时,确认添加了一个会被通知的事件处理程序。
  // 这里允许其他页签来更新数据库,如果不这样做,版本升级将不会发生知道用户关闭了这些页签。
  db.onversionchange = function (event) {
    db.close();
    alert("A new version of this page is ready. Please reload or close this tab!");
  };

  // 处理数据库
}

一个基本的存储示例

const dbName = "the_name";

var request = indexedDB.open(dbName, 2);

request.onerror = function (event) {
  // 错误处理
};
request.onupgradeneeded = function (event) {
  var db = event.target.result;

  // 建立一个对象仓库来存储我们客户的相关信息,我们选择 ssn 作为键路径(key path)
  // 因为 ssn 可以保证是不重复的
  var objectStore = db.createObjectStore("customers", { keyPath: "ssn" });

  // 建立一个索引来通过姓名来搜索客户。名字可能会重复,所以我们不能使用 unique 索引
  objectStore.createIndex("name", "name", { unique: false });

  // 使用邮箱建立索引,我们向确保客户的邮箱不会重复,所以我们使用 unique 索引
  objectStore.createIndex("email", "email", { unique: true });

  // 使用事务的 oncomplete 事件确保在插入数据前对象仓库已经创建完毕
  objectStore.transaction.oncomplete = function (event) {
    // 将数据保存到新创建的对象仓库
    var customerObjectStore = db.transaction("customers", "readwrite").objectStore("customers");
    customerData.forEach(function (customer) {
      // add 函数在 keyPath 冲突时会抛异常,如果期望直接覆盖,请使用 put 函数
      customerObjectStore.add(customer);
    });
  };
};

关于索引: IndexedDB 的索引会自动按照由小到大的顺序自动进行排列(有点像 HBase 的 RowKey 设计),同时可以支持多个字段联合索引,如: objectStore.createIndex(“f1-f2”, [“f1”, “f2”]); 对于要想使用类似于 SQL 中的 where 条件查询,则必须预先在对应的字段创建索引才能实现。另外由于 IndexedDB API 并不提供类似于 Order By 的功能,因此只能使用 cursor 的遍历方向来替代。

基本 CURD 示例

// ========= 增加数据 ============
// 在所有数据添加完毕后的处理
transaction.oncomplete = function (event) {
  alert("All done!");
};

transaction.onerror = function (event) {
  // 不要忘记错误处理!
};

var objectStore = transaction.objectStore("customers");
customerData.forEach(function (customer) {
  var request = objectStore.add(customer);
  request.onsuccess = function (event) {
    // event.target.result === customer.ssn;
  };
});

// =========== 删除数据 ==================
var request = db
  .transaction(["customers"], "readwrite")
  .objectStore("customers")
  .delete("444-44-4444");
request.onsuccess = function (event) {
  // 删除成功!
};

// =========== 读取数据 ==================
var transaction = db.transaction(["customers"]);
var objectStore = transaction.objectStore("customers");
var request = objectStore.get("444-44-4444");
request.onerror = function (event) {
  // 错误处理!
};
request.onsuccess = function (event) {
  // 对 request.result 做些操作!
  alert("Name for SSN 444-44-4444 is " + request.result.name);
};

// 简化示例,和上面等效
db.transaction("customers").objectStore("customers").get("444-44-4444").onsuccess = function (
  event
) {
  alert("Name for SSN 444-44-4444 is " + event.target.result.name);
};

// 使用游标
var objectStore = db.transaction("customers").objectStore("customers");

objectStore.openCursor().onsuccess = function (event) {
  var cursor = event.target.result;
  if (cursor) {
    alert("Name for SSN " + cursor.key + " is " + cursor.value.name);
    cursor.continue();
  } else {
    alert("No more entries!");
  }
};

// 使用 index 索引数据
// 首先,确定你已经在 request.onupgradeneeded 中创建了索引:
// objectStore.createIndex("name", "name");
// 否则你将得到 DOMException。

var index = objectStore.index("name");

index.get("Donna").onsuccess = function (event) {
  alert("Donna's SSN is " + event.target.result.ssn);
};

// =========== 更新数据 ==================
var objectStore = db.transaction(["customers"], "readwrite").objectStore("customers");
var request = objectStore.get("444-44-4444");
request.onerror = function (event) {
  // 错误处理
};
request.onsuccess = function (event) {
  // 获取我们想要更新的数据
  var data = event.target.result;

  // 更新你想修改的数据
  data.age = 42;

  // 把更新过的对象放回数据库
  var requestUpdate = objectStore.put(data);
  requestUpdate.onerror = function (event) {
    // 错误处理
  };
  requestUpdate.onsuccess = function (event) {
    // 完成,数据已更新!
  };
};

关于搜索查询

通过上述的示例,可以看到其实 IndexedDB 存储其实是文档型数据库的套路,并且自身的 API 并不支持像 SQL 那样复杂功能多样的查询功能,因此在使用前必须好好设计存储结构,务必确保自己的查询条件不至于太复杂难以实现,这里列出一些常见的查询示例与 SQL 的对照(所有需要用到的 index 需要事先创建好,这里不再赘述):

// select * from table order by field desc
objectStore.index("field").openCursor(null, "prev");

// select * from table where field between 1 and 10
objectStore.index("field").openCursor(IDBKeyRange.bound(1, 10, false, true));

// select distinct on (f1, f2) * from table
// 需要预先创建f1, f2的联合索引: objectStore.createIndex("f1-f2", ["f1", "f2"], { unique: false });
objectStore.index("f1-f2").openCursor(null, "nextunique");

条件搜索主要使用openCursor(keyRange, direction)函数,IDBKeyRange 参数表示查询的范围,类似于where的功能,而direction 决定游标遍历的方向,因此可以实现简单的order by的功能。但是特别注意对于联合索引,每个字段也都是按照asc顺序排序的,因此没办法通过 direction 实现类似于order by f1 asc, f2 desc, f3 desc这样的排序,使用前务必设计好存储结构。

还需要注意无论nextunique还是prevunique,由于它们内部对于相同 value 的排序都是从小到大的,因此它们返回的内容都是一样的,例如:

f1 f2 f3
“a” “b” “c”
“a” “b” “d”

假设创建objectStore.createIndex("f1-f2", ["f1", "f2"], { unique: false })索引,无论openCursor的 direction 参数是nextunique还是prevunique,都只会筛选出{"f1":"a","f2":"b","f3":"c"}这条结果,cursor 的遍历顺序只会影响这条记录出现的次序而已。

Promise

IndexedDB API 本身并不提供 Promise 接口,如果你的场景确实需要用到 Promise,可以考虑自己简单封装下:

new Promise(resolve, reject) {
  const request = window.indexedDB.open("MyTestDatabase");

  request.onerror = function (event) {
    return reject(event);
  }

  request.onsuccess = function (event) {
    const db = event.target.result;
    const getReq = db
      .transaction("customers")
      .objectStore("customers")
      .get("444-44-4444")

    getReq.onsuccess = function (event) {
      return resolve(event.target.result.name);
    };

    getReq.onerror = function (event) {
      return reject(event)
    }
  }
}

如果需要大量使用 Promise,可以考虑使用第三方类库idb或更精简的版本idb-keyval,它们将 IndexedDB 的 API 进行了 Promise 的封装,其中idb-keyval对 KV 对这种存储结构进行了更简化的封装调用,对于自己的场景只有简单的 KV 存储这种场景可以考虑使用


友情链接


相关文章