本地优先的时间记录工具:纯前端+IndexedDB实现离线计时与统计
2026/9/24 19:10:55 网站建设 项目流程

最近我给自己写了一个本地优先的个人时间记录工具,起因很简单:我发现自己每天在电脑前忙忙叨叨一天,晚上一复盘,却说不出时间到底花在了哪里。市面上的时间管理软件不是要注册账号,就是数据存在别人服务器上,动不动还得联网才能用。我就想要一个能完全离线运行、数据自己握着、打开浏览器就能用的东西。于是花了几个晚上,用纯前端技术做了一套轻量级的“待办+计时+统计”三合一工具,整个过程踩了不少坑,也总结出一些复用价值很高的经验,今天把它完整拆开讲一遍。

这个工具适合谁?如果你平时有记录工作耗时、统计任务分布的需求,又不想把隐私数据交给第三方服务,或者你本身就是前端开发者,想看看“本地优先”思路在一个小工具里怎么落地,这篇文章应该能给你不少参考。我会把方案选型、数据模型、核心代码、踩坑排查全部串起来讲,尽量做到你照着思路就能自己复现一版。

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 type="module">引入,不需要打包器,浏览器原生支持,干净利落。

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),在 UTC+8 时区下,如果现在是凌晨 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 文件就能拥有一个完全属于自己的小工具,这种掌控感,用过一次就回不去了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询