做可视化看板那几年圆角矩形这个需求出现的频率高得离谱。卡片背景、按钮底、头像裁切、图表容器、提示气泡全都要圆角。一开始我也图省事直接四段arc拼结果半径一改大就错位斜接处还会冒出一条细缝后来换arcTo代码短了但碰上只圆上面两个角四个角半径不一样要把路径导出成 SVG这类需求又开始别扭。折腾几轮之后我基本固定用canvas 二次贝塞尔曲线绘制圆角矩形这套方案四条直线加四段quadraticCurveTo路径干净、点数可控、能直接塞进Path2D缓存还能顺手改造成不规则圆角和超椭圆变体。这篇就把这套东西从几何原理、参数约束、代码落地到踩坑排查完整捋一遍前端新手能照着抄做过几年可视化的人也能在精度和性能这两块拿到点新东西。1. 为什么圆角矩形最后都落到 quadraticCurveTo 上1.1 三种画法的路线对比Canvas 里画圆角矩形能走的路其实就三条我把它们放在一起对比过很多次结论是有明确分工的不是谁绝对比谁好。第一条是arc拼圆角。思路最直白四条直线加四段四分之一圆弧每段圆弧用ctx.arc(cx, cy, r, startAngle, endAngle)单独指定圆心和起止角。问题在于圆心要自己算四个角的圆心分别是(xr, yr)、(xw-r, yr)、(xw-r, yh-r)、(xr, yh-r)起始角也得跟着角位调整。代码量大概是二次贝塞尔版本的两倍而且一旦某个角的半径单独变化角度判断就得重写一遍。我早期写过一版维护成本很高后来直接弃了。第二条是arcTo。它接受两个切线端点和一个半径浏览器自动帮你算圆弧确实省事。标准写法四行就能画完一个圆角矩形很多人也就是这么用的。但arcTo有个隐性的坑它的行为依赖当前点和第一个参数点构成的切线方向如果路径中间插了别的子路径或者你在循环里复用同一个 ctx 而忘了beginPath切线方向就错了画出来的东西会莫名其妙地甩出去一条线。另外arcTo生成的是真正的圆弧段路径点数比二次贝塞尔多做Path2D缓存和高频isPointInPath检测时会稍微吃点亏。第三条就是quadraticCurveTo。每段圆角只需要一次调用、三个点起点当前点、控制点角点本身、终点。没有角度计算没有切线方向推断四个角的代码结构完全对称复制粘贴改个坐标就行。这就是它在工程上最大的优势——结构对称意味着好维护、好参数化、好扩展。方案代码量精度路径点数参数化难度适用场景arc拼四段多真圆弧中高需要严格圆弧的工程图arcTo少真圆弧中偏多中快速出活、规则四角quadraticCurveTo少近似圆弧少低不规则圆角、路径导出、缓存复用1.2 二次贝塞尔画圆角的几何逻辑很多人第一次看到quadraticCurveTo(x w, y, x w, y r)这种写法会愣一下控制点为什么放在角点上答案其实很朴素——圆角在角点附近是切进去的两条边在角点处相交形成一个直角而圆角的弧线正是被这个直角夹出来的。把控制点放在两条边的交点也就是矩形原始的尖角曲线就会自然地被拉向那个方向形成一段从水平边过渡到垂直边的平滑弧。用生活里的东西打个比方你手里有一根有弹性的细竹条两端分别固定在水平边和垂直边的端点上然后你用手指把竹条往尖角方向顶一下再松开竹条弹回来的形状就是二次贝塞尔曲线。手指顶的位置就是控制点顶得越靠近尖角弧就越鼓、越接近直角顶得越远弧就越平。这个直觉非常重要因为它解释了后面所有的参数调整逻辑控制点位置决定了圆角的饱满度。标准写法把控制点放在角点上是最鼓的一种如果你觉得圆角看起来比设计稿方了一点唯一的调节手段就是别把控制点放在角点上往回收一点。当然这样会破坏半径等于 r的语义所以我一般不动控制点而是直接换三次贝塞尔这个在第 4 节展开。1.3 什么时候该选它什么时候不该我给自己定的判断标准很简单只要这个圆角矩形需要作为一条完整路径被复用——缓存成Path2D、参与clip裁剪、做命中检测、导出成 SVG 的d属性、或者后续还要跟别的路径做addPath合并——那就用二次贝塞尔。因为它是纯路径描述没有任何当前状态依赖把它包成一个函数随便什么时候调用都能得到同样的结果。反过来如果是那种一次性画完就扔的场景比如临时在某个 demo 里画个框我可能直接arcTo四行了事懒得写函数。另外如果项目对圆弧精度有硬性要求比如要做工业零件轮廓示意、要跟设计稿做像素级比对那也应该绕开二次贝塞尔直接上arc或者用三次贝塞尔的做法。还有一点要说清楚现代浏览器已经有原生的ctx.roundRect()了参数是(x, y, w, h, radii)radii 支持数字和数组行为跟 CSS 的border-radius基本对齐。如果你的目标环境确定支持直接用原生最省事。但它有两个现实约束一是较老的浏览器版本没有二是它在某些实现细节上比如负半径、超大半径的收缩逻辑跟老代码可能不一致。所以我现在的做法是业务代码里优先探测ctx.roundRect没有就回落到自己写的二次贝塞尔版本两套逻辑保持一致的半径归一化规则这样视觉上不会出现差异。注意不要在同一段绘制逻辑里混用原生roundRect和手写路径两者的超大半径收缩策略不一样。比如w100, h40, r50手写版本如果不做约束会画出交叉的麻花而原生版本会自动压成胶囊形。混用会导致同一个组件在不同浏览器上形状对不上。2. 把圆角矩形拆成八段线段和四个控制点2.1 顺时针走一圈的路径顺序画路径这件事最忌讳的就是边画边想。我的习惯是先在纸上把八个关键点标出来确认顺序无误再动键盘。以左上角为(x, y)、宽w、高h、四角统一半径r为例顺时针一圈的八个点是上边起点(x r, y)上边终点(x w - r, y)右边起点(x w, y r)右边终点(x w, y h - r)下边起点(x w - r, y h)下边终点(x r, y h)左边起点(x, y h - r)左边终点(x, y r)四段直线依次连接 1→2、3→4、5→6、7→8四段曲线依次连接 2→3、4→5、6→7、8→1。每一段曲线的控制点就是它经过的那个角点比如 2→3 这段的控制点是右上角(x w, y)。这个八点四段的结构一旦记住后面加不规则半径、加单边缺口、改成超椭圆全都只是在这个骨架上改坐标不会乱。路径方向为什么一定要顺时针因为这牵涉到后面的closePath和填充规则。Canvas 默认用 nonzero非零环绕规则填充如果同一个路径里既有顺时针的外圈又有顺时针的内圈内圈不会被挖空。做圆角矩形挖孔这类效果时内圈必须反向绘制逆时针或者干脆改用ctx.fill(evenodd)。这个细节我在第 4 节还会再提一次。2.2 半径的四条边界约束半径这块是最容易出事的地方也是我见过最多线上 bug 的地方。理论上r可以取任意正数但视觉上它有三个硬边界r w / 2时左右两个圆角的弧会互相越界上边的直线段长度变成负数画出来是一个交叉的蝴蝶结r h / 2时上下两个圆角的弧互相越界右边直线段长度变负效果一样r 0时曲线会朝反方向鼓出去形状完全不可预测。所以不管调用方传什么函数内部必须做一次收敛r Math.min(r, w / 2, h / 2)同时r Math.max(r, 0)。这一行代码看着不起眼但它把调用方传错参数从线上事故降级成了视觉上变成胶囊性质完全不同。有个细节值得单独说约束的时候要不要保持圆角的比例感比如w200, h60, r100按min(w/2, h/2)收敛会得到r30画出来是个胶囊形但用户脑子里的预期可能是两个大圆角加一段直边。这两种理解都没错取决于业务语义。我的一般做法是如果是用户拖拽滑块调半径的交互就用min(h/2, w/2)硬夹住让滑块拖到底就是胶囊直观如果是设计稿里给定的固定半径那就在开发阶段就确认好不要在运行时偷偷改。传入情况处理方式视觉效果r 0走直线分支不调用曲线纯直角矩形0 r min(w,h)/2正常绘制标准圆角r min(w,h)/2短边一半正常绘制短边方向变成半圆胶囊r min(w,h)/2收敛到短边一半胶囊但宽边方向仍有直段r 0收敛到 0直角矩形2.3 半径参数的归一化设计真实项目里半径很少是一个数字。CSS 的border-radius支持10px、10px 20px、10px 20px 30px、10px 20px 30px 40px四种写法分别对应 1/2/3/4 个值的展开规则一个值是四角相同两个值是左上右下 / 右上左下三个值是左上 / 右上左下 / 右下四个值是左上 / 右上 / 右下 / 左下顺时针。我的归一化函数严格按这个规则来输出一个长度为 4 的数组[tl, tr, br, bl]。这样设计的好处是上层调用方可以直接把 CSS 字符串里解析出来的半径扔进来也可以传数组、传数字接口统一。而且半径顺序固定为左上、右上、右下、左下跟路径顺时针遍历的顺序天然一致写代码的时候不用来回查索引。还有一个容易被忽略的点归一化必须发生在收敛之前还是之后我的答案是每个角独立收敛而不是先取全局最小值。因为不规则圆角场景下tl40, tr0, br0, bl0这种只圆左上角的写法是真实存在的比如聊天气泡的尾巴如果先取全局最小会把所有角都抹成 0功能就废了。所以代码里是map一次逐个夹到min(w/2, h/2)和 0 之间。function normalizeRadius(radius, w, h) { const max Math.min(w, h) / 2; let r; if (typeof radius number) { r [radius, radius, radius, radius]; } else if (Array.isArray(radius)) { const src radius.length 1 ? [radius[0], radius[0], radius[0], radius[0]] : radius.length 2 ? [radius[0], radius[1], radius[0], radius[1]] : radius.length 3 ? [radius[0], radius[1], radius[2], radius[1]] : radius.slice(0, 4); r src; } else { r [0, 0, 0, 0]; } return r.map(v Math.max(0, Math.min(Number(v) || 0, max))); }这段代码我用了很久没改过唯一需要注意的是Number(v) || 0这个写法会把NaN和null都变成 0是刻意为之的容错不是偷懒。3. 手写一版能直接用的圆角矩形函数3.1 函数签名与整体骨架函数签名我试过好几个版本最后定成了traceRoundRect(target, x, y, w, h, radius)第一个参数叫target而不是ctx。这个命名的原因很实在Canvas 的CanvasRenderingContext2D和Path2D都实现了moveTo、lineTo、quadraticCurveTo、closePath这四个方法签名完全一致所以同一个函数可以同时服务直接画到画布上和先缓存成路径对象两种用法。把参数叫ctx会让人误以为只能传上下文叫target就没这个心理障碍。另外一个刻意的设计是函数内部不调用beginPath()。原因很简单Path2D压根没有beginPath这个方法。而且把beginPath交给调用方意味着你可以在同一个路径里先画圆角矩形、再用addPath或者rect追加别的东西最后一次性填充做复合形状的时候非常方便。代价是调用方必须自己记得写beginPath这个坑我在第 5 节的排查表里专门列了一条。整体骨架就是八个点、四段直线、四段曲线、一个closePath。四段直线的顺序是上边、右边、下边、左边四段曲线紧跟在对应的直线后面这样读代码的时候是一条连续的动线不会来回跳。function traceRoundRect(target, x, y, w, h, radius) { const [tl, tr, br, bl] normalizeRadius(radius, w, h); target.moveTo(x tl, y); // 上边左上角之后 - 右上角之前 target.lineTo(x w - tr, y); // 右上角 if (tr) { target.quadraticCurveTo(x w, y, x w, y tr); } // 右边 target.lineTo(x w, y h - br); // 右下角 if (br) { target.quadraticCurveTo(x w, y h, x w - br, y h); } // 下边 target.lineTo(x bl, y h); // 左下角 if (bl) { target.quadraticCurveTo(x, y h, x, y h - bl); } // 左边 target.lineTo(x, y tl); // 左上角收口 if (tl) { target.quadraticCurveTo(x, y, x tl, y); } target.closePath(); }3.2 四段曲线部分的关键代码四段曲线里每一段的三个点是固定模式起点是上一条直线的终点控制点是那个角的尖角坐标终点是下一条直线的起点。以右上角为例起点(x w - tr, y)是上边的终点控制点(x w, y)是右上角点终点(x w, y tr)是右边的起点。三个点构成一个L形曲线被夹在里面。我特意在每段曲线外面包了if (tr)这样的判断而不是无脑调用。原因有两个一是半径为 0 的时候quadraticCurveTo会画一段退化曲线虽然视觉上看不出来但路径里多了一个曲线段某些场景下会影响isPointInPath的边界判定和 SVG 导出字符串的长度二是在做动态隐藏某个角这类交互时判断存在能省掉一些无意义的路径操作。这个习惯是做了几年路径动画之后养成的属于不影响功能但影响长期可维护性的细节。还有一个容易忽略的收口问题最后一段曲线画完之后路径回到了最开始的moveTo位置closePath()会补一条直线过去——但因为起点终点本来就重合这条直线长度是 0实际不产生任何绘制。但如果四个角半径都是 0路径就是四条直线围成的矩形closePath补的那条线正好闭合了右边这时候closePath是必须的不能删。有些人为了减一次调用把closePath去掉结果填充时缺一条边看着像个缺口的三角形。3.3 调用与渲染验证最小可运行的调用是这样的const canvas document.getElementById(stage); const ctx canvas.getContext(2d); ctx.beginPath(); traceRoundRect(ctx, 20, 20, 200, 120, 16); ctx.fillStyle #1f2a37; ctx.fill();如果要缓存成路径对象反复用const cardPath new Path2D(); traceRoundRect(cardPath, 20, 20, 200, 120, [16, 16, 4, 4]); // 每帧只需要 fill 一个现成对象不重新构造路径 ctx.fillStyle #1f2a37; ctx.fill(cardPath);验证画得对不对我有个土办法但特别有效先只描边不填充把lineWidth设成 1四个角放大看。如果某个角出现一小段直线叠在弧上说明半径归一化算错了如果两个角的弧线在中间打个叉说明半径超过了短边一半而没做收敛如果整个图形偏移了半个像素、边缘发虚那就是坐标对齐问题第 5 节会讲。另一个验证手段是打印路径点数。用new Path2D()构造之后你没法直接读点数但我习惯在开发阶段临时加一个计数变量在每次lineTo/quadraticCurveTo时累加正常情况应该是四角都有半径时是 8 段4 直线 4 曲线四角都是 0 时是 4 段。数字对不上就说明有分支跑错了。4. 把精度和适用面再往上推一档4.1 二次贝塞尔和真圆弧差多少这块内容我在网上很少看到有人认真算但它直接决定了要不要换方案。用二次贝塞尔的参数方程在t 0.5处取值可以得到曲线中点B(0.5) 0.25 * P0 0.5 * P1 0.25 * P2。以右上角为例P0 (xw-r, y)P1 (xw, y)P2 (xw, yr)代入得到中点坐标是(x w - 0.25r, y 0.25r)。这个点到角点(xw, y)的距离是√(0.25² 0.25²) * r ≈ 0.3536r。而真正的四分之一圆弧在 45 度方向上那个点到角点的距离是r * (√2 - 1) ≈ 0.4142r。两者相减差值约等于0.0606r。翻译成人话在圆角的正中间位置二次贝塞尔画出来的边比理论圆弧向外鼓了大约 6% 的半径。这个数字意味着什么r 8px时偏差 0.48px视网膜屏下完全无感r 20px时偏差 1.2px正常观看距离下也看不出来但r 120px那种大卡片、大按钮偏差达到 7px 以上跟设计稿放在一起对比时肉眼是能看出这个角有点方的。所以我的经验阈值是半径在 40px 以内随便用二次贝塞尔超过 80px 且要求跟设计稿严丝合缝的换成三次贝塞尔。4.2 换成三次贝塞尔的控制点系数三次贝塞尔近似 90 度圆弧有一个经典系数k 4/3 * tan(θ/4)其中θ是圆弧的角度90 度就是π/2代入得k 4/3 * tan(π/8) ≈ 0.5522847。控制点放在距离端点k * r的位置沿着切线方向延伸。以右上角为例弧的起点是(x w - r, y)切线方向是水平向右所以第一个控制点是(x w - r k*r, y)终点是(x w, y r)切线方向是垂直向下反推第二个控制点是(x w, y r - k*r)。用bezierCurveTo串起来就是const K 0.5522847498; function arcCornerTR(target, x, y, w, h, r) { const cx x w; const cy y; target.bezierCurveTo( cx - r K * r, cy, cx, cy r - K * r, cx, cy r ); }这样画出来的弧和真圆弧的最大偏差在 0.03% 量级基本可以认为是一致了。代价是路径段数没变但每段的控制点从 1 个变成 2 个路径数据量翻倍SVG 导出字符串会明显变长。我通常的做法是小半径用二次大半径用三次用一个阈值比如 60px在函数内部自动切换这样既省数据又保精度。近似方式最大径向偏差控制点数量路径字符串长度建议半径范围二次贝塞尔控制点在角点约 6% 半径每段 1 个短0 ~ 60px三次贝塞尔k0.5523约 0.03% 半径每段 2 个中60px 以上arc真圆弧00用角度描述中偏长任意4.3 描边、裁剪、命中检测的三个实操场景场景一圆角卡片描边。这里最大的坑是半像素模糊。Canvas 的stroke是沿着路径中心线向两侧各扩lineWidth / 2当lineWidth 1且路径坐标是整数时笔画正好跨越两个像素各一半浏览器做抗锯齿平均之后颜色被摊薄看起来发灰、发虚。解决办法有两种一是把整个路径平移 0.5px让笔画落在整数像素边界上二是在高 DPI 屏上先按devicePixelRatio放大画布再缩放上下文让 1 个 CSS 像素对应多个物理像素模糊问题自然消失。我一般用第二种因为第一种在lineWidth变化时要重新计算容易忘。const dpr window.devicePixelRatio || 1; const cssW 240, cssH 140; canvas.width cssW * dpr; canvas.height cssH * dpr; canvas.style.width cssW px; canvas.style.height cssH px; ctx.scale(dpr, dpr);场景二圆角裁图。头像、缩略图这类需求标准做法是save→ 建路径 →clip→drawImage→restore。ctx.save(); ctx.beginPath(); traceRoundRect(ctx, 0, 0, 96, 96, 48); ctx.clip(); ctx.drawImage(img, 0, 0, 96, 96); ctx.restore();这里的restore是必须的而且必须保证save和restore成对出现。我见过最典型的 bug 是某个分支里clip之后提前return了restore没执行导致整个画布后续所有绘制都被裁在那 96×96 的圆角里排查半天以为是图层问题。所以现在我的习惯是clip一定包在save/restore之间中间不写任何return要提前退出就先把结果算好再统一处理。场景三命中检测。做圆角按钮的点击判定ctx.isPointInPath(x, y)是首选。但它有个非常隐蔽的坑当上下文做过scale(dpr, dpr)缩放之后isPointInPath的入参并不是简单的 CSS 坐标多数浏览器实现里它拿的是未经当前变换矩阵放大的画布位图坐标。也就是说你scale(2, 2)之后路径的实际坐标被放大了两倍判定点也得跟着放大两倍否则点哪儿都不中。function hitTest(canvas, ctx, path, evt) { const rect canvas.getBoundingClientRect(); const scaleX canvas.width / rect.width; const scaleY canvas.height / rect.height; const px (evt.clientX - rect.left) * scaleX; const py (evt.clientY - rect.top) * scaleY; return ctx.isPointInPath(path, px, py); }我给的建议是别凭记忆写先在目标浏览器里打印一次验证。写一个明显在路径内的点和一个明显在路径外的点跑一遍看结果对不对把这个结论固化到自己的工具函数里。不同版本规范在这个点上确实调整过实测优先于文档。5. 高频问题与排查实录5.1 问题速查表下面这张表是我这几年积累的基本覆盖了 90% 的现场问题。遇到画得不对先按表里的现象对号入座比从头审代码快得多。现象最可能的原因排查动作修复方式多个圆角矩形连在一起中间出现乱线重复调用前没beginPath在每个图形绘制前打日志每次新建图形前补ctx.beginPath()四个角变成蝴蝶结交叉半径超过短边一半打印归一化后的半径数组逐个夹到min(w,h)/2某个角的弧线位置明显偏移半径数组顺序与路径顺序错位对照[tl,tr,br,bl]与四个角统一索引顺序描边发虚、颜色变浅路径落在半像素边界检查坐标是否为整数平移 0.5px 或启用 DPR 缩放填充后少一条边漏写closePath()看路径是否闭合补上closePath()裁图后整个画布都不见了clip后没restore搜索clip附近的控制流用save/restore严格包裹isPointInPath点不中坐标空间没换算打印入参与实际坐标对照乘canvas.width / rect.width路径复用后图形叠加复用了同一个Path2D又追加了内容检查是否重复traceRoundRect每次重建或拆成多个路径对象高分屏下边缘有锯齿没有按 DPR 放大画布检查canvas.width与 CSS 宽度关系按 DPR 设置尺寸并scale5.2 几个文档里不会写的避坑经验第一条半径数组一定要有语义锚点。我吃过一次亏某个组件里半径数组的索引顺序在不同函数里不一致一处当成[tl, tr, bl, br]另一处当成[tl, tr, br, bl]因为大部分时候四个角半径相同问题藏了两个月才在下单页的一个特定卡片上暴露出来。现在的做法是所有涉及半径的函数参数统一命名为tl, tr, br, bl并且在文件顶部写一行注释说明这是顺时针顺序不允许缩写。这种用命名约束语义的习惯比写注释管用。第二条不要用浮点数做半径的累加。做拖拽调半径的交互时r 0.1这种写法会累积浮点误差拖一会儿之后r变成15.999999999虽然视觉上看不出来但如果你的半径参与了缓存 key 的生成就会导致缓存永远命中不了、每帧重建路径。我现在的做法是交互层只存整数需要平滑的话在渲染层做插值。第三条路径复用时留意Path2D的变换继承。new Path2D(oldPath)复制的是路径数据不复制变换。如果你在ctx上做过translate然后new Path2D()里塞坐标塞进去的是当前变换空间的坐标之后的绘制会再叠加一次变换图像就跑飞了。所以缓存路径的时候坐标一定要用未变换的绝对坐标把变换留给ctx.setTransform处理。第四条做圆角动画时只改半径不要重建路径对象。早期我做过一个卡片展开的动画每帧都beginPath然后重新调traceRoundRect帧率掉得厉害。后来改成每帧只更新半径变量、重画一次路径其实差别不大真正省下来的是那些可以做Path2D缓存的部分——静态的、半径不变的元素全部预生成路径动画只改translate和透明度帧率立刻稳了。这个思路在做列表滚动、图表刷新的时候特别有用。第五条一次性检查视觉所有边界。我这几年养成的验收习惯是把半径拖到 0、拖到刚好等于短边一半、拖到超过短边一半这三个状态各看一眼。0 的时候看是不是直角一半的时候看是不是标准胶囊超过的时候看有没有交叉。这三个状态全对基本可以认为代码没有边界问题。比只测一个正常值靠谱得多。第六条把圆角矩形函数当成一个基础件来对待。它其实跟rect、arc是一个级别的原语项目里应该只有一份实现放在公共工具目录里带单元测试对画布也能测用Path2D构造之后比路径数据的快照。我见过太多项目里散落着五六份不同的圆角矩形实现每份的半径收敛策略都不一样最后视觉对不齐改一处漏五处。统一成一份哪怕它不够完美也比六份各自为政要强。我个人在做大屏和图表容器的这些年圆角矩形这个函数被复制进过至少十几个项目。最后一次重构的时候我把二次贝塞尔版本和三次贝塞尔版本合成了一个函数内部按半径阈值自动选精度调用方完全无感。真正让这个函数稳定的不是那段数学而是那些看起来跟绘制无关的东西半径的归一化规则、beginPath的归属、save/restore的包裹、DPR 的处理。这些细节写进代码之后这个函数就再也没在线上出过问题。