适合新手的JavaScript练习项目:ToDoList开发实战

作者:窗边听雨时间:2026-10-30 19:00:48浏览:323

面向前端初学者的原生JavaScript实战教程。以ToDoList(待办事项清单)项目为载体,系统讲解DOM操作、事件委托、状态管理与localStorage持久化,帮助新手建立数据驱动视图的编程思维。

对于刚刚掌握了变量、函数、循环等 JavaScript 基础语法的初学者来说,脱离练习题并独立开发一个完整的交互应用,是跨越“看得懂语法却写不出项目”阶段的关键一步。在各类练习中,ToDoList(待办事项清单)是公认综合性价比最高的新手项目:它不依赖复杂的第三方库,却涵盖了获取 DOM 节点、动态创建与渲染元素、处理表单事件、利用事件委托解耦,以及使用浏览器本地存储(localStorage)实现数据持久化等前端核心技能。本文将摒弃空洞概念,从架构构思、步骤拆解到完整代码编写,带领读者一步步完成一个功能扎实且结构清晰的 ToDoList 实战应用。

一、核心思路:为什么推荐“数据驱动”而非直接硬改 DOM

很多新手初次接触此类项目时,最习惯的做法是直接操作页面元素:点击“添加”按钮,就调用 document.createElement('li') 并直接 appendChild 到列表中;点击“完成”复选框,就用 classList.toggle 修改样式;点击“删除”按钮,就找到对应节点的父级并执行 removeChild。虽然这种方式在功能极其简单时勉强可行,但当后续需要增加本地持久化、状态筛选(全部/未完成/已完成)或者批量操作时,代码就会迅速陷入混乱,因为状态分散在各个 HTML 标签的属性和样式中,很难统一管理与同步。

规范的做法是采用数据驱动视图的设计思想:在内存中始终维护一个代表当前任务列表的数据数组(例如 todos)。界面的所有新增、修改、删除操作,本质上都只是在修改这个数组中的对象属性;数据更新完成后,统一调用一个专门的渲染函数将最新的数据映射回页面,并同步存入浏览器的 localStorage 中。这种单向数据流的方式能够彻底解耦“交互逻辑”与“界面绘制”,让代码层次清晰且极具拓展性。

适合新手的JavaScript练习项目:ToDoList开发实战
图 1:ToDoList 项目的单向数据流与模块架构交互图

二、开发流程拆解:从静态骨架到数据闭环

完成一个完整的 ToDoList 应用,可以遵循以下由浅入深的五个实施步骤。按照结构化流程推进,可以避免在刚写几行代码时就因为同时考虑样式与事件逻辑而无从下手。

适合新手的JavaScript练习项目:ToDoList开发实战
图 2:ToDoList 项目的五步开发实施流程图

步骤 1:构建页面结构与基础样式

界面的基本骨架包括一个输入表单(包含文本输入框与添加按钮)和一个用于承载待办项的无序列表 <ul>。将输入框包裹在 <form> 中,可以天然支持用户在输入框内按下回车键(Enter)时自动触发提交,而无需针对键盘事件单独做 keyCode === 13 的判断。

步骤 2:设计内存数据结构

每个待办事项在数据层面应该是一个简单的 JavaScript 对象。建议包含三个字段:

  • id:唯一标识符,可使用时间戳 Date.now() 快速生成,方便后续精确定位该项;
  • text:字符串类型,保存用户输入的任务内容;
  • completed:布尔值(true/false),表示该任务是否已经完成。

步骤 3:编写渲染函数(Render)

渲染函数的作用是遍历 todos 数组,并根据每个对象的状态组装出对应的 HTML 字符串,最后一次性更新到 <ul> 容器中。在拼接 HTML 时,利用 HTML5 的 data-id="${todo.id}" 属性将唯一的 ID 绑定在元素上,这样在后续处理点击时就能精准提取对应的数据项。

步骤 4:利用事件委托实现交互响应

当列表中有大量待办事项,或者待办事项是后续动态添加的时候,新手很容易在新增节点的同时为其内部的“完成勾选框”和“删除按钮”单独绑定 addEventListener。这种做法不仅会产生大量重复的事件监听器导致内存开销,还极易引发监听器丢失或重复绑定的问题。

正确的方案是使用事件委托(Event Delegation):把一个 click 事件监听器绑定在它们共同的父容器 <ul id="todo-list"> 上。当用户点击列表中的任何子元素时,事件会沿着 DOM 树冒泡到父级,在父级的事件处理函数中,通过 e.target 识别被点击的是不是删除按钮或复选框,再根据其父级或自身上的 data-id 找到数据源进行修改。

适合新手的JavaScript练习项目:ToDoList开发实战
图 3:DOM 操作模式对比:直接绑定与事件委托的区别

步骤 5:本地存储持久化(localStorage)

前端单页应用如果刷新后数据全部丢失,可用性会大打折扣。浏览器的 localStorage 提供了键值对形式的持久化能力。需要注意的是,localStorage 只能存储字符串,因此需要借助 JSON.stringify() 将数组序列化为 JSON 字符串存储;在页面初始化读取时,使用 JSON.parse() 反序列化回 JavaScript 数组。

三、实战代码:完整可运行的原生实现

以下是经过精简、结构规范的完整代码。你可以将其直接保存为一个 .html 文件在浏览器中打开运行:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>原生 JavaScript ToDoList 实战</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: sans-serif; background: #f4f6f8; padding: 40px 20px; }
    .todo-app { max-width: 480px; margin: 0 auto; background: #fff; padding: 24px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
    h2 { font-size: 20px; margin-bottom: 16px; color: #333; text-align: center; }
    .todo-form { display: flex; gap: 8px; margin-bottom: 20px; }
    .todo-input { flex: 1; padding: 10px 12px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; outline: none; }
    .todo-input:focus { border-color: #0066cc; }
    .btn-add { padding: 10px 18px; background: #0066cc; color: #fff; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; }
    .btn-add:hover { background: #0052a3; }
    .todo-list { list-style: none; }
    .todo-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-bottom: 1px solid #eee; }
    .todo-item.completed .item-text { text-decoration: line-through; color: #999; }
    .item-content { display: flex; align-items: center; gap: 10px; flex: 1; }
    .item-text { font-size: 14px; color: #333; }
    .btn-delete { background: #ff4d4f; color: #fff; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px; }
    .btn-delete:hover { background: #d9363e; }
    .empty-tip { text-align: center; color: #999; font-size: 14px; padding: 20px 0; }
  </style>
</head>
<body>

  <div class="todo-app">
    <h2>我的待办清单</h2>
    <form id="todo-form" class="todo-form">
      <input type="text" id="todo-input" class="todo-input" placeholder="输入待办事项,按回车添加..." autocomplete="off" />
      <button type="submit" class="btn-add">添加</button>
    </form>
    <ul id="todo-list" class="todo-list"></ul>
  </div>

  <script>
    // 1. 初始化数据:从 localStorage 恢复,若无则初始化为空数组
    const STORAGE_KEY = 'my_todo_list_data';
    let todos = JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];

    // 2. 获取常用 DOM 元素
    const todoForm = document.getElementById('todo-form');
    const todoInput = document.getElementById('todo-input');
    const todoList = document.getElementById('todo-list');

    // 3. 辅助函数:保存数据到本地存储
    function saveTodos() {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
    }

    // 4. 渲染函数:根据内存中的 todos 数组生成 HTML
    function renderTodos() {
      if (todos.length === 0) {
        todoList.innerHTML = '<li class="empty-tip">暂无待办事项,快去添加吧!</li>';
        return;
      }

      todoList.innerHTML = todos.map(todo => `
        <li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
          <div class="item-content">
            <input type="checkbox" class="toggle-checkbox" ${todo.completed ? 'checked' : ''} />
            <span class="item-text">${escapeHtml(todo.text)}</span>
          </div>
          <button class="btn-delete">删除</button>
        </li>
      `).join('');
    }

    // 简易转义,防止 XSS 注入
    function escapeHtml(str) {
      return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }

    // 5. 事件监听:处理表单添加提交
    todoForm.addEventListener('submit', function (e) {
      e.preventDefault(); // 阻止表单默认刷新行为
      const text = todoInput.value.trim();
      if (!text) {
        alert('待办内容不能为空');
        return;
      }

      // 仅向数据模型添加新对象
      todos.push({
        id: Date.now(),
        text: text,
        completed: false
      });

      todoInput.value = ''; // 清空输入框
      saveTodos();          // 同步到本地
      renderTodos();        // 重新渲染页面
    });

    // 6. 事件委托:统一监听列表容器的点击交互
    todoList.addEventListener('click', function (e) {
      const target = e.target;
      const todoItem = target.closest('.todo-item');
      if (!todoItem) return;

      const todoId = Number(todoItem.dataset.id);

      // 分支 A: 点击了勾选框
      if (target.classList.contains('toggle-checkbox')) {
        const item = todos.find(t => t.id === todoId);
        if (item) {
          item.completed = !item.completed;
          saveTodos();
          renderTodos();
        }
      }

      // 分支 B: 点击了删除按钮
      if (target.classList.contains('btn-delete')) {
        todos = todos.filter(t => t.id !== todoId);
        saveTodos();
        renderTodos();
      }
    });

    // 7. 首次加载时启动初始渲染
    renderTodos();
  </script>
</body>
</html>

四、初学者高频踩坑点与排错指南

很多新手在独立复现代码时,容易遇到各种细微的逻辑错误。以下总结了四个最常见的问题及其排查方法:

避坑 1:表单提交导致页面瞬间刷新重载
在 <form> 上监听 submit 事件时,浏览器的默认行为是向当前页面或 action 地址发送请求并刷新页面。如果忘记调用 e.preventDefault(),每次添加新项时页面都会一闪而过重新刷新,看起来就像代码没有生效一样。

避坑 2:localStorage 读取到 null 导致报错
在全新浏览器或隐私模式下,首次读取本地存储会返回 null。如果直接写 JSON.parse(localStorage.getItem(KEY)),代码后续执行 todos.push() 时会报 TypeError: Cannot read properties of null (reading 'push')。务必加上短路回退:JSON.parse(...) || []。

避坑 3:dataset 提取出的 ID 是字符串类型
通过 todoItem.dataset.id 从 DOM 属性中读取出来的值永远是 string。而我们在定义数据时使用的是 Date.now() 数字。如果直接使用严格相等 t.id === todoId 进行查找,会导致 171200000 !== "171200000",造成勾选与删除毫无反应。必须使用 Number(todoItem.dataset.id) 进行类型转换。

避坑 4:未做首尾空格过滤(trim)与防注入转义
如果用户连续输入几个空格,或者输入了 <script>、<img onerror=...> 等恶意字符,直接插入 HTML 会导致排版错乱甚至安全隐患。在推入数组前使用 .trim() 剔除多余空格,并对特殊字符做基本转义是良好的编程习惯。

五、技能总结与向现代框架演进

通过独立实现这套 ToDoList,你已经掌握了前端开发中最为本质的基础技能。更重要的是,这个项目中的核心逻辑正是现代主流前端框架(如 Vue、React)的雏形:

适合新手的JavaScript练习项目:ToDoList开发实战
图 4:ToDoList 实战技能总结与进阶演进路径图

在熟练运行上述基础代码后,强烈建议读者不要直接复制粘贴,而是关闭参考代码,凭记忆与思路尝试独立从零重写一遍。遇到问题时多使用浏览器的 console.log() 与开发者工具的 Elements 面板观察状态流向。当你能够轻松自如地扩展筛选过滤或行内编辑功能时,说明你已经真正掌握了原生 JavaScript 的核心要领,可以自信地开启现代框架的学习之旅了。

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