通过构建经典待办事项(To-Do List)应用,系统串联原生JavaScript基础语法、DOM增删查改、事件冒泡与委托机制以及localStorage持久化存储方案。
用原生JavaScript实现待办事项列表:基础语法综合实战
时间:2026-10-30
浏览:218
文章目录
待办事项列表(To-Do List)是检验前端基本功最直观的小型项目。本文不依赖任何第三方类库或打包工具,使用纯原生 HTML、CSS 和 JavaScript 构建一个具备增删、切换状态与本地数据持久化功能的待办应用。通过这个实战案例,可以一次性串联变量声明、数组操作、DOM 查询与节点创建、事件委托机制以及浏览器存储 API。
一、整体架构与开发流程
实现一个结构清晰的待办应用,推荐将数据状态与界面渲染解耦。传统的做法是直接在按钮点击时往列表中插入 HTML 字符串,这种方式一旦遇到稍复杂的交互就容易导致状态混乱。更为稳健的逻辑是:让用户操作去修改内存中的数据列表,再根据最新数据统一绘制页面。
二、构建页面结构与样式基石
HTML 负责表达功能结构,CSS 提供基础视觉区分。我们只需要一个表单(包含文本输入框和提交按钮)以及一个用于挂载条目的 <ul> 容器。
使用 <form> 包裹输入框的优势在于:用户输入完成后按下键盘回车键(Enter),表单会自动触发 submit 事件,无需再手动编写回车监听代码。关键骨架如下:
<!-- HTML 骨架 -->
<div class="todo-app">
<header>
<h1>我的待办清单</h1>
<form id="todo-form">
<input
type="text"
id="todo-input"
placeholder="写下你要做的事情..."
autocomplete="off"
required
/>
<button type="submit" id="add-btn">添加</button>
</form>
</header>
<ul id="todo-list" class="todo-list">
<!-- 动态由 JS 渲染待办条目 -->
</ul>
<footer class="todo-footer">
<span id="todo-count">未完成:0 项</span>
</footer>
</div>
在样式上,核心在于为完成状态赋予视觉反馈。当某个待办项被标记为完成时,为其添加 completed 类名,通过 text-decoration: line-through 展示删除线,并降低文字颜色透明度。
三、核心数据结构设计与 DOM 渲染
在编写 JavaScript 前,首先明确单个待办项的数据结构。我们使用数组容纳待办对象,每个对象包含三项基础属性:
id:时间戳或自增整数,作为唯一标识,用于准确查找和更新特定项;text:用户输入的文本描述;completed:布尔值,记录完成状态(true或false)。
很多初学者在此容易陷入一个误区:每次添加条目时,直接调用 ul.innerHTML += newHtml。这种做法看似省事,实则每次都会销毁整个列表内的已有 DOM 节点并重新构造,不仅丢失原有未提交的临时状态(如焦点),还会产生不必要的性能损耗。正确的做法是基于数组遍历,使用 document.createElement 或一次性组装 DocumentFragment 完成追加,或者在数据变化时统一定向渲染。
四、交互设计与事件委托机制
待办列表具有动态增删的特性:条目在页面加载时可能并不存在,而是由用户后续不断点击添加生成的。如果每生成一个 <li> 节点就为其内部的复选框与删除按钮单独调用一次 addEventListener,会带来两个明显缺陷:
- 内存占用随条目增多而线性上升;
- 每处新增逻辑中都必须记得编写绑定代码,容易遗漏。
利用浏览器的事件冒泡机制,我们可以采用事件委托(Event Delegation)模式:只在父级容器 <ul id="todo-list"> 上绑定一次监听器,通过检查事件对象的 event.target,判断用户点击的是复选框还是删除按钮,进而执行对应操作。
五、状态持久化与 LocalStorage 读写流
纯内存数组在网页刷新或关闭标签页后会立即重置。为了让用户记录的数据得到保存,需要借助浏览器提供的 localStorage API。该 API 仅支持存储键值字符串,因此在保存与恢复时需要进行 JSON 转换:
- 保存到本地:
localStorage.setItem('my_todos', JSON.stringify(todos)) - 从本地还原:
const data = JSON.parse(localStorage.getItem('my_todos') || '[]')
六、完整原生代码实现
下面给出完整的代码组织,将逻辑按关注点分为“状态初始化”、“持久化助手”、“渲染引擎”和“事件监听”四个清晰区块:
// 1. 获取关键 DOM 节点
const todoForm = document.getElementById('todo-form');
const todoInput = document.getElementById('todo-input');
const todoList = document.getElementById('todo-list');
const todoCount = document.getElementById('todo-count');
// 存储键名常量
const STORAGE_KEY = 'practical_todos_data';
// 2. 状态初始化:优先读取本地缓存,无数据则退化为空数组
let todos = loadTodos();
function loadTodos() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch (err) {
console.error('读取本地存储失败,重置为空数组:', err);
return [];
}
}
function saveTodos() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch (err) {
console.warn('保存到 localStorage 失败:', err);
}
}
// 3. 视图渲染函数:负责把 todos 数组完整映射为 DOM 节点
function render() {
// 清空现有内容
todoList.innerHTML = '';
if (todos.length === 0) {
const emptyNotice = document.createElement('li');
emptyNotice.className = 'todo-empty';
emptyNotice.textContent = '暂无待办事项,轻松一下吧!';
todoList.appendChild(emptyNotice);
} else {
// 遍历生成每个待办项
todos.forEach(todo => {
const li = document.createElement('li');
li.className = `todo-item ${todo.completed ? 'completed' : ''}`;
li.dataset.id = todo.id;
// 复选框
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'toggle-status';
checkbox.checked = todo.completed;
// 文本容器
const span = document.createElement('span');
span.className = 'todo-text';
span.textContent = todo.text; // 避免直接 innerHTML 拼接,防止 XSS
// 删除按钮
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-btn';
deleteBtn.textContent = '删除';
deleteBtn.setAttribute('aria-label', `删除待办:${todo.text}`);
li.append(checkbox, span, deleteBtn);
todoList.appendChild(li);
});
}
// 更新未完成计数
const activeCount = todos.filter(t => !t.completed).length;
todoCount.textContent = `未完成:${activeCount} 项`;
}
// 4. 事件监听一:添加待办(利用表单 submit 事件)
todoForm.addEventListener('submit', (e) => {
e.preventDefault(); // 阻止页面默认刷新行为
const text = todoInput.value.trim();
if (!text) return;
const newTodo = {
id: Date.now(), // 毫秒时间戳作为唯一主键
text: text,
completed: false
};
todos.push(newTodo);
saveTodos();
render();
todoInput.value = '';
todoInput.focus();
});
// 5. 事件监听二:利用事件委托集中处理勾选与删除
todoList.addEventListener('click', (e) => {
const target = e.target;
const parentLi = target.closest('li.todo-item');
if (!parentLi) return;
const targetId = Number(parentLi.dataset.id);
// 点击了状态切换复选框
if (target.classList.contains('toggle-status')) {
const targetTodo = todos.find(item => item.id === targetId);
if (targetTodo) {
targetTodo.completed = target.checked;
saveTodos();
render();
}
}
// 点击了删除按钮
if (target.classList.contains('delete-btn')) {
todos = todos.filter(item => item.id !== targetId);
saveTodos();
render();
}
});
// 6. 首屏首次执行渲染
render();
七、容易混淆的要点与避坑指南
在编写原生应用的过程中,有几个细节往往会成为逻辑漏洞的来源,需要特别留意:
- XSS 防护与 textContent: 展示用户输入的文本时,始终使用
node.textContent = text或对特殊字符做转义,切忌直接采用模板字符串拼进innerHTML(例如<span>${todo.text}</span>),否则一旦用户输入包含<img src=x onerror=alert(1)>,就会引发跨站脚本注入。 - 表单的 preventDefault: 若没有在
submit回调中调用e.preventDefault(),页面会在点击按钮时向当前地址发起 GET 请求并刷新页面,导致数据看似“一闪而过”没有被添加。 - dataset 属性的类型转换: 存放在 HTML 属性
dataset.id中的值全部为字符串。如果你的原始数据中id是数字类型(如Date.now()),在做全等对比(item.id === targetId)时务必记得用Number(parentLi.dataset.id)做显式类型转换,否则比较结果将始终为false。 - localStorage 的存储异常: 在无痕浏览模式或存储空间满载时,调用
localStorage.setItem可能抛出QuotaExceededError异常,使用try...catch包裹能够有效避免程序崩溃。
八、核心知识体系全景回顾
通过这个实战项目,我们可以把看似散落的原生 JavaScript 基础语法凝聚成一个具备实战价值的知识闭环:
综上所述,即便脱离庞大的现代前端开发框架,依靠清晰的“状态驱动渲染”逻辑和扎实的原生 API 使用,我们同样能写出结构优美、高效且健壮的应用程序。理解这套底层运行机理,不仅能够夯实编程基本功,更是未来深入掌握各种主流前端框架不可或缺的基石。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。