资讯动态

Canvas圆形倒计时进度条实战:从原理到高性能组件实现

发布时间:2026/10/17 21:28:59 来源:尧图企业网站定制
1. 为什么选择 Canvas 来做圆形倒计时1.1 从一次活动页需求说起去年接了一个限时抢购活动页的外包需求很明确页面中央一个圆形倒计时外圈进度条随着时间流逝逐渐缩短中间显示剩余秒数倒计时结束后按钮从灰色变成可点击状态。第一反应是用 CSS 的conic-gradient配合property做动画但实际写下来发现两个问题一是进度条要做成圆头端点conic-gradient做不到二是中间数字要跟进度条严格同步CSS 动画和 JS 计时器之间总会有几十毫秒的偏差视觉上能看出来数字跳了但进度条还没走完。换成 SVG 的stroke-dasharray方案后圆头端点的问题解决了但每帧都要改stroke-dashoffset属性在低端安卓机上掉帧明显。最后切到 Canvas用requestAnimationFrame驱动一帧只做一次clearRect加一次arc绘制性能反而最稳。这就是我后来一直用 Canvas 做圆形倒计时的原因——控制粒度细、性能可控、视觉还原度高。1.2 Canvas 方案的核心优势在哪很多人觉得 Canvas 画个圆而已杀鸡用牛刀。但真正做过倒计时组件的人会明白难点从来不是画一个圆而是时间精度、视觉同步、性能稳定这三件事同时满足。用 DOM 加 CSS 的方案时间推进靠setInterval渲染靠浏览器合成层两者是解耦的。你设 1000ms 的间隔实际可能因为主线程繁忙延迟到 1010ms 甚至更多累积下来进度条和数字就对不上了。Canvas 方案里时间计算和绘制在同一帧内完成requestAnimationFrame每帧回调时先算当前剩余时间再根据这个时间画进度天然同步。另一个优势是绘制自由度。进度条要做渐变色、要做发光效果、要在端点加个小圆点、要在圆环内侧加刻度线这些在 Canvas 里就是几行绘制代码的事换成 CSS 或 SVG 就要各种 hack。我见过一个需求是倒计时最后 5 秒进度条变红并闪烁Canvas 里改个颜色变量加个透明度正弦波动就搞定了。1.3 适合哪些场景和不适合哪些场景圆形倒计时进度条最适合的场景是活动限时、验证码倒计时、考试答题计时、视频缓冲进度、任务完成度展示。这些场景的共同点是有一个明确的起止时间且需要视觉上直观地传达还剩多少。不适合的场景也要说清楚如果页面上有几十个倒计时同时运行每个都开一个 Canvas 加一个requestAnimationFrame那性能会崩。这种情况应该用一个 Canvas 统一绘制所有倒计时或者干脆用 CSS 方案降低精度要求。另外如果倒计时精度要求到毫秒级且要显示毫秒数Canvas 每帧 16.7ms 的刷新率也不够用得用performance.now()配合更高频的绘制策略。2. 核心原理拆解从圆弧绘制到时间映射2.1 Canvas 画圆弧的底层逻辑Canvas 的arc方法签名是arc(x, y, radius, startAngle, endAngle, anticlockwise)。这里有几个坑必须先说清楚。角度单位是弧度不是度360度 2 * Math.PI。起始角度 0 弧度对应的是三点钟方向不是十二点钟方向。所以如果你想让进度条从顶部开始走起始角度要设成-Math.PI / 2。这个-90度的偏移是几乎所有圆形进度条都要处理的第一个细节。anticlockwise参数默认是false表示顺时针绘制。倒计时进度条通常是顺时针从满到空所以保持默认即可。但如果你要做从空到满的加载效果逻辑就要反过来。// 画一个从顶部开始、顺时针、占 3/4 圈的圆弧 const centerX 100; const centerY 100; const radius 80; const startAngle -Math.PI / 2; // 顶部 const endAngle startAngle (Math.PI * 2) * 0.75; // 走 3/4 圈 ctx.beginPath(); ctx.arc(centerX, centerY, radius, startAngle, endAngle, false); ctx.stroke();2.2 时间到角度的映射计算倒计时的核心是一个线性映射剩余时间比例 剩余时间 / 总时间然后结束角度 起始角度 2π × 剩余比例。假设总时长 60 秒当前剩 45 秒那么剩余比例是 0.75结束角度就是-π/2 2π × 0.75。这个计算每帧都要做一次因为剩余时间在变。这里有个精度问题要注意Date.now()的精度在部分浏览器里被降低到 2ms 甚至 100ms出于安全考虑所以计算剩余时间时最好用performance.now()它的精度是微秒级且单调递增不受系统时间调整影响。const totalDuration 60000; // 60秒单位毫秒 const startTime performance.now(); function getRemainingRatio() { const elapsed performance.now() - startTime; const remaining Math.max(0, totalDuration - elapsed); return remaining / totalDuration; }用Math.max(0, ...)是为了防止倒计时结束后出现负数比例导致圆弧反向绘制。2.3 圆头端点与渐变色的实现细节Canvas 的lineCap属性可以设成butt、round、square。要做圆头进度条设成round即可。但圆头有个视觉问题当进度接近 0 时圆头本身还有宽度看起来像没走完。解决办法是当剩余比例低于某个阈值比如 0.01时直接不绘制进度弧。渐变色用createLinearGradient或createConicGradient。createConicGradient是较新的 API兼容性在 2023 年后已经不错了但保险起见可以用线性渐变模拟。线性渐变的局限是它沿直线方向变化对于圆形进度条来说颜色过渡不是沿着圆弧走的。如果对渐变方向要求不高线性渐变够用要求高的话就用createConicGradient。// 锥形渐变从顶部开始颜色沿圆弧变化 const gradient ctx.createConicGradient(-Math.PI / 2, centerX, centerY); gradient.addColorStop(0, #4facfe); gradient.addColorStop(1, #00f2fe); ctx.strokeStyle gradient;2.4 背景圆环与进度圆环的层级关系一个完整的圆形进度条通常由三层组成背景圆环、进度圆环、中心文字。绘制顺序不能乱后画的会覆盖先画的。背景圆环用浅灰色画一个完整的圆进度圆环用渐变色画部分圆弧中心文字用fillText绘制。如果要在进度圆环端点加个小圆点需要在画完圆弧后再单独画一个实心圆位置在结束角度对应的坐标上。// 计算端点坐标 const endX centerX radius * Math.cos(endAngle); const endY centerY radius * Math.sin(endAngle); ctx.beginPath(); ctx.arc(endX, endY, lineWidth / 2, 0, Math.PI * 2); ctx.fillStyle #4facfe; ctx.fill();这个端点小圆点的半径通常设成线宽的一半这样它和圆头端点能完美衔接。3. 完整实操从零实现一个可复用的倒计时组件3.1 初始化 Canvas 与处理高清屏适配高清屏适配是 Canvas 的第一个坑。在devicePixelRatio为 2 的屏幕上如果 Canvas 的width和height属性设成 200实际显示区域是 200 CSS 像素但绘制缓冲区只有 200 物理像素结果就是模糊的。正确做法是Canvas 的width和height属性设成200 * dprCSS 的width和height设成200px然后ctx.scale(dpr, dpr)。这样绘制时用的坐标系还是 200x200但实际渲染分辨率翻倍。function setupCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; canvas.width cssWidth * dpr; canvas.height cssHeight * dpr; canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }注意ctx.scale会累积如果多次调用会叠加缩放。所以要么只调一次要么每次绘制前用ctx.setTransform(dpr, 0, 0, dpr, 0, 0)重置。3.2 绘制背景圆环与进度圆环背景圆环和进度圆环的线宽要一致通常设成 8 到 12 像素。线宽太大显得笨重太小看不清。半径的计算要考虑线宽如果 Canvas 尺寸是 200x200线宽 10那么半径最大只能设成100 - 10/2 95否则圆环会超出边界被裁掉。const size 200; const lineWidth 10; const radius size / 2 - lineWidth / 2; const centerX size / 2; const centerY size / 2; // 背景圆环 ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.strokeStyle #e6e6e6; ctx.lineWidth lineWidth; ctx.stroke(); // 进度圆环 const startAngle -Math.PI / 2; const endAngle startAngle Math.PI * 2 * remainingRatio; ctx.beginPath(); ctx.arc(centerX, centerY, radius, startAngle, endAngle); ctx.strokeStyle gradient; ctx.lineWidth lineWidth; ctx.lineCap round; ctx.stroke();3.3 中心倒计时数字的绘制与格式化中心数字用fillText绘制需要设置font、textAlign、textBaseline。textAlign设成centertextBaseline设成middle这样文字就居中在圆心位置。数字格式化要注意剩余时间可能是 59.8 秒显示时应该向上取整成 60 秒而不是向下取整成 59 秒。因为用户感知的是还有 60 秒如果显示 59 秒但实际还有 59.8 秒会让人觉得倒计时走快了。const remainingSeconds Math.ceil(remaining / 1000); ctx.font bold 36px Arial; ctx.textAlign center; ctx.textBaseline middle; ctx.fillStyle #333; ctx.fillText(remainingSeconds s, centerX, centerY);如果倒计时超过 60 秒通常格式化成01:30这样的分秒格式。超过 1 小时则格式化成01:30:45。3.4 用 requestAnimationFrame 驱动动画循环requestAnimationFrame的回调频率跟显示器刷新率一致通常是 60fps也就是每 16.7ms 一次。在这个回调里我们要做四件事清空画布、计算剩余时间、绘制所有元素、判断是否结束。function animate() { const remaining Math.max(0, totalDuration - (performance.now() - startTime)); const ratio remaining / totalDuration; ctx.clearRect(0, 0, size, size); drawBackgroundRing(); drawProgressRing(ratio); drawCenterText(remaining); if (remaining 0) { requestAnimationFrame(animate); } else { onComplete(); } }clearRect的参数是 CSS 像素坐标系下的值因为我们之前ctx.scale(dpr, dpr)了所以直接传size即可。3.5 倒计时结束的回调与状态重置倒计时结束时要做两件事触发业务回调比如启用按钮、跳转页面以及清理动画循环。requestAnimationFrame返回一个 id可以用cancelAnimationFrame取消但在上面的写法里剩余时间为 0 时不再调用requestAnimationFrame循环自然就停了。如果需要支持重置比如用户点击重新开始要把startTime重新赋值并重新启动动画循环。注意重置时要先取消可能还在运行的旧循环否则会有两个循环同时跑。let rafId null; function start() { if (rafId) cancelAnimationFrame(rafId); startTime performance.now(); animate(); } function animate() { // ... 绘制逻辑 if (remaining 0) { rafId requestAnimationFrame(animate); } else { rafId null; onComplete(); } }4. 常见问题与排查技巧实录4.1 进度条与数字不同步怎么办这是最常见的问题。表现是数字已经跳到 30 了但进度条看起来还有 35 秒的量。原因通常是数字和进度条用了不同的时间源或者数字更新用了setInterval而进度条用了requestAnimationFrame。解决办法只有一个所有视觉元素都在同一帧内基于同一个时间值绘制。不要用setInterval单独更新数字数字的更新应该发生在requestAnimationFrame回调里和进度条绘制用同一个remaining变量。如果发现数字跳变有延迟检查是不是在requestAnimationFrame里做了耗时操作导致帧率下降。可以在回调开头记录performance.now()结尾再记录一次看看单帧耗时是否超过 16ms。4.2 低端机上掉帧的优化思路低端安卓机上 Canvas 掉帧通常有三个原因绘制区域太大、每帧创建新对象、阴影和模糊效果太重。优化手段按优先级排列第一把 Canvas 尺寸控制在 300x300 以内超过这个尺寸的圆形进度条很少见第二渐变对象、字体设置这些不要每帧创建在初始化时创建好存起来第三避免使用shadowBlur它非常耗性能如果一定要发光效果用径向渐变模拟第四如果页面不可见比如切到后台暂停动画循环用document.visibilitychange监听。document.addEventListener(visibilitychange, () { if (document.hidden) { if (rafId) cancelAnimationFrame(rafId); } else { start(); // 重新启动注意处理时间补偿 } });切回前台时要注意时间补偿如果后台停留了 10 秒倒计时应该扣除这 10 秒而不是从暂停的地方继续。因为倒计时是基于绝对时间的performance.now()在后台仍然在走。4.3 圆环端点重叠与锯齿问题当剩余比例接近 1 时起始角度和结束角度几乎重合圆头端点会重叠在一起看起来像一个小圆点。解决办法是当比例大于 0.99 时直接画一个完整的圆不走arc的部分绘制逻辑。锯齿问题出现在圆弧边缘尤其是线宽较小时。可以在绘制前设置ctx.imageSmoothingEnabled true但更有效的方法是确保 Canvas 分辨率是dpr倍数并且线宽是偶数。如果线宽是奇数在dpr为 1 的屏幕上会出现半像素渲染导致模糊。4.4 常见问题速查表问题现象可能原因排查方法解决方案进度条模糊Canvas 分辨率未适配 dpr检查 canvas.width 与 CSS width 比值按 dpr 放大 canvas 尺寸并 scale数字与进度不同步时间源不一致检查是否混用 setInterval 和 rAF统一在 rAF 内基于同一时间值绘制低端机掉帧每帧创建渐变/阴影过重用 Performance 面板录制帧耗时缓存渐变对象去掉 shadowBlur端点重叠比例接近 1 时圆弧过短观察剩余 1% 时的表现比例 0.99 时画完整圆切后台后时间错乱未处理 visibilitychange切后台 10 秒再切回观察基于绝对时间计算切回时重新计算圆环超出边界半径未减去线宽一半检查 radius lineWidth/2 是否大于 size/2半径设为 size/2 - lineWidth/24.5 几个我踩过的坑第一个坑是ctx.scale的累积效应。我在一个页面里多次调用setupCanvas每次都会ctx.scale(dpr, dpr)结果第二次调用后缩放变成了dpr的平方绘制出来的东西大了一倍。后来改成每次绘制前用setTransform重置。第二个坑是performance.now()和Date.now()混用。performance.now()是从页面加载开始计算的毫秒数Date.now()是 Unix 时间戳。两者混用会导致时间计算完全错乱。统一用performance.now()就好。第三个坑是倒计时结束后的最后一帧。如果剩余时间刚好在两次requestAnimationFrame之间归零最后一帧可能画的是剩余 0.5 秒的状态然后直接跳到结束回调视觉上进度条没有走到 0。解决办法是在判断remaining 0时强制再画一帧比例为 0 的状态然后再触发回调。if (remaining 0) { ctx.clearRect(0, 0, size, size); drawBackgroundRing(); drawProgressRing(0); drawCenterText(0); onComplete(); return; }这个细节很小但用户能感知到。倒计时走到 0 的那一刻进度条应该是空的数字应该是 0然后按钮才变亮。顺序错了就会觉得卡了一下。5. 进阶扩展让倒计时组件更通用5.1 支持自定义尺寸、颜色与线宽把尺寸、颜色、线宽这些做成配置项传入组件就能复用到不同场景。配置项的设计要遵循约定优于配置的原则大部分参数有合理默认值调用方只需要传必要的几个。const defaultOptions { size: 200, lineWidth: 10, bgColor: #e6e6e6, progressColors: [#4facfe, #00f2fe], textColor: #333, fontSize: 36, duration: 60000, onComplete: () {} }; function createCountdown(canvas, options) { const opts Object.assign({}, defaultOptions, options); // ... }渐变色用数组传入两个颜色做线性渐变三个以上颜色做锥形渐变。这样调用方想改配色只需要传一个数组。5.2 倒计时暂停与恢复的实现暂停和恢复的关键是记录已过去的时间。暂停时记录pausedElapsed performance.now() - startTime恢复时把startTime设成performance.now() - pausedElapsed。这样剩余时间计算逻辑不用改。let pausedElapsed 0; function pause() { pausedElapsed performance.now() - startTime; if (rafId) cancelAnimationFrame(rafId); rafId null; } function resume() { startTime performance.now() - pausedElapsed; animate(); }注意暂停时也要取消动画循环否则画面会继续刷新但时间不走看起来像卡住了。5.3 多个倒计时共用一个动画循环如果页面上有多个倒计时不要每个都开一个requestAnimationFrame。正确做法是维护一个倒计时实例列表用一个全局的动画循环遍历列表逐个更新和绘制。const instances []; function globalAnimate() { instances.forEach(inst { inst.update(); inst.draw(); }); if (instances.some(inst inst.isRunning())) { requestAnimationFrame(globalAnimate); } }这样无论页面上有多少个倒计时都只有一个requestAnimationFrame在跑性能开销大幅降低。每个实例只负责自己的时间计算和绘制逻辑。5.4 从倒计时到正计时进度条的改造把倒计时改成正计时进度条只需要改两个地方剩余比例改成已用比例结束条件改成已用时间达到总时长。中心数字从剩余秒数改成已用秒数或百分比。这个改造在考试答题计时、任务完成度展示等场景很有用。代码结构完全不用变只是ratio的计算从remaining / total变成elapsed / total。5.5 性能对比Canvas vs SVG vs CSS方案圆头端点渐变控制低端机性能时间同步精度实现复杂度Canvas原生支持完全可控优秀帧级同步中等SVG原生支持受限于渐变定义一般需手动同步中等CSS conic-gradient不支持受限于渐变语法优秀难同步低CSS SVG 混合支持受限于 SVG一般需手动同步高从表里能看出来Canvas 在渐变控制和性能上综合最优代价是实现复杂度中等。如果项目对包体积敏感且不需要圆头端点CSS 方案也能用。但如果要做活动页这种对视觉要求高的场景Canvas 是更稳妥的选择。6. 一些实战中的经验补充6.1 关于时间精度的取舍倒计时组件不需要毫秒级精度但需要视觉上的连续感。如果每秒才更新一次数字进度条也是每秒跳一下用户会觉得卡顿。用requestAnimationFrame每帧更新进度条数字用Math.ceil每秒变一次这样进度条是平滑的数字是整秒跳的视觉上最舒服。如果数字也要平滑变化比如显示 59.8、59.7 这样那就要处理小数位显示的问题。通常保留一位小数就够了再多用户也看不清。6.2 移动端触摸事件与倒计时的配合在移动端如果倒计时期间用户触摸屏幕可能会触发页面的滚动或缩放导致 Canvas 重绘。可以在 Canvas 上监听touchstart并preventDefault但要注意不要阻止页面正常的滚动。更好的做法是把 Canvas 放在一个固定定位的容器里容器不参与页面滚动。另外 iOS 上有个经典问题requestAnimationFrame在页面滚动时会暂停。如果倒计时组件在滚动区域内用户滚动时倒计时会停住。解决办法是把倒计时组件放在position: fixed的容器里或者用will-change: transform提升合成层。6.3 服务端时间校准的必要性如果倒计时是基于服务端下发的结束时间戳客户端要用服务端时间而不是本地时间来计算剩余时间。因为用户可能手动改了系统时间或者设备时间本身就不准。做法是接口返回serverTime和endTime客户端计算offset serverTime - Date.now()之后所有时间计算都加上这个offset。这样即使本地时间不准倒计时也是对的。const offset serverTime - Date.now(); const remaining endTime - (Date.now() offset);这个细节在电商活动页里特别重要因为倒计时结束直接关系到用户能不能下单时间错了就是事故。6.4 无障碍访问的考虑Canvas 里的文字对屏幕阅读器是不可见的。如果倒计时组件需要支持无障碍访问要在 Canvas 旁边放一个视觉隐藏的div用aria-live实时更新剩余时间。这样屏幕阅读器用户也能感知到倒计时的变化。div classsr-only aria-livepolite idcountdown-text剩余 60 秒/div.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }每秒钟更新一次这个div的文字内容即可不需要每帧更新否则屏幕阅读器会读个不停。6.5 组件销毁时的资源清理单页应用里页面切换时如果倒计时组件没有正确销毁requestAnimationFrame会继续跑造成内存泄漏。组件销毁时要取消动画循环、移除事件监听、清空 Canvas 引用。function destroy() { if (rafId) { cancelAnimationFrame(rafId); rafId null; } document.removeEventListener(visibilitychange, handleVisibility); canvas null; ctx null; }如果用了全局动画循环的方案销毁时要把实例从instances数组里移除否则全局循环会一直遍历一个已经失效的实例。6.6 关于圆环起始角度的视觉习惯虽然数学上 0 弧度在三点钟方向但用户习惯的进度条起点是十二点钟方向。所以起始角度统一设成-Math.PI / 2。如果产品经理要求从六点钟方向开始比如某些仪表盘设计改成Math.PI / 2即可。顺时针还是逆时针也有讲究。倒计时通常顺时针走因为顺时针符合时间流逝的直觉。但如果是剩余电量这种场景逆时针可能更合适因为电量是从满到空逆时针走更像消耗。这些视觉细节没有绝对的对错但要在组件设计时留出配置项让调用方根据场景选择。6.7 最后分享一个调试小技巧开发倒计时组件时把总时长设成 5 秒而不是 60 秒这样能快速看到从满到空的全过程包括结束时的状态切换。等 5 秒的逻辑调通了再改成 60 秒验证长时间运行的稳定性。另外可以在 Canvas 上叠加一个显示当前帧率和剩余毫秒数的调试层用fillText画在角落。这样能直观看到帧率是否稳定、时间计算是否有偏差。上线前把这个调试层去掉即可。// 调试层 ctx.fillStyle red; ctx.font 12px monospace; ctx.textAlign left; ctx.textBaseline top; ctx.fillText(FPS: ${fps} | Remain: ${remaining.toFixed(0)}ms, 5, 5);这个技巧帮我省了很多排查时间尤其是定位为什么进度条看起来走快了这类问题时直接看剩余毫秒数的变化就能判断是时间计算错了还是绘制逻辑错了。

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

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

免费获取报价 →
↑