Fabric.js 从零封装一个可拖拽缩放的画布工作区
做画布类应用的第一件事,就是让「内容」能在一个有限的可视区域里被自由地移动、放大、缩小。听起来简单,真正动手才发现,坐标换算的坑一个接一个。这篇文章把我沉淀下来的完整方案写出来,附带可直接复现的最小实现。

一、先想清楚:我们到底在操作什么
我们通常用一个 <canvas> 作为"窗口",里面放一张更大的"画纸"(内容)。用户看到的是窗口里的一部分。传统的做法是把 window 裁出来叫"视口",把内容移动缩放全部交给 viewportTransform(视口变换矩阵) 来管。
对比两种方案:
| 方案 | 做法 | 缺点 |
|---|---|---|
| 改元素坐标 | 拖动时逐个修改每个元素的 left/top | 元素多时性能差、坐标随时乱 |
| 矩阵改视口 | 只改一个 6 元素矩阵 | 所有渲染统一走矩阵,高效且一致 |
绝大多数成熟库都走第二条路。Fabric.js 里 canvas.viewportTransform 就是那个 [a, b, c, d, e, f] 矩阵,其中 a 是缩放比例, e / f 是平移量。
二、核心三个能力
1. 基于某个点缩放
直接 setZoom 会让画面落在画布左上角,体验很糟。正确做法是"以鼠标所在点为中心缩放",即 zoomToPoint :
function zoomToPoint(canvas, point, zoom) {
const vpt = canvas.viewportTransform.slice(); // 拷贝,避免直接改
const zoomChange = zoom / canvas.getZoom();
vpt[4] = point.x - (point.x - vpt[4]) * zoomChange;
vpt[5] = point.y - (point.y - vpt[5]) * zoomChange;
vpt[0] = zoom;
vpt[3] = zoom;
canvas.setViewportTransform(vpt);
canvas.requestRenderAll();
}
关键点:先算 zoomChange ,再按比例修正平移量 e/f ,保证缩放中心不跳。
2. 拖拽平移
按住画布空白处,用"上一帧位置 - 当前位置"的差值累加进 e/f :
canvas.on('mouse:move', (opt) => {
if (!isDragging) return;
const e = opt.e;
if (!e.touches && !e.clientX) return;
const cur = e.touches ? e.touches[0] : e;
canvas.viewportTransform[4] += cur.clientX - lastX;
canvas.viewportTransform[5] += cur.clientY - lastY;
canvas.requestRenderAll();
lastX = cur.clientX;
lastY = cur.clientY;
});
3. 将某对象居中
让画纸精确地摆到窗口正中,需要反推 e/f :
function centerOnObject(canvas, obj) {
const center = obj.getCenterPoint();
const vpt = canvas.viewportTransform;
vpt[4] = canvas.width / 2 - center.x * vpt[0];
vpt[5] = canvas.height / 2 - center.y * vpt[3];
canvas.setViewportTransform(vpt);
canvas.requestRenderAll();
}
三、自适应画纸
导入一张大图时,通常希望自动缩放,让整张画纸完整落在窗口里。本质是求出"宽方向比例"和"高方向比例",取较小者,并留点边距:
function zoomAuto(canvas, obj) {
const pad = 20;
const sw = (canvas.width - pad) / obj.width; // 宽适配比
const sh = (canvas.height - pad) / obj.height; // 高适配比
const zoom = Math.min(sw, sh); // 取更小的那个
canvas.setViewportTransform(fabric.iMatrix.concat());
canvas.zoomToPoint({ x: canvas.width / 2, y: canvas.height / 2 }, zoom);
centerOnObject(canvas, obj);
canvas.requestRenderAll();
}
四、避坑合集(真实遇到的)
- 拖拽后坐标漂移 :原因多半是
viewportTransform被直接改了却没有concat克隆,导致在旧基础上累加错误。保险做法是先slice()一份再改。 - 缩放后水印/辅助线不跟 :辅助线、水印等是"覆盖层",必须监听缩放事件,把新
e/f同步给它们,否则会出现"错位叠合"。 - 缩放边界 :不加限制,用户能放大到 10000%,直接马赛克。务必夹住 min/max:
zoom = Math.max(0.05, Math.min(5, zoom));
- 触摸与鼠标事件差异 :移动端用
e.touches[0],桌面用e。务必抽一个统一的坐标取值函数,避免在业务里到处判分支。
五、总要校验
把 ZoomToPoint 做成一个可运行的最小 demo(一个 canvas + mousewheel 缩放 + 拖拽 + 一个按钮居中),跑起来验证三点:缩放中心不跳、拖拽丝滑、居中准确。核心逻辑就这三段代码,其余的只是事件接线。
把这套能力封装成独立模块后,无论是做"素材画布""标注工具"还是"编辑器",都能直接复用。
FAQ(常见问题)
问:为什么缩放后画面偏到边角而不是围绕鼠标点?
因为直接调用了 setZoom ,它会以画布左上角为锚点缩放。要围绕鼠标点缩放,必须用 zoomToPoint 并把缩放前的位置换算进新矩阵,这样缩放中心才不跳。
问: viewportTransform 可以被直接修改吗?
可以,但别在旧引用上原地累加。推荐先 slice() 拷贝一份,再修改 e/f 和缩放值,最后通过 setViewportTransform 写回,避免叠加错误导致坐标漂移。