资讯动态

H5 Canvas 手写圆形倒计时进度条:从几何原理到性能优化

发布时间:2026/10/17 10:19:02 来源:尧图企业网站定制
1. 从零拆解为什么圆形倒计时比线性进度条更值得做做过活动页、抢购页、答题倒计时的人都有一个共识时间压力是转化率最直接的杠杆。但大多数项目里倒计时要么是一行干巴巴的数字要么是一根横着的进度条。这两种方案不是不能用而是它们对用户的心理压迫感太弱了。线性进度条从左到右缩短用户余光扫到的时候大脑需要先判断这根条还剩多少比例再换算成时间多了一层认知成本。而圆形倒计时不一样它是一个封闭的环缺口的大小天然对应剩余时间用户一眼就能感知快没了。这就是我当初决定用 H5 Canvas 手写一个圆形倒计时进度条的核心动机。市面上当然有现成的图表库比如 ECharts、Chart.js甚至 CSS 的conic-gradient也能做出类似效果。但活动页对体积和性能极其敏感为了一个倒计时引入几百 KB 的图表库性价比太低而纯 CSS 方案在需要圆角端点、渐变描边、动态内圈文字的时候会变得非常别扭兼容性也参差不齐。Canvas 则刚好卡在中间原生 API、零依赖、绘制自由度高一个两三百行的组件就能覆盖绝大多数场景。这篇文章适合谁看如果你是有一定 JS 基础、做过 H5 页面、但对 Canvas 绘制还停留在画个矩形阶段的前端那这篇基本可以当模板抄。如果你已经用过 Canvas但每次做倒计时都要重新查arc的参数、被requestAnimationFrame的时间戳绕晕那这篇能帮你把这些细节一次性理清楚。我会从坐标系、角度换算、动画循环、性能取舍一路讲到实际项目里踩过的坑尽量把为什么这么写讲透而不是甩一段代码让你自己悟。需要先说明一点下面涉及的具体参数值比如线宽、缓动系数、刷新策略都是基于常见活动页场景给出的经验值不是唯一解。你在自己的项目里要根据设计稿和性能预算做调整我会在每个关键参数旁边解释它的影响方便你按需改。2. Canvas 圆形进度的几何原理与坐标系陷阱2.1 圆心、半径与线宽的关系Canvas 画圆用的是arc(x, y, radius, startAngle, endAngle, anticlockwise)。这里第一个容易踩的坑就是线宽。很多人第一次画圆环会发现圆环被画布边缘切掉了一半原因很简单arc的半径是路径中心线的半径而stroke是以这条中心线为基准向两侧各扩展lineWidth / 2。也就是说圆环实际占用的外半径是radius lineWidth / 2。假设你的画布是 200×200圆心在 (100, 100)如果你把radius设成 100线宽设成 20那么圆环会从半径 90 画到半径 110超出画布 10 像素直接被裁掉。正确的做法是让radius (画布尺寸 - lineWidth) / 2。以 200 的画布、20 的线宽为例radius应该是 90这样外边缘刚好到 100内边缘到 80完整落在画布内。还有一个隐藏问题在高分屏devicePixelRatio 1上如果只按 CSS 像素设置 canvas 的 width/height 属性绘制出来的圆环边缘会发虚。解决办法是把 canvas 的实际像素尺寸乘以 dpr再用ctx.scale(dpr, dpr)把坐标系缩回来。这一步不做圆环的圆角端点会糊成一团视觉上非常廉价。2.2 角度换算从剩余百分比到弧度Canvas 的角度单位是弧度不是度。一圈是2 * Math.PI。默认情况下0 弧度在三点钟方向正右方顺时针为正方向。但设计稿里的倒计时通常是从十二点钟方向开始顺时针递减。这就涉及两个转换第一起始角度要偏移。十二点钟方向对应的是-Math.PI / 2也就是1.5 * Math.PI或-0.5 * Math.PI。第二进度方向要确定。如果是从满圈递减那么绘制的弧长等于2 * Math.PI * progress其中progress是剩余比例1 表示满0 表示空。这里有个细节值得说清楚arc的anticlockwise参数默认是false即顺时针绘制。当你要画剩余部分时从-Math.PI / 2开始顺时针画2 * Math.PI * progress的弧长得到的就是从顶部开始、顺时针延伸的剩余环。而底层的灰色轨道则画一整圈。两者叠加视觉上就是彩色环在缩短。我见过不少人用endAngle startAngle 2 * Math.PI * progress这种写法逻辑上没错但当progress接近 0 时如果没处理好浮点误差可能会画出极短的一段弧甚至报错。稳妥的做法是在绘制前对progress做一次Math.max(0, Math.min(1, progress))的钳制。2.3 圆角端点的实现与代价设计稿里那种两头圆润的进度条靠lineCap round就能实现。但圆角端点有个副作用它会在弧的两端各多出半个线宽的长度。也就是说当进度接近 0 时即使弧长几乎为零圆角端点依然会显示成一个圆点看起来像还剩一点点这跟已经结束的语义冲突。处理方式有两种。一种是在progress小于某个阈值比如 0.01时直接把lineCap切成butt让端点消失。另一种是干脆接受这个圆点把它当作倒计时结束的视觉锚点。我个人的选择是前者因为活动页的倒计时结束往往要触发弹窗或跳转视觉上干净利落更符合预期。这个阈值具体取多少取决于你的圆环半径和线宽半径越小、线宽越大圆点越明显阈值就要相应调大。3. 动画循环requestAnimationFrame 与时间基准的正确用法3.1 为什么不能用 setInterval 驱动倒计时新手最容易犯的错是用setInterval(fn, 1000)每秒减一然后重绘。这个方案在页面切到后台标签页时会被浏览器降频甚至暂停等你切回来倒计时已经严重滞后。更糟的是setInterval的间隔并不精确长时间运行会累积误差一个 60 秒的倒计时可能实际跑了 63 秒。正确的做法是用requestAnimationFrame简称 rAF驱动重绘但时间基准不能靠每帧减一点而要靠时间戳计算。核心思路是记录倒计时的结束时刻endTime每一帧用endTime - Date.now()算出剩余毫秒数再换算成进度。这样无论帧率是 60 还是 30无论页面是否被降频只要下一帧恢复剩余时间永远是准确的。const endTime Date.now() duration * 1000; function tick() { const remaining Math.max(0, endTime - Date.now()); const progress remaining / (duration * 1000); draw(progress); if (remaining 0) { requestAnimationFrame(tick); } else { onComplete(); } } requestAnimationFrame(tick);这段代码的关键在于progress完全由真实时间推导跟帧率无关。哪怕某一帧卡了 500 毫秒下一帧算出来的progress会自动跳到正确位置不会出现越跑越慢的问题。3.2 时间戳精度与页面可见性Date.now()的精度是毫秒级对倒计时来说完全够用。但如果你要做那种毫秒级跳动的炫酷效果Date.now()在部分浏览器上会被降低精度出于安全考虑通常降到 2 毫秒或 100 毫秒。这时候可以用performance.now()它返回的是从页面加载开始的高精度时间戳不受系统时钟调整影响。另一个必须处理的问题是页面可见性。当用户切到别的标签页rAF 会暂停这本身是好事省电。但如果你在倒计时结束时依赖 rAF 触发回调那用户切回来之前回调不会执行。稳妥的做法是额外监听visibilitychange事件在页面重新可见时立即手动调用一次tick把状态同步到最新。提示如果你的倒计时结束要触发网络请求比如提交答案、锁定订单千万不要只依赖前端计时。前端时间可以被用户随意修改服务端必须用自己收到请求的时间做二次校验前端倒计时只是体验层的东西。3.3 缓动要不要加纯线性的倒计时进度条视觉上是最诚实的时间流逝速度恒定。但有些设计稿会要求最后几秒加速收缩制造紧迫感。这时候可以在progress上套一个缓动函数比如easeInQuad让进度在后期变化更快。但我要提醒一句缓动会破坏进度条长度 剩余时间比例的直觉映射。用户看到环还剩四分之一以为还有 15 秒实际可能只剩 8 秒这种认知偏差在抢购、答题场景里可能引发投诉。我的建议是除非产品明确要求否则倒计时进度条保持线性最稳妥。真要制造紧迫感不如在最后 5 秒改变颜色比如从蓝色变红色既直观又不欺骗用户。4. 视觉细节渐变描边、内圈文字与状态切换4.1 用 createLinearGradient 做渐变环单色圆环看久了确实单调很多设计稿会要求从一种颜色渐变到另一种。Canvas 的createLinearGradient或createRadialGradient都能实现但要注意渐变是作用在描边上的而描边的颜色采样位置跟路径的几何位置有关。对于圆环线性渐变从左上到右下是最常见的做法视觉上比较自然。const gradient ctx.createLinearGradient(0, 0, size, size); gradient.addColorStop(0, #4facfe); gradient.addColorStop(1, #00f2fe); ctx.strokeStyle gradient;这里有个坑渐变坐标系是相对于画布的不是相对于圆环的。如果你在绘制前做了ctx.translate或ctx.scale渐变的位置会跟着变。所以要么在设置渐变前重置变换要么把渐变坐标算清楚。我一般习惯在draw函数开头就ctx.setTransform(dpr, 0, 0, dpr, 0, 0)把坐标系固定下来避免累积变换带来的诡异偏移。4.2 内圈文字的对齐与字体加载圆环中间通常要放剩余秒数。Canvas 绘制文字用fillText对齐靠textAlign和textBaseline。把这两个都设成center再把绘制坐标设成圆心文字就会居中。但要注意textBaseline middle在不同字体下的视觉居中效果略有差异数字字体尤其明显。如果发现文字偏上或偏下可以手动加一个几像素的偏移量微调。字体加载是另一个隐蔽的坑。如果你用了自定义字体比如某个数字特别好看的 webfont而字体还没加载完就开始绘制Canvas 会回退到默认字体等字体加载完也不会自动重绘。解决办法是用document.fonts.ready或FontFaceObserver之类的方案等字体就绪后再启动倒计时。这个细节在活动页里特别重要因为活动页往往对视觉要求极高字体回退会直接毁掉设计效果。4.3 状态切换正常、警告、结束一个完整的倒计时组件通常有三种状态正常倒计时、进入警告区间比如最后 10 秒、结束。状态切换不只是改颜色还涉及动画节奏和回调触发。我的做法是把状态判断放在draw函数里根据remaining的数值决定用哪套配色。警告区间可以用一个阈值常量控制比如WARNING_THRESHOLD 10000毫秒。颜色切换时加一个短暂的过渡动画会更顺滑但过渡本身也要用 rAF 驱动不能靠 CSS transitionCanvas 不吃这套。结束状态要处理两件事一是把进度环画成空或者画成满圈的结束色二是触发onComplete回调。回调里通常会做弹窗、跳转、埋点上报等操作。这里要确保回调只触发一次用一个hasCompleted标志位守住避免因为 rAF 的最后一帧和visibilitychange的手动调用重复触发。5. 性能取舍什么时候该重绘什么时候该偷懒5.1 全量重绘 vs 分层 Canvas最简单的实现是每一帧都clearRect然后重画所有东西灰色轨道、彩色进度、中间文字。在 200×200 这种小尺寸下这个开销可以忽略不计60 帧跑满毫无压力。但如果你的画布很大比如全屏倒计时或者页面上同时有多个倒计时实例全量重绘就可能成为瓶颈。优化思路是分层。用两个叠在一起的 canvas底层画静态的灰色轨道只在初始化时画一次上层画动态的彩色进度和文字每帧只清空上层。这样每帧的绘制量减少一半以上。代价是 DOM 结构变复杂定位要精确对齐。我的经验是单个倒计时、尺寸在 300 像素以内没必要分层多个实例或大尺寸场景分层收益明显。5.2 帧率控制与降频策略不是所有倒计时都需要 60 帧。如果进度环只是平滑收缩没有毫秒级数字跳动30 帧甚至 15 帧在视觉上完全够用。降频的做法是在 rAF 回调里加一个时间判断比如距离上次绘制不足 33 毫秒就跳过。let lastDraw 0; function tick(now) { if (now - lastDraw 33) { draw(progress); lastDraw now; } requestAnimationFrame(tick); }这个策略在低端机上效果显著能明显降低 CPU 占用和发热。但要注意降频后进度条的顺滑感会下降如果设计稿要求丝滑动画还是老老实实跑满帧。5.3 内存与实例销毁在单页应用里倒计时组件往往会被频繁创建和销毁。如果销毁时没有取消 rAF回调会继续执行引用着已经脱离 DOM 的 canvas造成内存泄漏。正确的做法是在组件卸载时调用cancelAnimationFrame(rafId)并把rafId置空。另外如果倒计时绑定了visibilitychange或resize事件也要在销毁时移除监听。我见过一个活动页用户反复进出弹窗几十次后页面卡死排查下来就是每次创建倒计时都加了一个 resize 监听从来没移除最后几百个监听器同时响应 resize直接把主线程堵死。6. 实战踩坑记录那些文档不会告诉你的问题6.1 圆环在部分安卓机上出现锯齿这个问题困扰了我很久。同样的代码在 iOS 和桌面浏览器上圆环边缘很平滑但在某些安卓机型上圆环外侧有明显的锯齿。排查后发现原因是这些机型在 dpr 处理上有差异加上ctx.scale之后arc的抗锯齿没有正确生效。解决方案是给 canvas 的 CSS 加transform: translateZ(0)触发硬件加速同时在绘制前确保ctx.imageSmoothingEnabled true。如果还不行可以尝试把圆环的绘制半径增加 0.5 像素利用亚像素渲染来骗过抗锯齿算法。这个 0.5 像素的技巧在多个项目里都救过场虽然原理上有点玄学但实测有效。6.2 倒计时结束瞬间的闪一下当progress从极小值跳到 0 时如果处理不当圆环会先显示一个圆点因为lineCap round然后突然消失视觉上就是闪一下。前面提到的阈值切换lineCap能解决大部分情况但还有一个隐患如果onComplete回调里立即清空画布或隐藏 canvas而最后一帧的绘制还没提交也会造成闪烁。稳妥的做法是在progress归零后先绘制一帧结束状态比如空环或满环的结束色等这一帧渲染完成可以用requestAnimationFrame再包一层再触发回调。这样视觉上有一个明确的落定过程不会闪。6.3 多个倒计时实例的时间同步有些页面需要同时显示多个倒计时比如列表里每个商品都有自己的倒计时。如果每个实例都独立用Date.now()计算理论上没问题但如果它们共享同一个结束时刻最好用一个统一的时间源避免因为创建时刻的微小差异导致显示不一致。我的做法是创建一个全局的ticker用一个 rAF 循环驱动所有倒计时实例每个实例只负责自己的绘制。这样既省了 rAF 的调度开销又保证了所有实例的时间基准完全一致。实现上可以用一个简单的发布订阅模式ticker每帧通知所有订阅者订阅者根据自己的endTime算进度。6.4 服务端时间与客户端时间的偏差这是最容易被忽视、后果却最严重的问题。如果倒计时的endTime是前端用Date.now() duration算出来的那用户改一下系统时间倒计时就废了。正确做法是让服务端返回一个剩余秒数或结束时间戳前端用这个值作为基准。但即使这样客户端时间和服务端时间也可能有偏差。更严谨的做法是服务端返回剩余秒数的同时也返回服务端的当前时间戳前端算出偏差量在后续计算中补偿。这个补偿逻辑不复杂但能避免用户本地时间快了 3 分钟倒计时提前结束这类问题。// 假设服务端返回 serverNow 和 endTime const offset serverNow - Date.now(); function getRemaining() { return Math.max(0, endTime - (Date.now() offset)); }这段代码里的offset就是客户端与服务端的时间差每次计算剩余时间时都补偿上倒计时的准确性就与用户本地时间无关了。7. 组件封装思路与可复用接口设计7.1 对外暴露的配置项一个可复用的倒计时组件配置项应该覆盖视觉和行为的常见需求。我通常会暴露这些参数size画布尺寸、lineWidth环宽、duration总时长秒、endTime结束时间戳优先于 duration、colors轨道色、进度色、警告色、warningThreshold警告阈值秒、onComplete结束回调、onTick每帧回调可选。这些参数里endTime和duration二选一优先用endTime因为它能配合服务端时间做校准。onTick是可选的用于外部需要实时知道剩余时间的场景比如同步更新页面上的其他元素。7.2 内部状态管理组件内部需要维护的状态不多rafId当前动画帧 ID、hasCompleted是否已触发结束回调、offset时间偏差补偿、isPaused是否暂停。暂停功能在活动页里偶尔会用到比如用户点击暂停按钮。实现暂停就是在 rAF 回调里判断isPaused为真就跳过绘制但继续请求下一帧同时把endTime往后推。这里有个细节暂停期间endTime要动态调整否则恢复后剩余时间会不对。具体做法是记录暂停开始的时刻恢复时把endTime加上暂停的时长。这个逻辑不难但容易漏测试的时候一定要覆盖暂停—等待—恢复的完整流程。7.3 销毁与资源释放组件必须提供一个destroy方法内部做三件事取消 rAF、移除所有事件监听、把 canvas 引用置空。如果用了分层 canvas两个都要处理。这个方法在单页应用的路由切换、弹窗关闭时一定要调用否则就是内存泄漏的温床。我一般会在组件里维护一个listeners数组把所有addEventListener的引用存起来destroy时统一移除。这样比手动一个个removeEventListener可靠得多也不容易漏。8. 写在最后的一点个人体会这个圆形倒计时组件我从第一版到现在前后改了七八次每次都是被实际项目里的问题逼着改。最开始那版只有几十行能画个圈、能倒计时就完事了后来遇到高分屏模糊加了 dpr 处理遇到安卓锯齿加了亚像素技巧遇到时间不准加了服务端校准遇到内存泄漏加了完整的销毁逻辑。每一次改动都对应一个真实的线上问题没有一个是为了优雅而优雅。如果你现在要动手做这个组件我的建议是先用最简版本跑通核心逻辑——一个 canvas、一个 rAF、一个时间戳计算确认倒计时准确、圆环能画出来。然后再逐步叠加视觉细节和边界处理。不要一上来就追求大而全的封装那样很容易在细节里迷失最后连最基本的倒计时都不准。另外Canvas 这个东西看再多文章不如自己动手画一遍。arc的角度方向、lineCap的端点效果、clearRect的清除范围这些参数光看文档是记不住的画几次、改几次参数自然就刻在脑子里了。我到现在做新项目偶尔还会开个空白页先画几个圆找找手感这比翻文档快得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑