尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

微信小程序番茄时钟源码:定时器原理与状态机设计

微信小程序番茄时钟源码:定时器原理与状态机设计 简介微信小程序番茄时钟源码面向小程序开发者和时间管理工具使用者完整演示番茄工作法里的专注计时、短暂休息、暂停重置与进度可视化等场景适合初学者理解计时器类应用从页面到逻辑的实现路径。资源共二十个文件压缩包仅1.22MB其中含五个JS逻辑脚本、四个WXSS样式表、三个WXML页面结构、一个JSON配置文件以及多张PNG/GIF图示素材能直观呈现小程序页面渲染、事件响应与数据绑定的协作方式随包附带的README说明也可辅助快速浏览模块分工。目前已有2769人学习下载说明该示例具备一定参考价值。开发者可基于源码直接修改倒计时长、提示音或界面配色也可对照示例动画复现完整交互效果省去从零搭建的时间是一份轻量、完整且易扩展的入门级微信小程序实践素材。1. 番茄时钟小程序从源码里看到定时器不该那么写把番茄时钟搬进微信小程序听起来是再简单不过的事一个 setInterval 每秒把秒数减一到点震动一下完事。真在真机上跑两天就会发现小程序进后台再切回来倒计时永远比墙上挂钟慢半拍甚至彻底停摆。这不是你代码写得不对而是小程序对 js 定时器有一套自己的生命周期管理页面不可见时定时器会被节流js 线程被挂起依赖 setInterval 做纯前端倒计时天然不可靠。这篇不打算给你一个只会在模拟器里转圈圈的例子而是把一份能落地到审核、能撑住日常使用的番茄时钟源码拆开讲清楚定时器的正确姿势、四个状态的切换逻辑、以及完成一轮番茄后的数据沉淀。适合想自己写工具类小程序的人也适合前端工程师把这个项目当短平快练手——毕竟番茄时钟的复杂度刚好卡在能讲明白原理和不会沦为玩具之间。2. 微信小程序番茄时钟的定时器原理与状态机设计2.1 为什么 setInterval 在微信小程序里会漂移先看生命周期小程序页面有五个核心生命周期onLoad、onShow、onReady、onHide、onUnload。很多人只盯着页面里有没有数据忽略了 onHide 与 onUnload 对定时器的杀伤力。当你按下 Home 键或切到别的 App小程序页面会触发 onHide此时页面从前台运行变为后台挂起——逻辑层的定时器调用会被微信客户端挂起最多保留一个极低频率的时钟供给。等切回小程序触发 onShow 时会发现 setInterval 已经累积了明显的误差。// 这是反例不要直接用 let seconds 25 * 60; setInterval(() { seconds - 1; this.setData({ seconds }); }, 1000);这段代码的问题在于它假设 setInterval 每一秒一定会被精确调用一次而且调用之间没有任何时间损耗。事实上微信小程序的 setInterval 在页面不可见时基本不再触发就算是前台运行低端机的 js 线程被数据绑定、渲染任务抢占时回调间隔也会被拉长。更隐蔽的是setInterval 的累计误差会随着时间正比例放大——25 分钟的番茄工作时段足以让误差积累到肉眼可见。2.1.1 正解用时间戳差值替代计数器常见的可靠做法是放弃每秒减一的计数思路改为记录目标结束时间戳再用当前时间戳与结束时间戳的差值计算剩余秒数。这样无论定时器何时触发、触发几次最终算出来的剩余时间都是准的。// 记录截止时间而不是记录剩余秒数 const endTime Date.now() 25 * 60 * 1000; const timer setInterval(() { const remain Math.max(0, Math.round((endTime - Date.now()) / 1000)); this.setData({ remain }); if (remain 0) { clearInterval(timer); } }, 200);代码说明endTime 是固定的时间戳不管 setInterval 被节流了多久只要回调还能跑remain 就是真实剩余秒数200ms 的刷新间隔是为了让 UI 数字滚动更平滑同时兼顾性能实际业务中 1000ms 也完全够用。这个方案的缺点是需要自己在 onHide 时记录一个 extraTime否则切后台期间时间照走前台恢复时 endTime 还在原地——这个问题放到第 5 章单独讲。2.2 番茄时钟状态机的四态设计与数据模型番茄工作法的基本循环是工作 25 分钟、短休息 5 分钟、每四个番茄后长休息 15 分钟。放到小程序里就需要一个状态机来管理当前处于哪一段否则用户手动切换工作/休息时逻辑会乱成一团。状态码含义默认时长分钟流转目标0待开始未启动2511工作中252 或 32短休息513长休息151代码中用 state 字段表示当前状态配合一个 totalStages 表示番茄完成的累计轮次。data: { state: 0, // 0-待开始 1-工作 2-短休息 3-长休息 remain: 25 * 60, // 当前状态剩余秒数 totalSeconds: 25 * 60, // 当前状态总秒数用于计算进度百分比 completedCount: 0, // 已完成番茄数 isRunning: false // 计时器是否在跑 }每次状态流转时统一走一个 switchState 方法switchState(nextState) { const durations [25 * 60, 25 * 60, 5 * 60, 15 * 60]; this.setData({ state: nextState, remain: durations[nextState], totalSeconds: durations[nextState], isRunning: true }); this.startTimer(); }参数说明durations 数组下标与 state 状态码一一对应下标 0 的待开始状态没有被流转入口但保留在数组里是为了 getCurrentState 这类方法取值时不越界startTimer 会先 clear 掉旧定时器再重新创建避免重复启动导致倒计时速度翻倍。状态机的价值在于把所有跳转路径收拢到函数里前端按钮只负责调用完成或跳过不会出现用户乱点导致的工作时长错乱。3. 微信小程序番茄时钟源码核心实现WXML/WXSS/JS 三件套3.1 页面数据与 run 逻辑从 setData 开始前面定义了数据模型现在把它变成能跑的最小实现。页面的 JS 里核心就三件事启动计时、暂停计时、状态切换。暂停时不能直接 clearInterval 完事因为要保留当前 remain 值等用户恢复时用这个 remain 重新计算 endTime。startTimer() { if (this.timer) clearInterval(this.timer); if (!this.data.isRunning) { this.setData({ isRunning: true }); } const endAt Date.now() this.data.remain * 1000; this.timer setInterval(() { const remain Math.round((endAt - Date.now()) / 1000); if (remain 0) { this.handleFinish(); return; } this.setData({ remain }); }, 200); } pauseTimer() { if (this.timer) { clearInterval(this.timer); this.timer null; } this.setData({ isRunning: false }); }逻辑说明startTimer 每次调用都会先清掉旧定时器这是防止快速点击启动按钮时出现多个 setInterval 叠加endAt 在启动时固定后续所有 setData 的 remain 都由 endAt 推导而来pauseTimer 不清空 remain只停止计时器。有个细节值得注意setData 在 200ms 间隔下每分钟调用 300 次对低端机会产生可感知的渲染压力所以进度条在这种模式下会消耗更多性能——优化手段是把刷新间隔改为 1000ms或者只在整秒变化时才 setData。3.2 进度环与 WXSS 动画实现进度显示常见做法是配一个圆环圆环的进度用 CSS conic-gradient 或者 SVG stroke-dashoffset 控制。SVG 的兼容性和控制精度在小程序里更稳定先用一个 200x200 的 view 包裹 svg。view classprogress-wrap svg viewBox0 0 200 200 circle cx100 cy100 r90 fillnone stroke#ececec stroke-width12/ circle cx100 cy100 r90 fillnone stroke#ff6347 stroke-width12 stroke-linecapround stroke-dasharray565.2 stroke-dashoffset{{ringOffset}}/ /svg text classtime-text{{remainText}}/text /view对应的 WXSSsvg 里的 circle 不支持在 wxss 里直接写 stroke需要放在行内属性里进度圆环的 circumference 是 2 * π * r 565.2px剩余秒数映射到 stroke-dashoffset 的公式为565.2 * (1 - remain / totalSeconds)。const CIRCUMFERENCE 565.2; const progress (remain / totalSeconds) * CIRCUMFERENCE; this.setData({ ringOffset: CIRCUMFERENCE - progress, remainText: formatTime(remain) });代码说明stroke-dasharray 画出一整圈实线stroke-dashoffset 把非进度部分隐藏掉offset 越大实线越少。因为 remain 每秒变化一次ringOffset 也会同步更新圆环呈现顺时针缩短的效果。要改成顺时针增长也很简单把 offset 公式里的 progress 换成CIRCUMFERENCE * remainPercent即可但增长方向需要配合 scale 翻转这里不展开。3.3 启动、暂停、重置的事件处理与参数设置三个按钮对应三个 handler启动按钮在暂停状态下显示继续运行时显示暂停这个切换靠 isRunning 的取反判断来完成。view classbtn-group button classbtn-primary bindtaphandlePrimaryTap{{isRunning ? 暂停 : 开始}}/button button classbtn-secondary bindtaphandleReset重置/button button classbtn-skip bindtaphandleSkip跳过/button /viewhandlePrimaryTap() { if (this.data.isRunning) { this.pauseTimer(); } else { this.startTimer(); } }, handleReset() { if (this.timer) { clearInterval(this.timer); this.timer null; } this.setData({ state: 0, remain: 25 * 60, totalSeconds: 25 * 60, isRunning: false }); }, handleSkip() { if (this.timer) { clearInterval(this.timer); this.timer null; } this.handleFinish(); }handleSkip 的逻辑是直接调用 handleFinish 走正常的完成流程好处是完成提醒、轮次计数、休息切换这些行为全部复用不会因为跳过和自然完成走两套分支。handleReset 则彻底回到 25 分钟的初始状态同时把 completedCount 清零。这里顺带提一个常见误用不要在 onUnload 里手动 clearInterval小程序框架会在页面销毁时自动回收定时器但你无法保证 onUnload 一定触发所以页面里维护一个 this.timer 引用并在每次启动/重置时主动清理才是安全做法。4. 从源码到可用的小程序配置、提醒与本地存储4.1 app.json 与页面配置导航栏、窗口和权限微信小程序番茄时钟源码跑起来前app.json 的窗口配置直接影响体验。番茄时钟是一个沉浸式应用用户会长时间盯着倒计时数字所以 navigationBar 可以保留但建议用默认样式不要折腾自定义导航window 的 backgroundTextStyle 设为 dark 更配合白色背景。{ pages: [pages/index/index], window: { navigationBarTitleText: 番茄时钟, navigationBarBackgroundColor: #f6f6f6, navigationBarTextStyle: black, backgroundColor: #f6f6f6 }, style: v2, sitemapLocation: sitemap.json }参数说明style v2 会启用新版组件样式对 button 的默认边框有影响需要关掉 button 的默认样式时使用button::after { border: none; }sitemap.json 如果只是个人自用写入空对象即可。4.2 完成提醒与休息切换振动与提示音倒计时归零后代码要做的第一件事不是弹窗而是给用户一个物理反馈。微信小程序里最基本的提醒组合是 wx.vibrateLong长时间振动 wx.showToast如果担心振动在某些安卓机型上无效可以叠加一个音频播放——用 wx.createInnerAudioContext 加载项目目录下的本地 mp3 文件。handleFinish() { wx.vibrateLong(); const audio wx.createInnerAudioContext(); audio.src /audio/done.mp3; audio.play(); const nextState this.getNextState(); const completedCount this.data.completedCount 1; this.setData({ state: nextState, remain: this.durationOf(nextState), totalSeconds: this.durationOf(nextState), completedCount, isRunning: false }); this.saveStatToLocal(completedCount); } getNextState() { const { state } this.data; if (state 1 || state 0) { return (this.data.completedCount 1) % 4 0 ? 3 : 2; } return 1; }逻辑说明getNextState 里通过(completedCount 1) % 4 0判断是否达到长休息条件——每四个番茄休息一次。注意 handleFinish 里完成了 setData 但故意不自动 startTimer用户看到休息界面后需要手动点开始这是有意设计给用户喘息机会而不是强迫进入休息倒计时。audio 对象用完要调用 destroy 释放内存这里省略了实际代码里建议在 play 完成后手动销毁。这章还涉及一个常见数据追求有人想把音频文件走网络 URL 而不是本地包建议不要。番茄时钟的音频文件通常只有几十 KB本地包不占体积还能避免网络延迟导致的提醒滞后。4.3 统计信息落库用 Storage 存完成记录番茄时钟的核心价值是我今天完成了几个番茄所以完成记录不能只存在内存里刷新页面就丢。小程序没有 localStorage对应的是 wx.setStorageSync 和 wx.getStorageSync按 key-value 方式操作key 用日期字符串很合适。saveStatToLocal(count) { const today new Date().toLocaleDateString(zh-CN); const key pomodoro_${today}; wx.setStorageSync(key, count); const totalKey pomodoro_total; const prevTotal wx.getStorageSync(totalKey) || 0; wx.setStorageSync(totalKey, prevTotal 1); }参数说明日期字符串每天不同所以每天都有一条独立记录totalKey 累加的是总完成数要做周统计时可以先取最近七天的日期分别 getStorageSync 再求和。还有一种做法是把记录存成数组而不是分散的 keywx.setStorageSync(pomodoro_logs, JSON.stringify(logs))数组的好处是可以保留每次番茄的起止时间戳坏处是每次写入都要整体序列化数据量大时有性能问题。对这个工具类小程序按天存储的 key-value 是最省事的方案读取时一次 getStorageSync 就能拿到当天数据不需要遍历。5. 后台计时漂移问题与一个验证技巧5.1 用时间戳修正切后台期间的计时失真第 2 章提到时间戳方案解决了定时器触发不准的问题但切后台期间 endAt 一直在那里从前台恢复时 remain 会算出来一个已经结束的值直接跳到完成状态。这其实是一种分支情况如果你希望切后台时计时继续走比如工作到一半切到微信回复消息那 remain 算出来是 0 就对了如果你希望切后台时暂停需要在 onHide 里记录冻结时间。onHide() { if (this.data.isRunning) { this.pauseTimer(); wx.setStorageSync(frozen_remain, this.data.remain); wx.setStorageSync(frozen_state, this.data.state); } }, onShow() { const remain wx.getStorageSync(frozen_remain); const state wx.getStorageSync(frozen_state); if (remain state) { this.setData({ remain, state, isRunning: false }); wx.removeStorageSync(frozen_remain); wx.removeStorageSync(frozen_state); } }这段代码的效果是用户切后台的瞬间冻结当前剩余时间和状态回到小程序时恢复暂停状态需要手动点击继续才启动。这是最保守的逻辑适合番茄时钟这种分心即中断的使用习惯——离开就暂停回来再继续。如果想让后台时间继续走改为在 onShow 里判断remain是否小于 0小于则按完成处理即可把消费端变成服务端思路一样。实际开发中会遇到底部 tab 切换页面的情况tab 切换会触发 onHide/onShow但页面实例不销毁此时代码里的 frozen_remain 写入与读取是同一会话行为正确。唯一要注意的是 storage 写入是异步的setStorageSync 在极端低端机上存在失败可能稳妥做法是在 setData 前读一次 storage 校验。5.2 验证方法开发者工具配真机一个 console 断言写完源码后不要急着上传先花两分钟验证核心逻辑。在开发者工具的 console 面板里可以手动喂数据来断言时间戳方案的正确性// 模拟 endAt 在 3 秒后到期 const mockEndAt Date.now() 3000; let remain Math.round((mockEndAt - Date.now()) / 1000); console.assert(remain 3, remain 应该小于等于 3); // 模拟切后台到切前台的时间差为 10 秒 const hideAt Date.now(); setTimeout(() { const elapsed Math.round((Date.now() - hideAt) / 1000); const frozenRemain 1500; const afterResume frozenRemain - elapsed; if (afterResume 0) { console.log(后台期间超时应走完成逻辑); } else { console.log(切回后剩余秒数, afterResume); } }, 10000);验证的核心是 two-point 原则不管 setInterval 是否准时执行只要拿到 hideAt 和 endAt 两个时间戳中间任何耗时都能被精确计算。如果验证过程中发现切后台恢复后 remain 没有按预期减少优先检查 onShow 里是否丢失了 frozen_remain其次检查 startTimer 里 endAt 是不是在每次 setInterval 回调里被重置成了 Date.now() remain。后者是新手最容易犯的错——把 endAt 写在回调内部导致每次刷新都重新计算截止时间倒计时永不归零。最后再提一个长统计技巧在 storage 里维护一个数组字段pomodoro_records每次完成时往数组末尾 push 当天的日期戳做周报时直接用filter筛出本周的记录数量比按 key 逐天读取的代码量少一半。番茄时钟的核心价值落在坚持记录上这一步做好整个小程序从工具变成了习惯养成器。本文还有配套的精品资源点击获取
返回列表