学习前提

理解数组、稳定ID、DOM事件、JSON和本地存储;使用独立练习文件。

本课完成后你能做什么

完成添加、勾选、删除和刷新恢复,并理解状态与DOM的单向关系。

先理解机制

第一阶段定义数据模型:每条任务有稳定id、text和done,数组是唯一事实来源。DOM只是数组的展示结果。删除应按id定位,不能依赖当前展示索引,否则排序或筛选后容易删错记录。

第二阶段实现动作:添加先trim并排除空值,勾选更新done,删除移除对应记录。每次动作后统一保存并渲染。使用createElement和textContent展示任务文字,防止输入被当HTML解释。

第三阶段持久化:启动时读取自己的localStorage键,捕获读写异常。示例对数组及字段进行基础过滤,但生产版本还应限制文本长度和条目数量。保存失败时内存仍可使用,应提示刷新后可能丢失。

第四阶段完善体验:显示剩余数量,输入成功后清空并保留焦点;删除导致焦点对象消失时,应移到合理位置。这个项目刻意不包含账号、跨设备同步和协同编辑,localStorage不能提供这些能力。

可运行示例

<!doctype html>
<html lang="en"><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>My tasks</title><style>
body{max-width:600px;margin:40px auto;padding:20px;font:18px/1.6 system-ui}
form{display:flex;gap:8px;flex-wrap:wrap}input,button{font:inherit;padding:8px;max-width:100%;box-sizing:border-box}
li{margin:14px 0}li button{margin-left:12px}input[type=checkbox]{margin-right:10px}
</style></head><body><h1>My tasks</h1>
<form id="form"><label for="task">New task</label>
<input id="task" required maxlength="120"><button>Add</button></form>
<p id="status" role="status"></p><p id="remaining"></p><ul id="list"></ul>
<script>
const key = "honeysuck-demo-tasks";
const list = document.querySelector("#list");
const status = document.querySelector("#status");
let tasks = [];
try {
  const saved = JSON.parse(localStorage.getItem(key) || "[]");
  if (Array.isArray(saved)) tasks = saved.filter(t => t && typeof t.text === "string" && t.text.trim() && t.text.length <= 120).slice(0, 200).map(t => ({ id: crypto.randomUUID(), text: t.text, done: t.done === true }));
} catch { status.textContent = "Starting without saved tasks."; }
function save() {
  try { localStorage.setItem(key, JSON.stringify(tasks)); }
  catch { status.textContent = "Storage unavailable. Tasks last until this page closes."; }
}
function render() {
  document.querySelector("#remaining").textContent = `${tasks.filter(t => !t.done).length} remaining`;
  list.replaceChildren();
  tasks.forEach(task => {
    const li = document.createElement("li");
    const label = document.createElement("label");
    const check = document.createElement("input");
    check.type = "checkbox"; check.checked = Boolean(task.done);
    const text = document.createElement("span"); text.textContent = task.text;
    text.style.textDecoration = task.done ? "line-through" : "none";
    check.addEventListener("change", () => { task.done = check.checked; save(); render(); const checks = list.querySelectorAll("input"); const position = tasks.findIndex(t => t.id === task.id); checks[position]?.focus(); });
    const remove = document.createElement("button");
    remove.type = "button"; remove.textContent = "Delete";
    remove.setAttribute("aria-label", "Delete " + task.text);
    remove.addEventListener("click", () => { tasks = tasks.filter(t => t.id !== task.id); save(); render(); document.querySelector("#task").focus(); });
    label.append(check, text); li.append(label, remove); list.append(li);
  });
}
document.querySelector("#form").addEventListener("submit", event => {
  event.preventDefault();
  const input = document.querySelector("#task"); const text = input.value.trim();
  if (!text || text.length > 120 || tasks.length >= 200) { status.textContent = "Enter 1–120 characters; maximum 200 tasks."; return; }
  tasks.push({id:crypto.randomUUID(), text, done:false}); input.value = ""; status.textContent = "Task added.";
  save(); render(); input.focus();
});
render();
</script></body></html>

推演与验证

按序验收:空格输入不新增;连续添加同名任务仍能分别勾选和删除;刷新后保留状态;把输入改为HTML片段应只显示文字;损坏本项目存储键后刷新不会白屏。最后检查键盘和320像素布局。

常见错误与边界

不要使用localStorage.clear,它会删除同源其他功能的数据。示例采用简单ID策略适合单页练习,跨标签页或服务端同步需要更明确的冲突策略。重复渲染可能丢失焦点,应把焦点恢复纳入迭代。 本例使用crypto.randomUUID,需要HTTPS或localhost等安全上下文。恢复时重新分配本页唯一id;跨标签页同步不在本例范围。

动手练习

增加全部、未完成、已完成筛选,并确保删除操作仍按稳定id执行。筛选状态可以仅保存在内存。

参考解法与理由

保留完整tasks数组,render前用filter派生可见集合,不要把筛选结果覆盖原数组。剩余数量从全部任务计算;空状态区分“没有任务”和“该筛选下没有任务”。

项目深化:保存失败时不能假装已经保存

待办应用里的“添加成功”和“已经持久保存”不是同一个状态。浏览器可能禁用存储或容量不足。把存储作为参数传入,使用会失败的测试替身,才能稳定复现故障。下例没有写入真实localStorage。

在现代浏览器Console中独立运行这个实验;它不替换上方主示例。

function saveItems(storage, items) {
  try {
    storage.setItem('todo-v1', JSON.stringify({version: 1, items}));
    return {saved: true, message: 'Saved'};
  } catch {
    return {saved: false, message: 'Not saved: keep this tab open and export your data'};
  }
}
const memory = new Map();
const working = {setItem: (key, value) => memory.set(key, value)};
const failing = {setItem: () => { throw new Error('Storage unavailable'); }};
const items = [{id: 'a', title: 'Learn HTML', done: false}];
console.log(saveItems(working, items).saved);
console.log(saveItems(failing, items).saved);
console.log(JSON.parse(memory.get('todo-v1')).items.length);
console.assert(items.length === 1);

预期观察

输出true、false、1。第二次失败没有删除内存里的事项,也没有改变先前测试存储的内容。

原因、取舍与适用边界

把返回状态接到页面中可见的保存提示;若只是console.log,普通用户仍会误以为关页安全。真实项目可以提供JSON导出,让用户在退出前另存;导入时必须重新校验版本、ID、长度及类型,不能把文件内容直接innerHTML展示。删除最后一项与读取失败也必须区别处理:前者是合法空列表,后者应保留恢复机会。

进阶练习

设计手工验收表,至少覆盖正常保存、写入失败、刷新恢复和坏JSON。

解法与推理

正常保存后刷新应恢复相同ID;注入写入失败时页面保留事项并明确未保存;坏JSON不自动覆盖原值;合法空列表恢复为空态。还应验证输入带HTML标签时只显示文字,两个同名事项仍可按ID独立删除。

进一步阅读

完整源码与验收

下载完整项目源码(.html.txt)

下载后在独立练习目录另存为.html文件,用浏览器打开。文件包含上方完整主项目,后面的深入实验独立练习;数据保存在对应浏览器来源中,不是服务端账号。