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

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

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

第 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 章)。

text 复制代码
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,就像给每个人一个身份证号------位置怎么变,身份不变。 这是数据建模里最基础、也最容易被忽略的一条。

初始状态长这样:

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

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


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

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

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、圆角、过渡、无障碍都用上了。

css 复制代码
: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() 函数

先写"工具"和"状态"

js 复制代码
'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 的唯一性是很多逻辑的地基。

再写渲染层

js 复制代码
/* ===== 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' 排序只在"全部"视图有意义;筛选视图里禁用

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

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

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

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


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

为什么用"事件委托"

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

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

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

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

CRUD 与事件

js 复制代码
/* ===== 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) 旧数据读进来对不上,整个应用崩

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

js 复制代码
/* ===== 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);
    }
  },
};

启动时读回来

js 复制代码
/* ===== 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,界面自动跟上,不需要为每个功能单独写"更新界面"的代码。

js 复制代码
/* ===== 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

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

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

js 复制代码
li.innerHTML = '<span class="todo-text">' + todo.text + '</span>';

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

text 复制代码
<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() 里,我们做的正是这件事:

js 复制代码
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 被拖的元素 拖动结束(无论成功与否),用来收尾
js 复制代码
/* ===== 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 与版本判断
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 题(辨析) 判断下面四句话对错并说明理由。

text 复制代码
① 列表的交互应该给每个 <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() 里加一个按钮(放在删除按钮前面):

js 复制代码
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 委托里,多判断一种情况:

js 复制代码
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 处),并说明为什么。

js 复制代码
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():

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

修正后:

js 复制代码
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 题(思考 / 综合) 本章的拖拽排序在手机上完全不能用 ,而且只用拖拽也不符合无障碍要求。请回答:

text 复制代码
① 为什么在手机上不能用?根因是什么?
② 如果要支持手机拖拽,推荐用什么方案?
③ 为了满足 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 章「实战三:影视搜索应用」会带你做第三个项目:申请一个真实的影视数据接口,做出一个能搜电影、能翻页、能看详情的小应用。 你会遇到这些真实世界的问题------接口怎么读、请求发出去了要等多久(加载状态)、用户打字太快会发出几百个请求怎么办(防抖 )、网络断了怎么给用户一个交代(错误兜底 )。这是把你从"会写页面"带向"能接后端"的一章。

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

相关推荐
程序员大阳1 小时前
HTML+CSS+JS对话式实战(1)-开篇:认识我们要做的社团活动发布平台
javascript·css·html·实战·系统·平台·社团活动发布
一支蜉蝣1 小时前
Day26 XD安全学习笔记--PHP 应用&模版引用&Smarty渲染&MVC 模型&数据联动&RCE 安全
笔记·网络安全·html·php
速易达网络1 小时前
Three.js 动画入门教学:从场景搭建到流畅动效
开发语言·javascript·ecmascript
摹客2 小时前
【3D设计】界面设计中的3D元素:应用场景+5款工具横评(含Three.js)
前端
Hilaku2 小时前
CORS 到底在保护谁?90% 的前端理解错了跨域的本质
前端·javascript·程序员
用户298698530142 小时前
Python 将 Markdown 批量转为 HTML:单文件与目录遍历两种实现
python·html·markdown
wno7042 小时前
node.js中使用jquery
前端·node.js·jquery
阿琳a_2 小时前
b-map-viewer:面向 Vue 3 的地图可视化 SDK,从接入到展示再到交互分析
前端·javascript·vue.js·gis·数据可视化·地图
10年前端老司机2 小时前
Agent 商业化困局:热闹的发布会,冷清的付费客户
前端·后端·agent