用原生JavaScript实现待办事项列表:完整项目实战教程

作者:白露时间:2026-10-30 18:54:53浏览:425

本教程详细讲解如何使用纯原生JavaScript从零构建一个完整的待办事项列表应用,涵盖数据状态管理、单向数据流、事件委托、本地持久化存储及过滤功能。

待办事项列表(TodoList)是前端开发中最经典且最能检验基础功底的实战项目。不依赖 React、Vue 等现代前端框架,纯粹使用原生 JavaScript 构建待办应用,不仅能够让开发者透彻理解 DOM 节点的创建、更新与销毁,还能深入掌握单向数据流、事件委托模式以及客户端持久化存储(LocalStorage)的核心机制。本文将以模块化和数据驱动的思想,带领读者从零实现一个具备增删、状态切换、过滤筛选与本地持久化的高性能待办事项管理系统。

一、项目设计思路与核心架构

许多初学者在初次编写原生 JavaScript 项目时,容易陷入“直接操作 DOM”的泥潭:点击添加按钮就拼接一段字符串插入 HTML,点击删除就找到父节点调用 remove()。这种做法在简单场景下尚可应付,但一旦涉及“过滤未完成项”、“刷新后保持状态”、“批量勾选”等复合需求,DOM 状态与界面展示极易失步,代码也会变得臃肿脆弱。

现代化原生 JavaScript 开发的核心理念是“状态驱动视图(State-Driven View)”。我们将待办列表的所有业务逻辑建立在纯 JavaScript 内存数组之上,界面的任何变动均源自内存状态的改变,具体流程包括四个环节:

  • 状态存储(State):使用包含对象项的纯数组维护待办数据,每个任务具备唯一 id、描述 text 和完成状态 completed。
  • 持久化同步(LocalStorage):任何对状态数组的增、删、改操作,均立即将数据序列化写入浏览器本地缓存。
  • 统一渲染(Render):编写无副作用的纯渲染函数,根据当前状态及筛选条件全量或增量刷新 DOM 树。
  • 事件委托(Event Delegation):在列表父容器上监听交互事件,通过事件冒泡机制识别目标子元素并派发状态修改。
用原生JavaScript实现待办事项列表:完整项目实战教程
图 1:基于原生 JavaScript 的状态驱动与数据流向闭环图

二、开发步骤与实现路径

为了让项目层次清晰且易于维护,我们将整体实现工作拆解为四个关键步骤:

  1. 结构搭建:编写语义化 HTML 骨架与整洁的基础样式,确立输入框、操作按钮、列表容器及过滤面板。
  2. 状态建模与输入捕获:定义 todos 核心数组结构,监听表单提交事件,处理文字校验与唯一标识生成。
  3. 事件委托与视图重绘:在无缝列表层挂载委托监听器,利用冒泡处理切换完成状态与删除操作,触发 render()。
  4. 数据持久化与高级过滤:封装 LocalStorage 读写逻辑,结合 filter 状态渲染“全部 / 进行中 / 已完成”,并支持清空已完成项。
用原生JavaScript实现待办事项列表:完整项目实战教程
图 2:待办事项列表完整项目的四个实战实施阶段

三、页面结构与样式搭建

界面的 HTML 应尽量保持语义化与简洁。主要由三部分构成:用于输入待办的表单 <form>、呈现条目的无序列表 <ul>,以及底部的状态统计与过滤控制栏。

<div class="todo-app">
  <h1>我的待办清单</h1>
  
  <!-- 添加任务表单 -->
  <form id="todo-form" class="todo-form">
    <input 
      type="text" 
      id="todo-input" 
      placeholder="需要做些什么?" 
      autocomplete="off" 
      autofocus 
    />
    <button type="submit" id="add-btn">添加</button>
  </form>

  <!-- 待办事项列表展示区 -->
  <ul id="todo-list" class="todo-list"></ul>

  <!-- 底部操作与过滤栏 -->
  <div id="todo-footer" class="todo-footer">
    <span id="todo-count">0 个待办</span>
    <div class="filters" id="filters">
      <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>
    </div>
    <button id="clear-completed" class="clear-btn">清除已完成</button>
  </div>
</div>

在样式设计中,使用 Flexbox 保证各组件在不同分辨率下的整齐排列。为了突出完成状态,当待办项拥有 completed 类名时,给文本添加横向贯穿删除线(line-through)并适当降低透明度。

四、核心功能逐项实现

1. 数据模型与初始化

为了让每个任务具备唯一性,每个待办项均保存为形如 { id: 1718000000000, text: '学习原生 JS', completed: false } 的对象。我们在应用启动时首先尝试从 localStorage 中提取历史数据,若无历史记录则初始化为空数组:

// 状态定义
let todos = JSON.parse(localStorage.getItem('todo_items_storage')) || [];
let currentFilter = 'all'; // 可选值: 'all' | 'active' | 'completed'

// 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 filtersContainer = document.getElementById('filters');
const clearCompletedBtn = document.getElementById('clear-completed');

2. 数据持久化助手函数

为了保证无论执行添加、修改或删除操作时本地缓存都能即时同步,我们编写一个统一的 saveTodos() 函数:

function saveTodos() {
  localStorage.setItem('todo_items_storage', JSON.stringify(todos));
}

3. 添加新任务

为表单绑定 submit 事件,通过 e.preventDefault() 拦截浏览器默认的刷新跳转行为。读取用户输入并利用 trim() 消除首尾空白;若内容非空,则生成新任务对象并推入 todos 数组:

todoForm.addEventListener('submit', function(e) {
  e.preventDefault();
  const text = todoInput.value.trim();
  
  if (!text) {
    return; // 禁止提交空字符
  }

  const newTodo = {
    id: Date.now(), // 使用当前毫秒时间戳作为唯一ID
    text: text,
    completed: false
  };

  todos.push(newTodo);
  saveTodos();
  render();

  todoInput.value = ''; // 清空输入框
  todoInput.focus();
});

4. 基于事件委托的高效交互机制

很多开发者在创建 <li> 元素时,会为其中的复选框和删除按钮分别调用 addEventListener。当待办项数量达到数百个,或者列表频繁增删时,过多的事件监听器会造成严重的内存占用,甚至引发内存泄漏。

事件委托(Event Delegation)是解决该问题的规范途径:由于 DOM 事件存在冒泡机制(Bubble Phase),在 <ul> 容器上只需绑定一次 click 监听器,即可捕获其内部所有子元素的点击事件。通过检查触发源(e.target)的类名或自定义属性,即可精准判断用户是点击了“切换勾选”还是“删除按钮”。

用原生JavaScript实现待办事项列表:完整项目实战教程
图 3:基于父级容器的事件委托与事件冒泡分发机制示意图
todoList.addEventListener('click', function(e) {
  // 向上寻找最近的带有 data-id 的 li 元素
  const li = e.target.closest('li[data-id]');
  if (!li) return;

  const id = Number(li.dataset.id);

  // 1. 点击了勾选框
  if (e.target.classList.contains('toggle-checkbox')) {
    const targetTodo = todos.find(item => item.id === id);
    if (targetTodo) {
      targetTodo.completed = !targetTodo.completed;
      saveTodos();
      render();
    }
  }

  // 2. 点击了删除按钮
  if (e.target.classList.contains('delete-btn')) {
    todos = todos.filter(item => item.id !== id);
    saveTodos();
    render();
  }
});

5. 数据渲染与过滤逻辑

渲染函数 render() 是界面的唯一出口。它负责获取经过当前过滤器筛选的条目,构建 HTML 片段并批量赋值给 todoList.innerHTML,同时更新计数器与清除按钮状态:

function render() {
  // 1. 根据当前筛选状态过滤列表
  let filteredTodos = todos;
  if (currentFilter === 'active') {
    filteredTodos = todos.filter(t => !t.completed);
  } else if (currentFilter === 'completed') {
    filteredTodos = todos.filter(t => t.completed);
  }

  // 2. 组装条目 HTML(严防 XSS:使用字符转义函数对文本过滤)
  todoList.innerHTML = filteredTodos.map(todo => `
    <li class="todo-item ${todo.completed ? 'completed' : ''}" data-id="${todo.id}">
      <input 
        type="checkbox" 
        class="toggle-checkbox" 
        ${todo.completed ? 'checked' : ''} 
      />
      <span class="todo-text">${escapeHTML(todo.text)}</span>
      <button class="delete-btn" title="删除该任务">&times;</button>
    </li>
  `).join('');

  // 3. 更新剩余未完成计数
  const activeCount = todos.filter(t => !t.completed).length;
  todoCount.textContent = `${activeCount} 个待办未完成`;

  // 4. 控制“清除已完成”按钮的显示与隐藏
  const hasCompleted = todos.some(t => t.completed);
  clearCompletedBtn.style.visibility = hasCompleted ? 'visible' : 'hidden';
}

// 辅助转义函数,杜绝 HTML 注入漏洞
function escapeHTML(str) {
  const div = document.createElement('div');
  div.textContent = str;
  return div.innerHTML;
}

6. 过滤切换与批量清除

为过滤按钮与批量清除按钮分别绑定监听:

// 过滤按钮点击切换
filtersContainer.addEventListener('click', function(e) {
  if (e.target.tagName !== 'BUTTON') return;

  document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
  e.target.classList.add('active');

  currentFilter = e.target.dataset.filter;
  render();
});

// 清除已完成任务
clearCompletedBtn.addEventListener('click', function() {
  todos = todos.filter(t => !t.completed);
  saveTodos();
  render();
});

五、完整源码整合实现

为了让读者能够立即运行并检验成果,下面提供单文件内联的完整可运行代码。读者可将其直接保存为本地 HTML 文件(例如 todo.html)并在任何现代浏览器中打开:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>原生 JavaScript 待办事项应用</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: sans-serif; background: #F8F9FA; display: flex; justify-content: center; padding-top: 50px; }
    .todo-app { width: 480px; background: #fff; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); padding: 24px; }
    h1 { font-size: 22px; color: #4F6272; text-align: center; margin-bottom: 20px; }
    .todo-form { display: flex; gap: 8px; margin-bottom: 16px; }
    .todo-form input { flex: 1; padding: 10px 14px; border: 1px solid #CBD5E1; border-radius: 4px; font-size: 14px; outline: none; }
    .todo-form input:focus { border-color: #477DAD; }
    .todo-form button { padding: 10px 18px; background: #477DAD; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
    .todo-list { list-style: none; margin-bottom: 16px; max-height: 360px; overflow-y: auto; }
    .todo-item { display: flex; align-items: center; padding: 10px 4px; border-bottom: 1px solid #F1F5F9; }
    .todo-item.completed .todo-text { text-decoration: line-through; color: #94A3B8; }
    .todo-item input[type="checkbox"] { margin-right: 12px; cursor: pointer; width: 16px; height: 16px; }
    .todo-text { flex: 1; font-size: 14px; color: #1E293B; word-break: break-all; }
    .delete-btn { background: none; border: none; color: #EF4444; font-size: 20px; cursor: pointer; opacity: 0; transition: opacity 0.2s; padding: 0 4px; }
    .todo-item:hover .delete-btn { opacity: 1; }
    .todo-footer { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #64748B; padding-top: 12px; border-top: 1px solid #E2E8F0; }
    .filter-btn { background: none; border: 1px solid transparent; padding: 4px 8px; border-radius: 4px; cursor: pointer; color: #64748B; }
    .filter-btn.active { border-color: #477DAD; color: #477DAD; font-weight: bold; }
    .clear-btn { background: none; border: none; color: #EF4444; cursor: pointer; }
  </style>
</head>
<body>
  <div class="todo-app">
    <h1>待办清单</h1>
    <form id="todo-form" class="todo-form">
      <input type="text" id="todo-input" placeholder="输入待办事项..." autocomplete="off" />
      <button type="submit">添加</button>
    </form>
    <ul id="todo-list" class="todo-list"></ul>
    <div class="todo-footer">
      <span id="todo-count">0 个待办</span>
      <div id="filters">
        <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>
      </div>
      <button id="clear-completed" class="clear-btn">清除已完成</button>
    </div>
  </div>

  <script>
    let todos = JSON.parse(localStorage.getItem('todo_items_storage')) || [];
    let currentFilter = 'all';

    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 filtersContainer = document.getElementById('filters');
    const clearCompletedBtn = document.getElementById('clear-completed');

    function saveTodos() {
      localStorage.setItem('todo_items_storage', JSON.stringify(todos));
    }

    function escapeHTML(str) {
      const div = document.createElement('div');
      div.textContent = str;
      return div.innerHTML;
    }

    function render() {
      let filtered = todos;
      if (currentFilter === 'active') filtered = todos.filter(t => !t.completed);
      if (currentFilter === 'completed') filtered = todos.filter(t => t.completed);

      todoList.innerHTML = filtered.map(item => `
        <li class="todo-item ${item.completed ? 'completed' : ''}" data-id="${item.id}">
          <input type="checkbox" class="toggle-checkbox" ${item.completed ? 'checked' : ''}>
          <span class="todo-text">${escapeHTML(item.text)}</span>
          <button class="delete-btn">&times;</button>
        </li>
      `).join('');

      const activeCount = todos.filter(t => !t.completed).length;
      todoCount.textContent = `${activeCount} 个待办未完成`;
      clearCompletedBtn.style.visibility = todos.some(t => t.completed) ? 'visible' : 'hidden';
    }

    todoForm.addEventListener('submit', function(e) {
      e.preventDefault();
      const text = todoInput.value.trim();
      if (!text) return;
      todos.push({ id: Date.now(), text, completed: false });
      saveTodos();
      render();
      todoInput.value = '';
    });

    todoList.addEventListener('click', function(e) {
      const li = e.target.closest('li[data-id]');
      if (!li) return;
      const id = Number(li.dataset.id);

      if (e.target.classList.contains('toggle-checkbox')) {
        const item = todos.find(t => t.id === id);
        if (item) {
          item.completed = !item.completed;
          saveTodos();
          render();
        }
      }

      if (e.target.classList.contains('delete-btn')) {
        todos = todos.filter(t => t.id !== id);
        saveTodos();
        render();
      }
    });

    filtersContainer.addEventListener('click', function(e) {
      if (e.target.tagName !== 'BUTTON') return;
      document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active'));
      e.target.classList.add('active');
      currentFilter = e.target.dataset.filter;
      render();
    });

    clearCompletedBtn.addEventListener('click', function() {
      todos = todos.filter(t => !t.completed);
      saveTodos();
      render();
    });

    // 页面初次加载立即执行渲染
    render();
  </script>
</body>
</html>

六、高频陷阱与开发避坑指南

在原生 JavaScript 项目开发中,有若干细节极易被忽视并引发隐蔽 Bug:

避坑要点 1:XSS(跨站脚本攻击)漏洞防范

当通过模板字符串拼接 HTML 时,如果直接将 todo.text 嵌入节点(如 <span>${todo.text}</span>),若用户输入形如 <img src="x" onerror="alert(1)"> 的恶意代码,浏览器将直接解析执行。因此必须使用 escapeHTML() 借助 textContent 转换转义,或者直接通过 document.createElement() 创建节点赋值。

避坑要点 2:LocalStorage 异常与类型转换

存储在 LocalStorage 中的内容必须为纯字符串。在调用 JSON.parse() 时,务必提供兜底默认值 || []。此外,HTML5 的 dataset.id 取出的值永远是字符串,而通过 Date.now() 存储的 id 是数字类型,在比对或过滤时须显式调用 Number(li.dataset.id),避免因 === 全等匹配导致找不到目标元素。

用原生JavaScript实现待办事项列表:完整项目实战教程
图 4:待办事项系统的单向闭环生命周期与状态同步总结

通过本项目实战,我们用纯原生技术实现了完整的数据流控制模型。无论是后续进阶学习虚拟 DOM 差异比较算法,还是深入理解主流现代前端框架的响应式更新原理,这些基于原生 JavaScript 的事件与状态管理模式都将是最坚实的技术底座。

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