Canvas绘图样式实战:从画布坐标到渐变阴影的完整指南

发布时间:2026/10/7 13:17:48
Canvas绘图样式实战:从画布坐标到渐变阴影的完整指南 很多人学 Canvas都是从一句ctx.fillRect(0, 0, 100, 100)开始的。在页面上画出一个黑方块之后就觉得自己会了。但真正做数据可视化、做 H5 互动页、做小游戏的时候会发现 Canvas 的绘图样式才是决定作品能不能看的关键同样一条线有人画出来是干净的 1 像素有人画出来就是灰蒙蒙的一团同样一个圆有人能做出立体感有人只能平涂。HTML 中的canvas元素只是提供了一个画板Javascript 里的CanvasRenderingContext2D才是那个真正握笔的手而绘图样式就是这支笔的笔锋、墨水和技法。这篇文章不打算从 Canvas 的 API 文档逐条抄起而是按我实际项目里最常用的几个维度把绘图样式这块一次性说透坐标系和画布尺寸怎么才不会踩坑、线条样式有哪些隐藏细节、渐变和图案填充的正确打开方式、阴影和合成模式到底怎么用、文字在 Canvas 里为什么总是对不齐以及怎么用状态管理和变换把复杂图形画得清清楚楚。适合刚接触 Canvas 的前端新手也适合写过一些 Demo 但始终觉得画出来的东西不够精致的开发者。1. 画布与坐标系绘图样式生效前要搞清的地基问题很多人画出来的东西模糊、变形、位置不对根源根本不在样式本身而是画布尺寸和坐标系没搞明白。样式是画在画布上的画布出了问题后面全都是白搭。1.1 width/height 属性和 CSS 尺寸是两套系统canvas元素有两个最容易混淆的尺寸概念。一个是 HTML 属性里的width和height它决定的是画布的实际像素缓冲区大小另一个是 CSS 里设置的width和height它决定的只是画布在页面上被拉伸显示出来的尺寸。canvas iddemo width800 height600 stylewidth: 400px; height: 300px;/canvas上面这段代码画布的像素缓冲区是 800x600但页面上只显示 400x300。浏览器会把 800x600 的位图硬压缩到 400x300 的盒子里面结果是所有内容整体缩小了一半。如果你反过来只设置 CSS 尺寸而不设置width/height属性画布默认的缓冲区只有 300x150页面却把它拉大显示那出来的效果就是严重模糊。我见过不少设计稿给的是 750x1334 的二倍图尺寸开发者直接把这套尺寸塞进width和height然后在真机上发现整个 Canvas 界面被缩小了一半。原因就是没有意识到 CSS 尺寸和缓冲区尺寸的差异。正确做法是CSS 尺寸负责布局HTML 属性负责像素密度两者通过 devicePixelRatio 关联。function setupCanvas(canvas, width, height) { const dpr window.devicePixelRatio || 1; canvas.width Math.round(width * dpr); canvas.height Math.round(height * dpr); canvas.style.width width px; canvas.style.height height px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }这段代码做完之后所有绘制操作都可以直接用 CSS 像素的坐标来写1 物理像素的线就是 1 逻辑像素乘以 dpr高分屏上依然锐利。还有一个细节调整 canvas 的 width 或 height 属性会清空画布同时把所有样式状态重置回默认值。所以如果需要响应式重绘必须在每次重新赋值之后完整地重新执行一遍绘制函数不能只改尺寸不重画。1.2 坐标系是一切绘图样式的参照系Canvas 的坐标系和数学中常见的坐标系不一样原点在左上角x 轴正方向向右y 轴正方向向下。这是个很小的知识点却影响着几乎所有绘图样式的判断。举个例子你想画一个从上到下的渐变那么渐变起点应该在图形上方终点在下方。但如果你的思维还停留在数学坐标系里 y 向上为正就很容易把起止点写反出来的渐变方向完全不对。再比如给文字加阴影shadowOffsetY设成正数阴影会出现在文字的下方这正是坐标系 y 向下的直接结果。还有旋转rotate(0.5)是顺时针转 0.5 弧度和数学课上学的逆时针为正正好相反。这些细节单独拎出来都很小但组合在一起就决定了你写出来的代码是不是一次就能画对。我个人的习惯是在动手写任何复杂图形之前先在纸上画出坐标系和要画的图形标好关键坐标点再开始写代码这样可以省掉大量调试时间。1.3 2D 上下文的 alpha 参数一个容易被忽略的性能开关获取绘图上下文的写法大多数人是这样的const ctx canvas.getContext(2d);其实getContext还接受一个配置对象其中alpha选项很实用const ctx canvas.getContext(2d, { alpha: false });当你的画布是纯色背景、不需要任何透明度信息的时候把alpha设为false浏览器内部就不需要维护一块额外的透明通道合成速度会更快。这是很多图表库都在用的优化手段。需要特别注意的是alpha: false的画布初始背景是纯黑色不是透明所以拿到之后要么先把背景色铺满整个画布要么心里要有数。这个选项和绘图样式的关系在于如果你要做的是粒子、光效、柔和阴影这类大量依赖透明度的效果就不要用alpha: false否则很多半透明叠加效果出不来。2. 线条造型strokeStyle、线宽、端点与虚线的完整控制线条是 Canvas 里最基础也是问题最多的一类样式。很多人画出来的线发虚、有毛边、端点形态不对基本都是对线条样式参数的理解不到位。2.1 strokeStyle 的六种颜色写法strokeStyle控制线条颜色同时也接受填充一样的颜色值。常见的写法有这些写法示例适用场景十六进制#0af或#00aaff日常使用最多简洁直观rgb / rgbargb(255, 0, 0)、rgba(255, 0, 0, 0.5)需要同时控制颜色的场景hsl / hslahsl(200, 80%, 50%)、hsla(200, 80%, 50%, 0.3)需要调整色相、饱和度的时候颜色关键字red、blue快速调试验证transparenttransparent隐藏线条但保留路径计算渐变或图案对象CanvasGradient、CanvasPattern制作渐变线条、纹理描边这里有个容易让人困惑的点Canvas 遇到非法的颜色值不会报错而是直接忽略。你写了一个拼写错误的颜色名浏览器既不提示也不抛异常线条继续保持上一次的颜色。这种静默失败在调试时非常恶心所以一旦发现线条颜色没变化第一件事就是检查字符串是不是合法颜色值。2.2 lineWidth 与 1px 线条模糊的真相lineWidth默认是 1但很多人第一次用 Canvas 画 1px 直线的时候发现画出来的线是灰的不是纯黑。这不是设备问题而是 Canvas 的路径坐标和像素网格对齐的问题。Canvas 的路径是理论上无限细的几何线描边的时候以这条几何线为中心向两侧各延伸半个线宽。当你用整数坐标比如moveTo(50, 10)画一条 1px 直线时几何线正好落在两个像素之间两侧各有 0.5px 的覆盖率抗锯齿算法就会给它折中成一个半透明灰看起来就是灰蒙蒙的。解决方案很朴素画 1px 直线时把坐标偏移半个像素。ctx.lineWidth 1; ctx.beginPath(); ctx.moveTo(50.5, 10); ctx.lineTo(350.5, 10); ctx.stroke();注意这个 0.5 的偏移量是在没有做过高分屏适配的前提下说的。如果你用了前面提到的setupCanvas已经执行过ctx.scale(dpr, dpr)那么逻辑坐标里的 0.5 在屏幕上对应的是0.5 * dpr个物理像素。当 dpr 是 2 时就需要偏移 0.25 而不是 0.5。所以更稳妥的说法是想让 1px 线条锐利就要让几何线落在物理像素网格的中央。当线宽大于等于 2 时这种半像素偏移带来的模糊基本看不出来不用太纠结。2.3 lineCap 和 lineJoin线条端点和拐角的形态控制lineCap控制线条端点的形状有三个值butt、round、square。butt是默认值端点平齐不向外延伸round让端点变成一个半径为线宽一半的半圆多出半个线宽的长度square让端点变成一个方形的帽子同样多出半个线宽的长度。lineJoin控制两条线相交时拐角的形态也有三个值miter、round、bevel。miter是默认值拐角保持尖锐的角round把拐角做成圆角过渡bevel把尖角切平。miter模式还有个隐藏参数miterLimit默认是 10。它表示尖角最长长度和线宽的比值上限。当两条线的夹角非常小、形成的尖角特别长时一旦超过miterLimit浏览器会把尖角截断成bevel的效果。画五角星、闪电这类尖锐图形时如果发现尖角被切平了不是代码写错了而是miterLimit不够大把它调大就能恢复尖锐的角。2.4 虚线模式与流动动画Canvas 画虚线主要靠两个 APIsetLineDash和lineDashOffset。ctx.setLineDash([12, 6]); // 12px 实线6px 空隙 ctx.lineDashOffset -30; // 虚线整体向左偏移 30pxsetLineDash接受一个数组数组元素按顺序表示实线段和空线段的长短可以写多个值形成复杂的重复模式。lineDashOffset表示虚线模式从哪里开始偏移负数表示向路径起点方向偏移。只要在requestAnimationFrame里不断改变lineDashOffset就能做出虚线向前流动的效果。这个技巧在选框动画、路径轨迹动画、以及类似蚂蚁线的交互提示里非常常用。一个容易踩的坑是setLineDash设置的虚线模式会被之后的绘制继承。如果你在某个地方设置了虚线画完之后忘了还原成实线后面所有线条都会变成虚线。要么在画完虚线后手动调用ctx.setLineDash([])清空要么用后面会讲到的save/restore把它包起来。3. 填充的艺术渐变、图案与透明度叠加的样式组合填充样式不只是fillStyle red这么简单。真正让图形有层次感的是渐变、图案和透明度的组合运用。3.1 线性渐变起止点决定方向createLinearGradient(x0, y0, x1, y1)创建线性渐变起点(x0, y0)到终点(x1, y1)决定渐变的方向。创建之后通过addColorStop(offset, color)添加颜色停止点。const gradient ctx.createLinearGradient(0, 0, 200, 0); gradient.addColorStop(0, #22d3ee); gradient.addColorStop(0.5, #818cf8); gradient.addColorStop(1, #a78bfa); ctx.fillStyle gradient; ctx.fillRect(0, 0, 200, 200);几个细节要注意addColorStop的offset范围是 0 到 1超出这个范围会直接抛IndexSizeError渐变对象的坐标是相对于当前坐标系含 transform的。如果你先执行了ctx.translate(100, 50)再创建渐变那么渐变起止点的坐标也会跟着平移这是个很有用的特性颜色停止点之间是线性插值想做出更复杂的效果就多加几个停止点渐变的范围不受填充图形的边界限制。一个 200px 宽的渐变填充到 100px 宽的矩形里矩形只显示渐变的一部分这是正常行为。3.2 径向渐变从内圆到外圆的扩散createRadialGradient(x0, y0, r0, x1, y1, r1)创建径向渐变从内圆(x0, y0, r0)向外圆(x1, y1, r1)扩散。两个圆心可以不同这种偏心设计能模拟出侧光照明的效果。最典型的用法是制作高光。比如一个按钮上的光泽感const glow ctx.createRadialGradient(100, 80, 3, 100, 100, 60); glow.addColorStop(0, rgba(255, 255, 255, 0.9)); glow.addColorStop(0.6, rgba(255, 255, 255, 0.15)); glow.addColorStop(1, rgba(255, 255, 255, 0)); ctx.fillStyle glow; ctx.fillRect(0, 0, 200, 200);这里的思路是内圆半径很小、颜色很白很实外圆半径很大、颜色完全透明中间做过渡出来的就是一个自然的圆形光晕。很多看起来挺精致的小组件其实就是这种高光叠加做出来的成本非常低。3.3 锥形渐变与兼容性处理createConicGradient(startAngle, x, y)以一个点为中心围绕这个点做角度渐变。startAngle是起始角度单位是弧度0 表示从 x 轴正方向开始按顺时针方向扩展。锥形渐变非常适合画色轮、雷达图、仪表盘指针区域的过渡。不过它的兼容性不如线性渐变和径向渐变目前主流浏览器的新版本基本都支持但如果你需要兼容比较老的浏览器最好加一个降级检测ctx.createConicGradient是否存在不存在就退回线性渐变或纯色填充。3.4 图案填充把图片变成 Canvas 的填充样式createPattern(image, repetition)创建图案填充。image可以是一个Image对象、另一个canvas元素甚至是视频帧。repetition支持repeat、repeat-x、repeat-y、no-repeat。这里最大的坑是图片加载时机。用new Image()创建图片对象后如果 src 还没加载完成就调用createPattern得到的fillStyle是无效的填充出来什么都没有而且不报错。const img new Image(); img.src texture.png; img.onload () { const pattern ctx.createPattern(img, repeat); ctx.fillStyle pattern; ctx.fillRect(0, 0, 400, 300); };另一个细节是图案的贴图原点。createPattern创建的图案默认从画布的原点开始拼接如果你想让图案在某个区域内偏移可以配合ctx.translate使用。比如给表单背景加防伪底纹、给地图加纹理底图、给卡片加噪点质感都是通过createPattern实现的。理论上createPattern也能接受 SVG 图片但 SVG 的尺寸和渲染范围在不同浏览器里表现差异很大不如直接用 PNG 稳妥。3.5 globalAlpha 和 rgba 的叠加差异globalAlpha是全局透明度默认是 1。它作用于之后所有的绘制操作对填充和描边同时生效但它只改透明程度不改变样式值本身。最容易让人困惑的是用半透明颜色画了两个重叠的矩形重叠区域为什么颜色更深这是正常现象因为每次绘制都是把新颜色叠加到已有像素上重叠区域相当于叠了两层半透明色浓度自然变大。这不是 bug是 Canvas 的工作方式。如果你希望多次叠加同一个图形时颜色不加深就不能直接在画布上画。正确做法是先把图形画到一个离屏的 canvas 上再一次性把这个离屏 canvas 绘制到主画布。这个技巧在粒子系统、刮刮乐、涂抹类交互里非常关键提前把思路记住后面遇到就不会懵。4. 阴影、合成与滤镜图形层次与像素级效果的控制阴影和合成模式是很多初学者设置了一堆属性但看不出效果的重灾区。这一部分把它们的脾气摸清楚。4.1 shadow 系列参数的行为细节Canvas 阴影由四个属性控制shadowColor、shadowBlur、shadowOffsetX、shadowOffsetY。shadowColor默认是rgba(0, 0, 0, 0)也就是透明的黑色shadowBlur默认是 0不模糊shadowOffsetX和shadowOffsetY默认也是 0表示阴影相对图形的偏移量。这里有个很容易踩的坑很多人设置了shadowColor却忘了设置shadowBlur期望看到一个柔和的阴影结果看到的是图形旁边多了一个硬边副本。因为阴影就是一个图形影子shadowBlur为 0 时影子不模糊只有偏移。关闭阴影的正确姿势是把四个属性全归零尤其注意shadowColor要设为透明ctx.shadowColor rgba(0, 0, 0, 0); ctx.shadowBlur 0; ctx.shadowOffsetX 0; ctx.shadowOffsetY 0;如果只是把shadowBlur设为 0 但shadowColor还是半透明的深色浏览器依然会在每次绘制时执行阴影计算白白增加性能开销。还有一点阴影默认是所有后续绘制操作都生效画完一个带阴影的图形后一定要关闭否则下一个图形也会莫名其妙带着影子。4.2 globalCompositeOperationsource 和 destination 的叠加逻辑globalCompositeOperation是 Canvas 里最强大也最难记的一组样式属性。其实只要抓住两个关键词就简单了source 是你正在绘制的新图形destination 是画布上已有的旧内容。这个属性决定的是新图形和旧内容怎么合成。属性值效果source-over默认新图形盖在旧内容上面source-in新图形只显示在与旧内容重叠的区域source-out新图形只显示在不与旧内容重叠的区域source-atop新图形只画在旧内容重叠处其余保持旧内容destination-over新图形画到旧内容下面destination-in旧内容只保留与新图形重叠的部分destination-out旧内容与新图形重叠的部分被擦除新图形不显示destination-atop旧内容中与新图形重叠的部分覆盖在旧内容上面lighter新图形颜色与旧内容颜色相加叠加变亮copy新图形整体替换旧内容xor重叠区域变透明类似异或实际项目里我最常用的是lighter、destination-out和destination-over。lighter做光效叠加。多个半透明粒子叠在一起会越来越亮很适合做火焰、灯光、星空这类效果destination-out当橡皮擦用。在旧内容上画什么什么就被擦掉可以做成刮奖效果destination-over把新图形画到旧内容下面适合做底板、水印这样的层叠逻辑。4.3 filter 属性把 CSS 滤镜搬进 CanvasCanvas 2D 的ctx.filter属性可以直接使用 CSS 滤镜效果比如blur、brightness、grayscale、saturate等。ctx.filter blur(4px) brightness(1.1); // 之后绘制的所有内容都会带滤镜 ctx.fillStyle #ff6b6b; ctx.fillRect(20, 20, 100, 100);用filter: blur()可以快速实现模糊底边、毛玻璃背景不用再手动去做先画到离屏再模糊的处理。但有两点要注意第一filter和shadow同时使用时滤镜会作用于阴影的计算结果开销会明显变大移动端要谨慎第二每次修改filter都有内部重编译成本不要在循环里频繁切换滤镜尽量把需要同一滤镜的内容集中画完再切换。5. 文字样式Canvas 中字体渲染与对齐的实战细节Canvas 里的文字样式看似简单实际问题非常多。最常见的就是字体设置无效、文字对不齐、文字位置总是偏上偏下。这些问题的根源都在font语法和基线机制上。5.1 font 属性的正确格式与默认值ctx.font是一个字符串格式类似 CSS 的font简写但顺序和语法有严格限制ctx.font italic bold 16px/1.5 Microsoft YaHei, sans-serif;完整的顺序是font-style、font-variant、font-weight、font-size/line-height、font-family。这里有三个关键点font-size必须指定而且必须有单位通常是px。如果没写字号或没写单位整个font声明会被浏览器静默忽略文字保持默认的10px sans-serif。很多新手发现我明明设置了 32px 的文字为什么画出来还是那么小就是这个原因line-height对fillText和strokeText基本不生效因为这两个方法只绘制单行文本不会自动换行行高也就没有用武之地字体族最好带备选字体不要只写一种在用户电脑上可能不存在的字体。Canvas 和普通网页一样用不了系统没有的字体。如果你需要加载网页自定义字体先让字体通过FontFace或 CSSfont-face加载完成再设置ctx.font才会生效否则会用默认字体绘制。5.2 textAlign 与 textBaseline 的组合用法textAlign控制文字在水平方向上相对锚点的位置取值有left、right、center、start、end。textBaseline控制文字在垂直方向上相对锚点的位置取值有top、hanging、middle、alphabetic、ideographic、bottom。最常用的组合是textAlign center配合textBaseline middle这样锚点就是文字的几何中心画居中文本很方便。但注意middle基线只是基于字体度量的一个近似中间值字体在视觉上往往并不严格居中。如果你想实现绝对精确的垂直居中就需要用measureText拿字体实际渲染的边界const metrics ctx.measureText(Hello Canvas); const textHeight metrics.actualBoundingBoxAscent metrics.actualBoundingBoxDescent; const centerY 100; const baselineY centerY - metrics.actualBoundingBoxDescent textHeight / 2; ctx.fillText(Hello Canvas, 200, baselineY);actualBoundingBoxAscent和actualBoundingBoxDescent表示字体的实际顶部和底部离基线的距离用它们来算垂直位置比单纯用textBaseline middle更精准。尤其是画仪表盘数值、给圆环配中心文字这类对位置要求高的场景这个小技巧很值钱。5.3 描边文字与文字测量Canvas 绘制文字有两个方法fillText用填充样式画实心字strokeText用描边样式画空心字。组合使用时可以实现描边字效果ctx.font bold 48px sans-serif; ctx.lineWidth 4; ctx.strokeStyle #222; ctx.strokeText(标题, 100, 100); ctx.fillStyle #fff; ctx.fillText(标题, 100, 100);先画描边再画填充文字边缘就有一圈深色描边内部是白色。这个效果在海报、封面、地图标注里很常见。measureText还可以用于自适应字号。比如固定一个容器宽度文字太长就缩小字号直到measureText(text).width小于容器宽度。这种文字自动收缩的交互在图表标题、标签场景中是刚需。6. 状态与变换用 save/restore 保底用矩阵变换做镜像和旋转复杂图形几乎离不开变换。但变换的逻辑和 CSS 不一样它是修改坐标系而不是移动元素。用好状态管理和变换绘制复杂图形的代码能简洁几个量级。6.1 save/restore 的栈结构与覆盖范围sava的全称是save()它把当前所有绘图状态压入一个状态栈。这些状态包括fillStyle、strokeStyle、lineWidth、lineCap、lineJoin、miterLimit、font、textAlign、textBaseline、shadowColor、shadowBlur、shadowOffsetX、shadowOffsetY、globalAlpha、globalCompositeOperation、当前的变换矩阵以及裁剪区域。也就是说clip裁剪区域也会被保存和恢复。这是 Canvas 绘制复杂图形最核心的工程化手段。状态栈是后进先出的可以嵌套。典型用法ctx.save(); ctx.translate(100, 100); ctx.rotate(0.3); ctx.setLineDash([6, 3]); // 在这一段里绘制的任何东西都处于平移 旋转 虚线的状态 ctx.strokeRect(0, 0, 80, 80); ctx.restore(); // 到这里前面设置的变换和虚线全部失效回到 save 之前的状态很多人写复杂图形时不注意成对出现的save和restore满屏都是ctx.translate、ctx.fillStyle画着画着发现图形歪了、颜色串了、虚线到处都是然后花大量时间去追查是哪一行的哪个样式没还原。用save/restore作为每个独立绘制块的保护罩这类混乱能消灭一大半。6.2 translate/rotate/scale绘制复杂图形的坐标系思路translate(x, y)把坐标系原点移动到指定位置rotate(radian)绕当前原点旋转scale(sx, sy)缩放坐标系。它们都作用于当前坐标系所以顺序很重要。画仪表盘刻度就是一个特别经典的场景。传统做法是用三角函数算出每个刻度的坐标再连线代码又长又容易错。用变换的话只要把原点移到圆心然后旋转坐标系在水平方向画一条刻度线就行了for (let i 0; i 12; i) { ctx.save(); ctx.translate(cx, cy); ctx.rotate(i * Math.PI / 6); ctx.strokeStyle #88a; ctx.lineWidth i % 3 0 ? 4 : 2; ctx.beginPath(); ctx.moveTo(radius - 16, 0); ctx.lineTo(radius - 5, 0); ctx.stroke(); ctx.restore(); }这段代码画出来的就是一个时钟或仪表盘的 12 个刻度。每个刻度都在原点在圆心、当前是第 i 个角度的坐标系里画一条水平短线然后 restore 回原位。用这种思路写旋转对称的图形不仅代码短而且不容易错。旋转角度记得用弧度radian degree * Math.PI / 180。6.3 变换矩阵对线宽和样式的影响scale(2, 2)之后不只是图形位置放大了线宽、阴影偏移、渐变范围也会跟着缩放。这是 Canvas 变换和 CSS 缩放最大的区别。假设你ctx.scale(2, 2)后在逻辑坐标画了一条线宽为 1 的线屏幕上实际是 2px 宽。想保持视觉上的 1px需要手动反算线宽const scaleX ctx.getTransform().a; ctx.lineWidth 1 / scaleX;getTransform()返回当前变换矩阵a是 x 方向缩放系数。在制作地图缩放、画布缩放这类交互时这个细节非常关键否则缩放着缩放着所有线条都变成粗线条了。如果你不希望变换影响后续的样式最简单的办法就是在变换之前先save()画完立刻restore()。凡是变换状态都要养成这个习惯。7. 综合实践一张仪表盘卡片从零到一的完整 Draw 代码前面讲了很多分散的样式点这一节用一个完整的仪表盘卡片把关键的知识串起来。这个卡片包含渐变背景、带阴影的外圈、渐变进度弧、旋转生成的刻度线、带阴影的中心数值文字、以及半透明高光。它几乎覆盖了这篇博文讲到的所有绘图样式。7.1 需求拆解画布尺寸使用setupCanvas做高分屏适配。绘制顺序是背景填充 - 外圈圆环阴影 - 进度弧渐变 - 刻度线旋转绘制 - 数值文字 - 高光合成。每一块都用save/restore隔离开避免样式串扰。7.2 完整代码实现function setupCanvas(canvas, width, height) { const dpr window.devicePixelRatio || 1; canvas.width Math.round(width * dpr); canvas.height Math.round(height * dpr); canvas.style.width width px; canvas.style.height height px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; } function drawGauge(canvas, value) { const W 400; const H 300; const ctx setupCanvas(canvas, W, H); const cx W / 2; const cy H / 2; const radius Math.min(W, H) / 2 - 40; // 背景渐变 const bg ctx.createLinearGradient(0, 0, 0, H); bg.addColorStop(0, #1e2733); bg.addColorStop(1, #0e1319); ctx.fillStyle bg; ctx.fillRect(0, 0, W, H); // 外圈圆环带阴影的深色环 ctx.save(); ctx.shadowColor rgba(34, 211, 238, 0.35); ctx.shadowBlur 18; ctx.strokeStyle #16212e; ctx.lineWidth 22; ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); // 进度圆弧线性渐变 圆头端点 const progressAngle -Math.PI / 2 (value / 100) * Math.PI * 2; const progressGradient ctx.createLinearGradient(cx - radius, cy, cx radius, cy); progressGradient.addColorStop(0, #22d3ee); progressGradient.addColorStop(1, #818cf8); ctx.save(); ctx.strokeStyle progressGradient; ctx.lineWidth 12; ctx.lineCap round; ctx.beginPath(); ctx.arc(cx, cy, radius, -Math.PI / 2, progressAngle); ctx.stroke(); ctx.restore(); // 刻度线通过旋转坐标系生成 for (let i 0; i 10; i) { ctx.save(); ctx.translate(cx, cy); ctx.rotate(-Math.PI / 2 (i / 10) * Math.PI * 2); const active i Math.round(value / 10); ctx.strokeStyle active ? #22d3ee : #3b4a5a; ctx.lineWidth i % 5 0 ? 3 : 1; ctx.beginPath(); ctx.moveTo(radius - 15, 0); ctx.lineTo(radius - 6, 0); ctx.stroke(); ctx.restore(); } // 中心数值文字带阴影 ctx.save(); ctx.textAlign center; ctx.textBaseline middle; ctx.font 700 34px Arial, sans-serif; ctx.fillStyle #e8f1ff; ctx.shadowColor rgba(34, 211, 238, 0.4); ctx.shadowBlur 12; ctx.fillText(value %, cx, cy 65); ctx.restore(); // 副标题文字 ctx.save(); ctx.textAlign center; ctx.textBaseline middle; ctx.font 12px sans-serif; ctx.fillStyle rgba(255, 255, 255, 0.5); ctx.fillText(当前进度, cx, cy 86); ctx.restore(); // 顶部高光径向渐变 加色混合 ctx.save(); ctx.globalCompositeOperation lighter; const highlight ctx.createRadialGradient(cx, cy - radius * 0.55, 5, cx, cy - radius * 0.55, radius * 0.9); highlight.addColorStop(0, rgba(255, 255, 255, 0.16)); highlight.addColorStop(1, rgba(255, 255, 255, 0)); ctx.fillStyle highlight; ctx.fillRect(0, 0, W, H); ctx.restore(); } // 使用示例 const canvas document.getElementById(gauge); drawGauge(canvas, 68);7.3 实测效果与微调建议这套代码跑起来你会得到一张深蓝渐变背景、带青色辉光外圈和渐变进度弧的仪表盘整体质感会比默认的平涂图形好很多。有几个微调点值得说文字的阴影不要开太大。实测下来shadowBlur在 8 到 16 之间比较合适开得太大会糊成一片文字反而看不清进度弧用了lineCap round所以圆弧两端自带圆头视觉上更柔和。如果你需要精确到某个角度圆头会向外多出半个线宽要注意预留量高光叠加用了lighter混合模式等于在现有画布上做加法增亮。如果你发现整个画面偏亮把高光渐变的 alpha 从 0.16 降到 0.08 左右即可如果你要响应式重绘在resize事件里重新调用drawGauge。因为setupCanvas里给canvas.width重新赋值会清空画布和所有样式状态所以每次 resize 都要完整重绘这是 Canvas 的固有行为提前写好重绘函数就不会手忙脚乱。我在多个项目里用这套思路画过环形进度、雷达图和仪表盘最大的体会是Canvas 绘图样式本身不难难点在于你是否清楚每一个属性在当前状态下是否会被后续操作意外改变。只要坚持把每个独立绘制块用save/restore包起来把变换和样式隔离清楚再复杂的图形也能一步步拆出来画完。下次再遇到阴影没效果虚线到处乱飘文字对不齐这类问题不妨先检查一下是不是有哪个restore没写或者setLineDash之后忘了清空。