Canvas 裁剪 clip — 路径变身模板,非矩形可视区域的秘密
到今天为止,我们画的东西都是"画多大就显示多大"。但产品里到处是"只显示一部分"的需求:进度环只显示扇形部分、头像只显示圆形区域、雷达图只显示多边形内部。Canvas 的答案叫裁剪(clip):把当前路径变成一块"镂空模板",之后的所有绘制只有在模板内的部分才可见,画出去的自动隐形。今天回收 Day 36 的伏笔——裁剪区域也是被 save/restore 管理的状态,这也是 clip 使用中 90% 事故的答案。
目录
- 一、clip 的心智模型:镂空模板
- 二、基本用法与三大铁律
- 三、clip 与 save/restore:伏笔回收
- 四、经典应用一:扇形进度环
- 五、经典应用二:圆形头像与图片裁剪
- 六、经典应用三:雷达图
- 七、clip 的性能真相
- 八、常见坑点与最佳实践
- 九、自测挑战
- 十、总结与知识图谱
一、clip 的心智模型:镂空模板
1.1 一张纸的比喻
想象在画布上盖一张【中间镂空了形状的黑纸】:
┌──────────────────────┐
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│
│▓▓▓▓▓┌────────┐▓▓▓▓▓▓│ 黑纸(什么都看不见)
│▓▓▓▓▓│ │▓▓▓▓▓▓│ 中间镂空(唯一可见区)
│▓▓▓▓▓│ 可见 │▓▓▓▓▓▓│
│▓▓▓▓▓│ │▓▓▓▓▓▓│ 之后画的任何东西:
│▓▓▓▓▓└────────┘▓▓▓▓▓▓│ 镂空区内的显示 ✅
│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│ 黑纸遮住的部分隐形 ❌
└──────────────────────┘
1.2 与"画小一点"的本质区别
// 需求:图片只显示中间 100×100 的正方形区域
// 方案 A:画小一点(drawImage 九参数裁剪,Day 33)
ctx.drawImage(img, sx, sy, 100, 100, dx, dy, 100, 100);
// 这是"源头裁剪":从图片上抠一块来画 —— 只适用于图片
// 方案 B:clip
ctx.save();
ctx.beginPath();
ctx.rect(dx, dy, 100, 100);
ctx.clip();
ctx.drawImage(img, dx, dy); // 整张图随便画
ctx.restore();
// 这是"显示裁剪":随便画什么,出口处只放行指定区域 —— 适用于一切绘制
// clip 的独门能力:动画过渡(模板形状/位置随时间变)、
// 多种绘制统一受裁剪(图片+文字+图形一起裁)
二、基本用法与三大铁律
2.1 标准三步
// clip 的使用永远是三步:
ctx.save(); // ① 救状态(铁律一)
ctx.beginPath(); // ② 建路径(镂空模板的形状)
ctx.arc(200, 150, 80, 0, Math.PI * 2);
ctx.clip(); // 激活!路径 → 模板
ctx.drawImage(img, 0, 0, 400, 300); // ③ 随便画(只有圆内可见)
ctx.restore(); // ④ 撤掉模板(铁律一的后半)
2.2 三大铁律
铁律一:clip 必须 save/restore 配对
裁剪区域一旦激活,【后续所有绘制】都被裁——不 restore 的话
下一帧的 clearRect 也会被裁掉 → 画面"越画越少"直至全黑。
铁律二:clip 用的是【当前路径】——先 build 路径再 clip
clip() 没有参数!它吃的是 beginPath 家族攒出来的当前路径。
(忘了 beginPath → 之前的路径全成了模板 → 裁剪区域莫名其妙巨大)
铁律三:clip 在路径的 fill/stroke 之前还是之后?
——clip 与 fill/stroke 是并列的"路径消费"操作,互不冲突:
同一条路径可以既 clip 又 fill(先 clip 再 fill = 在模板内填充)
2.3 非 zero winding:clip 的填充规则
// 路径自相交时(比如两个反向的圆),裁剪区域怎么算?
ctx.clip("nonzero"); // 默认:非零环绕规则(和 fill 相同)
ctx.clip("evenodd"); // 奇偶规则:重叠区域会被"挖空"
// 典型应用:圆环(大圆 + 反向小圆)用 nonzero → 中间被挖空的环
ctx.beginPath();
ctx.arc(cx, cy, 80, 0, Math.PI * 2); // 大圆(顺时针)
ctx.arc(cx, cy, 50, 0, Math.PI * 2, true); // 小圆(逆时针 → 方向相反)
ctx.clip();
// 结果:只有 50~80 之间的圆环可见(甜甜圈模板)
三、clip 与 save/restore:伏笔回收
3.1 Day 36 的清单里写过它
会 save/restore 管理的状态:……裁剪区域 clip() 设置的区域(第 7 周)
今天正式展开:
// 嵌套 clip 的行为:交集!
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, 200, 200);
ctx.clip(); // 模板 1:左上角 200×200
ctx.save();
ctx.beginPath();
ctx.rect(100, 100, 200, 200);
ctx.clip(); // 模板 2:右下角 200×200
// ⚠ 现在的有效可见区 = 模板1 ∩ 模板2 = (100,100)-(200,200) 的 100×100
ctx.fillRect(0, 0, 500, 500); // 只有交集区域显示
ctx.restore(); // 撤掉模板 2 → 回到只有模板 1
ctx.fillRect(250, 50, 50, 50); // 模板 1 外 → 隐形!(250 > 200)
ctx.restore(); // 撤掉模板 1 → 回到自由
3.2 一个反直觉实验
// 实验:clip 后 restore,再画东西——还裁吗?
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, 100, 100);
ctx.clip();
ctx.restore(); // ← 立刻 restore,什么都没画
ctx.fillRect(200, 200, 50, 50); // 显示!✅(restore 已撤掉模板)
// 结论:clip 的作用域就是 save...restore 之间的代码块
// —— 和"变换的作用域"完全一致(Day 36/37 的知识直接迁移)
四、经典应用一:扇形进度环
4.1 需求分析
扇形进度环(工业大屏常客):
┌─────────┐
│ ⬤ 68% │ 背景:整圆灰色
│ ╲▔▔▔╱ │ 前景:68% 的扇形亮色
│ ╲ ╱ │ 实现路径 A:arc 直接画扇形(Day 30 就会)
└─────────┘ 实现路径 B:clip —— 为什么还要 B?
clip 版的杀手锏:进度扇形里要画渐变 + 图片 + 文字的复合内容时,用 arc 画扇形只是"底色",内容还是矩形的——只有 clip 能把所有东西统一裁进扇形。
4.2 完整实现
// day44-progress-ring.ts —— 扇形进度环(clip 版)
import { bootCanvas } from "../day29/canvas-boot.js";
const { ctx, cssWidth, cssHeight } = bootCanvas(document.querySelector("#board")!);
const CX = cssWidth / 2, CY = cssHeight / 2, R = 110;
/**
* 绘制扇形进度环
* @param progress 0~1 的进度
*/
function drawRing(progress: number): void {
// ===== 1. 背景环(整圆,暗色)=====
ctx.strokeStyle = "#1a2740";
ctx.lineWidth = 22;
ctx.beginPath();
ctx.arc(CX, CY, R, 0, Math.PI * 2);
ctx.stroke();
// ===== 2. 进度环(clip 大法)=====
ctx.save();
// 2a. 建扇形路径(从 -90° 顶部开始,顺时针 progress 圈)
ctx.beginPath();
ctx.moveTo(CX, CY); // 圆心(扇形的尖)
ctx.arc(CX, CY, R + ctx.lineWidth, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * progress);
ctx.closePath(); // 闭合回圆心 → 完整扇形
ctx.clip(); // 激活模板
// 2b. 模板内随便画 —— 这里画整圆的渐变前景(clip 自动裁成扇形)
const grad = ctx.createLinearGradient(CX - R, CY - R, CX + R, CY + R);
grad.addColorStop(0, "#00c6ff");
grad.addColorStop(1, "#00ff88");
ctx.strokeStyle = grad;
ctx.lineWidth = 22;
ctx.beginPath();
ctx.arc(CX, CY, R, 0, Math.PI * 2);
ctx.stroke(); // 整圆渐变 → 只露扇形 ✅
ctx.restore(); // 撤模板
// ===== 3. 中心文字(不裁剪!在 restore 之后画)=====
ctx.fillStyle = "#e6f1ff";
ctx.font = "bold 42px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${Math.round(progress * 100)}%`, CX, CY - 8);
ctx.font = "14px sans-serif";
ctx.fillStyle = "#8fa3c0";
ctx.fillText("设备负载", CX, CY + 28);
}
// 动画:进度从 0 滑到目标值(Day 37 的指数逼近)
let current = 0;
const target = 0.68;
function frame(): void {
current += (target - current) * 0.06;
ctx.clearRect(0, 0, cssWidth, cssHeight);
drawRing(current);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
4.3 结构复盘
背景环(不受裁剪)─┐
├─ 顺序很关键:restore 之后画文字,文字才不被裁
进度扇形(clip 内)─┘
五、经典应用二:圆形头像与图片裁剪
5.1 圆形头像
/**
* 圆形头像:图片裁进圆里
* ⚠ 高频坑:drawImage 会使 canvas tainted(Day 33/39)
* → 图片必须同源或 crossOrigin="anonymous" + CORS
*/
function drawAvatar(img: CanvasImageSource, cx: number, cy: number, r: number): void {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
// cover 模式:图片等比缩放至"铺满圆",居中裁剪(CSS object-fit: cover 的手写版)
const iw = (img as HTMLImageElement).naturalWidth;
const ih = (img as HTMLImageElement).naturalHeight;
const scale = Math.max((r * 2) / iw, (r * 2) / ih); // 取更大的缩放 → 铺满
const dw = iw * scale, dh = ih * scale;
ctx.drawImage(img, cx - dw / 2, cy - dh / 2, dw, dh); // 居中绘制
ctx.restore();
// 描边要在 restore 后(不然描边也被裁掉一半)
ctx.strokeStyle = "#00ff88";
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
}
5.2 任意多边形裁剪图片
/**
* 把图片裁进任意多边形(组态里的"建筑轮廓贴图")
* points: 多边形顶点数组
*/
function clipPolygon(img: CanvasImageSource, points: { x: number; y: number }[]): void {
ctx.save();
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
ctx.clip();
// 裁剪包围盒铺图
const xs = points.map((p) => p.x), ys = points.map((p) => p.y);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
ctx.drawImage(img, minX, minY, maxX - minX, maxY - minY);
ctx.restore();
// 多边形描边
ctx.strokeStyle = "rgba(0,255,136,0.8)";
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
ctx.closePath();
ctx.stroke();
}
六、经典应用三:雷达图
6.1 需求与实现
雷达图 = 多边形 clip + 多边形描边 + 同心网格:
// day44-radar.ts —— 雷达图(clip 版数据面)
/**
* 绘制雷达图
* @param values 各维度数值(0~1),如 [0.8, 0.6, 0.9, 0.7, 0.5]
* @param labels 各维度名称
*/
function drawRadar(values: number[], labels: string[]): void {
const CX = 300, CY = 220, R = 140, N = values.length;
// 顶点计算:第 i 维的方向角(从顶部开始均分)
const vertex = (i: number, scale: number): { x: number; y: number } => {
const a = -Math.PI / 2 + (Math.PI * 2 * i) / N;
return { x: CX + Math.cos(a) * R * scale, y: CY + Math.sin(a) * R * scale };
};
// ===== 1. 背景网格(三层同心多边形 + 轴线)=====
for (const s of [1 / 3, 2 / 3, 1]) {
ctx.strokeStyle = "rgba(58,74,106,0.5)";
ctx.lineWidth = 1;
ctx.beginPath();
values.forEach((_, i) => {
const p = vertex(i, s);
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
});
ctx.closePath();
ctx.stroke();
}
values.forEach((_, i) => {
const p = vertex(i, 1);
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.lineTo(p.x, p.y);
ctx.stroke();
});
// ===== 2. 数据面(clip 大法:渐变填充自动被裁成多边形)=====
ctx.save();
ctx.beginPath();
values.forEach((v, i) => {
const p = vertex(i, v); // 每维一个顶点
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
});
ctx.closePath();
ctx.clip(); // 数据多边形 → 模板
// 模板内画一个大的径向渐变矩形(中心亮边缘暗)
const grad = ctx.createRadialGradient(CX, CY, 0, CX, CY, R);
grad.addColorStop(0, "rgba(0,198,255,0.5)");
grad.addColorStop(1, "rgba(0,255,136,0.25)");
ctx.fillStyle = grad;
ctx.fillRect(CX - R, CY - R, R * 2, R * 2); // 矩形被裁成雷达形 ✅
ctx.restore();
// ===== 3. 数据面描边 + 顶点 =====
ctx.strokeStyle = "#00c6ff";
ctx.lineWidth = 2;
ctx.beginPath();
values.forEach((v, i) => {
const p = vertex(i, v);
i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y);
});
ctx.closePath();
ctx.stroke();
values.forEach((v, i) => {
const p = vertex(i, v);
ctx.fillStyle = "#e6f1ff";
ctx.beginPath();
ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2);
ctx.fill();
});
// ===== 4. 维度标签 =====
ctx.fillStyle = "#8fa3c0";
ctx.font = "13px sans-serif";
ctx.textAlign = "center";
labels.forEach((label, i) => {
const p = vertex(i, 1.18); // 标签放顶点外侧一点
ctx.fillText(label, p.x, p.y);
});
}
drawRadar([0.85, 0.6, 0.9, 0.7, 0.45, 0.75],
["效率", "能耗", "良率", "稼动", "维保", "安全"]);
6.2 为什么数据面用 clip 而不是直接 fill
// 直接 fill:纯色可行,但【渐变按整块填充】
ctx.fillStyle = grad;
ctx.fill(); // 渐变覆盖数据面 —— 效果其实一样?
// 区别在"复杂填充"场景:
// ① 数据面内要叠加纹理(createPattern)→ fill 也行
// ② 数据面内要画"多个图层"(渐变 + 高光 + 图片)→ 只有 clip 能统一裁
// ③ 动画:数据值变化时,clip 路径重建即可,内部图层不用重算
// 经验:简单场景 fill,复合内容 clip。今天用雷达图练 clip 的"复合填充"肌肉。
七、clip 的性能真相
7.1 clip 不便宜
clip 的实现代价:浏览器要把裁剪区域做成"蒙版"应用到后续每次绘制
测量参考(普通笔记本,800×600 画布):
无 clip 的 fillRect × 100: ~0.5ms
clip(复杂路径)后的 fillRect × 100:~2-4ms ← 慢 4~8 倍
结论:
✅ 静态/低频内容用 clip:随便用,无感
⚠ 每帧热循环里用 clip:掂量(尤其是复杂路径的 clip)
❌ 嵌套多层 clip 的动画:性能重灾区,重构吧
7.2 替代方案速查
| 场景 | clip 之外的选择 |
|---|---|
| 图片裁矩形 | drawImage 九参数(源头裁剪,零开销) |
| 圆形头像(静态) | 离屏画布裁一次,之后 drawImage 贴(Day 41 思想) |
| 每帧动画里的非矩形显示 | 数学画法(arc 扇形本身就是非矩形的) |
原则:clip 是"显示层裁剪",能用"源头裁剪/数学画法"替代时优先替代;clip 留给"多内容统一裁剪"的刚需场景。
八、常见坑点与最佳实践
| # | 坑 | 症状 | 解法 |
|---|---|---|---|
| 1 | clip 后不 restore | 下一帧 clearRect 也被裁 → 画面逐渐消失 | 铁律:save…clip…restore 三件套 |
| 2 | clip 前忘了 beginPath | 旧路径变成模板,裁剪区莫名巨大 | clip 前必 beginPath |
| 3 | 描边画在 clip 内 | 圆形头像的边框只剩一半 | 描边放 restore 之后 |
| 4 | clip 内画了透明度低的底色 | 想给模板区域加底色,结果叠了好几层 | 底色也是"被裁内容",注意叠加顺序 |
| 5 | 热循环里复杂 clip | 帧率骤降 | 离屏预裁剪 / 数学画法替代 |
| 6 | 跨域图 + clip + 后续 getImageData | SecurityError | crossOrigin + CORS(Day 33/39 老坑) |
| 7 | 嵌套 clip 想要并集 | 只能得到交集 | clip 天生交集;并集要在【同一条路径】里加两个子路径 |
| 8 | clip 的路径用了变换 | 模板形状"歪了" | 路径构建时就考虑变换(Day 37:先变换再建路径) |
坑 7 展开并集:
// 想要"两个圆的并集"作为模板:
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.arc(220, 100, 50, 0, Math.PI * 2); // ⚠ 不 moveTo 直接 arc?会有连接线
// 正确:每个子路径前 moveTo 到圆的起点
ctx.beginPath();
ctx.moveTo(150, 100); ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.moveTo(270, 100); ctx.arc(220, 100, 50, 0, Math.PI * 2);
ctx.clip(); // 两个圆的并集模板 ✅
九、自测挑战
- 默写题:写出 clip 的标准三步(含铁律一的完整配对),并说明为什么每步不可省。
- 预测题:下面代码执行后画面上有什么?
ctx.save();
ctx.beginPath();
ctx.rect(50, 50, 100, 100);
ctx.clip();
ctx.fillRect(0, 0, 300, 300); // ①
ctx.restore();
ctx.fillRect(200, 200, 50, 50); // ②
- 实现题:把第 4 节的进度环改成"环形"(中间挖空):进度环外径 110 内径 88,用 nonzero 规则的 clip 实现(提示:大圆顺时针 + 小圆逆时针)。
- 改错题:下面的圆形头像代码有两个 bug,找出来:
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2);
ctx.strokeStyle = "#fff";
ctx.stroke();
- 设计题:大屏的"视频监控窗格"要实现圆形放大镜效果(鼠标到哪,哪里的画面以圆形放大 2 倍显示)。设计实现方案:clip 圆 + drawImage 放大重绘的流程,写出伪代码。
- 思考题:为什么"clip 后 restore 再画东西就自由了"能证明"裁剪区域是被 save/restore 管理的状态"?(对照 Day 36 的状态清单,说说这个设计的一致性价值。)
十、总结与知识图谱
Day 44 裁剪 clip
├── 心智模型
│ └── 镂空模板:模板内可见、模板外隐形
├── 三大铁律
│ ├── save/restore 必配对(否则 clearRect 都被裁)
│ ├── 吃当前路径(先 beginPath)
│ └── 与 fill/stroke 并列的路径消费
├── 嵌套规则
│ ├── 多层 clip = 交集
│ ├── 并集 = 同一路径多子路径(moveTo 断开)
│ └── nonzero/evenodd 填充规则
├── 三大应用
│ ├── 扇形进度环(渐变裁成扇形)
│ ├── 圆形头像(cover 模式 + 描边在 restore 后)
│ └── 雷达图(复合填充裁成多边形)
├── 性能
│ ├── clip 慢 4~8 倍 → 热循环慎用
│ └── 替代:源头裁剪 / 离屏预裁 / 数学画法
└── 架构位置
└── 裁剪 = 显示层过滤(与 Day 33 源头裁剪区分)
明天预告(Day 45):碰撞检测——图元世界的物理规则:AABB 包围盒、圆-圆、圆-矩形,以及碰撞后的"推离响应"。拖拽设备图元时与相邻图元重叠 → 警示变色 + 自动推开的"磁悬浮排座"效果,是明天实战的主角。它也是编辑器(Day 49)防节点重叠的基础。