本教程通过手写一个完整的待办事项(TodoList)应用,深入讲解JavaScript中的数据驱动UI、DOM事件委托、本地持久化与XSS防御等核心实战技能。
JavaScript实战项目教程:手写一个待办事项列表应用
时间:2026-10-30
浏览:221
文章目录
手写一个待办事项(TodoList)应用是检验与巩固原生 JavaScript 基础的黄金项目。它并非简单的界面堆砌,而是把“状态管理驱动视图渲染”、“DOM 事件委托机制”以及“本地持久化”有机结合的典型实操。通过这个实战,你将摆脱零散拼凑 DOM 节点的初级阶段,学会以数据为核心组织前端交互逻辑。
一、整体架构与设计思路
传统初学者在编写此类功能时,往往倾向于在用户点击按钮时直接创建 <li> 并插入页面。这种做法虽然直观,但在面对状态切换、多维度筛选以及离线存储时会产生大量难以维护的 DOM 操作分支。更规范的做法是采用“单向数据流与数据驱动渲染”:用户的一切交互只修改内存中的数组状态,再统一由渲染函数根据最新数据生成页面结构,并同步至浏览器的 localStorage。
二、第一步:构建语义化页面骨架与样式
在编写业务脚本前,需要准备清晰直观的 HTML 结构。我们将页面划分成输入表单、过滤筛选标签栏以及存放事项的列表容器:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>待办事项清单</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="todo-app">
<header class="app-header">
<h1>我的待办清单</h1>
<form id="todo-form" class="todo-form">
<input type="text" id="todo-input" placeholder="写下今天要完成的任务..." autofocus>
<button type="submit" id="add-btn">添加</button>
</form>
</header>
<section class="filter-controls">
<button class="filter-btn active" data-filter="all">全部</button>
<button class="filter-btn" data-filter="active">进行中</button>
<button class="filter-btn" data-filter="completed">已完成</button>
</section>
<ul id="todo-list" class="todo-list">
<!-- 动态由 JavaScript 渲染数据项 -->
</ul>
</div>
<script src="app.js"></script>
</body>
</html>
配套样式重点解决两件事:一是输入框与按钮的整齐对齐,二是事项勾选后赋予删除线和弱化色彩效果:
/* 关键 CSS 片段 */
.todo-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background-color: #ffffff;
border-bottom: 1px solid #e2e8f0;
}
.todo-item.completed .item-text {
text-decoration: line-through;
color: #94a3b8;
}
.delete-btn {
background-color: #ef4444;
color: #ffffff;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
}
三、第二步:标准化开发流程与数据模型
在编写具体函数之前,首先确立数据结构。每个待办事项都应该是一个独立的对象,包含唯一标识 id、事项文本 text、完成布尔值 completed 以及创建时间戳:
// 内存数据格式示例
const sampleTodo = {
id: "todo-1729600000000",
text: "完成 JavaScript 实战教程手写",
completed: false,
createdAt: 1729600000000
};
有了清晰的数据规范,整个开发过程将严格按照“生命周期五步法”进行推进:
四、第三步:核心 JavaScript 逻辑逐行实现
1. 初始化状态与获取 DOM 节点
在 app.js 顶部完成节点捕获与状态变量定义。这里使用一个独立的键名常量防止拼写失误:
// app.js
const STORAGE_KEY = "HANDMADE_TODOS_STORAGE_V1";
// 内存状态
let todos = [];
let currentFilter = "all";
// 缓存 DOM 节点引用
const todoForm = document.getElementById("todo-form");
const todoInput = document.getElementById("todo-input");
const todoList = document.getElementById("todo-list");
const filterButtons = document.querySelectorAll(".filter-btn");
2. 数据持久化的读取与写入封装
为确保对 localStorage 的操作安全,必须对读取过程进行异常保护,防止因存储了脏数据导致整个页面报错崩溃:
// 安全读取本地存储
function loadTodos() {
try {
const rawData = localStorage.getItem(STORAGE_KEY);
return rawData ? JSON.parse(rawData) : [];
} catch (error) {
console.warn("读取本地待办缓存失败,已回退至空数据:", error);
return [];
}
}
// 保存数据至本地
function saveTodos() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
} catch (error) {
console.error("写入 localStorage 失败,可能超出配额:", error);
}
}
3. 新增待办事项
监听表单的 submit 事件而非添加按钮的 click 事件。这样用户在输入框内按下回车键(Enter)也能自然触发提交:
todoForm.addEventListener("submit", function (event) {
event.preventDefault(); // 阻止浏览器刷新页面
const text = todoInput.value.trim();
if (!text) {
return; // 输入为空或全是空格时忽略
}
const newTodo = {
id: "item_" + Date.now() + "_" + Math.random().toString(36).substr(2, 5),
text: text,
completed: false,
createdAt: Date.now()
};
// 1. 更新数据源
todos.unshift(newTodo); // 新任务置顶
// 2. 同步存储
saveTodos();
// 3. 重新渲染
render();
// 清空输入框并重新聚焦
todoInput.value = "";
todoInput.focus();
});
五、第四步:事件委托机制与交互判定
在待办列表中,每个任务项都有勾选复选框和删除按钮。如果每生成一个 <li> 都给它内部的按钮分别绑定 addEventListener,列表变长时会创建大量监听器,且删除节点时如果未解绑容易导致内存泄漏。
最佳实践是使用事件委托(Event Delegation):利用 DOM 事件冒泡机制,只在父容器 ul#todo-list 上监听一次点击事件,通过 e.target 判断点击的具体子元素:
利用 dataset 属性进行动作派发和定位对应项:
// 在列表父容器上进行事件委托监听
todoList.addEventListener("click", function (event) {
const target = event.target;
const action = target.dataset.action;
if (!action) return;
// 通过最近的 .todo-item 祖先元素获取事项 ID
const itemElement = target.closest(".todo-item");
if (!itemElement) return;
const id = itemElement.dataset.id;
if (action === "toggle") {
// 切换完成状态
toggleTodoStatus(id);
} else if (action === "delete") {
// 执行删除
deleteTodo(id);
}
});
// 纯数据操作函数
function toggleTodoStatus(id) {
const targetTodo = todos.find(item => item.id === id);
if (targetTodo) {
targetTodo.completed = !targetTodo.completed;
saveTodos();
render();
}
}
function deleteTodo(id) {
todos = todos.filter(item => item.id !== id);
saveTodos();
render();
}
六、第五步:防 XSS 注入的安全渲染函数
许多初级教程为了省事,直接使用 innerHTML += `<span>${todo.text}</span>`。如果用户输入的待办内容包含 <img src="x" onerror="alert(1)"> 等恶意代码,就会立刻触发跨站脚本注入(XSS)。
规范做法是使用 document.createElement 生成元素节点,并通过 textContent 属性为文本赋值。浏览器会对 textContent 中的特殊字符做原生转义,确保安全:
function render() {
// 1. 清空当前容器
todoList.innerHTML = "";
// 2. 根据当前筛选状态过滤
const filteredTodos = todos.filter(item => {
if (currentFilter === "active") return !item.completed;
if (currentFilter === "completed") return item.completed;
return true; // "all"
});
// 3. 边界处理:空状态展示
if (filteredTodos.length === 0) {
const emptyLi = document.createElement("li");
emptyLi.className = "empty-notice";
emptyLi.textContent = "暂无相关待办事项";
emptyLi.style.textAlign = "center";
emptyLi.style.color = "#94a3b8";
emptyLi.style.padding = "20px 0";
todoList.appendChild(emptyLi);
return;
}
// 4. 批量创建节点并挂载
const fragment = document.createDocumentFragment();
filteredTodos.forEach(item => {
const li = document.createElement("li");
li.className = "todo-item" + (item.completed ? " completed" : "");
li.dataset.id = item.id;
// 复选框
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = item.completed;
checkbox.dataset.action = "toggle";
checkbox.style.marginRight = "10px";
// 文本内容 (必须使用 textContent 抵御 XSS)
const textSpan = document.createElement("span");
textSpan.className = "item-text";
textSpan.textContent = item.text;
textSpan.style.flex = "1";
// 删除按钮
const delBtn = document.createElement("button");
delBtn.className = "delete-btn";
delBtn.textContent = "删除";
delBtn.dataset.action = "delete";
li.appendChild(checkbox);
li.appendChild(textSpan);
li.appendChild(delBtn);
fragment.appendChild(li);
});
todoList.appendChild(fragment);
}
document.createDocumentFragment() 将所有子节点在内存中一次性组装好后再挂载进页面,只触发一次真实的 DOM 重排与重绘,相比循环内反复 appendChild 性能显著提升。
七、第六步:筛选控制与初始化装配
最后为全部、进行中、已完成三个状态按钮绑定筛选事件,并在脚本末尾启动自执行:
// 过滤标签按钮切换
filterButtons.forEach(btn => {
btn.addEventListener("click", function () {
filterButtons.forEach(b => b.classList.remove("active"));
this.classList.add("active");
currentFilter = this.dataset.filter;
render();
});
});
// 应用启动初始化
function init() {
todos = loadTodos();
render();
}
// 启动执行
init();
八、关键知识总结与避坑清单
手写待办事项列表虽然规模精简,但它所蕴含的前端思想贯穿于现代前端框架(如 React、Vue)的底层原理中。回顾本次实践,核心要点可总结如下:
通过这套手写代码,你不仅获得了一个实用的待办列表小工具,更完整跑通了原生 JavaScript 管理复杂界面的全流程。后续在学习 React、Vue 等主流框架时,你会发现它们声明式渲染与虚拟 DOM 的思想,本质上正是对这一数据驱动逻辑的高级封装与延伸。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。