LocalStorage + 事件委托:我的第一个持久化 Todo 应用实践

60 阅读4分钟

手把手打造一个基于 LocalStorage 的待办事项应用:从原理到实践

本文将带你深入理解如何用原生 JavaScript 构建一个功能完整、数据持久化的 Todo List 应用,并剖析其中涉及的核心前端概念:LocalStorage、JSON 序列化、DOM 操作、事件委托与数据驱动视图。


引言:为什么从零开始写一个 Todo App?

Todo 应用虽小,却是前端开发的“Hello World”。它涵盖了状态管理、用户交互、数据持久化和 DOM 更新等核心技能。更重要的是,亲手实现一遍,远胜过阅读十篇教程

今天,我们就来构建一个名为 “LOCAL TAPAS” 的极简待办事项应用。它不依赖任何框架,仅用 HTML、CSS 和原生 JavaScript,却能实现:

  • 添加新任务
  • 勾选完成状态
  • 刷新页面后数据不丢失

让我们开始吧! 先看完整代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>LocalStorage Todos</title>
    <link rel="stylesheet" href="./common.css"></link>
</head>
<body>
    <div class="wrapper">
        <h2>LOCAL TAPAS</h2>
        <p></p>
        <ul class="plates">
            <li>Loading Tapas...</li>
        </ul>
        <form  class="add-items">
            <input
             type="text" 
             placeholder="Item Name" 
             required
             name="item"

             >
             <input type="submit" value="+ Add Item">
        </form>
    </div>
    <script>
        const addItems =document.querySelector('.add-items');
        const itemsList = document.querySelector('.plates');
        const items  = JSON.parse(localStorage.getItem('todos')||'[]');
        //页面刷新
        function populateList(plates=[],platesList){
            //innerHTML 完全替换其中的内容,而并不是追加
            platesList.innerHTML =plates.map((plate,i)=>{
                return`
                <li>
                    <input type="checkbox" data-index=${i} id="item${i}" 
                    ${plate.done ? 'checked' : ''}
                    />
                    <label for="item${i}">${plate.text}</label>

                </li>
                
                `

            }).join('')

        }
        //处理添加事件
        function addItem(event){
            event.preventDefault();//阻止默认行为
            //函数执行时会有this 事件处理函数,指向form
            //console.log(this,'////');
            
            const text = (this.querySelector('[name=item]')).value.trim();//[]属性选择器
            const item ={
                text,
                done:false
            }
            items.push(item);
            //持久化存储 key=> value 字符串
            localStorage.setItem('todos',JSON.stringify(items));
            populateList(items,itemsList);
            this.reset();
        }
        //处理点击事件,当完成待办事项后及时更新
        function toggleDone(event){
            const el =event.target;
            //console.log(el.tagName,'?/?/');
            if(el.tagName ==='INPUT'){
            //console.log('///');
                const index =el.dataset.index;
                items[index].done = !items[index].done;
                localStorage.setItem('todos',JSON.stringify(items));
                populateList(items,itemsList);
            
            }
        }
        addItems.addEventListener('submit',addItem);
        itemsList.addEventListener('click',toggleDone);
        populateList(items,itemsList);
    </script>
</body>
</html>

效果图:

image.png 下面我们来逐步分析这个程序

一、项目结构与基础搭建

1.1 HTML 骨架

我们的 index.html 结构清晰:

<div class="wrapper">
  <h2>LOCAL TAPAS</h2>
  <ul class="plates">
    <li>Loading Tapas...</li> <!-- 初始加载提示 -->
  </ul>
  <form class="add-items">
    <input type="text" placeholder="Item Name" required name="item">
    <input type="submit" value="+ Add Item">
  </form>
</div>

关键点:

  • 使用 <form> 而非普通按钮,利用其内置的提交行为。
  • <input> 设置 required 属性,提供基础表单验证。
  • name="item" 便于后续通过属性选择器 [name=item] 精准定位。

1.2 CSS 样式:Flex 布局与视觉反馈

common.css 中运用了现代 CSS 技巧:

html {
  box-sizing: border-box;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
}
  • Flex 布局:轻松实现页面内容垂直水平居中。
  • box-sizing: border-box:让 paddingborder 包含在元素宽高中,布局更可控。
  • 自定义复选框:通过隐藏原生 <input type="checkbox">,用 ::before 伪元素显示 ✅/⬜️ 图标,提升 UI 美观度。

二、核心逻辑:JavaScript 实现

2.1 数据初始化:从 LocalStorage 读取

const items = JSON.parse(localStorage.getItem('todos') || '[]');

这里有两个关键知识点:

LocalStorage 是什么?
  • 浏览器提供的永久性本地存储(除非用户手动清除)。
  • 只能存储 字符串(key-value 形式)。
  • 适合存储小型、非敏感的结构化数据(如用户偏好、待办列表)。

image.png 当我们再次打开时会从本地读取数据来确保页面不会重置

为什么用 JSON.parse 和 '[]'?
  • 我们存入的是对象数组,但 LocalStorage 只认字符串,所以存时用 JSON.stringify(),取时用 JSON.parse()
  • 默认值必须是字符串 '[]'!如果写成 [](数组),JSON.parse([]) 会因传入非字符串而报错。

最佳实践:始终为 localStorage.getItem() 提供合法的 JSON 字符串作为兜底。

2.2 渲染函数:数据驱动视图

function populateList(plates = [], platesList) {
  platesList.innerHTML = plates.map((plate, i) => `
    <li>
      <input type="checkbox" data-index=${i} id="item${i}" ${plate.done ? 'checked' : ''} />
      <label for="item${i}">${plate.text}</label>
    </li>
  `).join('');
}

这个函数体现了 “数据驱动视图” 的思想:

  • 输入:纯数据数组 plates(如 [{text: "买菜", done: false}])。
  • 输出:完整的 HTML 字符串。
  • 关键技巧
    • 使用数组索引 i 生成唯一的 idfor 属性,确保 label 点击关联。
    • 通过 data-index=${i} 将数据索引绑定到 DOM,便于后续事件处理。
    • innerHTML = ... 是覆盖式更新,每次调用都会重建整个列表,避免 DOM 累积重复。

为什么用数组存数据,而不是直接操作 DOM?

  • DOM 元素无法被 JSON.stringify 序列化,不能存入 LocalStorage。
  • 数据与视图分离,逻辑更清晰,易于维护和测试。

2.3 添加任务:拦截表单默认行为

function addItem(event){
            event.preventDefault();//阻止默认行为
            //函数执行时会有this 事件处理函数,指向form
            //console.log(this,'////');
            
            const text = (this.querySelector('[name=item]')).value.trim();//[]属性选择器
            const item ={
                text,
                done:false
            }
            items.push(item);
            //持久化存储 key=> value 字符串
            localStorage.setItem('todos',JSON.stringify(items));
            populateList(items,itemsList);
            this.reset();
        }
}
为什么必须 preventDefault()?
  • 表单的默认提交行为是向服务器发送数据并刷新页面
  • 在纯前端应用中,刷新会导致内存中的状态丢失(尽管 LocalStorage 数据还在,但体验极差)。
  • preventDefault() 让我们接管控制权,只执行 JS 逻辑,不触发页面跳转。
  • 当我们输入内容时不发生自动提交刷新 image.png
this指向谁?
  • addEventListener 绑定的普通函数中,this 指向事件监听器挂载的 DOM 元素(即 <form>)。
  • 因此 this.querySelector('[name=item]') 能精准找到表单内的输入框。
trim() 的作用
  • 去除用户输入首尾的空白字符(空格、换行等)。
  • 防止用户误提交“看似有内容实则全空格”的无效任务。
  • 注意:HTML 的 required 属性认为空格是有效内容,因此 JS 层 trim() 是必要的补充验证。 当我们触发事件后,localStorage会及时更新

image.png

2.4 切换完成状态:事件委托

function toggleDone(event) {
  if (event.target.tagName === 'INPUT') {
    const index = event.target.dataset.index;
    items[index].done = !items[index].done;
    localStorage.setItem('todos', JSON.stringify(items));
    populateList(items, itemsList);
  }
}

itemsList.addEventListener('click', toggleDone); // 监听 ul,而非每个 li

这里使用了 事件委托(Event Delegation)

  • 问题:列表项是动态生成的,无法提前给每个 checkbox 绑定事件。

  • 解决方案:在父容器 ul.plates 上监听点击事件,利用事件冒泡机制捕获子元素(checkbox)的点击。

  • 优势

    • 性能更好(只需一个监听器)。
    • 自动兼容动态新增的列表项。
  • 关键判断if (event.target.tagName === 'INPUT') 确保只响应 checkbox 点击,忽略其他区域。

  • dataset.index是什么?
    它对应 HTML 中的 data-index 属性。通过 el.dataset.index 可以获取该自定义属性的值(字符串类型),用于定位 items 数组中的对应项。

同样的,当我们触发了点击事件来完成待办事项,也会调用populateList及时更新todos

image.png


三、关键概念总结

3.1 LocalStorage 与 JSON 的配合

  • 存储 : localStorage.setItem('key', JSON.stringify(data)) 将 JS 对象转为字符串存储
  • 读取 : JSON.parse(localStorage.getItem('key') || '[]') 读取并解析为 JS 对象,提供默认值
    改数据 → 重新渲染视图。

3.2 事件处理最佳实践

  • 表单提交:用 preventDefault() 阻止刷新。
  • 动态列表:用事件委托减少监听器数量。
  • 函数上下文:普通函数中 this 指向绑定元素,箭头函数则不会。


结语

通过这个小小的 Todo 应用,我们实践了前端开发的多个核心范式:

  • 数据驱动视图
  • 状态持久化
  • 事件委托

这些思想不仅适用于原生 JS,也是 React、Vue 等现代框架的基石。理解底层原理,才能在使用高级工具时游刃有余。