
Canvas 2D 里画圆角矩形很多人第一反应是ctx.roundRect()一行搞定。这个 API 确实方便但我在实际项目里用下来发现光知道“传五个参数”远远不够——radii参数的边界行为、老环境兼容、圆角路径精度、动画帧稳定性、图片裁剪衔接这些才是真正决定效果上限的地方。而且roundRect()是这个 API 原生支持的方法不是所有渲染场景都能直接用很多自定义 Canvas 组件、图表库、可视化大屏里手写圆角矩形路径依然是基本功。这篇我按照“参数细节 → 底层路径 → 高级案例 → 问题排查”的顺序把画圆角矩形这件事彻底讲透适合想深入掌握 Canvas 的开发者参考。1. roundRect 参数逐项拆解别让细节成了坑1.1 六个参数的边界行为与异常值处理ctx.roundRect(x, y, width, height, radii)是 Canvas 2D 在较新规范中加入的原生方法光看签名很简单前四个参数确定矩形的位置和大小第五个参数radii决定圆角半径。但radii不是只能传一个数字它有五种合法形式单个数字表示四个角都用同一个半径长度为 2 的数组[左上右下, 右上左下]也就是对角共用半径长度为 3 的数组[左上, 右上左下, 右下]这种形式可能很多人不熟但规范里确实有长度为 4 的数组[左上, 右上, 右下, 左下]按顺时针排列或者一个[number, number, number, number]的DOMPointInit序列等于是上面数组的显式写法。有一个非常容易迷糊的地方radii里如果只写了[10, 20]那它并不是“左上 10、右上 20”而是“左上和右下都是 10右上和左下都是 20”。我刚用这个 API 时也踩过这个坑做完一个组件后发现两个对角完全不对排查半天才意识到是数组语义理解错了。所以写代码前先在注释里标清楚每个位置的对应关系避免日后维护时再踩一遍。异常值处理也是规范里明确写的但很多文章不提radii中的负数会被当作 0 处理NaN和Infinity也会被转成 0。这不是浏览器随意发挥而是规范统一规定的。我实测过几种主流内核表现基本一致。但要注意radii如果传了一个空数组四个角会变成 0也就是退化成普通矩形如果传了字符串或者对象干脆会抛TypeError这一点和fillRect的行为不同。实测中还发现一个细节如果某个半径值比矩形短边的一半还大roundRect不会直接压扁而是按比例整体缩放所有半径让它们恰好贴合矩形的短边。这个行为很像 CSS 里border-radius的百分比极限情况但实现机制不完全一样。自行实现圆角路径时这类边界情况必须手动处理否则画出来的形状会溢出矩形边界。1.2 兼容性判断与安全降级方案roundRect在现代浏览器中的支持率已经很高但老版本环境里依然可能不存在这个方法。比如某些内置 WebView、旧版桌面浏览器直接调用会出现ctx.roundRect is not a function。我在某跨平台项目中就碰到过这个问题当时抽离了一个工具函数原则是“有能力用原生没能力走手写路径”两层兜底function drawRoundRect(ctx, x, y, w, h, radii) { // 轻微容错radii 允许传数字或数组 let r Array.isArray(radii) ? radii : [radii, radii, radii, radii]; // 格式化成长度为 4 的数组方便后续统一处理 const [tl, tr, br, bl] [ Math.max(0, r[0] || 0), Math.max(0, r[1] ! undefined ? r[1] : (r[0] || 0)), Math.max(0, r[2] ! undefined ? r[2] : (r[0] || 0)), Math.max(0, r[3] ! undefined ? r[3] : (r[1] ! undefined ? r[1] : (r[0] || 0))) ]; if (typeof ctx.roundRect function) { ctx.roundRect(x, y, w, h, [tl, tr, br, bl]); return; } // 手写路径核心是利用 arcTo 画四个角的弧线 const maxR Math.min(w, h) / 2; const r1 Math.min(tl, maxR); const r2 Math.min(tr, maxR); const r3 Math.min(br, maxR); const r4 Math.min(bl, maxR); ctx.beginPath(); ctx.moveTo(x r1, y); ctx.lineTo(x w - r2, y); ctx.arcTo(x w, y, x w, y r2, r2); ctx.lineTo(x w, y h - r3); ctx.arcTo(x w, y h, x w - r3, y h, r3); ctx.lineTo(x r4, y h); ctx.arcTo(x, y h, x, y h - r4, r4); ctx.lineTo(x, y r1); ctx.arcTo(x, y, x r1, y, r1); ctx.closePath(); }这段代码里的降级路径就是下一章要细说的arcTo方案。这里建议直接用我写过的这个工具函数作为唯一入口避免在业务代码里到处判断兼容性也方便后续统一调整圆角处理逻辑。2. 手写圆角路径不懂底层原理很难写出彩2.1 arcTo 的数学原理与四角坐标计算arcTo(x1, y1, x2, y2, r)这个方法理解它只需要抓住一个核心概念它以当前路径点为起点画一条到(x1, y1)的切线再画一条从(x1, y1)到(x2, y2)的切线然后在这两条切线上切出一个半径为r的圆弧。也就是说arcTo的起点并不是显式传参而是“当前画笔所在位置”这一点特别容易写错。画一个左上角为例。假设矩形左上角在(x, y)圆角半径是r那这个角的弧线应该这样构造先把笔移到(x r, y)也就是顶边往右偏移半径的位置调ctx.arcTo(x, y, x, y r, r)第一条切线是从(x r, y)到(x, y)第二条切线是从(x, y)到(x, y r)圆弧正好从(x r, y)平滑转到(x, y r)。为什么这里不用ctx.arc(x r, y r, r, Math.PI, Math.PI * 1.5)直接画弧理论上可以但arc需要显式指定起始角度和结束角度四个角的角度必须手算一旦矩形宽高或半径变化代码就要跟着改。arcTo的优势在于它自己根据半径和两条切线计算切点参数更少逻辑更统一。我更推荐把关键坐标画在一个坐标图上理解如图所示这里用文字示意(x, y) ---------- (x w - r, y) (x w, y) | | | | (x, y r) (x w, y r)四个角对称处理每条边用lineTo连接每个角用arcTo过渡就能拼出一个完整的圆角矩形。前面的降级函数里已经是这么写的。2.2 贝塞尔曲线画圆角的对比与新场景扩展除了arcTo还可以用quadraticCurveTo或bezierCurveTo画圆角。二次贝塞尔曲线只需要一个控制点用ctx.quadraticCurveTo(x, y, x, y r, x r, y)能把起点和终点之间拉出一个圆滑的弧度。这种做法的好处是路径天然平滑不需要关心arcTo的切点计算坏处是它画出来的并不是严格意义上的圆弧而是三次贝塞尔曲线的近似在某些精密的视觉对比下能看出和真圆的偏差。实际项目中我会这样选追求标准圆弧比如进度环、仪表盘、和arc()配合的场景用arcTo追求视觉柔和感比如自定义按钮、卡片描边用quadraticCurveTo反而更自然需要做不规则“超椭圆”圆角时直接用bezierCurveTo调整控制点位置可以得到比普通圆角更丝滑的苹果风曲线效果。如果你需要在同一个 Canvas 里混用多种圆角风格建议把圆角路径绘制封装成一个独立函数接收type参数区分实现方式这样上层调用时不需要关心几何细节。我做过一个数据可视化项目所有弹窗、标签、提示框都走同一个绘制函数换主题时只改圆角类型传入参数非常省事。这里再补一个容易被忽略的细节手写路径时ctx.beginPath()千万不能漏。roundRect内部会重新初始化路径但手写moveToarcTo不会自动帮你 begin如果前面的路径没有清除新路径会和旧路径粘连在一起可能出现奇怪的连线。这个我排查过好几次最后总结了一个规律——所有自定义路径绘制函数第一行写ctx.beginPath()最后一行写ctx.closePath()双保险。3. 高级技巧与真实案例从能画到画得好3.1 圆角卡片的完整实现坐标、排版与 Retina 适配先从一个最常见的场景开始用 Canvas 画一张卡片。卡片通常包含圆角背景、圆角描边、标题文本、底部小标签四部分。核心问题是“圆角矩形到底该画多大、画在哪”。我见过很多人把圆角矩形坐标写死结果设备像素比一变整个卡片都糊了。这里给一个可复用的做法function drawCard(ctx, { x, y, w, h, radius, bgColor, borderColor, title }) { const dpr window.devicePixelRatio || 1; // 如果外部没做缩放这里统一处理像素比 ctx.save(); ctx.scale(dpr, dpr); ctx.beginPath(); ctx.roundRect(x, y, w, h, radius); ctx.fillStyle bgColor; ctx.fill(); if (borderColor) { ctx.lineWidth 1; ctx.strokeStyle borderColor; ctx.stroke(); } // 文本绘制与圆角对齐 ctx.fillStyle #333; ctx.font 14px sans-serif; ctx.textBaseline middle; ctx.fillText(title, x 16, y 28); ctx.restore(); }注意这里的半径为 8 到 12 的区间通常是最耐看的小于 6 会偏硬大于 16 会显得过于圆润。卡片内边距一般取 16 或 24文本位置要与圆角顶点保持足够距离否则文字会压到弧线上。还有一个小细节卡片描边颜色如果和背景色接近1 像素描边在缩放后可能几乎看不见这时候需要配合shadowColor做微妙的层次感而不是盲目加粗lineWidth。Retina 屏适配方面很多人只会无脑ctx.scale(dpr, dpr)但实际上有个前提Canvas 的宽高属性也要同步乘以 dpr同时 CSS 显示尺寸保持逻辑像素。比如设计稿宽度 375Canvas 的width 375 * dprheight 某个高度 * dprCSS 里width: 375px。不做这一步圆角弧度在物理屏上一定会发虚。这个适配逻辑虽然基础但确实见过不少新手忽略。3.2 圆角图片裁剪用 clip 还是用绘制渐变把普通矩形图片变成圆角图片最直接的方式是先用圆角路径建立裁剪区域再drawImage。但这里有个容易出错的地方ctx.save()和ctx.restore()必须包裹住clip()和drawImage()否则裁剪区域会污染后续所有绘制操作。function drawRoundedImage(ctx, img, x, y, w, h, radius) { ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, w, h, radius); ctx.clip(); ctx.drawImage(img, x, y, w, h); ctx.restore(); }这种做法的性能优势很明显clip是一次性裁剪后续绘制不需要逐像素处理透明度。缺点是完全依赖 Canvas 的裁剪状态栈如果业务复杂、嵌套层级深很容易忘记restore导致后面的元素也被裁剪掉。另一种思路是“绘制裁剪”而不是“区域裁剪”也就是用ctx.globalCompositeOperation destination-in先画图片再画一个半透明的圆角矩形用目标合成模式把图片抠出圆角形状。这种做法在 WebGL 风格 Canvas 封装库里比较常见但 2D 场景下没有必要绕这么一大圈常规用clip就够了。我还试过一种做法先drawImage到离屏 Canvas再在离屏 Canvas 上做圆角裁剪最后把离屏 Canvas 整体贴回主屏。好处是主屏的绘制状态完全不受裁剪影响适合做头像批量预处理的场景缺点是多了离屏 Canvas 的内存开销不要在每一帧都这么干否则性能会很紧张。3.3 圆角进度环与仪表盘的组合应用画完静态圆角矩形再把圆角矩形和圆弧组合起来就能做出很有质感的仪表盘或者进度组件。常见结构是背景是一个完整圆环前景是一个带圆角端点的圆弧中间放一个圆角矩形容器显示百分比数字。这里面有两个关键技巧第一个是圆弧端点的圆角处理。用ctx.lineCap round可以让线段端点变成半圆这比手工计算圆弧端点再补一个圆简单得多。但如果想要“方形但只圆一个角”的效果lineCap就满足不了了需要自己在端点用arc或roundRect叠加一个圆角矩形。第二个是文字和圆角容器之间的间距。进度数字通常放在圆弧中心但数字宽度和字体大小不同居中不只是textAlign center和textBaseline middle那么简单。要真正居中的话先ctx.measureText(text)拿到文本宽度再做坐标偏移。对于两位数百分比可以近似固定偏移但更稳妥的还是动态测量。一个完整的仪表盘绘制片段function drawGauge(ctx, cx, cy, r, startAngle, endAngle, progress) { // 背景环 ctx.beginPath(); ctx.arc(cx, cy, r, startAngle, endAngle); ctx.strokeStyle #eee; ctx.lineWidth 12; ctx.stroke(); // 前景环计算弧度并圆头 const currentAngle startAngle (endAngle - startAngle) * progress; ctx.beginPath(); ctx.arc(cx, cy, r, startAngle, currentAngle); ctx.lineCap round; ctx.strokeStyle #5b8ff9; ctx.lineWidth 12; ctx.stroke(); // 中央圆角容器 const boxW 80; const boxH 36; ctx.beginPath(); ctx.roundRect(cx - boxW / 2, cy - boxH / 2, boxW, boxH, 8); ctx.fillStyle rgba(0,0,0,0.03); ctx.fill(); }这种组合场景里arc、roundRect、lineTo经常混用建议始终用beginPath()隔开不同的几何图形避免上一个图形的路径残留影响当前描边和填充。4. 常见问题与排查技巧实录4.1 锯齿与边界模糊的根源画出来的圆角矩形边缘有锯齿多数情况不是圆角本身的问题而是 Canvas 被 CSS 拉伸了。比如 Canvas 的width设置为 300但 CSS 里width: 600px浏览器会做一次隐式缩放所有线条边缘都会变模糊。排查方法很简单打开控制台查看 Canvas 元素的width属性和getBoundingClientRect().width是否一致。如果不一致按前面提到的 dpr 缩放逻辑统一修正。另一种锯齿来自绘制本身如果圆角矩形的填充颜色和背景色对比强烈且没有开启ctx.imageSmoothingEnabled就会在大缩放比下出现边缘噪点。解决办法是在画布初始化时显式ctx.imageSmoothingEnabled true; ctx.imageSmoothingQuality high;这个设置对drawImage平滑有效但注意它不作用于几何路径的抗锯齿。几何路径的抗锯齿由浏览器底层渲染引擎自动处理开发者能控制的是“别把 Canvas 缩放得太离谱”。所以设计高分辨率 Canvas 时最好按物理像素建立坐标系而不是靠 CSS 放大。4.2 阴影与描边的怪异表现圆角矩形加阴影是常见需求但这里有个顺序坑必须先设置shadowBlur、shadowColor、shadowOffsetX/Y再执行fill()。如果先fill()再设置阴影阴影不会对已经填充的部分生效。另外shadowBlur的数值不要超过矩形尺寸的一半否则阴影会穿透矩形中心在圆角内侧也出现一圈虚影看起来很脏。描边和圆角半径之间的比例也很微妙。当lineWidth较大时描边会同时向内和向外延伸导致圆角弧线的圆心出现偏移。如果希望描边完全位于矩形外圈需要使用stroke()前调整坐标把路径整体平移lineWidth / 2的距离如果要描边完全在内部就把路径缩小lineWidth的距离再画。Canvas 2D 没有原生strokeAlign属性这一块只能手工处理。很多 UI 组件之所以在放大时出现描边粗细不均就是没有处理这个偏移。4.3 动画卡顿与性能对比圆角矩形如果只是静态绘制性能不会有太大问题。但一旦进入动画循环比如圆角半径从 0 变化到 24或者矩形位置跟随鼠标移动每一帧都调用roundRect重新生成路径路径生成的开销会累积。这时候优化思路有两个优先考虑缓存路径用Path2D把静态部分的路径存下来const cardPath new Path2D(); cardPath.roundRect(20, 20, 200, 120, 12); // 后续每帧直接 ctx.fill(cardPath)不需要重新构建路径如果是圆角半径动态变化那Path2D不能完全复用但可以把“矩形位置、宽高固定”的部分拆出来只更新变化量。比如只变化 radius宽高不变的情况下可以预计算四角弧线的关键坐标动画时只改半径参数调用arcTo比每次都重新计算完整路径要快。第二点是避免在每一帧里创建新对象。很多封装库为了代码简洁在draw函数内部临时创建new Path2D()、数组、甚至对象这在低端机型上会导致频繁的垃圾回收肉眼可见地卡顿。建议把需要复用的对象提升到动画函数外部。我在移动端 H5 项目里实测过相同场景下复用路径对象比每帧重建帧率能提升 20% 到 30%。4.4 roundRect 方法缺失与异常抛出最后说一个容易忽略的边界ctx.roundRect并不存在于所有 Canvas 实现中而且不同版本对radii参数的严格程度不同。有的浏览器对非法参数会直接抛异常有的则会静默容错。为了统一最好在入口处做参数校验而不是依赖浏览器处理。我基于前面 1.2 节的降级函数扩展出了一个安全版本在实际项目里一直没有出过问题。补充一个原则任何 Canvas 路径方法调用尽量包一层工具函数否则一旦某个环境表现不同排查成本会非常高。再补充一个比较少见的坑roundRect的坐标支持小数但部分旧的 Canvas 实现存在坐标精度问题大量绘制微小尺寸的圆角矩形比如小于 5px时圆角弧线会出现肉眼可见的抖动。解决办法是做好坐标取整或半像素对齐Math.round(x)或Math.round(x * dpr) / dpr让线条落在像素网格上。我个人在实际操作中最深的一个体会是Canvas 绘制本身并不难难的是所有边界情况叠加在一起时的表现。比如圆角矩形、裁剪、阴影、retina 缩放、渐变填充同时出现任何一个环节出错视觉上都会是“哪里怪怪的”但很难定位。所以宁可把绘制封装得厚一点每一个几何参数都经过工具函数校验也不要在业务代码里到处散落裸的ctx.roundRect调用。至少在我的项目里这种封装让我少加了很多班。最后再分享一个小技巧如果你要在多个圆角矩形之间做视觉统一可以定义一套全局圆角规范比如“卡片 8px、按钮 12px、弹窗 16px、标签 999px”不要每个组件自己拍脑袋定数值。虽然roundRect只是一个函数但它决定了产品整体视觉的一致性值得认真对待。