ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Canvas画矩形全攻略:fillRect、strokeRect、rect()到底怎么用

Canvas画矩形全攻略:fillRect、strokeRect、rect()到底怎么用 先别急着背 API。很多前端新手第一次接触 Canvas都是从“画一个矩形”开始的结果一看文档更懵了fillRect、strokeRect、rect()三个东西长得很像到底用哪个画出来为什么边缘发虚为什么描边矩形总有一半被裁掉这篇文章就把 Canvas 画矩形这件事拆透。不是简单列 API而是带你把坐标、路径、变换、清屏这几条线一次性理顺。不管你之后是做数据可视化、H5 小游戏还是给页面加截图标注功能这三招都够用。内容按真实开发顺序来写先搞懂底层逻辑再上手三个画法最后把常见坑一次性列清楚。看完你至少能少走一个月的弯路。1. 先把坐标系和三个 API 的关系搞清楚1.1 Canvas 的坐标系和数学课本不一样很多人画矩形画歪不是算法问题是坐标系没转过弯来。Canvas 2D 的坐标系默认原点在画布左上角也就是(0, 0)位置。x 轴向右递增y 轴向下递增。这跟数学课上“左下角原点、y 轴向上”的习惯完全相反。举个最直观的例子ctx.fillRect(0, 0, 100, 100)画出来的矩形是在画布左上角往右下方向延伸。如果你心里还在用“第一象限右上角”的直觉大概率会把矩形画到画布外面去然后对着空白画布发呆。坐标系还不只是方向问题它还影响你后面处理缩放、旋转、像素对齐。特别是高 DPI 屏幕出现时坐标系里的一个单位不一定等于屏幕上的一个物理像素。这个问题我放到第五节专门讲因为它是“矩形边缘模糊”的头号元凶。1.2 三个 API 的真实关系先给结论fillRect和strokeRect是“一次性画法”内部帮你把路径、填充、描边全包了。而rect()只是建立一个矩形路径它自己什么都不画必须配合fill()或stroke()才会出现在画布上。可以这么理解rect()是“画草图”fillRect是“直接涂完”strokeRect是“直接描边”。// 方式一直接填充 ctx.fillStyle #4A90E2; ctx.fillRect(20, 20, 160, 90); // 方式二直接描边 ctx.strokeStyle #D0021B; ctx.lineWidth 4; ctx.strokeRect(20, 120, 160, 90); // 方式三先建路径再统一处理 ctx.beginPath(); ctx.rect(20, 230, 160, 90); ctx.fillStyle #7ED321; ctx.fill(); ctx.strokeStyle #4A4A4A; ctx.stroke();fillRect和strokeRect的好处是代码少、读起来直白适合画单个静态矩形。但缺点是每次调用都会立刻绘制如果你想先把一堆矩形路径收集起来再用一条stroke()统一描边它帮不上忙。这时候就得用rect()。1.3 前端面试最爱问的那条线这个问题我见过至少三次出现在 Canvas 相关的面试题里“fillRect和rect()fill()的区别是什么”标准回答要抓住两点fillRect是独立绘制命令会立即在画布上填充一个矩形不参与当前路径。rect()只是往当前路径里添加一个矩形子路径后续必须调用fill()或stroke()而且可以一次性处理路径里的多个矩形。如果面试官继续追问“多个矩形用哪种方式性能好”答案不是单纯“用rect()更好”而是看场景。如果矩形互不相交、都是纯色填充fillRect的循环写法也没问题。如果矩形很多而且需要同一种描边样式推荐用路径合并beginPath() 循环rect() 一次stroke()。减少状态切换和路径提交次数渲染性能会好很多。2. 第一招用 fillRect / strokeRect 直接画适合静态小场景2.1 最短的矩形绘制代码先看最基础的一段const canvas document.getElementById(demo); const ctx canvas.getContext(2d); ctx.fillStyle #4A90E2; ctx.fillRect(30, 30, 200, 80);这一行代码的含义是从坐标(30, 30)开始画一个宽200、高80的矩形并填充成蓝色。fillRect的四个参数顺序是x, y, width, height。注意这里的width和height不是终点坐标而是尺寸。我见过新手把参数理解成(x1, y1, x2, y2)结果画出来的矩形尺寸完全不对这是第一个很容易踩的坑。strokeRect的参数和fillRect完全一样只是它画的是边框。ctx.strokeStyle #D0021B; ctx.lineWidth 2; ctx.strokeRect(30, 30, 200, 80);这里有个隐藏问题strokeRect的边框线是沿着矩形边界“骑墙”画的。什么意思呢lineWidth为 2 时线宽有一半落在矩形内部一半落在矩形外部。如果矩形贴在画布边缘外半部分会被裁掉视觉上看起来线宽变细了。2.2 描边矩形为什么总像被“吃掉一半”这个问题非常经典也常被当作“老手和新手的分界线”。假设画布是 200 x 200执行ctx.strokeStyle #333; ctx.lineWidth 4; ctx.strokeRect(0, 0, 100, 100);你会发现左上角两条边的线宽看起来只有 2px外半部分被画布边界裁掉了。解决办法有两种把起始坐标往内偏移半个线宽ctx.strokeRect(2, 2, 96, 96)。用ctx.translate(0.5, 0.5)做全局像素偏移解决单像素线模糊的问题。第二种方法主要针对lineWidth 1的情况。因为 1px 的线如果刚好画在整数坐标上会同时覆盖两个物理像素的一半屏幕上看起来发灰、模糊。偏移 0.5px 后线就能刚好落在像素网格上看起来才够“锐利”。2.3 先填充还是先描边顺序真的会翻车假如你画了一个矩形想让它既有填充色又有红色边框ctx.fillStyle #FFF; ctx.strokeStyle #F00; ctx.lineWidth 8; ctx.fillRect(40, 40, 120, 60); ctx.strokeRect(40, 40, 120, 60);先填充后描边边框完整压在填充色上面效果正常。如果把顺序反过来ctx.strokeRect(40, 40, 120, 60); ctx.fillRect(40, 40, 120, 60);填充会把描边内侧的一半盖掉最后你看到的边框只有外侧一半颜色还会变浅。原因就是 Canvas 是“后画的盖先画的”不是像 PS 里那样图层面板自动分层。所以我的习惯是带边框的矩形永远先fill后stroke。这条经验在画地图、画格子、画 UI 控件的时候都适用能避免很多“看起来不对劲”的诡异效果。3. 第二招用 rect() 路径统一处理复杂场景的关键3.1 rect() 不是“直接画”而是“往路径里加”先说一句最关键的ctx.rect()本身不显示任何东西。它只是把一个矩形区域追加到当前路径中。之后你调fill()就会把所有路径围起来的区域一次性填充调stroke()就会把所有路径一次性描边。这带来一个非常大的好处可以用同一个描边样式画大量矩形而不需要每画一个就重新设置一次lineWidth、strokeStyle。ctx.strokeStyle #999; ctx.lineWidth 1; ctx.beginPath(); for (let i 0; i 5; i) { ctx.rect(10 i * 60, 10, 50, 50); } ctx.stroke();这段代码会画出 5 个独立的空心方块但只调用了一次stroke()而且整体看起来边框单位一致。如果你用strokeRect写循环代码会重复设置样式累赘不说性能也差一些。3.2 用路径批量画网格效率立刻上一个台阶路径的好处在“画网格”这种场景里体现得最明显。假设你要画一个 10 x 8 的棋盘格const cols 10; const rows 8; const cellSize 30; ctx.strokeStyle #CCC; ctx.lineWidth 1; ctx.beginPath(); for (let i 0; i cols; i) { for (let j 0; j rows; j) { ctx.rect(i * cellSize, j * cellSize, cellSize, cellSize); } } ctx.stroke();一次性把 80 个矩形路径全部塞进去再统一描边。这比循环执行 80 次strokeRect要清爽很多也方便后续整体加上旋转或者缩放变换。当然路径不是没有代价。如果每个矩形的填充色不同用rect() 统一fill()就不太合适了因为fillStyle是全局状态。这时候要么把矩形分组要么直接逐个fillRect。3.3 封装一个自己的 drawRect工作里更顺手实际项目里我建议你在rect()基础上封装一个简单的函数把常用参数收敛起来function drawRect(ctx, options) { const { x, y, width, height, fill, stroke, lineWidth 2, radius 0 } options; ctx.beginPath(); if (radius 0) { // 圆角矩形用 arcTo 模拟 const r Math.min(radius, width / 2, height / 2); ctx.moveTo(x r, y); ctx.arcTo(x width, y, x width, y height, r); ctx.arcTo(x width, y height, x, y height, r); ctx.arcTo(x, y height, x, y, r); ctx.arcTo(x, y, x width, y, r); ctx.closePath(); } else { ctx.rect(x, y, width, height); } if (fill) { ctx.fillStyle fill; ctx.fill(); } if (stroke) { ctx.strokeStyle stroke; ctx.lineWidth lineWidth; ctx.stroke(); } }为什么要把封装写在路径方案这一节因为本质上“函数封装 路径绘制”才是 Canvas 实战的正确姿势。复杂图形不可能靠裸写fillRect完成你总会需要圆角、描边、透明度、宽度的统一控制。4. 第三招用 transform matrix() 让矩形动起来4.1 画矩形不只有“画”本身还有“变”很多前端新手学会fillRect之后发现下一个难题是怎么让矩形旋转、缩放、倾斜直接改width和height只能做等比缩放做不到旋转。想要让矩形绕中心点转起来必须借助变换。Canvas 里的变换最常用的是三个方法translate(x, y)平移原点。rotate(angle)绕当前原点旋转单位是弧度。scale(sx, sy)缩放。旋转矩形的标准套路是先平移再旋转然后让矩形以自身中心为基准绘制ctx.save(); ctx.translate(centerX, centerY); ctx.rotate(Math.PI / 6); // 30度 ctx.fillStyle #4A90E2; ctx.fillRect(-width / 2, -height / 2, width, height); ctx.restore();这段代码最关键的是fillRect(-width / 2, -height / 2, width, height)。因为原点已经被translate移到了矩形中心所以绘制矩形时 x、y 要取负的一半宽高否则矩形会以“左上角”为轴旋转效果完全不对。4.2 matrix() 里的六个参数到底怎么算这个问题也是热搜词里反复出现的“transform 中 matrix() 矩形变换如何用”。Canvas 里对应的是setTransform(a, b, c, d, e, f)。它的含义是坐标变换为新 x a * x c * y e新 y b * x d * y f简化理解a 和 d 控制 x、y 方向的缩放b 和 c 控制倾斜e 和 f 控制平移。以旋转为例如果旋转角度为 θconst cos Math.cos(angle); const sin Math.sin(angle); ctx.setTransform(cos, sin, -sin, cos, centerX, centerY);这个矩阵等价于“先旋转再平移到 centerX, centerY”。如果你要斜切、镜像、不规则拉伸直接改 a、b、c、d 就能做到。不过setTransform()有个特性它会把之前的变换覆盖掉而不是叠加。所以用之前最好save()用完了restore()否则会影响后续绘制。4.3 实战让卡片矩形产生呼吸感我做 H5 活动页的时候经常要画一个带圆角的卡片让它每隔几秒轻微放大缩小。用变换实现非常顺。let scale 1; let direction 1; function animate() { const canvas document.getElementById(card); const ctx canvas.getContext(2d); // 清空画布 ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.restore(); // 更新缩放 scale direction * 0.005; if (scale 1.05) direction -1; if (scale 0.95) direction 1; ctx.save(); ctx.translate(canvas.width / 2, canvas.height / 2); ctx.scale(scale, scale); ctx.fillStyle rgba(74, 144, 226, 0.85); ctx.fillRect(-90, -60, 180, 120); ctx.restore(); requestAnimationFrame(animate); } animate();这里的重点是requestAnimationFrame每一帧都要先clearRect再重新绘制。如果不清理上一帧的矩形会残留在画布上形成拖影。5. 避坑指南这些画不出来的坑我全踩过5.1 矩形边缘发虚先检查 DPR在 Retina 屏、手机高分屏上Canvas 很容易出现“文字模糊、矩形边缘发虚”的问题。原因不是代码写错而是 Canvas 的物理像素和 CSS 像素不一致。解决办法是手动让画布分辨率匹配设备像素比function setupCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; // 让画布物理像素放大 canvas.width Math.round(cssWidth * dpr); canvas.height Math.round(cssHeight * dpr); canvas.style.width cssWidth px; canvas.style.height cssHeight px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); return ctx; }之后你依然用 CSS 像素坐标来画矩形例如fillRect(10, 10, 100, 50)最终画出来的内容会清晰很多。注意setupCanvas只需要执行一次不要放在需要渲染每一帧的函数里否则scale会不断叠加最后矩形直接“飞出屏幕”。5.2 清屏只清一半十有八九是变换没复位动画场景里清屏用的都是clearRect(0, 0, canvas.width, canvas.height)。但如果当前上下文还处于旋转、缩放过的状态这个clearRect清除的是“当前变换后的区域”不一定能清掉整个画布。最稳的做法是清屏前先把变换矩阵重置ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.restore();在你的setupCanvas里已经ctx.scale(dpr, dpr)的情况下这个写法稍作调整也可以。核心思路是清屏用物理像素范围画矩形用 CSS 像素坐标。5.3 阴影和全局透明度是低性能卡顿的常见来源如果给每一帧动态矩形都设置ctx.shadowBlur 20会非常消耗性能。尤其是移动端低端机大概率掉帧。我的建议是静态场景随便用动态场景少用。如果一定要阴影优先考虑用预渲染的离屏 Canvas 或者直接改用 DOM/CSS 的box-shadow。画布上的矩形动画尽量靠清晰边缘和背景色对比来体现效果不要迷信“加阴影更高级”。5.4 常见问题速查表下面这张表是我做程序化生成图形时反复用到的问题排查清单直接按症状对号入座症状可能原因处理方法矩形画出来是空的用了rect()但没调用fill()或stroke()补上fill()/stroke()边框线发灰lineWidth 1画在整数坐标上坐标偏移 0.5px或全局translate(0.5, 0.5)边框线粗细不均矩形贴在画布边缘线宽被裁切矩形向内偏移半个线宽填充色盖住边框先stroke()后fill()改为先fill()后stroke()动画有拖影每帧没有正确清屏用clearRect或重置变换后清屏高 DPI 屏幕模糊画布物理像素不够用devicePixelRatio放大画布旋转中心不对fillRect用的还是左上角坐标平移后让矩形中心在原点画缩放几次后矩形消失scale不断叠加使用save/restore或setTransform覆盖6. 从矩形到实战前端项目里的正确姿势6.1 用 Canvas 画一个简单进度条矩形最常见的实战场景之一是进度条。虽然通常用 CSS 更简单但如果要带复杂纹理、异形圆形、动态波纹Canvas 就更有发挥空间。一个最基础的进度条稻作法是const canvas document.getElementById(progress); const ctx canvas.getContext(2d); const width 300; const height 12; const progress 0.65; canvas.width width; canvas.height height; // 背景条 ctx.fillStyle #EEE; ctx.fillRect(0, 0, width, height); // 进度条 ctx.fillStyle #4A90E2; ctx.fillRect(0, 0, width * progress, height);这里面最值得注意的陷阱是进度条数值来自用户操作或者接口返回时要先做区间限制。如果进度是 1.2width * progress会超过画布宽度画出来溢出画布视觉上可能直接消失。6.2 在 Vue / React 里用 Canvas 的三条铁律现在前端项目基本都绕不开 Vue 或 React。把 Canvas 塞进组件里有三个容易踩的坑。第一不要在组件的模板渲染阶段直接操作 Canvas。getContext必须等 DOM 挂载完成才能拿到所以 Vue 里放onMountedReact 里放useEffect。第二不要把绘制函数绑在响应式数据上频繁执行。比如 Vue 里reactive里的某个值一变就重绘很容易造成 Canvas 状态混乱。正确做法是数据更新时调用一个独立的重绘函数函数内部自己管理清屏和save/restore。第三组件卸载时要取消requestAnimationFrame。不然组件已经销毁回调还在继续画轻则报错重则内存泄漏。Vue 里可以参考这个模式import { ref, onMounted, onBeforeUnmount } from vue; const canvasRef ref(null); let rafId null; function draw() { const ctx canvasRef.value.getContext(2d); ctx.clearRect(0, 0, 300, 150); ctx.fillStyle #4A90E2; ctx.fillRect(10, 10, 120, 60); } onMounted(() { draw(); rafId requestAnimationFrame(draw); }); onBeforeUnmount(() { cancelAnimationFrame(rafId); });6.3 如果只是画几个矩形要不要上 Canvas最后说一个更实际的判断标准静态页面上偶尔画两个圆角卡片、分隔条、进度块优先用 CSS不要用 Canvas。Canvas 的优势是“状态化、可编程、体积小”适合海量图元、动态交互、跨端绘制。劣势是文本可访问性差、调试链路长、放大模糊需要额外处理。如果你的需求只是“页面里放一个蓝色方块”div加border-radius比 Canvas 不知道省心多少。但如果你要做类似图形编辑器、涂鸦板、数据大屏、小游戏这种高频绘制场景现在你就可以拿着今天这三招开干了先fillRect/strokeRect画出静态画面再用rect()合并路径提升性能最后用transform让矩形动起来。我个人做了几年 Canvas 相关项目后最大的感受是矩形看似简单但它是所有 2D 绘图的地基。你愿意花一小时把这几个 API 的差异、描边算法、像素对齐原理弄明白后面学路径、贝塞尔曲线、离屏渲染都会顺很多。下次再遇到“矩形看不清”“清屏没清掉”“旋转中心不对”这类问题先别怀疑数学回头检查一下坐标和变换状态。
返回列表