客户端存储
现代 Web 浏览器支持网站通过多种方式在用户的计算机上存储数据(经用户许可),并在必要时检索这些数据。这让你可以持久存储数据,保存网站或文档以供离线使用,保留网站的用户特定设置等等。本文解释了这些工作原理的基础知识。
预备知识 | 熟悉 HTML、CSS 和 JavaScript,尤其是 JavaScript 对象基础知识以及 DOM 脚本和网络请求等核心 API 知识。 |
---|---|
学习成果 |
|
客户端存储?
在 MDN 学习区的其他地方,我们讨论了静态网站和动态网站之间的区别。大多数主要的现代网站都是动态的——它们使用某种数据库(服务器端存储)在服务器上存储数据,然后运行服务器端代码来检索所需数据,将其插入静态页面模板,并将生成的 HTML 提供给客户端以供用户浏览器显示。
客户端存储的工作原理类似,但用途不同。它由 JavaScript API 组成,允许你在客户端(即用户机器上)存储数据,并在需要时检索它。这有很多不同的用途,例如:
- 个性化网站偏好设置(例如,显示用户选择的自定义小部件、配色方案或字体大小)。
- 持久化之前的网站活动(例如,存储上次会话的购物车内容,记住用户是否之前登录过)。
- 在本地保存数据和资产,使网站下载更快(并且可能更便宜),或者在没有网络连接的情况下也能使用。
- 在本地保存 Web 应用程序生成的文档以供离线使用
通常,客户端和服务器端存储会一起使用。例如,你可以下载一批音乐文件(可能由 Web 游戏或音乐播放器应用程序使用),将它们存储在客户端数据库中,并根据需要播放。用户只需下载一次音乐文件——在后续访问时,它们将从数据库中检索。
注意:使用客户端存储 API 存储的数据量是有限制的(可能针对每个单独的 API 和累积总量);确切的限制因浏览器而异,也可能根据用户设置而异。有关更多信息,请参阅浏览器存储配额和逐出标准。
老派:Cookies
客户端存储的概念已经存在很长时间了。自 Web 早期以来,网站就一直使用Cookie来存储信息,以个性化用户在网站上的体验。它们是 Web 上常用最早的客户端存储形式。
如今,有更简单的机制可用于存储客户端数据,因此本文中我们不会教你如何使用 Cookie。然而,这并不意味着 Cookie 在现代 Web 上完全无用——它们仍然常用于存储与用户个性化和状态相关的数据,例如会话 ID 和访问令牌。有关 Cookie 的更多信息,请参阅我们的使用 HTTP Cookie文章。
新派:Web Storage 和 IndexedDB
我们上面提到的“更简单”的功能如下:
- Web Storage API 提供了一种机制,用于存储和检索较小的数据项,这些数据项由名称和对应的值组成。当你只需要存储一些简单数据时,例如用户的姓名、他们是否登录、屏幕背景使用的颜色等,这会很有用。
- IndexedDB API 为浏览器提供了一个完整的数据库系统,用于存储复杂数据。这可以用于从完整的客户记录集到音频或视频文件等复杂数据类型。
你将在下面了解有关这些 API 的更多信息。
Cache API
Cache
API 旨在存储特定请求的 HTTP 响应,对于存储网站资产以供离线使用非常有用,这样网站就可以在没有网络连接的情况下继续使用。Cache 通常与Service Worker API结合使用,尽管并非必须如此。
Cache 和 Service Worker 的使用是一个高级主题,我们不会在本文中详细介绍,但我们会在下面的离线资产存储部分展示一个示例。
存储简单数据 — Web Storage
Web Storage API 使用起来非常简单——你存储简单的数据名称/值对(仅限于字符串、数字等),并在需要时检索这些值。
基本语法
我们来演示一下
-
首先,在 GitHub 上打开我们的Web Storage 空模板(在新标签页中打开)。
-
打开浏览器开发者工具的 JavaScript 控制台。
-
所有 Web Storage 数据都包含在浏览器内部的两个类似对象的结构中:
sessionStorage
和localStorage
。前者在浏览器打开期间持久存储数据(浏览器关闭时数据丢失),后者即使在浏览器关闭后再次打开也持久存储数据。本文中我们将使用后者,因为它通常更有用。Storage.setItem()
方法允许你将数据项保存在存储中——它接受两个参数:项目的名称及其值。尝试在 JavaScript 控制台中输入以下内容(如果愿意,可以将值更改为你自己的姓名!):jslocalStorage.setItem("name", "Chris");
-
Storage.getItem()
方法接受一个参数——你要检索的数据项的名称——并返回该项的值。现在在 JavaScript 控制台中输入以下行:jslet myName = localStorage.getItem("name"); myName;
输入第二行后,你应该会看到
myName
变量现在包含name
数据项的值。 -
Storage.removeItem()
方法接受一个参数——你要删除的数据项的名称——并将该项从 Web 存储中删除。在 JavaScript 控制台中输入以下行:jslocalStorage.removeItem("name"); myName = localStorage.getItem("name"); myName;
第三行现在应该返回
null
——name
项不再存在于 Web 存储中。
数据持久性!
Web 存储的一个关键特性是数据在页面重新加载之间(甚至在浏览器关闭后,对于 localStorage
而言)都是持久的。让我们看看实际效果。
-
再次打开我们的 Web 存储空模板,但这次是在你打开本教程的浏览器之外的另一个浏览器中!这会更容易操作。
-
在浏览器的 JavaScript 控制台中输入以下行:
jslocalStorage.setItem("name", "Chris"); let myName = localStorage.getItem("name"); myName;
你应该会看到返回的名称项。
-
现在关闭浏览器并重新打开。
-
再次输入以下行:
jslet myName = localStorage.getItem("name"); myName;
你应该会看到该值仍然可用,即使浏览器已经关闭并重新打开。
每个域的单独存储
每个域(浏览器中加载的每个单独的网址)都有一个单独的数据存储。你会看到,如果你加载两个网站(例如 google.com 和 amazon.com)并尝试在一个网站上存储一个项目,它将无法供另一个网站使用。
这很有道理——你可以想象如果网站可以互相查看数据,会产生什么安全问题!
一个更复杂的例子
让我们应用这些新获得的知识,编写一个可工作的示例,让你了解 Web 存储的用法。我们的示例将允许你输入一个名字,然后页面会更新以显示个性化问候语。由于名字存储在 Web 存储中,此状态也会在页面/浏览器重新加载后持久存在。
你可以在 personal-greeting.html 找到示例 HTML — 它包含一个带有标题、内容和页脚的网站,以及一个用于输入你名字的表单。
我们来构建这个例子,这样你就能理解它是如何工作的。
-
首先,在你的计算机上创建一个新目录,并将我们的 personal-greeting.html 文件复制到本地。
-
接下来,请注意我们的 HTML 是如何通过
<script src="index.js" defer></script>
这样的行引用一个名为index.js
的 JavaScript 文件的。我们需要创建它并编写我们的 JavaScript 代码。在与 HTML 文件相同的目录中创建一个index.js
文件。 -
我们首先创建对本例中需要操作的所有 HTML 功能的引用——我们将它们都创建为常量,因为这些引用在应用程序的生命周期中不需要更改。将以下行添加到你的 JavaScript 文件中:
js// create needed constants const rememberDiv = document.querySelector(".remember"); const forgetDiv = document.querySelector(".forget"); const form = document.querySelector("form"); const nameInput = document.querySelector("#entername"); const submitBtn = document.querySelector("#submitname"); const forgetBtn = document.querySelector("#forgetname"); const h1 = document.querySelector("h1"); const personalGreeting = document.querySelector(".personal-greeting");
-
接下来,我们需要添加一个小的事件监听器,以阻止表单在提交按钮被按下时实际提交,因为这不是我们想要的行为。将此代码片段添加到你之前的代码下方:
js// Stop the form from submitting when a button is pressed form.addEventListener("submit", (e) => e.preventDefault());
-
现在我们需要添加一个事件监听器,它的处理函数将在“打招呼”按钮被点击时运行。注释详细解释了每个部分的具体作用,但本质上,我们正在获取用户在文本输入框中输入的名称,并使用
setItem()
将其保存在 Web 存储中,然后运行一个名为nameDisplayCheck()
的函数,该函数将负责更新实际的网站文本。将此添加到你的代码底部:js// run function when the 'Say hello' button is clicked submitBtn.addEventListener("click", () => { // store the entered name in web storage localStorage.setItem("name", nameInput.value); // run nameDisplayCheck() to sort out displaying the personalized greetings and updating the form display nameDisplayCheck(); });
-
此时,我们还需要一个事件处理程序,用于在“忘记”按钮被点击时运行一个函数——此按钮仅在“打招呼”按钮被点击后显示(这两个表单状态来回切换)。在此函数中,我们使用
removeItem()
从 Web 存储中删除name
项,然后再次运行nameDisplayCheck()
来更新显示。将此添加到底部:js// run function when the 'Forget' button is clicked forgetBtn.addEventListener("click", () => { // Remove the stored name from web storage localStorage.removeItem("name"); // run nameDisplayCheck() to sort out displaying the generic greeting again and updating the form display nameDisplayCheck(); });
-
现在是时候定义
nameDisplayCheck()
函数本身了。在这里,我们通过将localStorage.getItem('name')
作为条件测试来检查名称项是否已存储在 Web 存储中。如果名称已存储,此调用将评估为true
;否则,此调用将评估为false
。如果调用评估为true
,我们显示个性化问候语,显示表单的“忘记”部分,并隐藏表单的“打招呼”部分。如果调用评估为false
,我们显示通用问候语并执行相反的操作。同样,将以下代码放在底部:js// define the nameDisplayCheck() function function nameDisplayCheck() { // check whether the 'name' data item is stored in web Storage if (localStorage.getItem("name")) { // If it is, display personalized greeting const name = localStorage.getItem("name"); h1.textContent = `Welcome, ${name}`; personalGreeting.textContent = `Welcome to our website, ${name}! We hope you have fun while you are here.`; // hide the 'remember' part of the form and show the 'forget' part forgetDiv.style.display = "block"; rememberDiv.style.display = "none"; } else { // if not, display generic greeting h1.textContent = "Welcome to our website "; personalGreeting.textContent = "Welcome to our website. We hope you have fun while you are here."; // hide the 'forget' part of the form and show the 'remember' part forgetDiv.style.display = "none"; rememberDiv.style.display = "block"; } }
-
最后但同样重要的是,我们需要在页面加载时运行
nameDisplayCheck()
函数。如果我们不这样做,那么个性化问候语将不会在页面重新加载时持久化。将以下内容添加到你的代码底部:jsnameDisplayCheck();
你的示例已完成——干得好!现在只剩下保存代码并在浏览器中测试你的 HTML 页面了。你可以在此处查看我们完成的版本正在运行。
注意:在使用 Web Storage API 还有一个稍微复杂一点的例子可以探索。
注意:在我们完成版本的源代码中,<script src="index.js" defer></script>
行中的 defer
属性指定 <script>
元素的内容将不会在页面加载完成之前执行。
存储复杂数据 — IndexedDB
IndexedDB API(有时简称 IDB)是浏览器中一个完整的数据库系统,你可以在其中存储复杂的关联数据,其类型不限于字符串或数字等简单值。你可以在 IndexedDB 实例中存储视频、图像以及几乎任何其他内容。
IndexedDB API 允许你创建一个数据库,然后在该数据库中创建对象存储。对象存储类似于关系数据库中的表,每个对象存储可以包含多个对象。要了解有关 IndexedDB API 的更多信息,请参阅使用 IndexedDB。
然而,这也付出了代价:IndexedDB 的使用比 Web Storage API 复杂得多。在本节中,我们只会浅尝辄止地介绍它的功能,但我们将为你提供足够的入门知识。
通过一个笔记存储示例
在这里,我们将为你介绍一个示例,它允许你在浏览器中存储笔记,并随时查看和删除它们,让你自己构建它,并在此过程中解释 IDB 最基本的部分。
这个应用程序看起来像这样:
每条笔记都有标题和正文,并且都可以单独编辑。我们将在下面介绍的 JavaScript 代码包含详细的注释,以帮助你理解正在发生的事情。
入门
- 首先,将我们的
index.html
、style.css
和index-start.js
文件复制到你本地计算机上的一个新目录中。 - 看一下这些文件。你会看到 HTML 定义了一个带有页眉和页脚的网站,以及一个主内容区域,其中包含一个用于显示笔记的地方,以及一个用于向数据库输入新笔记的表单。CSS 提供了一些样式,使其更清晰。JavaScript 文件包含五个声明的常量,其中包含对将显示笔记的
<ul>
元素、标题和正文<input>
元素、<form>
本身以及<button>
的引用。 - 将你的 JavaScript 文件重命名为
index.js
。现在你可以开始向其中添加代码了。
数据库初始设置
现在让我们看看我们首先需要做什么来实际设置一个数据库。
-
在常量声明下方,添加以下行:
js// Create an instance of a db object for us to store the open database in let db;
在这里,我们声明了一个名为
db
的变量——它稍后将用于存储表示我们数据库的对象。我们将在多个地方使用它,因此我们在此处将其全局声明,以方便操作。 -
接下来,添加以下内容:
js// Open our database; it is created if it doesn't already exist // (see the upgradeneeded handler below) const openRequest = window.indexedDB.open("notes_db", 1);
此行创建了一个请求,用于打开名为
notes_db
的数据库的第1
版。如果此数据库尚不存在,后续代码将为你创建它。你将经常在 IndexedDB 中看到这种请求模式。数据库操作需要时间。你不想在等待结果时挂起浏览器,因此数据库操作是异步的,这意味着它们不会立即发生,而是在将来的某个时间点发生,并在完成后通知你。为了在 IndexedDB 中处理这个问题,你创建一个请求对象(可以命名为你喜欢的任何名称——我们在此处将其命名为
openRequest
,这样它的作用就很明显了)。然后,你使用事件处理程序在请求完成、失败等情况下运行代码,这将在下面看到。注意: 版本号很重要。如果你想升级你的数据库(例如,通过更改表结构),你必须使用增加的版本号,在
upgradeneeded
处理程序中指定不同的模式(见下文),等等,再次运行你的代码。本教程不涉及数据库升级。 -
现在,在你之前的添加代码下方,添加以下事件处理程序:
js// error handler signifies that the database didn't open successfully openRequest.addEventListener("error", () => console.error("Database failed to open"), ); // success handler signifies that the database opened successfully openRequest.addEventListener("success", () => { console.log("Database opened successfully"); // Store the opened database object in the db variable. This is used a lot below db = openRequest.result; // Run the displayData() function to display the notes already in the IDB displayData(); });
如果系统返回请求失败,则会运行
error
事件处理程序。这允许你响应此问题。在我们的示例中,我们只是在 JavaScript 控制台中打印一条消息。如果请求成功返回,则会运行
success
事件处理程序,这意味着数据库已成功打开。如果发生这种情况,一个表示已打开数据库的对象将通过openRequest.result
属性可用,允许我们操作数据库。我们将其存储在我们之前创建的db
变量中以供以后使用。我们还会运行一个名为displayData()
的函数,该函数将数据库中的数据显示在<ul>
中。我们现在运行它,以便在页面加载后立即显示数据库中已有的笔记。你将在后面看到displayData()
的定义。 -
最后,对于本节,我们将添加可能是设置数据库最重要的事件处理程序:
upgradeneeded
。如果数据库尚未设置,或者如果数据库以比现有存储数据库更大的版本号打开(执行升级时),则会运行此处理程序。在你之前的处理程序下方添加以下代码:js// Set up the database tables if this has not already been done openRequest.addEventListener("upgradeneeded", (e) => { // Grab a reference to the opened database db = e.target.result; // Create an objectStore in our database to store notes and an auto-incrementing key // An objectStore is similar to a 'table' in a relational database const objectStore = db.createObjectStore("notes_os", { keyPath: "id", autoIncrement: true, }); // Define what data items the objectStore will contain objectStore.createIndex("title", "title", { unique: false }); objectStore.createIndex("body", "body", { unique: false }); console.log("Database setup complete"); });
这就是我们定义数据库模式(结构)的地方;也就是说,它包含的列(或字段)集。在这里,我们首先从事件目标的
result
属性(e.target.result
)获取对现有数据库的引用,该属性是request
对象。这等同于success
事件处理程序中的db = openRequest.result;
行,但我们在这里需要单独执行此操作,因为upgradeneeded
事件处理程序(如果需要)将在success
事件处理程序之前运行,这意味着如果我们不这样做,db
值将不可用。然后,我们使用
IDBDatabase.createObjectStore()
在我们打开的数据库中创建一个名为notes_os
的新对象存储。这相当于传统数据库系统中的单个表。我们给它命名为 notes,并指定了一个名为id
的autoIncrement
键字段——在每个新记录中,它将自动获得一个递增值——开发人员无需显式设置。作为键,id
字段将用于唯一标识记录,例如在删除或显示记录时。我们还使用
IDBObjectStore.createIndex()
方法创建了另外两个索引(字段):title
(将包含每个笔记的标题)和body
(将包含笔记的正文文本)。
因此,设置好这个数据库模式后,当我们开始向数据库添加记录时,每条记录都将表示为一个对象,其结构大致如下:
{
"title": "Buy milk",
"body": "Need both cows milk and soy.",
"id": 8
}
向数据库添加数据
现在让我们看看如何向数据库添加记录。这将通过我们页面上的表单来完成。
在你之前的事件处理程序下方,添加以下行,它设置了一个 submit
事件处理程序,当表单提交时(当提交<button>
被按下导致表单成功提交时),会运行一个名为 addData()
的函数:
// Create a submit event handler so that when the form is submitted the addData() function is run
form.addEventListener("submit", addData);
现在我们来定义 addData()
函数。在上一行下方添加此代码:
// Define the addData() function
function addData(e) {
// prevent default - we don't want the form to submit in the conventional way
e.preventDefault();
// grab the values entered into the form fields and store them in an object ready for being inserted into the DB
const newItem = { title: titleInput.value, body: bodyInput.value };
// open a read/write db transaction, ready for adding the data
const transaction = db.transaction(["notes_os"], "readwrite");
// call an object store that's already been added to the database
const objectStore = transaction.objectStore("notes_os");
// Make a request to add our newItem object to the object store
const addRequest = objectStore.add(newItem);
addRequest.addEventListener("success", () => {
// Clear the form, ready for adding the next entry
titleInput.value = "";
bodyInput.value = "";
});
// Report on the success of the transaction completing, when everything is done
transaction.addEventListener("complete", () => {
console.log("Transaction completed: database modification finished.");
// update the display of data to show the newly added item, by running displayData() again.
displayData();
});
transaction.addEventListener("error", () =>
console.log("Transaction not opened due to error"),
);
}
这相当复杂;分解来看,我们:
- 在事件对象上运行
Event.preventDefault()
,以阻止表单以传统方式实际提交(这会导致页面刷新并破坏用户体验)。 - 创建一个对象,表示要输入数据库的记录,并使用表单输入的值填充它。请注意,我们不必显式包含
id
值——正如我们之前解释的,它是自动填充的。 - 使用
IDBDatabase.transaction()
方法针对notes_os
对象存储打开一个readwrite
事务。此事务对象允许我们访问对象存储,以便我们可以对其执行操作,例如添加新记录。 - 使用
IDBTransaction.objectStore()
方法访问对象存储,并将结果保存在objectStore
变量中。 - 使用
IDBObjectStore.add()
将新记录添加到数据库。这会创建一个请求对象,方式与我们之前看到的相同。 - 向
request
和transaction
对象添加一堆事件处理程序,以便在生命周期的关键点运行代码。请求成功后,我们清空表单输入以准备输入下一条笔记。事务完成后,我们再次运行displayData()
函数以更新页面上笔记的显示。
显示数据
我们已经在代码中两次引用了 displayData()
,所以我们最好定义它。将其添加到你的代码中,放在之前的函数定义下方:
// Define the displayData() function
function displayData() {
// Here we empty the contents of the list element each time the display is updated
// If you didn't do this, you'd get duplicates listed each time a new note is added
while (list.firstChild) {
list.removeChild(list.firstChild);
}
// Open our object store and then get a cursor - which iterates through all the
// different data items in the store
const objectStore = db.transaction("notes_os").objectStore("notes_os");
objectStore.openCursor().addEventListener("success", (e) => {
// Get a reference to the cursor
const cursor = e.target.result;
// If there is still another data item to iterate through, keep running this code
if (cursor) {
// Create a list item, h3, and p to put each data item inside when displaying it
// structure the HTML fragment, and append it inside the list
const listItem = document.createElement("li");
const h3 = document.createElement("h3");
const para = document.createElement("p");
listItem.appendChild(h3);
listItem.appendChild(para);
list.appendChild(listItem);
// Put the data from the cursor inside the h3 and para
h3.textContent = cursor.value.title;
para.textContent = cursor.value.body;
// Store the ID of the data item inside an attribute on the listItem, so we know
// which item it corresponds to. This will be useful later when we want to delete items
listItem.setAttribute("data-note-id", cursor.value.id);
// Create a button and place it inside each listItem
const deleteBtn = document.createElement("button");
listItem.appendChild(deleteBtn);
deleteBtn.textContent = "Delete";
// Set an event handler so that when the button is clicked, the deleteItem()
// function is run
deleteBtn.addEventListener("click", deleteItem);
// Iterate to the next item in the cursor
cursor.continue();
} else {
// Again, if list item is empty, display a 'No notes stored' message
if (!list.firstChild) {
const listItem = document.createElement("li");
listItem.textContent = "No notes stored.";
list.appendChild(listItem);
}
// if there are no more cursor items to iterate through, say so
console.log("Notes all displayed");
}
});
}
再次,我们来分解一下:
- 首先,我们清空
<ul>
元素的内容,然后再用更新后的内容填充它。如果你不这样做,每次更新都会导致大量重复内容被添加到列表中。 - 接下来,我们使用
IDBDatabase.transaction()
和IDBTransaction.objectStore()
获取对notes_os
对象存储的引用,就像我们在addData()
中所做的那样,只不过这里我们将它们链接在一行中。 - 下一步是使用
IDBObjectStore.openCursor()
方法打开一个游标请求——这是一个可用于遍历对象存储中记录的构造。我们在此行末尾链上一个success
事件处理程序,以使代码更简洁——当游标成功返回时,处理程序就会运行。 - 我们使用
const cursor = e.target.result
获取对游标本身(一个IDBCursor
对象)的引用。 - 接下来,我们检查游标是否包含数据存储中的记录(
if (cursor){ }
)——如果是,我们创建一个 DOM 片段,用记录中的数据填充它,并将其插入页面(在<ul>
元素内)。我们还包含一个删除按钮,单击该按钮时,将通过运行deleteItem()
函数删除该笔记,我们将在下一节中查看该函数。 - 在
if
块的末尾,我们使用IDBCursor.continue()
方法将游标推进到数据存储中的下一条记录,并再次运行if
块的内容。如果还有下一条记录可以迭代,这将导致它被插入页面,然后再次运行continue()
,依此类推。 - 当没有更多记录可迭代时,
cursor
将返回undefined
,因此将运行else
块而不是if
块。此块检查是否有任何笔记插入到<ul>
中——如果没有,它会插入一条消息,表示没有存储笔记。
删除笔记
如上所述,当按下笔记的删除按钮时,笔记将被删除。这通过 deleteItem()
函数实现,该函数如下所示:
// Define the deleteItem() function
function deleteItem(e) {
// retrieve the name of the task we want to delete. We need
// to convert it to a number before trying to use it with IDB; IDB key
// values are type-sensitive.
const noteId = Number(e.target.parentNode.getAttribute("data-note-id"));
// open a database transaction and delete the task, finding it using the id we retrieved above
const transaction = db.transaction(["notes_os"], "readwrite");
const objectStore = transaction.objectStore("notes_os");
const deleteRequest = objectStore.delete(noteId);
// report that the data item has been deleted
transaction.addEventListener("complete", () => {
// delete the parent of the button
// which is the list item, so it is no longer displayed
e.target.parentNode.parentNode.removeChild(e.target.parentNode);
console.log(`Note ${noteId} deleted.`);
// Again, if list item is empty, display a 'No notes stored' message
if (!list.firstChild) {
const listItem = document.createElement("li");
listItem.textContent = "No notes stored.";
list.appendChild(listItem);
}
});
}
- 这第一部分需要一些解释——我们使用
Number(e.target.parentNode.getAttribute('data-note-id'))
检索要删除的记录的 ID——回想一下,记录的 ID 在首次显示时保存在<li>
上的data-note-id
属性中。但是,我们需要通过全局内置的Number()
对象传递属性,因为它是字符串数据类型,因此数据库无法识别它,数据库期望的是一个数字。 - 然后我们使用之前看到的相同模式获取对对象存储的引用,并使用
IDBObjectStore.delete()
方法从数据库中删除记录,并传入其 ID。 - 当数据库事务完成时,我们从 DOM 中删除笔记的
<li>
,并再次检查<ul>
是否为空,并酌情插入一条笔记。
就是这样了!你的示例现在应该可以工作了。
通过 IndexedDB 存储复杂数据
如上所述,IndexedDB 不仅仅可以存储文本字符串。你可以存储几乎任何你想要的内容,包括视频或图像 blob 等复杂对象。实现起来也并不比其他类型的数据困难多少。
为了演示如何做到这一点,我们编写了另一个示例,名为IndexedDB 视频存储(此处也可查看其在线运行)。当你首次运行该示例时,它会从网络下载所有视频,将它们存储在 IndexedDB 数据库中,然后将视频显示在 UI 的<video>
元素中。当你第二次运行它时,它会从数据库中查找视频并从中获取它们,然后再显示——这使得后续加载更快,带宽消耗更少。
我们来看看这个例子中最有趣的部分。我们不会全部看一遍——很多地方都与上一个例子相似,而且代码注释也很详细。
-
对于这个例子,我们将要获取的视频名称存储在一个对象数组中:
jsconst videos = [ { name: "crystal" }, { name: "elf" }, { name: "frog" }, { name: "monster" }, { name: "pig" }, { name: "rabbit" }, ];
-
首先,数据库成功打开后,我们运行一个
init()
函数。该函数遍历不同的视频名称,尝试从videos
数据库加载由每个名称标识的记录。如果每个视频都在数据库中找到(通过检查
request.result
是否评估为true
来判断——如果记录不存在,它将为undefined
),其视频文件(以 blob 形式存储)和视频名称将直接传递给displayVideo()
函数以将其放置在 UI 中。如果未找到,视频名称将传递给fetchVideoFromNetwork()
函数,你猜对了,就是从网络中获取视频。jsfunction init() { // Loop through the video names one by one for (const video of videos) { // Open transaction, get object store, and get() each video by name const objectStore = db.transaction("videos_os").objectStore("videos_os"); const request = objectStore.get(video.name); request.addEventListener("success", () => { // If the result exists in the database (is not undefined) if (request.result) { // Grab the videos from IDB and display them using displayVideo() console.log("taking videos from IDB"); displayVideo( request.result.mp4, request.result.webm, request.result.name, ); } else { // Fetch the videos from the network fetchVideoFromNetwork(video); } }); } }
-
以下代码片段取自
fetchVideoFromNetwork()
内部——在这里,我们使用两个独立的fetch()
请求获取视频的 MP4 和 WebM 版本。然后,我们使用Response.blob()
方法将每个响应的主体提取为 blob,从而获得视频的对象表示,以便稍后存储和显示。然而,这里我们有一个问题——这两个请求都是异步的,但我们只希望在两个 Promise 都兑现后才尝试显示或存储视频。幸运的是,有一个内置方法可以处理此类问题——
Promise.all()
。它接受一个参数——对你想要检查兑现的所有单个 Promise 的引用,这些引用放在一个数组中——并返回一个 Promise,该 Promise 在所有单个 Promise 都兑现时兑现。在此 Promise 的
then()
处理程序内部,我们像以前一样调用displayVideo()
函数以在 UI 中显示视频,然后我们还调用storeVideo()
函数以将这些视频存储在数据库中。js// Fetch the MP4 and WebM versions of the video using the fetch() function, // then expose their response bodies as blobs const mp4Blob = fetch(`videos/${video.name}.mp4`).then((response) => response.blob(), ); const webmBlob = fetch(`videos/${video.name}.webm`).then((response) => response.blob(), ); // Only run the next code when both promises have fulfilled Promise.all([mp4Blob, webmBlob]).then((values) => { // display the video fetched from the network with displayVideo() displayVideo(values[0], values[1], video.name); // store it in the IDB using storeVideo() storeVideo(values[0], values[1], video.name); });
-
我们首先来看
storeVideo()
。这与你在上一个示例中看到的向数据库添加数据的模式非常相似——我们打开一个readwrite
事务并获取对我们videos_os
对象存储的引用,创建一个表示要添加到数据库的记录的对象,然后使用IDBObjectStore.add()
添加它。js// Define the storeVideo() function function storeVideo(mp4, webm, name) { // Open transaction, get object store; make it a readwrite so we can write to the IDB const objectStore = db .transaction(["videos_os"], "readwrite") .objectStore("videos_os"); // Add the record to the IDB using add() const request = objectStore.add({ mp4, webm, name }); request.addEventListener("success", () => console.log("Record addition attempt finished"), ); request.addEventListener("error", () => console.error(request.error)); }
-
最后,我们有
displayVideo()
,它创建在 UI 中插入视频所需的 DOM 元素,然后将它们附加到页面。其中最有趣的部分是下面显示的部分——要实际在<video>
元素中显示我们的视频 blob,我们需要使用URL.createObjectURL()
方法创建对象 URL(指向存储在内存中的视频 blob 的内部 URL)。完成后,我们可以将对象 URL 设置为我们<source>
元素的src
属性的值,它就可以正常工作了。js// Define the displayVideo() function function displayVideo(mp4Blob, webmBlob, title) { // Create object URLs out of the blobs const mp4URL = URL.createObjectURL(mp4Blob); const webmURL = URL.createObjectURL(webmBlob); // Create DOM elements to embed video in the page const article = document.createElement("article"); const h2 = document.createElement("h2"); h2.textContent = title; const video = document.createElement("video"); video.controls = true; const source1 = document.createElement("source"); source1.src = mp4URL; source1.type = "video/mp4"; const source2 = document.createElement("source"); source2.src = webmURL; source2.type = "video/webm"; // Embed DOM elements into page section.appendChild(article); article.appendChild(h2); article.appendChild(video); video.appendChild(source1); video.appendChild(source2); }
离线资产存储
上述示例已经展示了如何创建一个应用程序,它将大型资产存储在 IndexedDB 数据库中,从而避免了多次下载的需求。这已经极大地改善了用户体验,但仍然缺少一件事——每次访问网站时,主 HTML、CSS 和 JavaScript 文件仍然需要下载,这意味着在没有网络连接时它将无法工作。
这就是Service Worker和密切相关的Cache API发挥作用的地方。
Service Worker 是一个 JavaScript 文件,当浏览器访问特定源(网站,或某个域下网站的一部分)时,它会针对该源进行注册。注册后,它可以控制该源下的页面。它通过位于加载页面和网络之间,并拦截针对该源的网络请求来实现这一点。
当它拦截到一个请求时,它可以执行任何你希望的操作(参见用例设想),但经典的例子是离线保存网络响应,然后提供这些响应来代替来自网络的响应。实际上,它允许你使网站完全离线工作。
Cache API 是另一种客户端存储机制,但有所不同——它旨在保存 HTTP 响应,因此与 Service Worker 配合得非常好。
Service Worker 示例
让我们看一个例子,让你对这可能是什么样子有个大致了解。我们创建了上一节中视频存储示例的另一个版本——它的功能完全相同,但它也通过 Service Worker 将 HTML、CSS 和 JavaScript 保存到 Cache API 中,从而允许该示例离线运行!
查看带有 Service Worker 的 IndexedDB 视频存储在线运行,以及源代码。
注册 Service Worker
首先要注意的是,主 JavaScript 文件中有一段额外的代码(参见 index.js)。首先,我们进行特性检测,以查看 Navigator
对象中是否提供了 serviceWorker
成员。如果返回 true,则我们知道至少支持 Service Worker 的基本功能。在这里,我们使用 ServiceWorkerContainer.register()
方法注册一个包含在 sw.js
文件中的 Service Worker,使其针对其所在源进行注册,这样它就可以控制与它位于同一目录或子目录中的页面。当其 Promise fulfilled 时,Service Worker 被视为已注册。
// Register service worker to control making site work offline
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register(
"/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/sw.js",
)
.then(() => console.log("Service Worker Registered"));
}
注意: sw.js
文件的给定路径是相对于网站源的,而不是包含代码的 JavaScript 文件。Service Worker 位于 https://mdn.github.io/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/sw.js
。源是 https://mdn.github.io
,因此给定的路径必须是 /learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/sw.js
。如果你想将此示例托管在你自己的服务器上,则必须相应地更改此路径。这相当令人困惑,但出于安全原因必须这样做。
安装 Service Worker
下一次访问 Service Worker 控制的任何页面时(例如,当示例重新加载时),Service Worker 将针对该页面安装,这意味着它将开始控制该页面。当这种情况发生时,将向 Service Worker 触发一个 install
事件;你可以在 Service Worker 内部编写代码以响应安装。
我们来看一个示例,在 sw.js 文件中(Service Worker)。你会看到安装监听器是针对 self
注册的。这个 self
关键字是在 Service Worker 文件内部引用 Service Worker 全局范围的一种方式。
在 install
处理程序内部,我们使用事件对象上可用的 ExtendableEvent.waitUntil()
方法,以表示浏览器不应在其中包含的 Promise 成功实现之前完成 Service Worker 的安装。
这就是我们看到 Cache API 实际运行的地方。我们使用 CacheStorage.open()
方法打开一个新的缓存对象,用于存储响应(类似于 IndexedDB 对象存储)。此 Promise 以一个表示 video-store
缓存的 Cache
对象实现。然后,我们使用 Cache.addAll()
方法获取一系列资产并将其响应添加到缓存中。
self.addEventListener("install", (e) => {
e.waitUntil(
caches
.open("video-store")
.then((cache) =>
cache.addAll([
"/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/",
"/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/index.html",
"/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/index.js",
"/learning-area/javascript/apis/client-side-storage/cache-sw/video-store-offline/style.css",
]),
),
);
});
目前就这些,安装完成。
响应后续请求
Service Worker 已在我们的 HTML 页面上注册并安装,并且相关资产已全部添加到我们的缓存中,我们几乎准备就绪。只剩下一件事要做:编写一些代码来响应后续的网络请求。
这就是 sw.js
中第二部分代码所做的事情。我们向 Service Worker 全局范围添加了另一个监听器,当 fetch
事件触发时,它会运行处理程序函数。当浏览器请求 Service Worker 注册目录中的资产时,就会发生这种情况。
在处理程序内部,我们首先记录请求资产的 URL。然后,我们使用 FetchEvent.respondWith()
方法为请求提供自定义响应。
在此块内部,我们使用 CacheStorage.match()
检查在任何缓存中是否可以找到匹配的请求(即,匹配 URL)。如果找到匹配项,此 Promise 将以匹配的响应实现;如果未找到,则为 undefined
。
如果找到匹配项,我们将其作为自定义响应返回。如果找不到,我们从网络中 获取() 响应并返回它。
self.addEventListener("fetch", (e) => {
console.log(e.request.url);
e.respondWith(
caches.match(e.request).then((response) => response || fetch(e.request)),
);
});
这就是我们的 Service Worker 的全部内容。你可以用它们做更多的事情——有关更多详细信息,请参阅Service Worker 食谱。非常感谢 Paul Kinlan 的文章将 Service Worker 和离线功能添加到你的 Web 应用,它启发了这个示例。
离线测试示例
要测试我们的Service Worker 示例,你需要加载它几次以确保它已安装。完成此操作后,你可以:
- 尝试拔掉网线/关闭 Wi-Fi。
- 如果你使用的是 Firefox,请选择 文件 > 离线工作。
- 如果你使用的是 Chrome,请转到开发者工具,然后选择 应用程序 > Service Worker,然后勾选 离线 复选框。
如果你再次刷新你的示例页面,你应该仍然看到它加载得很好。所有内容都离线存储——页面资产在缓存中,视频在 IndexedDB 数据库中。
总结
目前就这些了。我们希望你觉得我们对客户端存储技术的概述很有用。