学过变量、数组和事件之后,最有用的练习是把它们连成一个可以反复使用的小工具。本篇做一份浏览器待办清单:添加任务、勾选完成、删除任务,并在普通刷新后保留数据。你会看到数据怎样变成界面,以及界面操作怎样回到数据。

先明确数据放在哪里

每条任务只保存两个字段:文字 text 和完成状态 done。任务数组保存在当前站点的 localStorage 中,没有服务器同步。换设备、换浏览器、清除站点数据,或某些隐私浏览设置,都可能让记录消失。因此它适合练习和临时清单,不适合保存唯一一份重要资料。

可以运行的完整示例

在空文件夹中新建 todo.html,填入以下内容。如果电脑已安装 Python 3,可在该文件夹打开终端运行 python3 -m http.server 8000 --bind 127.0.0.1,再访问 http://127.0.0.1:8000/todo.html。Windows 可根据安装方式把 python3 换成 py -3。保持相同地址测试;直接使用 file 地址时,存储行为可能因浏览器而异。

HTML
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>学习待办清单</title>
<h1>今天完成一小步</h1>
<form id="task-form">
  <label for="task">新的任务</label>
  <input id="task" maxlength="80" required autocomplete="off">
  <button>添加</button>
</form>
<p id="status" role="status"></p>
<ul id="tasks"></ul>
<script>
  const key = 'learning-tasks-v1';
  const list = document.querySelector('#tasks');
  const input = document.querySelector('#task');
  const status = document.querySelector('#status');
  let tasks = [];
  try {
    const saved = JSON.parse(localStorage.getItem(key) || '[]');
    // 存储数据也要检查,不能假设它永远符合预期。
    if (!Array.isArray(saved) || !saved.every(item =>
      item && typeof item.text === 'string' &&
      typeof item.done === 'boolean')) throw new Error('数据格式有误');
    tasks = saved;
  } catch (_) {
    status.textContent = '旧记录无法读取,当前清单从空列表开始。';
  }
  function save() {
    try {
      localStorage.setItem(key, JSON.stringify(tasks));
      status.textContent = `已保存,共 ${tasks.length} 项任务。`;
    } catch (_) {
      status.textContent = '保存失败,当前操作仅在本页有效。';
    }
  }
  function render() {
    list.replaceChildren();
    tasks.forEach((task, index) => {
      const item = document.createElement('li');
      const label = document.createElement('label');
      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.checked = task.done;
      checkbox.addEventListener('change', () => {
        task.done = checkbox.checked;
        save();
      });
      const text = document.createElement('span');
      // 用户输入作为文字展示,避免被当作 HTML 执行。
      text.textContent = task.text;
      label.append(checkbox, text);
      const remove = document.createElement('button');
      remove.textContent = '删除';
      remove.setAttribute('aria-label', `删除任务:${task.text}`);
      remove.addEventListener('click', () => {
        tasks.splice(index, 1);
        save(); render(); input.focus();
      });
      item.append(label, ' ', remove);
      list.append(item);
    });
  }
  document.querySelector('#task-form').addEventListener('submit', event => {
    event.preventDefault();
    const text = input.value.trim();
    if (!text) { status.textContent = '请输入任务内容。'; return; }
    tasks.push({text, done: false});
    save(); render(); input.value = ''; input.focus();
  });
  render();
</script>
</html>

把交互拆成三个阶段

添加任务时,先从输入框读取内容并去掉首尾空白,再修改数组,最后保存并重新绘制列表。这里让数组承担“当前数据”的角色,页面只是它的一种展示。将来加入筛选或统计时,就不必从页面文字反向猜测任务状态。

勾选只修改当前任务的完成状态,因此不重建整个列表,键盘焦点也能留在原复选框。删除会改变数组位置,所以示例重新绘制列表,并把焦点交回输入框。列表很大时可以使用稳定 ID、事件委托和更精细的更新,但第一版先把状态变化理清楚。

测试正常操作,也测试失败情况

  1. 添加两项不同任务,勾选第一项,刷新页面:任务和勾选状态应保留。
  2. 删除第二项再刷新:它不应重新出现。
  3. 输入几个空格:不应增加空任务。
  4. 输入 <b>测试</b>:列表应原样显示这段文字,而不是粗体“测试”。
  5. 在开发者工具中阻止或清除站点存储后再试:读取或保存失败时应出现提示,不能假装已经可靠保存。

下一步:让数据更容易带走

练习增加“只看未完成”筛选,注意不要把过滤后的下标直接用于删除原数组。再考虑加入 JSON 导出与导入:导入前检查结构,覆盖前让使用者确认。完成这些之后,才有必要考虑跨设备账号与云端同步。

继续查阅

关于存储范围、异常与 file 地址的差异,参见 MDN:Window.localStorage。本文示例不使用外部依赖,也不发送任务内容。