客户端存储

现代 Web 浏览器支持多种方式,让网站在用户计算机上存储数据 — 经过用户许可 — 然后在需要时检索数据。这使您能够持久化数据以进行长期存储、保存网站或文档以供离线使用、保留用户对您网站的特定设置等等。本文介绍了这些工作原理的基础知识。

先决条件 JavaScript 基础知识(参见 入门构建块JavaScript 对象),客户端 API 基础知识
目标 学习如何使用客户端存储 API 存储应用程序数据。

客户端存储?

在 MDN 学习区域的其他地方,我们讨论了 静态网站动态网站 之间的区别。大多数主要的现代网站都是动态的 - 它们使用某种数据库(服务器端存储)在服务器上存储数据,然后运行 服务器端 代码来检索所需数据,将其插入静态页面模板,并将生成的 HTML 提供给客户端以供用户的浏览器显示。

客户端存储的工作原理类似,但用途不同。它包含 JavaScript API,允许您将数据存储在客户端(即用户机器上),然后在需要时检索它。这有许多不同的用途,例如

  • 个性化网站偏好(例如,显示用户选择的自定义小部件、配色方案或字体大小)。
  • 持久化以前的网站活动(例如,存储来自先前会话的购物车内容,记住用户是否以前登录过)。
  • 在本地保存数据和资产,以便网站下载速度更快(并且可能成本更低),或者可以在没有网络连接的情况下使用。
  • 在本地保存 Web 应用程序生成的文档以供离线使用

通常客户端存储和服务器端存储一起使用。例如,您可以下载一批音乐文件(可能用于 Web 游戏或音乐播放器应用程序),将它们存储在客户端数据库中,并在需要时播放它们。用户只需下载一次音乐文件 - 在后续访问中,它们将从数据库中检索。

注意:使用客户端存储 API 可以存储的数据量有限(可能是每个 API 以及累积的);确切限制因浏览器而异,并且可能基于用户设置。有关更多信息,请参见 浏览器存储配额和驱逐标准

老方法:Cookie

客户端存储的概念已经存在很长时间了。自网络的早期阶段以来,网站一直使用 Cookie 来存储信息以个性化用户在网站上的体验。它们是 Web 上最常使用的客户端存储的早期形式。

如今,有更简单的机制可用于存储客户端数据,因此我们不会在本文中教您如何使用 Cookie。但是,这并不意味着 Cookie 在现代网络中完全没有用 - 它们仍然经常用于存储与用户个性化和状态相关的数据,例如会话 ID 和访问令牌。有关 Cookie 的更多信息,请参见我们的 使用 HTTP Cookie 文章。

新方法:Web 存储和 IndexedDB

我们上面提到的“更容易”的功能如下

  • Web 存储 API 提供了一种机制来存储和检索较小的数据项,这些数据项由名称和相应的价值组成。这在您只需要存储一些简单数据时很有用,例如用户名、是否登录、屏幕背景要使用什么颜色等。
  • IndexedDB API 为浏览器提供了一个完整的数据库系统,用于存储复杂数据。这可以用于从完整的客户记录集到甚至音频或视频文件等复杂数据类型等各种事物。

您将在下面详细了解这些 API。

缓存 API

Cache API 旨在存储对特定请求的 HTTP 响应,并且非常适合执行诸如在脱机状态下存储网站资产之类的操作,以便该网站可以在没有网络连接的情况下使用。缓存通常与 Service Worker API 结合使用,尽管它不必如此。

缓存和 Service Worker 的使用是一个高级主题,我们不会在本文中详细介绍,但我们将在下面的 离线资产存储 部分中展示一个示例。

存储简单数据 — Web 存储

Web 存储 API 非常易于使用 - 您存储简单的数据名称/值对(仅限于字符串、数字等),并在需要时检索这些值。

基本语法

让我们向您展示如何操作

  1. 首先,访问我们位于 GitHub 上的 Web 存储空白模板(在新标签页中打开)。
  2. 打开浏览器开发者工具的 JavaScript 控制台。
  3. 您所有 Web 存储数据都包含在浏览器内的两个类似对象的结构中: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;
    
    您应该看到 name 项目返回。
  3. 现在关闭浏览器,然后再次打开它。
  4. 再次输入以下行
    js
    let myName = localStorage.getItem("name");
    myName;
    
    您应该看到该值仍然可用,即使浏览器已经关闭并重新打开。

每个域的独立存储

每个域(浏览器中加载的每个单独的 Web 地址)都有一个单独的数据存储。您会发现,如果您加载两个网站(例如 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 如何引用一个名为 index.js 的 JavaScript 文件,并使用类似 <script src="index.js" defer></script> 的行。我们需要创建它并将我们的 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. 现在我们需要添加一个事件监听器,它的处理程序函数将在单击“说你好”按钮时运行。注释详细说明了每个部分的作用,但实质上,我们在这里将用户输入文本输入框中的名称保存到 Web 存储中,使用 setItem(),然后运行一个名为 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() 函数本身了。在这里,我们检查 name 项目是否已存储在 Web 存储中,方法是将 localStorage.getItem('name') 用作条件测试。如果已存储名称,此调用将评估为 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 存储 API 中还有一个稍微更复杂的示例可供探索。

注意:在我们已完成版本源代码中的 <script src="index.js" defer></script> 行中,defer 属性指定 <script> 元素的内容将不会在页面加载完成后执行。

存储复杂数据 — IndexedDB

IndexedDB API(有时缩写为 IDB)是浏览器中可用的完整数据库系统,您可以在其中存储复杂的相关数据,其类型不受字符串或数字等简单值的限制。您可以在 IndexedDB 实例中存储视频、图像,以及几乎所有其他内容。

IndexedDB API 允许您创建一个数据库,然后在该数据库中创建对象存储。对象存储类似于关系数据库中的表,每个对象存储可以包含多个对象。要详细了解 IndexedDB API,请参见 使用 IndexedDB

然而,这也有代价:IndexedDB 比 Web 存储 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");
    });
    
    这是我们定义数据库模式(结构)的地方;也就是说,它包含的列(或字段)集。在这里,我们首先从事件目标(e.target.result)的 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'.

这就是服务工作者和密切相关的缓存 API的用武之地。

服务工作者是一个 JavaScript 文件,它在浏览器访问某个特定来源(网站或特定域名的网站的一部分)时,会针对该来源进行注册。注册后,它可以控制该来源的可用页面。它是通过坐在加载的页面和网络之间,拦截针对该来源的网络请求来实现的。

当它拦截一个请求时,它可以对请求做任何你希望它做的事情(参见用例想法),但最典型的例子是将网络响应保存到离线状态,然后在响应请求时提供这些响应,而不是提供来自网络的响应。实际上,它允许你使网站完全离线工作。

缓存 API 是另一种客户端存储机制,但也有一些不同——它旨在保存 HTTP 响应,因此与服务工作者配合得很好。

服务工作者示例

让我们看一个例子,让你对它可能是什么样子有更深入的了解。我们创建了前面部分中看到的视频存储示例的另一个版本——它在功能上是相同的,只是它还使用服务工作者将 HTML、CSS 和 JavaScript 保存到缓存 API 中,从而使示例可以在离线状态下运行!

查看带有服务工作者的 IndexedDB 视频存储实时运行,以及查看源代码

注册服务工作者

首先需要注意的是,在主 JavaScript 文件中添加了一些额外的代码(查看index.js)。首先,我们进行一项功能检测测试,以查看serviceWorker成员是否在Navigator对象中可用。如果返回 true,那么我们就知道至少服务工作者的基本功能是受支持的。在这里,我们使用ServiceWorkerContainer.register()方法来注册位于sw.js文件中的服务工作者,使其可以控制与它位于同一目录或子目录中的页面。当它的 promise 完成时,服务工作者被认为已注册。

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 文件。服务工作者位于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。如果你想将这个例子放在自己的服务器上,你需要相应地改变它。这有点令人困惑,但出于安全原因,它必须这样工作。

安装服务工作者

下次访问服务工作者控制下的任何页面(例如,重新加载示例时),服务工作者就会针对该页面进行安装,这意味着它将开始控制该页面。当这种情况发生时,一个install事件会针对服务工作者触发;你可以在服务工作者本身内部编写代码来响应安装。

让我们看一个例子,在sw.js文件(服务工作者)中。你会看到,安装监听器是在self上注册的。这个self关键字是用来从服务工作者文件内部引用服务工作者的全局范围的。

install处理程序内部,我们使用ExtendableEvent.waitUntil()方法,该方法在事件对象上可用,用来表示浏览器在内部 promise 成功完成之前不应完成服务工作者的安装。

这里我们看到了缓存 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",
        ]),
      ),
  );
});

现在就这些了,安装完成。

响应后续请求

在服务工作者针对我们的 HTML 页面注册和安装之后,以及所有相关的资产都添加到我们的缓存中后,我们几乎准备好了。还有一件事要做:编写一些代码来响应后续的网络请求。

这就是sw.js中的第二部分代码所做的。我们在服务工作者的全局范围中添加了另一个监听器,当fetch事件触发时,它会运行处理程序函数。每当浏览器请求服务工作者注册目录中的资产时,就会发生这种情况。

在处理程序内部,我们首先记录请求的资产的 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)),
  );
});

这就是我们的服务工作者的全部内容。你还可以用它做很多其他的事情——更多细节请查看服务工作者手册。非常感谢 Paul Kinlan 的文章将服务工作者和离线功能添加到你的 Web 应用程序,它启发了这个例子。

离线测试示例

要测试我们的服务工作者示例,你需要加载它几次,以确保它已安装。安装完成后,你可以:

  • 尝试拔掉网络/关闭你的 Wi-Fi。
  • 如果你使用的是 Firefox,请选择文件 > 离线工作
  • 如果你使用的是 Chrome,请转到开发者工具,然后选择应用程序 > 服务工作者,然后勾选离线复选框。

如果你再次刷新示例页面,你应该仍然看到它正常加载。所有内容都存储在离线状态——页面资产存储在缓存中,视频存储在 IndexedDB 数据库中。

总结

现在就这些了。我们希望你发现我们对客户端存储技术的概述很有用。

另请参见