
在浏览器里画画这件事很多人第一反应是找图表库、找游戏引擎却忽略了最基础也最灵活的 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。对比一下对比项CanvasSVG渲染方式像素位图矢量图形修改需要整体重绘可直接操作元素事件支持靠坐标计算命中元素级事件适合场景游戏、粒子、大量图形、像素处理图标、图表、需要频繁交互选中的场景内存开销由画布尺寸决定由节点数量决定实际选型建议是这样的如果你要做交互地图、图标系统或者每个元素都要独立响应 hover 和 clickSVG 天生更合适它每个图形都是 DOM 节点。但如果你要做粒子动画、物理碰撞、实时绘制几千上万个点Canvas 几乎是唯一理性选择。作为前端从业者两个都要会但本文重心放在 Canvas 的样式层所以接下来所有内容都默认基于 Canvas 2D 上下文展开。2. 画布基础搭建一个能跑起来的 Canvas 环境2.1 HTML 结构里的三个陷阱先看一个最基础的环境!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleCanvas 基础画布/title style canvas { border: 1px solid #ccc; } /style /head body canvas idplayground width600 height400/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 到 y5 的区域。描边同理后面先填充再描边时描边会向内吃掉一半的填充边缘。来看一个典型例子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)之后设置 lineWidth4实际渲染的线宽是 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开启 shadowBlur20 直接掉到十几帧移动端更惨。原因是阴影需要对每个图形模糊算法处理计算量很大。如果你要做大量带阴影的元素可以退而求其次用半透明渐变模拟近似的视觉效果或者把带阴影的静态内容画在离屏 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 或手机上一看就糊。原因很简单高分屏的设备像素比 devicePixelRatioDPR通常为 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 langzh-CN head meta charsetUTF-8 titleCanvas 小人形象示例/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 idfigure width300 height400/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 都不难难的是它们组合起来时的交互关系。但只要把坐标系、状态机、变换级联这几条主线捋顺后续加再花哨的视觉效果也只是往骨架上添细节而已。希望这篇文章能让你少走几趟弯路。