浏览器存储与待办事项
前面的学员列表使用 JavaScript 数组保存数据。页面刷新后,数组会重新创建,用户刚才的操作也会消失。
搜索条件、主题设置和个人待办等非敏感页面状态,可以先保存在浏览器中。本节使用 localStorage 保存“检查报名资料”“确认补交作业”等学员跟进待办,完成一个刷新后仍能保留数据的页面。
本节课解决五个问题:
localStorage和sessionStorage有什么区别。- 怎样保存、读取和删除字符串数据。
- 怎样使用 JSON 保存对象和数组。
- 怎样处理空数据和损坏的 JSON。
- 怎样让待办事项的新增、完成和删除操作持久化。
本节按“认识存储范围 → 读写与 JSON 转换 → 修改后持久化 → 待办案例”的顺序推进。localStorage 只保存当前浏览器中的非敏感数据,不会替代后面的 API 和数据库。前半节的 Console 片段分别演示单个知识点;出现相同变量名时,请分开运行,不要把所有片段连续粘贴到同一脚本中。
存储类型与范围
浏览器提供两个用法接近的存储对象:
| 存储对象 | 数据保留时间 | 常见用途 |
|---|---|---|
localStorage | 关闭标签页和浏览器后仍然保留,直到主动删除 | 主题、搜索偏好、非关键本地数据 |
sessionStorage | 当前标签页关闭后删除 | 当前标签页的临时筛选和操作状态 |
两者都以键值对保存数据,方法基本相同:
localStorage.setItem("studentKeyword", "张三");
sessionStorage.setItem("studentKeyword", "张三");本节案例需要关闭浏览器后仍保留数据,因此使用 localStorage。
同源隔离
浏览器存储按来源隔离。来源由三部分组成:
协议 + 主机 + 端口下面两个页面的端口不同,存储空间也不同:
http://127.0.0.1:5500
http://127.0.0.1:8000在一个地址保存的数据,换到另一个协议、主机或端口后可能读取不到。
课程案例建议通过本地静态服务器打开,例如:
http://127.0.0.1:5500/.code/p04/c08-storage-todo/04-storage-todo.html不要依赖 file:// 页面的存储规则。不同浏览器对本地文件的处理可能不同。
字符串读写
写入与覆盖
假设要记住学员列表最后一次输入的搜索关键字:
localStorage.setItem("studentKeyword", "张");setItem() 接收两个参数:
localStorage.setItem("键", "值");同一个键再次保存时,新值会覆盖旧值:
localStorage.setItem("studentKeyword", "张");
localStorage.setItem("studentKeyword", "李");
console.log(localStorage.getItem("studentKeyword")); // 李保存后刷新同一来源页面,仍可以读取到最后保存的“李”。键名相同表示更新同一份数据,不会新增多条记录。
读取与默认值
使用 getItem() 读取指定键:
const keyword = localStorage.getItem("studentKeyword");
console.log(keyword);键不存在时返回 null:
const missingKeyword = localStorage.getItem("notFound");
console.log(missingKeyword); // null恢复输入框内容时,可以提供空字符串作为默认值:
<input id="keyword" placeholder="姓名或手机号"/>const keywordInput = document.querySelector("#keyword");
const savedKeyword = localStorage.getItem("studentKeyword");
keywordInput.value = savedKeyword ?? "";?? 只在左侧是 null 或 undefined 时使用右侧默认值。
删除与清空
删除一个指定键:
localStorage.removeItem("studentKeyword");删除当前来源下的全部 localStorage 数据:
localStorage.clear();业务代码优先使用 removeItem()。clear() 可能把同一个网站其他页面保存的设置一起删除。
API 速查
| API | 作用 | 返回值 |
|---|---|---|
setItem(key, value) | 保存或覆盖一个值 | undefined |
getItem(key) | 读取一个值 | 字符串或 null |
removeItem(key) | 删除一个值 | undefined |
clear() | 清空当前来源的全部数据 | undefined |
key(index) | 按位置读取键名 | 字符串或 null |
length | 已保存的键数量 | 数字 |
查看当前存储内容:
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
console.log(key, localStorage.getItem(key));
}存储键不要只写 data、list 这类宽泛名称。带上项目和用途更容易区分:
const STORAGE_KEY = "fastapi-frontend:todos:v1";末尾的 v1 表示数据结构版本。以后字段发生较大变化时,可以使用新键迁移数据。
类型转换
保存数字和布尔值时,浏览器也会把它们转换成字符串:
localStorage.setItem("pageSize", 20);
localStorage.setItem("showDisabled", false);
console.log(localStorage.getItem("pageSize")); // "20"
console.log(localStorage.getItem("showDisabled")); // "false"读取后要按业务类型转换:
const pageSize = Number(localStorage.getItem("pageSize") ?? "10");
const showDisabled = localStorage.getItem("showDisabled") === "true";下面的判断是错误的:
if (localStorage.getItem("showDisabled")) {
console.log("显示停用学员");
}字符串 "false" 也是非空字符串,在条件判断中会得到 true。
打开字符串存储练习(源码文件:./.code/p04/c08-storage-todo/01-string-storage.html),依次保存、读取和删除搜索词。刷新页面后再读取,观察数据是否仍在。
JSON 存储
序列化与解析
待办事项不是一个字符串,而是对象数组:
const sampleTodos = [
{id: 1, title: "检查报名资料", done: false},
{id: 2, title: "确认补交作业", done: true},
];如果直接保存数组,浏览器会执行普通字符串转换,数据结构会丢失:
localStorage.setItem("todos", sampleTodos);
console.log(localStorage.getItem("todos"));
// [object Object],[object Object]保存前使用 JSON.stringify() 转换为 JSON 字符串:
const text = JSON.stringify(sampleTodos);
localStorage.setItem("todos", text);存储中的内容类似:
[
{
"id": 1,
"title": "检查报名资料",
"done": false
},
{
"id": 2,
"title": "确认补交作业",
"done": true
}
]读取后使用 JSON.parse() 恢复为数组:
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() 遇到损坏的字符串会抛出异常。先用一个独立例子观察同步异常的执行顺序:
try {
JSON.parse("这不是 JSON");
console.log("解析成功");
} catch (error) {
console.error("解析失败:", error.message);
}
console.log("继续初始化页面");解析失败后,程序跳过 try 中剩下的“解析成功”,进入 catch,随后继续输出“继续初始化页面”。没有捕获的异常会中断当前执行过程;try...catch 让我们决定失败后的处理方式。throw new Error("说明") 则用于主动报告发现的数据问题。
把这个过程用于本地读取,失败时返回空数组:
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,点击“安全读取”,确认页面显示读取失败提示。
本地持久化
更新流程
新增、切换状态和删除都遵循同一条流程:
顺序不要反过来。页面只是数据的显示结果,数组和本地存储才是当前案例的数据来源。
例如,删除任务时不要只删除页面中的 li:
todoItem.remove();刷新后,存储中的旧数据会再次渲染。正确做法是修改数组、保存,再重新渲染:
todos = todos.filter((todo) => todo.id !== todoId);
saveTodos();
renderTodos();持久化流程练习(源码文件:./.code/p04/c08-storage-todo/03-persistence-flow.html)用学员姓名列表演示同一顺序。新增后刷新页面应保留姓名;点击“清空本案例数据”后,列表和对应存储键都应清空。若存储值已损坏,页面仍可打开并提示读取失败。
存储检查
Chrome 开发者工具可以直接查看本地存储:
- 打开页面并按
F12。 - 切换到 Application 面板。
- 展开 Storage → Local Storage。
- 选择当前页面来源。
- 检查键名和 JSON 字符串。
也可以在 Console 中执行:
localStorage.getItem("fastapi-frontend:todos:v1");排查时重点确认当前页面的协议、主机和端口。页面换了端口后“数据消失”,通常是进入了另一个来源的存储空间。
适用边界
localStorage 使用方便,但它不是数据库。
| 需求 | localStorage | 后端 API 与数据库 |
|---|---|---|
| 保存页面主题和每页条数 | 适合 | 通常不需要 |
| 保存本机个人练习数据 | 可以 | 视需求决定 |
| 多设备同步 | 不支持 | 适合 |
| 多人共享和协作 | 不支持 | 适合 |
| 权限校验和业务规则 | 不可信 | 必须由后端处理 |
| 大量长期业务数据 | 不适合 | 适合 |
不要在 localStorage 中保存密码、银行卡号等敏感数据。页面中的 JavaScript 和能操作浏览器的人都可以读取或修改它。
localStorage 容量有限,而且读写会同步执行。它适合少量页面状态,不适合保存大型文件、图片和大量业务数据。
后面的 FastAPI 课程会讲后端持久化。本节只练习浏览器端状态保存,两者不会冲突。
学员跟进待办
完成一个刷新后仍保留数据的待办事项页面:
任务填写“检查报名资料”等学员跟进事项;这里仅记录当前浏览器中的任务标题与完成状态,正式学员资料仍由后面的 API 管理。
- 输入任务并提交,新增到“正在进行”。
- 勾选复选框,在“正在进行”和“已经完成”之间移动。
- 删除指定任务,不使用数组索引作为长期标识。
- 清除全部已完成任务。
- 显示总数、进行中数量和已完成数量。
- 所有修改都写入
localStorage。 - 存储内容损坏时显示提示,页面仍能继续使用。
前面的 loadTodos() 用空数组演示缺失数据的处理。综合案例为了方便验证,在存储键尚不存在时改用 3 条内置任务;检测到损坏数据时仍返回空数组,并显示错误提示。下面展示关键代码片段,表单、DOM 变量、renderTodos() 和提示函数均在完整待办页面(源码文件:./.code/p04/c08-storage-todo/04-storage-todo.html)中定义,请从完整页面运行案例。
数据结构与持久化
数据结构
const sampleTodos = [
{
id: 1,
title: "检查报名资料",
done: false,
},
];使用稳定 ID 查找任务。任务切换分组或删除其他任务后,当前任务的 ID 不会变化。
加载与提交
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
function createTodoId() {
const maxId = todos.reduce((result, todo) => {
return Math.max(result, todo.id);
}, 0);
return maxId + 1;
}当前案例只在一个页面中生成本地数据,使用最大 ID 加一已经足够。接入后端 API 后,ID 由服务端生成。
表单提交
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() 把新任务放到数组最前面。
状态切换
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}`);
});复选框和列表项每次渲染都会重新创建,因此继续使用上一节学习的事件委托。
删除与清理
删除单条任务
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}`);
});清除已完成任务
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 条学员跟进任务。新增、切换、删除或清除已完成任务后刷新页面,结果应保持不变。
可以按下面步骤验证:
- 新增“复习 localStorage”,刷新页面后它仍应在“正在进行”列表中。
- 勾选一条任务,它应移动到“已经完成”,统计数字同步变化;刷新后状态仍保留。
- 删除一条任务或点击“清除已完成”,刷新后被删除的数据不应恢复。
- 在开发者工具 Application → Local Storage 中把该键的值改成无效 JSON,刷新页面应显示读取失败提示,页面仍可继续操作;点击“重置案例数据”可恢复。
完整代码见本地待办事项(源码文件:./.code/p04/c08-storage-todo/04-storage-todo.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 事件:
window.addEventListener("storage", (event) => {
if (event.key !== STORAGE_KEY) {
return;
}
todos = loadTodos();
renderTodos();
showMessage("已同步其他标签页的修改");
});storage 事件通常发生在其他同源页面中,不会因为当前页面自己调用 setItem() 而在当前页面触发。完整案例已经加入这段同步逻辑。
同步存储与异步加载
本节中的 localStorage.getItem() 和 localStorage.setItem() 会在当前语句中完成,调用后可以立即得到读取结果或保存结果。它们是同步操作:
const savedTodos = localStorage.getItem(STORAGE_KEY);
console.log("读取结果已经返回", savedTodos);下一节会读取本地学员数据:结果需要等待,不能当作普通函数的立即返回值。先观察执行顺序,再学习等待数据和恢复页面状态:
| 步骤 | 写法 | 先建立的认识 |
|---|---|---|
| 1 | setTimeout() + 回调 | 等待期间后面的同步代码仍会继续执行 |
| 2 | async/await + try/catch/finally | 按代码顺序等待数据,并管理异步页面状态 |
localStorage 本身仍是同步 API。下一节先用计时器观察执行顺序,再通过本地数据请求练习 async/await,最后用成功、空数据和失败模式处理页面状态。
可复制的练习源码
复制后保持导入路径中的目录结构。纯 HTML 页面可直接在浏览器打开;需要 ES Module 或测试 API 的案例,按本节前文的启动命令运行并访问对应路径。
.code/p04/c08-storage-todo/01-string-storage.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
<!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
<!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>小结
本节课最核心的是掌握六件事:
localStorage长期保留,sessionStorage在当前标签页关闭后清除。- 存储按同源隔离,协议、主机或端口不同都会进入另一个空间。
- 浏览器存储只保存字符串,对象和数组需要配合 JSON 转换。
getItem()可能返回null,JSON.parse()可能抛出异常。- 持久化操作遵循“修改数组—保存数据—重新渲染”的流程。
- 本地存储适合非敏感页面状态,不能替代后端 API 和数据库。