ARTICLE DETAIL

资讯详情

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

Canvas实战:手把手教你绘制高DPI动态时钟

Canvas实战:手把手教你绘制高DPI动态时钟 最近总有人问我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 / 660分钟/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和textBaselineCanvas默认的文本基准是左下角画出来的数字会偏。正确做法是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 绘制指针形状不是一根线而是多边形状很多教程里的指针就是moveTolineTo画一根细线说实话太简陋。想让指针有质感应该用多边形路径去画。例如时针和分针可以近似一个修长的等腰三角形尾部再带一点小尾巴这样指针看起来是立体部件而不是线条。先写一个通用函数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 langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleCanvas动态时钟/title style body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #1d1f27; } /style /head body canvas idclock/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和heightCanvas内部分辨率会默认是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。改进方案是使用离屏CanvasOffscreenCanvas或一个隐藏的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的很多高级能力——渐变、阴影、离屏渲染、路径计算——都能在这么一个小项目里得到实战体验比看一百篇教程都有用。希望这份手把手的流程能让你少踩几个我当年踩过的坑。
返回列表