ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Canvas圆角矩形绘制指南:roundRect API与手写Path全解析

Canvas圆角矩形绘制指南:roundRect API与手写Path全解析 做 H5 交互页的时候我经常要跟 Canvas 打交道。不管是游戏里的血条、头像框、抽奖卡片还是绘图引擎里最基本的图元输出圆角矩形几乎是绕不开的一块积木。但说真的画一个圆角矩形这件事很多人都是“能画出来”和“画得对”之间反复横跳。ctx.roundRect手动拼 Path四个角半径不一样怎么处理圆角太大了会不会炸这些问题如果不在动手前想清楚后面每一步都在填坑。这篇文章就围绕 Canvas 画圆角矩形的参数、步骤和高级技巧展开把我实际项目里踩过的坑、用顺手的方法都写出来。适合正在写 Canvas 游戏、自绘 UI、或者做 H5 动效页面的同学参考。1. 画圆角矩形前先想清楚它到底难在哪1.1 圆角矩形不是“四个圆弧拼起来”这么简单圆角矩形的本质是四条直线段加四段圆弧。如果只是追求视觉效果有个偷懒办法用ctx.arc()画四个角中间用lineTo接起来。可问题在于角度稍微一偏圆弧和直线之间就容易出现重叠或者裂缝。尤其在 Canvas 游戏里你需要逐帧绘制大量不同尺寸的圆角卡片路径一多这种手工拼法很快就会露馅。更深一层的问题是填充和描边的边界处理完全不一样。fill只要路径闭合视觉上基本没问题stroke会把每一条路径线段和圆弧的交接处按lineJoin的要求单独处理。如果你把圆角矩形拆成独立的直线和圆弧lineJoin的设定会影响每一个拐角稍微没处理好外边框就会多出一个尖角或者缺口。所以我一直强调真正可靠的方案是让路径本身首尾相连、按标准顺序走完而不是“看起来差不多”。1.2 先按使用场景选择技术路线在实际项目里我一般先问自己三个问题目标浏览器是否支持原生 API圆角是四个角统一还是要单独控制绘制频率高不高如果目标环境是较新的浏览器或 WebView直接使用原生ctx.roundRect()是最省事的。如果项目需要兼容旧系统或者要支持单边圆角、椭圆角这种高度定制需求手写 Path 反而更灵活。还有一类情况在 WebGL 或者轻量级 Canvas 绘图引擎里想直接在 GPU 上画圆角矩形并不简单通常会先在离屏 Canvas 上烘焙出一张贴图再作为纹理贴上去。这条路线跟 API 关系不大但思路可以复用。我整理了一个简单的选型表方便快速决策方案优点缺点适用场景原生roundRect简单、性能好兼容性有门槛、定制受限活动页、中后台图表手写 Path完全可控代码量多、需要几何基础旧浏览器、不规则圆角离屏缓存绘制极快额外内存、不支持动态改半径游戏、高频重绘列表2. 原生 roundRect()参数好记边界条件不能想当然2.1 参数逐项拆解ctx.roundRect(x, y, width, height, radii)是 Canvas 2D API 提供的原生圆角矩形方法。前四个参数就是矩形左上角坐标和宽高跟ctx.rect()完全一致。最后一个radii是重点它既可以是数字也可以是一个数组甚至可以传包含{x, y}的 DOMPoint 对象。如果传数字四个角统一使用这个半径。如果传数组数组长度从 1 到 4 分别对应不同角1 个值四角相同。2 个值第一个值作用于左上角和右下角第二个值作用于右上角和左下角。3 个值第一个给左上角第二个给右上角和左下角第三个给右下角。4 个值按左上、右上、右下、左下顺序排列。这个顺序跟 CSS 的border-radius是一致的记住之后就不用每次现查。真正容易踩坑的是“二值数组”的语义它不是“上边两个角各设多少”而是对角线分组。如果你只是想给左上角和右上角设置不同半径直接写[20, 10, 0, 0]更稳妥不要用二值数组去猜。DOMPoint 模式平时用得少但知道它存在很有必要。比如想画一个“水平和垂直半径不一致”的椭圆角可以这样传ctx.roundRect(0, 0, 100, 60, { x: 20, y: 10 })。这个能力原生支持很自然手写路径想模拟就会麻烦不少。2.2 负数、零和超出边界的半径规范里负的半径会被当作 0 处理0 就是直角。真正容易出问题的是半径大于矩形短边一半的情况。例如一个100 x 50的矩形你把 radius 设置成 40理论上四个 40px 的圆弧会互相打架。标准做法是等比缩放每个角的半径让曲线仍然平滑但不同浏览器对极端值的表现会有细微差异测试时不能只看 Chrome。我的习惯是传入之前先自己 clamp 一次const maxR Math.min(width, height) / 2; const safeR Math.min(radius, maxR);虽然 API 内部大概率也会做处理但程序里有个明确的边界排查问题时能少很多“为什么这个角翘起来了”的疑惑。尤其是做动效时半径是从外部传入的变量某帧数据稍微越界画面就会突然变丑提前 clamp 可以避免这种偶发故障。width 或 height 为负时也一样。roundRect会沿负方向画矩形但路径方向和圆角位置会反过来。如果你只是fill可能看不出问题一旦用stroke或者给路径加阴影方向不同带来的差异就变得明显。遇到非正的宽高我建议先归一化坐标再用正宽高去画不要指望 API 帮你处理。2.3 路径状态roundRect 不会替你 beginPath初学者最容易忽略的是roundRect只是往当前路径对象里加了一个子路径它不会调用beginPath也不会保存和恢复上下文状态。如果你在同一个 Canvas 上先画了一条线又直接调用roundRect再fill这两段路径会被当成同一块图形处理轻则颜色混在一起重则整块区域被错误填充。所以每次绘制前先问自己一句“这条路径跟上一帧有没有关系”。大多数情况下你想要的都是独立圆角矩形那就老老实实ctx.beginPath()。另外roundRect生成的子路径是闭合的路径起点在左上角终点也会回到左上角附近不需要额外closePath。兼容性方面原生roundRect从 2022 年前后在主流浏览器里逐渐普及桌面端问题不大但移动端旧 WebView 仍是重灾区。写公共组件时我建议做能力检测if (typeof ctx.roundRect function) { ctx.roundRect(x, y, width, height, radii); } else { drawRoundRectPath(ctx, x, y, width, height, radii); }把兜底方案藏在同一个入口后面业务层不需要感知差异。3. 手写圆角矩形路径兼容旧环境也能玩出花3.1 用 arcTo 把四个角拼起来手写圆角矩形的关键不是arc而是arcTo。arc需要你算圆心、起始角度和结束角度四个角就是四套坐标加四套角度极易算错arcTo只需要提供“两个控制点”和半径它会基于当前点自动补一条切线圆弧语义更接近“给直角倒圆角”。路径可以从左上角圆弧的起点开始也就是(x r, y)然后先向右画直线到右上角圆弧的起点用arcTo转弯再继续向下。逐角处理最后closePath收尾。推荐顺序左上 - 右上 - 右下 - 左下方向为顺时针符合大多数 Canvas 工具的默认填充规则。具体代码可以这样写function drawRoundRect(ctx, x, y, w, h, r) { const maxR Math.min(w, h) / 2; const radius Math.max(0, Math.min(r, maxR)); ctx.beginPath(); ctx.moveTo(x radius, y); ctx.lineTo(x w - radius, y); ctx.arcTo(x w, y, x w, y radius, radius); ctx.lineTo(x w, y h - radius); ctx.arcTo(x w, y h, x w - radius, y h, radius); ctx.lineTo(x radius, y h); ctx.arcTo(x, y h, x, y h - radius, radius); ctx.lineTo(x, y radius); ctx.arcTo(x, y, x radius, y, radius); ctx.closePath(); }这个函数有两个细节值得注意。第一我先把半径 clamp 到Math.min(w, h) / 2避免过大的半径把路径压成麻花。第二beginPath被放在函数内部意味着每次调用都会开启一条新路径这个设计适合“一次函数只画一个矩形”的场景。3.2 四角不同半径怎么处理很多场景只需要“顶边两个圆角”比如弹窗卡片、标签页 tab。这种时候四个角共用同一个r就不合适了。数组版本的手写路径并不复杂只要把每一段直线和圆弧的半径分开传就行。function roundRectPath(ctx, x, y, w, h, radii) { const maxR Math.min(w, h) / 2; let [tl, tr, br, bl] Array.isArray(radii) ? radii : [radii, radii, radii, radii]; tl Math.max(0, Math.min(tl, maxR)); tr Math.max(0, Math.min(tr, maxR)); br Math.max(0, Math.min(br, maxR)); bl Math.max(0, Math.min(bl, maxR)); 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(); }注意这里我对每个角都限制到Math.min(w, h) / 2简单粗暴但已经能覆盖绝大多数 UI 需求。如果要做非常极端的不等半径还需要额外保证“同一条边上的两个半径之和不超过边长”否则会出现负的直线段。我在处理“弹窗底部两个直角、顶部两个圆角”时通常就是[12, 12, 0, 0]逻辑非常清晰。3.3 arc 和 quadraticCurveTo 能不能替代 arcTo有人喜欢用ctx.arc()直接画四分之一圆数学上完全可行但可读性差。以右上角为例圆心是(x w - r, y r)从-90°到0°代码一多就非常难维护。如果你想兼容更老的环境arc()反而是最古老、最通用的一种思路。还有一条偏方用quadraticCurveTo画贝塞尔曲线模拟圆角。例如ctx.quadraticCurveTo(x w, y, x w, y r);控制点放在直角顶点曲线会让角“软”下来。这个方法不是标准圆角半径不能精确控制但优点是代码极短而且贝塞尔曲线本身非常平滑在小型图标上几乎看不出跟圆弧的差别。我一般只在做简易占位图或者特殊形状标签时用。3.4 椭圆角原生 radius 和手动 scale 的取舍原生roundRect的radii支持 DOMPoint例如ctx.roundRect(x, y, w, h, { x: 20, y: 10 })可以做出椭圆角。手写路径要模拟椭圆角常见姿势是savescale后画圆角矩形但scale会把描边宽度也缩放不是所有场景都能接受。更可靠的做法是用ctx.ellipse()在四个角分别画椭圆弧但这需要你精确控制每段弧的圆心和起止角。坦白说日常项目里用椭圆角的频次并不高我通常只在“要做异形弹窗”时才写这种函数性能上没有惊喜工程量倒是不小。如果你也遇到这个需求建议优先保留原生roundRect的 DOMPoint 参数把椭圆角作为高级特性单独封装不要跟基础圆角混在一起。4. 圆角矩形的高阶玩法渐变、阴影、镂空和动效4.1 给圆角矩形加渐变和阴影但不能无脑加圆角矩形填渐变很多人直接设置fillStyle为createLinearGradient然后fill。这个思路没问题但要注意渐变的坐标系是整个 Canvas不是圆角矩形内部。如果你把多个圆角矩形放在不同位置却想让它们共享同一套渐变方向直接用createLinearGradient做不到它只会按 Canvas 坐标计算颜色。如果想让渐变跟随每个矩形走最简单的做法是每次用矩形的本地坐标创建渐变const gradient ctx.createLinearGradient(x, y, x, y h); gradient.addColorStop(0, #fff); gradient.addColorStop(1, #e5e5e5); ctx.fillStyle gradient;阴影是另一个重灾区。shadowBlur、shadowColor用起来很顺手但每帧修改shadowBlur会强制 Canvas 在绘制时做大量采样帧率掉得很快。我的做法是静态内容先画到离屏 Canvas再drawImage贴回主画布动态内容则尽量不用阴影或者用一段半透明渐变模拟阴影物理计算量要小得多。4.2 镂空圆角矩形一个哪怕没有官方方法也能做到的效果镂空效果在抽奖转盘、仪表盘、遮罩蒙层里特别常见。思路是用evenodd填充规则先画一个覆盖全屏的大矩形再在里面画一个圆角矩形填色时用“奇偶规则”重叠区域就会被掏空。ctx.beginPath(); ctx.rect(0, 0, canvas.width, canvas.height); roundRectPath(ctx, 100, 80, 200, 120, 16); ctx.fillStyle rgba(0, 0, 0, 0.5); ctx.fill(evenodd);用这个方式做蒙层比savecliprestore干净得多。clip方式需要先裁剪再绘制如果你只是想盖一层半透明遮罩fill(evenodd)一行就完成了。这个技巧在 Canvas 绘图引擎里尤其有用因为引擎层经常要屏蔽“遮罩模版”的细节。4.3 圆角半径的动态变化与单角动画圆角矩形动效最常见的形态是“普通矩形”和“胶囊形”之间切换也就是半径从 0 变化到短边一半。用requestAnimationFrame逐帧改半径每次调用手写路径函数就能得到很顺畅的过渡。这个过程的性能瓶颈不在路径计算而在fill和stroke所以不要每帧都新建渐变色对象。function animateCard(canvas) { const ctx canvas.getContext(2d); let progress 0; function tick() { progress 0.02; const r Math.abs(Math.sin(progress)) * 30; ctx.clearRect(0, 0, canvas.width, canvas.height); drawRoundRect(ctx, 40, 40, 200, 60, r); ctx.fillStyle #4f8ff7; ctx.fill(); requestAnimationFrame(tick); } tick(); }更精细的单角动画比如弹窗底部两个角慢慢变成直角可以用数组半径实现。动画里维护tl、tr、br、bl四个值每一个都做线性插值roundRectPath本身不用改。这里有一个经验半径变化过程不要用固定公式硬套因为不同屏幕尺寸下最大半径不一样用“当前半径 / 最大半径”作为进度更容易统一。4.4 高分屏、响应式布局怎么保证圆角不发虚Canvas 在高分屏上发虚几乎都是因为canvas.width没跟上设备像素比。正确做法是把 canvas 的位图尺寸设置成 CSS 尺寸乘以devicePixelRatio再用ctx.scale(dpr, dpr)恢复逻辑坐标系。这样所有坐标和半径仍然用 CSS 像素写圆角尺寸不会因为屏幕缩放而缩水。function setupCanvas(canvas, cssWidth, cssHeight) { const dpr window.devicePixelRatio || 1; 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); return ctx; }响应式布局时还需要注意resize 后 Canvas 上下文会被重置scale和transform全部恢复默认需要重新设置。如果你把圆角半径写成固定值在一些窄屏小卡片上可能出现半径大于短边一半的边界情况所以计算半径时要结合当前矩形宽度动态取min。实测下来只要把 clamp 逻辑放在公共函数里这类问题基本能一次性解决。5. 一个能直接上线的圆角矩形组件与填坑复盘5.1 封装优先原生自动降级把原生roundRect和手写路径揉进同一个入口是维护起来最舒服的方式。下面这个drawRoundRect函数支持统一半径和数组半径自动做能力检测同时把fill、stroke、lineWidth暴露出来按需使用。function drawRoundRect(ctx, options) { const { x, y, width, height, radii, fill, stroke, lineWidth } options; ctx.beginPath(); if (typeof ctx.roundRect function) { ctx.roundRect(x, y, width, height, radii); } else if (Array.isArray(radii)) { roundRectPath(ctx, x, y, width, height, radii); } else { drawRoundRect(ctx, x, y, width, height, radii); } if (fill) { ctx.fillStyle fill; ctx.fill(); } if (stroke) { ctx.strokeStyle stroke; ctx.lineWidth lineWidth || 1; ctx.stroke(); } }这里有一个取舍beginPath放在函数最前面好处是调用方不用每次记得清理旧路径坏处是如果你真的想在一个大路径里连续画多个圆角矩形这种方式会打断路径。分页列表、图表柱状图这种场景我一般会再提供一个不主动beginPath的变体把控制权交还给调用方。5.2 我实际踩过的几个坑第一个坑是忘记beginPath。封装函数里我统一调用了一次但在异步绘制或批量绘制 list 时很容易在调用处又画了其他路径最终填充出一团乱码。建议把beginPath的位置根据调用方式明确写进注释不要让使用者猜。第二个坑是数组半径的顺序。UI 同学给的视觉稿标注“左上 12、右上 12、右下 0、左下 0”我一开始直接传[12, 12, 0, 0]正好对上了原生的[tl, tr, br, bl]顺序。但如果你用的是[12, 0, 0, 12]这类非对称值最好在函数文档里写清楚顺序否则几个月后回来看代码很容易记错。这个坑尤其在多人协作的 Canvas 项目里常见。第三个坑是stroke会把圆角外沿吃掉。Canvas 的描边是沿着路径中心线往两边扩展的所以一个矩形如果fill是 100px 宽、stroke是 4px实际视觉宽度会变成 104px。如果你要求整体尺寸严格不超界可以先fill再通过clip或偏移路径来画内描边。这个问题在圆角矩形上比直角矩形更隐蔽因为圆弧处会显得特别粗。第四个坑是雷达图、仪表盘这类图形里圆角矩形经常被用来画刻度槽。刻度的头部和尾部如果都使用同一个半径视觉上会显得不对称。正确做法是两端用不同半径的数组形式传入例如[2, 8, 8, 2]让刻度“一头尖一头圆”观感会舒服很多。5.3 性能实测的一点结论我拿原生roundRect和手写roundRectPath在同一个项目里做过一次简单对比连续绘制 1 万个半径为 8 的圆角矩形两种方式的耗时差距在个位数毫秒以内真正的开销基本都集中在fill操作的像素填充上。所以不要迷信“原生一定比手写快”也不要觉得手写路径就很慢。真正的性能问题出在高频动画和大量独立渐变上。如果你要在 Canvas 游戏里画几百个悬浮气泡每个气泡都是圆角矩形加阴影那最稳的方案是把气泡预先画到离屏 Canvas再按位置drawImage而不是每帧重新fill。离屏 Canvas 的额外内存大约等于位图宽乘高乘 4 字节控制数量级之后是划算的。如果目标平台是 WebGL 或混合渲染引擎更常见的做法是提前把圆角矩形烘焙成一张四角透明的贴图然后用矩阵变换贴到任意位置。这种方式跟 Canvas 2D 的 Path 完全是两条路线但绘制速度会有质的提升。遇到渲染管线层面的圆角需求时可以考虑直接切过去。5.4 一点个人经验不是标准答案说了这么多我最想强调的还是“先理解路径再谈 API”。原生roundRect是个好东西但它不是银弹手写路径也不是老古董它让你真正拥有对每个角、每条边的控制权。现在这个时代做 Canvas 游戏和绘图引擎的同学越来越多圆角矩形这种基础图元反而值得花时间打磨清楚。项目不忙的时候我会把这类基础函数放到自己的工具库里反复调优等下次真要用的时候直接抄自己过去验证过的版本。最后再分享一个小习惯调试圆角矩形时先用ctx.strokeStyle red; ctx.stroke();把路径轮廓打出来确认每个角的切点位置再考虑填充和阴影。这个习惯帮我省下了大量“为什么圆角不圆”的排查时间。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进