基于Fabric.js的Canvas画布交互:拖拽、缩放、旋转与图形编辑器实现
做前端这些年我接过不少和画布交互沾边的需求。最开始用原生 Canvas画一个矩形确实很容易但要让用户能拖拽它、缩放它、旋转它就得自己动手做命中检测、维护每个图形的坐标和矩阵状态代码越写越闹心最后整个模块重构成了一场灾难。后来我换成了 Fabric.js一周时间就把一个包含拖拽、缩放、旋转、编排和属性回填的图形编辑器雏形做出来了。这篇文章就是把当时踩过的坑、走过的弯路、最后沉淀下来的通用方案完整地写出来包含可直接运行的代码和足够多的工程细节适合正在做可视化编辑器、流程图工具、报表设计器、甚至在线海报编辑器的前端同学参考。1. 为什么图形交互这块我最终放弃了原生Canvas1.1 原生Canvas的交互痛点原生 Canvas API 本身只提供绘制能力你调用ctx.fillRect()、ctx.arc()把像素渲染到画布上但渲染完成之后这块图形在代码里没有对象没有zIndex没有事件绑定也没有描述它自身位置和形态的结构化数据。想让它响应鼠标点击只能靠你自己做逆运算——监听mousedown把鼠标坐标换算到画布坐标再和所有图形的几何数据做碰撞检测图形多了以后还要考虑层级从最上层往下逐个比对。这只是第一步。第二道坎是拖拽。就算命中了图形拖拽过程中你要自己维护 偏移量 相对位置每次mousemove都重新擦除整个画布、重绘所有图形。如果图形本身有旋转角度那么计算拖拽方向、缩放控制点的位置时还需要做坐标系的旋转和逆旋转。等到加缩放、加旋转控制点时矩阵运算直接劝退了一大批人——Math.sin和Math.cos到处飞边界情况还有一堆。我做一版最基础的拖拽功能光坐标换算的代码就写了一百多行还带着几个肉眼很难发现的边界 bug。1.2 Fabric.js给了什么现成的能力Fabric.js 做的事情本质上是给 Canvas 补上了一套对象模型和事件系统。它把fabric.Rect、fabric.Circle、fabric.Path、fabric.Text等都封装成了独立的 Object每个 Object 自带left、top、scaleX、scaleY、angle、opacity、fill、stroke这些属性。你在画布上添加的每个图形都是活生生的、可以单独监听事件的对象。它内置的交互能力相当完整拖拽移动默认开启object:moving等事件随时可监听。缩放每个对象自带 8 个控制点四角 四边中点鼠标拖拽即可实时缩放。旋转对象上方有一个旋转手柄默认就能绕中心点旋转任意角度。多选、编组同时框选多个对象后可以整体拖动、缩放、旋转。序列化与恢复canvas.toJSON()和canvas.loadFromJSON()可以完整保存画布状态配合后端存储非常方便。这套机制让我从造轮子中解脱出来可以把精力放到真正的业务逻辑上限定边界、对齐辅助线、属性面板联动、撤销重做、导出图片。下面我把最小实现方案和实战中的关键细节拆开来讲。1.3 Fabric.js和Konva.js、原生Canvas怎么选很多人在选型时会在 Fabric.js 和 Konva.js 之间犹豫。直接放一张我在实际项目中用过的对比表对比维度Fabric.jsKonva.js原生Canvas上手难度低纯面向对象API文档丰富中等API和事件模型类似但生态略薄高交互全手写内置交互控件完整缩放控制点、旋转手柄开箱即用有Transform组件但通常配合React使用无序列化toJSON/loadFromJSON一站式需要自己序列化节点树无适合场景中后台编辑器、报表设计、图形标注复杂舞台调度、音视频封面编辑、游戏场景UI高性能图表、完全定制化渲染维护活跃度社区大npm周下载量高v6已经支持Tree-shaking社区也活跃但国内资料相对少不涉及组合能力原生支持fabric.Group分组支持Group和Layer分层需要自己实现我的判断是如果项目主要场景是让用户和任意图形交互并且要快速落地Fabric.js 是目前前端社区里综合成本最低的方案。如果团队对渲染性能有极致要求而且交互模式完全自定义再考虑裸 Canvas 或 Konva.js。接下来所有内容都基于 Fabric.js v5/v6 展开。2. Fabric.js最小画布搭建从安装到三个图形跑通2.1 安装方式与版本差异Fabric.js 在 v5 之前最常用的引入方式是 CDN 或 CommonJS直接全局fabric对象。v6 发布之后包体积做了优化开始支持按需引入API 也做了一些破坏性调整。这里我建议新项目直接使用 v6同时兼容地给出 v5 的写法方便大家对照。使用 npm 安装npm install fabriclatest在模块化工程中引入// 如果用的是 v6建议按需引入 import { Canvas, Rect, Circle, Triangle } from fabric; // 如果用的是 v5 或者更早的版本常见写法是 // import { fabric } from fabric;很多人在这块会被版本差异坑到。v6 里new fabric.Canvas()这种写法不是不行而是需要import * as fabric from fabric把所有导出挂到fabric命名空间下。从 v6 开始更推荐具名导入const canvas new Canvas(canvasEl, { width: 800, height: 600, backgroundColor: #fafafa });如果你的项目已经跑在 v5 上我没有建议你贸然升级因为 v6 改了部分内部接口会对一些自定义扩展点有影响后面我会专门提到。2.2 页面结构与基础样式HTML 结构非常简单只需要一个 canvas 元素div classeditor-wrap canvas idcanvasEl/canvas /div这里有一个重要细节Fabric.js 会在传入的 canvas 元素之上再包一层canvas-container并且内联设置position: relative。如果编辑器外层容器有滚动条或者做了transform: scale鼠标坐标会出现偏移。最稳妥的写法是把 canvas 的宽度高度和父容器保持一致并且不要在外层结构上做 scale 变形.editor-wrap { width: 100%; height: 600px; border: 1px solid #ddd; border-radius: 8px; overflow: hidden; } .editor-wrap canvas { display: block; width: 100%; height: 100%; }注意CSS 的width/height和 Fabric.js 初始化时的width/height要区分开Fabric 的宽高渲染的是 canvas 位图的分辨率CSS 宽高控制的是显示尺寸。如果你在初始化时设置了width: 800、height: 600而在 CSS 里又写成width: 100%那么画布会被拉伸显示。要解决高清屏的模糊问题还得自己处理devicePixelRatio这块我放到坑点篇展开。2.3 创建画布并添加三个基础图形创建画布之后添加图形就是实例化图形对象然后canvas.add()。示例import { Canvas, Rect, Circle, Triangle } from fabric; const canvas new Canvas(canvasEl, { width: 800, height: 600, backgroundColor: #fafafa, selection: true, // 允许框选 preserveObjectStacking: true, // 选中时保持原有的层级顺序 }); // 矩形 const rect new Rect({ left: 100, top: 80, width: 200, height: 120, fill: #3b82f6, stroke: #1e40af, strokeWidth: 2, rx: 8, ry: 8, shadow: 0 4px 8px rgba(0,0,0,0.15), }); // 圆形 const circle new Circle({ left: 420, top: 100, radius: 60, fill: #f59e0b, stroke: #b45309, strokeWidth: 2, }); // 三角形 const triangle new Triangle({ left: 600, top: 140, width: 120, height: 100, fill: #10b981, stroke: #047857, strokeWidth: 2, }); canvas.add(rect, circle, triangle);这里解释几个容易忽略的配置项left和top在 Fabric.js 中表示对象中心点的坐标而不是左上角。这一点和 CSS 的left/top理解完全不同。如果后续你从后端拿到的是左上角坐标需要手动加上宽度高度的一半。selection: true之后用户可以用鼠标拖出选框同时选中多个对象然后整体拖动。preserveObjectStacking这个配置比较冷门但它能保证对象被选中后不会自动置顶对维护画布层级很有用。添加完之后调用canvas.renderAll()。其实canvas.add()内部会自动触发渲染大部分场景不需要手动调用但如果你对多个对象做了属性修改比如循环里改了fill那就需要canvas.requestRenderAll()。这里推荐在需要刷新视图时用requestRenderAll()代替renderAll()它会把同一帧内的多次更新合并性能更好。2.4 让图形动起来最小交互代码实际上你什么都不用做Fabric.js 默认就允许拖拽。你可以直接按住矩形在画布里拖来拖去拖动时会显示选中框选中对象后四角和四边中点会出现白色的缩放控制点上方有一个旋转手柄。但为了做成能交差的编辑器通常要做两件事监听对象变化事件、把变化后的属性同步到侧边栏属性面板。canvas.on(object:modified, (e) { const obj e.target; if (!obj) return; // obj.left, obj.top, obj.scaleX, obj.scaleY, obj.angle 都已经更新 updatePropertyPanel(obj); });updatePropertyPanel的逻辑可以是填充右侧输入框的值function updatePropertyPanel(obj) { document.getElementById(posX).value Math.round(obj.left); document.getElementById(posY).value Math.round(obj.top); document.getElementById(scaleX).value obj.scaleX.toFixed(2); document.getElementById(scaleY).value obj.scaleY.toFixed(2); document.getElementById(angle).value Math.round(obj.angle); document.getElementById(fill).value obj.fill; }当你从属性面板输入新值修改对象时注意scaleX和对象的width之间是相乘关系。用户在属性面板里输入宽度 240你不能直接obj.width 240然后期望它变成 240px因为对象实际显示的宽度是width * scaleX。正确做法是先设置obj.scaleX 1再设置obj.width 240然后再重新渲染。3. 拖拽、缩放、旋转背后的对象模型与坐标逻辑3.1 对象的中心点和边界盒Fabric.js 每个对象都有一套非常清晰的坐标系。left/top是中心点坐标width/height是原始逻辑尺寸scaleX/scaleY是缩放倍数最终渲染出来的实际宽度是width * scaleX实际高度是height * scaleY。当用户拖拽缩放控制点时Fabric.js 内部会调整scaleX/scaleY而不会改width/height。这个设计带来的好处是你可以在代码里用obj.width和obj.height拿到原始设计尺寸配合obj.getBoundingRect()拿到渲染后的 AABB 边界盒const rect obj.getBoundingRect(); // rect.left 是图形渲染后边界盒的左上角X // rect.top 是渲染后边界盒的左上角Y // rect.width 是渲染后的总宽度已乘scaleX // rect.height 是渲染后的总高度已乘scaleY搞明白这一点以后很多坐标相关的推导会顺畅很多。我之前遇到一个需求不让图形拖出画布边界。当时我把obj.left和画布宽度直接比较发现图形旋转 45 度以后明明中心点在画布内视觉上却有一角已经跑出画布。原因就是我用错了坐标——应该用obj.getBoundingRect()得到的 AABB 边界盒去判断而不是对象中心。因为旋转后的对象是倾斜的Fabric 在内部计算拖拽边界时也要考虑旋转矩阵。3.2 控制点(Controls)的工作机制Fabric.js 的控制点体系由两部分组成controls和corner。controls定义了 9 个控制点的坐标和交互逻辑cornerStyle、cornerSize、cornerColor等属性定义了展示样式。默认的控制点行为四角控制点等比缩放按住 Shift 可以变成非等比这里要纠正——默认拖动四角是等比缩放按住 Alt 是以中心为原点缩放按住 Shift 是自由缩放。四边中点控制点水平或垂直方向拉伸。上方旋转手柄绕中心点旋转。实际项目中你可以用setControlsVisibility控制哪些控制点出现。比如只允许等比缩放不允许单边拉伸rect.setControlsVisibility({ mt: false, // 上中 mb: false, // 下中 ml: false, // 左中 mr: false, // 右中 mtr: true, // 旋转手柄 });这样用户只能拖四角进行等比缩放和旋转不会因为误拖单边导致宽高比例失衡。如果业务上需要控制最小能缩到多小最大能放到多大Fabric.js 提供了两个属性rect.set({ minScaleLimit: 0.3, maxScaleLimit: 5, });这两个属性的计算基准是scaleX/scaleY。假设当前已经缩放到scaleX 0.3用户再继续缩小就不会生效了。3.3 从鼠标坐标到画布坐标getPointer和viewportTransform大多数编辑器都会做画布整体缩放、平移。Fabric.js 的Canvas对象有viewportTransform它是一个长度为 6 的数组保存着画布的平移e/f和缩放a/d信息。当你把鼠标事件e传进canvas.getPointer(e)得到的坐标就是已经换算过 viewport 之后的画布逻辑坐标。canvas.on(mouse:down, (opt) { const pointer canvas.getPointer(opt.e); console.log(画布逻辑坐标, pointer.x, pointer.y); });这个转换非常重要因为 canvas 元素本身的getBoundingClientRect()返回的是页面坐标不经过getPointer换算就直接用的话一旦画布被缩放或平移所有命中检测和拖拽定位都会错位。还有一个常见需求把某个对象移动到鼠标当前位置。比如拖进一个图形时希望它出现在鼠标所在位置。正确做法是canvas.on(mouse:move, function(opt) { const pointer canvas.getPointer(opt.e); newRect.set({ left: pointer.x, top: pointer.y }); });这里用set而不是直接赋值newRect.left pointer.x是因为set会触发对象的属性变更回调经过setCoords()更新控制点位置这是后续事件联动的基础。3.4 旋转后的拖拽边界为什么容易算错一个很容易出错的地方是对象旋转之后我们通过obj.set({ left: obj.left deltaX })来移动对象时deltaX应该基于世界坐标系而很多人在计算时误用了对象自己的局部坐标系。举个例子当一个对象旋转了 90 度它的局部 X 轴其实对应的是世界 Y 轴方向。如果你在object:moving事件里自己做了复杂的边界计算千万不要直接用obj.getBoundingRect()的宽高去做简单的矩形碰撞判断而要考虑到角度因素或者直接把边界判断交给 Fabric 内置的交互逻辑去处理。我的经验是除非有极其特殊的需求否则不要重写 Fabric 的拖拽坐标计算尽量用它的事件体系在 事后 做校正canvas.on(object:moving, (e) { const obj e.target; const bound obj.getBoundingRect(); if (bound.left 0) { obj.set(left, obj.left - bound.left 1); } if (bound.top 0) { obj.set(top, obj.top - bound.top 1); } });这种写法不关心旋转角度只看渲染后的 AABB 边界是否越过画布边缘改起来又快又稳。4. 五个踩坑实录控制点、边界、序列化与性能4.1 坑一缩放之后图形边框越来越粗这是新手最容易踩的视觉问题。往画布添加一个strokeWidth: 2的矩形把它放大三倍肉眼看上去边框宽度变成了 6px。原因是 Fabric.js 默认把strokeWidth也作为逻辑尺寸的一部分会跟随scaleX/scaleY一起缩放。但在大多数编辑器里我们期望边框宽度不随图形缩放而变粗。解决办法是给对象设置strokeUniform: trueconst rect new Rect({ left: 100, top: 100, width: 200, height: 120, fill: #3b82f6, stroke: #1e40af, strokeWidth: 2, strokeUniform: true, // 边框不随缩放变化 });strokeUniform同样适用于圆、路径、文本等所有 Fabric 对象。需要注意的是v5 老项目里如果直接用strokeScaleEnabled false也是可以的但 v6 之后推荐统一使用strokeUniform。设置完之后缩放图形时边框始终保持 2px观感稳定很多。4.2 坑二图形被拖出画布后找不回来编辑器里用户很容易把图形拖到画布外特别是旋转后有一角挂在外面。再不限制边界图形可能整个消失只能重新添加。我见过很多线上工具干这种事体验确实差。我建议在object:moving和object:scaling事件里统一做边界限制。这里要注意的是用户在拖动过程中对象属性可能还没完全 finalize使用getBoundingRect()判断边界是最保险的。function confineToCanvas(obj, canvas) { const canvasWidth canvas.getWidth(); const canvasHeight canvas.getHeight(); const bound obj.getBoundingRect(); if (bound.left 0) { obj.set(left, obj.left - bound.left); } if (bound.top 0) { obj.set(top, obj.top - bound.top); } if (bound.left bound.width canvasWidth) { obj.set(left, obj.left - (bound.left bound.width - canvasWidth)); } if (bound.top bound.height canvasHeight) { obj.set(top, obj.top - (bound.top bound.height - canvasHeight)); } } canvas.on(object:moving, (e) { confineToCanvas(e.target, canvas); }); canvas.on(object:scaling, (e) { confineToCanvas(e.target, canvas); });注意scaling事件里对象还在动态变化中直接设置left/top可能会造成抖动。实测下来配合requestRenderAll()可以明显减缓抖动。如果还是抖可以考虑在scaling里只做 软限制等object:modified触发时再做最终钳制。4.3 坑三自定义控制点之后坐标全乱需求总是花式的。某次我需要做一个类似流程图编辑器要求每个图形右上角有一个自定义的删除按钮点击之后删除图形。用setControlVisible不行需要自己注册一个 control。Fabric.js 允许往对象的controls对象里添加自定义控制项import { Control } from fabric; const deleteControl new Control({ x: 0.5, y: -0.5, offsetX: 16, offsetY: -16, cursor: pointer, render: (ctx, left, top, styleOverride, fabricObject) { const size 24; ctx.save(); ctx.fillStyle #ef4444; ctx.beginPath(); ctx.arc(left, top, size / 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle #fff; ctx.font 14px sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(×, left, top 1); ctx.restore(); }, mouseDownHandler: (eventData, transform) { const target transform.target; target.canvas.remove(target); } }); rect.controls.deleteControl deleteControl;在自定义 control 时坐标x/y的取值区间是-0.5到0.5对应对象左上角到右下角的相对位置。(0.5, -0.5)就是右上角。offsetX/offsetY做像素级微调。这个坐标系和getBoundingRect()不同千万别拿像素值往里填我第一次就把x写成了200控制点直接飞到了十万八千里外。4.4 坑四对象多了之后拖拽明显卡顿Fabric.js 的渲染性能在对象数量达到几百上千时会明显下降尤其每个对象还带阴影、渐变、复杂路径时。做编辑器时我总结出三个立竿见影的优化手段第一关闭对象缓存或者调整缓存策略。Fabric 默认开启objectCaching每个对象会被缓存到一个离屏 canvas 上。对于静态对象这能大幅提升性能但如果对象每次都在变比如在拖拽缩放过程中被实时修改频繁重建缓存反而更慢。可以在对象拖拽期间临时关闭缓存canvas.on(object:moving, (e) { e.target.objectCaching false; }); canvas.on(object:modified, (e) { e.target.objectCaching true; canvas.requestRenderAll(); });第二控制阴影的使用范围。阴影很吃性能尤其是大范围模糊阴影。能不用的地方尽量不用实在需要阴影可以把shadow.blur控制在 8 以内。第三开启renderOnAddRemove和skipOffscreen这类渲染层面的开关并不是所有项目都适用但值得测试const canvas new Canvas(canvasEl, { renderOnAddRemove: true, skipOffscreen: true, });如果对象数量真的很大还可以考虑把静态图形区域预先toCanvasElement()合成为一张底图只在用户编辑时叠加动态层。这个方案我在后面进阶里稍微提一下。4.5 坑五toJSON后再loadFromJSON控制台报错保存画布状态是编辑器的标配功能。Fabric.js 的canvas.toJSON()会输出一个包含所有对象属性的 JSON然后你可以把它存到数据库。回显时canvas.loadFromJSON(savedData, () { canvas.renderAll(); });很多踩坑出现在两个地方一是loadFromJSON是异步的加载完后必须调用canvas.renderAll()才能看到结果很多人漏了这一步以为数据丢了其实是没重新渲染。二是toJSON()时如果对象上有自定义属性比如你在 Rect 上挂了一个nodeId字段用于业务关联默认序列化不会带上它。需要第二个参数传入要保留的属性数组const data canvas.toJSON([nodeId, nodeType, customData]);loadFromJSON之后这些自定义属性会重新挂载到对应对象上这样你就可以继续用obj.nodeId找业务数据了。如果后端存储空间敏感可以用canvas.toDatalessJSON([nodeId])。它会把路径、图片等大体积数据压缩成引用但这依赖你维护一个独立的数据源字典需要配合后端做二次解析。大多数人保守起见直接用toJSON就行。5. 进阶技巧控制点定制、对齐吸附与撤销重做5.1 用activeObject实现选中态联动编辑器里点击图形后右侧属性面板需要同步显示当前对象的属性。Fabric.js 用canvas.getActiveObject()获取当前选中对象canvas.on(selection:created, () { const obj canvas.getActiveObject(); if (obj) updatePropertyPanel(obj); }); canvas.on(selection:updated, () { const obj canvas.getActiveObject(); if (obj) updatePropertyPanel(obj); }); canvas.on(selection:cleared, () { clearPropertyPanel(); });注意区分selection:created从无选中到有选中、selection:updated从一个对象切换到另一个对象或者多选状态下增删了对象、selection:cleared取消选中。实测下来这几个事件配合使用属性面板的同步才会自然。5.2 缩放控制点的皮肤定制为了让控制点更贴合产品视觉可以统一修改 corner 样式const canvas new Canvas(canvasEl, { cornerColor: #fff, cornerStrokeColor: #3b82f6, cornerSize: 10, cornerStyle: circle, // rect | circle transparentCorners: false, borderColor: #3b82f6, borderScaleFactor: 2, // 选中边框粗细系数 rotatingPointOffset: 30, // 旋转手柄离对象上边缘的距离 });cornerSize是控制点的大小建议不要小于 8太小用户不好点。cornerStyle用圆形还是方形看产品偏好我习惯圆形视觉上轻一些。borderScaleFactor控制选中框的粗细调成 2 在 1:1 缩放下是 2px清晰不刺眼。如果某个对象的控制点要特殊化比如不可选中、不可旋转也可以在对象实例上单独覆盖rect.set({ selectable: true, hasRotatingPoint: false, // 隐藏旋转手柄 lockRotation: true, // 禁止旋转 lockScalingFlip: true, // 禁止镜像翻转 });lockScalingFlip这个属性容易被忽略。用户比如把图形缩放到宽度为负值会出现镜像翻转某些业务场景里不允许这种操作提前 lock 掉能省去很多后续兼容问题。5.3 对齐辅助线边缘吸附与中心吸附做设计工具类的产品对齐辅助线是专业感的一个重要来源。Fabric.js 本身没有内置吸附网格但这个功能可以自己实现const SNAP_THRESHOLD 8; // 吸附阈值单位px function snapObject(obj, canvas) { const objects canvas.getObjects().filter(o o ! obj o.visible); const objBound obj.getBoundingRect(); const objCenter { x: objBound.left objBound.width / 2, y: objBound.top objBound.height / 2 }; let targetLeft obj.left; let targetTop obj.top; let snapOffsetX 0; let snapOffsetY 0; // 画布边缘 if (Math.abs(objBound.left) SNAP_THRESHOLD) { snapOffsetX -objBound.left; } if (Math.abs(objBound.top) SNAP_THRESHOLD) { snapOffsetY -objBound.top; } if (Math.abs(objBound.left objBound.width - canvas.getWidth()) SNAP_THRESHOLD) { snapOffsetX canvas.getWidth() - (objBound.left objBound.width); } if (Math.abs(objBound.top objBound.height - canvas.getHeight()) SNAP_THRESHOLD) { snapOffsetY canvas.getHeight() - (objBound.top objBound.height); } // 和其他对象做中心点对齐 for (const other of objects) { const otherBound other.getBoundingRect(); const otherCenter { x: otherBound.left otherBound.width / 2, y: otherBound.top otherBound.height / 2 }; if (Math.abs(objCenter.x - otherCenter.x) SNAP_THRESHOLD) { snapOffsetX otherCenter.x - objCenter.x; } if (Math.abs(objCenter.y - otherCenter.y) SNAP_THRESHOLD) { snapOffsetY otherCenter.y - objCenter.y; } } obj.set({ left: obj.left snapOffsetX, top: obj.top snapOffsetY, }); } canvas.on(object:moving, (e) { snapObject(e.target, canvas); });这套逻辑是最简实现实际产品里还可以画一条参考线提示用户已经对齐。画辅助线可以通过canvas.add(new fabric.Line(...))动态创建、canvas.remove(line)动态移除注意别把辅助线加进业务对象列表否则toJSON时会把辅助线序列化进去回显时多出一堆线。我的做法是在序列化前先filter掉isGuideline标记的对象或者在加载后统一移除。5.4 撤销重做从快照栈到command模式Fabric.js 官方核心包没有提供撤销重做但常见的编辑器基本都离不开这个能力。最朴素的实现是维护一个快照栈const undoStack []; const redoStack []; function saveState() { redoStack.length 0; undoStack.push(JSON.stringify(canvas)); if (undoStack.length 50) undoStack.shift(); } function undo() { if (!undoStack.length) return; redoStack.push(JSON.stringify(canvas)); const state undoStack.pop(); canvas.loadFromJSON(state, () canvas.renderAll()); } function redo() { if (!redoStack.length) return; undoStack.push(JSON.stringify(canvas)); const state redoStack.pop(); canvas.loadFromJSON(state, () canvas.renderAll()); } canvas.on(object:modified, saveState);这段代码对于中小型编辑器足够用了。但注意每次loadFromJSON都是全部重建如果画布上有几千个复杂对象性能会很差。这时候需要升级为操作记录模式不保存整包 JSON只记录每个操作的type和payload然后针对性地创建对象、修改属性、删除对象。这类工程化改造比较重核心思路是把每一次修改转成一个可逆动作。另外如果你决定用第三方库开源社区有fabric-history插件底层思路就是快照栈优点是省事缺点是同样不适合超大画布。这里我把完整实现贴出来因为自己实现一遍对理解 Fabric 的事件触发时机很有帮助。5.5 导出图片生成高清PNG或JPG图形编辑完之后导出图片也是刚需。Fabric.js 提供了非常方便的导出方法const dataUrl canvas.toDataURL({ format: png, multiplier: 2, // 导出2倍清晰度 left: 0, top: 0, width: canvas.getWidth(), height: canvas.getHeight(), });然后你可以把它放到a标签里触发下载或者直接传给后端const link document.createElement(a); link.download design.png; link.href dataUrl; link.click();multiplier: 2很实用可以解决高清屏下的模糊问题。如果只想导出某一组对象可以先用canvas.getObjects()筛选目标对象再通过canvas.discardActiveObject()取消选中、临时隐藏不需要的对象导出完后再 show 回来。导出时还有一个容易被忽视的点如果画布背景是backgroundColor默认会一起导出。如果你只想导出图形本身、不想要白底需要把背景色去掉再用或者导出时设置backgroundColor: transparent覆盖。6. 关于Fabric.js版本迁移与团队协作的建议6.1 v5到v6迁移时的三个注意点Fabric.js v6 是一次大版本升级API 变化不算天翻地覆但我的项目迁移时依然踩了小坑引入方式从import { fabric } from fabric变成import { Canvas, Rect } from fabric如果你的代码里大量使用fabric.Rect全局替换成本并不小。v6 开始不再自动注册所有图形类部分扩展必须手动 import。fabric.util下的很多工具函数被重命名或拆分比如fabric.util.rotatePoint的位置变了。升级前建议全局搜一下fabric.util的使用点。如果项目里自定义扩展特别多我建议锁定 v5.x然后等有时间再规划迁移没必要在业务迭代高峰期做这种高危升级。新项目则直接上 v6后续维护成本更低。6.2 多人协作如何避免状态覆盖编辑器项目通常需要多人同时编辑同一张画布Fabric.js 本身并不处理协同。我的方案是把canvas.toJSON()产生的数据结构当作文档存储格式使用类似yjs的 CRDT 框架来同步这个 JSON每个对象用自定义字段id作为唯一键。Fabric.js 的优势在这里体现出来——它的 JSON 是可序列化的、可 diff 的不像原生 Canvas 什么都没有。实际落地时我会把每个对象的id通过toJSON([id])保留然后实现一个diff(prevState, nextState)函数对比后把增删改操作发给服务端再广播给其他人。这个过程尽量在object:modified之后触发减少网络消息频率。6.3 画布大小自适应与高清屏适配如果你的编辑器页面支持窗口拖拽调整大小必须同步调整 Fabric 画布尺寸function resizeCanvas(width, height) { canvas.setWidth(width); canvas.setHeight(height); canvas.requestRenderAll(); } window.addEventListener(resize, () { const wrap document.querySelector(.editor-wrap); resizeCanvas(wrap.clientWidth, wrap.clientHeight); });高清屏适配方案是检查devicePixelRatio然后手动设置属性const dpr window.devicePixelRatio || 1; canvas.setDimensions({ width: cssWidth * dpr, height: cssHeight * dpr }, { cssOnly: false });Fabric.js 内部有enableRetinaScaling配置但默认值有时候不够用特别是做在线设计器时导出图片还是要手动处理高清输出。7. 一组可以直接落地的封装建议用 Fabric.js 做编辑器的效率高低关键在于能把交互层和业务层解耦。我个人的做法是把画布模块封装成一个类对外暴露统一方法class FabricEditor { constructor(container, options {}) { this.canvas new Canvas(container, { width: options.width || 800, height: options.height || 600, backgroundColor: options.backgroundColor || #ffffff, preserveObjectStacking: true, }); this.bindEvents(); } addRect(attrs {}) { const rect new Rect({ left: 100, top: 100, width: 200, height: 120, fill: #3b82f6, ...attrs, }); this.canvas.add(rect); return rect; } removeActiveObject() { const active this.canvas.getActiveObject(); if (active) this.canvas.remove(active); } save() { return JSON.stringify(this.canvas); } load(state) { this.canvas.loadFromJSON(JSON.parse(state), () this.canvas.renderAll()); } bindEvents() { this.canvas.on(object:modified, (e) { // 统一在这里做状态保存 / 上报 / 属性同步 }); this.canvas.on(selection:created, (e) { // 更新属性面板 }); } }这样业务代码统一调用editor.addRect()、editor.save()、editor.load()不用到处散落canvas操作。后面即使要把 Fabric 换成别的库也只需要改这一个类内部实现业务层无感知。这是我在两个中型项目里验证过比较舒服的结构。在实际使用 Fabric.js 的过程中我还发现一个对团队比较友好的习惯把常用对象的默认样式集中定义成配置文件例如统一的边框色、控制点大小、阴影参数。这样不同开发写出来的功能不会出现一个图一个样的混乱视觉。可能刚开始会觉得这些细节不重要等画布上的对象多起来、项目进入联调阶段统一设计规范的收益会非常明显也省去反复调样式的沟通成本。