学过变量、数组和事件之后,最有用的练习是把它们连成一个可以反复使用的小工具。本篇做一份浏览器待办清单:添加任务、勾选完成、删除任务,并在普通刷新后保留数据。你会看到数据怎样变成界面,以及界面操作怎样回到数据。
先明确数据放在哪里
每条任务只保存两个字段:文字 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 地址时,存储行为可能因浏览器而异。
<!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、事件委托和更精细的更新,但第一版先把状态变化理清楚。
测试正常操作,也测试失败情况
- 添加两项不同任务,勾选第一项,刷新页面:任务和勾选状态应保留。
- 删除第二项再刷新:它不应重新出现。
- 输入几个空格:不应增加空任务。
- 输入
<b>测试</b>:列表应原样显示这段文字,而不是粗体“测试”。 - 在开发者工具中阻止或清除站点存储后再试:读取或保存失败时应出现提示,不能假装已经可靠保存。
下一步:让数据更容易带走
练习增加“只看未完成”筛选,注意不要把过滤后的下标直接用于删除原数组。再考虑加入 JSON 导出与导入:导入前检查结构,覆盖前让使用者确认。完成这些之后,才有必要考虑跨设备账号与云端同步。
继续查阅
关于存储范围、异常与 file 地址的差异,参见 MDN:Window.localStorage。本文示例不使用外部依赖,也不发送任务内容。