news 2026/9/15 15:39:41

微信小程序番茄时钟:从setInterval到时间戳校准的完整实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
微信小程序番茄时钟:从setInterval到时间戳校准的完整实现

简介:微信小程序番茄时钟项目,以经典番茄工作法为核心场景,面向小程序初学者、前端开发者及希望快速搭建效率工具的人群,帮助解决自定义专注计时与环境搭建问题。整套资源可作为可直接运行或参考改造的项目源码,包含页面布局、样式定义、逻辑处理和配置信息,共22个文件,压缩包整体约1.79MB。文件类型以js逻辑文件、wxml页面结构、wxss样式表为主,配有png静态截图与gif动态演示,另含README说明文档和统计工具快捷入口,目录层次清晰,适合按模块查阅和调试。学习源码可重点理解定时与状态切换、页面数据绑定、事件交互等小程序基础写法,结合截图与动图能快速核对倒计时展示、进度反馈和按钮态变化;README文档则提供项目背景和运行说明,方便二次开发。目前已有1456人浏览或学习,适用于课程设计、兴趣开发、日常任务管理工具改进,也可在现有基础上增加提醒音效、待办清单等自定义功能。

1. 微信小程序番茄时钟:难点不在 UI,在时间走了没

把番茄时钟搬进微信小程序,很多人第一反应是“页面不复杂,setInterval 每秒减一秒就行”。真机上一测就露馅:小程序切到后台,JS 定时器会被系统挂起,再回来发现倒计时没走;锁屏几分钟再打开,圆环原封不动。这不是代码 bug,是平台对后台执行的根本限制。标题里“截图+源码”,源码的核心不是画界面,而是把计时从“每秒递减”改成“时间戳差值”,并在生命周期里做校准。这篇文章会从状态机设计讲到计时器实现、提醒和统计,再到截图分享的验证技巧,适合已经会小程序基础语法、想啃下计时类应用的人。

2. 番茄时钟的状态机设计与生命周期约束

2.1 番茄工作法拆成状态机的边界

番茄工作法的标准节奏是 25 分钟专注、5 分钟短休息,每完成 4 个番茄进入一次 15 分钟长休息。放到小程序里,它天然是一个有限状态机:阶段(工作、短休息、长休息)与运行状态(未开始、计时中、已暂停)是两个正交维度。

我一般会这样建模:

字段取值说明
phasework / shortBreak / longBreak当前阶段
statusidle / running / paused计时器运行状态
round1~4当前轮次,work 完成一次 round +1
remainingMs毫秒剩余时间,渲染层用来显示
totalMs毫秒当前阶段总时长,用于进度环计算

状态转换的规则就三条:work 倒计时归零 → 若 round % 4 === 0 进 longBreak,否则进 shortBreak;短休息归零 → round 不变,进 work;长休息归零 → round 重置为 1,进 work。注意短休息结束不能把 round 加回去,否则休息完成会多算一轮,这是最常见的实现偏差。

把状态定义成数据而非散落的变量,后续所有 UI 行为都由状态推导。按钮显示什么、进度环颜色、震动要不要触发,全部看 status 和 phase,不需要额外维护布尔标记,这在微信开发者工具的调试器里看数据面板就能确认逻辑是否一致。

2.2 后台暂停没得躲,只能靠时间戳校准

微信小程序的计时类应用绕不开一个平台事实:onHide 之后 JS 执行会被挂起,setInterval 回调不再触发,就算触发,频率也被降到不可用。这是为了让真机省电,不是 API 缺陷。所以“每秒减 1”的写法只能在前台勉强工作,锁屏、切后台、来电话都会让计时失真。

正确做法是让界面倒计时只负责“显示”,真正的时长计算靠时间戳差值。记录一个 startTime(时间戳,毫秒),剩余时间 = 目标结束时间 - Date.now()。setInterval 只做 200ms~500ms 一次的渲染刷新,中间即使停了,下次触发时用当前时间重算,结果依然准确。

这里有一个细节:刷新间隔别设 1000ms。显示“分:秒”的场景下,1 秒的请求动画帧不是必要开销,但间隔太长会让秒数跳变有顿挫感。我常用 250ms,肉眼顺滑,也没有并发回调堆积的负担。每次刷新做一次“是否到达结束时间”的判断,走到终点就切状态机。

2.3 数据流与页面结构取舍

单页面足够。番茄时钟的界面就一个进度圆环、一段时间文本、三个按钮(开始/暂停、重置、跳过)。自定义组件可以做,但收益有限,反倒是数据放页面还是放全局会影响后台恢复后的行为。

如果希望用户退出小程序再进来,计时还在跑,那就把状态写进 wx.setStorageSync,onShow 时读回来和时间戳做差值。如果只是页面内切换,放在 Page 的 data 里即可。我做的时候会把 core 逻辑抽成独立模块 timer.js,不在 Page 里堆代码,这样后续做自定义组件或迁移到 Skyline 渲染都可以复用。

存储内容除了状态字段,还要额外存一个 savedAt 时间戳。恢复时不是直接把 remainingMs 读回来,而是用原本的 endTime 减去 Date.now()。如果 endTime 已经过去,说明在后台期间该阶段已经结束,直接推进到下一阶段并触发提醒——这个边界往往测试时容易漏掉。

3. 番茄时钟核心计时器:从 setInterval 到时间戳差值

3.1 最小可用的计时器实现

先看核心模块。下面是一个可以直接跑通的 timer.js,不依赖任何框架,只暴露业务需要的几个方法:

// timer.js const STORAGE_KEY = 'tomato_state'; function createTimer(options) { const config = Object.assign({ work: 25, short: 5, long: 15, rounds: 4 }, options); let timerId = null; let state = { phase: 'work', status: 'idle', round: 1, endTime: 0, // 目标结束时间戳(毫秒) remainingMs: config.work * 60 * 1000, totalMs: config.work * 60 * 1000, updatedAt: Date.now() }; function persist() { wx.setStorageSync(STORAGE_KEY, Object.assign({}, state, { updatedAt: Date.now() })); } function computeRemaining(now) { if (state.status !== 'running') return state.remainingMs; const diff = state.endTime - now; return diff > 0 ? diff : 0; } function durationFor(phase) { return { work: config.work, shortBreak: config.short, longBreak: config.long }[phase] * 60 * 1000; } function nextPhaseAfter(phase, round) { if (phase === 'work') return round % config.rounds === 0 ? 'longBreak' : 'shortBreak'; return 'work'; } function start(callback) { if (state.status === 'running') return; if (state.status === 'idle') { state.totalMs = durationFor(state.phase); state.remainingMs = state.totalMs; } state.endTime = Date.now() + state.remainingMs; state.status = 'running'; persist(); stopTick(); timerId = setInterval(() => { const remain = computeRemaining(Date.now()); if (remain <= 0) { stopTick(); handlePhaseComplete(callback); } else { state.remainingMs = remain; if (typeof callback === 'function') callback(state); } }, 250); if (typeof callback === 'function') callback(state); } function handlePhaseComplete(callback) { const finishedPhase = state.phase; state.round = finishedPhase === 'work' ? state.round + 1 : state.round; state.phase = nextPhaseAfter(finishedPhase, state.round - (finishedPhase === 'work' ? 0 : 1)); state.totalMs = durationFor(state.phase); state.remainingMs = state.totalMs; state.status = 'idle'; persist(); if (typeof callback === 'function') callback(state, finishedPhase); } function pause() { if (state.status !== 'running') return; state.remainingMs = computeRemaining(Date.now()); state.status = 'paused'; stopTick(); persist(); } function resume(callback) { if (state.status !== 'paused') return; start(callback); } function reset() { stopTick(); state.status = 'idle'; state.phase = 'work'; state.round = 1; state.totalMs = durationFor('work'); state.remainingMs = state.totalMs; state.endTime = 0; persist(); if (typeof callback === 'function') callback(state); } function stopTick() { if (timerId) { clearInterval(timerId); timerId = null; } } function restore() { const saved = wx.getStorageSync(STORAGE_KEY); if (!saved) return; state = Object.assign(state, saved); if (state.status === 'running') { const remain = computeRemaining(Date.now()); if (remain <= 0) { // 后台期间已完成,推进状态机 state.status = 'idle'; state.phase = nextPhaseAfter(state.phase, state.round); state.totalMs = durationFor(state.phase); state.remainingMs = state.totalMs; persist(); if (typeof callback === 'function') callback(state, saved.phase); } else { state.remainingMs = remain; } } } return { start, pause, resume, reset, restore, getState: () => state }; } module.exports = { createTimer };

关键逻辑在于computeRemaining:每次 tick 不依赖上一次的剩余值,而是用endTime - now重新算。setInterval 被挂起再恢复后,第一次回调就能拿到真实的剩余时间,不会出现“显示 5 秒但实际只过了 1 秒”的偏差。

handlePhaseComplete里注意 round 的推进逻辑:work 完成才加轮次,休息完成不重置轮次。判断是否进入长休息用的是round % config.rounds === 0,在编写时要意识到这里圆整的顺序——先 round+1,再用新值判断,否则第 4 个番茄结束后会错误地进入短休息。

restore是后台恢复的入口。小程序冷启动时 App.onLaunch 里调用一次,检查到上次是 running 且 endTime 已过,就直接推进到下一阶段并把阶段变更回调传给 UI,让页面弹出提醒。

3.2 页面生命周期校准与状态恢复

Page 里调用 timer 模块,重点处理 onHide、onShow 和 onUnload。下面是一个页面骨架:

// pages/index/index.js const { createTimer } = require('../../utils/timer'); Page({ data: { display: '25:00', percent: 1, phaseText: '专注', status: 'idle' }, onLoad() { this.timer = createTimer({ work: 25, short: 5, long: 15, rounds: 4 }); this.timer.restore(); this.bindTimerCallback(); this.render(this.timer.getState(), null); }, onShow() { // 冷启动恢复后,如果状态是 running 而界面没有 tick,重新拉起定时器 const st = this.timer.getState(); if (st.status === 'running' && !this.ticking) { this.timer.start((s, finished) => this.render(s, finished)); this.ticking = true; } }, onHide() { // 什么都不用做,timer 内部每次状态变更已持久化 // 真正需要清的是 onUnload 里的定时器 }, onUnload() { this.timer.pause(); this.ticking = false; }, bindTimerCallback() { this._onTimer = (s, finishedPhase) => this.render(s, finishedPhase); }, render(state, finishedPhase) { const totalSec = Math.ceil(state.remainingMs / 1000); const mm = String(Math.floor(totalSec / 60)).padStart(2, '0'); const ss = String(totalSec % 60).padStart(2, '0'); const phaseMap = { work: '专注', shortBreak: '短休息', longBreak: '长休息' }; this.setData({ display: `${mm}:${ss}`, percent: state.totalMs ? state.remainingMs / state.totalMs : 0, phaseText: phaseMap[state.phase] || '', status: state.status }); if (finishedPhase) { wx.vibrateShort({ type: 'medium' }); } }, onStart() { this.timer.start((s, finished) => this.render(s, finished)); this.ticking = true; }, onPause() { this.timer.pause(); this.ticking = false; this.render(this.timer.getState(), null); }, onReset() { this.timer.reset(); this.ticking = false; this.render(this.timer.getState(), null); } });

onHide不需要额外处理,因为startpausereset内部已经 persist 过。这里区分timerIdticking两个标志:timerId是 setInterval 的句柄,ticking是页面角度对“当前是否由我发起轮询”的记录。onShow里的恢复动作只补拉一次 timer.start,兼容从后台回前台时状态仍是 running 的场景。

render里用Math.ceil而不是Math.floor显示秒数,可以让 25:00 在开始时完整显示 1 秒而不是立刻跳到 24:59。percent 用 remaining/total,进度环从满到空,符合番茄时钟的视觉习惯。

3.3 圆环进度与按钮状态联动

圆环用 CSS 实现比 Canvas 轻量得多。常见做法是两个半圆遮罩旋转,但更省事的是conic-gradient——微信小程序的基础库对它的支持已经足够日常使用,配合 mask 即可画空心环。

.progress-ring { width: 320rpx; height: 320rpx; border-radius: 50%; background: conic-gradient(#ff6b35 0deg, #eee 0deg); -webkit-mask: radial-gradient(transparent 62%, #000 63%); mask: radial-gradient(transparent 62%, #000 63%); transition: background 0.25s linear; }

conic-gradient的角度没法直接从 data 绑定,我一般用一个ringStyle字段拼字符串,在 render 里 setData:

const angle = Math.round(state.remainingMs / state.totalMs * 360); this.setData({ ringStyle: `background: conic-gradient(#ff6b35 ${angle}deg, #e5e5e5 ${angle}deg)` });

角度用百分比换算,结束时 0deg,整个环变灰。按钮区域的交互走 data 里的 status 分流:idle 显示“开始”、running 显示“暂停”、paused 显示“继续”,同一位置三个文案,避免用户找按钮。

4. 番茄时钟进阶:提醒、统计与配置化

4.1 阶段结束的震动与铃声提醒

倒计时到 0 以后用户可能没盯着屏幕。wx.vibrateShort是基础的触觉反馈,安卓和 iOS 表现不一致,Android 部分机型需要在用户点击时先触发过一次震动授权。我一般配合InnerAudioContext播放一段短音频:

const audio = wx.createInnerAudioContext(); audio.src = '/assets/sounds/complete.mp3'; audio.volume = 0.6; audio.play();

注意音频文件别超过 200KB,否则首次加载会有明显延迟。完整做法是在 app.json 里配置requiredBackgroundModes: ['audio']——但这里是播放提示音不是持续播放,不需要声明 background modes,声明了反而会让小程序在审核时多出“后台音频”的说明义务,无谓增加复杂度。

阶段结束同时还要处理 endTime 已过的恢复场景。用户锁屏 30 分钟再打开,两个阶段都结束了,此时restore只推进一步,剩下一个阶段留在 idle 等用户手动点开始,这个行为是可接受的——不做自动连跑,避免用户回来发现番茄被“偷偷休息”完了。

4.2 历史完成记录与每日统计

统计的存储结构保持简单:每次 work 阶段完成时追加一条记录{ phase: 'work', round: 1, completedAt: 1688888888888 },key 按日期分区,方便天维度查询。

function appendLog(entry) { const dayKey = new Date(entry.completedAt).toISOString().slice(0, 10); const all = wx.getStorageSync('tomato_logs') || {}; if (!all[dayKey]) all[dayKey] = []; all[dayKey].push(entry); wx.setStorageSync('tomato_logs', all); } function todayCount() { const dayKey = new Date().toISOString().slice(0, 10); const all = wx.getStorageSync('tomato_logs') || {}; return (all[dayKey] || []).length; }

逻辑说明:toISOString().slice(0, 10)得到的是 UTC 日期,中国时区用这个切会偏移 8 小时,建议用本地时间函数拼“年-月-日”。appendLog不关心 round 字段的具体值,统计只数 work 条的个数,round 留给将来做“第几个番茄”展示用。整表存储会有体积膨胀问题,我一般保留 90 天数据,超期在启动时裁剪。

4.3 可配置时长与轮次

预设值是 25/5/15/4,但用户的需求差异很大。配置入口放在一个单独页面,写回 storage:

// pages/settings/index.js function saveSettings(e) { const { work, short, long, rounds } = e.detail.value; wx.setStorageSync('tomato_config', { work: clamp(Number(work), 5, 60), short: clamp(Number(short), 1, 30), long: clamp(Number(long), 5, 60), rounds: clamp(Number(rounds), 2, 8) }); }

clamp在代码里限定取值范围,这样即使输入框被填了 0 或负数,也只会被钳制到下限,不会出现 totalMs 为 0 导致的除零问题。timer.js 创建时从 storage 读配置:

const savedCfg = wx.getStorageSync('tomato_config') || {}; const timer = createTimer(Object.assign({ work: 25, short: 5, long: 15, rounds: 4 }, savedCfg));

已在进行中的计时不受新配置影响。因为 totalMs 在 start 时就已经固化,只有下一次 idle 进入 start 才会重新求一次时长。这样设计避免“改配置导致本次倒计时突然变长/变短”的惊吓。

5. 验证计时精度与截图分享的四个落地技巧

5.1 短周期快速验证法

把配置改成 work=0.1、short=0.1,即 6 秒一个阶段,在开发者工具和真机上各跑一轮。切后台、锁屏、再回来,看 display 是否和时间流逝一致,看阶段是否在后台期间自动切换。这个验证方法比肉眼盯着 25 分钟高效得多,也是排查“restore 只推进一步”这类 bug 最直接的路径。真机验证要关掉“开发工具不校验合法域名”之类的设置,用真实的网络环境跑。

5.2 截图分享图用 Canvas 2D

标题里的“截图”可以做成用户分享时的炫耀卡片。新版 canvas 2d 接口需要先拿节点再画:

const query = wx.createSelectorQuery(); query.select('#share-canvas').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node; const ctx = canvas.getContext('2d'); const dpr = wx.getSystemInfoSync().pixelRatio; canvas.width = res[0].width * dpr; canvas.height = res[0].height * dpr; ctx.scale(dpr, dpr); // 画背景、圆环、时间文本 ctx.fillStyle = '#f7f7f7'; ctx.fillRect(0, 0, 300, 200); ctx.font = 'bold 48px sans-serif'; ctx.fillStyle = '#333'; ctx.fillText(this.data.display, 60, 120); wx.canvasToTempFilePath({ canvas, success: (file) => { wx.shareFileMessage({ filePath: file.tempFilePath }); } }); });

canvas 的宽高是 CSS 像素乘以 dpr,ctx.scale之后所有绘制坐标都按逻辑像素走,不会在真机上发虚。别在 onLoad 里执行这段代码,要等 canvas 节点真实渲染完成再调用,否则拿到的 node 是 null。

5.3 setInterval 泄漏与页面栈残留

页面跳转到设置页再返回,计时器实例并不会自动销毁。我习惯在onUnload里调stopTick,在onHide里不动定时器,因为 hide 之后定时器本来就被系统挂起,回到前台时 onShow 再补拉。这里最容易出的问题是在onUnload里调pause()——它会把 running 状态改成 paused,用户下一次进页面看到的是暂停而非继续,感觉像是计时被“偷偷停了”。所以页面卸载时我只清定时器,不动业务状态。

5.4 用 onError 兜底录音与震动异常

wx.vibrateShort在部分 Android 机型的静音模式下会直接走 fail 回调,代码里必须挂fail,否则控制台报 unhandled error。同理wx.createInnerAudioContext播放失败时要有 fallback——判断audio.onError后改用wx.showModal做视觉提示。这些异常分支写在发布前,比用户反馈后再补要省钱得多。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 15:37:23

机试Day2训练全攻略:高频题型与调试实战

1. 机试Day2到底该练什么&#xff1a;先想清楚再动手最近在准备机试的同学不少&#xff0c;不管是考研复试的机房上机考试&#xff0c;还是校招笔试里的编程环节&#xff0c;“机试”这两个字总能让人心里一紧。今天这篇是机试训练第二天的复盘记录&#xff0c;主要讲我在这一天…

作者头像 李华
网站建设 2026/9/15 15:36:34

PHP自动抢单系统源码:卡单连单管理与佣金设置实现

简介&#xff1a;此源码包为赚多多自动抢单系统最新版完整代码&#xff0c;面向需要部署自动抢单或派单业务场景的开发者和站长&#xff0c;解决传统抢单平台匹配效率低、佣金计算不灵活的问题。系统基于第三方匹配平台自动分配订单&#xff0c;简化了程序流程&#xff0c;并新…

作者头像 李华
网站建设 2026/9/15 15:36:25

大型集团综合管理规划方案解析与实施指南

1. 大型集团综合管理规划方案的核心价值解析在当今高度竞争的商业环境中&#xff0c;大型集团企业面临着前所未有的管理挑战。这份92页的PPT综合管理规划方案&#xff0c;正是为解决集团化运营中的系统性难题而设计的专业工具包。不同于零散的管理建议&#xff0c;这套方案从战…

作者头像 李华
网站建设 2026/9/15 15:31:32

AI内容生成重复问题解析与优化方案

1. AI生成重复问题的本质与挑战在AI内容创作领域&#xff0c;重复性问题已经成为从业者最头疼的技术瓶颈之一。我最近在批量生成技术文档时&#xff0c;就遇到过GPT-3.5连续三页输出几乎相同段落的情况。这种现象的本质在于大语言模型的概率采样机制——当模型对某些高频模式形…

作者头像 李华