浏览器离线存储概述
一起来看一下 Data Persistence/storage 这个部分,翻译成中文叫做浏览器离线存储或者本地存储;顾名思义,就是内容存储在浏览器这一边;
目前常见的浏览器离线存储的方式如下:
- Cookie
- Web Storage
- WebSQL
- IndexedDB
- File System
IndexedDB 简介
-
随着浏览器的功能不断增强,越来越多的网站开始考虑,将大量数据储存在客户端,这样可以减少从服务器获取数据,直接从本地获取数据;
-
现有的浏览器数据储存方案,都不适合储存大量数据:Cookie 的大小不超过 4KB ,且每次请求都会发送回服务器;LocalStorage 在 2.5MB 到 10MB 之间(各家浏览器不同),而且不提供搜索功能,不能建立自定义的索引;所以,需要一种新的解决方案,这就是 IndexedDB 诞生的背景;
-
MDN 官网是这样解释 IndexedDB 的:
- IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据,也包括文件/二进制大型对象(blobs);
- 该 API 使用索引实现对数据的高性能搜索;虽然 Web Storage 在存储较少量的数据很有用,但对于存储更大量的结构化数据来说力不从心;而 IndexedDB 提供了这种场景的解决方案;
- 由于 IndexedDB 所提供的原生 API 比较复杂,所以现在也出现了基于 IndexedDB 封装的库,例如 Dexie.js;该库和 IndexedDB 之间的关系,就类似于 jQuery 和 JavaScript 之间的关系;
-
通俗地说 IndexedDB 就是浏览器提供的本地数据库,它可以被网页脚本创建和操作。IndexedDB 允许储存大量数据,提供查找接口,还能建立索引;这些都是 LocalStorage 所不具备的;就数据库类型而言,IndexedDB 不属于关系型数据库 (不支持 SQL 查询语句),更接近 NoSQL 数据库;
-
下表罗列出了几种常见的客户端存储方式的对比:
会话期 Cookie 持久性 Cookie sessionStorage localStorage IndexedDB WebSQL 存储大小 4kb 4kb 2.5~10MB 2.5~10MB >250MB 已废弃 失效时间 浏览器关闭自动清除 设置过期时间,到期后清除 浏览器关闭后清除 永久保存(除非手动清除) 手动更新或删除 已废弃 -
IndexedDB 具有以下特点:
键值对储存
:IndexedDB 内部采用对象仓库 (object store) 存放数据;所有类型的数据都可以直接存入,包括 JavaScript 对象;对象仓库中,数据以 键值对 的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误;异步
:IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的;异步设计是为了防止大量数据的读写,拖慢网页的表现;支持事务
:IndexedDB 支持事务 (transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况;这和 MySQL 等数据库的事务类似;同源限制
:IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库;储存空间大
:这是 IndexedDB 最显著的特点之一;IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB ,甚至没有上限;支持二进制储存
:IndexedDB 不仅可以储存字符串,还可以储存二进制数据 (ArrayBuffer 对象和 Blob 对象);
-
IndexedDB 主要使用在于客户端需要存储大量的数据的场景下:
- 数据可视化等界面,大量数据,每次请求会消耗很大性能;
- 即时聊天工具,大量消息需要存在本地;
- 其它存储方式容量不满足时,不得已使用 IndexedDB;
IndexedDB 重要概念
-
在正式开始之前,先来介绍一下 IndexedDB 里面一些重要的概念;
-
IndexedDB 是一个比较复杂的 API ,涉及不少概念;它把不同的实体,抽象成一个个对象接口;学习这个 API 就是学习它的各种对象接口;
- 数据库:IDBDatabase 对象
- 对象仓库:IDBObjectStore 对象
- 索引:IDBIndex 对象
- 事务:IDBTransaction 对象
- 操作请求:IDBRequest 对象
- 指针:IDBCursor 对象
- 主键集合:IDBKeyRange 对象
数据库
-
数据库是一系列相关数据的容器;每个域名(严格的说,是协议 + 域名 + 端口)都可以新建任意多个数据库;
-
IndexedDB 数据库有版本的概念;同一个时刻,只能有一个版本的数据库存在;如果要修改数据库结构(新增或删除表、索引或者主键),只能通过升级数据库版本完成;
对象仓库
-
每个数据库包含若干个对象仓库 (object store);
-
它类似于关系型数据库的表格;
数据记录
-
对象仓库保存的是数据记录;每条记录类似于关系型数据库的行,但是只有主键和数据体两部分;
-
主键用来建立默认的索引,必须是不同的,否则会报错;主键可以是数据记录里面的一个属性,也可以指定为一个递增的整数编号;
{ id: 1, text: 'foo' }
-
上面的对象中,id 属性可以当作主键;数据体可以是任意数据类型,不限于对象;
索引
-
为了加速数据的检索,可以在对象仓库里面,为不同的属性建立索引;在关系型数据库当中也有索引的概念,可以给对应的表字段添加索引,以便加快查找速率;
-
在 IndexedDB 中同样有索引,可以在创建 store 的时候同时创建索引,在后续对 store 进行查询的时候即可通过索引来筛选,给某个字段添加索引后,在后续插入数据的过成功,索引字段便不能为空;
事务
-
数据记录的读写和删改,都要通过事务完成;
-
事务对象提供 error、abort 和 complete 三个事件,用来监听操作结果;
指针(游标)
-
游标是 IndexedDB 数据库新的概念;
-
可以把游标想象为一个指针,比如要查询满足某一条件的所有数据时,就需要用到游标,让游标一行一行的往下走,游标走到的地方便会返回这一行数据,此时便可对此行数据进行判断,是否满足条件;
IndexedDB 实操
IndexedDB 所有针对仓库的操作都是基于事务的;
操作数据库
-
首先第一步是创建以及连接数据库;
htmlJavaScript<body> <script src="./db.js"></script> <script> openDB('stuDB',1) </script> </body>
/*** * 打开数据库 * @param {object} dbName 数据库的名字 * @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 }); }; }); }
-
在上面的代码中,封装了一个 openDB 的函数,该函数调用 indexedDB.open 方法来尝试打开一个数据库,如果该数据库不存在,就会创建;
- indexedDB.open 方法返回一个对象,在这个对象上面分别监听了成功、错误以及更新这三个事件;
- 这里尤其要说一下 upgradeneeded 更新事件;该事件会在数据库发生更新时触发,什么叫做数据库有更新时呢?就是添加或删除表,以及数据库版本号更新的时候;
- 因为一开始创建数据库时,版本是从无到有,所以也会触发这个事件;
-
在 index.html 文件中,引入了 db.js,然后调用了 openDB 方法,效果如下图所示
-
使用完数据库后,建议关闭数据库,以节约资源:
/*** * 关闭数据库 * @param {object} db 数据库实例 */ function closeDB(db) { db.close(); console.log("数据库已关闭"); }
-
如果要删除数据库,可以使用 indexDB 的 deleteDatabase 方法即可;
/*** * 删除数据库 * @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("删除成功"); }; }
插入数据
-
接下来是插入数据,仍然封装一个 addData 方法,代码如下:
/*** * 新增数据 * @param {object} db 数据库实例 * @param {string} storeName 仓库名称 * @param {object} 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("数据写入失败"); }; }
-
接下来在 index.html 中来测试;
<body> <script src="./db.js"></script> <script> openDB('stuDB', 1) .then((db) => { addData(db, "stu", { "stuId": 1, "stuName": "谢杰", "stuAge": 18 }); addData(db, "stu", { "stuId": 2, "stuName": "雅静", "stuAge": 20 }); addData(db, "stu", { "stuId": 3, "stuName": "谢希之", "stuAge": 4 }); }) </script> </body>
-
效果如下:
注意:插入的数据是一个对象,而且必须包含声明的索引键值对;
读取数据
-
在仓库对象上面调用 get 方法从而通过主键获取数据;
JavaScripthtml/*** * 通过主键读取数据 * @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); }; }); }
<body> <script src="./db.js"></script> <script> openDB('stuDB', 1) .then((db) => { addData(db, "stu", { "stuId": 1, "stuName": "谢杰", "stuAge": 18 }); addData(db, "stu", { "stuId": 2, "stuName": "雅静", "stuAge": 20 }); addData(db, "stu", { "stuId": 3, "stuName": "谢希之", "stuAge": 4 }); // 返回了主键 stuId 为 2 的学生数据 return getDataByKey(db, "stu", 2); }).then((stuInfo)=>{ console.log(stuInfo); // {stuId: 2, stuName: '雅静', stuAge: 20} }) </script> </body>
-
仓库对象也提供了 getAll 方法,能够查询整张表的数据内容;
-
还可以通过指针来进行查询,例如:
JavaScripthtml/*** * 通过游标读取数据 * @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(); // 方法开启了一个指针,这个指针会指向数据表的第一条数据,之后指针逐项进行偏移从而遍历整个数据表 // 游标开启成功,逐行读数据 // 每次偏移拿到数据后 push 到 list 数组里面,如果某一次没有拿到数据,说明已经读取完了所有的数据,那么就返回 list 数组 request.onsuccess = function (e) { var cursor = e.target.result; if (cursor) { // 必须要检查 list.push(cursor.value); cursor.continue(); // 遍历了存储对象中的所有内容 } else { resolve(list) } }; }) }
<body> <script src="./db.js"></script> <script> openDB("stuDB", 1) .then((db) => { addData(db, "stu", { stuId: 1, stuName: "谢杰", stuAge: 18 }); addData(db, "stu", { stuId: 2, stuName: "雅静", stuAge: 20 }); addData(db, "stu", { stuId: 3, stuName: "谢希之", stuAge: 4 }); return cursorGetData(db, "stu"); }) .then((stuInfo) => { console.log(stuInfo); }); </script> </body>
-
可以通过索引来查询数据;
JavaScripthtml/*** * 通过索引读取数据 * @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); }; }) }
<body> <script src="./db.js"></script> <script> openDB('stuDB', 1) .then((db) => { addData(db, "stu", { "stuId": 4, "stuName": "牛牛", "stuAge": 4 }); return getDataByIndex(db, "stu", "stuAge", 4); }).then((stuInfo) => { console.log(stuInfo); // {stuId: 3, stuName: '谢希之', stuAge: 4} }) </script> </body>
但是很奇怪的是查询出来的数据却只有第一条符合要求的;如果想要查询出索引中满足某些条件的所有数据,可以将索引和游标结合起来;
-
通过索引和游标查询记录;
利用索引和游标结合查询,可以查询出索引值满足传入函数值的所有数据对象,而不是只查询出一条数据或者所有数据
/*** * 通过索引和游标查询记录 * @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 需要我们自己实现分页;
JavaScripthtml/*** * 通过索引和游标分页查询记录 * @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); // advance 可以让游标跳过多少条开始查询 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) { }; }) }
<body> <script src="./db.js"></script> <script> openDB('stuDB', 1) .then((db) => { addData(db, "stu", { "stuId": 5, "stuName": "张三", "stuAge": 23 }); addData(db, "stu", { "stuId": 6, "stuName": "李四", "stuAge": 24 }); addData(db, "stu", { "stuId": 7, "stuName": "王武", "stuAge": 32 }); addData(db, "stu", { "stuId": 8, "stuName": "刘德华", "stuAge": 34 }); addData(db, "stu", { "stuId": 9, "stuName": "张学友", "stuAge": 28 }); addData(db, "stu", { "stuId": 10, "stuName": "郭富城", "stuAge": 27 }); addData(db, "stu", { "stuId": 11, "stuName": "黎明", "stuAge": 17 }); addData(db, "stu", { "stuId": 12, "stuName": "邓超", "stuAge": 19 }); addData(db, "stu", { "stuId": 13, "stuName": "刘翔", "stuAge": 15 }); addData(db, "stu", { "stuId": 14, "stuName": "洋洋", "stuAge": 12 }); addData(db, "stu", { "stuId": 15, "stuName": "林佳音", "stuAge": 14 }); addData(db, "stu", { "stuId": 16, "stuName": "袁进", "stuAge": 34 }); addData(db, "stu", { "stuId": 17, "stuName": "老闫", "stuAge": 36 }); addData(db, "stu", { "stuId": 18, "stuName": "沈爷", "stuAge": 34 }); return cursorGetDataByIndexAndPage(db, "stu", "", "", 3, 5); }).then((stuInfo) => { console.log(stuInfo); // {stuId: 3, stuName: '谢希之', stuAge: 4} }) </script> </body>
更新数据
-
IndexedDB 更新数据较为简单,直接使用 put 方法,值得注意的是如果数据库中没有该条数据,则会默认增加该条数据,否则更新;有些小伙伴喜欢更新和新增都是用 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: "更新数据失败" }) }; }) }
-
在上面的方法中,使用仓库对象的 put 方法来修改数据,所以在调用该方法时,需要传入整条数据对象,特别是主键;因为是通过主键来查询到要修改的数据;如果传入的数据没有主键,则是一个新增数据的效果;
openDB('stuDB', 1) .then((db) => { return updateDB(db, "stu", {stuId: 1, stuName: '谢杰2', stuAge: 19}); }).then(({message}) => { console.log(message); })
-
效果如下:
删除数据
-
删除数据这里记录 2 种方式,一个是通过主键来进行删除;
/*** * 通过主键删除数据 * @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: "删除数据失败" }) }; }) }
-
执行下面的代码后 stuId 为 1 的学生被删除掉:
openDB('stuDB', 1) .then((db) => { return deleteDB(db, "stu", 1) }).then(({message}) => { console.log(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) { }; }) }
-
在上面的示例中,删除了所有 stuName 值为 “雅静” 的同学;
openDB('stuDB', 1) .then((db) => { return cursorDelete(db, "stu", "stuName", "雅静") }).then(({ message }) => { console.log(message); })
浏览器🧑💻 浏览器的离线存储-WebSQL
上一篇