JavaScript实战项目教程:手写一个待办事项列表应用

作者:白露时间:2026-10-30 19:11:57浏览:220

本教程通过手写一个完整的待办事项(TodoList)应用,深入讲解JavaScript中的数据驱动UI、DOM事件委托、本地持久化与XSS防御等核心实战技能。

手写一个待办事项(TodoList)应用是检验与巩固原生 JavaScript 基础的黄金项目。它并非简单的界面堆砌,而是把“状态管理驱动视图渲染”、“DOM 事件委托机制”以及“本地持久化”有机结合的典型实操。通过这个实战,你将摆脱零散拼凑 DOM 节点的初级阶段,学会以数据为核心组织前端交互逻辑。

一、整体架构与设计思路

传统初学者在编写此类功能时,往往倾向于在用户点击按钮时直接创建 <li> 并插入页面。这种做法虽然直观,但在面对状态切换、多维度筛选以及离线存储时会产生大量难以维护的 DOM 操作分支。更规范的做法是采用“单向数据流与数据驱动渲染”:用户的一切交互只修改内存中的数组状态,再统一由渲染函数根据最新数据生成页面结构,并同步至浏览器的 localStorage。

JavaScript实战项目教程:手写一个待办事项列表应用
图 1:待办事项列表应用的整体单向数据流与模块架构

二、第一步:构建语义化页面骨架与样式

在编写业务脚本前,需要准备清晰直观的 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实战项目教程:手写一个待办事项列表应用
图 2:手写待办事项应用的五个标准开发阶段

四、第三步:核心 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 判断点击的具体子元素:

JavaScript实战项目教程:手写一个待办事项列表应用
图 3:事件委托机制与分散绑定的性能与架构对比

利用 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实战项目教程:手写一个待办事项列表应用
图 4:待办事项实战项目的四大核心支柱与架构思想

通过这套手写代码,你不仅获得了一个实用的待办列表小工具,更完整跑通了原生 JavaScript 管理复杂界面的全流程。后续在学习 React、Vue 等主流框架时,你会发现它们声明式渲染与虚拟 DOM 的思想,本质上正是对这一数据驱动逻辑的高级封装与延伸。

作者最新文章
相关文章
更多