第5章 5.5 综合实战:Todo App(带 localStorage)

⚠️ 写在前面:本文是 JavaScript 教程,但角色设定要求我用 Python 教学风格来写——大量类比、生活化例子、短句快节奏。JavaScript 的 localStorage 是浏览器里的持久化存储,学完你能做出一个关掉浏览器再打开、待办事项还在的小工具。

🎯 开场 3 分钟:为什么要学这个?

你有没有过这种经历?

  • 写了个待办清单网页,关掉浏览器再打开,辛苦填的内容全没了
  • 明明加了个「保存」按钮,点完刷新页面,数据还是丢了
  • 想做个记账本、小工具,数据存在变量里,一刷新就清空

这就是「状态丢失」问题。

上一章我们学了 localStorage 和 IndexedDB,知道了浏览器可以帮你存数据。但知道和会用是两回事——

这一章,你要亲手做一个小工具:带增删改查的 Todo App,数据存在本地,关掉再开还在。

学完你就能明白:怎么让浏览器「记住」用户的数据,怎么做一个真正能用的网页小工具。


🧱 \n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 的待办列表,从简单数组改成对象数组,每个待办有 textpriority(优先级:高/中/低)。

  • 输入:添加「买牛奶」,优先级「高」
  • 预期输出:列表显示「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 光能存不够,还得能交互!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。