前端阶梯——第四十三章、实战二:待办清单To-Do

1 阅读35分钟

上一章,你做出了一个漂亮的官网。但它有个"致命"的问题:你刷新一下,它就什么都不记得了。

这就是"页面"和"应用"的分界线。页面负责展示,应用要能记住用户做过什么——你勾掉一个任务、加一条备忘,关掉浏览器再打开,它们还在。这一章,我们就来跨过这条线。

第 43 章「实战二:待办清单 To-Do」的目标是:不依赖任何框架,用原生 JavaScript 写一个真正能存数据的应用。 它不大,但"麻雀虽小,五脏俱全"——增删改查、筛选统计、本地持久化、拖拽排序,一个完整的 CRUD 应用该有的东西它都有。

更重要的是,你会学到两个"内功"级别的思想:

  • 数据是唯一真相(single source of truth)——先改数据,再让界面跟着数据重绘;
  • 防住用户输入(XSS 防护)——别人在输入框里敲的东西,绝不能直接当代码执行。

学完本章,你能:

  • 设计一个清晰的数据模型,并让它驱动整个界面;
  • 用事件委托优雅地处理列表里的增删改;
  • 用带版本号、带容错的封装把数据存进 localStorage,刷新不丢;
  • 做出筛选、统计、清除已完成、拖拽排序这些"像样应用"该有的细节;
  • 说清楚为什么渲染用户输入必须用 textContent,而不是 innerHTML。

43.1 需求拆解与功能清单

我们要做什么

做一个待办清单(To-Do):中间是任务列表,上面是输入框,下面是筛选和统计。功能清单如下。

#功能交互方式验收标准
1新增任务输入框回车 / 点"添加"空内容不能添加;添加后输入框清空
2标记完成点复选框完成项加删除线、文字变灰
3删除任务点右侧 ×该条从列表消失
4编辑任务双击文字变成输入框;回车保存、Esc 取消、失焦保存;清空即删除
5筛选全部 / 进行中 / 已完成列表只显示对应状态的任务
6统计底部显示剩余数显示"还剩 N 项待完成"
7清除已完成点击按钮一次删掉所有已完成;无已完成时按钮禁用
8持久化自动刷新页面后所有数据仍在
9拖拽排序拖动列表项可改变顺序,且顺序被存下来
10安全自动用户输入的特殊字符被当作纯文本,不产生脚本

📌 记忆点:这一章的核心只有一句话——"数据是唯一真相"。 传统写法是"用户点了删除,我直接把这个 <li> 从页面上删掉"。本章的写法是"用户点了删除,我先改数据,然后整体重绘一遍列表"。界面永远只是数据的一张"照片"。 照片脏了不要擦,重新拍一张就好。这个思想,是所有现代前端框架(Vue / React)的底层逻辑——你会在纯 JS 里先把它练明白。

前置知识与目录结构

本章需要:DOM 操作(第 26 章)、事件机制与事件委托(第 27 章)、localStorage(第 28 章)、数组的 filter / find / map(第 20 章)。

todo/
├── index.html          # 页面结构
├── css/
│   └── style.css       # 样式
└── js/
    └── app.js          # 全部逻辑

⚠️ 注意:请用"本地服务器"打开,别直接双击 HTML 文件。 localStorage 是按"源"(origin)隔离的。很多浏览器对 file:// 协议下的存储有特殊限制(甚至几个不同文件会共用同一份存储)。最稳的做法是跑一个本地服务器:在项目目录执行 python -m http.server 8080(或 npx serve),然后访问 http://localhost:8080。开发时这样跑,行为才和上线后一致。


43.2 数据模型设计:一个任务长什么样

先想清楚"数据",再想"界面"

写 UI 之前,先回答一个问题:一条任务,需要记住哪些信息?

字段类型含义为什么需要
idstring唯一标识增删改都要"精确定位"到某一条,不能用数组下标(排序后会错位)
textstring任务内容就是用户输入的文字
doneboolean是否已完成决定有没有删除线、要不要被筛选出来

除了"任务数组",界面还有一些不是任务、但会影响显示的状态:

状态取值作用
todos数组全部任务(唯一真相)
filter'all' / 'active' / 'done'当前看的是哪一类
editingIdstring 或 null哪一条正在被编辑

🔍 为什么 id 一定要有,而且不能用数组下标? 想象你删掉了列表第 2 项,原来的第 3 项就变成了新的第 2 项。如果界面靠"下标"来认定"我在改第几条",那么删一条之后,所有下标都会错位,编辑和删除就会"点错人"。给每条数据一个 id,就像给每个人一个身份证号——位置怎么变,身份不变。 这是数据建模里最基础、也最容易被忽略的一条。

初始状态长这样:

const state = {
  todos: [],        // 任务数组(唯一真相)
  filter: 'all',    // 当前筛选:'all' | 'active' | 'done'
  editingId: null,  // 正在编辑的任务 id,没有就是 null
};

✅ 正确做法:把所有"会变的状态"集中放在一个 state 对象里。 别把 todos、filter 散成好几个全局变量。集中放有三个好处:好找(都在一处)、好存(state.todos 直接序列化)、好清(调试时打印一个对象就看清全局)。这是"可预测"的第一步。


43.3 项目结构、HTML 骨架与样式

先写 HTML。注意:列表本身是空的——因为任务是我们用 JS 根据数据"画"出来的,HTML 里不需要预先写好任何一条。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>待办清单 To-Do</title>
  <link rel="stylesheet" href="css/style.css">
</head>
<body>
  <main class="app">
    <header class="app-header">
      <h1>待办清单</h1>
      <p class="app-sub">双击任务可编辑,拖动可排序</p>
    </header>

    <form class="todo-form" id="todo-form">
      <label class="sr-only" for="todo-input">输入新任务</label>
      <input
        class="todo-input"
        id="todo-input"
        type="text"
        maxlength="100"
        placeholder="想做点什么?回车添加"
        autocomplete="off">
      <button class="todo-add" type="submit">添加</button>
    </form>

    <div class="toolbar" role="group" aria-label="筛选任务">
      <button class="filter is-active" type="button" data-filter="all">全部</button>
      <button class="filter" type="button" data-filter="active">进行中</button>
      <button class="filter" type="button" data-filter="done">已完成</button>
      <button class="clear-done" id="clear-done" type="button">清除已完成</button>
    </div>

    <ul class="todo-list" id="todo-list" aria-live="polite"></ul>

    <p class="empty" id="empty" hidden>还没有任务,先在上面添加一个吧</p>

    <footer class="app-footer">
      <span id="count-left">0 项待完成</span>
    </footer>
  </main>

  <script src="js/app.js"></script>
</body>
</html>

这段 HTML 为什么这么写

写法作用为什么重要
<form> 包住输入框回车即提交用表单的 submit 事件,回车和点按钮走同一条逻辑,不用单独监听 keydown
<ul id="todo-list"> 空的列表由 JS 渲染体现"数据驱动":HTML 只留一个"容器"
aria-live="polite"列表变化时朗读读屏软件会播报新增 / 删除,无障碍
<label class="sr-only">给输入框一个可读的名字视觉上看不见,但读屏软件读得到
data-filter="all"把"意图"写在属性上JS 里用 dataset.filter 读,不用靠文字判断
<button type="button">明确不是提交按钮否则点它可能触发表单提交

完整样式

样式表不长,但它把前面几章学的 Flex、圆角、过渡、无障碍都用上了。

:root {
  --bg: #f4f6fb;
  --card: #ffffff;
  --ink: #1f2937;
  --muted: #6b7280;
  --line: #e5e7eb;
  --brand: #2563eb;
  --danger: #ef4444;
  --done: #9ca3af;
  --radius: 14px;
  --shadow: 0 12px 30px rgba(15, 23, 42, .08);
}

*,
*::before,
*::after { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100dvh;
  font-family: "Microsoft YaHei", system-ui, -apple-system, sans-serif;
  color: var(--ink);
  background: var(--bg);
  display: flex;
  justify-content: center;
  padding: clamp(1rem, 4vw, 3rem) 1rem;
}

/* 视觉上隐藏、但读屏软件可读 */
.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;
}

.app {
  width: 100%;
  max-width: 560px;
  height: fit-content;
  background: var(--card);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: clamp(1.25rem, 4vw, 2rem);
}

.app-header h1 { margin: 0; font-size: clamp(1.5rem, 5vw, 2rem); }
.app-sub { margin: .25rem 0 1.5rem; color: var(--muted); font-size: .9rem; }

/* 表单 */
.todo-form { display: flex; gap: .5rem; }
.todo-input {
  flex: 1;
  padding: .75rem 1rem;
  border: 1px solid var(--line);
  border-radius: 10px;
  font-size: 1rem;
  outline: none;
  transition: border-color .2s, box-shadow .2s;
}
.todo-input:focus { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(37, 99, 235, .15); }
.todo-add {
  border: 0;
  border-radius: 10px;
  padding: 0 1.25rem;
  background: var(--brand);
  color: #fff;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: background .2s;
}
.todo-add:hover { background: #1e40af; }

/* 工具栏(Flex 一维排列) */
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: 1.25rem 0; }
.filter {
  border: 1px solid var(--line);
  background: #fff;
  color: var(--muted);
  padding: .4rem .85rem;
  border-radius: 999px;
  font-size: .875rem;
  cursor: pointer;
}
.filter.is-active { background: var(--brand); border-color: var(--brand); color: #fff; }
.clear-done {
  margin-left: auto;
  border: 0;
  background: transparent;
  color: var(--danger);
  font-size: .875rem;
  cursor: pointer;
}
.clear-done:disabled { color: var(--line); cursor: not-allowed; }

/* 列表 */
.todo-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: .5rem; }
.todo-item {
  display: flex;
  align-items: center;
  gap: .75rem;
  padding: .7rem .85rem;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: #fff;
  cursor: grab;
}
.todo-item.is-dragging { opacity: .5; }
.todo-item.is-done .todo-text { color: var(--done); text-decoration: line-through; }

.todo-check { width: 18px; height: 18px; accent-color: var(--brand); cursor: pointer; }
.todo-text { flex: 1; cursor: text; word-break: break-word; }
.todo-edit {
  flex: 1;
  padding: .35rem .5rem;
  border: 1px solid var(--brand);
  border-radius: 8px;
  font-size: 1rem;
  outline: none;
}
.todo-del {
  border: 0;
  background: transparent;
  color: var(--muted);
  font-size: 1.25rem;
  line-height: 1;
  padding: 0 .25rem;
  border-radius: 6px;
  cursor: pointer;
}
.todo-del:hover { color: var(--danger); background: #fef2f2; }

/* 空状态与页脚 */
.empty { padding: 2rem 0; text-align: center; color: var(--muted); }
.app-footer {
  margin-top: 1.5rem;
  padding-top: 1rem;
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: .85rem;
}

/* 触屏上不显示"可拖拽"的手型(拖拽本身在触屏无效) */
@media (hover: none) {
  .todo-item { cursor: default; }
}

/* 无障碍:尊重"减少动效"偏好 */
@media (prefers-reduced-motion: reduce) {
  * { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}

验证这一步:用本地服务器打开页面,你会看到一个居中卡片:标题、"添加"输入框、三个筛选按钮、一行"0 项待完成"。列表区是空的——因为还没写 JS。这正常。


43.4 核心思想:数据是唯一真相

在写代码前,把这一章的"魂"讲透。

做交互界面,有两种思路:

对比直接操作 DOM(旧思路)数据驱动(本章思路)
点"删除"时找到那个 <li>,把它从页面删掉从数组里删掉那条数据,然后整体重绘
数据在哪分散在页面上(DOM 就是数据)集中在 state 里
改完还要做什么手动数一数剩几条、手动改统计重绘函数自己会算
出 bug 时数据和页面对不上了,极难查数据和页面永远一致
好处写得快(小场景)可预测、可维护,撑得住复杂逻辑

🔍 为什么"数据驱动"更靠谱? 想象"直接操作 DOM"的写法:用户删了一条,你要 ① 删掉那个元素 ② 更新剩余数量 ③ 如果删的是正在编辑的那条,还要退出编辑态 ④ 如果删完后列表空了,要显示空状态……四件事,少做一件就出 bug。 而"数据驱动"只需要一步:改 state.todos,然后调一次 render()。至于"数量怎么算、空状态要不要显示",都写在 render() 里,每次重绘都会重新算一遍,永远不会有"漏更新"。 记住这句话:先改数据,再重绘。 这是本章所有代码的骨架。

这一章的代码会分三块,各司其职:

模块职责关键函数
数据层存状态、改状态state、addTodo / toggleTodo / …
渲染层把数据"画"成界面render()、createItem()
持久化层把数据存到本地、读回来store.load() / store.save()

43.5 状态与渲染:写一个 render() 函数

先写"工具"和"状态"

'use strict';

/* ===== 1. 小工具:生成一个足够唯一的 id ===== */
function uid() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}

/* ===== 2. 状态:数据是唯一真相 ===== */
const state = {
  todos: [],
  filter: 'all',      // 'all' | 'active' | 'done'
  editingId: null,
};

/* ===== 3. 拿到所有要用到的 DOM 元素 ===== */
const els = {
  form: document.getElementById('todo-form'),
  input: document.getElementById('todo-input'),
  list: document.getElementById('todo-list'),
  empty: document.getElementById('empty'),
  countLeft: document.getElementById('count-left'),
  clearDone: document.getElementById('clear-done'),
  filters: Array.from(document.querySelectorAll('.filter')),
};

💡 提示:uid() 是什么,为什么不用自增数字? 它是"时间戳 + 随机数"拼出来的字符串,比如 lp3k9x2a7。为什么不用 1、2、3 自增? 因为自增 id 在"删除后新增"时可能重复(删了 3 再加一条,又变成 3),而用时间戳 + 随机数,碰撞概率极低。在真实项目里,id 的唯一性是很多逻辑的地基。

再写渲染层

/* ===== 4. 按当前筛选,挑出要显示的任务 ===== */
function visibleTodos() {
  if (state.filter === 'active') return state.todos.filter((t) => !t.done);
  if (state.filter === 'done') return state.todos.filter((t) => t.done);
  return state.todos;
}

/* ===== 5. 重绘整个界面 ===== */
function render() {
  const visible = visibleTodos();

  // ① 重绘列表:先清空容器,再按数据一条条"画"出来
  els.list.replaceChildren();
  visible.forEach((todo) => els.list.appendChild(createItem(todo)));

  // ② 空状态:没有可见任务时才显示
  els.empty.hidden = visible.length > 0;

  // ③ 统计剩余数量
  const left = state.todos.filter((t) => !t.done).length;
  els.countLeft.textContent =
    left === 0 ? '全部完成,太棒了' : '还剩 ' + left + ' 项待完成';

  // ④ 没有已完成任务时,禁用"清除已完成"
  els.clearDone.disabled = !state.todos.some((t) => t.done);

  // ⑤ 高亮当前选中的筛选按钮
  els.filters.forEach((btn) => {
    btn.classList.toggle('is-active', btn.dataset.filter === state.filter);
  });
}

/* ===== 6. 根据一条数据,造出一个 <li> ===== */
function createItem(todo) {
  const li = document.createElement('li');
  li.className = 'todo-item';
  li.dataset.id = todo.id;
  if (todo.done) li.classList.add('is-done');
  li.draggable = state.filter === 'all';   // 只在"全部"视图里能拖

  // 复选框
  const check = document.createElement('input');
  check.type = 'checkbox';
  check.className = 'todo-check';
  check.checked = todo.done;
  check.setAttribute('aria-label', todo.done ? '标记为未完成' : '标记为已完成');

  // 中间:编辑中显示输入框,否则显示文字
  let middle;
  if (todo.id === state.editingId) {
    middle = document.createElement('input');
    middle.className = 'todo-edit';
    middle.type = 'text';
    middle.value = todo.text;
    middle.maxLength = 100;
    middle.setAttribute('aria-label', '编辑任务内容');
    // 等这一轮渲染结束,再把焦点移进去
    queueMicrotask(() => { middle.focus(); middle.select(); });
  } else {
    middle = document.createElement('span');
    middle.className = 'todo-text';
    middle.textContent = todo.text;   // 关键:用 textContent,见 43.9
    middle.title = '双击可编辑';
  }

  // 删除按钮
  const del = document.createElement('button');
  del.type = 'button';
  del.className = 'todo-del';
  del.textContent = '×';
  del.setAttribute('aria-label', '删除任务');

  li.append(check, middle, del);
  return li;
}

这段为什么这么写

关键点说明
els.list.replaceChildren()一行清空容器。只清空"列表容器",绝不碰用户正在输入的输入框
visible.forEach(... createItem ...)数据有几条,就画几条——列表内容 100% 由数据决定
state.todos.filter((t) => !t.done).length剩余数量是每次重绘现算的,不存在"忘记更新"
queueMicrotask(() => middle.focus())编辑框刚造出来时还没进 DOM,直接 focus() 会失效,等一拍再聚焦
li.draggable = state.filter === 'all'排序只在"全部"视图有意义;筛选视图里禁用

🚫 误区:每次重绘都"全部重建",是不是很浪费性能? 对"待办清单"这种几十条的量级,完全不必担心——重绘一遍比你想的快得多,而且换来了"数据和界面绝对一致"这个巨大的好处。只有在列表成千上万行、且频繁更新时,才需要考虑"局部更新"或虚拟列表。 记住一条工程原则:先用最简单、最不容易出错的方案把功能做对,等真的慢了再优化。

验证这一步:先在浏览器控制台里手动给数据"塞"一条,看看界面会不会自己长出来。

state.todos.push({ id: uid(), text: '我的第一条任务', done: false });
render();

如果列表里出现了一条"我的第一条任务",说明渲染层通了——你已经能"用数据画出界面"。这一步是整个章节的基石。


43.6 增删改查:事件委托与交互

为什么用"事件委托"

列表是动态生成的:每新增一条,就多一个 <li>。如果给每个 <li> 都绑一次点击事件,新增时还得补绑,非常麻烦。

事件委托的思路是:不在子元素上绑,只在它们的共同父元素(这里是 <ul>)上绑一次。 点击时事件会冒泡到父元素,我们再用 e.target 判断"到底点到了哪个子元素"。

做法绑定次数动态新增的元素还有效吗
给每个 <li> 单独绑N 次无效,得手动补绑
事件委托(绑在 <ul> 上)1 次有效,新增的自动"继承"

📌 记忆点:事件委托 = "把监听器挂在父级,用 e.target 认出是谁触发的"。 它有两个好处:代码更少(只绑一次)、对动态元素天然有效(因为监听的是父级)。凡是"列表 / 动态内容"的交互,优先想事件委托——这是第 27 章最重要的实战技巧。

CRUD 与事件

/* ===== 7. 四个"改数据"的动作(改完统一 commit) ===== */
function addTodo(text) {
  const value = text.trim();
  if (!value) return;                       // 空白内容不加
  state.todos.unshift({ id: uid(), text: value, done: false });  // 新任务放最前
  commit();
}

function toggleTodo(id) {
  const t = state.todos.find((x) => x.id === id);
  if (!t) return;
  t.done = !t.done;
  commit();
}

function removeTodo(id) {
  state.todos = state.todos.filter((x) => x.id !== id);
  if (state.editingId === id) state.editingId = null;
  commit();
}

function updateTodo(id, text) {
  const value = text.trim();
  if (!value) { removeTodo(id); return; }   // 内容被清空 → 视为删除
  const t = state.todos.find((x) => x.id === id);
  if (!t) return;
  t.text = value;
  state.editingId = null;
  commit();
}

/* ===== 8. 一个统一的"提交"动作:存盘 + 重绘 ===== */
function commit() {
  store.save(state.todos);   // 持久化(43.7 定义)
  render();                  // 重绘
}

/* ===== 9. 绑定事件 ===== */
// 新增:表单提交(回车或点按钮都会触发)
els.form.addEventListener('submit', (e) => {
  e.preventDefault();
  addTodo(els.input.value);
  els.input.value = '';
  els.input.focus();
});

// 列表内的点击:只处理"删除"
els.list.addEventListener('click', (e) => {
  const li = e.target.closest('.todo-item');
  if (!li) return;
  if (e.target.matches('.todo-del')) removeTodo(li.dataset.id);
});

// 勾选 / 取消勾选
els.list.addEventListener('change', (e) => {
  if (!e.target.matches('.todo-check')) return;
  toggleTodo(e.target.closest('.todo-item').dataset.id);
});

// 双击文字 → 进入编辑
els.list.addEventListener('dblclick', (e) => {
  if (!e.target.matches('.todo-text')) return;
  state.editingId = e.target.closest('.todo-item').dataset.id;
  render();
});

// 编辑框:回车保存,Esc 取消
els.list.addEventListener('keydown', (e) => {
  if (!e.target.matches('.todo-edit')) return;
  const li = e.target.closest('.todo-item');
  if (e.key === 'Enter') {
    updateTodo(li.dataset.id, e.target.value);
  } else if (e.key === 'Escape') {
    state.editingId = null;
    render();
  }
});

// 编辑框失焦(点到别处)→ 也视为保存
els.list.addEventListener('focusout', (e) => {
  if (!e.target.matches('.todo-edit')) return;
  const li = e.target.closest('.todo-item');
  if (state.editingId === li.dataset.id) updateTodo(li.dataset.id, e.target.value);
});

这段为什么这么写

  • 每个动作都只做一件事:改数据,然后 commit()。 没有一处直接去操作页面元素——这就是"数据是唯一真相"落到代码里的样子。
  • commit() 把"存盘 + 重绘"打包,所以任何地方改了数据都不会忘记其中一步。
  • e.target.closest('.todo-item'):从"被点的小元素"往上找到整条任务,拿到 dataset.id。
  • e.target.matches('.todo-del'):判断被点的到底是删除按钮还是别的地方。
  • 失焦保存有个防重入:if (state.editingId === ...)。因为按回车保存后,编辑框会被重绘掉、触发 focusout,此时 editingId 已经是 null,就不会重复保存了。

验证这一步:现在可以完整地用一遍了——输入内容回车,出现新任务;点复选框,加删除线;点 ×,删除;双击文字,变成输入框,改完回车保存。但刷新一下,数据就全没了——因为我们还没写持久化。


43.7 持久化:带版本号与容错的 localStorage 封装

直接存行不行?

可以,但很脆。localStorage 有两个"坑":

坑表现后果
只存字符串直接存对象会变成 "[object Object]"读出来全是乱的
没有"版本"概念你哪天改了数据结构(比如 done 改名成 completed)旧数据读进来对不上,整个应用崩

所以我们要写一个带版本号、带容错的小封装。

/* ===== 10. 持久化:版本号 + 容错 + 数据清洗 ===== */
const STORAGE_KEY = 'todo-app';
const STORAGE_VERSION = 1;      // 数据结构一变,就把这个数字 +1

const store = {
  /** 读:读不到 / 版本不符 / 数据损坏 → 一律返回 null */
  load() {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (!raw) return null;

      const data = JSON.parse(raw);
      if (!data || data.version !== STORAGE_VERSION || !Array.isArray(data.todos)) {
        return null;                 // 不是"我这个版本"的数据,不认
      }

      // 逐条清洗:把结构不完整的脏数据过滤掉,并补齐字段类型
      return data.todos
        .filter((t) => t && t.id && typeof t.text === 'string')
        .map((t) => ({
          id: String(t.id),
          text: t.text,
          done: Boolean(t.done),
        }));
    } catch (err) {
      console.warn('[todo] 读取本地数据失败,改用空列表', err);
      return null;
    }
  },

  /** 写:带上版本号;失败(如空间不足)只告警,不崩 */
  save(todos) {
    try {
      localStorage.setItem(
        STORAGE_KEY,
        JSON.stringify({ version: STORAGE_VERSION, todos: todos })
      );
    } catch (err) {
      console.warn('[todo] 保存失败(可能是存储空间不足)', err);
    }
  },
};

启动时读回来

/* ===== 11. 启动:读回上次的数据,然后渲染 ===== */
(function init() {
  const saved = store.load();
  if (saved) state.todos = saved;
  render();
})();

这份封装为什么这么写

关键点说明
存成 { version, todos }把"数据"和"它属于哪个版本"一起存,将来升级格式时才有判断依据
data.version !== STORAGE_VERSION版本对不上就当没有,用空列表起步,而不是读出一堆错乱数据
try...catch 包住读和写JSON 损坏、空间不足(QuotaExceededError)都不会让页面白屏
.filter(...).map(...) 清洗就算存进去的是"半个脏数据",读出来也保证结构完整
读不到返回 null调用方一句 if (saved) 就能判断,不用到处写空值判断

📖 补充:为什么"版本号"这么重要? 假设今天是 v1,你存的数据里 done 是布尔值。过几天你想把它改成 status: 'done' | 'todo'。如果没版本号,旧的 {done:true} 读进来,代码里到处找 status,全是 undefined,列表直接坏掉。 有了版本号,v2 的代码一看"数据是 v1 的",就可以选择"忽略旧数据"或"写一段迁移逻辑把它转成 v2"。这叫数据迁移(migration),是真实应用绕不过去的一课。 你现在就把这个习惯装上,以后能省掉很多半夜排 bug 的时间。

⚠️ 注意:localStorage 大约只有 5MB,而且读写是"同步"的。 它够放"待办清单"这种小数据(几百条也就几十 KB),但不适合放图片、大段文本。而且它的读写会同步阻塞主线程——一次读几 MB 会明显卡顿。大量数据请改用 IndexedDB(第 28 章讲过选型)。

验证这一步:添加几条任务,刷新页面——它们应该都还在。打开 DevTools 的 Application → Local Storage,你能看到一条键为 todo-app 的数据,内容是 {"version":1,"todos":[...]}。这就是你的应用"记住事情"的证据。


43.8 筛选、统计与清除已完成

筛选和统计的逻辑,在 43.5 的 render() 里已经写好了——这正是"数据驱动"的妙处:改 state,界面自动跟上,不需要为每个功能单独写"更新界面"的代码。

/* ===== 12. 切换筛选 ===== */
els.filters.forEach((btn) => {
  btn.addEventListener('click', () => {
    state.filter = btn.dataset.filter;
    state.editingId = null;      // 换筛选时退出编辑态,避免错位
    render();
  });
});

/* ===== 13. 清除所有已完成 ===== */
els.clearDone.addEventListener('click', () => {
  const hasDone = state.todos.some((t) => t.done);
  if (!hasDone) return;
  state.todos = state.todos.filter((t) => !t.done);
  if (!state.todos.some((t) => t.id === state.editingId)) state.editingId = null;
  commit();
});

这段为什么这么写

关键点说明
筛选只改 state.filter一行数据改动,界面自动重排——render() 里的 visibleTodos() 负责挑
切换筛选时清空 editingId否则你在"全部"里编辑到一半,切到"已完成",编辑框可能挂在一条已隐藏的任务上
清除已完成用 filter 反选一行拿到"所有未完成",比一个个删干净
用 commit() 而不是 render()清除是会改数据的操作,要存盘;筛选只是换视图,不用存盘

✅ 正确做法:想清楚一个操作"要不要存盘"。 凡是会改变 state.todos 的(增 / 删 / 改 / 排序 / 清除),都要 commit();只是改变"看的方式"的(筛选、进入编辑态),只要 render()。 分不清这两者,就会出现"刷新后筛选状态莫名其妙带回来了"这类怪现象。筛选状态要不要存,是一种产品选择——本章选择"不存",刷新后回到"全部"。

验证这一步:添加几条任务,勾掉一条。切到"进行中",只看得到未完成的;切到"已完成",只看得到勾掉的;"全部"则都显示。底部"还剩 N 项待完成"会实时变化。点"清除已完成",勾掉的那些一次消失,且按钮在你没有已完成任务时是灰的(禁用)。


43.9 XSS 防护:为什么必须用 textContent

一个"看起来没问题"的写法

新手很容易这样写列表渲染:

li.innerHTML = '<span class="todo-text">' + todo.text + '</span>';

这行代码是一个安全漏洞。 假设用户在输入框里输入的不是普通文字,而是:

<img src=x onerror="alert('你的数据被偷走了')">

用 innerHTML 拼进去之后,这段字符串会被浏览器当成真正的 HTML 解析——那个 onerror 就会执行。攻击者可以用它读取 localStorage、跳转页面、伪造内容。这就是 XSS(跨站脚本攻击)。

两种写法对比

写法用户输入 <b>x</b>用户输入 <img onerror=...>安全吗
el.innerHTML = text显示加粗的 x执行脚本⛔ 危险
el.textContent = text显示字面的 <b>x</b>显示一串没有害处的文字✅ 安全

✅ 正确做法:渲染任何"来自用户的文本",一律用 textContent,别指望"手写转义"就够了。

  • 要显示纯文本 → 用 textContent(本章做法);
  • 真要显示富文本 / HTML → 别自己拼,用经过安全审计的库(如 DOMPurify)先"消毒",再用 innerHTML;
  • 那"我自己转义一下行不行"?不行。 手写转义(把 < 换成 &lt; 之类)非常容易漏,各种边界情况很难覆盖全;而且攻击不一定来自"这个用户自己",可能来自别人分享的链接或导入的数据。

一句话:innerHTML 只在"这段 HTML 完全由你自己写、且不含用户输入"时才安全。

在 43.5 的 createItem() 里,我们做的正是这件事:

middle.textContent = todo.text;   // 用户输入被当作纯文本,脚本不会执行

验证这一步:故意输入 <b>粗体</b>,保存后你看到的应该是字面的 <b>粗体</b>,而不是加粗的"粗体"。再输入 <img src=x onerror="alert(1)">,确认不会弹窗。这两个测试都通过,你的应用就是安全的。


43.10 拖拽排序与体验优化

用 HTML5 拖拽 API

它靠一组事件:draggable="true" 的元素被拖动时,会依次触发这些事件。

事件触发对象作用
dragstart被拖的元素记录"正在拖谁",并写入 dataTransfer
dragover滑过的元素必须 preventDefault(),否则 drop 不触发
drop松手的元素完成放置
dragend被拖的元素拖动结束(无论成功与否),用来收尾
/* ===== 14. 拖拽排序 ===== */
let dragId = null;

els.list.addEventListener('dragstart', (e) => {
  const li = e.target.closest('.todo-item');
  if (!li) return;
  dragId = li.dataset.id;
  li.classList.add('is-dragging');
  e.dataTransfer.effectAllowed = 'move';
  // Firefox 要求 dragstart 里至少 setData 一次,否则拖不动
  e.dataTransfer.setData('text/plain', dragId);
});

els.list.addEventListener('dragover', (e) => {
  e.preventDefault();                    // 关键:不写这句,drop 永远不会来
  e.dataTransfer.dropEffect = 'move';

  const li = e.target.closest('.todo-item');
  if (!li || li.dataset.id === dragId) return;

  // 判断鼠标在目标元素的"上半"还是"下半",决定插在前面还是后面
  const rect = li.getBoundingClientRect();
  const after = e.clientY > rect.top + rect.height / 2;

  const dragged = els.list.querySelector('[data-id="' + dragId + '"]');
  if (!dragged) return;
  if (after) li.after(dragged); else li.before(dragged);
});

els.list.addEventListener('drop', (e) => {
  e.preventDefault();
});

els.list.addEventListener('dragend', () => {
  const dragged = els.list.querySelector('.is-dragging');
  if (dragged) dragged.classList.remove('is-dragging');
  dragId = null;
  syncOrderFromDOM();                    // 把新的顺序写回数据
});

/* 从 DOM 当前的顺序,反向更新 state.todos */
function syncOrderFromDOM() {
  const ids = Array.from(els.list.children).map((li) => li.dataset.id);
  const map = new Map(state.todos.map((t) => [t.id, t]));
  const ordered = ids.map((id) => map.get(id)).filter(Boolean);
  state.todos.forEach((t) => {
    if (!ordered.includes(t)) ordered.push(t);   // 兜底:没在列表里的补回去
  });
  state.todos = ordered;
  commit();
}

这段为什么这么写

关键点说明
e.dataTransfer.setData('text/plain', id)Firefox 的硬性要求:dragstart 里不 setData 一次,拖拽根本不启动
dragover 里的 e.preventDefault()HTML5 拖拽最经典的坑:不写它,drop 永远不触发,而且不报错,只是"没反应"
用 getBoundingClientRect() 比中点鼠标在目标"下半部分"才插到后面,手感更符合直觉
拖动时实时移动 DOM这样能看到"拖动预览",而不是松手后才跳过去
dragend 里 syncOrderFromDOM()拖拽只改了 DOM 顺序,数据还没变——收尾时把 DOM 顺序"反向同步"回 state.todos,再 commit()

🔍 为什么是"从 DOM 反推数据",而不是"边拖边改数据"? 因为拖拽过程中位置变化极其频繁(鼠标每动一下都可能变),如果每次都改数据 + 重绘,会卡,而且中途状态很难控制。所以我们的策略是:拖动时只改 DOM(轻量、流畅);松手时一次性把结果写回数据(干净、可控)。这是"高频交互"常见的一种折中。

移动端怎么办:说清楚取舍

HTML5 拖拽 API 在手机上基本不可用——它基于鼠标事件,而触屏用的是另一套事件系统(很多人第一次遇到"电脑上能拖、手机上拖不动",就是这里)。

方案能用于触屏吗适用场景
HTML5 拖拽 API否(iPad Safari 13+ 长按是例外)桌面端简单列表排序
Pointer Events是(鼠标 / 触摸 / 触控笔统一)新项目的推荐做法,配 setPointerCapture() + CSS touch-action: none
Touch Events是需要多指手势等底层控制时
拖拽库(SortableJS 等)是复杂排序 / 跨列表拖拽,不想自己踩坑

📖 关于拖拽的现状(截至 2026 年 10 月): HTML5 拖拽 API 的事件(dragstart / dragover / drop 等)在桌面端早已全面支持,但在绝大多数手机浏览器上不会触发(因为手机没有"鼠标拖动"这个概念,触屏有独立的触摸事件体系;iPad Safari 13+ 是例外,它支持长按拖动)。 如果要在手机上做拖拽,推荐用 Pointer Events:一套 pointerdown / pointermove / pointerup 就能统一鼠标和触摸,配合 setPointerCapture() 让手指移出元素也继续跟踪,再用 CSS touch-action: none 阻止页面跟着滚动。本章示例是"桌面优先"的教学实现,移动端的 Pointer Events 版本留作拓展练习。

⚠️ 注意:只支持拖拽,是不达标的无障碍设计。 WCAG 2.5.7(Dragging Movements,AA 级)明确要求:所有靠拖拽完成的功能,都必须有一条"不靠拖拽"的操作路径。对着本章的排序功能,正确的做法是给每个任务加上"上移 / 下移"按钮(或用键盘快捷键)。这条要求不是"锦上添花",是硬性规范——做产品时请务必补上。

验证这一步:在桌面浏览器里,按住一条任务拖动,它会跟着鼠标在列表里上下移动;松手后顺序固定。刷新页面,新顺序还在。再在手机上打开同一个页面——拖拽会失效(这是预期内的,属 HTML5 拖拽的已知限制)。


43.11 验收清单

#检查项预期结果不通过怎么办
1新增回车 / 点"添加"都能加;空内容不加检查 addTodo 里的 if (!value) return 和 text.trim()
2输入框清空添加后输入框变空、焦点仍在检查 els.input.value = '' 与 els.input.focus()
3完成勾选后加删除线、文字变灰检查 commit() 里是否重绘、is-done 类是否加上
4删除点 × 该条消失检查 e.target.matches('.todo-del') 与 dataset.id
5编辑双击变输入框,回车/Esc/失焦行为正确检查 editingId 的进入与退出是否配对
6编辑清空把内容删空后确认,该条被删除检查 updateTodo 里的 if (!value) { removeTodo(id); return; }
7筛选三个按钮各显示对应子集,且高亮同步检查 visibleTodos() 与 .is-active 的切换
8统计"还剩 N 项"随操作实时变化检查 render() 里的 left 计算与 textContent 赋值
9清除已完成一次删掉所有已完成;无已完成时按钮禁用检查 clearDone.disabled 的计算
10持久化刷新后数据仍在检查 commit() 里有没有调 store.save
11版本容错手动改坏 localStorage 后刷新不白屏检查 store.load() 的 try...catch 与版本判断
12XSS 安全输入 <b>x</b> 显示字面文本,不弹窗确认渲染用的是 textContent 而非 innerHTML
13拖拽排序桌面端可拖动排序,且顺序被存下检查 dragover 的 preventDefault() 与 syncOrderFromDOM()
14无障碍列表变化能被读屏播报;排序有非拖拽入口检查 aria-live="polite";补"上移/下移"按钮

43.12 常见问题排查

现象可能原因解决办法
刷新后数据没了commit() 里没调 store.save,或改数据后没走 commit全局搜一下,凡是改 state.todos 的地方后面都要跟 commit()
控制台报 Unexpected tokenlocalStorage 里存了坏 JSON用 try...catch 包住 JSON.parse;或在 DevTools 里手动清掉那条键
读出来是 [object Object]存的时候没 JSON.stringify存之前序列化、读之后反序列化
输入 <b>x</b> 真的变粗体了用了 innerHTML改成 textContent
点删除没反应e.target 是小图标而非按钮本身,matches 没命中用 closest('.todo-item') 找整条,用 matches 判断类型
双击后编辑框不聚焦元素还没进 DOM 就 focus()用 queueMicrotask 或 setTimeout 延后一拍
回车保存后又多存了一次focusout 与 keydown 重复触发用 if (state.editingId === li.dataset.id) 防重入
拖不动dragover 里漏了 preventDefault()补上(这是头号原因,且不会报错)
Firefox 拖不动dragstart 里没 setData至少写一次 e.dataTransfer.setData('text/plain', id)
手机上拖不动HTML5 拖拽在触屏不生效这是已知限制,改用 Pointer Events 或拖拽库
排序后刷新顺序复原dragend 里没把 DOM 顺序同步回数据检查 syncOrderFromDOM() 是否执行
换筛选后编辑框串位切换筛选时没退出编辑态切换时把 state.editingId = null

🔍 卡住时,按"数据 → 渲染 → 持久化"三层来查。 ① 数据对不对? 在控制台 console.log(state.todos),看操作后数组有没有按预期变;② 渲染对不对? 直接手动调 render(),看界面重画后是否正确;③ 存储对不对? 在 DevTools 的 Application 面板看 localStorage 里存了什么。把问题定位到"哪一层坏了",比漫无目的地改代码高效十倍。


43.13 本章小结

  • 数据是唯一真相:先改 state,再让 render() 按数据重绘 UI;界面只是数据的一张"照片"。
  • 状态集中放:todos / filter / editingId 统一放在一个 state 对象里,好找、好存、好调试。
  • 每条数据要有 id:不能用数组下标定位(排序后会错位)。
  • render() 干四件事:按筛选画出列表、处理空状态、算统计、同步按钮状态——每次全量重算,所以不会漏更新。
  • 事件委托:监听器挂在 <ul> 上,用 e.target.closest() / matches() 识别子元素,对动态元素天然有效。
  • 统一 commit():改数据后"存盘 + 重绘"打包成一步;改数据用 commit,只换视图用 render。
  • 持久化要带版本号与容错:存 { version, todos };读取时校验版本、清洗数据、try...catch 兜底。
  • localStorage 只存字符串(要 JSON.stringify)、约 5MB、同步阻塞;大数据用 IndexedDB。
  • XSS 防护:渲染用户输入必须用 textContent,绝不能拼进 innerHTML。
  • HTML5 拖拽的两个坑:dragover 必须 preventDefault(),Firefox 必须在 dragstart 里 setData。
  • 拖拽在移动端不可用(iPad Safari 13+ 例外);新项目推荐 Pointer Events(配 touch-action: none 和 setPointerCapture())。
  • 拖拽排序的策略:拖动时只改 DOM(流畅),松手时把顺序同步回数据(可控)。
  • 无障碍是硬要求:WCAG 2.5.7 要求所有拖拽功能都要有非拖拽的操作路径。

43.14 拓展练习

第 1 题(概念) 请用两三句话,向一个没学过前端的朋友解释:"数据是唯一真相"到底是什么意思?它解决了什么问题?

参考答案

一句话版本:界面长什么样,不靠"手动去改页面",而靠"照着数据重新画一遍"。

打个比方:把数据想成一份名单,界面想成墙上的一张照片。老办法是"直接在照片上涂改"(删掉这个、改掉那个),改着改着照片就和真人对不上了。新办法是"名单一变,就重新拍一张照片"——照片永远和名单一致。

它解决的问题:

  • 一致性:不会有"数据删了、但页面还说有 3 条"这种对不上的情况;
  • 可维护性:新增一个功能,只要"改数据 + 让 render() 多算一样东西",不用到处找"还有哪块界面没更新";
  • 可预测:出了 bug,先看 state 对不对,再看渲染对不对,问题范围能快速缩小到一层。

补充:这正是 Vue / React 这些框架的底层思想。你在这一章用原生 JS 手写了一遍 state → render,以后理解框架的"响应式"会轻松得多。

第 2 题(辨析) 判断下面四句话对错并说明理由。

① 列表的交互应该给每个 <li> 单独绑定事件,这样最精确。
② 排序、删除这类操作,可以用数组下标来定位是第几条。
③ 渲染用户输入时,用 textContent 比 innerHTML 更安全。
④ localStorage 可以直接存对象和数组。
参考答案
#判断理由
①⛔ 错应该用事件委托——监听器挂在父级 <ul> 上,只绑一次。给每个 <li> 单独绑,不仅代码多,而且动态新增的元素还得手动补绑,极易漏掉
②⛔ 错必须用 id 定位,不能用下标。 删掉一条后,后面所有元素的下标都会前移,用下标会"点错人"。id 是稳定的身份证号,位置变了它也不变
③✅ 对innerHTML 会把字符串当 HTML 解析,用户输入的 <img onerror=...> 会真正执行脚本(XSS)。textContent 把它当纯文本,脚本不会执行
④⛔ 错localStorage 只存字符串。直接存对象会变成 "[object Object]",必须 JSON.stringify 后再存、JSON.parse 后再用

一句话总结:"事件委托绑父级、定位要用 id、渲染文本用 textContent、存对象先序列化。"

第 3 题(实战) 给应用加一个"双击任务进入编辑"之外的新入口:给每条任务加一个"编辑"按钮(点它也能进入编辑态)。请写出你需要改的最小代码。

参考答案

思路:这正好又用上"数据驱动 + 事件委托"。我们只要 ① 在 createItem 里多造一个按钮;② 在 click 委托里多处理一种情况。

改动一:createItem() 里加一个按钮(放在删除按钮前面):

const edit = document.createElement('button');
edit.type = 'button';
edit.className = 'todo-edit-btn';
edit.textContent = '改';
edit.setAttribute('aria-label', '编辑任务');
// ... 造好删除按钮后:
li.append(check, middle, edit, del);

改动二:在已有的 click 委托里,多判断一种情况:

els.list.addEventListener('click', (e) => {
  const li = e.target.closest('.todo-item');
  if (!li) return;

  if (e.target.matches('.todo-del')) {
    removeTodo(li.dataset.id);
  } else if (e.target.matches('.todo-edit-btn')) {
    state.editingId = li.dataset.id;   // 和双击走的是同一套状态
    render();                          // 重绘后,那条就变成输入框了
  }
});

要点:

  • 不用改渲染逻辑——因为"是不是编辑态"完全由 state.editingId 决定。你只要改这个数据,render() 自然会把那一条画成输入框。这就是"数据驱动"最爽的地方:新增一个入口,往往只需要动数据,不用动渲染。
  • 不要用 prompt() 弹窗来编辑。它会阻塞主线程(页面卡住),体验差,而且无法校验。能用页面内的输入框,就不要用 alert / prompt。
  • 如果想用更现代的弹窗,可以了解 <dialog> 元素:它的 showModal() 会自带焦点管理、Esc 关闭、顶层显示,截至 2026 年 10 月已是 Baseline Widely Available(Chrome 37+ / Edge 79+ / Firefox 98+ / Safari 15.4+),是替代 alert 的好选择。

第 4 题(调试) 小美的待办应用"新增、删除都正常,但一刷新,数据全没了"。相关代码如下,请找出问题(至少 1 处),并说明为什么。

function addTodo(text) {
  const value = text.trim();
  if (!value) return;
  state.todos.unshift({ id: uid(), text: value, done: false });
  render();          // ← 只重绘
}

function removeTodo(id) {
  state.todos = state.todos.filter((x) => x.id !== id);
  render();          // ← 只重绘
}
参考答案

问题:所有"改数据"的地方都只调了 render(),没有调 store.save()——数据改了,但从来没写进 localStorage。 所以页面看着是对的,一刷新,内存里的 state 被清空,自然什么都没了。

为什么要用 commit() 统一处理: 这正是我们在 43.6 做的事——把"存盘 + 重绘"打包成一个 commit():

function commit() {
  store.save(state.todos);   // ① 先存盘
  render();                  // ② 再重绘
}

修正后:

function addTodo(text) {
  const value = text.trim();
  if (!value) return;
  state.todos.unshift({ id: uid(), text: value, done: false });
  commit();          // ✅ 存盘 + 重绘一起做
}

function removeTodo(id) {
  state.todos = state.todos.filter((x) => x.id !== id);
  if (state.editingId === id) state.editingId = null;
  commit();          // ✅ 同上
}

教训:只要出现"某处改了数据、另一处忘了配套动作"的现象,就说明这个"动作组合"应该被封装起来。 这里封装成 commit(),就不会再有"改了数据忘了存盘"的问题。

一个排查技巧:在 commit() 里加一行 console.log('saved', state.todos),然后操作几下、看控制台有没有输出——没输出,就说明你走的那条路径根本没存盘。

第 5 题(思考 / 综合) 本章的拖拽排序在手机上完全不能用,而且只用拖拽也不符合无障碍要求。请回答:

① 为什么在手机上不能用?根因是什么?
② 如果要支持手机拖拽,推荐用什么方案?
③ 为了满足 WCAG 的要求,最少要补上什么?
参考答案

① 为什么手机上不能用?

根因:HTML5 拖拽 API 基于"鼠标拖动"模型(dragstart / dragover / drop),而触屏用的是另一套独立的触摸事件体系(touchstart / touchmove / touchend)。 手机浏览器不会因为手指滑动就去触发 dragstart,所以拖拽事件在手机上一个都不来。(例外:iPad Safari 13+ 支持长按拖动。)

② 推荐方案:Pointer Events。

它把鼠标、触摸、触控笔统一成一套事件(pointerdown / pointermove / pointerup),一套代码通吃桌面和手机。关键配套两点:

  • setPointerCapture(e.pointerId):让手指滑出元素范围后,事件仍锁定在这个元素上,拖拽不会中断;
  • CSS touch-action: none:告诉浏览器"这块区域的手势由我的 JS 处理,别拿去做页面滚动"。

一句话:新项目做拖拽,优先 Pointer Events,而不是 HTML5 拖拽 API。

③ 为满足 WCAG,最少要补什么?

补一条"不依赖拖拽"的操作路径。WCAG 2.5.7(Dragging Movements,AA)要求的原话精神是:凡是用拖拽能完成的功能,都要能用非拖拽的方式完成。 对排序来说,最常见、最省事的做法就是——给每条任务加"上移 / 下移"两个按钮(或者支持键盘快捷键)。

为什么这不是"可选"的:对无法使用鼠标 / 触屏拖拽的人(运动功能障碍、只能键盘操作的用户)来说,如果排序只能靠拖拽,那这个功能对他们就是完全不可用的。"能用键盘做完,才算真的做完。"

拓展方向:把"上移 / 下移"按钮和拖拽共用同一套数据操作(都是"交换两条数据的位置"),这样两种入口永远不会出现行为不一致——又一次回到那条主线:让所有入口都只改数据。


下一章预告:接通真实世界的数据。 待办清单的数据,是你自己敲进去的;但真实的应用,数据往往来自别人服务器上的接口。

第 44 章「实战三:影视搜索应用」会带你做第三个项目:申请一个真实的影视数据接口,做出一个能搜电影、能翻页、能看详情的小应用。 你会遇到这些真实世界的问题——接口怎么读、请求发出去了要等多久(加载状态)、用户打字太快会发出几百个请求怎么办(防抖)、网络断了怎么给用户一个交代(错误兜底)。这是把你从"会写页面"带向"能接后端"的一章。

说一声「继续」,我们就往下走。