开头直接讲了这次作业的背景和我当时的真实状态,然后把整个项目从需求理解到技术落地的过程完整铺开,希望能给你一些可以参考的东西。
1. 作业背景与需求边界:这次作业到底要做什么
先交代一下背景。这是我参与的前端开发基础课程中的第三次作业,课程进度正好推进到 JavaScript DOM 操作和本地存储。前两次作业分别是"个人简介静态页面"和"CSS 响应式布局卡片",都停留在纯静态展示层面。这次的题目是"实现一个支持增删改查的待办事项管理应用",没有给具体的 UI 设计稿,只有功能要求:支持添加待办、标记完成、删除单条、清除已完成、编辑已有事项,并且刷新页面后数据不能丢。
说实话,刚拿到需求时我有点懵。功能看着不多,但"刷新数据不丢"这一条意味着必须引入某种持久化机制,而课程还没讲到后端和数据库。这意味着我得在前端本地存储方案里找一个最合适的,并且把它的边界搞清楚。我当时的心态是:这个作业不只是一个"写代码"的任务,更像是一次对"前端数据流管理"的初体验。如果你也正在学前端,或者刚接触类似的项目作业,你会发现这类需求的核心考察点其实不是"会不会写增删改查",而是"在受限条件下能不能做出合理的技术取舍"。
我把需求拆解成了三个层次。第一层是最基本的 CRUD 操作,对应 DOM 的增删改查;第二层是把数据从"临时内存"提升到"本地持久化",对应 localStorage 或 sessionStorage;第三层是交互体验,包括空状态提示、操作反馈、编辑态切换这些细节。后来事实证明,第三层才是真正拉开作业档次的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型:原生三件套是"限制"也是"最优解"
这次作业限定不能使用框架,必须用原生 HTML、CSS、JavaScript 完成。说实话,刚开始我有点不理解为啥都 2020 年代了还要用原生写业务逻辑。但做完之后我明白了,这个"限制"其实是刻意的——只有当你用原生 DOM API 手动操作节点、手动管理状态同步时,你才会真正理解 Vue 或 React 里那些"响应式""虚拟 DOM""数据驱动视图"的概念到底解决了什么问题。
那么核心问题来了:数据到底存在哪?课程里提到过 localStorage,但我自己也查了 sessionStorage 和 IndexedDB,做了一个简单的对比,如下表所示:
| 存储方案 | 生命周期 | 容量上限 | 同步/异步 | 适用场景 |
|---|---|---|---|---|
| localStorage | 永久,除非手动清除 | 约 5-10MB | 同步 | 简单键值对、偏好设置、中小型业务数据 |
| sessionStorage | 会话结束即清除 | 约 5-10MB | 同步 | 临时会话数据、表单草稿 |
| IndexedDB | 永久 | 理论上很大 | 异步 | 结构化大数据、离线应用、文件存储 |
对于"待办事项"这种需求,IndexedDB 属于大材小用。它的异步 API 处理起来很啰嗦,而且事务、对象仓库这些概念对初学者来说负担太重。sessionStorage 倒是简单,但一关浏览器就没了,完全不符合"数据不能丢"的要求。所以 localStorage 几乎是唯一合理的答案。
如果用一句话解释 localStorage 的原理,那就是:浏览器给你一个基于字符串键值对的"保险柜",你用 setItem 往里放东西,用 getItem 往外取,用 removeItem 删掉某个键,用 clear 清空整个保险柜。但这里有个隐藏的坑——保险柜只能存字符串。如果你直接 localStorage.setItem('todos', todoList),它会调用 toString() 方法,最终存进去的是 [object Object] 这种完全没用的东西。所以存之前必须用 JSON.stringify() 把对象数组转成 JSON 字符串,取出来后再用 JSON.parse() 还原成数组。这一步是很多第一次接触 localStorage 的人最容易踩的坑。
javascript复制// 存储示例
const todos = [
{ id: 1, text: '学习 JavaScript', completed: false },
{ id: 2, text: '整理作业笔记', completed: true }
];
// 存之前必须转成字符串
localStorage.setItem('todos', JSON.stringify(todos));
// 取出来之后要还原成对象数组
const storedTodos = JSON.parse(localStorage.getItem('todos') || '[]');
选型总结:在这次作业里,原生三件套加 localStorage 的组合谈不上"酷炫",但它是约束条件下的最优解。如果你想在作业里额外加分,可以顺手实现一个"数据导出为 JSON 文件下载"的功能,这完全在原生 JavaScript 能力范围内,而且非常实用。
3. 待办事项应用的前端实现细节
这一部分我想按真实的编码顺序来复盘,不按官方文档的顺序来讲。因为写代码的过程中,你会发现需求理解、数据设计、界面交互和状态同步是交织在一起的,单独拎出任何一环都没法独立成立。
3.1 先规划数据模型,而不是先写页面
我见过不少同学一上来就写 HTML 结构,把输入框、列表、按钮搭好之后才开始想数据怎么存,结果往往在"编辑这条事项时怎么找到它"这种问题上卡住。我的建议是:先把数据模型定下来。
对于待办事项,最小可用的数据模型长这样:
javascript复制{
id: Date.now(), // 唯一标识,这里用时间戳,简单够用
text: '写第三次作业', // 事项内容
completed: false, // 是否完成
createdAt: '2025-01-15 10:30:00' // 创建时间,方便后续做排序或统计
}
id 我用了 Date.now(),因为它是数字且单调递增,生成成本低,不需要额外引入 UUID 库。你也可以用 crypto.randomUUID(),但在实验环境里它的兼容性需要考虑一下,而 Date.now() 是无条件可用的。用一个唯一 id 的意义在于:增删改查时,你只需要根据 id 找到目标对象,而不是通过文本内容去匹配,这样即使有两件事写的是同样的文字,它们也能被区分开。
3.2 页面结构设计:分区清晰,交互才有载体
页面结构我用语义化标签分了四个区域:
- header:标题和新增待办的输入区
- main:待办事项列表容器
- footer:统计信息和批量操作按钮
HTML 里只写骨架,列表内容全部由 JavaScript 动态生成。这是"数据驱动视图"的第一步——HTML 里不写死任何一条待办,所有条目都来自 todos 数组。这样每次数据变化后,只要重新执行渲染函数,页面就会和最新状态保持一致。
3.3 渲染函数的两个分支:全量渲染与增量渲染
渲染是把数据变成界面的桥梁。我写了两个辅助函数:
renderAllTodos() 负责把整个 todos 数组重新绘制一遍。它的逻辑不复杂:清空列表容器,然后遍历数组,对每一项调用 createTodoElement(todo) 生成 DOM 节点,再追加到容器里。
javascript复制function renderAllTodos() {
const todoList = document.getElementById('todo-list');
todoList.innerHTML = '';
todos.forEach(todo => {
todoList.appendChild(createTodoElement(todo));
});
updateStats();
}
但这里有个性能隐患:每次只改一条数据时,全量渲染会重建所有 DOM 节点,如果列表很长会浪费性能。所以我同时写了一个 createTodoElement(todo),它只负责根据一条数据生成一个节点。这样在"只改某一条的完成状态"时,我可以只替换那一个节点,而不是整个列表。实际作业里列表通常只有几十条,全量渲染和增量渲染的差异感知不明显,但养成"最小化 DOM 操作"的习惯对后续学习 React 的 key 机制非常有帮助。
3.4 添加待办:输入校验与回车提交
添加功能的逻辑要从"不要无条件信任用户输入"这个原则展开。用户在输入框里敲一个字就点添加、敲几个空格就点添加,这些情况如果都不做限制,列表里会出现一堆空白事项,看起来很不专业。
javascript复制function addTodo() {
const input = document.getElementById('todo-input');
const text = input.value.trim(); // 去掉首尾空格
if (!text) {
showToast('待办内容不能为空');
return;
}
const newTodo = {
id: Date.now(),
text: text,
completed: false,
createdAt: new Date().toLocaleString()
};
todos.push(newTodo);
saveToLocalStorage();
renderAllTodos();
input.value = '';
input.focus();
}
这里的关键点是 trim() 方法。' 写作业 ' 经过 trim 之后会变成 '写作业'。如果用户输入的是一串空格,trim 之后是空字符串,if (!text) 就会被拦住,这就是"输入校验"的简单实现。showToast 是我自己写的一个轻提示函数,在页面右上角弹出一小段文字,2 秒后自动消失,替代浏览器默认的 alert() 弹窗——原生 alert 会阻塞页面,体验很差,而 toast 提示对用户友好得多。
回车提交是另一个容易被忽略的细节。表单内的输入框可以直接用 form 的 submit 事件天然支持回车提交,但如果你像我一样用的是 div + input 结构,就需要手动监听 keydown 事件,判断 event.key === 'Enter'。这个细节很小,但用户操作起来感受差异明显:有的人习惯敲回车,有的人习惯点按钮,两个都得支持。
3.5 删除与状态切换:事件委托是唯一的正确做法
删除单条和标记完成,我都是通过点击列表里的按钮或复选框来实现的。这里有一个新手常见的坑:给每个按钮单独绑定事件监听器。如果列表里有 100 条待办,你就绑了 100 个监听器,每个监听器占用内存不说,动态新增的节点还得重新绑定,非常麻烦。
更好的做法是事件委托。利用事件冒泡机制,只在列表容器上绑定一个监听器,然后通过 event.target 判断用户点击的到底是哪个元素。
javascript复制todoList.addEventListener('click', (event) => {
const target = event.target;
const todoItem = target.closest('.todo-item');
if (!todoItem) return;
const id = Number(todoItem.dataset.id);
if (target.classList.contains('delete-btn')) {
deleteTodoById(id);
} else if (target.classList.contains('checkbox')) {
toggleTodoCompleted(id);
}
});
closest() 的作用是从当前元素往上找最近的匹配选择器的祖先节点,这样即使你点的是按钮内部的小图标,也能正确找到整个待办项。dataset.id 是我在生成 DOM 节点时通过 data-id 属性存进去的 id。这个 id 在整个增删改查链路中就是"定位锚点"。
删除操作我加了一个 confirm() 确认步骤,防止用户误删。但后来我在作业演示的时候,觉得 confirm() 弹窗同样太丑了,于是换成了自己实现的"二次确认"气泡:点击删除后,按钮会变成"确认删除?"并变为红色,3 秒内不操作就自动恢复。这个交互在真实产品里很常见,放在作业里比原生 confirm 加分不少。因为代码量不大,我把实现过程也记录一下:
javascript复制function confirmDelete(btn, id) {
btn.textContent = '确认删除?';
btn.classList.add('danger');
btn.dataset.confirming = 'true';
setTimeout(() => {
btn.textContent = '删除';
btn.classList.remove('danger');
}, 3000);
}
同时,在事件委托的处理器里,如果检测到按钮 dataset.confirming 是 'true',就执行真正的删除,并把状态重置;否则先进入确认状态。这样既防误删,又不打断用户的操作节奏。
3.6 编辑功能:从"删除重建"到"原地替换"
编辑待办内容是这次作业里最繁琐的功能。我一开始的思路很粗暴:双击待办文本时,把文本替换成一个 input 输入框,用户按回车或失焦后,读取输入框的值,更新数组里对应的对象,然后重新渲染。这其实就是"原地替换删除重建",但实现的时候还是有几个细节要照顾到。
首先,什么时候进入编辑态?我用的是双击事件。理由是单击已经被用来切换完成状态了,如果单击也触发编辑,用户很容易误操作。双击的判定在事件监听器里用 dblclick 事件即可,但它不会自动阻止单击事件的触发,所以你需要小心处理"先单击后双击"导致的状态切换。我当时的处理是:单击切换完成状态时加一个小延迟(比如 250 毫秒),如果 250 毫秒内又来了第二次点击,就取消切换操作,把这次点击视为双击。这种"延迟决策"的写法是处理单击双击冲突的常见套路,但如果你觉得代码太复杂,也可以设置一个"编辑按钮",单击编辑按钮进入编辑态。这个方案更直观,但少了一点"探索感"。
其次,编辑态的数据绑定。进入编辑态后,输入框的初始值必须是当前待办文本。我做法是提前把文本存到 input.dataset.original 里,这样用户按 Esc 取消时可以直接恢复。按回车保存,按 Esc 取消,这两个快捷键都是必须支持的。
javascript复制function enterEditMode(todoItem, todo) {
const textSpan = todoItem.querySelector('.todo-text');
const input = document.createElement('input');
input.type = 'text';
input.value = todo.text;
input.className = 'edit-input';
textSpan.replaceWith(input);
input.focus();
input.select(); // 全选当前文本,方便直接覆盖
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
saveEdit(input, todo);
} else if (event.key === 'Escape') {
cancelEdit(input, textSpan);
}
});
input.addEventListener('blur', () => saveEdit(input, todo));
}
这里会引发一个"路径冲突":如果既监听 blur 又监听 Enter,按回车时输入框还没来得及触发 blur,可能同一份编辑被保存两次。解决方法是加一个标志位 isSaved,保存后把标志位置为 true,blur 检测到已经保存过就跳过。这种小坑在原生 DOM 开发中非常典型,踩过一次你就记住了。
3.7 过滤与统计:数据驱动视图的直观体现
作业要求里有"清除已完成"和"查看全部/未完成/已完成"这种过滤需求。我一开始是用三个按钮切换不同的"过滤器"状态,然后根据状态决定 renderAllTodos() 里显示哪些数据。后来发现每次过滤状态变化都要重新渲染一遍,性能上没问题,但代码写起来有点重复。于是我把"当前过滤器"存成一个变量 currentFilter,然后在渲染函数里加了一个判断。
javascript复制function getFilteredTodos() {
if (currentFilter === 'active') {
return todos.filter(todo => !todo.completed);
}
if (currentFilter === 'completed') {
return todos.filter(todo => todo.completed);
}
return todos; // 'all'
}
filter 方法是数组的天然利器,它返回一个新的数组,不会修改原数组。这顺便教会我一件事:尽量用不改变原数组的数组方法去处理数据,这样每次状态更新都是不可变的,后续想加"撤销"功能会容易得多。
统计信息也很直白:总事项数、未完成数、已完成数。我在 updateStats() 里更新这些数字,然后用 Object.keys(todos).length 或者直接用 todos.length 获取总数。todos.filter(todo => todo.completed).length 获取已完成数。统计信息在每次数据变化后都要更新,所以我把 updateStats() 的调用放在 saveToLocalStorage() 之后、renderAllTodos() 之前,顺序看起来不直观,但实际运行效果没有问题。你也可以把 updateStats 并入 renderAllTodos 的末尾,看个人习惯。
4. 数据持久化坑点:localStorage 的边界与对策
4.1 数据没存上还是没取出来?先分清故障层
我必须承认,第一次运行"刷新页面数据还在"这个功能时,我失败了。现象很诡异:添加待办后一切正常,但一刷新页面,列表就空了。我一开始以为是浏览器设置问题,后来在控制台里手动执行了 localStorage.getItem('todos'),返回的是 null。这说明根本没有存进去。
再检查代码才发现,我在 addTodo() 函数里忘了调用 saveToLocalStorage()。这暴露了一个很基础但也很容易犯的错误:不能只把更新逻辑写在"视图层",必须同步到"数据层"。存储函数应该在每次数据变动之后立刻执行,而不是等整个操作结束再统一处理。梳理下来,localStorage 相关的坑主要集中在这几个方面:
| 问题类型 | 表现 | 原因 | 解决方案 |
|---|---|---|---|
| 存储格式错误 | 页面出现 [object Object] |
没有使用 JSON.stringify |
存之前转字符串 |
| 取用格式错误 | 遍历时报 todos.forEach is not a function |
取出来的还是字符串,没有用 JSON.parse |
取之后还原对象 |
| 容量超限 | 控制台报 QuotaExceededError |
存了太多大数据 | 压缩数据、清理冗余字段 |
| 初值空数组问题 | 第一次访问时 getItem 返回 null |
没有任何存储 | 用 ` |
4.2 一种更稳妥的封装:统一读写入口
为了避免上面这些"低级错误",我写了一个 todoStorage 对象,把读写逻辑封装起来,这样所有增删改查都只通过这两个方法操作存储,不会出现有的地方存了、有的地方忘存的情况。
javascript复制const todoStorage = {
get() {
try {
const raw = localStorage.getItem('todos');
return raw ? JSON.parse(raw) : [];
} catch (error) {
console.warn('读取本地存储失败,已返回空列表', error);
return [];
}
},
set(todos) {
try {
localStorage.setItem('todos', JSON.stringify(todos));
} catch (error) {
console.warn('写入本地存储失败,请注意存储空间', error);
}
}
};
try...catch 是必须的。因为 localStorage 在隐私模式或存储满时可能抛出异常,如果前端不做捕获,整个脚本会崩溃,用户看到的页面就白屏了。捕获异常后至少可以返回一个空数组,页面还能正常工作,只是数据不持久化。这是优雅降级的处理思路。
4.3 初始化加载的正确位置
数据初始化的逻辑应该放在 DOM 结构加载完成之后。我这里直接用 script 标签放在 body 末尾,所以可以放心在顶层代码里读取 localStorage。但如果你把脚本放在 head 里或者用 defer 加载可能有其他约束。标准做法是监听 DOMContentLoaded 事件:
javascript复制document.addEventListener('DOMContentLoaded', () => {
todos = todoStorage.get();
renderAllTodos();
});
这样能保证读取数据时 DOM 已经就绪。我在作业代码里用了"脚本放在 body 末尾"的方式,因为这时 DOM 已经解析完毕,直接初始化即可。两者都行,关键是知道区别。
4.4 别忘了"清除已完成"的边界情况
"清除已完成"这个功能在第一次实现时很简单:todos = todos.filter(todo => !todo.completed); 然后重新存储和渲染。但这里有一个交互细节:如果当前过滤状态是"已完成",用户把所有已完成事项清除完后,列表会变成空白,而空白状态应该显示"暂无数据"的提示,而不是一个光秃秃的列表。这个空状态的处理很容易被忽略,但如果你把空状态文案和样式做好了,整个应用的完成度会立刻上一个台阶。
同时,清除前还需要再次确认。我用了和单个删除一样的"确认按钮"交互,但这次是清空所有已完成项,影响范围大,所以我把确认文案写得更明确:"确定清除所有已完成事项吗?此操作不可撤销。"
5. 样式与交互优化:默认样式看起来太低端了
功能做出来之后,界面非常朴素。input 是系统默认白底黑边,按钮是默认的灰底黑字,列表项之间没有间距。如果直接拿这个去交作业,功能再完整也显得没有诚意。所以我把大量时间花在了样式和交互打磨上。
5.1 布局方案:Flex 垂直居中
页面整体做成卡片居中布局。主体是一个最大宽度 560px 的白色卡片,四周有圆角和阴影,视觉上"浮"在灰色背景上。这个风格现在很多 Web 应用和移动端应用都在用,可以说是"现代感"的标配。
css复制.container {
max-width: 560px;
margin: 40px auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
padding: 24px;
}
表单区域我用 Flex 加 gap 做间隔,替代了传统的 margin 布局。gap 在现代浏览器兼容性很好,用起来也非常方便,不需要担心外边距重合的问题。
5.2 状态区分:视觉符号比文字更直观
完成和未完成的状态,我用一个复选框加文本样式来区分。完成的文本加删除线、颜色变浅,复选框在选中状态下打勾。空列表状态则显示一个明显的提示语:"暂无待办,添加一条吧"。这种视觉的一致性让用户不需要靠文字就能理解状态。
5.3 动效细节:过渡不能夸张但必须有
我使用了 CSS 过渡效果来提升交互反馈感:
- 按钮 hover 状态时颜色轻微变化,过渡时长 0.2 秒;
- 复选框切换时背景色和勾选标记有 0.15 秒的过渡;
- 列表项删除前使用简单的 opacity 过渡(从 1 到 0),然后在动画结束后从 DOM 移除,这样删除过程不突兀。
如果你觉得原生动画太复杂,可以用 CSS @keyframes 做一个简单的淡出动画,再用 animationend 事件触发真正的 DOM 移除。这个思路在真实项目中很常用,也是"先动效后移除"的典型实现。
5.4 自定义确认气泡的实现
前面我提到了用"确认气泡"替代原生 confirm,这里补充一下实现思路。我在待办项内部预埋了一个"待确认"状态层,默认 display: none。点击删除时,把该状态层显示出来,里面包含"确认删除?"文本和"确定""取消"两个按钮。点击"确定"真正执行删除;点击"取消"或点击其他区域时,隐藏该状态层。这种方案完全不用写额外的事件委托逻辑,因为确认按钮的事件可以在生成每个待办节点时单独绑定一次,待办项数量有限,性能可接受。
这里有一个血泪教训:自定义气泡的 click 事件会冒泡到列表容器。如果你在容器级监听器里写的是"点击任意位置关闭气泡",那么当你点击气泡里的"确定"按钮时,事件先触发按钮的处理函数,然后冒泡到容器触发"关闭气泡",执行顺序可能导致删除逻辑被跳过。我的解决方式是在气泡内部处理完逻辑后调用 event.stopPropagation(),阻止事件继续冒泡。
6. 课程答辩前的代码审查:自己给自己找茬
作业还有一个环节是代码评审。助教会随机抽几个同学讲自己的代码思路,并现场提问。为了不在答辩时被问住,我在提交前给自己做了一轮"代码审查",每看到一个操作都问一句"为什么要这样写"。按这个过程,我总结出了几个最高频的追问点,也是很多新手最容易被问倒的地方。
6.1 为什么用 Date.now() 做 id?会不会冲突?
如果用户在同一个毫秒内连续添加两条待办,时间戳理论上会重复。实际场景中,人类手动操作添加待办的速度远达不到毫秒级,所以 Date.now() 对于这个作业来说足够好了。但如果助教追问,你可以回答:如果需要更强唯一性,可以换成 crypto.randomUUID(),或者自己写一个"时间戳加随机数"的组合 id,比如 Date.now() + '-' + Math.random().toString(16).slice(2)。
6.2 数据量大了之后,全量渲染会不会卡?
我在答辩里如实回答:对于待办列表这种轻量级数据,几十到几百条的全量渲染不会造成可感知的卡顿。但如果规模扩展到上千条,可以考虑虚拟列表或分批渲染。这样的回答既展示了你对性能边界的思考,也不会让人感觉你在过度设计。
6.3 XSS 攻击怎么防?
这是一个真正有含金量的问题。用户输入的待办内容如果是 <img src=x onerror=alert(1)> 这样的 HTML 片段,如果直接通过 innerHTML 插入到页面,这段代码就会被浏览器执行,这就是 XSS 攻击。我一开始为了图省事用了 innerHTML 拼接模板字符串,后来改成用 document.createElement('div') 和 textContent 赋值。textContent 会把所有内容当作纯文本处理,<script> 标签不会被解析成 DOM 元素,这就是最基础也最有效的 XSS 防护方案。
javascript复制function createTodoElement(todo) {
const div = document.createElement('div');
div.className = 'todo-item';
div.dataset.id = todo.id;
const textSpan = document.createElement('span');
textSpan.className = 'todo-text';
textSpan.textContent = todo.text; // 而不是 innerHTML
div.appendChild(textSpan);
return div;
}
6.4 localStorage 被用户手动清掉了怎么办?
这不是 bug,而是设计边界。localStorage 本来就是"尽力而为"的存储,它不受开发者控制。你要做的是在数据缺失时优雅降级——重新初始化为空列表,而不是页面崩溃。我在读取封装的 try...catch 里已经做了这个处理,所以这个问题答辩时答得很稳。
7. 作业演示与评分之外的一些额外收获
做完这个项目,实际运行效果是这样:打开页面会看到一条欢迎提示和空列表;输入新待办后回车,列表底部出现一条新的事项,统计数字自动更新;点击复选框,文字变成灰色加删除线,已完成数加一;双击文字可以进入编辑状态,修改内容后回车保存;删除按钮不直接删除,而是要求二次确认,避免误操作;点击"清除已完成"时,所有已完成事项会逐条淡出消失;刷新页面,所有数据原样恢复。
相比最初的朴素版本,最终效果已经有了一个"产品原型"的感觉。我在演示时还顺手加了按 Ctrl + Enter 快速聚焦输入框的快捷键,以及页面 LocalStorage 空间不足时弹 toast 提醒的功能。这些细节不需要很多代码,但能展示你对用户体验的敏感度。
在完成这个作业的过程中,我最大的感受是:前端开发的成就感往往不来自复杂炫酷的算法,而是来自把每一个交互细节都做得合理、自然。 一段 10 行代码就能实现的"回车提交",看起来简单,但如果你从未认真考虑过用户怎么用,就不会去想"输入后焦点是否归位""输入内容是否为空""输入内容是否需要去空格"这些具体问题。
如果你正在做类似的作业或项目,我的建议是先花几分钟把需求拆成"数据模型-数据操作-界面渲染-交互反馈"四个层次,然后按这个顺序写代码。数据模型决定你能不能灵活扩展功能;数据操作决定你的存储和数据一致性是否可靠;界面渲染决定你的用户看到什么;交互反馈决定用户用得顺不顺手。这四个层次分别想清楚,项目不会差到哪里去。如果你在实现中遇到了"刷新数据不见了""列表无法添加"这类问题,先打开控制台,手动执行 localStorage.getItem('todos') 看看存储层到底有没有数据。大多数情况下,问题要么出在没调用存储函数,要么出在 JSON 格式转换上。定位到这两类问题,你的项目就基本稳定了。
