资讯动态

用localStorage和原生JS打造一个可统计的任务看板:数据驱动渲染与事件委托实践

发布时间:2026/10/20 4:31:16 来源:尧图企业网站定制
DAY 3写在前头这是我自己发起的连续30天每天一个Web小项目挑战的第三天记录。前两天分别做了倒计时页和随机颜色生成器都是热身级别的小玩意儿。今天这个项目我决定做点真正会在日常里用到的东西——一个带基础统计功能的本地任务看板用来管理这三十天每天的产出。毕竟挑战进入第三天手头同时要推进的事情开始变多光靠脑子记已经有点吃力了。这个小看板的核心逻辑很简单任务数据全部存在浏览器 localStorage 里页面加载渲染任务卡片支持添加、勾选完成、删除顶部一排统计卡片展示今日完成数、累计完成数、进行中数量和当前连续打卡天数。没有后端没有构建工具单 HTML 文件加原生 JavaScript 加上一点点 CSS 就能跑起来。今天这篇就从头到尾讲讲这个看板是怎么从零搭出来的包括遇到了哪些坑、哪些地方做得不够好、如果让我重来一遍会怎么改。1. 项目定位与设计思路拆解1.1 为什么选“任务看板”而不是框架练习做前三天的项目我一直都在有意识地控制复杂度。第一天倒计时页只练了 Date 计算和 setInterval 的清理第二天随机颜色生成器练的是数组操作和事件绑定。到了第三天我开始往项目里塞进一点真正的状态管理——多条任务数据的渲染、修改、持久化。这个选择背后其实有个很实用的理由。连续30天每天做一个项目最大的敌人不是难度而是做到一半不想做了的倦怠感。如果把前几天的项目定位在刚好吃得动的难度推进起来会顺很多。任务看板这个主题功能边界清晰增删改查加统计不会像做一个完整管理系统那样越做越没底同时又不至于像倒计时那样一眼望到头。另一个考虑是实用性。挑战期间我每天要记的事其实不少比如写DAY 3博文优化部署脚本回复留言……如果我每天自己都在用这个小工具那后面几天需要修什么、加什么功能需求会非常明确不用凭空设计。事实证明这个判断是对的——写完这个项目之后我立刻把当天的剩余任务录了进去然后整天都在用。1.2 技术方案原生三件套不碰框架技术上这次我依然没有引入任何框架连构建工具都没上。原因很简单一个本地单机工具项目规模大概三百行代码引入框架反而会增加心智负担和调试成本。原生 JavaScript 的读写会直接暴露在眼前每一行代码干了什么清清楚楚。而且对于挑战这种快速产出节奏零依赖意味着零环境问题——随便一个浏览器打开就能跑这对我来说非常重要。存储层选 localStorage 而不是 sessionStorage 也有讲究。sessionStorage 的数据在标签页关闭后会清空适合临时场景localStorage 会一直保存符合任务管理这种长期使用的需求。代价是 localStorage 只能存字符串对象需要 JSON.stringify 序列化读取时需要 JSON.parse 反序列化。这个序列化和反序列化过程刚好是后面各种 bug 的高发区后面会详细讲到。界面布局我采用了顶部统计栏 中部任务列表 底部输入区的经典三栏结构。统计数字放在最上方是因为任务看板的核心价值就是一眼看清今天还有几件事没做这个信息必须零眼动成本就能获取。输入区放底部贴合从上到下浏览然后落笔的视觉动线这是我从手机备忘录的操作习惯里提炼出来的。1.3 数据模型设计一个任务对象长什么样在开始写代码之前我先把任务的字段结构定下来了这是整个项目的地基。每个任务就是一个普通的 JavaScript 对象{ id: t_1729084800000_3, title: 写DAY 3博文, status: done, // todo 或 done priority: high, // high / normal / low createdTime: 1729084800000, // 时间戳毫秒 finishedTime: null, // 完成时写入时间戳未完成为 null dayKey: 2024-10-16 // 归属日期形如 YYYY-MM-DD }这里比较关键的设计是 dayKey 字段。任务不是简单地堆在一个数组里而是按日期归类。为什么要这样设计因为统计功能里必然有今天完成了几件事连续打卡多少天这类按天聚合的需求。如果任务对象里没有归属日期那今天只能靠 createdTime 所在的日期来算但一个昨天晚上创建、今天才完成的任务到底算昨天创建还是今天完成会很容易混乱。有了 dayKey 和 finishedTime统计逻辑就清晰多了。顺带一说id 我用的是时间戳加随机数写成字符串拼接。时间戳保证基本唯一随机数防止同一毫秒内创建多个任务时碰撞。虽然在小项目里用数组下标当 id 也能跑但还是值得养成生成唯一 id 的习惯——后面做删除、状态切换都要用 id 定位如果 id 会变比如排序后引用关系就会断。2. 核心功能拆解与实现细节2.1 存储层封装把 localStorage 包成两个函数任务数据要持久化我写了两个极简函数来封装 localStorage 操作避免在业务逻辑里到处写 JSON.parse 和 JSON.stringifyfunction loadTasks() { try { return JSON.parse(localStorage.getItem(day3_tasks) || []); } catch (e) { console.warn(任务数据解析失败已重置为空数组, e); return []; } } function saveTasks(tasks) { localStorage.setItem(day3_tasks, JSON.stringify(tasks)); }注意 loadTasks 里我把 JSON.parse 包在 try-catch 里了。这个习惯是被坑出来的——如果某次你手动改 localStorage 时写错了 JSON 格式或者旧版本数据结构和新的不兼容JSON.parse 就会抛异常整个页面直接白屏。有个兜底逻辑至少能保证页面能打开数据虽然丢了但比看板完全不可用要好太多。saveTasks 其实也有一个隐含问题高频写入时性能不好。localStorage 的写入是同步的每次 setItem 都会阻塞主线程一小会儿。对于任务看板这种低频率写入场景完全够用但如果你以后要做实时联动、拖拽排序、每拖一步就保存一次建议改用防抖保存——也就是只在操作停止后几百毫秒才真正写一次存储这个优化后面我再展开。2.2 渲染逻辑render 函数把整个列表画出来整个看板的核心就是一个 render 函数。它读取当前的任务数组根据筛选条件和排序规则生成 HTML 字符串后插入到容器元素中function render() { let tasks filterTasks(getVisibleFilter()); tasks sortTasks(tasks); const container document.getElementById(taskList); if (tasks.length 0) { container.innerHTML div classempty-tip这里什么都没有添加一条任务吧/div; return; } container.innerHTML tasks.map(task { const doneCls task.status done ? task-done : ; return div classtask-item${doneCls}>function toggleTask(id) { const tasks loadTasks(); const task tasks.find(t t.id id); if (!task) return; task.status task.status done ? todo : done; task.finishedTime task.status done ? Date.now() : null; saveTasks(tasks); render(); }2.5 删除与自动清理别让数据膨胀删除功能相对简单点击删除按钮确认一下再删我做了 confirm然后从数组里移除该任务。但数据积累带来的另一个问题需要处理——今天的任务看完了明天再看板子上还是满满当当没有区分今天和历史的视图那这个工具用几天就废了。我的处理方式是加了一个简单的视图过滤底部提供三个 tab——今日待办所有任务已完成。今日待办按 dayKey 今天的日期筛选同时 status todo所有任务展示全部已完成展示 status done。这样每天早上打开页面直接落在今日待办标签上看到的就是一个干净清爽的当日清单不会被前几天残留的信息干扰。另外我手动加了一个清理逻辑删除已完成且完成时间超过七天前的任务作为一次深度清理入口。因为任务数据存 localStorage 不像数据库有索引任务量太大时整个 JSON 字符串会变得又长又慢每打开页面解析一次就要卡一下。对这类轻量工具定期的全量清理比写一个复杂的增量清理简单可靠得多。3. 实操过程从零搭到完整可用3.1 页面骨架与样式细节整个页面我的文件结构非常传统一个 index.html一个 style.css一个 app.js。开发时我直接在本地用浏览器打开 HTML 文件就能调试不需要起任何本地服务。这里需要注意一点如果用 file:// 协议打开localStorage 在绝大多数浏览器中同样可用但某些浏览器对 file:// 下的 localStorage 行为会有差异比如存储配额或隔离机制。如果你调试时发现数据写入没生效建议用python3 -m http.server 8000起个本地静态服务然后访问 localhost:8000这样最稳。CSS 方面我保持了极简风格主背景用了深色系卡片用稍亮一档的圆角块完成状态的任务标题加删除线和 50% 透明度。统计卡片四个数字颜色做了区分今日完成用绿色累计完成用蓝色待办数用橙色连续天数用紫色。这些颜色不是随便选的绿色主体传达完成的正向反馈蓝色代表累积橙色提醒还有活要干紫色则给出坚持的成就感。功能和视觉的对应关系明确看一眼就能懂。对于移动端适配我用了一个简单的媒体查询把三列布局压成单列。这个工具的主导使用场景其实是手机浏览器而不是桌面——我白天不在电脑前时打开手机浏览器就能看到今天的任务完成一条就划一条。移动端的核心体验是输入框要够大、按钮要够好点我给复选框和删除按钮都设置了至少 40px 的点击区域避免出现想点删除点成了完成的操作失误。3.2 统计卡片背后的计算逻辑统计卡片是今天这个项目里最需要动一点脑子的地方。每张卡片背后都有一段聚合计算我分别说今日完成数对已完成任务做筛选条件是 finishedTime 和今天零点到现在做区间比较。这里注意不能用new Date().getDate()去直接比对因为 getDate 只返回几号跨月之后判断会混乱。正确做法是拿到今天的零点时间戳然后判断 finishedTime 今天零点 finishedTime 明天零点。我封装了一个 dayRange() 函数统一生成这两个值。累计完成数统计 status done 的任务总数这个最简单直接 filter 一下取 length。待办数status todo 的任务总数其中又分今日待办和任意日期的待办我显示的是全部待办让用户知道积压量大概有多大。连续打卡天数这个稍微绕了一下。我的定义是从今天开始倒推最早哪天之前没有完成记录就停。实现方法是维护一个 Set存放所有 done 任务的 dayKey然后从今天往回逐天查询查到某一天在 Set 中不存在就停止计数返回累计天数。这个算法时间复杂度是 O(连续天数)对于个人使用完全够快。如果数据量更大可以改成用有序数组二分查找第一个缺口。function calcStreak(tasks) { const doneSet new Set(); tasks.forEach(t { if (t.status done t.finishedTime) { const d new Date(t.finishedTime); const key formatDateKey(d); doneSet.add(key); } }); let streak 0; const now new Date(); for (let i 0; ; i) { const d new Date(now.getFullYear(), now.getMonth(), now.getDate() - i); const key formatDateKey(d); if (doneSet.has(key)) { streak; } else { break; } } return streak; }顺便说一句formatDateKey 这种函数千万别自己拼字符串去补零。我一开始是${d.getFullYear()}-${d.getMonth()1}-${d.getDate()}结果 5 月 3 日生成的 key 是 2024-5-3和 2024-5-13 在 Set 里完全不是一回事。必须统一格式化成两位数的 YYYY-MM-DD用String(n).padStart(2, 0)补零。这类日期字符串格式不一致的 bug 非常隐蔽而且只在特定日期出现排查起来极其头大。3.3 事件委托只绑一次监听别重复绑定渲染会频繁重建 DOM如果每次渲染后都给按钮重新绑事件会越绑越多最后出现重复点击事件执行多次的问题。我的解法是在容器上只做一次事件委托用事件对象的 target 去判断点到了什么document.getElementById(taskList).addEventListener(click, (e) { const delBtn e.target.closest(.btn-del); const checkBox e.target.closest(.task-check); const item e.target.closest(.task-item); if (!item) return; const id item.dataset.id; if (delBtn) { if (confirm(确定要删除这条任务吗)) { deleteTask(id); } return; } if (checkBox) { toggleTask(id); } });使用 closest 而不是直接判断 e.target 是 input 或 button好处是哪怕按钮内部还有子元素比如图标和文字点中内部任意位置都能正确向上查找到目标元素。这一个小函数省去了大量按钮里套了个 span 导致事件绑不中的经典坑。输入框的键盘事件需要注意回车提交已经是最通用的交互了我在 keydown 里监听 Enter 键并阻止默认行为避免在 form 场景下触发整页刷新。这里我特意没有用 form 提交的天然回车行为因为页面里没有真正的后端 endpoint提交表单会导致浏览器进行页面跳转或刷新配合 localStorage 倒是能恢复数据但那种闪一下白屏再回来的体验很糟糕。3.4 排序逻辑待办优先时间倒序排序规则我定的是先按完成状态分组未完成在上面然后组内按创建时间从新到旧排。这个规则背后对应的是真实行为——我打开看板最想看到的是现在还没做完的事按我刚加的顺序排下来而不是按照某个我不常打理的 priority 字段去排。如果将来要支持优先级排序我建议把排序设计成一个可切换的状态而不是把多个维度混在一个 compare 函数里写死。我之前做过一个项目把优先级和时间混在一起算加权分值结果排序结果总是出乎意料后来痛定思痛发现单一排序维度的规则最可预测也最好跟用户解释。3.5 完整代码结构速览整个项目最终的精简代码我贴一份大致结构方便想直接照着做的朋友有一个整体感知细节比较长的函数我做了省略处理// 数据层 const STORAGE_KEY day3_tasks; function loadTasks() { /* 读取 JSON.parse 兜底 */ } function saveTasks(tasks) { /* JSON.stringify setItem */ } // 工具函数 function genId() { /* 时间戳 随机数 */ } function formatDateKey(d) { /* YYYY-MM-DD */ } function dayRange() { /* 返回今天零点/明天零点时间戳 */ } function escapeHTML(str) { /* 转义特殊字符 */ } // 业务逻辑 function addTask(title, priority) { /* 创建对象推入数组保存重渲染 */ } function toggleTask(id) { /* 切换完成状态写 finishedTime保存重渲染 */ } function deleteTask(id) { /* 从数组过滤掉指定 id保存重渲染 */ } function getVisibleFilter() { /* 读取当前选中的 tab */ } function filterTasks(filter) { /* 按 filter 过滤 */ } function sortTasks(tasks) { /* 按状态创建时间排序 */ } // 渲染层 function render() { /* 生成 HTML 插入容器再 updateStats */ } function updateStats() { /* 更新四张统计卡片数字 */ } // 事件绑定 document.getElementById(taskInput).addEventListener(...); document.getElementById(taskList).addEventListener(...); document.querySelectorAll(.tab).forEach(...); // 启动 render();如果你自己动手写一遍我建议也采用这个结构数据层、工具函数、业务逻辑、渲染层、事件绑定、启动。这个分层习惯了以后哪怕项目复杂度翻十倍改动起来也不会乱套。4. 常见问题与排查技巧实录4.1 页面白屏打开就是一片空白这个项目最容易翻车的场景就是白屏。我实际调试时遇到过一次不小心把 localStorage 里手动塞了一段不合法 JSON然后 loadTasks 里没有 try-catchJSON.parse 直接抛异常整个脚本执行中断页面什么都没渲染。这个问题的通用排查思路是一层层剥离先看浏览器控制台有没有报错大概率有顺着报错定位到具体函数然后把 loadTasks 的返回值手动替换成[]看页面是否恢复正常最后再把数据恢复看是不是数据本身有问题。经验是凡是解析外部数据localStorage、URL 参数、接口返 回的地方都必须做异常兜底。这个习惯越早养成越好看似多写三行 try-catch实际上能省掉日后大量的线上排查时间。4.2 数据渲染出来了但勾选状态总是不对这个 bug 我调了很久最后发现原因有一半出在渲染顺序上。当用户勾选一个复选框change 事件触发我的逻辑里先执行了 toggleTask保存状态然后又调了 renderrender 重建了 DOM。到这里其实没有大问题真正的问题在于某些浏览器中checkbox 的 checked 属性在用户点击之后、DOM 被重建之前浏览器内部已经先拖动了选中状态。等到 render 新建的 checkbox 带上了新状态用户看到的视觉状态和 checked 属性对不上就会出现闪一下又跳回去的错觉。解决方案有两个方向一是复选框的点击事件直接改成 pointerdown 而不是 change抢在浏览器自动置灰之前做处理二是更彻底地拥抱数据驱动——在渲染模板时checkbox 的 checked 完全以任务数组里的状态为准不依赖浏览器内部的即时 UI 状态。我把事件换成了 change 事件 数据驱动模式之后这个问题彻底消失了。4.3 localStorage 写入失败多标签页互相覆盖本地存储偶发问题里最隐蔽的是多标签页之间的覆盖。我如果同时打开了两个标签页操作同一个看板标签页 A 加了一条任务保存到 localStorage标签页 B 里还是旧数据B 随后又保存了一次——它会用自己内存里的旧数组覆盖 A 刚写入的新数据。结果 A 添加的任务消失了。处理这个问题最简单可靠的方式是监听 storage 事件。storage 事件可以在其他标签页写入 localStorage 时给当前页面发送通知此时重新 loadTasks 并 render 一次让多个标签页保持一致。注意 storage 事件只在其他标签页触发当前页面自身写入不会触发所以不会造成死循环。另外别忘了在事件回调里做轻量防抖连续写入时不要每写一次就重渲染一次。4.4 统计数字对不上排查思路是什么有一次我发现完成卡片数字明显少于实际已勾选的任务数。排查后才明白问题出在已完成任务被误删了——我某次调试时写了一个清空函数把 localStorage 里整个数组清了完成数当然对不上。这类问题跟代码逻辑关系不大反而提醒我本地存储的数据本质上很脆弱一个低级失误就会清空全部内容。建议给这个工具加上导出备份和导入恢复两个功能把当前数组导出成一个 JSON 文件存到本地每月导出一次即可。如果数字对不上又不是因为误删那多半是统计维度定义上出了问题。比如我计算今日完成数用了 finishedTime 的日期但如果任务在完成状态下被创建有些工具允许直接创建已完成任务finishedTime 是 null这个任务就永远不会出现在今日完成里。需要明确这类业务规则避免统计口径前后不一致。4.5 输入中文时回车直接提交了还没选好词这是一个非常中国特色的坑。在输入框打拼音时还处于输入法组合状态此时按回车原本是确认选字的意思但如果我的 keydown 监听不加任何判断就会直接触发提交逻辑把没打完的拼音当成任务标题加进去。这个问题我在第二天写颜色生成器时就踩过一次所以这次直接预留了解决方案input.addEventListener(keydown, (e) { if (e.key Enter !e.isComposing) { addTask(); } });关键就是这个e.isComposing属性。它在输入法组合输入期间为 true此时回车是输入法内部的确认动作不应该被当成提交。这个属性兼容性很好是处理这类问题的标准姿势。4.6 性能问题渲染慢怎么办这个看板最大的性能瓶颈在 render 里大量拼接大字符串。如果任务数量到了几千条每次加一条任务都会重建整个列表浏览器会开始感觉到明显卡顿。优化思路无非两条变成局部更新只插入新节点到列表头部或者用 DocumentFragment 批量插入。但如果数据量真的到了那个量级我更建议换个思路——分页或者只渲染今天的数据反正这个工具不太需要把几千条历史任务一次性全展示出来。5. 完成后的持久化思考与调用体验回顾这个项目做过一遍之后我把看板数据接入了自己当前的真实工作流用了大半天有几个体会还挺值得说一说的。第一今天做的最值的一个决定是用了 dayKey 这个字段。头两天临时记着要跟进的几件事我都没往系统里录结果今天打开看板发现历史遗留的数据和今天的待办混在一个列表里非常混乱。好在按天聚类的设计让今日待办视图很干净该忘掉的旧任务不会跳出来干扰注意力。第二加任务的操作越顺手使用频率越高。我一开始把添加任务放在页面底部但手机上单手操作时拇指要往下伸。后来我把输入框在移动端媒体查询里固定到了页面底部类似于手机五花八门的底部输入栏设计经历了一次小小的改动以后录入体验顺了很多。这一点让我意识到小工具的功能设计如果不贴合实际操作场景功能再完备也不会被长期使用。第三颜色编码和完成动效虽然小但确实会影响使用意愿。完成任务时统计卡片上的数字会滚动更新复选框前有一个轻微的动画这些看似多余的细节在真实使用中给了我非常强烈的正反馈甚至让我有点想为了连续打卡而多记录一些任务。工具的鼓励机制在个人效率工具里影响力比我想象中大得多。第四导出备份的重要性远超预期。早上拿去跑给朋友演示时他无意识地打开了控制台在 Application 面板里手动清了 localStorage——所有数据瞬间清零。虽然这个工具当初就是为了练习开发丢了数据也不是什么大损失但那一刻我深切理解了为什么导出备份对本地存储应用来说是标配功能而不是锦上添花。我打算后面的挑战里找个时间把这个功能补上做成一个导出和导入都支持的 JSON 文件接口最好再有个自动备份到本地的方案。这两天连续写下来我发现当天做一个小工具的套路已经有点上明白了——头一天想好题目第二天动手写完第三天在真实使用中找到问题。今天这个任务看板虽然还是一个几百行的原生 JS 小玩具但它已经能解决我目前实际的信息管理需求更重要的是它逼着我把数据建模、渲染更新、事件委托、异常兜底这些基本功又完整地过了一遍。我希望后面几天做的项目也能像今天这样不是为了做而做而是真的能融进接下来几天的挑战流程里。下一轮挑战的题目我已经想好了给这个看板加一个按周汇总的数据图表把每天完成的任务量画成柱状图直观看到这波三十天计划的完成节奏。这个项目应该会用到 Canvas 或者轻量级图表库到时候再把踩坑心得更新出来。今天就先到这我得去勾掉今天最后一条待办了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑