上一章,你做出了一个漂亮的官网。但它有个"致命"的问题:你刷新一下,它就什么都不记得了。
这就是"页面"和"应用"的分界线。页面负责展示,应用要能记住用户做过什么——你勾掉一个任务、加一条备忘,关掉浏览器再打开,它们还在。这一章,我们就来跨过这条线。
第 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 之前,先回答一个问题:一条任务,需要记住哪些信息?
| 字段 | 类型 | 含义 | 为什么需要 |
|---|---|---|---|
id | string | 唯一标识 | 增删改都要"精确定位"到某一条,不能用数组下标(排序后会错位) |
text | string | 任务内容 | 就是用户输入的文字 |
done | boolean | 是否已完成 | 决定有没有删除线、要不要被筛选出来 |
除了"任务数组",界面还有一些不是任务、但会影响显示的状态:
| 状态 | 取值 | 作用 |
|---|---|---|
todos | 数组 | 全部任务(唯一真相) |
filter | 'all' / 'active' / 'done' | 当前看的是哪一类 |
editingId | string 或 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;- 那"我自己转义一下行不行"?不行。 手写转义(把
<换成<之类)非常容易漏,各种边界情况很难覆盖全;而且攻击不一定来自"这个用户自己",可能来自别人分享的链接或导入的数据。一句话:
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()让手指移出元素也继续跟踪,再用 CSStouch-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 与版本判断 |
| 12 | XSS 安全 | 输入 <b>x</b> 显示字面文本,不弹窗 | 确认渲染用的是 textContent 而非 innerHTML |
| 13 | 拖拽排序 | 桌面端可拖动排序,且顺序被存下 | 检查 dragover 的 preventDefault() 与 syncOrderFromDOM() |
| 14 | 无障碍 | 列表变化能被读屏播报;排序有非拖拽入口 | 检查 aria-live="polite";补"上移/下移"按钮 |
43.12 常见问题排查
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 刷新后数据没了 | commit() 里没调 store.save,或改数据后没走 commit | 全局搜一下,凡是改 state.todos 的地方后面都要跟 commit() |
控制台报 Unexpected token | localStorage 里存了坏 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 章「实战三:影视搜索应用」会带你做第三个项目:申请一个真实的影视数据接口,做出一个能搜电影、能翻页、能看详情的小应用。 你会遇到这些真实世界的问题——接口怎么读、请求发出去了要等多久(加载状态)、用户打字太快会发出几百个请求怎么办(防抖)、网络断了怎么给用户一个交代(错误兜底)。这是把你从"会写页面"带向"能接后端"的一章。
说一声「继续」,我们就往下走。