2026/10/21 7:13:12

Canvas圆角矩形绘制全攻略:从手写路径到原生roundRect与性能优化

Canvas圆角矩形绘制全攻略:从手写路径到原生roundRect与性能优化 直接用Canvas画图多多少少都会碰到圆角矩形的需求卡片、按钮、头像遮罩、弹窗背景、标签气泡到处都要它。标题里写的“Canvas画圆角矩形”看起来是个小问题但真要动手你会发现里面有不少参数边界和兼容性坑。尤其是早期很多环境并没有原生的roundRect接口你需要自己拼路径还要考虑半径掐上限、高清屏缩放、动画性能这些问题。这篇文章就把Canvas绘制圆角矩形的参数、步骤和高级技巧完整拆一遍。从最基础的坐标推导开始到可以上生产环境的封装函数再到裁剪、动画、离屏缓存这些进阶玩法还会整理我实际踩过的几个典型问题。适合刚接触Canvas的前端开发者也适合正在写图表库、可视化组件、H5页面的朋友。1. 圆角矩形的核心思路与API选型1.1 为什么圆角矩形不能直接画很多人都问过一个问题Canvas明明有rect()方法为什么没有一个roundRect()一步到位其实现在的现代浏览器已经把roundRect()补上了后面我会细讲。但更早的时候Canvas 2D的绘图模型是“路径”驱动的你用beginPath()开启一条路径然后用moveTo()定起点lineTo()画直线arcTo()画弧线最后fill()或stroke()。rect()只是帮你快速画了一个直角矩形的闭合路径圆角矩形需要你自己把直角替换成圆弧。你只要把圆角矩形的轮廓拆开看本质就是四段直线加四段90度的圆弧。比如一个左上角带圆角的矩形轮廓是顶部直线 → 右上角直线 → 右下角直线 → 底部直线 → 左下角直线 → 左侧直线 → 左上角圆弧 → 回到起点。所以手写圆角矩形的关键路径就是“直线 圆弧”的拼接。arcTo()在这里非常关键。很多人不熟悉它它并不直接画一个完整的弧而是接收三个参数arcTo(x1, y1, x2, y2, radius)。它的逻辑是以当前路径点为起点假想一条到(x1, y1)的直线再假想一条从(x1, y1)到(x2, y2)的直线然后画一段与这两条假想线都相切的圆弧半径由你指定。相切这个特性非常适合做圆角因为它天然能把两条呈角度的线平滑连接起来。1.2 三条技术路线的取舍不同人实现圆角矩形有不同的习惯我整理了三类常见方案方案优点缺点适用场景手写arcTo()兼容老环境可控制每个角代码量多容易漏moveTo需要兼容老旧WebView或自定义部分圆角手写quadraticCurveTo()写法直观拐角自然不是严格圆弧曲线略平做特殊“柔和圆角”效果原生ctx.roundRect()/Path2D.roundRect()代码极简支持数组分别设置四角需要特性检测旧环境不支持新项目或配合降级方案使用我的建议很直接优先用原生roundRect()但同时在代码里做特性检测检测失败就降级到手写函数。这样既享受新API的简洁又不会在低版本环境里暴雷。在某个图表组件项目里我就是这么干的一个十几行的检测函数加一个四十行的手写函数覆盖了所有运行环境。2. 手写圆角矩形的参数拆解2.1 从直角矩形到圆弧的路径推导先看最经典的手写版本。假设你要在Canvas上画一个左上角坐标为(x, y)宽为w高为h四个角圆角半径都为r的圆角矩形function drawRoundRect(ctx, x, y, w, h, r) { r Math.max(0, Math.min(r, Math.min(w, h) / 2)); ctx.beginPath(); ctx.moveTo(x r, y); ctx.arcTo(x w, y, x w, y h, r); ctx.arcTo(x w, y h, x, y h, r); ctx.arcTo(x, y h, x, y, r); ctx.arcTo(x, y, x w, y, r); ctx.closePath(); }一步步看。moveTo(x r, y)表示起点在顶部边线上距离左上角r的位置也就是右上角圆弧的起点附近。第一个arcTo(x w, y, x w, y h, r)当前点在顶部目标先到右上角顶点(x w, y)再到右下角(x w, y h)这段弧就把右上角的直角替换成了半径r的圆弧。后面的三个arcTo依次处理右下角、左下角、左上角。四个弧走完closePath()自动闭合路径就完整了。有个超常见的坑我必须提很多人抄代码时漏了moveTo(x r, y)这一步直接ctx.beginPath()然后连着四个arcTo。结果就是左上角冒出一条斜线因为arcTo()会从当前点默认是(0, 0)或者上一段路径的终点拉一条直线到目标点圆角矩形直接变成“四边形加一根尾巴”。2.2 圆角半径的约束和归一化圆角半径r看着只是个数字其实有硬约束r必须大于等于0且不能超过宽高的一半。也就是0 ≤ r ≤ min(w, h) / 2。如果不做这个限制拿一个宽100高50的矩形非要传r 40那弧与弧会互相打架图形会变成一个像饺子一样的怪异形状。这在我实际接到的业务需求里非常常见——半径来自运营配置人家填了个大数字页面就崩坏了。所以函数第一行我习惯先做一次归一化r Math.max(0, Math.min(r, Math.min(w, h) / 2));这里Math.min(w, h) / 2是半径的最大安全值。如果传入负数就按0处理相当于直角矩形如果传入超大值就按半短边处理保证图形还是一个正常的凸圆角矩形。2.3 现代APIroundRect的参数详解如果你不需要兼容很老的浏览器直接用原生方法就好。CanvasRenderingContext2D.roundRect()的签名是ctx.roundRect(x, y, width, height, radii);radii这个参数比想象中灵活它可以是数字也可以是数组10四个角都是10。[10, 20]左上和右下是10右上和左下是20。[10, 20, 30, 40]依次对应左上、右上、右下、左下。还支持嵌套数组比如[[10, 20], 0, 0, 0]表示某个角的横向和纵向半径分别不同。这种情况极少用但要知道有这个东西。一个实际例子const canvas document.getElementById(myCanvas); const ctx canvas.getContext(2d); ctx.beginPath(); ctx.roundRect(20, 20, 200, 100, 16); ctx.fillStyle #f5a623; ctx.fill();Path2D上也有一个roundRect()方法可以构建一个路径对象再传给fill或stroke。它的好处是路径可以复用方便后续做clip()裁剪const path new Path2D(); path.roundRect(20, 20, 200, 100, [16, 16, 0, 0]); ctx.fill(path);使用时建议先做一次能力检测避免在旧环境调用不存在的方法导致白屏const hasNativeRoundRect typeof ctx.roundRect function; const hasPath2DRoundRect typeof Path2D.prototype.roundRect function;3. 完整实操从直角矩形到圆角矩形的落地方案3.1 先跑一个最小可复现Demo空讲参数不够直观我直接给一个可以复制到浏览器里跑的完整HTML文件。这个Demo画了一个带圆角的矩形卡片背景和边框都带一点颜色!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleCanvas圆角矩形Demo/title style body { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #f0f2f5; } canvas { background: #ffffff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } /style /head body canvas iddemoCanvas width400 height300/canvas script const canvas document.getElementById(demoCanvas); const ctx canvas.getContext(2d); // 圆角矩形路径构建函数 function drawRoundRect(ctx, x, y, w, h, r) { r Math.max(0, Math.min(r, Math.min(w, h) / 2)); ctx.beginPath(); ctx.moveTo(x r, y); ctx.arcTo(x w, y, x w, y h, r); ctx.arcTo(x w, y h, x, y h, r); ctx.arcTo(x, y h, x, y, r); ctx.arcTo(x, y, x w, y, r); ctx.closePath(); } // 绘制一个带描边的圆角矩形卡片 drawRoundRect(ctx, 60, 50, 280, 180, 20); ctx.fillStyle #fdf6ec; ctx.fill(); ctx.strokeStyle #e6a23c; ctx.lineWidth 3; ctx.stroke(); // 在圆角矩形内部写一行文字 ctx.fillStyle #333; ctx.font 16px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(Canvas RoundRect Demo, 200, 140); /script /body /html这里要注意一个顺序问题所有路径构建和状态设置必须放在beginPath()之后。否则后果是路径叠加上一次画的圆角矩形轮廓还留在当前路径里这次填充就会把前后两个图形一起填进来画面上出现奇怪的“残影”。3.2 封装一个可复用的函数上面的drawRoundRect只处理四角半径相同的情况。实际项目里更常见的是按钮只有上方两个角是圆角、下方直角或者只有左上角带一个圆角。所以我会把路径构建和绘制动作拆开封装成更灵活的工具函数。一个建议是函数只负责构建路径不要在里面fill()或stroke()。为什么因为一旦函数内部直接填充你就没法拿这条路径去做clip()裁剪或自定义描边。路径构建和绘制分离使用起来才灵活。下面这个版本支持传入四个角不同的半径数组顺序是[左上, 右上, 右下, 左下]function buildRoundRectPath(ctx, x, y, w, h, radii) { let [tl, tr, br, bl] normalizeRadii(w, h, radii); ctx.beginPath(); ctx.moveTo(x tl, y); ctx.lineTo(x w - tr, y); ctx.arcTo(x w, y, x w, y tr, tr); ctx.lineTo(x w, y h - br); ctx.arcTo(x w, y h, x w - br, y h, br); ctx.lineTo(x bl, y h); ctx.arcTo(x, y h, x, y h - bl, bl); ctx.lineTo(x, y tl); ctx.arcTo(x, y, x tl, y, tl); ctx.closePath(); } function normalizeRadii(w, h, radii) { const max Math.min(w, h) / 2; const parsed Array.isArray(radii) ? radii : [radii, radii, radii, radii]; return parsed.map(r Math.max(0, Math.min(r || 0, max))); }这种写法的核心变化是用lineTo显式画直线用arcTo只负责弧线部分。每个角都是“线 → 弧 → 线”的节奏所以你可以轻松让某个角的半径传0实现只有部分圆角的效果。很多“聊天气泡”就是右下角半径设为0其他三个角用圆角视觉效果一下子就出来了。3.3 高清屏适配与圆角边缘清晰度圆角矩形画完发现边缘发虚、有锯齿绝大多数情况不是Canvas自身抗锯齿的问题而是canvas的物理尺寸和CSS显示尺寸不一致导致的缩放模糊。常见场景是CSS里写了width: 400px; height: 300px;但canvas标签属性只写了width200 height150。这时浏览器会把200x150的画布拉伸到400x300显示本来1:1的像素变成了1:2任何线条和圆角都会变糊。正确做法是先拿到设备的devicePixelRatio让canvas的物理像素宽度等于CSS宽度乘以dpr再用setTransform把所有绘制坐标缩放为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.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; } const canvas document.getElementById(myCanvas); const ctx setupCanvas(canvas, 400, 300); // 之后所有坐标都按400x300的逻辑尺寸写圆角边缘会清晰很多注意如果你在setupCanvas之后又手动改了canvas.width之前的setTransform会被重置需要重新设置。每次resize后都要重新走一遍这个逻辑。3.4 渐变填充、阴影与组合效果圆角矩形不一定是纯色填充。做一个有质感的小卡片可以给圆角矩形加线性渐变和投影。投影用Canvas的shadowBlur实现function drawCard(ctx, x, y, w, h, r) { buildRoundRectPath(ctx, x, y, w, h, r); const gradient ctx.createLinearGradient(x, y, x, y h); gradient.addColorStop(0, #ffffff); gradient.addColorStop(1, #f0f0f0); ctx.shadowColor rgba(0, 0, 0, 0.15); ctx.shadowBlur 12; ctx.shadowOffsetY 4; ctx.fillStyle gradient; ctx.fill(); // 用完阴影记得重置否则后续绘制都会被阴影影响 ctx.shadowColor transparent; ctx.shadowBlur 0; ctx.shadowOffsetY 0; }这里有一个性能相关的提醒shadowBlur会大幅增加Canvas渲染的消耗。如果只是静态画一次完全没问题但如果每一帧都在画画面有大量卡片或者要跟随鼠标移动那阴影会变成一场性能灾难卡顿到怀疑人生。动画场景里建议用预渲染的阴影贴图替代或者直接放弃Canvas阴影用一张已经带阴影的图片资源。4. 高级技巧动画、裁剪与性能优化4.1 用圆角矩形路径做头像裁剪遮罩Canvas的clip()可以把绘制范围限制在当前路径内。圆角矩形路径最常见的高级应用就是裁剪图片让图片的四角变成圆角。基本步骤是保存状态 → 构建圆角矩形路径 →clip()→ 绘制图片 → 恢复状态。每一步都不能少function clipRoundImage(ctx, image, x, y, w, h, r) { ctx.save(); buildRoundRectPath(ctx, x, y, w, h, r); ctx.clip(); ctx.drawImage(image, x, y, w, h); ctx.restore(); }save()和restore()在这里不是可选项。clip()一旦生效后面所有绘制都会被限制在圆角矩形内部如果不恢复你接下来画的所有内容都会“缺角”。clip()还会受当前路径和变换矩阵影响所以状态保存要养成习惯。裁剪完图片后如果还想加一圈圆角描边就在restore()之后重新构建路径再stroke()否则描边也只有内部一半。4.2 圆角进度条与动态半径动画圆角矩形很适合做进度条、加载柱子、呼吸按钮。做动画时不需要什么复杂的数学公式只要能改变宽度或半径配合requestAnimationFrame就够。这里给一个“圆角从0生长到20像素”的动画示例效果很像按钮从直角渐渐变成圆角的过渡const canvas document.getElementById(animCanvas); const ctx canvas.getContext(2d); const duration 600; const startTime performance.now(); function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } function animate(currentTime) { const elapsed currentTime - startTime; const progress Math.min(elapsed / duration, 1); const eased easeOutCubic(progress); const radius 40 * eased; ctx.clearRect(0, 0, canvas.width, canvas.height); buildRoundRectPath(ctx, 60, 80, 240, 100, radius); ctx.fillStyle #4f9dde; ctx.fill(); if (progress 1) { requestAnimationFrame(animate); } } requestAnimationFrame(animate);clearRect放在每次绘制前避免上一帧残留。用easeOutCubic做缓动是因为直接从0到40线性增长会显得很机械带缓动之后整个动画有“落下来”的弹性感视觉上更舒服。这个思路也适用于元素弹窗出现、卡片浮现等场景。4.3 性能优化离屏Canvas与路径复用圆角矩形看着简单但如果你在一个大屏可视化页面里一次性画几千个一样会卡。性能优化有三个方向按性价比从高到低排列。第一个是静态内容用离屏Canvas缓存。把不会变的圆角矩形先画在一个隐藏的canvas上然后每次主循环只需要一次drawImage()把这整块画布贴过来。这个方案能让绘制成本从几千次路径填充降到一次位图拷贝。代码大概是const offscreen document.createElement(canvas); offscreen.width 400; offscreen.height 300; const offCtx offscreen.getContext(2d); // 在offCtx上画好所有静态圆角矩形 // 主循环里 ctx.drawImage(offscreen, 0, 0);第二个是避免每帧重复创建Path2D。如果每帧都要画一个位置或大小变化的圆角矩形你可以复用同一个Path2D对象调它的roundRect()覆盖参数。这个技巧能把路径分配带来的垃圾回收压力降下来肉眼不一定看得出来但遇到连续动画时能明显减少卡顿。第三个是减少Canvas状态切换。频繁设置fillStyle、shadowBlur、lineWidth都会造成内部状态验证开销。可以把相同样式的图形先归组绘制再统一切换样式。4.4 每个角不同半径的自由组合很多场景下四个角半径都不一样比如“一个标签左上和右下角圆右上和左下角直角”这种形状很像某些潮流UI里的斜切标签。用原生API可以传数组ctx.beginPath(); ctx.roundRect(20, 20, 160, 60, [20, 0, 20, 0]); ctx.fill();如果不用原生API也可以用前面写的buildRoundRectPath(ctx, x, y, w, h, radii)手写。两种方式都支持分别控制四个角。区别在于手写方案里每个角的弧线相切点是固定从lineTo进入的逻辑更直观也更容易调试。在组件库开发中我强烈建议把“四角半径数组”作为配置项暴露给业务方而不要只暴露一个统一radius。理由很简单产品经理随时可能说“这个卡片只要上面两个角圆”如果你只支持统一半径就得临时改库很被动。5. 常见问题与排查技巧实录5.1 左上角出现一条斜线这是初次手写圆角矩形时最常见的翻车现场。现象是图形的左上角附近莫名其妙多了一条对角线。原因几乎只有一个没有先moveTo()到圆弧起点导致arcTo()从上一个点拉了一条直线过来。排查方法很简单检查路径构建第一行是不是ctx.moveTo(x r, y)。如果你使用了ctx.beginPath()之后直接arcTo那默认当前点在(0, 0)自然会从画布左上角连一条线过来。5.2 圆角边缘发虚、有锯齿先检查画布物理尺寸和CSS尺寸是否一致。最常见的是canvas标签的width/height没有按devicePixelRatio放大导致浏览器把低分辨率画布拉伸到大尺寸显示。按前面setupCanvas的方案处理后问题基本能解决。接着检查坐标是否落在小数上。比如roundRect(100.5, 100.3, 200, 100, 16)这种半像素坐标会让抗锯齿算法变笨拙。建议在绘制前对坐标做Math.round()。半径同理如果半径是1.2这种极小值渲染出来可能不像是圆角更像直角这是正常现象。5.3 半径过大图形变成“饺子”或上下凸起一旦半径超过短边的一半圆角矩形就不是凸的了。比如width 100, height 40, r 30图形边缘的弧会互相挤压最终看起来像一个被揉过的面团。解决思路不是“把所有值都除以某个系数”而是做归一化const maxRadius Math.min(width, height) / 2; const safeRadius Math.max(0, Math.min(userRadius, maxRadius));如果你用的是原生roundRect()它内部其实会做非法值处理但不同浏览器处理方式不完全一致所以不要依赖它自己算一遍更稳妥。5.4 老环境不支持roundRect时的降级策略新API虽好但实际项目里总会有一些内置WebView版本很老的环境。直接调用ctx.roundRect()会抛出TypeError导致整个脚本中断。我的降级策略分两步。第一步写一个能力检测function getRoundRectDrawer(ctx) { if (typeof ctx.roundRect function) { return function nativeRoundRect(x, y, w, h, r) { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); }; } return function fallbackRoundRect(x, y, w, h, r) { buildRoundRectPath(ctx, x, y, w, h, r); }; }第二步把所有画圆角矩形的入口统一走这个getRoundRectDrawer返回的函数而不是到处散落ctx.roundRect的调用。这样将来即使环境升级你也能在一处地方切换实现。我在实际项目里的习惯是哪怕服务器端统计显示所有用户都是新浏览器我依然保留手写版本作为降级。因为线上永远可能有你没统计到的旧设备一个四十行的函数远比一次线上兼容事故便宜。写Canvas圆角矩形这件事技术上不难难的是把参数边界和兜底方案都提前设计好。把半径限制、高清适配、特性检测这三个动作做成肌肉记忆以后画什么圆角都不慌。