A09 实战待办清单
A09 实战待办清单
摘要:待办清单把前八篇的能力合成一条完整链路:状态数组、渲染函数、事件委托、表单提交、本地持久化。可控的实现只有一个数据源,DOM 由状态派生,任何操作先改状态再渲染。本文给出单文件实现、六项验收与五条取舍记录,代码刷新后数据不丢。
关键词:待办清单,单向数据流,localStorage,事件委托,持久化
所属:frontend/js / A-入门 | 上一篇:A08-DOM操作与事件 | 下一篇:无 | 返回:A00-总结
1 引言
清单类应用的需求固定:新增、勾选、筛选、删除、刷新后保留。常见实现把状态散落在 DOM 里(用 class 表示完成、用节点数量表示条数),改一个筛选条件就要遍历节点。
本文采用单一数据源:state 是唯一真相,render() 只读 state。范围见表 1-1。
| 项 | 本文覆盖 | 本文不覆盖 |
|---|---|---|
| 功能 | 增、勾选、筛选、删除、清空已完成 | 多用户同步 |
| 持久化 | localStorage 读写与版本号 |
服务端存储(D03) |
| 结构 | 状态 → 渲染 → 事件 三层 | 组件化拆分(D01) |
2 预备知识
| 项 | 要求 | 核对方式 |
|---|---|---|
| 环境 | Chrome 130+,本地服务器 | npx serve . 可访问 |
| 前置 | A08 | 会事件委托与 textContent |
| 文件 | todo/index.html、todo/app.js |
编码 UTF-8 |
3 原理与实现
3.1 单向数据流
所有交互走同一条路径:事件改状态,状态驱动渲染,渲染结果只读,如图 3-1 所示。
flowchart LR
A["用户操作"] --> B["事件处理器"]
B --> C["修改 state"]
C --> D["save() 持久化"]
D --> E["render() 重建列表"]
E --> F["DOM 反映最新状态"]
F --> A
图 3-1 状态为唯一数据源,DOM 不反向写入状态
图上可见:DOM 只作为输出端,不从节点读回数据;这样筛选、统计、持久化都只依赖
state。
3.2 完整实现
<!-- todo/index.html;运行环境:Chrome 130+ -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>待办清单</title>
</head>
<body>
<h1>待办清单</h1>
<form id="form">
<label for="title">新任务</label>
<input id="title" name="title" required maxlength="60" />
<button type="submit">添加</button>
</form>
<div role="group" aria-label="筛选">
<button type="button" data-filter="all" aria-pressed="true">全部</button>
<button type="button" data-filter="active" aria-pressed="false">未完成</button>
<button type="button" data-filter="done" aria-pressed="false">已完成</button>
</div>
<ul id="list"></ul>
<p id="summary"></p>
<button type="button" id="clear">清空已完成</button>
<script type="module" src="./app.js"></script>
</body>
</html>
// todo/app.js;运行环境:Chrome 130+
const KEY = 'todo:v1';
const form = document.querySelector('#form');
const input = document.querySelector('#title');
const list = document.querySelector('#list');
const summary = document.querySelector('#summary');
const filters = document.querySelector('[aria-label="筛选"]');
const clearBtn = document.querySelector('#clear');
// 1 状态与持久化
let state = load();
function load() {
try {
const raw = JSON.parse(localStorage.getItem(KEY) ?? '[]');
return Array.isArray(raw) ? raw : [];
} catch {
return []; // 数据损坏时回退为空列表
}
}
function save() {
localStorage.setItem(KEY, JSON.stringify(state));
}
// 2 派生数据:筛选与统计都由 state 计算
let filter = 'all';
function visible() {
if (filter === 'active') return state.filter((t) => !t.done);
if (filter === 'done') return state.filter((t) => t.done);
return state;
}
// 3 渲染:只读 state
function render() {
const fragment = document.createDocumentFragment();
for (const todo of visible()) {
const li = document.createElement('li');
li.dataset.id = todo.id;
li.dataset.done = String(todo.done);
const label = document.createElement('label');
const box = document.createElement('input');
box.type = 'checkbox';
box.checked = todo.done;
box.dataset.action = 'toggle';
label.append(box, document.createTextNode(todo.title));
const del = document.createElement('button');
del.type = 'button';
del.textContent = '删除';
del.dataset.action = 'remove';
li.append(label, del);
fragment.append(li);
}
list.replaceChildren(fragment);
const doneCount = state.filter((t) => t.done).length;
summary.textContent = `共 ${state.length} 条,已完成 ${doneCount} 条`;
clearBtn.disabled = doneCount === 0;
for (const btn of filters.querySelectorAll('button')) {
btn.setAttribute('aria-pressed', String(btn.dataset.filter === filter));
}
}
// 4 事件:改状态后统一 save + render
form.addEventListener('submit', (event) => {
event.preventDefault();
const title = input.value.trim();
if (!title) return;
state.push({ id: crypto.randomUUID(), title, done: false });
form.reset();
save();
render();
});
list.addEventListener('change', (event) => {
const box = event.target.closest('input[data-action="toggle"]');
if (!box) return;
const id = box.closest('li').dataset.id;
const todo = state.find((t) => t.id === id);
if (todo) todo.done = box.checked;
save();
render();
});
list.addEventListener('click', (event) => {
const btn = event.target.closest('button[data-action="remove"]');
if (!btn) return;
const id = btn.closest('li').dataset.id;
state = state.filter((t) => t.id !== id);
save();
render();
});
filters.addEventListener('click', (event) => {
const btn = event.target.closest('button[data-filter]');
if (!btn) return;
filter = btn.dataset.filter;
render();
});
clearBtn.addEventListener('click', () => {
state = state.filter((t) => !t.done);
save();
render();
});
render();
3.3 实施清单
| 序号 | 改动 | 文件 | 说明 |
|---|---|---|---|
| 1 | 建立页面骨架与筛选按钮 | todo/index.html |
筛选按钮用 aria-pressed 标记状态 |
| 2 | 定义状态结构与读写 | todo/app.js |
数据带版本号键名 todo:v1 |
| 3 | 实现 render() |
todo/app.js |
一次替换子树,只触发一次布局 |
| 4 | 绑定五类交互 | todo/app.js |
提交、勾选、删除、筛选、清空 |
| 5 | 校验刷新后数据保留 | 浏览器 | 关闭标签页再打开仍需保留 |
4 验证与讨论
- 添加三条任务,勾选一条,确认摘要显示「共 3 条,已完成 1 条」。
- 切到「未完成」,确认列表只显示两条;切回「全部」,确认三条都在。
- 刷新页面,确认任务与勾选状态均保留。
- 在控制台执行
localStorage.setItem('todo:v1', '{坏数据')后刷新,确认页面回退为空列表且不报错。
| 序号 | 验收项 | 判定标准 |
|---|---|---|
| 1 | 新增 | 提交后立刻出现在列表首位或末位 |
| 2 | 勾选 | 摘要与筛选结果同步变化 |
| 3 | 删除 | 仅目标行消失 |
| 4 | 清空已完成 | 未完成项全部保留 |
| 5 | 持久化 | 刷新后状态完全一致 |
| 6 | 数据损坏 | 回退为空列表,无控制台报错 |
五项取舍见表 4-1。
| 决策点 | 采用方案 | 备选方案 | 不选的理由 |
|---|---|---|---|
| 状态归属 | 单一 state 数组 |
以 DOM 为准 | 节点数量与完成状态难以聚合统计 |
| 渲染方式 | 整树 replaceChildren |
逐项增删节点 | 后者易漏状态同步,代码分支多 |
| 事件绑定 | 容器上委托 | 每行单独绑定 | 行被重建后监听器丢失 |
| 持久化时机 | 每次变更后立即写入 | 定时批量写入 | 关闭页面时可能丢失最后一次操作 |
| 存储格式 | JSON 数组 + 版本号键名 | 逗号分隔字符串 | 结构升级时无法兼容字段新增 |
讨论与局限。 整树重建会丢失行内输入焦点与滚动位置,任务量过千时需改为差量更新。另,localStorage 有 5 MB 上限且同步阻塞主线程,不适合存大对象;数据量超过该上限或需要按条件查询时改用 IndexedDB。
5 常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 刷新后数据消失 | 只在初始化时读取,变更未写入 | 每次变更后调用 save() |
| 勾选状态与数据不同步 | 用 click 监听复选框 |
用 change 事件读 checked |
| 筛选后无法新增 | 新增后未重置筛选条件 | 新增后把 filter 置回 all 或保持原值 |
| 同一任务出现两条 | id 用时间戳,快速连点重复 |
用 crypto.randomUUID() |
打开页面报 Unexpected token |
存储中被写入非 JSON 文本 | 用 try/catch 包裹解析并回退 |
6 结论
| 序号 | 结论 | 依据 |
|---|---|---|
| 1 | 单一状态源让筛选与统计只写一次 | 图 3-1 |
| 2 | 勾选状态用 change 事件读取 |
5 节 |
| 3 | 整树替换实现简单,代价是焦点丢失 | 表 4-1 |
| 4 | 持久化失败必须有回退路径 | 4 节步骤 4 |
- 上一篇:A08-DOM操作与事件
- 返回:A00-总结 | 科目 README
参考文献
[1] MDN Web Docs · Window.localStorage,https://developer.mozilla.org/zh-CN/docs/Web/API/Window/localStorage(访问于 2026-10-31)
[2] MDN Web Docs · 使用 data 属性,https://developer.mozilla.org/zh-CN/docs/Web/HTML/How_to/Use_data_attributes(访问于 2026-10-31)

评论 0
还没有评论,快来抢沙发吧~