Canvas矩形绘制三招:fillRect、strokeRect、rect与避坑指南
说到 Canvas 画矩形可能是前端入门阶段最微妙的体验之一代码就几行原理看着也不难但真要画出清晰、不糊、能适配各种屏幕的矩形坑比想象中多。页面上的进度条、卡片、头像框、图片裁剪选区剥开看全是矩形。我见过不少前端新手照着文档敲下 fillRect(0, 0, 100, 100) 后屏幕确实出现了一个方块接着就开始踩坑线条糊成一片、高清屏上发虚、循环里画出来的矩形叠成一团黑。这些问题不是手不熟而是对 Canvas 的绘制模型和坐标系理解不到位。这篇就把 Canvas 画矩形的三招——fillRect、strokeRect、rect——连同配套的颜色、描边、路径管理和避坑经验一起讲清楚。适合刚学完 HTML/CSS、准备进入 Canvas 图形世界的前端新人也适合想给项目加一个轻量画板组件但又不想上重型库的人。1. 先搞清楚坐标系矩形才不会画歪1.1 原点在左上角y 轴是往下的Canvas 的坐标系和我们初中数学课本上的坐标系不太一样。数学里原点一般在左下角x 轴向右、y 轴向上Canvas 的原点在画布左上角x 轴向右增长y 轴向下增长。这一点新手特别容易翻车想画一个靠近底部的矩形下意识觉得 y 应该写大一点结果写小了想做从下往上增长的柱状图直接把数学课上的公式搬过来画出来的柱子全是倒的。为什么这样设计因为屏幕像素在显存里的排列就是从左上方开始、逐行往下填充的Canvas 的坐标系和这个物理事实天然一致。所以一旦你接受了往右是正 x、往下是正 y这个设定再去看其他 Canvas 代码都会顺很多。实操里最常用的换算思路是画布总高度是 H元素距离底部想留 d元素自身高度是 h那么 y H - d - h。比如画布高 400想在底部往上有 20 像素留白的位置放一个高 100 的矩形y 就是 400 - 20 - 100 280。我一开始也是靠画草稿才记住这个公式的建议你也把坐标系画一遍比硬背参数管用。1.2 画布尺寸有两个宽高别混为一谈另一个特别隐蔽的坑是canvas 标签的 width 和 height 属性跟 CSS 里写的 width 和 height不是同一回事。canvas.width 和 canvas.height 是绘图缓冲区的实际像素大小决定了一块内存里真正有多少个像素点可画而 CSS 里的 width 和 height 只是把这个缓冲区拉伸到页面上的显示尺寸。如果你在 HTML 里写canvas width300 height150/canvas又在 CSS 里写canvas { width: 600px; height: 300px; }那浏览器会把 300×150 的图硬放大到 600×300结果是边缘发虚、文字模糊。新手最常见的错误就是在 CSS 里做响应式缩放却忘了同步改 canvas.width 和 canvas.height。原则只有一句话绘图尺寸和显示尺寸的比例关系必须由你主动控制不要让浏览器随手拉伸。后面讲高清屏适配时会再出现这个问题所以先把这个概念记牢。2. 第一招 fillRect三秒画出一个实心矩形2.1 参数拆解与第一个方块fillRect(x, y, width, height) 是逻辑上最简单的画矩形方式。四个参数分别表示左上角 x、左上角 y、宽度和高度。调用之后Canvas 会用当前的 fillStyle 颜色把这个区域直接填满立刻出现在屏幕上不需要再调用任何提交方法。先给你一个最朴素的练习——画一个浅灰背景加一个按钮底色const canvas document.getElementById(c); const ctx canvas.getContext(2d); ctx.fillStyle #f5f5f5; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle #1677ff; ctx.fillRect(80, 120, 160, 48);这里有一个顺序概念要尽早建立后画的会覆盖先画的这叫画家算法。Canvas 不像 DOM 有层级和 z-index它更像一张真实的纸你换不同颜色的笔往上涂后来的颜色会盖住之前的。很多人调了半天发现颜色不对其实只是两个 fillRect 写反了先后。2.2 用循环批量生成棋盘格fillRect 第二个很好用的场景是批量绘制。比如一个 8×8 棋盘格const size 40; for (let row 0; row 8; row) { for (let col 0; col 8; col) { ctx.fillStyle (row col) % 2 0 ? #333 : #fff; ctx.fillRect(col * size, row * size, size, size); } }(row col) % 2 0 这个判断是个经典技巧因为 row col 的奇偶性正好沿对角线翻转黑白格就自然交错排列了。以后你写马赛克效果、像素画、表格斑马纹思路都是同一套。再说个小细节批量绘制时把 fillStyle 放在循环里改是没问题的但如果循环次数多、且颜色只有几种更好的做法是先把同一颜色的矩形全部画完再换颜色继续画。因为 Canvas 状态切换比如 fillStyle 赋值虽然不慢但高频切换会拖累渲染性能养成批量同色绘制的习惯没有坏处。2.3 半透明遮罩与负坐标两个冷门细节fillStyle 支持 rgba、hsla可以很方便地画半透明块。我经常用它做遮罩先整体盖上透明度 0.4 的黑色再用 clearRect 把中间挖空就得到一种高亮选区的效果。ctx.fillStyle rgba(0, 0, 0, 0.4); ctx.fillRect(0, 0, canvas.width, canvas.height);另外fillRect 的坐标和宽高允许负数甚至越界。比如 fillRect(50, 50, -30, 20) 会从 (50, 50) 向左画出一个 30 宽的矩形。也就是说当宽为负时实际左上角会变成 x w 20。这个行为我不建议你日常依赖因为你得时刻做符号换算极易出错。如果你需要动态计算矩形区域比如拖拽出选区正确的做法是用 Math.min 和 Math.max 把左上角和宽高先算清楚再交给 fillRect。3. 第二招 strokeRect只描边不填充3.1 描边样式strokeStyle 与 lineWidthstrokeRect(x, y, width, height) 的调用方式跟 fillRect 一样区别是它只画边框不填充内部。边框颜色由 strokeStyle 控制粗细由 lineWidth 控制线帽样式由 lineCap 控制虚线则由 setLineDash 控制。ctx.strokeStyle #1677ff; ctx.lineWidth 2; ctx.strokeRect(20, 20, 200, 100);有个经常被忽略的细节lineWidth 是以路径为中心线向两侧扩张的。strokeRect(20, 20, 200, 100) 在 lineWidth 2 时视觉上的外边缘其实在 x 19 到 x 221左右各扩大 1px。如果你的矩形贴住画布边缘那一侧的边框会被裁掉一半看起来粗细不均匀。这个问题我在第 5 章会给出具体的处理方式。3.2 fillRect 加 strokeRect先填后描参数必须一致实际项目里填充加描边的组合非常常见比如卡片、按钮、头像框。最简单的做法是先 fillRect 后 strokeRect再用同一个矩形对象存几何尺寸避免手工算两遍const box { x: 50, y: 50, w: 200, h: 80 }; ctx.fillStyle #e6f4ff; ctx.fillRect(box.x, box.y, box.w, box.h); ctx.strokeStyle #1677ff; ctx.lineWidth 2; ctx.strokeRect(box.x, box.y, box.w, box.h);我见过不少人在这里出问题填充用一个 x/y描边为了视觉好看自己再加 1px结果边框像盖章盖歪了。其实当 lineWidth 是 2 时描边本来就向外扩了 1px 向内收了 1px你不需要手动补偿如果你觉得边框切到了内容需要调整的是矩形本身的位置而不是描边参数。3.3 虚线框与状态污染用完记得重置strokeRect 配合 setLineDash 能画虚线这种样式在设计工具里常用来表示选中区域ctx.setLineDash([6, 4]); ctx.strokeStyle #1677ff; ctx.strokeRect(50, 50, 180, 90); ctx.setLineDash([]); // 立刻重置否则后面所有描边都是虚线重点提醒setLineDash 和 fillStyle 一样是持续性状态它不会自动恢复。画完虚线如果不重置后面画的任何描边图形都会继承虚线效果。这种状态污染在 Canvas 里特别容易踩建议每次绘制前显式设置要用的状态而不是依赖上一次的值。3.4 想要圆角矩形原生 roundRect 了解一下严格来说fillRect 和 strokeRect 只能画直角矩形。如果你需要圆角卡片以前只能用路径手动画四个圆弧代码又长又容易算错。现代浏览器里可以直接用 roundRect 方法它也属于路径 API和 rect 一样需要配合 beginPath、fill、stroke 使用ctx.beginPath(); ctx.roundRect(50, 50, 200, 80, 12); ctx.fill(); ctx.stroke();第三个参数半径可以是单个数字也支持数组[左上, 右上, 右下, 左下]分别控制四个角的弧度。兼容性在现代浏览器已经足够好。等理解了第 4 章的路径机制你会发现 roundRect 的用法和 rect 完全一致只是多了圆角参数。4. 第三招 rect先记账再统一绘制4.1 rect 与 fillRect/strokeRect 的本质区别fillRect 和 strokeRect 是一条龙服务调用完直接上屏而 rect(x, y, width, height) 只是往当前路径集合里追加一个矩形描述屏幕上暂时什么都没有直到你调用 ctx.fill() 或 ctx.stroke()Canvas 才会把路径里的内容统一画出来。一个好懂的类比fillRect 像便利店买即饮咖啡拿到就能喝rect 像买咖啡豆先记录我要一杯咖啡这个订单之后再统一烘焙冲泡。所以 rect 更适合需要先组合一批图形、再一次性提交的场景。4.2 忘记 beginPath路径累积会把你画糊新手在 rect 上踩得最狠的坑就是忘记调用 beginPath。看这段代码ctx.strokeStyle #000; ctx.lineWidth 2; for (let i 0; i 10; i) { ctx.rect(i * 20, 0, 10, 10); ctx.stroke(); }你本意是画 10 个小方块结果会越描越黑、边线糊成一片。原因是每次 rect 都把新矩形追加到已有路径里第二次循环时路径里有两个矩形stroke() 会把两个矩形的所有轮廓一起描一遍重叠边界被反复画到第 10 次边框叠了十层颜色自然深成黑团。解决办法是每轮开始前调用 ctx.beginPath()清空当前路径for (let i 0; i 10; i) { ctx.beginPath(); ctx.rect(i * 20, 0, 10, 10); ctx.stroke(); }写作文类比beginPath 是换一张干净纸rect 是打草稿stroke 是上色。不 beginPath 等于一直在一张纸上叠着画越描越乱。4.3 beginPath 不会重置 fillStyle但会清掉什么有些同学会担心beginPath 会不会把颜色、线宽也重置掉不会。它只清空路径数据fillStyle、strokeStyle、lineWidth 这些绘制状态全部保留。所以你可以把样式设置放在循环外面循环里只做 beginPath、rect、stroke既安全又高效ctx.strokeStyle #999; ctx.lineWidth 1; for (let i 0; i 10; i) { ctx.beginPath(); ctx.rect(i * 20, 0, 10, 10); ctx.stroke(); }那 beginPath 到底清了什么它清空的是内部路径数组——你之前累积的所有 moveTo、lineTo、rect、arc 等子路径都会被扔掉。换句话说它让绘制从零开始。4.4 三招对比什么时候该用谁到这里三招都介绍完了我用一张表做个总结方法参数是否立即绘制适合场景fillRectx, y, w, h是直接填充背景、色块、遮罩strokeRectx, y, w, h是直接描边边框、网格、选中框rectx, y, w, h否需配合 fill/stroke批量图形、动画、复杂路径简单判断标准只是画一个静态矩形用 fillRect 或 strokeRect要在循环里画很多矩形、或者要跟其他路径组合就用 rect beginPath fill/stroke。用错了也能出效果但代码会绕很多弯路。5. 避坑指南像素模糊、高清屏与动画拖影5.1 1px 线条为何变成 2px把坐标对准像素中心这是 Canvas 新手问得最多的问题同样的 lineWidth 1有人画出细线有人画出 2px 灰线。原因在于Canvas 的坐标值对齐的是像素网格的线不是像素中心。当坐标为整数时笔刷中心正好落在网格线上向两侧各扩展 0.5px于是这两个 0.5px 的半透明像素都被点亮视觉上就是一条灰蒙蒙的 2px 线。而当坐标是 x 20.5 时笔刷中心落在某个像素中央这一个像素被完整覆盖才能看到清晰的 1px 细线。所以画 1px 线的经验法则是让坐标带上 .5。矩形描边同样适用用 strokeRect(20.5, 20.5, 100, 50) 会比 strokeRect(20, 20, 100, 50) 清晰得多。如果你在循环里生成坐标可以先用 Math.round 再 0.5。5.2 高清屏发虚devicePixelRatio 适配在高 DPR 的屏幕上普通 Canvas 内容会发虚原因是绘图缓冲区的物理像素数小于屏幕物理像素数浏览器被迫做了放大插值。标准的适配方案是让 canvas.width 等于 CSS 尺寸乘以 devicePixelRatio再用 ctx.scale 把绘图坐标系放大回 CSS 像素const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; ctx.scale(dpr, dpr);注意顺序先设置 canvas.width/height再调用 ctx.scale。如果你先 scale 再改 canvas.widthscale 会因画布重置而失效——因为改变 canvas.width 会自动清空画布并重置所有状态。另一个经验是把适配和重绘逻辑封装成 draw() 函数每次窗口尺寸变化时重新设置 canvas 尺寸并调用 draw()。5.3 动画拖影每帧先 clearRect 再重绘如果你用 requestAnimationFrame 做动画常见的错误是每帧只画新矩形、不清旧图形于是画面像拖着一条影子。正确的重绘流程是清屏、更新状态、绘制、请求下一帧function draw(time) { ctx.clearRect(0, 0, canvas.width, canvas.height); const x (time / 16) % (canvas.width - 30); ctx.fillStyle #1677ff; ctx.fillRect(x, 40, 30, 30); requestAnimationFrame(draw); } requestAnimationFrame(draw);clearRect 的四个参数同样也是 x、y、宽、高所以你可以只清除局部区域来优化性能。但萌新阶段直接整块 clearRect 通常够用现代浏览器会做不少优化。真正要注意的是clearRect 只清成透明如果你画了背景色清屏之后要先重新画背景再画前景内容。5.4 状态遗忘与 save/restore 的使用边界Canvas 不是声明式 UI它没有重置到默认样式这回事。fillStyle、strokeStyle、lineWidth、setLineDash 这些状态一旦设置就会一直保留到下次被修改。我见过不少新手在某个函数里设置了红色又在另一个函数里画图结果所有图形都是红的最后才发现是状态残留。解决办法很简单每个绘制函数开头把你需要的所有样式重新设置一遍不要依赖上一次的残留值。这样即使以后重构代码也不会突然被某个遥远的 fillStyle影响。另外Canvas 提供了 ctx.save() 和 ctx.restore() 用来做状态快照。save() 把当前的 fillStyle、strokeStyle、lineWidth、setLineDash 等状态压入一个栈restore() 再弹出来恢复。比如你在函数开头 save()函数末尾 restore()函数里怎么改样式都不影响外面。但要注意save/restore 只针对样式状态不会把你画好的像素保存下来——它是画笔设置的快照不是画作的快照。需要保存像素时用离屏 canvas 才是正确方向。6. 实战组合用三招拼一个可拖拽的框选工具6.1 需求拆解把前面三招组合起来做一个图片裁剪里常见的鼠标拖拽出选区小工具。需求很简单在画布上按住鼠标左键拖动会出现一个半透明遮罩遮罩中间跟着出现一个可调整的矩形选区框松开鼠标选区停止更新。交互上需要监听三个事件mousedown 记录起始点mousemove 根据当前位置重绘mouseup 结束拖拽。绘制上的核心就是把 fillRect 和 strokeRect 配合使用先用 fillRect 铺半透明遮罩再用 clearRect 把选区那块擦透明最后用 strokeRect 画蓝色边框。这里 clearRect 的挖空思路是比计算反选区域简单得多的方案。6.2 完整可运行代码!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleCanvas 选区框选 demo/title style canvas { cursor: crosshair; border: 1px solid #ddd; } /style /head body canvas idbox width600 height360/canvas script const canvas document.getElementById(box); const ctx canvas.getContext(2d); let dragging false; let start { x: 0, y: 0 }; function drawBackground() { ctx.save(); ctx.fillStyle #fafafa; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle #ddd; ctx.lineWidth 1; for (let i 0; i canvas.width; i 20) { ctx.beginPath(); ctx.moveTo(i 0.5, 0); ctx.lineTo(i 0.5, canvas.height); ctx.stroke(); } for (let j 0; j canvas.height; j 20) { ctx.beginPath(); ctx.moveTo(0, j 0.5); ctx.lineTo(canvas.width, j 0.5); ctx.stroke(); } ctx.restore(); } function drawSelection(startX, startY, endX, endY) { drawBackground(); ctx.fillStyle rgba(0, 0, 0, 0.2); ctx.fillRect(0, 0, canvas.width, canvas.height); const x Math.min(startX, endX); const y Math.min(startY, endY); const w Math.abs(endX - startX); const h Math.abs(endY - startY); ctx.clearRect(x, y, w, h); ctx.strokeStyle #1677ff; ctx.lineWidth 2; ctx.strokeRect(x, y, w, h); } canvas.addEventListener(mousedown, (e) { dragging true; start.x e.offsetX; start.y e.offsetY; }); canvas.addEventListener(mousemove, (e) { if (!dragging) return; drawSelection(start.x, start.y, e.offsetX, e.offsetY); }); canvas.addEventListener(mouseup, () { dragging false; }); /script /body /html把这段代码保存成 HTML 文件直接用浏览器打开就能跑。6.3 代码拆解为什么这么画重点看 drawSelection 里的几个细节。第一用 Math.min 和 Math.abs 把开始点、结束点统一转换成左上角加宽高的形式。这样即使用户从右下角往左上角拖选区也不会变形。第二遮罩用 fillRect 全屏铺一层 rgba 黑色再用 clearRect 把选区擦掉相当于先盖布再掏洞这是 Canvas 图形编辑器里非常流行的选区表现手法比起手动计算哪些区域盖哪些区域简单可靠得多。第三整个绘制流程是重绘整张画布。mousemove 每触发一次就调用 drawSelection虽然看起来是反复重画但在这个复杂度下完全没问题代码也比局部擦除更容易维护。等以后项目复杂了再考虑用离屏画布保存背景进一步优化性能。6.4 两个能直接用的小优化顺手再分享两个实用优化。第一个是框太小就不算选区在 mousemove 里判断 w 或 h 小于 4 就不画边框避免用户点击时出现一个 1×1 的噪点。第二个是移动端适配桌面端的 mousedown/mousemove/mouseup 在触摸屏上不生效。最简单的改法是监听 pointerdown、pointermove、pointerup并用 e.offsetX 或 e.clientX 换算坐标。如果你想在拖拽期间还能滚动页面需再结合 CSS touch-action 处理这里不展开但方向是明确的。另外如果你希望框选区域显示宽 × 高的文字提示可以在 drawSelection 里继续用 fillText 画一行小字同样记得先设置 fillStyle 和 font。最后我再分享一个调试经验矩形画出来位置不对时第一件事不是改参数而是在浏览器控制台里打印 canvas.width、canvas.height以及上下文的基本状态也可以用 getImageData 去查看某个像素的颜色验证到底画没画到预期位置。Canvas 不像 DOM 那样能直接检查元素但养成先查尺寸和状态、再怀疑绘制逻辑的习惯能帮你省下大把时间。这三招加这份避坑指南差不多就是我踩了无数次坑之后沉淀下来的全部心法了。希望你也能把基础夯扎实矩形画明白了后面的弧形、路径、图像处理都会越走越顺。