第5章 5.5 综合实战:Todo App(带 localStorage)
⚠️ 写在前面:本文是 JavaScript 教程,但角色设定要求我用 Python 教学风格来写——大量类比、生活化例子、短句快节奏。JavaScript 的 localStorage 是浏览器里的持久化存储,学完你能做出一个关掉浏览器再打开、待办事项还在的小工具。
🎯 开场 3 分钟:为什么要学这个?
你有没有过这种经历?
- 写了个待办清单网页,关掉浏览器再打开,辛苦填的内容全没了
- 明明加了个「保存」按钮,点完刷新页面,数据还是丢了
- 想做个记账本、小工具,数据存在变量里,一刷新就清空
这就是「状态丢失」问题。
上一章我们学了 localStorage 和 IndexedDB,知道了浏览器可以帮你存数据。但知道和会用是两回事——
这一章,你要亲手做一个小工具:带增删改查的 Todo App,数据存在本地,关掉再开还在。
学完你就能明白:怎么让浏览器「记住」用户的数据,怎么做一个真正能用的网页小工具。
🧱 \n\n
\n\n
\n\n基础 25 分钟:核心概念(小白视角)
localStorage 是什么?—— 浏览器的「记事本」
想象你用微信小程序,小程序关闭后再打开,你的信息还在。为什么?
因为微信帮你把数据存到了服务器上。
但网页不一样——网页刷新、数据就没了,除非你主动「记下来」。
localStorage 就是浏览器给网页准备的一个自带记事本。
- 每个网站有一块独立的存储空间(大约 5MB)
- 存在里面的数据永久保存(除非手动清除或清缓存)
- 读写速度很快,代码也简单
为什么要用?
你想做一个待办清单、记账本、收藏夹,任何需要「记住用户数据」的工具,localStorage 就是最简单粗暴的方案。
怎么用?
// 存数据(键值对,值必须是字符串)
localStorage.setItem('name', '小明')
// 取数据
let name = localStorage.getItem('name')
console.log(name) // 输出: 小明
// 删除数据
localStorage.removeItem('name')
// 清空所有数据(慎用!)
// localStorage.clear()
解释:setItem 就是「写入记事本」,getItem 就是「从记事本里读」。
JSON.stringify 和 JSON.parse —— 复杂数据的翻译官
上面存的是字符串,但如果我想存一个数组怎么办?
// 直接存数组试试
let todos = ['买菜', '跑步', '写代码']
localStorage.setItem('todos', todos)
console.log(localStorage.getItem('todos')) // 输出: 买菜,跑步,写代码
诶,看起来对了。但如果是对象呢?
let todo = { text: '买菜', done: false }
localStorage.setItem('todo', todo)
console.log(localStorage.getItem('todo')) // 输出: [object Object]
完蛋,对象变成乱码了。
原因是 localStorage 只认字符串,其他类型会自动转成字符串(对象就变成了 [object Object])。
这就需要 JSON 来帮忙——
// 把对象转成字符串再存(序列化)
let todo = { text: '买菜', done: false }
localStorage.setItem('todo', JSON.stringify(todo))
// 读出来再转回对象(反序列化)
let saved = JSON.parse(localStorage.getItem('todo'))
console.log(saved.text) // 输出: 买菜
console.log(saved.done) // 输出: false
解释:JSON.stringify 就像把水果装进保鲜盒(变成可以传输的字符串),JSON.parse 就是打开盒子取出水果(还原成对象)。
DOM 基础 —— 网页的「骨骼系统」
做 Todo App,光存数据不够,还得把数据显示在网页上。
这就需要操作 DOM(Document Object Model)——网页的「骨骼系统」。
- HTML 写骨架(有哪些元素)
- CSS 写外貌(长什么样)
- JavaScript 写行为(点击干嘛、数据怎么显示)
// 获取页面上的元素
let list = document.getElementById('todo-list') // 通过 id 找
// 创建一个新元素
let li = document.createElement('li')
li.textContent = '新待办'
// 把新元素加到列表里
list.appendChild(li)
// 删除元素
list.removeChild(li)
解释:getElementById 就像「用放大镜找 id=todo-list 的那个人」,createElement 是「凭空捏一个 li 出来」,appendChild 是「把它塞进列表里」。
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):学会存取单个数据
目标:做一个输入名字、点保存,下次打开页面还能看到名字的小工具。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>项目1:记住我的名字</title>
</head>
<body>
<h1>请输入你的名字</h1>
<input type="text" id="nameInput" placeholder="输入名字">
<button onclick="saveName()">保存</button>
<p id="showArea"></p>
<script>
// 页面加载时,读取已保存的名字
window.onload = function() {
let savedName = localStorage.getItem('username')
if (savedName) {
document.getElementById('showArea').textContent = '欢迎回来,' + savedName + '!'
}
}
function saveName() {
let name = document.getElementById('nameInput').value
if (name) {
localStorage.setItem('username', name)
document.getElementById('showArea').textContent = '已保存,欢迎你,' + name + '!'
document.getElementById('nameInput').value = ''
}
}
</script>
</body>
</html>
预期输出:
- 首次打开:输入框为空,显示区空白
- 输入「张三」点保存:显示「已保存,欢迎你,张三!」
- 关掉页面再打开:显示「欢迎回来,张三!」
一句话解释:window.onload 是页面加载完成时自动执行的函数,在这里读取 localStorage 里存的名字。
项目 2(15 分钟):Todo 列表的增删查
目标:做一个小待办清单,可以添加、查看列表,数据存在 localStorage 里。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>项目2:待办清单</title>
<style>
body { font-family: Arial; padding: 20px; }
li { padding: 8px; border-bottom: 1px solid #eee; }
button { margin-left: 10px; }
</style>
</head>
<body>
<h1>我的待办</h1>
<input type="text" id="todoInput" placeholder="新待办...">
<button onclick="addTodo()">添加</button>
<ul id="todoList"></ul>
<script>
// 从 localStorage 读取已保存的待办列表
function getTodos() {
let data = localStorage.getItem('todos')
return data ? JSON.parse(data) : []
}
// 保存待办列表到 localStorage
function saveTodos(todos) {
localStorage.setItem('todos', JSON.stringify(todos))
}
// 渲染待办列表到页面
function renderTodos() {
let todos = getTodos()
let list = document.getElementById('todoList')
list.innerHTML = '' // 清空现有列表
todos.forEach(function(todo, index) {
let li = document.createElement('li')
li.textContent = (index + 1) + '. ' + todo
list.appendChild(li)
})
}
// 添加新待办
function addTodo() {
let input = document.getElementById('todoInput')
let text = input.value.trim()
if (text) {
let todos = getTodos()
todos.push(text)
saveTodos(todos)
input.value = ''
renderTodos()
}
}
// 页面加载时渲染列表
window.onload = renderTodos
</script>
</body>
</html>
预期输出:
- 打开页面:显示之前保存的待办列表(如果有的话)
- 输入「买菜」点添加:列表里多一项「1. 买菜」
- 再输入「跑步」点添加:列表里多一项「2. 跑步」
- 关掉再打开:两条待办还在
一句话解释:todos 用数组存储,JSON.stringify 存进去,JSON.parse 读出来,每次修改后都重新保存。
项目 3(15 分钟):完整 Todo App(增删改查 + 分类 + 本地持久化)
目标:做一个功能完整的待办清单,支持:
1. 添加待办(带分类:工作/生活/学习)
2. 删除待办
3. 标记完成(划线效果)
4. 按分类筛选
5. 数据永久保存
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>项目3:完整 Todo App</title>
<style>
body { font-family: Arial; padding: 20px; max-width: 600px; margin: 0 auto; }
h1 { color: #333; }
.input-row { margin-bottom: 20px; }
input, select { padding: 8px; font-size: 14px; }
button { padding: 8px 12px; cursor: pointer; }
li { padding: 10px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
li.done span { text-decoration: line-through; color: #999; }
.category-tag { font-size: 12px; padding: 2px 8px; border-radius: 4px; margin-right: 10px; }
.work { background: #e3f2fd; color: #1565c0; }
.life { background: #f3e5f5; color: #7b1fa2; }
.study { background: #e8f5e9; color: #2e7d32; }
.filter-row { margin: 15px 0; }
.filter-row button { margin-right: 5px; background: #f5f5f5; border: 1px solid #ddd; }
.filter-row button.active { background: #333; color: #fff; }
</style>
</head>
<body>
<h1>我的待办清单</h1>
<div class="input-row">
<input type="text" id="todoInput" placeholder="新待办..." style="width: 200px;">
<select id="categorySelect">
<option value="work">工作</option>
<option value="life">生活</option>
<option value="study">学习</option>
</select>
<button onclick="addTodo()">添加</button>
</div>
<div class="filter-row">
<button onclick="filterTodos('all')" id="btn-all" class="active">全部</button>
<button onclick="filterTodos('work')" id="btn-work">工作</button>
<button onclick="filterTodos('life')" id="btn-life">生活</button>
<button onclick="filterTodos('study')" id="btn-study">学习</button>
</div>
<ul id="todoList"></ul>
<p id="stats"></p>
<script>
// 从 localStorage 读取数据
function getTodos() {
let data = localStorage.getItem('fullTodos')
return data ? JSON.parse(data) : []
}
// 保存数据到 localStorage
function saveTodos(todos) {
localStorage.setItem('fullTodos', JSON.stringify(todos))
}
// 添加待办
function addTodo() {
let input = document.getElementById('todoInput')
let select = document.getElementById('categorySelect')
let text = input.value.trim()
let category = select.value
if (text) {
let todos = getTodos()
todos.push({
id: Date.now(), // 用时间戳当唯一 ID
text: text,
category: category,
done: false
})
saveTodos(todos)
input.value = ''
renderTodos('all')
}
}
// 删除待办
function deleteTodo(id) {
let todos = getTodos()
todos = todos.filter(t => t.id !== id)
saveTodos(todos)
renderTodos(currentFilter)
}
// 切换完成状态
function toggleDone(id) {
let todos = getTodos()
todos.forEach(t => {
if (t.id === id) t.done = !t.done
})
saveTodos(todos)
renderTodos(currentFilter)
}
// 渲染列表(带筛选)
let currentFilter = 'all'
function renderTodos(filter) {
currentFilter = filter
let todos = getTodos()
let filtered = filter === 'all' ? todos : todos.filter(t => t.category === filter)
let list = document.getElementById('todoList')
// 更新筛选按钮样式
document.querySelectorAll('.filter-row button').forEach(btn => {
btn.classList.remove('active')
})
document.getElementById('btn-' + filter).classList.add('active')
// 渲染列表
list.innerHTML = ''
filtered.forEach(todo => {
let li = document.createElement('li')
if (todo.done) li.className = 'done'
let span = document.createElement('span')
span.textContent = todo.text
span.style.cursor = 'pointer'
span.onclick = () => toggleDone(todo.id)
let tag = document.createElement('span')
tag.className = 'category-tag ' + todo.category
tag.textContent = {work: '工作', life: '生活', study: '学习'}[todo.category]
let delBtn = document.createElement('button')
delBtn.textContent = '删除'
delBtn.onclick = () => deleteTodo(todo.id)
li.appendChild(tag)
li.appendChild(span)
li.appendChild(delBtn)
list.appendChild(li)
})
// 更新统计
let total = todos.length
let done = todos.filter(t => t.done).length
document.getElementById('stats').textContent = '共 ' + total + ' 项,已完成 ' + done + ' 项'
}
// 筛选功能
function filterTodos(filter) {
renderTodos(filter)
}
// 页面加载时渲染
window.onload = () => renderTodos('all')
</script>
</body>
</html>
预期输出:
- 页面打开:显示之前保存的待办,带分类标签
- 输入「写周报」选「工作」点添加:列表出现带蓝色「工作」标签的待办
- 点击待办文字:文字划线,表示完成
- 点「删除」:该条待办消失
- 点「生活」筛选:只显示生活类待办
- 关掉再打开:所有数据都在
一句话解释:每个待办对象有 id、text、category、done 四个属性,存成数组后 JSON.stringify 保存,读出来 JSON.parse 还原。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:存对象没转 JSON
// ❌ 错误:对象直接存会变成 [object Object]
let todo = { text: '买菜', done: false }
localStorage.setItem('todo', todo)
// ✅ 正确:先转 JSON 字符串
localStorage.setItem('todo', JSON.stringify(todo))
// ❌ 错误:读出来直接当对象用
let saved = localStorage.getItem('todo')
console.log(saved.done) // undefined!
// ✅ 正确:读出来要 parse
let saved = JSON.parse(localStorage.getItem('todo'))
console.log(saved.done) // false
坑 2:数组操作没写回去
// ❌ 错误:修改了数组但没保存
let todos = getTodos()
todos.push('新待办')
// 这里忘了 saveTodos(todos),数据丢了
// ✅ 正确:修改后一定要保存
let todos = getTodos()
todos.push('新待办')
saveTodos(todos)
坑 3:忘了处理 null 的情况
// ❌ 错误:localStorage 没有数据时 getItem 返回 null
let todos = localStorage.getItem('todos')
todos.push('新待办') // 报错!null 没有 push 方法
// ✅ 正确:判断是否有数据
let data = localStorage.getItem('todos')
let todos = data ? JSON.parse(data) : []
todos.push('新待办')
saveTodos(todos)
坑 4:ID 用索引(数组下标)做删除
// ❌ 错误:用索引删,筛选后索引就乱了
let todos = getTodos()
todos.splice(index, 1) // 假设 index=0,但筛选后可能不是这条
// ✅ 正确:用唯一 ID 删
let todos = getTodos()
todos = todos.filter(t => t.id !== id) // 用 Date.now() 生成的 ID
saveTodos(todos)
坑 5:大量数据存 localStorage 性能差
// ❌ 错误:每次操作都读写整个列表(数据多会很慢)
function addTodo(todo) {
let todos = JSON.parse(localStorage.getItem('todos') || '[]')
todos.push(todo)
localStorage.setItem('todos', JSON.stringify(todos))
}
// ✅ 正确:数据多考虑IndexedDB,或合并多次操作
// 但 Todo App 这种小数据量,localStorage 完全够用
调试技巧:console.log 打印中间状态
function addTodo() {
let todos = getTodos()
console.log('读取到的数据:', todos) // 打印出来看看对不对
todos.push({ id: Date.now(), text: '新待办', done: false })
console.log('修改后的数据:', todos)
saveTodos(todos)
console.log('保存成功')
}
打开浏览器按 F12 看控制台输出,能帮你发现数据哪里出了问题。
✏️ 练习题 + 作业题(共 7 分钟)
练习 1(2 分钟):换个名字
在项目 1 的代码里,把 username 改成 nickname,让用户输入昵称。
- 输入:页面加载时无昵称,输入「编程小王子」点保存
- 预期输出:显示「已保存,欢迎你,编程小王子!」
提示:只需要改 localStorage 的 key 名和显示文字。
练习 2(2 分钟):加个非空判断
在项目 1 的 saveName 函数里,加个判断:如果输入为空,弹出提示「请输入名字」。
- 输入:直接点保存(不输入内容)
- 预期输出:弹出提示,名字不保存
提示:用 if (name === '') { alert(...) } 判断。
练习 3(3 分钟):换个数据类型
把项目 2 的待办列表,从简单数组改成对象数组,每个待办有 text 和 priority(优先级:高/中/低)。
- 输入:添加「买牛奶」,优先级「高」
- 预期输出:列表显示「1. 买牛奶 [高]」
提示:存储结构从 ['买菜'] 变成 [{text: '买菜', priority: '中'}]。
练习 4(3 分钟):串联两个筛选
在项目 3 里,加一个「只显示未完成」的筛选按钮。
- 输入:有两个待办,一个已完成,点「未完成」筛选
- 预期输出:只显示未完成的那条
提示:类似 filterTodos('work'),但筛选条件是 todo.done === false。
练习 5(5 分钟):分析报错
用户说:「我点添加按钮没反应,控制台报错:Cannot read property 'push' of null」
function addTodo() {
let todos = localStorage.getItem('todos')
todos.push({ text: '新待办' }) // 报错这行
localStorage.setItem('todos', JSON.stringify(todos))
}
- 问题:为什么报错?怎么修?
提示:localStorage 没有数据时 getItem 返回 null,null 没有 push 方法。
作业:做一个「收藏夹小工具」
需求描述:做一个网页工具,可以把网页链接收藏起来,支持分类(技术/生活/其他)、添加备注、标记是否已读,数据存 localStorage,关掉再开还在。
功能点:
1. 添加收藏(链接 + 标题 + 分类 + 备注)
2. 按分类筛选查看
3. 标记已读/未读
4. 删除收藏
5. 数据本地持久化
加分项:
1. 显示收藏数量统计
2. 点击链接能直接跳转
验收标准:
- 能跑起来(双击 HTML 文件打开)
- 添加收藏后刷新页面,数据还在
- 按分类筛选正常工作
📚 总结 + 资源
本文学了 3 件事:
1. localStorage 的存和取(setItem/getItem)
2. JSON.stringify/parse 处理复杂数据
3. 做一个完整的 Todo App(增删改查 + 持久化)
延伸资源:
- MDN Web Docs - localStorage(官方文档,最权威)
- MDN - JSON 入门(配合学习 JSON)
- 《JavaScript 高级程序设计》第 8 章(红宝书,体系完整)
互动钩子:你在做网页小工具的时候,遇到过「数据存不住」的坑吗?评论区聊聊你是怎么解决的!
下章预告:这一章我们学会了把数据存起来,下一章要学怎么「用起来」——DOM 操作让你能动态创建、修改网页元素,事件处理让用户点击、输入能被代码感知。做 Todo App 光能存不够,还得能交互!

评论(0)