news 2026/10/7 13:17:43

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 id="demo" width="800" height="600" style="width: 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 / rgba'rgb(255, 0, 0)'、'rgba(255, 0, 0, 0.5)'需要同时控制颜色的场景
hsl / hsla'hsl(200, 80%, 50%)'、'hsla(200, 80%, 50%, 0.3)'需要调整色相、饱和度的时候
颜色关键字'red'、'blue'快速调试验证
transparent'transparent'隐藏线条但保留路径计算
渐变或图案对象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 画虚线主要靠两个 API:setLineDash和lineDashOffset。

ctx.setLineDash([12, 6]); // 12px 实线,6px 空隙 ctx.lineDashOffset = -30; // 虚线整体向左偏移 30px

setLineDash接受一个数组,数组元素按顺序表示实线段和空线段的长短,可以写多个值形成复杂的重复模式。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 globalCompositeOperation:source 和 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 滤镜搬进 Canvas

Canvas 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。这里有三个关键点:

  1. font-size必须指定,而且必须有单位(通常是px)。如果没写字号或没写单位,整个font声明会被浏览器静默忽略,文字保持默认的10px sans-serif。很多新手发现"我明明设置了 32px 的文字,为什么画出来还是那么小",就是这个原因;
  2. line-height对fillText和strokeText基本不生效,因为这两个方法只绘制单行文本,不会自动换行,行高也就没有用武之地;
  3. 字体族最好带备选字体,不要只写一种在用户电脑上可能不存在的字体。Canvas 和普通网页一样,用不了系统没有的字体。

如果你需要加载网页自定义字体,先让字体通过FontFace或 CSS@font-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之后忘了清空。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 13:17:42

STM32F103C8T6最小系统原理图绘制实战:从电源、时钟到复位电路

1. 为什么值得亲手画一遍STM32F103C8T6最小系统STM32F103C8T6这颗芯片&#xff0c;在嵌入式圈子里几乎无人不晓。它属于ST的F1系列&#xff0c;基于ARM Cortex-M3内核&#xff0c;主频72MHz&#xff0c;64KB Flash、20KB SRAM&#xff0c;48个引脚&#xff0c;LQFP封装。价格便…

作者头像 李华
网站建设 2026/10/7 13:17:39

t3code:跨平台开发流编排引擎,统一Electron+iOS+Android调试

1. 项目概述&#xff1a;t3code 是什么&#xff1f;它解决的不是“工具问题”&#xff0c;而是“开发流断裂”本身t3code 这个名字乍看像某个小众 CLI 工具的代号&#xff0c;但结合它在热搜词中与 Electron、iOS、Android、CLI 紧密捆绑的出现频率&#xff0c;再叠加大量真实开…

作者头像 李华
网站建设 2026/10/7 13:17:03

剪映数字人免费使用全攻略:合规操作与实战技巧

最近后台收到好多朋友留言&#xff0c;都在问同一件事&#xff1a;剪映的数字人功能到底怎么免费用&#xff1f;有没有什么版本能绕过会员直接用&#xff1f;先说结论&#xff1a;我劝你别碰所谓“免SVIP”的破解版、绿色版&#xff0c;这类东西十个里有九个绑了东西&#xff0…

作者头像 李华
网站建设 2026/10/7 13:16:21

REDox 64位Token位编码:结构化数据内存优化与多格式互转实践

1. 从一次内存告警说起&#xff1a;REDox 到底解决了什么问题 上周帮朋友排查一个数据管道的内存泄漏&#xff0c;服务跑在 8GB 的容器里&#xff0c;处理的是几十万条结构化记录&#xff0c;每条记录字段不多&#xff0c;但嵌套层级深。用常规的对象字典存&#xff0c;跑不到半…

作者头像 李华
网站建设 2026/10/7 13:15:44

DeepSeek Harness桌面端实战:API Key配置、工作区与插件部署全指南

1. 桌面端这件事&#xff0c;为什么值得单独聊一次DeepSeek Harness 出官方桌面端&#xff0c;这个消息在开发者圈子里传开的时候&#xff0c;我第一反应不是"终于等到了"&#xff0c;而是"这下工作流要重新捋一遍了"。原因很简单&#xff1a;过去用 Harne…

作者头像 李华
网站建设 2026/10/7 13:15:04

Unity 3D入门实战:从角色移动到敌人AI与多平台构建

很多 Unity 开发者在入门中期会陷入同一种状态&#xff1a;场景会搭了&#xff0c;材质会换了&#xff0c;脚本也能挂上去了&#xff0c;但一运行就出各种“怪毛病”——角色按下方向键不动&#xff0c;或者原地抽搐&#xff1b;明明有碰撞体&#xff0c;角色还是穿墙&#xff…

作者头像 李华