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 的,如果你的设备devicePixelRatio(DPR)是 2,浏览器就会用两个物理像素去展示一个逻辑像素,相当于把那张 300×300 的图硬拉大了一倍,不糊才怪。
解决办法是让 Canvas 的实际分辨率和高分屏匹配,再用setTransform把绘制坐标统一缩放到逻辑尺寸。我常用的初始化写法是这样:
<canvas id="clock" width="300" height="300" style="width: 300px; height: 300px;"></canvas>const 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的问题在于:它只保证每隔一段时间把回调放进任务队列,不保证回调执行的时机和屏幕的刷新率同步。如果主线程繁忙,回调可能被延后,或者一秒钟内执行多次,结果是秒针走得不均匀,看着忽快忽慢。
requestAnimationFrame(rAF)则完全不一样。浏览器会在下一次屏幕刷新之前调用你传入的回调函数,而且会自动把回调频率和显示器的刷新率对齐(通常是 60Hz)。这意味着动画每秒钟重绘的次数是稳定的,帧与帧之间的时间间隔也基本均匀。
还有一个非常重要的现实差异:当用户切换到其他浏览器标签页时,rAF 会暂停执行,而setInterval会继续在后台空转。这不仅是性能问题,更是逻辑问题。我们用到了这一点,就是下面要说的时间来源策略。
两者的对比如下:
| 特性 | setInterval | requestAnimationFrame |
|---|---|---|
| 执行时机 | 定时器队列,与屏幕刷新无关 | 与屏幕刷新同步 |
| 后台标签页行为 | 继续执行,空耗资源 | 自动暂停 |
| 帧率控制 | 需要额外计算时间差 | 自动匹配显示器 |
| 用在这个场景 | 会造成秒针跳跃或漂移 | 最合适 |
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 吃透的最佳路径。