在浏览器里画画这件事,很多人第一反应是找图表库、找游戏引擎,却忽略了最基础也最灵活的 Canvas。我最早接触 Canvas 是因为要做一个小游戏里的角色动画,当时照着教程画了一个方块,心想这不就是 fillRect 吗?直到后来要做场景光效、描边层次、阴影过渡,才发现真正拉开作品差距的,是那堆平时不太起眼的“绘图样式”API。这篇文章打算把 HTML 和 JavaScript 环境下的 Canvas 绘图样式从头到尾梳理一遍,内容包括 fillStyle、strokeStyle、lineWidth、渐变、阴影、图案填充、变换矩阵、虚线、混合模式,以及高分屏适配和一些我实际踩过的坑。适合刚入门 Canvas 想系统打基础的朋友,也适合写过不少 Canvas 但一直靠复制代码混过来的同学。
1. Canvas 绘图样式的定位:很多人搞混了“画什么”和“怎么画”
1.1 为什么样式在 Canvas 里这么关键
Canvas 的渲染模型可以理解为“在画布上涂像素”。它和 DOM 里画一个 div、SVG 里画一个 rect 最大的不同是:Canvas 本身没有记忆,它只记录那些像素的颜色值,画完就“烧”进了布面里。这带来一个特点:图形一旦画完,你想像改 DOM 一样改一个属性让画面自动刷新,是不可能的,必须重新绘制。这意味着你在编码时必须把“画什么”和“怎么画”分开思考:路径负责形状,样式负责外观。很多人卡住不是因为路径不会写,而是样式设置得太随意。
可以这么理解:路径告诉 Canvas 你要把笔落在哪里、怎么移动,样式则决定这笔落下去之后看起来是什么样。前者像建筑图纸,后者像装修方案。图纸画得再精确,装修材料选不对,最终效果还是廉价。许多初学者画完基础图形之后陷入瓶颈,恰恰是因为只掌握了路径 API,对样式层几乎一无所知:不知道渐变怎么加、不知道阴影怎么控制、不知道线条圆角从哪里设置。这篇文章就是把这层窗户纸捅破。
1.2 Canvas 与 SVG 的选型差异
既然聊绘图,免不了要对比 Canvas 和 SVG。两者都能在网页上呈现图形,但底层逻辑完全不同,选错方案后面要付出很大代价。我做过一个可视化大屏项目,最初用 SVG 标的交互,后来数据量一上来页面卡到没法看,只能推倒重来换 Canvas。对比一下:
| 对比项 | Canvas | SVG |
|---|---|---|
| 渲染方式 | 像素(位图) | 矢量 |
| 图形修改 | 需要整体重绘 | 可直接操作元素 |
| 事件支持 | 靠坐标计算命中 | 元素级事件 |
| 适合场景 | 游戏、粒子、大量图形、像素处理 | 图标、图表、需要频繁交互选中的场景 |
| 内存开销 | 由画布尺寸决定 | 由节点数量决定 |
实际选型建议是这样的:如果你要做交互地图、图标系统,或者每个元素都要独立响应 hover 和 click,SVG 天生更合适,它每个图形都是 DOM 节点。但如果你要做粒子动画、物理碰撞、实时绘制几千上万个点,Canvas 几乎是唯一理性选择。作为前端从业者,两个都要会,但本文重心放在 Canvas 的样式层,所以接下来所有内容都默认基于 Canvas 2D 上下文展开。
2. 画布基础:搭建一个能跑起来的 Canvas 环境
2.1 HTML 结构里的三个陷阱
先看一个最基础的环境:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Canvas 基础画布</title> <style> canvas { border: 1px solid #ccc; } </style> </head> <body> <canvas id="playground" width="600" height="400"></canvas> <script> const canvas = document.getElementById('playground'); const ctx = canvas.getContext('2d'); console.log(ctx); </script> </body> </html>这段代码本身很简单,但里面埋着三个坑,我见过不少人踩过。
第一个坑:canvas 的 width 和 height 属性与 CSS 尺寸是两套完全独立的东西。属性决定内部位图分辨率,CSS 只做显示缩放。如果你用 CSS 把 canvas 撑大、撑小,却不动属性,画出来的图形要么被拉伸变形,要么边缘糊成一片。这个细节到后面讲高分屏适配时还会再展开。
第二个坑:设置 width 或 height 属性(哪怕只是设置为相同的值)会清空画布内容。很多人不知道这个特性,于是写canvas.width = canvas.width来“清屏”,结果把之前设置的 transform 状态也一并重置了,动画中途突然乱掉。清屏的正规做法是ctx.clearRect(0, 0, canvas.width, canvas.height)。
第三个坑:不要用 CSS 的 background 代替 Canvas 内容做底色。如果后续画的是半透明图形,CSS 背景位于 Canvas 像素层之下,行为会和直接用fillRect绘制完全不同,叠加方式和透明度混合结果都不可控。我建议所有背景色都在 Canvas 内部画,保持渲染管线统一。
2.2 坐标系:为什么原点在左上角
Canvas 默认坐标系的原点 (0,0) 在画布左上角,x 轴向右,y 轴向下。这和数学课上学过的笛卡尔坐标系正好相反,y 轴方向是反的。所以画折线图、散点图时,y 值总是需要额外换算一遍,比如实际数据点高度是 h,那画布坐标就得写成canvas.height - h。
一个比较干净的解决思路:如果项目里需要频繁使用数学坐标系,可以用变换矩阵翻转 y 轴:
const ctx = canvas.getContext('2d'); const h = canvas.height; ctx.setTransform(1, 0, 0, -1, 0, h);这样原点就跑到左下角去了,y 轴向上增长。但要留意,翻转之后绘制文字也会镜像倒立,需要在画文字的地方单独save(),再scale(1, -1)把文字正过来,写完restore()。这点很细节,不提醒的话第一次做翻转变换的人基本都会在 fillText 那儿卡住。
坐标系对样式的影响也很直接:渐变的端点坐标、阴影的偏移方向、旋转的圆心,全部依赖当前坐标系的形态。可以说,Canvas 样式里一半的问题都出在坐标系理解不到位。基础打牢再往下走,会顺很多。
3. 第一层样式:描边与填充
3.1 fillStyle 和 strokeStyle 的取值玩法
fillStyle 控制填充颜色,strokeStyle 控制描边颜色。它们支持的取值和 CSS 颜色完全一致:颜色名(red、blue)、十六进制(#ff0000)、rgb/rgba、hsl/hsla,以及关键字transparent。
需要特别注意一个状态机特性:样式是持久的。如果你画完一个红色方块,没有修改 fillStyle 就直接画下一个圆,那这个圆也是红色。很多人写复杂场景时,图形颜色“莫名”串了,就是状态污染导致的。我的习惯是:每次绘制独立图形前,显式写出 fillStyle 和 strokeStyle,哪怕和上一个一样也要写,别偷懒。代码看起来啰嗦,但调试时能省掉大量猜谜时间。
另外一个值得说的点:Canvas 的fillStyle和strokeStyle还可以被赋值为渐变对象或图案对象(Pattern),这远远超出了“颜色”的范畴。后面两章会分别展开,先把基础颜色的部分吃透。
3.2 lineWidth、lineCap、lineJoin:线条细节决定质感
画线条的时候,光有颜色还远远不够。lineWidth 控制线宽,默认是 1 像素,单位跟随当前变换坐标系。lineCap 控制线头形状,有 butt(平头,默认)、round(圆头)、square(方头)三种。lineJoin 控制折线拐角,有 miter(尖角)、round(圆角)、bevel(斜切)三种,miterLimit 用来限制尖角的突出长度,超了会自动切成 bevel。
很多人忽略一个细节:线宽是沿着路径中心线向两侧均匀扩展的。这意味着,一个从 (0,0) 到 (100,0) 的线段,lineWidth 设为 10,实际占用是从 y=-5 到 y=5 的区域。描边同理,后面先填充再描边时,描边会向内吃掉一半的填充边缘。
来看一个典型例子:
ctx.beginPath(); ctx.arc(120, 100, 50, 0, Math.PI * 2); ctx.fillStyle = '#3498db'; ctx.fill(); ctx.strokeStyle = '#2c3e50'; ctx.lineWidth = 6; ctx.stroke();这段代码画出的是一个圆形,但描边的 6px 有 3px 压在填充色上,3px 伸到填充外面。如果你希望描边完全在填充外侧,要么把圆的半径扩大半个线宽,要么先 stroke 后 fill 再做细调。很多图标放大后边缘显得“被切了一刀”,多半就是没想清楚这层逻辑。
还有一个变换相关的问题:ctx.scale(2, 2)之后设置 lineWidth=4,实际渲染的线宽是 8。如果你想让线宽在缩放状态下保持屏幕像素一致,需要反过来除以缩放系数。这在绘制与屏幕像素对齐的 UI 控件时特别重要,否则在不同缩放级别下边框粗细会忽大忽小。
4. 让颜色活起来:渐变与透明
4.1 线性渐变和它的坐标系陷阱
ctx.createLinearGradient(x0, y0, x1, y1)创建一个线性渐变,参数是两个点的坐标,渐变沿着这个方向铺开。返回值赋给 fillStyle 或 strokeStyle 就能用。看起来简单,但我见过最多的“渐变不生效”其实是坐标写错了。
因为渐变的端点坐标是基于画布坐标系,而不是基于你要填充的图形所在的位置。一个常见误区:图形画在 (100, 100) 到 (200, 200) 的区域,但渐变端点写的是 (0, 0) 到 (50, 0),结果图形上只显示了渐变的前一小段颜色,看起来像纯色,怎么调都调不出多点渐变的效果。
解决思路很简单:如果你希望渐变跟随图形移动,就利用变换把坐标系移到图形处,再用局部坐标创建渐变。封装一个辅助函数效果更好:
function fillRectWithLinearGradient(ctx, x, y, w, h, stops) { const g = ctx.createLinearGradient(x, y, x + w, y); stops.forEach(([offset, color]) => g.addColorStop(offset, color)); ctx.fillStyle = g; ctx.fillRect(x, y, w, h); }addColorStop 的 offset 必须落在 0 到 1 之间,超出范围会直接抛 IndexSizeError。如果想让渐变继续向外延伸,正确做法是拉长渐变端点之间的距离,而不是试图设置大于 1 的 offset。这一点在写径向渐变的时候同样适用。
4.2 径向渐变:做出球的体积感
ctx.createRadialGradient(x0, y0, r0, x1, y1, r1)接受两组参数,前三个定义内圆圆心和半径,后三个定义外圆圆心和半径。常用来做球体、按钮高光、光晕一类效果。
核心逻辑是:内圆区域颜色浅,外圆区域颜色深,两个圆心做一点偏移,就能模拟出侧光照射的立体感。举一个画球的例子:
const canvas = document.getElementById('sphere'); const ctx = canvas.getContext('2d'); const grad = ctx.createRadialGradient(90, 80, 5, 100, 100, 60); grad.addColorStop(0, '#ffffff'); grad.addColorStop(0.3, '#85c1e9'); grad.addColorStop(0.8, '#21618c'); grad.addColorStop(1, '#154360'); ctx.fillStyle = grad; ctx.beginPath(); ctx.arc(100, 100, 60, 0, Math.PI * 2); ctx.fill();内圆圆心 (90, 80) 偏左上方,外圆圆心在球的正中心 (100, 100)。这样画出来的效果是球的左上角有一团高光,越往右下颜色越深,很像从右上角打了一盏灯。做游戏角色头像、仪表盘按钮、加载动画时,这套“径向渐变高光”组合拳非常实用。
我自己的体会是,径向渐变最怕“圆心位置不对”。如果内圆和外圆圆心距离太远、半径差太大,颜色过渡会非常生硬,甚至出现一道明显的色带。实操时多试几组坐标,找到那个过渡自然的值,比硬背参数公式管用得多。
4.3 透明度:globalAlpha 和 rgba 的选择
全局透明度ctx.globalAlpha影响它设置之后的所有绘制操作,包括填充、描边、drawImage 图片。它和 rgba 颜色里的 alpha 通道是连乘关系:fillStyle 设为rgba(0, 0, 0, 0.5),同时 globalAlpha 是 0.5,那最终画上去的像素 alpha 就是 0.25。
所以选择依据很明确:只想让某一个颜色变透明,用 rgba;想整组图形一起淡出,比如做一个渐变过渡动画,用 globalAlpha 配合 save/restore 控制范围。
这里还有一个做过云朵、烟雾场景的人都会遇到的坑:多个半透明圆重叠绘制时,重叠区域颜色会明显加深,形成难看的接缝。比如画一朵云,你先画三个半透明的圆,圆与圆相交的部分就会比单个圆更厚。
怎么解决?把三个圆都放进同一个 beginPath 里,作为同一路径的子路径,然后一次性 fill,这样重叠区域会作为一个整体填充,不会出现重复叠加。这是 Canvas 在路径填充逻辑上一个很实用的特性,大部分人不知道。
5. 阴影、图案与变换:进阶样式工具箱
5.1 shadow 属性与性能警告
Canvas 的阴影系统由四个属性组成:shadowColor 控制阴影颜色,shadowBlur 控制模糊程度,shadowOffsetX 和 shadowOffsetY 控制阴影位移。只需要设置一次,之后所有 fill 和 stroke 都会自动带上阴影。
这也带来一个隐蔽问题:如果你只给某个图形设置了阴影,画完以后忘记重置,下一个图形也会带阴影,而且 shadowColor、shadowBlur、shadowOffset 是分别独立的,只把 shadowBlur 设回 0 但忘记恢复颜色,可能出现“偏移透明影子”这种诡异效果。我建议在阴影使用完后,统一把四个属性全部恢复,比较稳妥。
性能方面需要敲个警钟:shadowBlur 是 Canvas 里排名靠前的性能杀手。我实测过一个粒子项目,同样一帧 1000 个圆,不开阴影稳定 60fps,开启 shadowBlur=20 直接掉到十几帧,移动端更惨。原因是阴影需要对每个图形模糊算法处理,计算量很大。
如果你要做大量带阴影的元素,可以退而求其次,用半透明渐变模拟近似的视觉效果,或者把带阴影的静态内容画在离屏 canvas 上,再整体 drawImage 到主画布。这个思路能保留阴影质感又不拖垮帧率,我在游戏场景里验证过多次,效果不错。
5.2 createPattern 图案填充
ctx.createPattern(image, repetition)可以创建一个图案对象,赋给 fillStyle 后,填充区域会像贴瓷砖一样铺上这个图案。repetition 取值有"repeat"、"repeat-x"、"repeat-y"、"no-repeat"。image 可以是 HTMLImageElement、另一个 canvas,甚至 video 元素。
这个 API 平时用得不多,但做纹理背景非常顺手。比如给游戏地图画草地纹理:先创建一个 32x32 的小 canvas,在上面画一簇草,然后 createPattern 填充一大片区域,比循环 drawImage 性能好很多,代码也简洁得多。
const patternCanvas = document.createElement('canvas'); patternCanvas.width = 32; patternCanvas.height = 32; const pctx = patternCanvas.getContext('2d'); pctx.fillStyle = '#27ae60'; pctx.fillRect(0, 0, 32, 32); pctx.fillStyle = '#2ecc71'; for (let i = 0; i < 8; i++) { const x = Math.random() * 32; const y = Math.random() * 32; pctx.fillRect(x, y, 2, 4); } const pattern = ctx.createPattern(patternCanvas, 'repeat'); ctx.fillStyle = pattern; ctx.fillRect(0, 0, 600, 400);这里有几个需要注意的地方。第一,跨域图片在 createPattern 或 drawImage 之后,会把当前 canvas 标记为“被污染”,后续调用 getImageData、toDataURL 都会抛 SecurityError。要避免这个问题,图片标签或 Image 对象需要设置crossOrigin = 'anonymous',并且图片服务器要返回正确的 CORS 响应头。本地用 file:// 协议直接打开页面时,很多浏览器依然会拦,建议起一个本地静态服务调试,比如 deno 或 node 的静态文件模块。
第二,pattern 的平铺基准是画布原点。如果你先做了一套 translate 变换,图案的起始位置也会跟着移动。这有时是你想要的,有时不是,需要根据场景判断。
5.3 变换级联:translate、rotate、scale 的使用逻辑
ctx.translate(x, y)、ctx.rotate(angle)、ctx.scale(sx, sy)这三个方法都是基于当前变换矩阵进行叠加的。它们的调用顺序直接影响最终结果。
看到过一个很经典的对比:同样是 translate 和 rotate 组合,顺序不同,图形的位置天差地别。因为 rotate 是围绕当前坐标系原点旋转,而 translate 是移动坐标系原点。正确做法是,先 translate 把原点挪到某个“局部原点”,再 rotate 和 scale,然后在这个局部坐标系里从 0 开始画图形。这就是游戏引擎里的“局部坐标系”思想,能显著减少坐标计算量。
function drawArrow(ctx, x, y, angle) { ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.fillStyle = '#e67e22'; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(-20, -8); ctx.lineTo(-20, 8); ctx.closePath(); ctx.fill(); ctx.restore(); }调用这个函数时,只需传入箭头的世界坐标 x、y 和角度 angle,函数内部所有绘制都基于局部坐标 (0,0) 完成。比如画一个朝右的箭头,指针自然就指向 x 轴正方向。
这里必须强调 save/restore 的重要性。save() 保存当前完整状态,包括样式、变换矩阵、合成模式、裁剪区域;restore() 恢复。如果不小心把 transform 累积下去,画着画着会发现图形全跑到屏幕外面去了。我见过太多新手写动画写到一半,元素突然横着飞出去,就是 save/restore 没配对。
有个额外的坑需要提醒:rotate 的参数单位是弧度,不是角度。很多人写ctx.rotate(45),期望看到 45 度,结果只转了不到 8 度,因为那是 45 弧度。正确写法是ctx.rotate(Math.PI / 4)或ctx.rotate((45 * Math.PI) / 180)。这个小问题每年都能在社区里看到好多次。
6. 高级样式与调优:虚线、混合模式与高清屏
6.1 setLineDash 的隐藏行为
ctx.setLineDash(segments)用来设置虚线样式,参数是一个数组,表示实线段和间隙的交替长度序列。比如[10, 5]就是 10px 实线、5px 空白、10px 实线,以此类推。
这个数组有一个容易忽略的行为:如果数组长度是奇数,规范会要求浏览器自动复制一遍补成偶数。比如[10, 5, 2]实际会变成[10, 5, 2, 10, 5, 2]。如果在设计线条节奏时没考虑到这一点,可能会得到预料之外的花纹。
ctx.lineDashOffset配合虚线可以做“蚂蚁线”动画。每帧让 offset 偏移一定像素,虚线就会整体流动起来。典型场景是地图上的路径追踪线、UI 上的选中框跑马灯:
let offset = 0; function animate() { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.setLineDash([8, 4]); ctx.lineDashOffset = -offset; ctx.strokeStyle = '#2ecc71'; ctx.lineWidth = 3; ctx.strokeRect(20, 20, 200, 120); offset++; requestAnimationFrame(animate); }lineDashOffset为负值时虚线向路径前进方向移动,正值反向。这个符号问题很容易弄反,调试时自己试一下就有直觉了。
6.2 globalCompositeOperation:混合模式不只是“叠加”
ctx.globalCompositeOperation控制后续绘制内容与画布已有内容如何混合,它的默认值是source-over,即新内容覆盖旧内容。真正让它强大的,是那些看起来不常用但关键的取值。
我个人用得比较多的几个:
| 模式 | 一句话理解 | 常见用途 |
|---|---|---|
| source-over | 新内容盖在旧内容上面 | 默认绘制 |
| destination-over | 新内容画在旧内容下层 | 快速插入背景 |
| lighter | 颜色相加,越叠越亮 | 粒子、光束、发光特效 |
| source-atop | 只在与旧内容重叠处绘制新内容 | 遮罩、局部纹理 |
| destination-out | 挖掉旧内容与重叠部分 | 橡皮擦、选区删除 |
| xor | 重叠部分挖空 | 特殊图案、轮廓标注 |
做发光效果时,lighter模式几乎是标配。粒子系统里,密集粒子在 lighter 模式下会自然融合成明亮的光团,这是 source-over 永远达不到的效果。实现火焰、极光、霓虹灯氛围,第一步就是把合成模式切到 lighter。
需要注意的是,globalCompositeOperation 也是状态的一部分,用完要恢复成默认,否则后面所有绘制都受影响。还有一种常见需求是“局部混合”,比如只想让某个区域内部的元素使用 lighter,但整个画布上还有其他已有的背景。这时更可靠的办法是使用离屏 canvas:先在一个临时画布上画好需要混合的内容,设置好混合模式,再整体 drawImage 到主画布的指定位置。这种分层思路在复杂 UI 效果里几乎是标准做法。
6.3 高分屏模糊:CSS 尺寸和物理像素的落差
几乎所有 Canvas 初学者都会遇到一个问题:画出来的图形在普通屏幕上还行,放到 MacBook 或手机上一看就糊。原因很简单:高分屏的设备像素比 devicePixelRatio(DPR)通常为 2,意味着 1 个 CSS 像素对应 2x2 个物理像素。而 Canvas 默认的绘图缓冲区只有 CSS 尺寸那么大,系统会把这块小位图拉伸放大到物理像素尺寸,于是糊。
标准适配方案如下:
const canvas = document.getElementById('hires'); const dpr = window.devicePixelRatio || 1; const cssWidth = canvas.clientWidth; const cssHeight = canvas.clientHeight; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr);完成以上步骤后,所有绘制逻辑都可以继续按 CSS 像素坐标来写,线宽也不用乘以 dpr。由于绘图缓冲区有了足够的分辨率,屏幕上的线条自然就清晰了。
这里有一个前后顺序的坑要特别强调:先设置 canvas.width 和 canvas.height,再设置 style.width 和 style.height。因为修改 width 和 height 属性会触发画布清空和坐标重置,所以必须在调整尺寸之后再做后续绘制操作。如果项目里画布尺寸会动态变更,调整属性的时机一定要放在绘制逻辑之前。
另外一个我常看到的误区:只设置 CSS 的width/height但不改 canvas 属性,希望浏览器“自动适配”。事实上 Canvas 不会自动适配,它只会把内部低分辨率内容拉伸显示,越拉越糊。所以调试模糊问题时,先看一眼canvas.width和canvas.clientWidth的值是否一致,很大概率问题就出在这。
7. 综合实操:用样式 API 画一个完整的 Canvas 小人形象
前面讲了不少零散的知识点,是时候把它们串起来用一次了。这里用前面提到的所有样式基础,画一个有立体感的简易小人形象,包含渐变头部、带阴影的帽子、圆头线条的四肢和一个圆角矩形身体。你可以在浏览器里直接跑这段代码:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Canvas 小人形象示例</title> <style> body { display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; } canvas { border: 1px solid #eee; } </style> </head> <body> <canvas id="figure" width="300" height="400"></canvas> <script> const canvas = document.getElementById('figure'); const ctx = canvas.getContext('2d'); // 背景淡蓝 ctx.fillStyle = '#ecf0f1'; ctx.fillRect(0, 0, 300, 400); // 头部:径向渐变制造立体感 const headGrad = ctx.createRadialGradient(140, 108, 8, 150, 135, 55); headGrad.addColorStop(0, '#ffe0bd'); headGrad.addColorStop(1, '#f5b47b'); ctx.fillStyle = headGrad; ctx.beginPath(); ctx.arc(150, 130, 50, 0, Math.PI * 2); ctx.fill(); // 帽子:使用阴影制造层次 ctx.save(); ctx.shadowColor = 'rgba(0,0,0,0.25)'; ctx.shadowBlur = 10; ctx.shadowOffsetY = 6; ctx.fillStyle = '#2c3e50'; ctx.fillRect(98, 58, 104, 18); ctx.fillStyle = '#e74c3c'; ctx.beginPath(); ctx.arc(150, 72, 34, Math.PI, Math.PI * 2); ctx.fill(); ctx.restore(); // 眼睛和嘴巴 ctx.fillStyle = '#34495e'; ctx.beginPath(); ctx.arc(135, 128, 4, 0, Math.PI * 2); ctx.arc(165, 128, 4, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#c0392b'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(150, 145, 12, 0.1 * Math.PI, 0.9 * Math.PI); ctx.stroke(); // 身体:圆角矩形 ctx.fillStyle = '#3498db'; ctx.beginPath(); ctx.roundRect(125, 180, 50, 80, 10); ctx.fill(); // 手臂和腿:圆头线条 ctx.strokeStyle = '#2c3e50'; ctx.lineWidth = 10; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(130, 200); ctx.lineTo(105, 240); ctx.moveTo(170, 200); ctx.lineTo(195, 240); ctx.moveTo(140, 210); ctx.lineTo(140, 270); ctx.moveTo(160, 210); ctx.lineTo(160, 270); ctx.stroke(); // 鞋子 ctx.fillStyle = '#e74c3c'; ctx.fillRect(120, 265, 28, 12); ctx.fillRect(152, 265, 28, 12); </script> </body> </html>这个例子几乎覆盖了前面讲过的所有点:背景用 fillRect 填充;头的立体感来自径向渐变的内外圆偏移;帽子阴影用的是 shadowBlur 加 shadowOffsetY 的组合;四肢用了 lineCap 和 lineJoin 让圆头线条看起来更像手脚;嘴巴用弧线路径 stroke;身体和鞋子用 fillRect 和圆角矩形。
一个小提醒:roundRect是现代浏览器才普遍支持的 API,老版本环境里可能没有,需要自己用 arcTo 组合路径来实现。实际项目中如果兼容性要求高,最好先判断一下ctx.roundRect是否存在。
8. 常见问题与排查实录(含避坑指南)
8.1 样式“没有生效”的排查顺序
遇到“设置了 fillStyle 但图形颜色不对”这类问题,不要先怀疑浏览器,按下面这个顺序排查,基本能覆盖九成情况:
- 颜色值写对了吗?
#fff、rgb(255, 0, 0)都合法,但少一个括号、多一个空格,有些浏览器会直接忽略。 - 真的调用
fill()或stroke()了吗?设置样式只是配置画笔,不调用绘制函数,画布上不会有任何痕迹。 - 样式是不是被覆盖了?Canvas 是状态机,后设置的样式会覆盖先前的。检查前后代码是否共用了同一个 ctx。
- 路径是空的?beginPath 之后如果 arc、lineTo 参数写错,填充一个空路径当然没效果。
- 是不是被其他图形盖住了?Canvas 的绘制顺序就是图层顺序,后画的永远盖在先画的上面。
8.2 动画闪烁、画布被清空的排查
做动画最常见的问题就是画面闪烁或内容消失。我复盘过很多次,出问题的原因集中在两类:
第一类:用canvas.width = canvas.width当成清屏操作。这个写法确实会清空画布,但同时会重置 Canvas 的状态,包括变换矩阵、globalAlpha、合成模式。动画里只要有一次这种操作,之前维护好的状态全部归零,画面自然就乱了。正确做法永远是clearRect。
第二类:在底部注释行使用 requestAnimationFrame 时,绘制依赖的图片还没加载完成,导致中间若干帧绘制内容为空。解决方法很简单:预先加载图片,等加载回调完成后才启动动画循环。
8.3 性能优化的三个层次
我在实际项目里总结了一套 Canvas 性能优化的思路,从最直观到最深层的顺序如下:
- 样式层:少用大范围的 shadowBlur,少在每帧创建新的渐变对象。渐变创建本身有开销,能复用就复用。
- 绘制层:减少路径点数,同类图形合并到一个 beginPath 里批量填充。能直接用 fillRect 的地方就别走 arc + fill。
- 分辨率层:画布越大,填充和重绘开销越高。对中间计算结果可以先在小尺寸离屏 canvas 上完成,再 drawImage 放大到主画布,避免每个像素都走昂贵的路径计算。
一个实用的调试技巧是:打开开发者工具,在控制台执行canvas.width和canvas.height,确认它们和 CSS 尺寸是否一致。排查模糊和性能问题时,这两个数字能帮你快速判断方向。
9. 最后分享几个我养成的习惯
写到这儿,这篇针对 Canvas 绘图样式的完整梳理就基本结束了。最后分享几个我在实际开发中逐步养成的小习惯,希望能帮你避开一些我当年踩过的坑。
第一个习惯:每次绘制前 save,绘制完立刻 restore。这样做至少能避免一半的“状态污染”问题。哪怕是同一个绘制函数里连续画两个图形,我也会在它们之间配对 save/restore,宁可多写两行,也不让状态在不知不觉中带过去。
第二个习惯:调试图形样式时先用高对比、大线宽把路径本身画出来,确认几何形状没问题,再去调渐变、阴影、透明度这些视觉细节。很多时候几何改对了,样式问题会自动消失;反过来,几何没对,怎么调样式都白费。
第三个习惯:把常用颜色和公共样式封装成配置对象,比如const COLORS = { primary: '#3498db', line: '#2c3e50' }。这样调色时只需改一处,所有引用的地方同步生效。对新手来说,这比出一堆文档管用得多,也是让代码更接近“工程化”的一个小技巧。
Canvas 绘图样式这个东西,单独看每个 API 都不难,难的是它们组合起来时的交互关系。但只要把坐标系、状态机、变换级联这几条主线捋顺,后续加再花哨的视觉效果,也只是往骨架上添细节而已。希望这篇文章能让你少走几趟弯路。