最近总有人问我Canvas入门能做点什么实际的东西,我一般都会推荐:画一个动态时钟。原因很简单,时钟几乎把Canvas最核心的功夫全练到了——坐标计算、弧度换算、路径绘制、重绘与动画循环,一个都不缺。你从画一个静止表盘到指针流畅转动起来,中间会碰到不少容易懵的细节:为什么角度要减90度?为什么画出来是模糊的?秒针到底该跳秒还是走秒?这篇文章就用“动态时钟”这个项目,把从表盘到指针动画的每一步掰开揉碎,手把手带你从零实现。无论你是刚接触Canvas的前端新手,还是想用一个作品练手的进阶学习者,这份流程都能直接拿去跑。
1. 整体设计与思路拆解
1.1 为什么选Canvas而不是DOM或SVG
先说最底层的问题:同样能画时钟,为什么大家推荐Canvas,而不用DOM或SVG?这不是没有道理的。
用DOM旋转指针确实可以实现,给时针、分针、秒针分别做一个div,然后用CSS的transform: rotate()每秒转6度,代码很短。SVG也可以,矢量特性让它在放大缩小时保持清晰。但Canvas的核心优势在于,它是一张可以直接用脚本逐像素控制的位图。时钟的表盘往往有渐变、阴影、磨砂质感、复杂的刻度,这些效果用CSS或SVG表达起来很绕,但在Canvas里就是一个fillStyle、一个createRadialGradient、一个shadowBlur的问题。更关键的是,Canvas天然就是为高频重绘设计的,配合requestAnimationFrame做流畅动画非常顺手。时钟这种每秒(甚至每帧)都要整体重绘的场景,Canvas的图形上下文模型反而比“控制一堆DOM节点”更直接。
另外还有一点:Canvas把所有绘制结果拍平到一张图上,没有DOM树节点数量带来的内存压力。你画100个刻度,它们不是100个结点,只是Canvas画布上的100条线。这对时钟、仪表盘、图表这类有大量重复图形元素的场景,是实打实的性能优势。
1.2 坐标系与时钟设计的核心
Canvas的坐标系默认原点在左上角,x轴向右,y轴向下。绘制时钟时,我们会把原点平移到画布中心,也就是执行ctx.translate(w / 2, h / 2)。这样表盘圆心就成了坐标原点,后面所有刻度和指针的计算都会集中在本地坐标系内,逻辑非常清晰。
但这里有一个新手最容易翻车的点:Canvas里的角度和钟表上的角度,起点不一样,方向也默认不一样。Canvas的旋转和三角函数基于弧度制,一周是2 * Math.PI,而钟表的0点位置在12点钟方向,也就是屏幕的垂直向上方向;Canvas默认的0度角在3点钟方向,也就是x轴正方向。所以我们在绘制指针或刻度时,要么在角度计算时减去Math.PI / 2(即90度),要么在旋转前先做一次偏移,否则你画出来的是“3点起的表”,怎么看怎么别扭。
这里还要强调一个基础换算:JavaScript的三角函数全用弧度,而钟表习惯用角度。换算公式是:
弧度 = 角度 * Math.PI / 18012个小时对应一圈2 * Math.PI,所以每个小时间隔是Math.PI / 6;60分钟/60秒对应一圈2 * Math.PI,所以每个间隔是Math.PI / 30。这组基础关系是后面所有指针角度计算的基石。
1.3 动画核心:为什么用requestAnimationFrame而不是setInterval
动态时钟需要不断重绘,最常见的直觉是用setInterval(draw, 1000),一秒跑一次。但实际上做动画并不推荐这么写。setInterval在执行频率上不受显示器的刷新率控制,它可能造成任务堆积,或者在页面后台时仍然继续消耗CPU。更麻烦的是,如果绘制本身耗时超过间隔,还会产生跳帧。
正确的做法是使用requestAnimationFrame,这是浏览器专门为动画设计的API。它会自动匹配显示器的刷新率,通常是60Hz,也就是大约16.7毫秒一帧。时钟这种动画其实用不到60fps,但requestAnimationFrame仍然是最佳选择,因为它具有以下优点:
- 页面切换到后台时自动暂停,不浪费资源;
- 浏览器自身进行了帧调度,动画更平滑;
- 你可以在回调里获取一个时间戳,用来做基于时间的连续运动。
所以我们的主循环结构就是定义draw函数,在函数末尾调用requestAnimationFrame(draw),形成递归绘制。每秒大概重绘60次,但每次绘制都会根据当前时间重新计算指针角度,所以无论帧率多高,指针位置始终是对的。
2. 核心细节解析与实操要点
2.1 表盘绘制:从基础圆到有质感的立体表盘
表盘是时钟的脸面,直接摆一个普通的圆,视觉上会很素。我常用的做法是“外圈深色+内圈浅色+径向渐变”,模拟光照和立体感。
画外圈先画一个半径较大的圆,填充深色;再在内部画一个稍小的圆,填充浅色或渐变。外圈在视觉上相当于表壳内壁,内圈是表盘底纸。最简单的版本:
// 假设已经 ctx.translate(centerX, centerY) ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.fillStyle = '#333'; ctx.fill(); ctx.beginPath(); ctx.arc(0, 0, radius - 10, 0, Math.PI * 2); ctx.fillStyle = '#f5f2e8'; ctx.fill();如果你想让表盘更透气、更有质感,不要用纯色,改用createRadialGradient。这个渐变可以模拟中心靠近光源、四周稍暗的效果:
const gradient = ctx.createRadialGradient(-radius * 0.3, -radius * 0.3, radius * 0.1, 0, 0, radius); gradient.addColorStop(0, '#fff'); gradient.addColorStop(0.8, '#f0ede6'); gradient.addColorStop(1, '#cdc7ba'); ctx.beginPath(); ctx.arc(0, 0, radius - 10, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();注意渐变中心点不是圆心,而是稍微偏左上方的坐标,比如(-radius * 0.3, -radius * 0.3),这样更像是光从左上角打过来。如果你想要更明显的表圈阴影,可以设置ctx.shadowBlur,不过需要小心性能,时钟重绘频率高,阴影会带来额外的绘制开销。我的经验是:静态表盘部分如果不变,可以放到离屏Canvas,后面章节再讲。
2.2 刻度和数字:用循环加三角函数搞定60个刻度
刻度是时钟最容易画歪的部分,也是最能体现功力的细节。12个大刻度,60个小刻度,如果一个个手写坐标,那代码会疯掉,而且永远对不齐。正确做法是用循环加三角函数。
先回顾时钟的基本布局:一圈2 * Math.PI,共60个格,每个格对应Math.PI / 30弧度;其中每隔5格(对应12点、1点、2点……)是大刻度,其余是小刻度。刻度都不是从圆心开始画的,而是从表盘的某个半径位置向圆心方向画一条短线,这样才能形成真实刻度感。
坐标计算公式:
x1 = 外圆半径 * cos(角度) y1 = 外圆半径 * sin(角度) x2 = (外圆半径 - 刻度长度) * cos(角度) y2 = (外圆半径 - 刻度长度) * sin(角度)但角度要从12点方向开始算,所以记得先减去Math.PI / 2。具体代码:
for (let i = 0; i < 60; i++) { const angle = i * Math.PI / 30 - Math.PI / 2; const lineLength = i % 5 === 0 ? 16 : 6; const x1 = radius * Math.cos(angle); const y1 = radius * Math.sin(angle); const x2 = (radius - lineLength) * Math.cos(angle); const y2 = (radius - lineLength) * Math.sin(angle); ctx.strokeStyle = i % 5 === 0 ? '#222' : '#555'; ctx.lineWidth = i % 5 === 0 ? 3 : 1; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); }这里lineLength决定刻度有多长,大刻度用16像素,小刻度用6像素,同时线条粗细也区分,大刻度一眼就能认出来。注意:所有刻度都是从外向内收缩,不是从内向外延伸,如果你把x1和x2搞反了,刻度会跑到表盘外面去。
数字的绘制稍微有些不同,主要问题是文本对齐。如果不设置textAlign和textBaseline,Canvas默认的文本基准是左下角,画出来的数字会偏。正确做法是:
ctx.font = `bold ${radius * 0.18}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (let i = 1; i <= 12; i++) { const angle = i * Math.PI / 6 - Math.PI / 2; const x = (radius - 30) * Math.cos(angle); const y = (radius - 30) * Math.sin(angle); ctx.fillText(i.toString(), x, y); }radius - 30是把数字放在刻度内侧,距离中心有一段距离。数字大小用radius * 0.18,可以随画布尺寸灵活缩放,而不是写死。
2.3 中心圆点:看似简单却影响整体质感
表盘、刻度、数字都画完后,中心空荡荡的,需要补一个中心圆点。这个圆点不只是装饰,还有实际作用:它能把三根指针的根部遮盖住,让指针交汇处看起来干净整洁。没有它,两根指针的根部会交错暴露,显得很毛糙。
中心点一般画两层:
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fillStyle = '#333'; ctx.fill(); ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI * 2); ctx.fillStyle = '#e74c3c'; ctx.fill();外层黑色或深灰色做底座,内层红色或亮色做点缀,这样指针根部有一个视觉焦点。要注意中心圆点的绘制时机:它应该画在指针之上,否则就被指针盖住了。所以完整的绘制顺序是:表盘 -> 刻度 -> 数字 -> 指针 -> 中心圆点。
3. 实操过程与核心环节实现
3.1 高DPI屏幕适配:不模糊的第一前提
很多新手第一次用Canvas画时钟,画完发现边缘发虚,线框到处都是毛刺。原因多半是没有处理devicePixelRatio(设备像素比)。普通屏幕的设备像素比是1,但Retina或高分屏可能是2甚至3。Canvas的width和height默认是逻辑像素,如果CSS尺寸是400px,画布实际也是400x400逻辑像素,但显示器上每个逻辑像素由多个物理像素组成,Canvas只按低分辨率渲染,就会被拉伸,从而模糊。
解决方案是让Canvas的物理像素尺寸等于CSS尺寸 * devicePixelRatio,然后用ctx.scale(dpr, dpr)把逻辑坐标系还原成CSS尺寸。这样画圆、画线时用的是逻辑尺寸,但底层渲染到足够多的物理像素,线条自然锐利。
const canvas = document.getElementById('clock'); const size = 400; const dpr = window.devicePixelRatio || 1; canvas.width = size * dpr; canvas.height = size * dpr; canvas.style.width = `${size}px`; canvas.style.height = `${size}px`; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);注意:设置ctx.scale之后,后面所有坐标都按size = 400的逻辑尺寸计算,不要直接使用canvas.width,否则会画到画布外的区域。这个方法几乎适用于所有Canvas项目,建议直接养成习惯。
3.2 获取当前时间并根据时间计算指针角度
要画出正确的时间,第一步是用Date对象获取当前时、分、秒、毫秒:
const now = new Date(); const hours = now.getHours() % 12; const minutes = now.getMinutes(); const seconds = now.getSeconds(); const milliseconds = now.getMilliseconds();然后就是核心角度计算。这里有两种常见风格:
- 跳秒:秒针在每秒整数位置跳动,角度是
seconds * 6度; - 扫秒:秒针随着毫秒连续移动,角度是
(seconds + milliseconds / 1000) * 6度。
我个人更推荐扫秒,因为它看起来更接近机械表,视觉上更顺滑。相应的分针和时针也应该带上下级单位,否则分针在某一分钟内纹丝不动,到下一分钟才“咔”地跳一下,很不自然。
计算公式如下:
// 秒针:每60秒转一圈,每秒 6 度(π/30 弧度) const secondAngle = (seconds + milliseconds / 1000) * Math.PI / 30; // 分针:每60分钟转一圈,每分钟 6 度,并叠加秒数带来的微小偏移 const minuteAngle = (minutes + seconds / 60) * Math.PI / 30; // 时针:每12小时转一圈,每小时30度(π/6),并叠加分钟带来的偏移 const hourAngle = (hours + minutes / 60) * Math.PI / 6;这三个角度全部是弧度制。绘制指针时统一处理偏移:把指针默认指向12点方向,所以先对角度减去Math.PI / 2。
3.3 绘制指针形状:不是一根线,而是多边形状
很多教程里的指针就是moveTo+lineTo画一根细线,说实话太简陋。想让指针有质感,应该用多边形路径去画。例如时针和分针可以近似一个修长的等腰三角形,尾部再带一点小尾巴,这样指针看起来是立体部件,而不是线条。
先写一个通用函数:
function drawHand(ctx, angle, length, width, color, tailLength = 12) { ctx.save(); ctx.rotate(angle - Math.PI / 2); ctx.beginPath(); ctx.moveTo(0, tailLength); ctx.lineTo(-width / 2, -length + 10); ctx.lineTo(0, -length); ctx.lineTo(width / 2, -length + 10); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); ctx.restore(); }解释一下形状:默认指针在未旋转时指向12点方向,也就是y轴负方向。(0, tailLength)是尾部靠近圆心的小尾巴;(-width / 2, -length + 10)和(width / 2, -length + 10)分别是针尾两侧的坐标;(0, -length)是针尖。tailLength控制指针尾部露出圆点的长度,这样指针根部不会太秃。
到了调用环节,直接用算好的角度:
drawHand(ctx, secondAngle, radius * 0.85, 2, '#c0392b'); drawHand(ctx, minuteAngle, radius * 0.7, 6, '#333'); drawHand(ctx, hourAngle, radius * 0.45, 10, '#333');秒针我用亮红色,视觉上更容易捕捉;时针和分针用深色。长度比例要合理,比如时针大概是半径的45%,分针70%,秒针85%,这样三根指针交错时不会打架。
3.4 动画循环与完整代码整合
把上面所有片段拼起来,就是完整的动态时钟。这里给出一个可以直接复制运行的完整HTML文件,代码里包含了高DPI适配、表盘、刻度、数字、指针和动画循环。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Canvas动态时钟</title> <style> body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #1d1f27; } </style> </head> <body> <canvas id="clock"></canvas> <script> const canvas = document.getElementById('clock'); const size = 400; const dpr = window.devicePixelRatio || 1; canvas.width = size * dpr; canvas.height = size * dpr; canvas.style.width = `${size}px`; canvas.style.height = `${size}px`; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); const R = 180; function drawDial() { // 外圈 ctx.beginPath(); ctx.arc(0, 0, R + 10, 0, Math.PI * 2); ctx.fillStyle = '#3a3d42'; ctx.fill(); // 表盘底 const dialGradient = ctx.createRadialGradient(-R * 0.3, -R * 0.3, R * 0.1, 0, 0, R); dialGradient.addColorStop(0, '#fff'); dialGradient.addColorStop(0.85, '#f0ede6'); dialGradient.addColorStop(1, '#cdc7ba'); ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.fillStyle = dialGradient; ctx.fill(); } function drawScales() { // 刻度 for (let i = 0; i < 60; i++) { const angle = i * Math.PI / 30 - Math.PI / 2; const lineLength = i % 5 === 0 ? 16 : 6; const x1 = R * Math.cos(angle); const y1 = R * Math.sin(angle); const x2 = (R - lineLength) * Math.cos(angle); const y2 = (R - lineLength) * Math.sin(angle); ctx.strokeStyle = i % 5 === 0 ? '#222' : '#555'; ctx.lineWidth = i % 5 === 0 ? 3 : 1; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } // 数字 ctx.font = `bold ${R * 0.18}px 'Arial', sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#222'; for (let i = 1; i <= 12; i++) { const angle = i * Math.PI / 6 - Math.PI / 2; const x = (R - 30) * Math.cos(angle); const y = (R - 30) * Math.sin(angle); ctx.fillText(i.toString(), x, y); } } function drawHand(angle, length, width, color, tailLength = 12) { ctx.save(); ctx.rotate(angle - Math.PI / 2); ctx.beginPath(); ctx.moveTo(0, tailLength); ctx.lineTo(-width / 2, -length + 10); ctx.lineTo(0, -length); ctx.lineTo(width / 2, -length + 10); ctx.closePath(); ctx.fillStyle = color; ctx.fill(); ctx.restore(); } function drawCenter() { ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fillStyle = '#333'; ctx.fill(); ctx.beginPath(); ctx.arc(0, 0, 4, 0, Math.PI * 2); ctx.fillStyle = '#c0392b'; ctx.fill(); } function draw() { const now = new Date(); const hours = now.getHours() % 12; const minutes = now.getMinutes(); const seconds = now.getSeconds(); const milliseconds = now.getMilliseconds(); const secondAngle = (seconds + milliseconds / 1000) * Math.PI / 30; const minuteAngle = (minutes + seconds / 60) * Math.PI / 30; const hourAngle = (hours + minutes / 60) * Math.PI / 6; ctx.clearRect(-size / 2, -size / 2, size, size); ctx.save(); ctx.translate(size / 2, size / 2); drawDial(); drawScales(); drawHand(secondAngle, R * 0.85, 2, '#c0392b', 16); drawHand(minuteAngle, R * 0.7, 6, '#333', 14); drawHand(hourAngle, R * 0.45, 10, '#333', 12); drawCenter(); ctx.restore(); requestAnimationFrame(draw); } requestAnimationFrame(draw); </script> </body> </html>注意这里用了ctx.clearRect(-size / 2, -size / 2, size, size),因为我们已经把原点平移到了画布中心,所以清屏区域要从-size/2开始,否则清不干净,会留下残影。
4. 常见问题排查与性能优化
4.1 画布模糊、线条发虚怎么解决
前面已经讲过devicePixelRatio的适配,但实际项目里还有一个容易被忽略的坑:canvas.style.width和canvas.width必须都设置正确。如果只设置CSS宽高而不设置canvas本身的width和height,Canvas内部分辨率会默认是300x150(历史默认值),大概率模糊。
排查思路:
- 检查
canvas.width和canvas.height是否等于CSS尺寸乘以dpr; - 检查
ctx.scale(dpr, dpr)是否在绘制前执行; - 如果使用了CSS的
transform: scale()缩放画布,Canvas渲染也会发虚,尽量避免。
如果你在做响应式页面,还需要监听window.resize事件,在回调里重新设置canvas尺寸并重绘,否则窗口放大后画布会变形。
4.2 动画闪烁或重影
动画出现残影或闪烁,最常见原因是清屏不彻底。在translate了的状态下,直接写ctx.clearRect(0, 0, canvas.width, canvas.height)是清不掉中心区域的,因为坐标原点已经不在左上角。正确写法是:
ctx.clearRect(-size / 2, -size / 2, size, size);或者更稳妥的办法,在save/restore之前先清屏:
ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);这样无论之前有没有translate或者scale,清屏都是按物理像素矩形来清的,不会有遗漏。很多闪烁问题其实就是这块没有处理好。
4.3 秒针“跳秒”和“扫秒”的取舍
秒针有两种风格,在代码层面就是角度计算时是否引入毫秒。跳秒适合模仿石英表,视觉上干净利落;扫秒适合模仿机械表,连续顺滑。推荐默认用扫秒,因为用户看到指针一直在走,会明显感觉“这是活的”。
这里有一个小细节:如果用跳秒,requestAnimationFrame每帧都在重绘,但秒针角度没有变化,其实是浪费性能;如果用扫秒,每帧角度都在变,重绘就有意义。如果你要最大程度省性能,可以检测角度变化,没变化时跳过重绘,但实际测算下来,一个时钟的绘制量非常小,完全不必为了省这点性能增加复杂度。
4.4 性能优化:两层Canvas方案
完整时钟每帧都在重绘表盘、刻度、数字、指针,实际上表盘、刻度、数字是恒定不变的静态内容。把它们放在主Canvas里每帧重绘,其实是白白消耗CPU。改进方案是使用离屏Canvas(OffscreenCanvas或一个隐藏的canvas元素),先把静态表盘画上去,然后在动画循环里用drawImage把离屏画布作为背景贴上去,再在上面画指针。
代码大致是这样:
const staticCanvas = document.createElement('canvas'); staticCanvas.width = canvas.width; staticCanvas.height = canvas.height; const staticCtx = staticCanvas.getContext('2d'); staticCtx.scale(dpr, dpr); staticCtx.translate(size / 2, size / 2); drawDial(); drawScales();然后在draw函数里:
ctx.clearRect(-size / 2, -size / 2, size, size); ctx.drawImage(staticCanvas, -size / 2, -size / 2, size, size);注意这里staticCanvas的尺寸要和主canvas一致,绘制时也要考虑到dpr缩放。这样静态部分只绘制一次,后面的每帧只画三根指针和中心点,性能提升非常明显。如果你的时钟还打算叠加深色主题、背景纹理或品牌元素,这种分层思路是标准做法。
4.5 页面切换导致的时间误差
由于requestAnimationFrame在页面隐藏时会自动暂停,恢复后时间会从当前时间继续计算,所以不会出现误差。但如果页面在后台停留很久,恢复的瞬间,时间会直接跳到当前正确时间,这是一件好事。唯一需要注意的是,如果你用setInterval并且没有在后台清理,那页面切回来后可能出现积压事件,导致时钟一下子快进好几秒,这也是我坚持用requestAnimationFrame的原因之一。
4.6 从离屏Canvas导出白图的坑
如果你在做项目时把时钟最终渲染成图片导出,比如用canvas.toDataURL(),在iOS Safari的某些版本下,可能出现导出白图。最常见的原因是离屏Canvas已经绘制成功但主Canvas还未完成一帧渲染,或者离屏Canvas的width或height是0。解决办法是确保在导出前,静态和动态所有绘制都已经同步执行完,且离屏Canvas尺寸明确。另一个技巧是直接在导出前强制调用一次完整的draw(),确保canvas上有内容。
写在最后的个人经验
画动态时钟这个项目,我前后帮别人调过很多次,最大的感触是:一定要把小模块拆开,先画静态表盘,再单独测指针,最后接动画。如果你把所有代码一股脑写在一起,一旦角度算错或者绘制顺序不对,报错信息大概率指向不明确,你得一行行去猜。尤其是指针角度的“减90度”问题,几乎每个人一开始都会栽一遍。我的调试技巧是:先用console.log把秒针角度打印出来,直观看到数值是不是在0到2π之间稳定递增;再把指针长度调长一点,方便肉眼定位旋转的基准方向。这样很快就能分辨是坐标基准问题还是计算逻辑问题。
时钟做完之后,你还可以继续扩展,比如加上闹钟功能、换成24小时制、或者把表盘做成一个炫酷的仪表盘。Canvas的很多高级能力——渐变、阴影、离屏渲染、路径计算——都能在这么一个小项目里得到实战体验,比看一百篇教程都有用。希望这份手把手的流程,能让你少踩几个我当年踩过的坑。