Skip to content

浏览器存储与待办事项 ​

前面的学员列表使用 JavaScript 数组保存数据。页面刷新后,数组会重新创建,用户刚才的操作也会消失。

搜索条件、主题设置和个人待办等非敏感页面状态,可以先保存在浏览器中。本节使用 localStorage 保存“检查报名资料”“确认补交作业”等学员跟进待办,完成一个刷新后仍能保留数据的页面。

本节课解决五个问题:

  1. localStorage 和 sessionStorage 有什么区别。
  2. 怎样保存、读取和删除字符串数据。
  3. 怎样使用 JSON 保存对象和数组。
  4. 怎样处理空数据和损坏的 JSON。
  5. 怎样让待办事项的新增、完成和删除操作持久化。

本节按“认识存储范围 → 读写与 JSON 转换 → 修改后持久化 → 待办案例”的顺序推进。localStorage 只保存当前浏览器中的非敏感数据,不会替代后面的 API 和数据库。前半节的 Console 片段分别演示单个知识点;出现相同变量名时,请分开运行,不要把所有片段连续粘贴到同一脚本中。

存储类型与范围 ​

浏览器提供两个用法接近的存储对象:

存储对象数据保留时间常见用途
localStorage关闭标签页和浏览器后仍然保留,直到主动删除主题、搜索偏好、非关键本地数据
sessionStorage当前标签页关闭后删除当前标签页的临时筛选和操作状态

两者都以键值对保存数据,方法基本相同:

js
localStorage.setItem("studentKeyword", "张三");
sessionStorage.setItem("studentKeyword", "张三");

本节案例需要关闭浏览器后仍保留数据,因此使用 localStorage。

同源隔离 ​

浏览器存储按来源隔离。来源由三部分组成:

text
协议 + 主机 + 端口

下面两个页面的端口不同,存储空间也不同:

text
http://127.0.0.1:5500
http://127.0.0.1:8000

在一个地址保存的数据,换到另一个协议、主机或端口后可能读取不到。

课程案例建议通过本地静态服务器打开,例如:

text
http://127.0.0.1:5500/.code/p04/c08-storage-todo/04-storage-todo.html

不要依赖 file:// 页面的存储规则。不同浏览器对本地文件的处理可能不同。

字符串读写 ​

写入与覆盖

假设要记住学员列表最后一次输入的搜索关键字:

js
localStorage.setItem("studentKeyword", "张");

setItem() 接收两个参数:

js
localStorage.setItem("键", "值");

同一个键再次保存时,新值会覆盖旧值:

js
localStorage.setItem("studentKeyword", "张");
localStorage.setItem("studentKeyword", "李");

console.log(localStorage.getItem("studentKeyword")); // 李

保存后刷新同一来源页面,仍可以读取到最后保存的“李”。键名相同表示更新同一份数据,不会新增多条记录。

读取与默认值

使用 getItem() 读取指定键:

js
const keyword = localStorage.getItem("studentKeyword");
console.log(keyword);

键不存在时返回 null:

js
const missingKeyword = localStorage.getItem("notFound");
console.log(missingKeyword); // null

恢复输入框内容时,可以提供空字符串作为默认值:

html
<input id="keyword" placeholder="姓名或手机号"/>
js
const keywordInput = document.querySelector("#keyword");
const savedKeyword = localStorage.getItem("studentKeyword");

keywordInput.value = savedKeyword ?? "";

?? 只在左侧是 null 或 undefined 时使用右侧默认值。

删除与清空

删除一个指定键:

js
localStorage.removeItem("studentKeyword");

删除当前来源下的全部 localStorage 数据:

js
localStorage.clear();

业务代码优先使用 removeItem()。clear() 可能把同一个网站其他页面保存的设置一起删除。

API 速查

API作用返回值
setItem(key, value)保存或覆盖一个值undefined
getItem(key)读取一个值字符串或 null
removeItem(key)删除一个值undefined
clear()清空当前来源的全部数据undefined
key(index)按位置读取键名字符串或 null
length已保存的键数量数字

查看当前存储内容:

js
for (let index = 0; index < localStorage.length; index += 1) {
    const key = localStorage.key(index);
    console.log(key, localStorage.getItem(key));
}

存储键不要只写 data、list 这类宽泛名称。带上项目和用途更容易区分:

js
const STORAGE_KEY = "fastapi-frontend:todos:v1";

末尾的 v1 表示数据结构版本。以后字段发生较大变化时,可以使用新键迁移数据。

类型转换 ​

保存数字和布尔值时,浏览器也会把它们转换成字符串:

js
localStorage.setItem("pageSize", 20);
localStorage.setItem("showDisabled", false);

console.log(localStorage.getItem("pageSize"));     // "20"
console.log(localStorage.getItem("showDisabled")); // "false"

读取后要按业务类型转换:

js
const pageSize = Number(localStorage.getItem("pageSize") ?? "10");
const showDisabled = localStorage.getItem("showDisabled") === "true";

下面的判断是错误的:

js
if (localStorage.getItem("showDisabled")) {
    console.log("显示停用学员");
}

字符串 "false" 也是非空字符串,在条件判断中会得到 true。

打开字符串存储练习(源码文件:./.code/p04/c08-storage-todo/01-string-storage.html),依次保存、读取和删除搜索词。刷新页面后再读取,观察数据是否仍在。

JSON 存储 ​

序列化与解析 ​

待办事项不是一个字符串,而是对象数组:

js
const sampleTodos = [
    {id: 1, title: "检查报名资料", done: false},
    {id: 2, title: "确认补交作业", done: true},
];

如果直接保存数组,浏览器会执行普通字符串转换,数据结构会丢失:

js
localStorage.setItem("todos", sampleTodos);
console.log(localStorage.getItem("todos"));
// [object Object],[object Object]

保存前使用 JSON.stringify() 转换为 JSON 字符串:

js
const text = JSON.stringify(sampleTodos);
localStorage.setItem("todos", text);

存储中的内容类似:

json
[
  {
    "id": 1,
    "title": "检查报名资料",
    "done": false
  },
  {
    "id": 2,
    "title": "确认补交作业",
    "done": true
  }
]

读取后使用 JSON.parse() 恢复为数组:

js
const savedText = localStorage.getItem("todos");
const savedTodos = savedText ? JSON.parse(savedText) : [];

console.log(Array.isArray(savedTodos)); // true
console.log(savedTodos[0].title);       // 检查报名资料

这两个方法的方向不要写反:

操作方法
JavaScript 数据 → JSON 字符串JSON.stringify(data)
JSON 字符串 → JavaScript 数据JSON.parse(text)

安全读取 ​

JSON.parse() 遇到损坏的字符串会抛出异常。先用一个独立例子观察同步异常的执行顺序:

js
try {
  JSON.parse("这不是 JSON");
  console.log("解析成功");
} catch (error) {
  console.error("解析失败:", error.message);
}

console.log("继续初始化页面");

解析失败后,程序跳过 try 中剩下的“解析成功”,进入 catch,随后继续输出“继续初始化页面”。没有捕获的异常会中断当前执行过程;try...catch 让我们决定失败后的处理方式。throw new Error("说明") 则用于主动报告发现的数据问题。

把这个过程用于本地读取,失败时返回空数组:

js
const STORAGE_KEY = "todos";

function loadTodos() {
  try {
    const text = localStorage.getItem(STORAGE_KEY);

    if (text === null) {
      return [];
    }

    const todos = JSON.parse(text);

    if (!Array.isArray(todos)) {
      throw new Error("待办数据不是数组");
    }

    return todos;
  } catch (error) {
    console.error("读取待办数据失败", error);
    return [];
  }
}

没有存储数据时返回空数组;数据不是合法 JSON 或结构不正确时也返回空数组。完整案例还会进一步检查每一项的 id、title 和 done 类型。

返回空数组是本例的恢复策略,不表示原数据已修复,也不会自动删除损坏的存储值。页面应提示读取失败,便于用户决定是否重新录入。

在 JSON 存储练习(源码文件:./.code/p04/c08-storage-todo/02-json-storage.html)中保存学员数组,再把对应的存储值改成无效 JSON,点击“安全读取”,确认页面显示读取失败提示。

本地持久化 ​

更新流程 ​

新增、切换状态和删除都遵循同一条流程:

mermaid Diagram

顺序不要反过来。页面只是数据的显示结果,数组和本地存储才是当前案例的数据来源。

例如,删除任务时不要只删除页面中的 li:

js
todoItem.remove();

刷新后,存储中的旧数据会再次渲染。正确做法是修改数组、保存,再重新渲染:

js
todos = todos.filter((todo) => todo.id !== todoId);
saveTodos();
renderTodos();

持久化流程练习(源码文件:./.code/p04/c08-storage-todo/03-persistence-flow.html)用学员姓名列表演示同一顺序。新增后刷新页面应保留姓名;点击“清空本案例数据”后,列表和对应存储键都应清空。若存储值已损坏,页面仍可打开并提示读取失败。

存储检查 ​

Chrome 开发者工具可以直接查看本地存储:

  1. 打开页面并按 F12。
  2. 切换到 Application 面板。
  3. 展开 Storage → Local Storage。
  4. 选择当前页面来源。
  5. 检查键名和 JSON 字符串。

也可以在 Console 中执行:

js
localStorage.getItem("fastapi-frontend:todos:v1");

排查时重点确认当前页面的协议、主机和端口。页面换了端口后“数据消失”,通常是进入了另一个来源的存储空间。

适用边界 ​

localStorage 使用方便,但它不是数据库。

需求localStorage后端 API 与数据库
保存页面主题和每页条数适合通常不需要
保存本机个人练习数据可以视需求决定
多设备同步不支持适合
多人共享和协作不支持适合
权限校验和业务规则不可信必须由后端处理
大量长期业务数据不适合适合

不要在 localStorage 中保存密码、银行卡号等敏感数据。页面中的 JavaScript 和能操作浏览器的人都可以读取或修改它。

localStorage 容量有限,而且读写会同步执行。它适合少量页面状态,不适合保存大型文件、图片和大量业务数据。

后面的 FastAPI 课程会讲后端持久化。本节只练习浏览器端状态保存,两者不会冲突。

学员跟进待办 ​

完成一个刷新后仍保留数据的待办事项页面:

任务填写“检查报名资料”等学员跟进事项;这里仅记录当前浏览器中的任务标题与完成状态,正式学员资料仍由后面的 API 管理。

  1. 输入任务并提交,新增到“正在进行”。
  2. 勾选复选框,在“正在进行”和“已经完成”之间移动。
  3. 删除指定任务,不使用数组索引作为长期标识。
  4. 清除全部已完成任务。
  5. 显示总数、进行中数量和已完成数量。
  6. 所有修改都写入 localStorage。
  7. 存储内容损坏时显示提示,页面仍能继续使用。

前面的 loadTodos() 用空数组演示缺失数据的处理。综合案例为了方便验证,在存储键尚不存在时改用 3 条内置任务;检测到损坏数据时仍返回空数组,并显示错误提示。下面展示关键代码片段,表单、DOM 变量、renderTodos() 和提示函数均在完整待办页面(源码文件:./.code/p04/c08-storage-todo/04-storage-todo.html)中定义,请从完整页面运行案例。

数据结构与持久化 ​

数据结构

js
const sampleTodos = [
    {
        id: 1,
        title: "检查报名资料",
        done: false,
    },
];

使用稳定 ID 查找任务。任务切换分组或删除其他任务后,当前任务的 ID 不会变化。

加载与提交

js
const STORAGE_KEY = "fastapi-frontend:todos:v1";
let todos = loadTodos();

function saveTodos() {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
    return true;
  } catch (error) {
    console.error("保存待办数据失败", error);
    showMessage("保存失败,请检查浏览器存储设置", "error");
    return false;
  }
}

function commitTodos(successMessage) {
  const saved = saveTodos();
  renderTodos();

  if (saved) {
    showMessage(successMessage, "success");
  }
}

页面初始化时先读取存储。每次修改数组后都调用 commitTodos():它负责保存、重新渲染,并在保存成功后显示提示。这样新增、切换状态、删除等操作不会各自复制同一段流程。若保存失败,页面仍会显示已修改的内存数组,并给出错误提示;刷新后这些修改不会保留。页面显示变化不等于持久化成功。

新增任务 ​

任务 ID

js
function createTodoId() {
    const maxId = todos.reduce((result, todo) => {
        return Math.max(result, todo.id);
    }, 0);

    return maxId + 1;
}

当前案例只在一个页面中生成本地数据,使用最大 ID 加一已经足够。接入后端 API 后,ID 由服务端生成。

表单提交

js
todoForm.addEventListener("submit", (event) => {
    event.preventDefault();

    const formData = new FormData(todoForm);
    const title = String(formData.get("title") ?? "").trim();

    if (!title) {
        showMessage("请输入待办事项", "error");
        return;
    }

    todos.unshift({
        id: createTodoId(),
        title,
        done: false,
    });

    commitTodos(`已添加任务:${title}`);
    todoForm.reset();
    todoTitleInput.focus();
});

先修改 todos,再保存和渲染。unshift() 把新任务放到数组最前面。

状态切换 ​

js
todoBoard.addEventListener("change", (event) => {
    if (!(event.target instanceof Element)) {
        return;
    }

    const checkbox = event.target.closest('[data-action="toggle"]');

    if (!checkbox || !todoBoard.contains(checkbox)) {
        return;
    }

    const item = checkbox.closest("[data-todo-id]");
    const todoId = Number(item?.dataset.todoId);
    const todo = todos.find((item) => item.id === todoId);

    if (!todo) {
        return;
    }

    todo.done = checkbox.checked;
    const actionText = todo.done ? "完成" : "恢复";
    commitTodos(`已${actionText}任务:${todo.title}`);
});

复选框和列表项每次渲染都会重新创建,因此继续使用上一节学习的事件委托。

删除与清理 ​

删除单条任务

js
todoBoard.addEventListener("click", (event) => {
    if (!(event.target instanceof Element)) {
        return;
    }

    const button = event.target.closest('[data-action="delete"]');

    if (!button || !todoBoard.contains(button)) {
        return;
    }

    const item = button.closest("[data-todo-id]");
    const todoId = Number(item?.dataset.todoId);
    const todo = todos.find((item) => item.id === todoId);

    if (!todo) {
        showMessage("没有找到对应的任务", "error");
        return;
    }

    todos = todos.filter((todo) => todo.id !== todoId);
    commitTodos(`已删除任务:${todo.title}`);
});

清除已完成任务

js
clearDoneButton.addEventListener("click", () => {
  const doneTotal = todos.filter((todo) => todo.done).length;

  if (doneTotal === 0) {
    return;
  }

  todos = todos.filter((todo) => !todo.done);
  commitTodos(`已清除 ${doneTotal} 条已完成任务`);
});

这里先统计已完成任务数量,再过滤出未完成任务。完整附件还会在清除前使用 window.confirm() 二次确认。

运行验证 ​

存储键尚不存在时,完整附件首次打开会显示 3 条学员跟进任务。新增、切换、删除或清除已完成任务后刷新页面,结果应保持不变。

可以按下面步骤验证:

  1. 新增“复习 localStorage”,刷新页面后它仍应在“正在进行”列表中。
  2. 勾选一条任务,它应移动到“已经完成”,统计数字同步变化;刷新后状态仍保留。
  3. 删除一条任务或点击“清除已完成”,刷新后被删除的数据不应恢复。
  4. 在开发者工具 Application → Local Storage 中把该键的值改成无效 JSON,刷新页面应显示读取失败提示,页面仍可继续操作;点击“重置案例数据”可恢复。

完整代码见本地待办事项(源码文件:./.code/p04/c08-storage-todo/04-storage-todo.html)。

本地待办事项
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>浏览器存储与待办事项</title>
  <style>
    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      background: #f3f6fa;
      color: #303133;
      font-family: Arial, "Microsoft YaHei", sans-serif;
    }

    button,
    input {
      font: inherit;
    }

    .sr-only {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }

    .page-container {
      width: min(980px, calc(100% - 32px));
      margin: 40px auto;
    }

    .page-header {
      display: flex;
      align-items: flex-end;
      justify-content: space-between;
      gap: 24px;
      margin-bottom: 22px;
    }

    .page-title {
      margin: 0 0 8px;
      font-size: 28px;
    }

    .page-description {
      margin: 0;
      color: #606266;
      line-height: 1.7;
    }

    .reset-button,
    .submit-button,
    .clear-button,
    .delete-button {
      border: 0;
      border-radius: 6px;
      cursor: pointer;
      transition: background-color 0.2s, transform 0.2s;
    }

    .reset-button:active,
    .submit-button:active,
    .clear-button:active,
    .delete-button:active {
      transform: translateY(1px);
    }

    .reset-button {
      flex: 0 0 auto;
      padding: 10px 16px;
      background: #fff;
      color: #606266;
      box-shadow: 0 2px 10px rgb(0 0 0 / 7%);
    }

    .reset-button:hover {
      background: #f5f7fa;
    }

    .panel {
      margin-bottom: 18px;
      padding: 22px;
      border-radius: 10px;
      background: #fff;
      box-shadow: 0 8px 24px rgb(0 0 0 / 5%);
    }

    .todo-form {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto;
      gap: 12px;
    }

    .todo-input {
      width: 100%;
      height: 44px;
      padding: 0 14px;
      border: 1px solid #dcdfe6;
      border-radius: 6px;
      outline: none;
    }

    .todo-input:focus {
      border-color: #409eff;
      box-shadow: 0 0 0 3px rgb(64 158 255 / 12%);
    }

    .submit-button {
      padding: 0 22px;
      background: #409eff;
      color: #fff;
    }

    .submit-button:hover {
      background: #66b1ff;
    }

    .message {
      min-height: 22px;
      margin: 14px 0 0;
      color: #606266;
      font-size: 14px;
    }

    .message.is-success {
      color: #1b9a59;
    }

    .message.is-error {
      color: #d93026;
    }

    .statistics {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 14px;
      margin-bottom: 18px;
    }

    .stat-card {
      padding: 18px;
      border-radius: 10px;
      background: #fff;
      box-shadow: 0 8px 24px rgb(0 0 0 / 5%);
    }

    .stat-label {
      margin: 0 0 8px;
      color: #909399;
      font-size: 14px;
    }

    .stat-value {
      margin: 0;
      font-size: 26px;
      font-weight: 700;
    }

    .todo-board {
      display: grid;
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 18px;
    }

    .todo-column {
      min-width: 0;
      padding: 20px;
      border-radius: 10px;
      background: #fff;
      box-shadow: 0 8px 24px rgb(0 0 0 / 5%);
    }

    .column-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      margin-bottom: 16px;
    }

    .column-title {
      margin: 0;
      font-size: 18px;
    }

    .count-badge {
      display: inline-grid;
      min-width: 25px;
      height: 25px;
      margin-left: 5px;
      padding: 0 7px;
      place-items: center;
      border-radius: 13px;
      background: #ecf5ff;
      color: #409eff;
      font-size: 13px;
    }

    .clear-button {
      padding: 7px 10px;
      background: #f5f7fa;
      color: #606266;
      font-size: 13px;
    }

    .clear-button:hover:not(:disabled) {
      background: #ebeef5;
    }

    .clear-button:disabled {
      color: #c0c4cc;
      cursor: not-allowed;
    }

    .todo-list {
      display: grid;
      gap: 10px;
      margin: 0;
      padding: 0;
      list-style: none;
    }

    .todo-item {
      display: grid;
      grid-template-columns: auto minmax(0, 1fr) auto;
      align-items: center;
      gap: 10px;
      min-height: 52px;
      padding: 10px 12px;
      border: 1px solid #ebeef5;
      border-radius: 7px;
      background: #fafcff;
    }

    .todo-item.is-done {
      background: #f7f8fa;
      color: #909399;
    }

    .todo-checkbox {
      width: 18px;
      height: 18px;
      accent-color: #409eff;
      cursor: pointer;
    }

    .todo-title {
      min-width: 0;
      overflow-wrap: anywhere;
      line-height: 1.5;
    }

    .todo-item.is-done .todo-title {
      text-decoration: line-through;
    }

    .delete-button {
      display: inline-flex;
      align-items: center;
      gap: 3px;
      padding: 7px 9px;
      background: transparent;
      color: #f56c6c;
    }

    .delete-button:hover {
      background: #fef0f0;
    }

    .delete-icon {
      display: inline-grid;
      width: 15px;
      height: 15px;
      place-items: center;
      font-weight: 700;
    }

    .empty-item {
      padding: 40px 16px;
      border: 1px dashed #dcdfe6;
      border-radius: 7px;
      color: #909399;
      text-align: center;
    }

    .storage-tip {
      margin: 18px 0 0;
      padding: 14px 16px;
      border-left: 4px solid #409eff;
      border-radius: 4px;
      background: #ecf5ff;
      color: #52606d;
      line-height: 1.7;
    }

    @media (max-width: 760px) {
      .page-container {
        margin: 24px auto;
      }

      .page-header {
        align-items: stretch;
        flex-direction: column;
      }

      .reset-button {
        align-self: flex-start;
      }

      .statistics,
      .todo-board {
        grid-template-columns: 1fr;
      }

      .todo-form {
        grid-template-columns: 1fr;
      }

      .submit-button {
        height: 42px;
      }
    }
  </style>
</head>
<body>
  <main class="page-container">
    <header class="page-header">
      <div>
        <h1 class="page-title">本地待办事项</h1>
        <p class="page-description">
          新增、完成和删除任务后刷新页面,数据仍会从 localStorage 中恢复。
        </p>
      </div>
      <button id="reset-button" class="reset-button" type="button">
        重置案例数据
      </button>
    </header>

    <section class="panel">
      <form id="todo-form" class="todo-form">
        <label class="sr-only" for="todo-title">待办事项</label>
        <input
          id="todo-title"
          class="todo-input"
          name="title"
          maxlength="80"
          placeholder="请输入待办事项,按回车添加"
          autocomplete="off"
          required
        />
        <button class="submit-button" type="submit">添加任务</button>
      </form>
      <p id="message" class="message" role="status">等待操作</p>
    </section>

    <section class="statistics" aria-label="待办统计">
      <article class="stat-card">
        <p class="stat-label">全部任务</p>
        <p id="total-count" class="stat-value">0</p>
      </article>
      <article class="stat-card">
        <p class="stat-label">正在进行</p>
        <p id="pending-stat" class="stat-value">0</p>
      </article>
      <article class="stat-card">
        <p class="stat-label">已经完成</p>
        <p id="done-stat" class="stat-value">0</p>
      </article>
    </section>

    <section id="todo-board" class="todo-board">
      <article class="todo-column">
        <header class="column-header">
          <h2 class="column-title">
            正在进行 <span id="pending-count" class="count-badge">0</span>
          </h2>
        </header>
        <ul id="pending-list" class="todo-list"></ul>
      </article>

      <article class="todo-column">
        <header class="column-header">
          <h2 class="column-title">
            已经完成 <span id="done-count" class="count-badge">0</span>
          </h2>
          <button id="clear-done-button" class="clear-button" type="button">
            清除已完成
          </button>
        </header>
        <ul id="done-list" class="todo-list"></ul>
      </article>
    </section>

    <p class="storage-tip">
      打开开发者工具的 Application → Local Storage,可以看到键
      <code>fastapi-frontend:todos:v1</code> 对应的 JSON 字符串。
    </p>
  </main>

  <script>
    const STORAGE_KEY = "fastapi-frontend:todos:v1";
    const INITIAL_TODOS = [
      { id: 1, title: "检查报名资料", done: false },
      { id: 2, title: "确认补交作业", done: true },
      { id: 3, title: "联系待确认学员", done: false },
    ];

    const todoForm = document.querySelector("#todo-form");
    const todoTitleInput = document.querySelector("#todo-title");
    const todoBoard = document.querySelector("#todo-board");
    const pendingList = document.querySelector("#pending-list");
    const doneList = document.querySelector("#done-list");
    const pendingCount = document.querySelector("#pending-count");
    const doneCount = document.querySelector("#done-count");
    const totalCount = document.querySelector("#total-count");
    const pendingStat = document.querySelector("#pending-stat");
    const doneStat = document.querySelector("#done-stat");
    const clearDoneButton = document.querySelector("#clear-done-button");
    const resetButton = document.querySelector("#reset-button");
    const message = document.querySelector("#message");

    function showMessage(text, type = "") {
      message.textContent = text;
      message.className = "message";

      if (type) {
        message.classList.add(`is-${type}`);
      }
    }

    function copyInitialTodos() {
      return INITIAL_TODOS.map((todo) => ({ ...todo }));
    }

    function isValidTodo(todo) {
      return (
        todo !== null &&
        typeof todo === "object" &&
        Number.isInteger(todo.id) &&
        typeof todo.title === "string" &&
        typeof todo.done === "boolean"
      );
    }

    function loadTodos() {
      try {
        const text = localStorage.getItem(STORAGE_KEY);

        if (text === null) {
          return copyInitialTodos();
        }

        const result = JSON.parse(text);

        if (!Array.isArray(result) || !result.every(isValidTodo)) {
          throw new Error("待办数据格式错误");
        }

        return result;
      } catch (error) {
        console.error("读取待办数据失败", error);
        showMessage("本地数据无法读取,请重置案例数据", "error");
        return [];
      }
    }

    let todos = loadTodos();

    function saveTodos() {
      try {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
        return true;
      } catch (error) {
        console.error("保存待办数据失败", error);
        showMessage("保存失败,请检查浏览器存储设置", "error");
        return false;
      }
    }

    function createEmptyItem(text) {
      const item = document.createElement("li");
      item.classList.add("empty-item");
      item.textContent = text;
      return item;
    }

    function createTodoItem(todo) {
      const item = document.createElement("li");
      const checkbox = document.createElement("input");
      const title = document.createElement("span");
      const deleteButton = document.createElement("button");
      const deleteIcon = document.createElement("span");

      item.classList.add("todo-item");
      item.classList.toggle("is-done", todo.done);
      item.dataset.todoId = String(todo.id);

      checkbox.type = "checkbox";
      checkbox.classList.add("todo-checkbox");
      checkbox.dataset.action = "toggle";
      checkbox.checked = todo.done;
      checkbox.setAttribute("aria-label", `切换“${todo.title}”的完成状态`);

      title.classList.add("todo-title");
      title.textContent = todo.title;

      deleteButton.type = "button";
      deleteButton.classList.add("delete-button");
      deleteButton.dataset.action = "delete";
      deleteButton.setAttribute("aria-label", `删除“${todo.title}”`);

      deleteIcon.classList.add("delete-icon");
      deleteIcon.textContent = "×";
      deleteIcon.setAttribute("aria-hidden", "true");

      deleteButton.append(deleteIcon, "删除");
      item.append(checkbox, title, deleteButton);
      return item;
    }

    function renderTodos() {
      const pendingFragment = document.createDocumentFragment();
      const doneFragment = document.createDocumentFragment();
      const pendingTodos = todos.filter((todo) => !todo.done);
      const doneTodos = todos.filter((todo) => todo.done);

      for (const todo of pendingTodos) {
        pendingFragment.append(createTodoItem(todo));
      }

      for (const todo of doneTodos) {
        doneFragment.append(createTodoItem(todo));
      }

      pendingList.replaceChildren(
        pendingTodos.length > 0
          ? pendingFragment
          : createEmptyItem("暂无进行中的任务"),
      );
      doneList.replaceChildren(
        doneTodos.length > 0
          ? doneFragment
          : createEmptyItem("暂无已完成任务"),
      );

      pendingCount.textContent = String(pendingTodos.length);
      doneCount.textContent = String(doneTodos.length);
      totalCount.textContent = String(todos.length);
      pendingStat.textContent = String(pendingTodos.length);
      doneStat.textContent = String(doneTodos.length);
      clearDoneButton.disabled = doneTodos.length === 0;
    }

    function createTodoId() {
      const maxId = todos.reduce((result, todo) => {
        return Math.max(result, todo.id);
      }, 0);

      return maxId + 1;
    }

    function commitTodos(successMessage) {
      const saved = saveTodos();
      renderTodos();

      if (saved) {
        showMessage(successMessage, "success");
      }
    }

    todoForm.addEventListener("submit", (event) => {
      event.preventDefault();

      const formData = new FormData(todoForm);
      const title = String(formData.get("title") ?? "").trim();

      if (!title) {
        showMessage("请输入待办事项", "error");
        return;
      }

      todos.unshift({
        id: createTodoId(),
        title,
        done: false,
      });

      commitTodos(`已添加任务:${title}`);
      todoForm.reset();
      todoTitleInput.focus();
    });

    todoBoard.addEventListener("change", (event) => {
      const checkbox = event.target.closest('[data-action="toggle"]');

      if (!checkbox || !todoBoard.contains(checkbox)) {
        return;
      }

      const item = checkbox.closest("[data-todo-id]");
      const todoId = Number(item?.dataset.todoId);
      const todo = todos.find((item) => item.id === todoId);

      if (!todo) {
        showMessage("没有找到对应的任务", "error");
        return;
      }

      todo.done = checkbox.checked;
      const actionText = todo.done ? "完成" : "恢复";
      commitTodos(`已${actionText}任务:${todo.title}`);
    });

    todoBoard.addEventListener("click", (event) => {
      const button = event.target.closest('[data-action="delete"]');

      if (!button || !todoBoard.contains(button)) {
        return;
      }

      const item = button.closest("[data-todo-id]");
      const todoId = Number(item?.dataset.todoId);
      const todo = todos.find((item) => item.id === todoId);

      if (!todo) {
        showMessage("没有找到对应的任务", "error");
        return;
      }

      todos = todos.filter((item) => item.id !== todoId);
      commitTodos(`已删除任务:${todo.title}`);
    });

    clearDoneButton.addEventListener("click", () => {
      const doneTotal = todos.filter((todo) => todo.done).length;

      if (doneTotal === 0) {
        return;
      }

      const confirmed = window.confirm(`确认清除 ${doneTotal} 条已完成任务吗?`);

      if (!confirmed) {
        showMessage("已取消清除");
        return;
      }

      todos = todos.filter((todo) => !todo.done);
      commitTodos(`已清除 ${doneTotal} 条已完成任务`);
    });

    resetButton.addEventListener("click", () => {
      const confirmed = window.confirm("确认恢复为案例初始数据吗?");

      if (!confirmed) {
        showMessage("已取消重置");
        return;
      }

      todos = copyInitialTodos();
      commitTodos("已恢复案例初始数据");
    });

    window.addEventListener("storage", (event) => {
      if (event.key !== STORAGE_KEY) {
        return;
      }

      todos = loadTodos();
      renderTodos();
      showMessage("已同步其他标签页的修改");
    });

    renderTodos();
  </script>
</body>
</html>

:::

多标签页同步 ​

以下内容可选。同一来源的另一个标签页修改存储时,当前页面可以监听 storage 事件:

js
window.addEventListener("storage", (event) => {
    if (event.key !== STORAGE_KEY) {
        return;
    }

    todos = loadTodos();
    renderTodos();
    showMessage("已同步其他标签页的修改");
});

storage 事件通常发生在其他同源页面中,不会因为当前页面自己调用 setItem() 而在当前页面触发。完整案例已经加入这段同步逻辑。

同步存储与异步加载 ​

本节中的 localStorage.getItem() 和 localStorage.setItem() 会在当前语句中完成,调用后可以立即得到读取结果或保存结果。它们是同步操作:

js
const savedTodos = localStorage.getItem(STORAGE_KEY);
console.log("读取结果已经返回", savedTodos);

下一节会读取本地学员数据:结果需要等待,不能当作普通函数的立即返回值。先观察执行顺序,再学习等待数据和恢复页面状态:

步骤写法先建立的认识
1setTimeout() + 回调等待期间后面的同步代码仍会继续执行
2async/await + try/catch/finally按代码顺序等待数据,并管理异步页面状态

localStorage 本身仍是同步 API。下一节先用计时器观察执行顺序,再通过本地数据请求练习 async/await,最后用成功、空数据和失败模式处理页面状态。

可复制的练习源码 ​

复制后保持导入路径中的目录结构。纯 HTML 页面可直接在浏览器打开;需要 ES Module 或测试 API 的案例,按本节前文的启动命令运行并访问对应路径。

.code/p04/c08-storage-todo/01-string-storage.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>localStorage 字符串读写</title>
  <style>
    body { max-width: 680px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
    input, button { padding: 9px 12px; margin: 4px; }
    #result { padding: 14px; background: #f4f6f8; }
  </style>
</head>
<body>
  <h1>保存一个搜索关键词</h1>
  <input id="keyword" value="Python" />
  <button id="save-button" type="button">保存</button>
  <button id="read-button" type="button">读取</button>
  <button id="remove-button" type="button">删除</button>
  <p id="result">等待操作</p>

  <script>
    const storageKey = "course-search-keyword";
    const keywordInput = document.querySelector("#keyword");
    const result = document.querySelector("#result");

    document.querySelector("#save-button").addEventListener("click", () => { localStorage.setItem(storageKey, keywordInput.value); result.textContent = "已保存字符串"; });
    document.querySelector("#read-button").addEventListener("click", () => { result.textContent = `读取结果:${localStorage.getItem(storageKey) ?? "没有数据"}`; });
    document.querySelector("#remove-button").addEventListener("click", () => { localStorage.removeItem(storageKey); result.textContent = "已删除"; });
  </script>
</body>
</html>
.code/p04/c08-storage-todo/02-json-storage.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>使用 JSON 保存对象数组</title>
  <style>
    body { max-width: 700px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
    button { padding: 9px 14px; margin-right: 8px; }
    pre { padding: 16px; background: #18222d; color: #dcf4e9; overflow: auto; }
  </style>
</head>
<body>
  <h1>JSON 与 localStorage</h1>
  <button id="save-button" type="button">保存学员数组</button>
  <button id="read-button" type="button">安全读取</button>
  <pre id="result">等待操作</pre>

  <script>
    const storageKey = "course-students";
    const students = [{ id: 1, name: "张三" }, { id: 2, name: "李四" }];
    const result = document.querySelector("#result");

    function loadStudents() {
      const rawValue = localStorage.getItem(storageKey);
      const savedStudents = rawValue === null ? [] : JSON.parse(rawValue);

      if (!Array.isArray(savedStudents)) {
        throw new Error("存储内容不是数组");
      }

      return savedStudents;
    }

    document.querySelector("#save-button").addEventListener("click", () => { localStorage.setItem(storageKey, JSON.stringify(students)); result.textContent = localStorage.getItem(storageKey); });
    document.querySelector("#read-button").addEventListener("click", () => {
      try {
        result.textContent = JSON.stringify(loadStudents(), null, 2);
      } catch (error) {
        result.textContent = `读取失败:${error.message}`;
      }
    });
  </script>
</body>
</html>
.code/p04/c08-storage-todo/03-persistence-flow.html
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>修改保存再渲染</title>
  <style>
    body { max-width: 700px; margin: 40px auto; padding: 0 20px; font-family: sans-serif; }
    form { display: flex; gap: 8px; }
    input { flex: 1; padding: 9px; }
    button { padding: 9px 12px; }
    li { margin: 8px 0; }
  </style>
</head>
<body>
  <h1>持久化数据的完整流程</h1>
  <form id="student-form"><input id="student-name" required placeholder="输入学员姓名" /><button>新增</button></form>
  <ul id="student-list"></ul>
  <button id="reset-button" type="button">清空本案例数据</button>
  <p id="message" role="status"></p>

  <script>
    const storageKey = "course-student-names";
    const form = document.querySelector("#student-form");
    const nameInput = document.querySelector("#student-name");
    const studentList = document.querySelector("#student-list");
    const message = document.querySelector("#message");

    function loadNames() {
      try {
        const savedNames = JSON.parse(localStorage.getItem(storageKey) ?? "[]");

        if (!Array.isArray(savedNames) || !savedNames.every((name) => typeof name === "string")) {
          throw new Error("存储内容不是姓名数组");
        }

        return savedNames;
      } catch (error) {
        message.textContent = `读取失败:${error.message}。可以清空本案例数据。`;
        return [];
      }
    }

    let names = loadNames();

    function renderNames() {
      const items = names.map((name) => {
        const item = document.createElement("li");
        item.textContent = name;
        return item;
      });
      studentList.replaceChildren(...items);
    }

    function saveAndRender() {
      try {
        localStorage.setItem(storageKey, JSON.stringify(names));
        message.textContent = "已保存学员名单";
      } catch (error) {
        message.textContent = `保存失败:${error.message}`;
      }
      renderNames();
    }

    form.addEventListener("submit", (event) => {
      event.preventDefault();
      const name = nameInput.value.trim();
      if (!name) return;
      names.push(name);
      nameInput.value = "";
      saveAndRender();
    });

    document.querySelector("#reset-button").addEventListener("click", () => {
      try {
        localStorage.removeItem(storageKey);
        names = [];
        message.textContent = "已清空本案例数据";
        renderNames();
      } catch (error) {
        message.textContent = `清空失败:${error.message}`;
      }
    });

    renderNames();
  </script>
</body>
</html>

小结 ​

本节课最核心的是掌握六件事:

  1. localStorage 长期保留,sessionStorage 在当前标签页关闭后清除。
  2. 存储按同源隔离,协议、主机或端口不同都会进入另一个空间。
  3. 浏览器存储只保存字符串,对象和数组需要配合 JSON 转换。
  4. getItem() 可能返回 null,JSON.parse() 可能抛出异常。
  5. 持久化操作遵循“修改数组—保存数据—重新渲染”的流程。
  6. 本地存储适合非敏感页面状态,不能替代后端 API 和数据库。