手把手打造一个基于 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>
效果图:
下面我们来逐步分析这个程序
一、项目结构与基础搭建
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:让padding和border包含在元素宽高中,布局更可控。- 自定义复选框:通过隐藏原生
<input type="checkbox">,用::before伪元素显示 ✅/⬜️ 图标,提升 UI 美观度。
二、核心逻辑:JavaScript 实现
2.1 数据初始化:从 LocalStorage 读取
const items = JSON.parse(localStorage.getItem('todos') || '[]');
这里有两个关键知识点:
LocalStorage 是什么?
- 浏览器提供的永久性本地存储(除非用户手动清除)。
- 只能存储 字符串(key-value 形式)。
- 适合存储小型、非敏感的结构化数据(如用户偏好、待办列表)。
当我们再次打开时会从本地读取数据来确保页面不会重置
为什么用 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生成唯一的id和for属性,确保 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 逻辑,不触发页面跳转。
- 当我们输入内容时不发生自动提交刷新
this指向谁?
- 在
addEventListener绑定的普通函数中,this指向事件监听器挂载的 DOM 元素(即<form>)。 - 因此
this.querySelector('[name=item]')能精准找到表单内的输入框。
trim() 的作用
- 去除用户输入首尾的空白字符(空格、换行等)。
- 防止用户误提交“看似有内容实则全空格”的无效任务。
- 注意:HTML 的
required属性认为空格是有效内容,因此 JS 层trim()是必要的补充验证。 当我们触发事件后,localStorage会及时更新
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
三、关键概念总结
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 等现代框架的基石。理解底层原理,才能在使用高级工具时游刃有余。