客户端存储

现代 Web 浏览器支持网站通过多种方式在用户的计算机上存储数据(经用户许可),并在必要时检索这些数据。这让你可以持久存储数据,保存网站或文档以供离线使用,保留网站的用户特定设置等等。本文解释了这些工作原理的基础知识。

预备知识 熟悉 HTMLCSSJavaScript,尤其是 JavaScript 对象基础知识以及 DOM 脚本网络请求等核心 API 知识。
学习成果
  • 客户端存储的概念,以及实现它的关键技术:Web Storage API、cookies、Cache API 和 IndexedDB API。
  • 主要用例——在重新加载时保持状态,持久化登录和用户个性化数据,以及本地/离线工作。
  • 使用 Web Storage 进行简单的键值对存储,由 JavaScript 控制。
  • 使用 IndexedDB 存储更复杂、结构化的数据。
  • 使用 Cache API 和 Service Worker 实现离线用例。

客户端存储?

在 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 使用起来非常简单——你存储简单的数据名称/值对(仅限于字符串、数字等),并在需要时检索这些值。

基本语法

我们来演示一下

  1. 首先,在 GitHub 上打开我们的Web Storage 空模板(在新标签页中打开)。

  2. 打开浏览器开发者工具的 JavaScript 控制台。

  3. 所有 Web Storage 数据都包含在浏览器内部的两个类似对象的结构中:sessionStoragelocalStorage。前者在浏览器打开期间持久存储数据(浏览器关闭时数据丢失),后者即使在浏览器关闭后再次打开也持久存储数据。本文中我们将使用后者,因为它通常更有用。

    Storage.setItem() 方法允许你将数据项保存在存储中——它接受两个参数:项目的名称及其值。尝试在 JavaScript 控制台中输入以下内容(如果愿意,可以将值更改为你自己的姓名!):

    js
    localStorage.setItem("name", "Chris");
    
  4. Storage.getItem() 方法接受一个参数——你要检索的数据项的名称——并返回该项的值。现在在 JavaScript 控制台中输入以下行:

    js
    let myName = localStorage.getItem("name");
    myName;
    

    输入第二行后,你应该会看到 myName 变量现在包含 name 数据项的值。

  5. Storage.removeItem() 方法接受一个参数——你要删除的数据项的名称——并将该项从 Web 存储中删除。在 JavaScript 控制台中输入以下行:

    js
    localStorage.removeItem("name");
    myName = localStorage.getItem("name");
    myName;
    

    第三行现在应该返回 null——name 项不再存在于 Web 存储中。

数据持久性!

Web 存储的一个关键特性是数据在页面重新加载之间(甚至在浏览器关闭后,对于 localStorage 而言)都是持久的。让我们看看实际效果。

  1. 再次打开我们的 Web 存储空模板,但这次是在你打开本教程的浏览器之外的另一个浏览器中!这会更容易操作。

  2. 在浏览器的 JavaScript 控制台中输入以下行:

    js
    localStorage.setItem("name", "Chris");
    let myName = localStorage.getItem("name");
    myName;
    

    你应该会看到返回的名称项。

  3. 现在关闭浏览器并重新打开。

  4. 再次输入以下行:

    js
    let myName = localStorage.getItem("name");
    myName;
    

    你应该会看到该值仍然可用,即使浏览器已经关闭并重新打开。

每个域的单独存储

每个域(浏览器中加载的每个单独的网址)都有一个单独的数据存储。你会看到,如果你加载两个网站(例如 google.com 和 amazon.com)并尝试在一个网站上存储一个项目,它将无法供另一个网站使用。

这很有道理——你可以想象如果网站可以互相查看数据,会产生什么安全问题!

一个更复杂的例子

让我们应用这些新获得的知识,编写一个可工作的示例,让你了解 Web 存储的用法。我们的示例将允许你输入一个名字,然后页面会更新以显示个性化问候语。由于名字存储在 Web 存储中,此状态也会在页面/浏览器重新加载后持久存在。

你可以在 personal-greeting.html 找到示例 HTML — 它包含一个带有标题、内容和页脚的网站,以及一个用于输入你名字的表单。

A Screenshot of a website that has a header, content and footer sections. The header has a welcome text to the left-hand side and a button labelled 'forget' to the right-hand side. The content has an heading followed by a two paragraphs of dummy text. The footer reads 'Copyright nobody. Use the code as you like'.

我们来构建这个例子,这样你就能理解它是如何工作的。

  1. 首先,在你的计算机上创建一个新目录,并将我们的 personal-greeting.html 文件复制到本地。

  2. 接下来,请注意我们的 HTML 是如何通过 <script src="index.js" defer></script> 这样的行引用一个名为 index.js 的 JavaScript 文件的。我们需要创建它并编写我们的 JavaScript 代码。在与 HTML 文件相同的目录中创建一个 index.js 文件。

  3. 我们首先创建对本例中需要操作的所有 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");
    
  4. 接下来,我们需要添加一个小的事件监听器,以阻止表单在提交按钮被按下时实际提交,因为这不是我们想要的行为。将此代码片段添加到你之前的代码下方:

    js
    // Stop the form from submitting when a button is pressed
    form.addEventListener("submit", (e) => e.preventDefault());
    
  5. 现在我们需要添加一个事件监听器,它的处理函数将在“打招呼”按钮被点击时运行。注释详细解释了每个部分的具体作用,但本质上,我们正在获取用户在文本输入框中输入的名称,并使用 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();
    });
    
  6. 此时,我们还需要一个事件处理程序,用于在“忘记”按钮被点击时运行一个函数——此按钮仅在“打招呼”按钮被点击后显示(这两个表单状态来回切换)。在此函数中,我们使用 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();
    });
    
  7. 现在是时候定义 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";
      }
    }
    
  8. 最后但同样重要的是,我们需要在页面加载时运行 nameDisplayCheck() 函数。如果我们不这样做,那么个性化问候语将不会在页面重新加载时持久化。将以下内容添加到你的代码底部:

    js
    nameDisplayCheck();
    

你的示例已完成——干得好!现在只剩下保存代码并在浏览器中测试你的 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 最基本的部分。

这个应用程序看起来像这样:

IndexDB notes demo screenshot with 4 sections. The first section is the header. The second section lists all the notes that have been created. It has two notes, each with a delete button. A third section is a form with 2 input fields for 'Note title' and 'Note text' and a button labeled 'Create new note'. The bottom section footer reads 'Copyright nobody. Use the code as you like'.

每条笔记都有标题和正文,并且都可以单独编辑。我们将在下面介绍的 JavaScript 代码包含详细的注释,以帮助你理解正在发生的事情。

入门

  1. 首先,将我们的 index.htmlstyle.cssindex-start.js 文件复制到你本地计算机上的一个新目录中。
  2. 看一下这些文件。你会看到 HTML 定义了一个带有页眉和页脚的网站,以及一个主内容区域,其中包含一个用于显示笔记的地方,以及一个用于向数据库输入新笔记的表单。CSS 提供了一些样式,使其更清晰。JavaScript 文件包含五个声明的常量,其中包含对将显示笔记的 <ul> 元素、标题和正文 <input> 元素、<form> 本身以及 <button> 的引用。
  3. 将你的 JavaScript 文件重命名为 index.js。现在你可以开始向其中添加代码了。

数据库初始设置

现在让我们看看我们首先需要做什么来实际设置一个数据库。

  1. 在常量声明下方,添加以下行:

    js
    // Create an instance of a db object for us to store the open database in
    let db;
    

    在这里,我们声明了一个名为 db 的变量——它稍后将用于存储表示我们数据库的对象。我们将在多个地方使用它,因此我们在此处将其全局声明,以方便操作。

  2. 接下来,添加以下内容:

    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 处理程序中指定不同的模式(见下文),等等,再次运行你的代码。本教程不涉及数据库升级。

  3. 现在,在你之前的添加代码下方,添加以下事件处理程序:

    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() 的定义。

  4. 最后,对于本节,我们将添加可能是设置数据库最重要的事件处理程序: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,并指定了一个名为 idautoIncrement 键字段——在每个新记录中,它将自动获得一个递增值——开发人员无需显式设置。作为键,id 字段将用于唯一标识记录,例如在删除或显示记录时。

    我们还使用 IDBObjectStore.createIndex() 方法创建了另外两个索引(字段):title(将包含每个笔记的标题)和 body(将包含笔记的正文文本)。

因此,设置好这个数据库模式后,当我们开始向数据库添加记录时,每条记录都将表示为一个对象,其结构大致如下:

json
{
  "title": "Buy milk",
  "body": "Need both cows milk and soy.",
  "id": 8
}

向数据库添加数据

现在让我们看看如何向数据库添加记录。这将通过我们页面上的表单来完成。

在你之前的事件处理程序下方,添加以下行,它设置了一个 submit 事件处理程序,当表单提交时(当提交<button>被按下导致表单成功提交时),会运行一个名为 addData() 的函数:

js
// Create a submit event handler so that when the form is submitted the addData() function is run
form.addEventListener("submit", addData);

现在我们来定义 addData() 函数。在上一行下方添加此代码:

js
// 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() 将新记录添加到数据库。这会创建一个请求对象,方式与我们之前看到的相同。
  • requesttransaction 对象添加一堆事件处理程序,以便在生命周期的关键点运行代码。请求成功后,我们清空表单输入以准备输入下一条笔记。事务完成后,我们再次运行 displayData() 函数以更新页面上笔记的显示。

显示数据

我们已经在代码中两次引用了 displayData(),所以我们最好定义它。将其添加到你的代码中,放在之前的函数定义下方:

js
// 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() 函数实现,该函数如下所示:

js
// 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>元素中。当你第二次运行它时,它会从数据库中查找视频并从中获取它们,然后再显示——这使得后续加载更快,带宽消耗更少。

我们来看看这个例子中最有趣的部分。我们不会全部看一遍——很多地方都与上一个例子相似,而且代码注释也很详细。

  1. 对于这个例子,我们将要获取的视频名称存储在一个对象数组中:

    js
    const videos = [
      { name: "crystal" },
      { name: "elf" },
      { name: "frog" },
      { name: "monster" },
      { name: "pig" },
      { name: "rabbit" },
    ];
    
  2. 首先,数据库成功打开后,我们运行一个 init() 函数。该函数遍历不同的视频名称,尝试从 videos 数据库加载由每个名称标识的记录。

    如果每个视频都在数据库中找到(通过检查 request.result 是否评估为 true 来判断——如果记录不存在,它将为 undefined),其视频文件(以 blob 形式存储)和视频名称将直接传递给 displayVideo() 函数以将其放置在 UI 中。如果未找到,视频名称将传递给 fetchVideoFromNetwork() 函数,你猜对了,就是从网络中获取视频。

    js
    function 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);
          }
        });
      }
    }
    
  3. 以下代码片段取自 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);
    });
    
  4. 我们首先来看 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));
    }
    
  5. 最后,我们有 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 文件仍然需要下载,这意味着在没有网络连接时它将无法工作。

Firefox offline screen with an illustration of a cartoon character to the left-hand side holding a two-pin plug in its right hand and a two-pin socket in its left hand. On the right-hand side there is an Offline Mode message and a button labeled 'Try again'.

这就是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 被视为已注册。

js
// 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() 方法获取一系列资产并将其响应添加到缓存中。

js
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

如果找到匹配项,我们将其作为自定义响应返回。如果找不到,我们从网络中 获取() 响应并返回它。

js
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 数据库中。

总结

目前就这些了。我们希望你觉得我们对客户端存储技术的概述很有用。

另见