使用 IndexedDB 构建一个基本的 Web 应用程序
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
IndexedDB 是一款 NoSQL 数据库,您可以在所有主流浏览器上使用它来存储大量数据,并像在 MongoDB 等数据库中一样进行查询。如果您正在开发一个存储大量数据的 Web 应用或浏览器扩展程序,并且希望提供多种查询方式,那么 IndexedDB 就是您的理想之选!
在本教程中,我们将创建一个简单的、不使用任何框架的便签 Web 应用,以此概述使用 IndexedDB 时应该了解的概念。如需更深入的了解,Mozilla 开发者网络的“使用 IndexedDB”是另一个很棒的概述,我还推荐https://www.freecodecamp.org/news/a-quick-but-complete-guide-to-indexeddb-25f030425501/,它更侧重于 API 方法。
您可以在这里找到本教程的代码,关于向 IDB 代码添加测试覆盖率的本教程第 2 部分在这里。
为什么要在我的Web应用程序中使用IndexedDB?
正如我前面提到的,选择 IndexedDB 而不是本地存储的原因主要有两个:
- 没有大小限制;如果您的应用处理大量数据,超过了本地存储和会话存储所能提供的几兆字节,IndexedDB 可以让您存储大量数据。
- 结构化存储;您可以将对象存储在 IndexedDB 对象存储中,并使用它们的字段查询它们。
这些也是将数据存储在服务器上的优势,因此如果你的项目有后端,你可以直接将数据存储在后端。但如果你正在开发一个离线优先的 Web 应用,或者一个没有后端的应用,IndexedDB 就是你技术栈的绝佳选择。例如,我正在开发一个浏览器扩展,用于生成用户标签页的图表,从而实现可视化、交互式的网页浏览历史记录。为此,我需要能够存储大量标签页,并按时间顺序检索它们,而且该应用没有 Web 后端,所以 IndexedDB 非常合适!
构建我们的数据库
好了,我们开始制作应用吧!首先,创建一个名为 indexeddb-tutorial 的文件夹,并在一个名为 indexeddb-tutorial 的文件中db.js,添加以下代码,这将创建我们的数据库!
let db;
let dbReq = indexedDB.open('myDatabase', 1);
dbReq.onupgradeneeded = function(event) {
// Set the db variable to our database so we can use it!
db = event.target.result;
// Create an object store named notes. Object stores
// in databases are where data are stored.
let notes = db.createObjectStore('notes', {autoIncrement: true});
}
dbReq.onsuccess = function(event) {
db = event.target.result;
}
dbReq.onerror = function(event) {
alert('error opening database ' + event.target.errorCode);
}
要运行这段 JavaScript 代码,请将其放入名为 index.html 的文件中,然后在 Chrome 浏览器中打开它:
<!DOCTYPE html>
<html>
<head><title>IndexedDB note store</title></head>
<body>
<div id="app"><h1>Coming soon</h1></div>
<script src="db.js"></script>
</body>
</html>
现在在 Chrome 浏览器中,打开开发者工具,点击应用程序标签,然后点击左侧栏中的IndexedDB,可以看到数据库已经创建好了!
太棒了!我们有一个名为`<database_name> ` 的数据库myDatabase,还有一个名为`<object_store_name>` 的对象存储(类似于 SQL 表或 MongoDB 中的集合)notes。但是仅仅为了创建数据库和存储就写了这么多代码,感觉有点多。这是怎么回事呢?
开头几行
let db;
let dbReq = indexedDB.open('myDatabase', 1);
我们打开名为 myDatabase 的数据库版本 1,但indexedDB.open它不会返回数据库,而是返回一个数据库请求,因为 IndexedDB 是一个异步API。IndexedDB 代码在后台运行,所以如果我们执行类似存储数千条记录的操作,Web 应用程序的其他部分不会停止运行 JavaScript 代码,而是等待该操作完成。因此,在其余代码中,我们使用事件监听器来监听数据库何时准备就绪:
dbReq.onupgradeneeded = function(event) {
db = event.target.result;
let notes = db.createObjectStore('notes', {autoIncrement: true});
}
myDatabase由于之前并不存在,因此会自动创建,然后onupgradeneeded事件触发。只有在 `onupgradeneeded` 回调函数中,我们才能创建数据库的对象存储。首先,db = event.target.result我们使用 `onupgradeneeded` 设置变量db来保存数据库。然后,我们创建一个名为 `<object store_name>` 的对象存储notes。
dbReq.onsuccess = function(event) {
db = event.target.result;
}
这里,它会在操作完成onsuccess后触发onupgradeneeded,如果我们刷新页面并再次打开数据库,它也会触发。所以,我们也会运行db = event.target.result这个函数来获取数据库以便使用它。
dbReq.onerror = function(event) {
alert('error opening database ' + event.target.errorCode);
}
最后,如果任何 IndexedDB 请求出错,都会onerror触发相应的事件,您可以根据需要处理该错误。我们这里就做一个alert.
将一些数据存入数据库
我们已经有了数据库,但是没有数据就做不了什么。让我们编写一个函数来添加便签吧!
function addStickyNote(db, message) {
// Start a database transaction and get the notes object store
let tx = db.transaction(['notes'], 'readwrite');
let store = tx.objectStore('notes');
// Put the sticky note into the object store
let note = {text: message, timestamp: Date.now()};
store.add(note);
// Wait for the database transaction to complete
tx.oncomplete = function() { console.log('stored note!') }
tx.onerror = function(event) {
alert('error storing note ' + event.target.errorCode);
}
}
为了实际演示这一点,让我们在代码中添加三个对我们的函数的调用,dbReq.onsuccess以便在数据库准备就绪后运行它们:
dbReq.onsuccess = function(event) {
db = event.target.result;
// Add some sticky notes
addStickyNote(db, 'Sloths are awesome!');
addStickyNote(db, 'Order more hibiscus tea');
addStickyNote(db, 'And Green Sheen shampoo, the best for sloth fur algae grooming!');
}
现在刷新浏览器中的 index.html,再次在开发者工具中选择“应用程序”>“IndexedDB”,点击对象存储,让我们看看我们的数据!
现在我们已经存储了一些数据!正如你所看到的,笔记对象存储中的便签是以 JavaScript 对象的形式存储的。那么,那段代码到底在做什么呢?
let tx = db.transaction(['notes'], 'readwrite');
let store = tx.objectStore('notes');
首先,我们在数据库上启动一个事务notes,将数据写入对象存储,然后我们从该事务中检索该对象存储。
let note = {text: message, timestamp: Date.now()};
store.add(note);
我们将便笺表示为一个 JavaScript 对象,并通过调用该函数将其存储在对象存储中store.add。
tx.oncomplete = function() { console.log('stored note!') }
tx.onerror = function(event) {
alert('error storing note ' + event.target.errorCode);
}
最后,就像我们打开数据库请求一样,此事务也有事件监听器;我们监听存储笔记的操作,无论是完成还是出错,都会触发事务oncomplete监听onerror器。
关于我们的便签,还有一点值得注意:每张便签都有一个Key递增的数字。所以,如果你在三张便签之后再放一张,它的键值就是 4。这些数字是怎么来的呢?在 IndexedDB 中,对象存储中的所有对象都有一个标识它们的键,当我们使用以下代码创建对象存储时:
let notes = db.createObjectStore('notes', {autoIncrement: true});
该autoIncrement选项表示我们希望存储中的每个对象都有一个递增的键。如果通过唯一名称存储和检索对象更有意义,您也可以创建使用字符串键的对象存储(例如,UUID 可以作为对象存储的字符串键,或者如果您有一个树懒对象存储,则可以使用树懒吱吱叫声的字符串编码作为键来标识每只树懒)。
现在让我们把这个addStickyNote功能添加到我们的实际 Web 应用中,以便用户可以点击提交便签。我们需要一个文本框来提交便签,所以在 id 为 `<div>` 的 div 元素中app,添加以下标签:
<div id="textbox">
<textarea id="newmessage"></textarea>
<button onclick="submitNote()">Add note</button>
</div>
并将以下函数添加到 db.js 中,该函数会在每次用户提交笔记时运行:
function submitNote() {
let message = document.getElementById('newmessage');
addStickyNote(db, message.value);
message.value = '';
}
现在去掉对addStickyNotein的调用dbReq.onsuccess,然后如果我们进入 index.html 并在文本区域中输入一些内容,点击提交后,我们就会看到笔记被存储在 IndexedDB 中!
不过,在我们继续讲解如何检索数据以便显示之前,让我们先来谈谈使用 IndexedDB 的一个核心概念——事务!
在 IndexedDB 中,事务至关重要。
正如您在上一个示例中看到的,要访问notes对象存储,我们需要db.transaction创建一个事务,事务是一组对数据库的一个或多个请求。IndexedDB 中的所有操作都通过事务完成。因此,存储便签、打开数据库以及检索便签都是在事务内部执行的请求。
您也可以在同一事务中发出多个请求,例如,如果您在同一个对象存储中存储多个项目,则可以在同一事务中发出所有 store.add 请求,如下所示:
function addManyNotes(db, messages) {
let tx = db.transaction(['notes'], 'readwrite');
let store = tx.objectStore('notes');
for (let i = 0; i < messages.length; i++) {
// All of the requests made from store.add are part of
// the same transaction
store.add({text: messages[i], timestamp: Date.now()});
}
// When all of these requests complete, the transaction's oncomplete
// event fires
tx.oncomplete = function() {console.log('transaction complete')};
}
就像请求有 `requests`onsuccess和onerror`transactions` 事件处理程序一样,事务也有 `transactions` oncomplete、 `transaction-error`onerror和 ` onaborttransaction-rollback` 事件处理程序,我们可以分别使用这些处理程序来响应事务的完成、出错或回滚。
但是,将每个请求都放在事务中究竟有什么好处呢?请记住,IndexedDB 是一个异步 API,因此可能同时存在多个请求。假设我们在便笺存储中有一张便笺,上面写着“树懒真棒”,我们发出一个请求将便笺内容全部大写,然后又发出另一个请求在便笺上添加一个感叹号。如果没有事务,我们可能会遇到这样的情况:
我们启动了这makeAllCaps两个addExclamation操作,它们都检索到了未修改的“树懒真棒”笔记。addExclamation第一个操作先保存了带有感叹号的笔记。第二个操作makeAllCaps耗时更长,保存的笔记是“树懒真棒”,没有感叹号。这次makeAllCaps更新完全覆盖了之前的更新addExclamation!
但是,有了事务,我们就实现了并发控制。在对象存储中,一次只能有一个事务创建、修改或删除项,因此 IndexedDB 中实际发生的情况更像是这样:
事务makeAllCaps先启动,但由于addExclamation它与 makeAllCaps 使用同一个对象存储,所以要等到 makeAllCaps 完成后才会启动。因此,makeAllCaps 完成后,addExclamation 读取全大写的注释,然后两个编辑操作才会生效!🎉
这也意味着,如果一条道路是一个对象存储库,而街道清扫车和划线车在没有交易的情况下运行,那么划线车可能会在街道清扫车移动树枝之前完成划线,结果就会出现这种情况:
但是有了 IndexedDB 运行事务,扫街车可以清扫道路上的树枝,划线工可以画线,这样树懒就可以安全地骑自行车了!
在继续之前,还有一点需要了解,那就是对同一对象存储进行的事务,只有在添加、修改或删除数据时才会一次执行一个;换句话说,它们是readwrite事务,其创建方式如下:
let tx = db.transaction(['notes', 'someOtherStore'], 'readwrite');
这里我们创建一个读写事务,并声明它会影响对象存储notesA 和 B。someOtherStore由于它是读写事务,因此只有在任何其他涉及这两个对象存储的事务完成后,它才能开始执行。
虽然读写事务一次只能执行一个,但还有readonly事务;您可以同时创建任意多个事务来访问同一个对象存储,因为我们不需要阻止它们互相干扰数据!您可以像这样创建它们:
// These transactions can all do their thing at the same time, even with
// overlapping object stores!
let tx = db.transaction(['notes', 'someOtherStore'], 'readonly');
let tx2 = db.transaction(['notes'], 'readonly');
let tx3 = db.transaction(['someOtherStore'], 'readonly');
取出一张便签
现在我们了解了事务和只读事务的工作原理,接下来让我们从便笺库中检索便笺以便显示它们。如果我们只从数据库中获取一个项目,我们会使用对象存储的get方法,如下所示:
// Set up an object store and transaction
let tx = db.transaction(['notes'], 'readonly');
let store = tx.objectStore('notes');
// Set up a request to get the sticky note with the key 1
let req = store.get(1);
// We can use the note if the request succeeds, getting it in the
// onsuccess handler
req.onsuccess = function(event) {
let note = event.target.result;
if (note) {
console.log(note);
} else {
console.log("note 1 not found")
}
}
// If we get an error, like that the note wasn't in the object
// store, we handle the error in the onerror handler
req.onerror = function(event) {
alert('error getting note 1 ' + event.target.errorCode);
}
我们发起一笔交易,请求笔记存储中键值为 1 的笔记以获取请求结果,然后我们会在请求的onsuccess处理程序中使用检索到的笔记,或者如果收到错误,则在处理程序中处理该错误onerror。请注意,如果便笺不存在,onsuccess仍然会触发,但event.target.result结果会是undefined。
这个模式和我们之前用于打开数据库的处理程序类似;我们发起请求,然后在onsuccess处理程序中获取结果或处理错误onerror。但我们想要的不仅仅是一条笔记,而是所有笔记。所以我们需要获取所有笔记,为此我们使用游标。
使用光标检索数据并显示您的便笺
从对象存储中检索所有项的语法很奇怪:
function getAndDisplayNotes(db) {
let tx = db.transaction(['notes'], 'readonly');
let store = tx.objectStore('notes');
// Create a cursor request to get all items in the store, which
// we collect in the allNotes array
let req = store.openCursor();
let allNotes = [];
req.onsuccess = function(event) {
// The result of req.onsuccess in openCursor requests is an
// IDBCursor
let cursor = event.target.result;
if (cursor != null) {
// If the cursor isn't null, we got an item. Add it to the
// the note array and have the cursor continue!
allNotes.push(cursor.value);
cursor.continue();
} else {
// If we have a null cursor, it means we've gotten
// all the items in the store, so display the notes we got.
displayNotes(allNotes);
}
}
req.onerror = function(event) {
alert('error in cursor request ' + event.target.errorCode);
}
}
执行该函数后,以下是所有步骤:
let tx = db.transaction(['notes'], 'readonly');
let store = tx.objectStore('notes');
在函数开始时,我们在notes对象存储上创建一个只读事务。然后我们获取存储,并通过该store.openCursor()方法获取请求。这意味着我们再次使用请求结果及其onsuccess处理onerror程序来处理这些结果。
在 onsuccess 处理程序中,事件的结果是一个IDBCursor,其中包含key光标所持便笺的 ID,以及便笺本身作为光标的 ID value。
let cursor = event.target.result;
if (cursor != null) {
allNotes.push(cursor.value);
cursor.continue();
} else {
在 if 语句中,如果游标不为空,则表示我们还有另一张便签,因此我们将游标添加value到我们的便签数组中,并通过调用继续检索便签cursor.continue。
} else {
displayNotes(allNotes);
}
但如果光标为空,则没有更多笔记可供检索,因此我们将笔记传递给displayNotes函数来显示笔记。
嗯,这cursor.continue()感觉有点像 while 循环,但实际上并没有循环或控制流。那么,我们究竟是如何实现循环的呢?这行代码会给你一些提示:
req.onsuccess = function(event) {
原来,每次调用 `onSuccess` 时cursor.continue(),都会触发一个事件,并将包含下一个项目的游标发送到 `onSuccess` 处理程序。因此onsuccess,在每次调用 `onSuccess` 时,我们都会收集一个新的便签,直到遇到游标为空的 `onSuccess` 事件。这就是我们使用游标遍历数据的方式。
现在,为了显示这些便签,在 index.html 文件中,在文本框 div 之后,在文本框下方添加一个 div 来存储我们的便签:
<div id="notes"></div>
在 db.js 文件中添加以下函数以显示备注:
function displayNotes(notes) {
let listHTML = '<ul>';
for (let i = 0; i < notes.length; i++) {
let note = notes[i];
listHTML += '<li>' + note.text + ' ' +
new Date(note.timestamp).toString() + '</li>';
}
document.getElementById('notes').innerHTML = listHTML;
}
该函数只是将每个笔记转换为一个<li>标签,并使用传统的 JavaScript 将它们显示为列表。
现在我们有了显示所有便签的函数,让我们把它添加到几个地方。我们希望在首次打开应用时能够看到所有便签,因此在数据库首次打开时,我们应该调用以下getAndDisplayNotes函数dbReq.onsuccess:
dbReq.onsuccess = function(event) {
db = event.target.result;
// Once the database is ready, display the notes we already have!
getAndDisplayNotes(db);
}
添加便笺后,应该能够立即看到它,因此addStickyNote,让我们将事务完成回调更改为调用getAndDisplayNotes:
tx.oncomplete = function() { getAndDisplayNotes(db); }
现在用 Chrome 浏览器重新打开页面,尝试添加更多注释。它应该看起来像这样!
最后,我们来创建一个模式,优先显示最新的笔记,看看为什么它被称为 IndexedDB!
索引,将索引放入 IndexedDB 中
我们有这样一个便签存储系统,并且存储的便签带有时间戳,所以应该能够检索某个时间范围内的所有便签(例如过去 10 分钟内的所有便签),或者能够先检索最新的便签,对吧?
当然可以,但要通过时间戳字段进行查询,我们需要在 notes 对象存储中为该字段创建一个索引。有了索引之后,我们就可以通过它进行查询了。但请记住,对数据库结构的任何更改都需要在数据库请求onupgradeneeded处理程序中进行,因此我们需要更新数据库版本才能创建索引,如下所示:
// We update the version of the database to 2 to trigger
// onupgradeneeded
let dbReq = indexedDB.open('myDatabase', 2);
dbReq.onupgradeneeded = function(event) {
db = event.target.result;
// Create the notes object store, or retrieve that store if it
// already exists.
let notes;
if (!db.objectStoreNames.contains('notes')) {
notes = db.createObjectStore('notes', {autoIncrement: true});
} else {
notes = dbReq.transaction.objectStore('notes');
}
// If there isn't already a timestamp index in our notes object
// store, make one so we can query notes by their timestamps
if (!notes.indexNames.contains('timestamp')) {
notes.createIndex('timestamp', 'timestamp');
}
}
首先,我们将数据库版本更新为 2,这表明数据库结构正在发生变化,因此会onupgradeneeded触发该事件。
现在我们进行版本升级,而笔记对象存储之前已经存在,所以我们检查是否已经存在笔记存储db.objectStoreNames:
if (!db.objectStoreNames.contains('notes')) {
如果该对象存储已存在,我们可以使用以下方式检索它dbReq.transaction.objectStore:
notes = dbReq.transaction.objectStore('notes');
最后,我们添加一个索引createIndex:
notes.createIndex('timestamp', 'timestamp');
第一个参数是索引的名称,第二个参数是索引的keyPath。索引本身实际上就是一个对象存储,因此索引中的每个项都有一个键。所以,如果您为索引指定 keyPath timestamp,那么对象存储中每个对象的时间戳将作为该索引的键。
此外,还有一个可选的第三个选项对象参数。假设我们的笔记有标题,并且我们希望规定如果一条笔记的标题与其他笔记相同,则不能将其存储。我们可以通过创建一个唯一的标题索引来实现这一点,如下所示:
notes.createIndex('title', 'title', {unique: true});
要查看新的索引,更新后onupgradeneeded,请在 Chrome 中刷新 index.html(可能需要关闭 Chrome 窗口才能看到更改),然后再次转到“开发者工具”>“应用程序”>“IndexedDB”,您应该能够在笔记对象存储中看到新的时间戳索引:
如您所见,笔记现在以时间戳作为主键进行排列。实际上,作为一种对象存储,索引拥有get与openCursor常规对象存储相同的方法。例如,我们可以通过调用以下方法请求列表中的第一条笔记:
tx.objectStore('notes').index('timestamp').get(1533144673015);
好了。现在我们有了很棒的新索引,让我们为 Web 应用添加一个模式,用来切换笔记的显示顺序。首先,在 db.js 文件中添加一个全局布尔变量:
let reverseOrder = false;
然后,在 getAndDisplayNotes 中,我们只需要更新我们的请求,以便我们使用时间戳索引,并选择从哪个方向读取便笺。
let tx = db.transaction(['notes'], 'readonly');
let store = tx.objectStore('notes');
// Retrieve the sticky notes index to run our cursor query on;
// the results will be ordered by their timestamp
let index = store.index('timestamp');
// Create our openCursor request, on the index rather than the main
// notes object store. If we're going in reverse, then specify the
// direction as "prev". Otherwise, we specify it as "next".
let req = index.openCursor(null, reverseOrder ? 'prev' : 'next');
在`getIndexPath` 方法中store.index(),我们检索指定名称的索引,就像从事务中检索对象存储一样。现在,我们可以针对该索引定义一个游标请求,以按时间戳排序获取笔记。
index.openCursor它有两个可选参数。第一个参数(如果不为空)允许我们指定要检索的项目范围。例如,如果我们只想获取过去一小时内的便签,可以这样打开光标:
let anHourAgoInMilliseconds = Date.now() - 60 * 60 * 1000;
// IDBKeyRange is a global variable for defining ranges to query
// indices on
let keyRange = IDBKeyRange.lowerBound(anHourAgoInMilliseconds);
let req = index.openCursor(keyRange, 'next');
第二个参数是我们想要检索项目的顺序,可以是“是”'prev'或“否'next'”,因此我们通过传入“是”来指定方向reverseOrder ? 'prev' : 'next'。
最后,让我们看看实际效果;在 index.html 文件中,添加另一个函数。这个函数用于翻转我们显示的笔记顺序:
function flipNoteOrder(notes) {
reverseOrder = !reverseOrder;
getAndDisplayNotes(db);
}
要从用户界面使用 flipNoteOrder 函数,请在 index.html 中添加一个用于翻转音符顺序的按钮。
<button onclick="flipNoteOrder()">Flip note order</button>
刷新 Chrome 浏览器后,翻转按钮应该就能正常工作了!
太棒了!现在我们可以更改笔记的显示顺序了!现在你已经了解了 IndexedDB 的基础知识。还有一些我们没有实际演示的功能,例如删除对象、在 IndexedDB 中存储二进制数据以及多字段索引,但这应该足以让你以身作则,开始使用 IndexedDB 构建 Web 应用程序。
正如你所见,IndexedDB 的基本 API 虽然功能强大,但使用起来并不方便。我不知道你怎么想,但对我来说,这些事件监听器用起来很不方便,而且我第一次研究如何为 IndexedDB 代码编写测试覆盖率时,也花了不少心思去琢磨这些处理程序。此外,我们又该如何为这个 API 编写自动化测试呢?
接下来的几期教程中,我会教大家如何重构这段代码使其易于测试;再下一期教程,我会教大家如何重构它使其更易于使用!下次见!
保持懒惰!
【本教程第三部分正在编写中】
文章来源:https://dev.to/andyhaskell/build-a-basic-web-app-with-indexeddb-38ef









