ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

本地优先的时间记录工具:纯前端+IndexedDB实现离线计时与统计

本地优先的时间记录工具:纯前端+IndexedDB实现离线计时与统计 最近我给自己写了一个本地优先的个人时间记录工具起因很简单我发现自己每天在电脑前忙忙叨叨一天晚上一复盘却说不出时间到底花在了哪里。市面上的时间管理软件不是要注册账号就是数据存在别人服务器上动不动还得联网才能用。我就想要一个能完全离线运行、数据自己握着、打开浏览器就能用的东西。于是花了几个晚上用纯前端技术做了一套轻量级的“待办计时统计”三合一工具整个过程踩了不少坑也总结出一些复用价值很高的经验今天把它完整拆开讲一遍。这个工具适合谁如果你平时有记录工作耗时、统计任务分布的需求又不想把隐私数据交给第三方服务或者你本身就是前端开发者想看看“本地优先”思路在一个小工具里怎么落地这篇文章应该能给你不少参考。我会把方案选型、数据模型、核心代码、踩坑排查全部串起来讲尽量做到你照着思路就能自己复现一版。1. 内容整体设计与思路拆解1.1 为什么坚持“本地优先”而不是“上云”先说“本地优先”这个决策。我一开始也犹豫过要不要顺便接个后端把数据同步到服务器上这样换电脑也能看。后来想清楚了一件事工具的核心价值是“记”而不是“同步”。记时间日志是一件高频、低数据量的操作本地存储完全扛得住而一旦引入云端同步牵扯到账号体系、接口鉴权、数据迁移、服务器成本复杂度会直接翻好几倍。本地优先还有一个隐性好处数据完全由你掌控。时间记录是最隐私的数据之一它能精确还原你每天几点到几点在干什么这类数据放在自己浏览器里比放在任何第三方服务器上都让人安心。而且纯前端实现意味着零部署成本把 HTML 文件扔到本地双击就能跑甚至拷到 U 盘里带到公司电脑上也能用这种便携性是“服务器客户端”架构很难给我的。技术选型上我最终定了三件套原生 HTML CSS JavaScript存储用 IndexedDB图表用 Canvas 手写。没有引入任何框架和第三方库不是我不爱用框架而是对这个体量的项目来说引入框架反而会增加维护成本和依赖风险。原生代码量其实不大核心逻辑几百行就能搞定而且完全没有构建步骤改完即刷新调试路径极短。1.2 功能范围界定做什么、不做什么任何一个工具最怕的就是野心太大。我见过不少时间管理项目一开始想做记账、想做习惯打卡、想做番茄钟、想做日历视图结果代码越写越臃肿最后哪里都没做到位。所以这次我在动手之前先给自己划了一条功能边界。核心功能只保留四个任务管理、单任务计时、按日统计、数据备份。任务管理解决“今天要做什么”的问题单任务计时解决“这件事花了多久”的问题按日统计解决“时间到底去哪了”的问题数据备份解决“浏览器数据没了怎么办”的问题。除此之外什么云同步、多人协作、PWA 推送、跨设备漫游统统不做最多留一个 JSON 导入导出的口子方便手动迁移。这样做的好处是项目体量被控制在一个周末能完成的范围之内而且每个功能都能做到相对扎实。“不做”的清单同样重要它能让你在开发过程中少掉很多“这个也可以加一下”的冲动聚焦在真正核心的链路上。1.3 项目目录结构的规划项目虽然只有一个 HTML 页面但我还是按模块把代码拆开了。我的目录结构大概是这样的time-log/ ├── index.html # 页面骨架左右两栏布局 ├── style.css # 全站样式手写无框架 ├── js/ │ ├── db.js # IndexedDB 封装暴露增删改查接口 │ ├── store.js # 业务逻辑层对接 UI 和数据层 │ ├── timer.js # 计时器核心处理计时、暂停、恢复 │ ├── stats.js # 统计与图表绘制 │ └── app.js # 入口绑定事件和初始化 └── backup/ # 存放导出的 JSON 文件模块化不是为了炫技是为了让调试变得可预期。比如计时器出了问题我只需要打开 timer.js 看状态流转就行不用在一坨回调里翻来找去。JS 用的是 ES Module直接用script typemodule引入不需要打包器浏览器原生支持干净利落。2. 核心细节解析与实操要点2.1 数据模型设计两张表承载所有逻辑数据模型是整个项目里最值得琢磨的部分。我设计了两个对象仓库一个是 tasks一个是 time_events。tasks 存任务本身的静态信息比如标题、计划时长、状态time_events 存每一次计时的起止时间戳是时间消耗的“流水账”。tasks 表的结构长这样{ id: task_1701234567890, title: 写周报, planMinutes: 30, // 计划花费的分钟数可选项 status: todo, // todo | doing | done createdAt: 1701234567890, // 创建时间戳 completedAt: null, // 完成时间戳null 表示未完成 date: 2024-11-29 // 任务归属的日期本地时区 }time_events 表长这样{ id: event_1701234567899, taskId: task_1701234567890, startAt: 1701234567890, endAt: null // 正在计时时为 null结束或暂停后写入时间戳 }为什么要把时间单独拆一张表而不是直接在 tasks 里存一个“累计秒数”因为拆出来之后你能回答很多额外的问题这个任务被打断了多少次每次连续专注了多久这些信息对复盘极有价值。而且从工程角度看每次计时结束只往 time_events 插入一条记录tasks 里的累计值可以通过汇总计算得到数据一致性更容易保证。这里有一个关键点所有时间戳都存“当前本地时区”的毫秒数日期字符串也是用本地时间生成的不要直接用toISOString()因为它会转成 UTC在非零时区会出现“日期偏移一天”的诡异问题。我自己就在这上面踩过一次坑后面会详细说。2.2 UI 布局左侧输入右侧复盘界面布局我采用的是“左任务、右统计”的双栏结构宽度参考了常见的记账软件。左侧是任务操作区从上到下依次是日期切换器、“新增任务”输入框、任务列表。右侧是统计区上半部分展示今日总投入时长和任务完成率下半部分是一张按小时分布的柱状图。左侧任务区每个任务卡片上放了三个关键操作按钮“开始/暂停”按钮、“完成”按钮、“删除”按钮。开始按钮是核心交互点击后按钮会变成“暂停”样式同时卡片背景色会变淡用来提示当前正在计时。这个视觉反馈很重要不然你很容易忘了自己还在计时一挂就是两小时。右侧统计区不做得很复杂就两张信息卡和一张图。信息卡一今天的总记录时长信息卡二已完成任务数量/总任务数量。柱状图则是把一天按小时切成 24 段统计每个小时里累计的专注分钟数。看起来简单但复盘“下午 3 点到 4 点之间的时间黑洞”这种问题非常直观。2.3 状态流转任务生命周期管理任务的状态机是我花了不少心思的部分。一个任务从创建到删除中间可能要经历这些状态todo待办→ doing进行中→ todo暂停回来→ doing再次开始→ done完成。这里有一个小设计值得说一下任务一旦完成就不允许再启动了这是为了避免误操作把已完成的任务又拉回进行中。状态流转代码我用一个简单的函数管理function canTransition(task, nextStatus) { if (task.status done nextStatus doing) return false; if (task.status doing nextStatus doing) return false; return true; }这种校验逻辑虽然只有几行但能挡住很多边界情况。比如用户对着一个已完成的任务疯狂点“开始”或者在任务已经进行中的时候再点一次开始这些都不应该产生新的计时事件。校验放在 store 层而不是 UI 层这样即使以后换了一套 UI比如改成命令行交互核心逻辑依然不会出错。3. 实操过程与核心环节实现3.1 原生 IndexedDB 封装不引入 idb 库也能顺手先说数据库封装。我原本想偷懒引入 idb 这个库后来想想项目就两张表手写一个 Promise 风格的封装并不难还能少一个依赖。核心就是打开数据库、建表、提供 get/add/put/delete 几个方法。// db.js 核心片段 function openDB() { return new Promise((resolve, reject) { const request indexedDB.open(time-log-db, 1); request.onupgradeneeded (e) { const db e.target.result; if (!db.objectStoreNames.contains(tasks)) { db.createObjectStore(tasks, { keyPath: id }); } if (!db.objectStoreNames.contains(time_events)) { const store db.createObjectStore(time_events, { keyPath: id }); store.createIndex(taskId, taskId, { unique: false }); store.createIndex(startAt, startAt, { unique: false }); } }; request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }注意store.createIndex(taskId, taskId)这一行这个索引在“查某个任务的所有计时事件”时是必用的没有它你就得全表扫描数据量一大就卡。IndexedDB 的坑在于 API 是事件回调风格的直接写很啰嗦所以我在上面包了一层 Promise再提供几个业务方法async function getAllTasks() { const db await getDB(); return new Promise((resolve, reject) { const tx db.transaction(tasks, readonly); const store tx.objectStore(tasks); const request store.getAll(); request.onsuccess () resolve(request.result); request.onerror () reject(request.error); }); }所有数据库操作都走这一层后面业务逻辑不用关心 IndexedDB 的具体 API代码读起来清晰很多。3.2 计时器核心实现时间戳差值是灵魂计时器是这个项目最容易出错的地方。很多人第一反应是setInterval每秒把数字加一这样写问题很大浏览器会在后台标签页节流定时器切出去一会儿再回来计时可能少算了。我的做法是页面上显示用setInterval但底层记录用“时间戳差值”两套配合使用。具体思路是这样的。点“开始”时不启动任何计数器只是把当前时间戳Date.now()存进内存和一个全局变量里。然后启动一个每秒执行的setInterval它的唯一职责是重新计算并刷新 UI。每次计算时用Date.now() - startTimestamp得出已耗时然后格式化成HH:mm:ss显示出来。// timer.js 核心逻辑 let startTs 0; let timerDisplay null; function startTiming(taskId) { startTs Date.now(); runningTaskId taskId; timerDisplay setInterval(updateDisplay, 1000); // 同时把 startTs 写入 localStorage用于刷新恢复 localStorage.setItem(runningTask, JSON.stringify({ taskId, startTs })); } function updateDisplay() { const elapsed Date.now() - startTs; document.querySelector(#timer-display).textContent formatDuration(elapsed); }即使setInterval因为后台节流变成 30 秒才触发一次但你每次触发时都是用真实时间戳重新算的所以最终 UI 显示的时间依然准确不会出现“计时器慢了 20 秒”的尴尬。暂停时把elapsed写入 time_events 表再清掉定时器恢复时重新把Date.now()设为起点把之前已累计的秒数作为基础偏移量。这里还有一个细节formatDuration函数不要去手动补零补仨小时之类的直接用Math.floor(ms / 1000)得到总秒数然后依次取小时、分钟、秒分别用String.prototype.padStart补零省心也不会出错。3.3 页面刷新后计时状态恢复用户可能正在计时的时候不小心按了 F5或者浏览器崩了重启这时候计时状态不能丢掉。我的做法是开始计时时把{ taskId, startTs }同时写进localStorage页面加载时读这个字段如果存在并且对应任务还在“进行中”状态就恢复计时器。function restoreRunningState() { const raw localStorage.getItem(runningTask); if (!raw) return null; const { taskId, startTs } JSON.parse(raw); const task await getTask(taskId); if (task task.status doing) { resumeTimer(taskId, startTs); return taskId; } // 如果没有对应任务或者任务已结束清理残留状态 localStorage.removeItem(runningTask); return null; }恢复的时候要注意一个细节startTs是开始计时的时间戳而不是“上次暂停的累计值”。如果你中间暂停了 10 分钟再恢复然后再刷新直接用Date.now() - startTs会把暂停的 10 分钟也算进去。解决办法是恢复时把基础偏移量也存下来比如存{ baseMs: 60000, lastStartTs: 1701234567890 }已耗时等于baseMs (Date.now() - lastStartTs)。这个逻辑不复杂但很容易被忽略。3.4 统计与图表绘制手写 Canvas 柱状图统计部分我原本想用 Chart.js后来觉得数据量太小没必要引一个 200KB 的库就手写了一个 Canvas 柱状图。先按小时分组统计每个小时内的专注分钟数function getHourlyStats(events) { const bins new Array(24).fill(0); for (const ev of events) { const startHour new Date(ev.startAt).getHours(); const durationMin (ev.endAt - ev.startAt) / 60000; bins[startHour] durationMin; } return bins; }然后绘制柱状图时关键是坐标换算。Canvas 的原点在左上角y 轴向下所以要做一次翻转barHeight (value / maxValue) * chartHeight像素坐标y chartHeight - barHeight。颜色渐变我也顺手做了用ctx.createLinearGradient让每根柱子从底部深色渐变到顶部浅色视觉上舒服很多。图表不追求花哨但轴的标注必须清楚x 轴标 0、6、12、18、24 几个整点y 轴标最大值的尺度和一半的尺度。鼠标悬浮提示暂时没做只在点击柱子时在下方显示具体分钟数这样工作量可控复盘也够用。3.5 数据备份与恢复JSON 导入导出数据备份是用浏览器下载文件的方式做的。导出时从 tasks 和 time_events 两张表里把所有记录读出来拼成一个 JSON 对象然后生成 Blob用a download触发下载。function exportBackup() { const data { version: 1, exportedAt: new Date().toISOString(), tasks: [...], timeEvents: [...] }; const blob new Blob([JSON.stringify(data, null, 2)], { type: application/json }); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download time-log-backup-${Date.now()}.json; a.click(); URL.revokeObjectURL(url); // 释放内存 }导入恢复的逻辑类似读取用户选择的 JSON 文件解析后逐条写入数据库。这里有个坑如果原数据库里已经有数据导入时要不要覆盖我的选择是提供一个“合并”选项如果导入的某条任务 id 在本地已存在就跳过避免把新的记录覆盖掉。这样即使你多次备份导入也不会造成灾难性数据丢失。4. 常见问题与排查技巧实录4.1 计时不准浏览器节流和休眠问题这是遇到最多的一个问题。表现是开着计时器挂着人走开去开会回来看屏幕发现计时器少了十几分钟。原因有两层第一层是浏览器对后台标签页的定时器节流我上面已经说了通过时间戳解决第二层是电脑休眠后JavaScript 定时器整个停摆休眠期间本来就不该计入专注时间这属于合理偏差。还有个容易忽略的点setInterval(updateDisplay, 1000)每秒执行一次但updateDisplay本身是不准的真正准的是Date.now() - startTs。如果你发现显示时间有误差不要去调setInterval的间隔而要检查startTs是否被意外重置了。我刚开始写的时候把startTs放在了updateDisplay函数里面声明结果每秒钟startTs都被重新赋值为当前时间计时器永远停在“刚刚开始”这是个很低级但很容易犯的错误。4.2 跨天任务归属千万别用 toISOString这个坑比较隐蔽。我最初生成任务日期是用new Date().toISOString().slice(0, 10)在 UTC8 时区下如果现在是凌晨 1 点toISOString()会返回前一天日期。于是就会出现“今天 1 点创建的任务被归到了昨天”的诡异现象。排查了半天才发现是 UTC 转换的问题。解决办法很简单用本地时间手动拼日期。function getLocalDateString(d new Date()) { const y d.getFullYear(); const m String(d.getMonth() 1).padStart(2, 0); const day String(d.getDate()).padStart(2, 0); return ${y}-${m}-${day}; }同理计算跨天任务的耗时归属时我采用的是“开始时间归属制”事件从哪一天开始就把时长计入那一天。跨过午夜继续计时的情况比如从 23:50 干到 00:20实际上一半时间属于前一天、一半属于后一天但简单起见统一归属到开始那天。这个规则我会在界面上提示避免用户困惑。4.3 IndexedDB 版本升级导致数据丢失IndexedDB 有个机制onupgradeneeded只在版本号变化时触发。如果你改了表结构但忘了把版本号从 1 升到 2浏览器根本不执行新代码甚至可能直接报版本冲突错误。反过来如果你版本号升得太快而本地数据库已经是 2打开数据库时版本号传 3也会抛错。我的排查经验是永远在onupgradeneeded里做结构迁移。比如你后续想给 tasks 表加一个tags字段一定要把indexedDB.open(time-log-db, 2)的版本号改成 2然后在onupgradeneeded里用db.objectStoreNames.contains判断一下再做增加字段的操作。开发阶段数据可以无所谓但如果工具已经在日常用了这一套必须事先想好否则一次版本升级就能清空所有记录。4.4 浏览器清理缓存导致数据“消失”IndexedDB 数据存在用户磁盘上理论上不会轻易消失但如果你用无痕模式或者在浏览器设置里清了“站点数据”数据就没了。这个坑我是在一次“数据凭空消失”的排查中发现的原来是我另一台电脑上浏览器开启了“关闭时清理浏览数据”的选项索引数据被一并清掉。对这种问题的态度是既然本地存储必然有被清理的风险那就把“备份”放在和“计时”同等重要的位置。我在工具右上角加了一个“备份提醒”的按钮点一下就能导出 JSON同时界面底部会显示“上次备份时间”。从实际使用效果看正是这个备份机制在后来一次浏览器重置中救了全部数据。4.5 高频率写入的 Async 竞态问题IndexedDB 的所有操作都是异步的如果你在一个计时结束的瞬间同时触发“写入事件”和“读取统计”有可能会因为请求完成的先后顺序不同导致统计结果漏掉刚写入的数据。尤其是暂停按钮连点两下或者暂停和统计按钮同时触发很容易出现竞态。我的解决办法是引入一个简单的队列所有数据库写入操作排成 Promise 队列写入完成后才允许后续的读取执行。JavaScript 单线程模型的异步机制让我只需要把“写”和“读”串行化就能规避大部分竞态。虽然在目前的数据量下这个 bug 概率很低但它一旦发生就让人困惑排查成本远高于预防成本值得一开始就处理好。5. 最后分享几个我自己用出来的小技巧第一任务标题别写太长。我之前习惯写很详细的描述比如“整理项目文档并发送给客户确认”结果在任务列表里显示不全还得靠鼠标悬浮看全文反而降低了记录效率。现在我会把任务拆成一个动词一个对象的结构“整理文档”“发客户确认”“订会议室”简洁明确统计复盘时一眼就能看懂。第二每天下班前花 30 秒看一眼统计图。这不是教条而是我自己复盘下来的真实体验——柱状图如果下午 3 点到 4 点之间出现长条空白十有八九那段时间用于刷网页了。知道事实是改变的第一步工具的价值不在于管住你而在于呈现事实。第三给工具留一个“扩展口子”。我虽然说不做同步但导出的 JSON 是标准格式后续如果真要写一个同步脚本或者把数据导入到某个数据分析工具里完全不需要改数据结构。一开始就把数据格式设计得通用后面怎么用都方便。这个小工具前后加在一起大概花了我两个晚上从数据模型到 UI 再到备份一路写下来最大的收获不是代码量而是“本地优先”这个思路在小工具上的可行性。如果你也打算做一个类似的工具建议你从最简版本开始先搞定“录入-计时-统计”这条主干再慢慢加功能。每一步都保持能运行、能导出数据的状态就不会被复杂度和数据丢失卡住。最后再提一句IndexedDB 这套方案真的很适合做这类“私人数据”工具。你不用后端不用买服务器不用注册第三方账号打开一个 HTML 文件就能拥有一个完全属于自己的小工具这种掌控感用过一次就回不去了。
返回列表