资讯详情

微信小程序番茄钟源码拆解:状态机与时间戳计时方案

📅 2026/9/15 16:15:35 | 华诺云谱 👁 阅读
微信小程序番茄钟源码拆解:状态机与时间戳计时方案
简介这是一份面向微信小程序初学者的番茄时钟完整案例提供可直接运行的源码、界面截图和操作动图帮助开发者快速掌握小程序页面布局、定时器交互、生命周期管理及本地存储等常用技术。压缩包共22个文件、总大小约1.79MB涵盖逻辑脚本、页面结构、样式表、示意图集、演示动图、项目配置与说明文档并附有一个便于接入数据统计的快捷入口。已有1456人学习下载。资源按主目录、页面模块、工具函数和说明文档的结构组织解压后可依照截图复现界面对照动图观察计时交互参考源码实现番茄钟的启动、暂停与重置还可基于现有逻辑扩展任务清单或自定义铃声适合课程设计、毕业设计或日常练手。1. 番茄时钟源码拆解为什么说它是最好的微信小程序纯前端练手项目手机里换过不少番茄钟 App最后留下的反而是个微信小程序——它不打广告、不常驻后台、想用时随时打开时间到了会响一声然后就消失。见到这份timer-master源码时第一反应是这正是那种“看起来功能少但工程结构一点不少”的小程序模板。根目录下app.json、app.js、app.wxss、pages、utils一应俱全README.md说明运行方式view.gif是运行截图连统计入口都留好了很典型的可直接 git clone 下来的项目结构。这个项目解决的痛点是大多数时间管理工具没做好的计时任务跨页面、跨前后台仍然要准确。小程序不同于网页用户随手切换到聊天窗口、锁屏、甚至主动杀掉小程序都会让你的setInterval直接失效。而这个番茄钟的核心巧妙之处恰恰是它没有在页面上硬记剩余秒数而是用“目标时间戳”对齐真实时间配合 Storage 做状态恢复。如果你刚开始学微信小程序想找一个“麻雀虽小、五脏俱全”的源码来拆这份资源很合适如果你已经写了几年业务页面也可以从它的状态机设计和生命周期处理里挑出点能移植的东西。2. 先把四个状态画清楚番茄钟状态机与 setInterval 的边界2.1 五个状态与流转规则番茄钟看起来只是 25 分钟倒计时真正实现起来要处理的场景不少开始、暂停、继续、跳过、工作完成、休息完成、第几个番茄、是否该长休息。这些场景如果都用if/else硬凑代码会越写越乱还容易在“暂停后再开始”时把时间算错。所以源码里通常会先把状态机建起来把“现在处于什么阶段”和“接下来能做什么事”分开。状态含义可触发的动作进入时要做的事IDLE空闲等待首次开始开始工作重置所有计时数据按钮只亮“开始”WORK正在专注暂停、跳过保证endAt为当前时间 25 分钟SHORT_BREAK短休息暂停、跳过重置 5 分钟倒计时LONG_BREAK长休息暂停、跳过重置 15 分钟倒计时PAUSED暂停态继续、重置保存当前剩余时间停止 tick流转规则比想象中简单WORK到点就进休息第 4 个番茄完成进LONG_BREAK休息到点回到WORKPAUSED只允许回到之前的计时状态。关键的一点是“跳过”操作它意味着用户可能根本没进入休息下一个流转仍然以“工作完成数”为准。2.2 用常量加 switch 实现轻量状态机不需要引入第三方状态管理库小程序里用纯 JavaScript 就够了。源码的utils层完全可以这样搭const STATE { IDLE: IDLE, WORK: WORK, SHORT_BREAK: SHORT_BREAK, LONG_BREAK: LONG_BREAK, PAUSED: PAUSED, }; const CONFIG { workMinutes: 25, shortBreakMinutes: 5, longBreakMinutes: 15, longBreakInterval: 4, }; function getNextState(currentState, finishedWorkCount) { switch (currentState) { case STATE.WORK: return finishedWorkCount % CONFIG.longBreakInterval 0 ? STATE.LONG_BREAK : STATE.SHORT_BREAK; case STATE.SHORT_BREAK: case STATE.LONG_BREAK: case STATE.IDLE: return STATE.WORK; default: return STATE.IDLE; } } module.exports { STATE, CONFIG, getNextState };注意getNextState的入参是finishedWorkCount不是当前剩余时间。这样设计的原因是长休息完全由“完成番茄数量”决定跟用户是否跳过休息没关系。若某个番茄被跳过数量不应累积因此这个计数只会在WORK正常自然结束时加 1。CONFIG里的四个数值可以后续挪到设置页也可以直接读 Storage这样用户改时长后不用改代码。2.3 为什么不能靠 setInterval 每秒减一很多初学者会这样写setInterval(() this.setData({ seconds: seconds - 1 }), 1000)。表面看没问题实际有三个坑。第一个是误差累积JS 定时器在事件循环里如果前面有耗时任务回调会往后压长时间运行后显示的时间会越来越慢。第二个是后台冻结小程序切后台后定时器会被系统挂起回到前台时它不会把中间流逝的时间补给你界面显示会停在你离开那一刻。第三个是渲染性能setData每次都会走一遍逻辑层到渲染层的通信高频调用在低端机上直接表现为掉帧卡顿。正确做法是不数秒只保存一个“完成时间点”每次渲染读当前时间差——这块在下一章展开。3. 计时准不准的关键目标时间戳加 Storage 持久化恢复3.1 三类计时方案怎么选小程序里做倒计时无外乎三种方案本地秒数累减、本地时间戳差值、服务端时间戳。实际工程里我几乎只用第二种。方案后台恢复准确性实现成本适用场景seconds--累减差后台挂起后时间停滞最低纯演示、娱乐页面本地时间戳差值准前后台切换后自动对齐中番茄钟、秒杀倒计时服务端时间戳最准但依赖网络高需要防篡改、多端同步本地时间戳方案的核心思路启动时记一个endAt Date.now() duration然后不管页面切走多少次、定时器被挂起多久下次渲染时remaining endAt - Date.now()一定是对的。小程序里拿不到精确到秒的服务器时间也没关系本地时间偏差对个人计时工具来说完全可接受。源码里的timer-master模块角色就是集中管理这套逻辑避免每个页面各写一份。3.2 timer 模块核心实现// utils/timer.js const DEFAULT_KEYS { state: pomodoro:state, endAt: pomodoro:endAt, remaining: pomodoro:remaining, finishedCount: pomodoro:finishedCount, }; function loadState() { return { status: wx.getStorageSync(DEFAULT_KEYS.state) || IDLE, endAt: wx.getStorageSync(DEFAULT_KEYS.endAt) || 0, remainingMs: wx.getStorageSync(DEFAULT_KEYS.remaining) || 0, finishedCount: wx.getStorageSync(DEFAULT_KEYS.finishedCount) || 0, }; } function saveState(s) { wx.setStorageSync(DEFAULT_KEYS.state, s.status); wx.setStorageSync(DEFAULT_KEYS.endAt, s.endAt); wx.setStorageSync(DEFAULT_KEYS.remaining, s.remainingMs); wx.setStorageSync(DEFAULT_KEYS.finishedCount, s.finishedCount); } function start(config) { const s loadState(); if (s.status RUNNING || s.status WORK) return; const now Date.now(); if (s.status PAUSED) { s.endAt now s.remainingMs; } else { const duration config.durationMs; s.endAt now duration; s.remainingMs duration; } s.status RUNNING; saveState(s); return s; } function getRemaining(s) { if (s.status ! RUNNING) return s.remainingMs; return Math.max(0, s.endAt - Date.now()); }几个容易忽略的参数要说明endAt存的是绝对时间戳单位毫秒remainingMs只在暂停时才有意义运行中它应该是“只读不写”的否则一暂停一继续反而把时间算丢了finishedCount用来判断长休息。saveState里我用的是setStorageSync同步写入虽然会阻塞线程但每次写的数据量只有几十字节比异步回调更不容易出现“页面已切走、数据还没落盘”的问题。3.3 进程被杀后如何恢复onShow 里的对账逻辑小程序最常见的意外是用户直接左滑删除或者系统内存紧张被回收。等用户再次打开所有内存变量都重置了唯一可依赖的就是 Storage。恢复的核心问题有两个当前状态是什么、剩余时间还剩多少。// 页面 onShow 中调用 function restoreFromStorage() { const s loadState(); if (s.status RUNNING s.endAt Date.now()) { // 计时已结束但用户一直没打开小程序 handleTimerComplete(s); return; } if (s.status RUNNING) { this.timer setInterval(this.tick.bind(this), 500); } this.setData({ displayTime: formatTime(getRemaining(s)), status: s.status, finishedCount: s.finishedCount, }); }注意这里对“计时结束但用户不在场”的处理不是静默重置而是触发handleTimerComplete把finishedCount加一、播放提示音、直接进入休息状态。这和手机上那些“我关掉 App 就不算数”的工具体验拉开了差距也是源码里值得借鉴的产品细节。setInterval间隔我设置成 500ms不是为了显示毫秒而是让视觉上的剩余秒数变化更快响应结束动作红绿灯切换的衔接更自然。4. 页面渲染与交互进度环、按钮态与提示音的工程细节4.1 页面骨架时间文本与三个操作按钮pages 目录下的页面通常拆成计时页和设置页两层。计时页里最核心的 WXML 结构很简洁一个进度环容器、一个时间文本、一组操作按钮没有多余装饰。页面数据绑定围绕displayTime、progress、status三个字段展开避免在模板里写任何计算逻辑。view classtimer-page view classprogress-wrap canvas canvas-idprogressCanvas idprogressCanvas classprogress-canvas/canvas text classtime-text{{displayTime}}/text /view view classbtn-group button bindtaponPrimaryTap disabled{{status RUNNING}}{{primaryBtnText}}/button button bindtaponResetTap重置/button button bindtaponSkipTap disabled{{status IDLE}}跳过/button /view /view三个按钮的状态直接由status推导IDLE时只允许开始RUNNING时按钮文字变成“暂停”PAUSED时变成“继续”跳过按钮在空闲态置灰。这样做的收益是界面状态永远和状态机一一对应不会出现用户连点两次开始导致创建两个定时器的情况。按钮文字我用primaryBtnText这个计算属性返回属于最典型的“视图层不做判断、只做映射”的写法。4.2 进度环的两种实现canvas 比样式拼接更省心进度环是番茄钟的视觉核心实现方案无非两种conic-gradient渐变圆环或者canvas绘制。网上很多 demo 用 CSS 方案代码短但有一个很隐蔽的坑当进度超过 50% 后缺口从右侧换到左侧动画会出现一次明显跳动部分安卓 WebView 上动态修改渐变角度还不能触发过渡动画。所以我更推荐 canvas 方案基础库兼容性更好动画曲线完全可控。// 核心绘制函数progress 取值 0-1 function drawProgress(ctx, size, progress) { const r size / 2 - 6; ctx.clearRect(0, 0, size, size); ctx.beginPath(); ctx.arc(size / 2, size / 2, r, 0, Math.PI * 2); ctx.strokeStyle #e8e8e8; ctx.lineWidth 8; ctx.stroke(); const startAngle -Math.PI / 2; const endAngle startAngle Math.PI * 2 * progress; ctx.beginPath(); ctx.arc(size / 2, size / 2, r, startAngle, endAngle); ctx.strokeStyle #07c160; ctx.lineWidth 8; ctx.lineCap round; ctx.stroke(); }这里的startAngle从 -90 度开始是为了让圆环起点在正上方符合表盘习惯lineCap: round给线条加上圆头视觉上更柔和。绘制时机要放在tick回调里也就是每秒最多触发一次配合getRemaining计算出的剩余毫秒反推进度。不推荐每帧都重绘canvas 在小程序里是独立组件频繁draw会占用渲染层资源尤其是低端机上切换页面时能明显感觉到掉帧。4.3 计时结束的反馈音频实例要按需创建计时结束没有声音提示的番茄钟基本是废的。用wx.createInnerAudioContext创建音频实例播放done.mp3。源码里通常把音频文件放在audio目录注意两点iOS 端用户首次需要在bindtap手势里先调用一次play()来解锁音频会话否则可能出现“第一次不响”的诡异问题同时每次播放结束后要显式destroy()实例否则会积累内存泄漏页面反复切换后声音延迟越来越明显。5. 生命周期、全局单例与事件埋点让番茄钟从能用到能上线5.1 全局单例防止页面栈里出现两个计时器小程序页面有一种特殊情况用户从计时页跳到设置页再从右上角胶囊返回或者 tab 之间来回切换页面实例会保留在页面栈中。如果每个页面onLoad里都new一个 timer就会出现两个定时器同时 tick、同一时间写出两个endAt的竞态。解决方式是用getApp()挂一个全局单例让所有页面共用同一个 timer 状态机。// app.js const Timer require(./utils/timer.js); App({ globalData: { timer: null, }, onLaunch() { this.globalData.timer new Timer(); }, });页面上获取const timer getApp().globalData.timer所有读取和操作都走这一个实例。这里有个容易被忽略的细节设置页修改工作时长后如果计时器正在运行不应该立即生效只能影响下一次开始否则用户正在专注时突然被改了倒计时状态机就乱了。实现上可以让CONFIG只读新配置通过timer.updateConfig(nextConfig)写入下次start()时再读取。5.2 前后台切换onHide 不是暂停onShow 才是恢复很多人会把onHide当成暂停时机这是错的。onHide只是页面被盖住计时器应该继续走。真正要做的两件事一是把当前剩余时间重新落盘一次二是把setInterval清理掉减少后台不必要的计算和耗电。等onShow回来再用上一章写的restoreFromStorage做对账一分钟后台和半小时后台都能正确衔接。如果想把每天的使用记录导出成日志可以在onHide里追加一条带时间戳的记录。这里用到wx.env.USER_DATA_PATH比较合适比如把日志文件写到USER_DATA_PATH/pomodoro.log这属于文件系统能力比全量塞进 Storage 更符合日志场景const fs wx.getFileSystemManager(); fs.appendFileSync( ${wx.env.USER_DATA_PATH}/pomodoro.log, ${Date.now()} state${status} remain${remainingMs}\n );注意appendFileSync是同步接口日志写入频率控制在一分钟一次以内对性能无影响。USER_DATA_PATH这个环境变量在不同平台指向不同目录小程序会自动处理不需要拼接完整路径。5.3 统计埋点别把关键事件漏了源码里留了统计入口这在个人项目里很常见。番茄钟最该埋的事件是开始专注、完成专注、跳过专注、完成休息。这四个事件能画出用户真实使用漏斗——是坚持完成的多还是反复跳过的多。小程序自带统计平台不负责业务事件分析需要自己调wx.reportAnalytics先在小程序后台创建事件拿到 eventId 再上报。// utils/track.js function track(eventId, params {}) { if (typeof wx.reportAnalytics function) { wx.reportAnalytics(eventId, params); } } // 开始专注时 track(start_work, { scene: home, duration: CONFIG.workMinutes, });上报时机放在状态机start()成功之后而不是按钮点击之时——防止用户连点两次上报两条脏数据。参数可以带上来源场景比如是从主按钮进入还是跳过休息后自动进入这些数据攒一个月就能看出产品的使用节奏了。这里的重点是封装一层track不要在每个页面直接调wx.reportAnalytics否则埋点代码会散落得到处都是收口后以后要换统计 SDK 也只需改一个文件。6. 二开的几个实用技巧胶囊适配、自定义时长与跨端移植拿到这套源码后很多人会先改颜色和时长但有几个细节比改样式更值得动手。第一是适配右上角胶囊按钮。小程序页面的自定义导航栏在不同机型上高度差异很大刘海屏和非刘海屏的statusBarHeight差出几十像素很正常。想在计时页把进度环垂直居中直接写padding-top: 100rpx在 iPhone 15 Pro 上没问题到老款安卓就顶穿。正确姿势是用wx.getMenuButtonBoundingClientRect()拿到胶囊的位置信息动态计算导航栏高度和内容区起始点const menuRect wx.getMenuButtonBoundingClientRect(); const statusBarHeight wx.getSystemInfoSync().statusBarHeight; const navBarHeight (menuRect.top - statusBarHeight) * 2 menuRect.height;第二是通过wx.setStorageSync把CONFIG参数做成可配置的。源码里设置页改 50 分钟工作时长后计时页下一次start()会读新值。这个改造点特别适合练习自定义事件传递和storage同步是理解页面间通信成本最低的路径。第三是把timer.js特意做成无渲染依赖的模块目的就是方便移植。实测同一天把utils/timer.js原样拷进 uni-app 的common目录用uni.getStorageSync全局替换一下wx.getStorageSync状态机部分一行不用动就能跑通跨端版本。如果你正在做自己的微信小程序项目实例这套状态机同样可以直接拿过去用不用改业务逻辑只改视图层的绑定方式。计时模块的独立性是这个源码包里最值钱的部分。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。