ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Canvas 动态时钟绘制全攻略:从坐标轴到高分屏适配一次讲透

Canvas 动态时钟绘制全攻略:从坐标轴到高分屏适配一次讲透 1. 动工之前先搞懂两件事坐标轴和高分屏适配如果你之前只用过 CSS 画图形第一次打开 Canvas 画时钟时十有八九会把表盘画歪。原因不是手笨而是 Canvas 的坐标系和你在数学课上学到的坐标系长得不一样。所以动手之前我建议先花五分钟把下面两个基础问题理清楚后面所有代码都会顺畅很多。1.1 时钟为什么默认长在三点钟方向Canvas 的坐标原点在画布左上角x 轴朝右y 轴朝下。这本来没什么稀奇的真正让人容易踩坑的是角度系统ctx.rotate(0)朝向正右方也就是表盘的三点钟方向而且角度增加的方向是顺时针不是数学里的逆时针。这意味着你如果直接写x cx r * Math.cos(angle)当angle 0时刻度会落在三点钟位置而不是十二点位置。想让 12 点刻度出现在正上方就得把角度整体往回转 90 度也就是在公式里减去Math.PI / 2。这里用生活里的例子来理解你把一个纸质表盘平放在桌上12 点朝墙3 点朝右。现在想在一张透明贴纸上标注所有刻度你得先把贴纸转成3 点朝右的基准对应的就是 Canvas 的 0 度然后逆推回12 点朝上就需要减掉 90 度。理解了这一点后面所有刻度、指针的角度计算就都不会迷糊。而弧度制换算也很简单一圈是2 * Math.PI对应 360 度所以每度是Math.PI / 180。我习惯先按度想清楚逻辑写代码时再统一转成弧度避免在多个地方重复做转换导致数值不一致。1.2 高分屏下线条发虚的真因很多初学者画完时钟后发现在 Mac 或手机上线条是糊的还以为是自己代码写错了。这其实是 Canvas 的物理像素和 CSS 逻辑像素不一致导致的。默认情况下Canvas 的绘图缓冲区和它在页面上显示的尺寸是 1:1 的如果你的设备devicePixelRatioDPR是 2浏览器就会用两个物理像素去展示一个逻辑像素相当于把那张 300×300 的图硬拉大了一倍不糊才怪。解决办法是让 Canvas 的实际分辨率和高分屏匹配再用setTransform把绘制坐标统一缩放到逻辑尺寸。我常用的初始化写法是这样canvas idclock width300 height300 stylewidth: 300px; height: 300px;/canvasconst canvas document.getElementById(clock); const ctx canvas.getContext(2d); const size 300; const dpr window.devicePixelRatio || 1; canvas.width size * dpr; canvas.height size * dpr; canvas.style.width size px; canvas.style.height size px; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);注意看这里的关键我先让 HTML 里的 width/height 等于逻辑尺寸然后在 JS 里用 DPR 缩放画布的实际分辨率再用setTransform把坐标系恢复成 300×300 的逻辑空间。这样后面所有绘制代码都不用关心设备差异直接以 300 为画布尺寸写就行。如果你用的是 Retina 屏或者手机浏览器这一行setTransform省掉的麻烦比你想象的多。1.3 初始化代码与整体结构做动态时钟前先把绘制框架搭好。我的习惯是分为三个层表盘层圆、刻度、数字、指针层三根指针和中心铆钉、动画层每帧重绘。分层的意义在于后面想加主题、换指针样式时只需要改对应函数不用推翻重来。const cx size / 2; const cy size / 2; const radius size / 2 - 10; function drawDial() { /* 表盘和刻度 */ } function drawNumbers() { /* 数字 */ } function drawHands(date) { /* 指针 */ } function render() { ctx.clearRect(0, 0, size, size); drawDial(); drawNumbers(); drawHands(new Date()); requestAnimationFrame(render); } render();有人会问为什么动画函数里要传入new Date()而不是在外面先算好时间再传进来。后面讲到动画驱动时我会详细解释这里先记住一点时钟类的动画每次都从系统时间取数是最稳妥的做法。2. 表盘层从圆环到刻度与数字的排版表盘是时钟的脸画得好不好看直接决定整个作品的观感。Canvas 绘制弧线、圆环很简单但刻度和数字的排布需要一点几何基础这一节我们逐个拆开。2.1 外圈与表盘底先填底色再描边先把最外层的表盘画出来。这里有个小讲究绘制顺序是填充在前、描边在后因为描边线宽的一半会覆盖在填充区域上如果反过来边缘会显得参差不齐。function drawDial() { // 表盘底色 ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fillStyle #fdf6e3; ctx.fill(); // 外圈描边 ctx.strokeStyle #333; ctx.lineWidth 6; ctx.stroke(); }如果想让表盘更有质感可以在填充时加一个径向渐变让中心稍微亮一点、边缘暗一点模拟真实表盘的金属反光。渐变不会增加太多代码量但视觉提升非常明显const gradient ctx.createRadialGradient(cx, cy, radius * 0.1, cx, cy, radius); gradient.addColorStop(0, #fffbe6); gradient.addColorStop(1, #e8dcc0); ctx.fillStyle gradient;2.2 刻度排布的核心公式先算坐标再连线刻度的本质是圆上的一系列点。以 12 点方向为起点每两个整点刻度之间是 360 ÷ 12 30 度每两个分钟刻度之间是 360 ÷ 60 6 度。从圆心出发某个刻度所在点的坐标是const angle (i * step) * Math.PI / 180 - Math.PI / 2; const outerX cx outerR * Math.cos(angle); const outerY cy outerR * Math.sin(angle);其中i是刻度序号step是每个刻度的角度步长outerR是刻度的外圈半径。- Math.PI / 2就是前面说的偏移量让 12 点方向变成起点。实际的刻度绘制分成两种整点刻度粗而长分钟刻度细而短。我常用的参数是外圈半径radius - 12整点刻度的内圈是radius - 28分钟刻度的内圈是radius - 20这样长短错落更像真实的表盘。完整代码如下function drawDial() { // 表盘底色与前缘描边 // ... // 整点刻度 for (let i 0; i 12; i) { const angle (i * 30) * Math.PI / 180 - Math.PI / 2; const outerX cx (radius - 12) * Math.cos(angle); const outerY cy (radius - 12) * Math.sin(angle); const innerX cx (radius - 28) * Math.cos(angle); const innerY cy (radius - 28) * Math.sin(angle); ctx.beginPath(); ctx.moveTo(outerX, outerY); ctx.lineTo(innerX, innerY); ctx.strokeStyle #333; ctx.lineWidth 6; ctx.lineCap round; ctx.stroke(); } // 分钟刻度 for (let i 0; i 60; i) { if (i % 5 0) continue; const angle (i * 6) * Math.PI / 180 - Math.PI / 2; const outerX cx (radius - 10) * Math.cos(angle); const outerY cy (radius - 10) * Math.sin(angle); const innerX cx (radius - 18) * Math.cos(angle); const innerY cy (radius - 18) * Math.sin(angle); ctx.beginPath(); ctx.moveTo(outerX, outerY); ctx.lineTo(innerX, innerY); ctx.strokeStyle #888; ctx.lineWidth 2; ctx.lineCap round; ctx.stroke(); } }这里有两个细节值得说明。第一个是lineCap round它会让刻度的两端变成半圆头视觉上更像真表盘的油印刻度第二个是整点刻度用较深的#333分钟刻度用较浅的#888拉开层次感读时间时一眼就能区分。如果你在任何 Canvas 项目里需要画圆上均匀分布的标记——不管是刻度、车轮辐条、数据仪表盘的标尺还是地铁线路图的站点节点——这套循环角度算出坐标再连线的思路都是通用的。这正是 Canvas 绘图引擎类工具普遍采用的基础算法。2.3 数字的摆放与字体细节数字的位置逻辑和刻度一模一样只不过把线段换成fillText半径稍微再往里收一点。我通常放在radius - 46的位置避免数字和分钟刻度挤在一起。function drawNumbers() { ctx.fillStyle #333; ctx.font bold 22px sans-serif; ctx.textAlign center; ctx.textBaseline middle; for (let i 1; i 12; i) { const angle (i * 30) * Math.PI / 180 - Math.PI / 2; const numX cx (radius - 46) * Math.cos(angle); const numY cy (radius - 46) * Math.sin(angle); ctx.fillText(String(i), numX, numY); } }textAlign和textBaseline很多人会漏掉。不设置的话Canvas 默认从文字的左上角开始绘制12 个数字的位置会全乱掉。设成center和middle后文字会以(numX, numY)为中心对齐位置才是我们期待的。字体方面建议把font的声明放在循环外面不要在每次循环里重复设置字符串虽然在 12 次循环里性能差异可以忽略但这是个好的编码习惯。另外如果你想做 3、6、9、12 这四个数字加大加粗的经典设计可以在循环里加一个个判断单独设置字号。3. 指针层时间值到角度的换算与绘制技巧刻度画完时钟的静态部分就结束了。接下来是灵魂所在——指针。指针的难点不在画而在怎么把当前时间转成准确的旋转角度。很多人卡在这一步其实就是没把时间拆成小数。3.1 从时间到角度的换算公式先明确一个认知表盘上指针的位置不是孤立的而是联动的。时针指向 10 点的同时如果分针指向 30 分那么时针应该在 10 和 11 点之间的正中间而不是死死定在 10 点。要做到这一点所有指针的角度都要带上下级单位的小数部分。我的换算逻辑是这样function getHandAngles(date) { const ms date.getMilliseconds(); const seconds date.getSeconds() ms / 1000; const minutes date.getMinutes() seconds / 60; const hours (date.getHours() % 12) minutes / 60; return { secondAngle: seconds * 6 * Math.PI / 180, minuteAngle: minutes * 6 * Math.PI / 180, hourAngle: hours * 30 * Math.PI / 180 }; }逐行解释一下秒针每秒旋转 6 度所以seconds × 6。分针每分钟旋转 6 度但真实的分针也会因为秒针的走动而缓慢前进所以加上seconds / 60的小数。时针每小时旋转 30 度但时针会随分钟移动所以加上minutes / 60的小数。最终再乘Math.PI / 180转成弧度供ctx.rotate使用。关键点在于秒针的角度里带上了毫秒小数ms / 1000这样秒针就不是咔哒咔哒地每秒跳一格而是平滑扫过表盘。如果你想要那种机械表的跳动效果把毫秒去掉就行。这个我们在动画部分还会展开。3.2 save / translate / rotate / restore 绘制指针有了角度绘制指针就变得极其简单。核心思路是先把坐标系原点移动到表盘中心再旋转对应的角度然后沿着 x 轴方向画一条线指针就自然指向正确的方向。这里用到了 Canvas 状态管理四件套save、translate、rotate、restore。function drawHand(angle, length, width, color) { ctx.save(); ctx.translate(cx, cy); ctx.rotate(angle); ctx.beginPath(); ctx.moveTo(0, 12); // 指针尾部伸出圆心形成配重 ctx.lineTo(length, 0); // 指针尖端 ctx.lineWidth width; ctx.lineCap round; ctx.strokeStyle color; ctx.stroke(); ctx.restore(); }moveTo(0, 12)这行很多人会忽略它的作用是让指针从圆心下方向前伸出也就是说指针不是从圆心开始而是从圆心上方的配重位置指向刻度方向。这个小细节能让指针看起来更真实因为机械表的指针尾部通常都有一小段配重。如果你想要更精致的指针造型可以把简单的线条升级成一个多边形。原理是沿着旋转后的坐标系勾勒出梯形的四个顶点function drawTaperedHand(angle, length, tail, thickness) { ctx.save(); ctx.translate(cx, cy); ctx.rotate(angle); ctx.beginPath(); ctx.moveTo(-tail, thickness); ctx.lineTo(length, 0); ctx.lineTo(-tail, -thickness); ctx.closePath(); ctx.fillStyle #333; ctx.fill(); ctx.restore(); }这样画出来的指针是从根部宽、尖端细的三角形或梯形比单纯一条线精致得多。实际项目中我经常用梯形指针画时针和分针用细线加圆头画秒针层次感马上就出来了。3.3 中心铆钉与指针装饰三根指针都画完后最后别忘了在圆心补一个小圆点也就是表盘中心的铆钉。这个圆点看似不起眼却能一下子把三根指针汇聚起来不然指针会像悬浮在表盘上一样缺一个视觉锚点。ctx.beginPath(); ctx.arc(cx, cy, 6, 0, Math.PI * 2); ctx.fillStyle #c0392b; ctx.fill(); ctx.lineWidth 2; ctx.strokeStyle #333; ctx.stroke();我习惯把铆钉画成红色因为很多经典表盘都用红点作为中心装饰也方便识别秒针的起始位置。铆钉的大小建议在 5~8px 之间太大遮挡指针根部的配重太小又没有存在感。4. 让指针动起来requestAnimationFrame 的正确打开方式静态时钟画得再好也只是个图片。真正让它活起来的是动画驱动机制。这里我会讲清楚为什么选requestAnimationFrame以及时钟类动画最容易忽略的一个问题。4.1 为什么用 rAF 而不是 setInterval很多初学者习惯用setInterval(render, 1000)每秒重绘一次。这个方案在电脑上看着能用其实隐患不少。setInterval的问题在于它只保证每隔一段时间把回调放进任务队列不保证回调执行的时机和屏幕的刷新率同步。如果主线程繁忙回调可能被延后或者一秒钟内执行多次结果是秒针走得不均匀看着忽快忽慢。requestAnimationFramerAF则完全不一样。浏览器会在下一次屏幕刷新之前调用你传入的回调函数而且会自动把回调频率和显示器的刷新率对齐通常是 60Hz。这意味着动画每秒钟重绘的次数是稳定的帧与帧之间的时间间隔也基本均匀。还有一个非常重要的现实差异当用户切换到其他浏览器标签页时rAF 会暂停执行而setInterval会继续在后台空转。这不仅是性能问题更是逻辑问题。我们用到了这一点就是下面要说的时间来源策略。两者的对比如下特性setIntervalrequestAnimationFrame执行时机定时器队列与屏幕刷新无关与屏幕刷新同步后台标签页行为继续执行空耗资源自动暂停帧率控制需要额外计算时间差自动匹配显示器用在这个场景会造成秒针跳跃或漂移最合适4.2 每次重绘都重新读取系统时间这是时钟类 Canvas 项目最核心的一个设计决策不要在动画外保存一个初始时间然后累加偏移量而是每次重绘时都重新new Date()。原因很简单。rAF 在标签页切到后台时会暂停如果我们的时间逻辑是上一帧时间 帧间隔那么切回标签页的瞬间动画会从暂停前的旧时间继续走时钟显示的时间就不准了严重时可能慢了几分钟。但如果每次重绘都重新读取系统时间那么不管 rAF 暂停了多久、恢复了多少次下一帧画出来的永远是此刻的真实时间。切回标签页的体验就是时钟瞬间归位误差为 0。这也是我坚持在render()内部直接new Date()而不是在外面先创建 Date 对象传进来的原因。任何做时钟、倒计时、实时图表的人都应该养成这个习惯。在秒针跳动那块还有个经典差异要讲清楚。如果你想要扫秒效果秒针角度就要包含毫秒小数。我把getHandAngles里的毫秒部分单独抽出来处理function render() { const date new Date(); const ms date.getMilliseconds(); const smoothSeconds date.getSeconds() ms / 1000; const secondAngle smoothSeconds * 6 * Math.PI / 180; // draw... }想要跳动效果就把ms去掉只用date.getSeconds()。两种风格对应石英表和机械表的感觉没有绝对优劣全看你的设计意图。我之前做一款桌面小组件时两种都试过最终选了扫秒——因为在 60Hz 屏幕下扫秒的视觉流畅度明显更好。4.3 全量重绘与双层 Canvas 的取舍目前的写法是每帧都清空画布然后重绘表盘、刻度、数字、指针。对一个只有圆、线段和文字的时钟来说全量重绘的性能开销完全可以忽略一帧才几个绘制指令60fps 毫无压力。但如果你未来要做更复杂的表盘——比如带渐变背景、复杂花纹、甚至动态光效——每次都全量重绘就会浪费大量计算。这时候的优化思路是分层把静态的表盘画到一个离屏 Canvas 上offscreen canvas每帧只需要用drawImage把静态层贴上去再在动画帧里单独画指针。const staticCanvas document.createElement(canvas); staticCanvas.width size * dpr; staticCanvas.height size * dpr; const staticCtx staticCanvas.getContext(2d); staticCtx.setTransform(dpr, 0, 0, dpr, 0, 0); // 在 staticCtx 上画表盘、刻度、数字 // 动画帧里 function render() { ctx.clearRect(0, 0, size, size); ctx.drawImage(staticCanvas, 0, 0, size, size); // 再画指针 }这样做的好处是无论你的静态表盘多复杂每个动画帧只需要一次drawImage和几根指针的绘制性能瓶颈完全被绕开了。如果你的自定义时钟要跑在低端 Android 机或老 iPad 上这种分层几乎是必须的。5. 实测踩坑与调优记录从白图到模糊问题的处理代码写完动起来并不代表结束。Canvas 动画在真实设备上的坑比我预想的多。这一节把我实际测试中遇到的高频问题整理出来很多是从线上反馈里一点点排查出来的。5.1 iOS Safari 导出白图的经典问题在实际项目里有个问题特别反直觉用 Canvas 画面和动画都正常但当你调用toDataURL()或canvas.toBlob()导出图片时得到的却是一张白图。尤其是在 iOS Safari 上如果页面上同时用了 Canvas 绘制队列例如在某些框架的 canvas 组件封装里比较常见导出的图片内容经常是空的。最终排查出来的根因主要有两个第一个是Canvas 没有布局完成就执行了绘制和导出。iOS Safari 对隐藏元素或尺寸为 0 的 Canvas绘制的指令会被吞掉虽然不报错但画布内容根本没画上去导出自然是白的。解决方法是先确保 Canvas 在 DOM 中可见、有明确的宽高再执行绘制导出前至少等一下requestAnimationFrame回调让绘制指令真正完成。第二种情况发生在先画后取的时序竞争上。iOS Safari 对 Canvas 指令的处理有异步成分画的太早、取的太快很可能拿到一个还没提交绘制结果的空白画布。我的经验是导出前不要立刻调用toDataURL而是先做一个双缓冲确认——把 Canvas 内容先drawImage到一个临时 Canvas 上如果临时 Canvas 的像素检测不到内容再等一帧重试。这个思路对大多数导出白图问题都有效。如果你的场景是 uni-app 这类跨端框架Canvas 底层还涉及原生成分和 Web 组件的同步问题我的建议是不要依赖框架自动处理而是在绘制完成后用一个小的延时或 rAF 回调去读导出接口把时序主动权握在自己手里。5.2 线条发虚与高分屏锯齿的排查第二个高频问题是线条看起来不够锐利。很多人以为设置了lineWidth 1就能得到 1 物理像素的细线结果画出来是灰蒙蒙的一条 2 像素宽的模糊线。根源在于 Canvas 的 1 像素线是以坐标点为中线向两侧各延展 0.5 像素的如果你把线画在整数坐标上它正好落在两个物理像素之间就被反锯齿算法糊掉了。解决的办法是给 1px 线的坐标加 0.5 的偏移ctx.moveTo(100.5, 50.5); ctx.lineTo(200.5, 50.5);但更彻底的方案还是回到第一节说的 DPR 适配。设备像素比大于 1 的屏幕光加 0.5 是不够的还得让画布分辨率与物理像素对齐否则不管你怎么调坐标画出来的线和预期都有偏差。这里顺便说一句如果你看到刻度线细的细粗的粗、粗细不均先检查自己是不是在循环里改了lineWidth没有还原。5.3 阴影与滤镜的移动端性能代价给指针加个阴影视觉上确实能抬高质感比如秒针尾部的一点光晕会让整个表盘立体很多。但shadowBlur在移动端是个非常昂贵的操作每一帧开启阴影都会触发额外的离屏渲染计算尤其在低端设备上帧率可能直接从 60fps 掉到 30fps 以下。我的实际建议是把阴影用在静态层而不是动画层如果表盘需要阴影把它画进离屏 Canvas如果指针必须加阴影那就限制阴影范围用shadowOffsetX、shadowOffsetY做小范围偏移而不是大数值的shadowBlur。在知道这个设备性能一般的情况下我通常直接弃用阴影改用颜色深浅来区分层次视觉损失并不大。5.4 从时钟衍生出去的扩展方向时钟项目虽小但它覆盖了 Canvas 绘制的三类基础能力几何坐标计算、变换矩阵、逐帧动画。这三样组合起来能做的事情非常多。比如可以把刻度算法延伸到仪表盘组件——车速表、CPU 占用率表、空气质量指数表本质都是根据数值映射到角度再在对应角度画指针。我之前用一个业余项目里的 Canvas 绘图引擎做过地铁线路图绘制工具原理也类似把站点坐标、线路角度、站点标记的几何关系梳理清楚剩下的就是按同样的计算坐标 绘制套路去画。所以即便你只是照着本文画了一个时钟我建议你把它当成一个练手模板尝试改三件事主题化把配色抽成一份配置对象做浅色、深色两套主题。把指针改成不同几何造型比如三角指针、镂空指针锻炼变换矩阵的理解。加一个日期窗口在表盘 3 点和 9 点的中线上画一个小矩形里面显示周一 18:30这就涉及 Canvas 文字排版和矩形裁切的知识。时钟这个项目最好的地方在于它的每个改动都能立刻看到视觉效果而且出错很容易定位指针不对就是角度问题刻度不对就是坐标问题动画卡顿就是性能问题。排查链路清晰很适合用来建立 Canvas 调试的直觉。我自己在写这个教程时又把好几年前的时钟代码翻出来重画了一版。最大的体会是Canvas 动画的代码不在于炫而在于把坐标系、状态管理和时间逻辑理顺。这三样理顺了就算只是黑底白线的基本风格也能做出很有质感的动态时钟这三样没理顺加再多装饰效果也是花架子过两天就崩给你看。建议你在跑通本文代码后试着把秒针改成跳动模式、把表盘改成深色主题每个改动都会逼着你重新理解一遍角度的换算逻辑这种改坏再修好的过程才是真正把 Canvas 吃透的最佳路径。
返回列表