A09 实战待办清单

2026-10-31 17:210 阅读约 16 分钟JavaScript

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 验证与讨论

  1. 添加三条任务,勾选一条,确认摘要显示「共 3 条,已完成 1 条」。
  2. 切到「未完成」,确认列表只显示两条;切回「全部」,确认三条都在。
  3. 刷新页面,确认任务与勾选状态均保留。
  4. 在控制台执行 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

参考文献

[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

0/2000

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