fabric.js 图形编辑器实战:从对象模型到性能优化

📅 发布时间:2026/10/13 17:05:12
fabric.js 图形编辑器实战:从对象模型到性能优化
简介Fabric.js前端项目压缩包是一个面向Web前端开发者的Canvas图形编程资源以HTML5 Canvas为基础通过Fabric.js库将矩形、圆形、文本等元素封装为可操作对象帮助开发者快速实现拖拽、旋转、缩放等交互效果。压缩包内共307个文件主要以JS代码为主149个js并配有PNG、SVG等测试与演示素材以及MD说明文档和构建相关配置整体体积14.4MB适合前端学习者与可视化项目开发者参考使用。已有415人学习下载。压缩包还涵盖SVG到Canvas的解析机制、JSDOM服务端模拟以及Node.js Canvas支持能够帮助读者理解在非浏览器环境中运行Fabric.js的方法。通过学习其中的源码与示例可掌握对象模型调用、Canvas事件处理、图形导出等核心技能服务于数据可视化、在线绘图、游戏开发等应用场景。1. 从原生 Canvas 到 fabric.js前端图形编辑的第三次重写做过前端图形项目的开发者大多有类似的经历第一次用原生 Canvas 画矩形第二次写拖拽和缩放第三次面对一堆坐标计算和重绘逻辑直接想把项目推翻重来。这个以 fabric.js 为核心的前端项目资源正好解决了这一层痛点——它把 Canvas 变成了一个数据驱动的对象容器你操作的是矩形、图片、文本这些对象而不是像素坐标。无论是要做在线海报编辑器、户型图绘制工具还是图像标注平台这份资源里的代码骨架都能直接复用。适合的人群很明确想绕开入门期大量坐标数学、直接进入业务功能开发的初中级前端工程师以及需要快速产出原型验证逻辑的产品型开发者。2. 对象模型与坐标系fabric.js 的“数据驱动”骨架2.1 Canvas 元素与 fabric.Canvas静态画布如何变成对象容器原生 Canvas 的工作方式很像一块黑板getContext(2d)拿到画笔然后fillRect()、drawImage()一路画下去画完就没了浏览器只保留像素结果。这块黑板不记得你画了什么想要移动一个矩形必须先clearRect()清屏再重画所有图形。当图形数量超过几十个或者需要频繁编辑时这种模式很容易失控。fabric.js 的处理思路完全不同。它用fabric.Canvas包装了原生canvas元素内部维护了一个对象列表。每次你在画布上添加一个图形fabric 会把底层逻辑“物化”成一个 JavaScript 对象const canvas new fabric.Canvas(c, { width: 800, height: 600, backgroundColor: #f5f5f5, preserveObjectStacking: true }); const rect new fabric.Rect({ left: 100, top: 80, width: 160, height: 120, fill: #4a90d9, rx: 8, ry: 8, stroke: #1c3f60, strokeWidth: 2, cornerColor: #4a90d9, cornerSize: 10 }); canvas.add(rect);这段代码做了三件事第一行创建了一个fabric.Canvas实例它接管了原生 canvas 元素c的渲染循环第二段定义了一个矩形对象left/top是它左上角在画布中的坐标width/height是尺寸fill是填充色rx/ry是圆角半径最后canvas.add(rect)把对象放进画布内部的对象列表。注意preserveObjectStacking这个参数——它保证对象在重绘时保持图层顺序这在多对象交叠的场景下很关键。从这一步起你不再直接操作像素而是操作对象。想移动这个矩形改它的left和top属性想换颜色改fill属性每帧的渲染和清屏由 fabric 内部自己处理。这意味着项目维护成本大幅降低业务代码只需要关心“对象有哪些属性”不需要关心“怎么把属性变成像素”。2.2 核心对象族与公共基类Rect、Circle、Text、Image 的共性fabric.js 提供了一整套预制图形类它们继承自同一个基类fabric.Object。我在实际使用中会把它们分成两组一组是基本几何形状Rect、Circle、Triangle、Ellipse、Polygon、Line另一组是复合内容Text 和 Image。// 圆形 const circle new fabric.Circle({ radius: 50, left: 300, top: 200, fill: rgba(255, 190, 50, 0.8), stroke: #b8860b, strokeWidth: 3 }); // 文本 const text new fabric.Text(标注内容, { left: 400, top: 300, fontSize: 24, fontFamily: Microsoft YaHei, fontWeight: bold, fill: #333333, editable: false }); canvas.add(circle, text);因为所有图形都继承自fabric.Object它们共享一套属性left/top决定位置scaleX/scaleY决定缩放angle决定旋转角度opacity决定透明度originX/originY决定定位原点。这套统一的属性名非常值得记住——后续做序列化、批量修改属性时都是遍历这套公共属性来操作。一个容易忽略的细节是editable: false。对于文本对象fabric 默认在双击时进入文本编辑模式但如果你只是想做展示性质的标注建议关掉它否则用户双击画布时会被输入框打断操作节奏。这是一个小参数但实际对交互体验影响很大。2.3 序列化机制为什么说这是可保存、可恢复的绘图结构真正让 fabric.js 区别于普通 Canvas 封装库的是它的序列化机制。调用canvas.toJSON()会把整个画布所有对象转成一个 JSON 结构调用canvas.loadFromJSON(json, callback)则反向恢复画布。这个能力意味着画布状态可以存进数据库也能随便发到服务端。// 保存画布状态 function saveCanvasState(canvas) { const state canvas.toJSON([id, customData]); const jsonString JSON.stringify(state); localStorage.setItem(editor_state, jsonString); } // 恢复画布状态 function restoreCanvasState(canvas) { const jsonString localStorage.getItem(editor_state); if (!jsonString) return; canvas.loadFromJSON(JSON.parse(jsonString), () { canvas.renderAll(); canvas.requestRenderAll(); }); }toJSON()接收一个数组作为参数这个数组指定额外需要序列化的自定义属性。上面代码传了[id, customData]意思是除了 fabric 默认的坐标、颜色、尺寸等属性外再把对象上的id和customData属性也一并序列化。这是多图层编辑器中很常用的做法——你有业务数据要挂到图形上又不希望它们丢失。loadFromJSON的第二个参数是回调函数在画布渲染完成后触发。这里必须注意loadFromJSON是异步操作加载完成后需要手动调用renderAll()或requestRenderAll()刷新画布。很多初用者在加载后直接操作对象结果发现画布是空的原因就是没有强制刷新。我自己的习惯是加载后立即调用requestRenderAll()它会在下一个渲染帧统一处理比renderAll()更高效。3. 交互式编辑选中、拖拽、缩放与旋转的实现逻辑3.1 内置交互机制免费的包围盒与操作手柄fabric.Canvas默认就开启了对象交互能力不需要额外编写监听器。用户点击对象fabric 会生成一个包围盒并在四角和四边显示控制手柄。拖动手柄可以缩放拖动上方旋转手柄可以旋转整个行为都在 fabric 内部完成。这个机制的核心是fabric.Object上的controls属性。每个控制手柄本质上是一个fabric.Control配置对象它定义了手柄的位置、触发的动作和光标的样式。默认配置下四角手柄控制缩放上方手柄控制旋转但也可以通过自定义配置改变行为const rect new fabric.Rect({ width: 200, height: 100, left: 100, top: 100, fill: #7cb342, lockRotation: false, lockScalingX: true, // 锁定水平缩放 lockScalingY: false, // 允许垂直缩放 hasControls: true, hasBorders: true, cornerStyle: circle, transparentCorners: false }); canvas.add(rect);lockScalingX和lockScalingY这两个属性用于锁定某方向的缩放。在实际项目中这个参数很重要——比如图片标注工具要求只能按固定比例缩放或者某些图形的宽度不能随意改变。hasControls和hasBorders控制是否显示手柄和边框如果为 false用户就完全无法通过鼠标操作这个对象。cornerStyle决定手柄的样式是圆形还是方形transparentCorners决定手柄是否半透明。这套交互是开箱即用的但它有一个特性值得注意fabric 中对象的旋转中心默认在元素的中心点。如果你希望旋转中心贴合元素某个角点需要修改originX和originY并且要注意这两个属性在缩放时也会参与计算改动后需要校准坐标。3.2 事件系统mouse:down、object:modified 与自定义事件做交互编辑器时事件系统是逃不掉的一环。fabric.js 的事件分为原生画布事件和对象事件两类。原生画布事件包括mouse:down、mouse:move、mouse:up对象事件包括object:added、object:modified、object:selected等。object:modified是在对象拖拽、缩放或旋转完成鼠标松开后触发这个事件是当前最频繁使用的回调。canvas.on(object:modified, (e) { const target e.target; if (!target) return; console.log(对象被修改, { left: target.left, top: target.top, scaleX: target.scaleX, scaleY: target.scaleY, angle: target.angle, type: target.type }); // 更新右侧属性面板中的数值 updatePropertiesPanel(target); }); canvas.on(selection:created, (e) { const selectedObjects canvas.getActiveObjects(); console.log(选中了, selectedObjects.length, 个对象); });事件回调的参数e上挂着e.target也就是被操作的对象。通过它读取修改后的坐标、缩放、角度用于实时同步到属性面板或者服务端保存。在选择多对象时getActiveObjects()返回当前所有被选中的对象数组这个情况的典型用途是批量设置颜色、批量删除等操作。一个容易出问题的地方是重复绑定。某些项目代码在initCanvas()被反复调用时会创建多个fabric.Canvas实例每个实例都绑定了事件监听器导致一次操作触发多次回调。比较好的做法是在销毁画布时调用canvas.dispose()它会把事件监听和画布元素都清理干净。没有这个习惯的话内存泄漏会在对象增删频繁的编辑器中很明显。3.3 选中态与多选group 对象的拾取逻辑fabric.Canvas上按下鼠标时fabric 会遍历画布中所有对象做命中检测hit-testing。它默认按对象列表的倒序检测——最上层对象最先被检测这样点击选中时最上面的对象优先获得焦点。默认情况下矩形只检测边框和填充区域如果要让透明区域也能被点选可以设置perPixelTargetFindconst canvas new fabric.Canvas(c, { perPixelTargetFind: true });perPixelTargetFind开启后命中检测精确到像素掩码透明区域不会响应点击。这个参数对包含 PNG 透明图片或复杂 SVG 路径的场景很有用——不加的话用户点图片的透明角落也会选中图片体验很怪。但请注意这个选项会带来额外的计算开销对象很多时需要做取舍。多选时 fabric 会把选中的对象组合成fabric.ActiveSelection它是fabric.Object的子类所以可以作为一个整体拖拽、缩放和旋转。缩放多选组时组内对象的相对位置会根据组的包围盒等比变化。这个逻辑是 fabric 内置的不需要额外处理但要注意多选组内的对象各自保有独立的scaleX/scaleY如果在多选组缩放后再单独操作某个对象它的坐标会带上组变换的修正初次接触时容易对最终坐标感到困惑。4. 实战从零搭一个带工具栏的图片标注编辑器4.1 初始化画布与基础状态管理直接进入一张完整可运行的项目结构。下面的代码建立一个基础的标注编辑器入口包含画布初始化、对象计数和画布背景设置。// editor.js - 编辑器核心逻辑 class AnnotationEditor { constructor(containerId) { this.canvas new fabric.Canvas(containerId, { width: 900, height: 600, backgroundColor: #fafafa, preserveObjectStacking: false, enableRetinaScaling: true, selection: true, defaultCursor: default, hoverCursor: pointer, movingCursor: move }); this.objectCount 0; this.history []; this._bindEvents(); } setBackground(url) { fabric.Image.fromURL(url, (img) { img.set({ scaleX: this.canvas.width / img.width, scaleY: this.canvas.height / img.height, selectable: false, evented: false, excludeFromExport: true }); this.canvas.backgroundImage img; this.canvas.requestRenderAll(); }, { crossOrigin: anonymous }); } _bindEvents() { this.canvas.on(object:added, () { this.objectCount; document.querySelector(#obj-count).textContent this.objectCount; }); this.canvas.on(object:removed, () { this.objectCount--; document.querySelector(#obj-count).textContent this.objectCount; }); } } const editor new AnnotationEditor(canvas-wrap);setBackground是标注编辑器的核心功能之一需要把上传的底图铺满画布作为标注参照。实现上通过fabric.Image.fromURL创建图片对象然后分别计算scaleX和scaleY让它匹配画布尺寸。注意两个参数selectable: false和evented: false它们让背景图既不能被选中也不会响应鼠标事件避免背景干扰标注操作。excludeFromExport: true则是让背景图在toJSON()导出时被排除只保留标注数据本身——这个细节在有“保存标注结果”需求时很关键。初始化时设置了多个光标类参数defaultCursor是画布空白处的光标hoverCursor是鼠标悬停在对象上的光标movingCursor是拖拽对象时的光标。这些参数看起来不起眼但直接影响编辑器的“手感”。4.2 添加矩形、圆形、文本标注的完整链路接下来实现工具栏的三个核心动作加矩形、加圆形、加文本。每个动作的代码会带上差异化的参数这是标注工具中很常遇到的定制需求。addRect() { const rect new fabric.Rect({ left: 50 Math.random() * 100, top: 50 Math.random() * 100, width: 180, height: 120, fill: rgba(74, 144, 217, 0.3), stroke: #4a90d9, strokeWidth: 2, strokeUniform: true, cornerColor: #4a90d9, cornerSize: 12, transparentCorners: false, borderColor: #4a90d9, objectCaching: false }); this.canvas.add(rect); this.canvas.setActiveObject(rect); this.canvas.requestRenderAll(); } addCircle() { const circle new fabric.Circle({ left: 120 Math.random() * 80, top: 80 Math.random() * 80, radius: 60, fill: rgba(231, 76, 60, 0.25), stroke: #e74c3c, strokeWidth: 2, strokeUniform: true, originX: left, originY: top }); this.canvas.add(circle); this.canvas.setActiveObject(circle); this.canvas.requestRenderAll(); } addText() { const text new fabric.Text(双击编辑文字, { left: 80, top: 60, fontSize: 22, fontFamily: Microsoft YaHei, fill: #2c3e50, editable: true, borderColor: #e67e22, cornerColor: #e67e22 }); this.canvas.add(text); this.canvas.setActiveObject(text); this.canvas.requestRenderAll(); }三个方法里有一些共性参数值得说明。strokeUniform: true是一个很容易被忽略的细节默认情况下描边宽度会随对象缩放而等比变化对象放大两倍后描边也变粗两倍设为 true 后描边保持恒定像素宽度。做标注工具时一般期望标注框的边框粗细不随缩放改变所以我会统一设置这个参数。objectCaching: false表示不对该对象做离屏缓存图形数量少时可以关掉后面在性能章再展开。setActiveObject(rect)让新添加的对象立即变为选中状态。这样用户添加完标注后不需要再手动点一下才能调整位置交互更顺手。三个方法最后都调用requestRenderAll()——这是 fabric 6.x 版本的标准做法旧版用的是renderAll()两者的差别是requestRenderAll会合并同一帧内的多次渲染请求在高频操作下性能更好。4.3 导出与保存toDataURL 和 toJSON 的完整链路标注结果最终要落到两种格式PNG 图片用于预览、分享JSON 数据用于回读编辑。下面这段代码把两条导出链写在一起同时覆盖导入场景。exportPNG() { // 临时移除选中态避免导出图片中带手柄和边框 this.canvas.discardActiveObject(); this.canvas.requestRenderAll(); const dataUrl this.canvas.toDataURL({ format: png, multiplier: 2, left: 0, top: 0, width: this.canvas.width, height: this.canvas.height, background: #ffffff }); const link document.createElement(a); link.download annotation_${Date.now()}.png; link.href dataUrl; link.click(); } exportJSON() { const json this.canvas.toJSON([ id, labelName, labelColor ]); const blob new Blob([JSON.stringify(json, null, 2)], { type: application/json }); const link document.createElement(a); link.download annotation_${Date.now()}.json; link.href URL.createObjectURL(blob); link.click(); } importJSON(jsonString) { const parsed JSON.parse(jsonString); this.canvas.loadFromJSON(parsed, () { this.canvas.requestRenderAll(); // 重新绑定图片对象的跨域处理 this.canvas.getObjects().forEach(obj { if (obj.type image) { obj.setCrossOrigin(anonymous); } }); }); }exportPNG里第一个动作是discardActiveObject()这是导出图片的最常用操作。如果不调用fabric 会把当前选中对象的控制手柄画进导出的图片里——这对交付成果来说是明显的瑕疵。toDataURL里的multiplier: 2控制导出分辨率值为 2 表示导出两倍尺寸的图片适合需要高清输出的场景。background参数指定导出图的背景色不指定的话默认透明背景。exportJSON里的toJSON传入数组把id、labelName、labelColor这些自定义属性写进 JSON 里。这类标注业务数据是原生 fabric 对象没有的必须显式声明才会被序列化。importJSON时我调用了setCrossOrigin(anonymous)这一步处理的是图片导出到非本域时的 canvas 污染问题后面在排查章节再详细展开。5. 避坑与排查五个高频故障及对应的诊断方法5.1 控件的交互区域非常小反复点不中对象现象在画布上添加了几个矩形鼠标几乎要精确到边框才能选中点击矩形内部完全无反应。元素一多选中变成了体力活。原因排查后是对象的fill属性。我用rgba(74, 144, 217, 0.3)这类半透明填充其实没问题问题出在部分图形使用了fill: transparent或根本没设置填充。fabric 默认的命中检测对完全透明区域是穿透的无论边框多宽内部都不会被识别为可点击区域。解决方法是先检查 fill如果设置了透明填充可以忽略检测但要注意在fabric.Canvas上设置perPixelTargetFind: false默认值时fabric 对无填充对象只检测边框。需要按大小响应区域同时要求范围内透明交互可以使用transparentCorners配合strokeWidth加大真实命中范围。我在标注场景里更常用的方案是给透明图形增加一个非常淡的底色调但视觉上又不影响底图观察。5.2 导出 Png 出现空白或黑块但画布看起来正常现象画布上添加了底图和标注框toDataURL 导出的 PNG 要么全白要么某个区域完全是黑块画布显示却一切正常。原因基本确定在图像跨域。当底图来自一个未设置 CORS 的第三方服务器时浏览器会对 canvas 做“污染”标记导致 toDataURL 读取像素失败。黑块则是图片尚未加载完成就出发了导出canvas 把空像素区域解析为黑色。解决分两步第一fabric.Image.fromURL时必须传crossOrigin: anonymous第二导出前用img.getSrc()校验图片的width属性是否真的大于 0而不是只看对象存在于画布中。更稳妥的做法是给图片加上load事件回调在图片onload后再启用导出按钮。如果底图来自自己的服务端确保响应头里有Access-Control-Allow-Origin。5.3 拖动对象时被意外触发点击事件现象用户从工具栏点了“矩形”然后开始拖动调整位置结果画布上不小心生成了一个新的对象。鼠标轻点也会误触。原因处理是mouse:down与mouse:up的间隔判定。fabric 的mouse:down事件不区分点击还是拖拽只要按下就会触发。我初始的代码把添加对象监听绑在mouse:down上所以拖拽的第一步就已经生成了新对象。解决方法是改用mouse:up且加上位移判断记录mouse:down时的坐标在mouse:up时计算位移差只有位移小于 3px 才视为“点击”并添加对象。另外需要检查 canvas 的skipTargetFind是否为 false为 true 时所有对象都不可选中会出现点击无反应也容易造成这类误判。5.4 用 loadFromJSON 恢复后图片全部拿不到现象JSON 导出成功importJSON 后坐标、缩放都在但所有图片对象都变成空白占位。控制台没有任何报错。原因卡在loadFromJSON的异步特性。它不是同步恢复图片对象内部需要重新异步加载资源。如果 callBack 里马上操作对象图片数据还没就绪访问img.width或尝试导出就会看到异常值。另一个常见情况是图片的 src 是相对路径页面从其它入口加载时路径失效。解决方式是给图片重新绑定加载事件。我一般在loadFromJSON的回调里遍历所有 image 对象调用image.setSrc(image.getSrc(), () { ... })触发重新加载。对于需要保证“恢复后能立即导出”的需求最好将图片资源改为 base64 或同域 url并在加载回调里打标记等待全部就绪后再允许导出操作。5.5 高频调用 add/remove 操作导致画布卡顿掉帧现象编辑器支持用户连续添加多个标注框点一次就生成一个对象主线程明显卡顿FPS 下降明显。原因分析是 fabric 默认每个对象都会做离屏缓存objectCaching。对象几何、样式变化后fabric 要重新生成缓存快照频繁 add/remove 导致大量缓存失效和重建。解决方式分两个方向一是临时缓存优化对高频操作的画布设置canvas.dispose()前的缓存回收或者对单个对象设置objectCaching: false让它实时重绘不缓存二是控制渲染频率canvas.requestRenderAll()会合并同一帧内的多次请求所以连续 add 时可以合并所有变更后统一调用一次并用renderOnAddRemove: false避免每次增删都自动触发重绘。增加对象后手动requestRenderAll()对象少时影响很小但我通常建议在新增操作里显式关闭renderOnAddRemove改为在批量操作结束后统一渲染。6. 性能优化与自定义控件让编辑器再快一步6.1 渲染与缓存的四个控制开关fabric.js 对高频重绘场景提供了几个关键开关合理组合能在实际体验上拉开梯度用得对可以让编辑器在元素密集时保持手感。renderOnAddRemove: false可以避免频繁添加删除对象时自动重绘skipTargetFind在不需要鼠标选中对象时关闭命中检测objectCaching控制单个对象是否使用离屏缓存enableRetinaScaling控制高分屏下是否按 devicePixelRatio 做像素映射。// 高频操作的优化配置示例 const canvas new fabric.Canvas(c, { renderOnAddRemove: false, // 增删对象不自动重绘 skipTargetFind: false, // 需要交互时保持开启 enableRetinaScaling: true, // 保持高分屏清晰度 selection: true }); // 批量添加对象后统一渲染 function addBatchRect(count) { for (let i 0; i count; i) { const rect new fabric.Rect({/* 参数 */}); canvas.add(rect); } // 手动触发一次渲染合并所有绘制动作 canvas.requestRenderAll(); }requestRenderAll()自动把同一事件循环内的多次请求合并这是多对象批量导入期间不会掉帧的主要原因。如果你重写为renderAll()每一次 add 都会强制同步绘制卡顿会很明显。6.2 自定义控件给标注框加一个删除按钮fabric 自带的控件只有缩放、旋转之类但标注工具几乎总要一个删除入口。与其在外部用点击事件判断不如直接在对象的控制柄列表里加一个自定义控件坐标跟随对象移动点击时直接触发删除回调。// 定义一个名为删除的自定义控件 const deleteControl new fabric.Control({ x: 0.5, // 位置相对对象中心点的偏移比例 y: -0.5, offsetX: 0, offsetY: -24, // 向上偏移 24px手柄放置在右上角偏上 cursorStyle: pointer, sizeX: 24, sizeY: 24, cornerSize: 24, actionHandler: () {} }); fabric.Object.prototype.controls.deleteControl deleteControl; // 在事件回调中捕获点击 canvas.on(mouse:down, (e) { if (e.target e.target.controls e.target.controls.deleteControl) { // 判断点击位置与 deleteControl 的距离 const pointer canvas.getPointer(e.e); if (Math.abs(pointer.x - e.target.getCenterPoint().x) 20 Math.abs(pointer.y - (e.target.getCenterPoint().y - 24)) 20) { canvas.remove(e.target); } } });fabric.Control的核心参数是x和y它们表示控件锚点相对于对象中心点的偏移比例。x: 0.5, y: -0.5表示把控件放在对象的右上角offsetX和offsetY再做像素级微调让删除按钮悬停在对象外部上方。actionHandler原本是控件拖拽时的处理逻辑删除功能用不到所以传一个空函数占位。用这种方式做自定义按钮有个天然优势按钮位置会随对象缩放、旋转自动更新不需要在每次object:modified后手动计算坐标。这个模式在前端图形编辑器的二次开发中非常常用尽量保留这个思路。6.3 对象数量过大的降级方案与性能边界当画布中对象数量超过几百个时只靠缓存优化还是不够。我遇到过的问题是画布上有大约 600 个标注框时拖拽明显迟滞用手持设备直接卡顿。此时通常需要按照实际场景选择降级策略。第一个方式是关闭对象缓存并优化渲染循环这只对内存占用有帮助对帧率改善有限。第二个方式是用服务端合成图片替换 client 对象渲染。把标注结果做 JSON 保存同时由服务端渲染一张静态 PNG 作为预览前台只读取图片不回放全部对象。第三种是把画布切分成多个区域只在视口内渲染可见对象但 fabric 并不直接支持视口裁剪实现成本比较高。所以我对这类项目的建议是标注编辑器尽量只保留当前编辑需要的对象超出阈值时把历史标注数据归档到 JSON 文件预览通过导出的 PNG 完成不要试图让 fabric 在浏览器里长期承载大量图形对象。从那以后我每次做图形编辑器都会先定一个性能上限预算用文档或者注释写明什么时候只渲染近区域对象、什么时候直接切换静态底图拒绝在画布上堆无上限的元素。这对前端图形项目的长期维护帮助很大希望帮到你。本文还有配套的精品资源点击获取