fabric.js直线绘制入门:从对象模型到交互实战
第一次碰 fabric.js 的人十有八九是从往画布上塞一个矩形或者一条直线开始的。做图形编辑器、做在线标注工具、做流程图白板fabric.js 都是绕不开的老牌方案。它把 Canvas 2D 那种纯指令式的绘图封装成了操作对象Object的模式——你画布上看到的每个图形都是一个有坐标、有样式、能被选中拖动的实例这跟原始 canvas 那种“画完就忘、想改只能重绘”的底层逻辑完全是两码事。这篇文章就拿最基础的直线开刀把 fabric.js 的这套对象思维讲透。你把这根线吃透了后面玩矩形、圆形、路径、甚至自定义类都是顺水推舟的事。1. 为什么要从一条直线开始学 fabric.js1.1 fabric.js 在解决什么问题原生 Canvas 画直线三行就能写完ctx.beginPath()、ctx.moveTo()、ctx.lineTo()再ctx.stroke()收工。问题在于画完就没了。你想让用户把这条线选中、拖动、改颜色、旋转、删除原生 API 完全不给你这种能力。要么自己维护一套“图形数据模型”每次操作都重算坐标、清空画布、重新绘制要么就去找别人造好的轮子。fabric.js 就是那个轮子。它把 canvas 变成了一个“对象容器”每个图形都是容器里的一个对象。对象有自己的坐标系、变换矩阵、样式属性还内置了选中框、控制点、缩放逻辑、事件系统、序列化能力。你不需要手动维护一堆状态只需要声明“我要一条从 (50, 50) 到 (200, 50) 的线红色、3 像素粗”fabric.js 负责剩下的所有脏活渲染、命中检测、交互、碰撞全包了。我见过不少团队一开始用原生 canvas 做编辑器做到中间发现要补的东西越来越多——选区、拖拽、旋转、撤销、导出图片、缩放兼容每一个都不是省油的灯。换到 fabric.js 之后那些基础能力直接就有了团队只需要聚焦在业务逻辑上。所以它到现在仍是 HTML5 图形编辑领域的首选之一不是没有原因的。1.2 直线这个案例选得有多聪明很多入门教程喜欢用矩形起步因为矩形有left/top/width/height和人直觉一致。但我个人更推荐直线原因是它逼你理解 fabric.js 最核心的一个概念数据与渲染分离。矩形造一个对象填 width 和 height 就行你完全可以不思考内部坐标。直线不一样。fabric.Line接收的是一组点坐标数组你必须搞清楚这四个数字到底是什么意思它跟 canvas 的全局坐标系怎么对应跟对象的left/top又是什么关系。搞清楚这一层你对 fabric.js 对象模型的理解会突然清晰一大截。后面遇到 Group 组合、自由绘制、路径工具这些复杂场景你会发现它们的底层都是同一套坐标运算逻辑。另外直线也是很多真实业务的起点。在线批注、户型图标注、思维导图连线、电气图纸画导线核心图形就是直线。初学者把它当练习实际项目中它是刚需。2. 环境准备把 fabric.js 跑起来2.1 CDN 引入浏览器环境下最快的上手方式不管项目最终用什么构建工具第一次验证某个库的能力我都是直接开一个 HTML 文件用 CDN 引进来试。fabric.js 的 CDN 地址很多比较稳的是 cdnjs 和 unpkg!DOCTYPE html html langzh-CN head meta charsetUTF-8 titlefabric.js 直线入门/title script srchttps://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.0/fabric.min.js/script /head body canvas idc width800 height600 styleborder: 1px solid #ccc;/canvas script // 等你写完初始化代码后这里就是主战场 /script /body /html需要注意fabric.js 不是“1.x 还能用5.x 还能跑”那样兼容性随便的库不同大版本之间 API 变动不小。像 6.x 里一些属性名和内部处理逻辑就和 5.x 有区别。我这篇文章以 5.3.0 为准这也是目前生产环境里比较常用的稳定版。如果你用的是 6.x大部分代码能直接跑但个别细节比如strokeUniform的行为会有差异遇到问题先查版本。2.2 npm 模块方式工程化项目的正规军如果你在 Vue、React 或纯 Webpack/Vite 项目里用就走包管理npm install fabric5然后按模块引入import { Canvas, Line } from fabric; // 5.x 的命名导出 // 或者 4.x 及更早import { fabric } from fabric; const canvas new Canvas(c); const line new Line([50, 50, 200, 50], { stroke: #333, strokeWidth: 2 }); canvas.add(line);这里有个经常踩的坑fabric 5.x 支持了命名导出但网上大量老教程写的都是import { fabric } from fabric然后new fabric.Canvas()。如果你用的是 5.xfabric这个命名导出是存在的但类型定义和实际运行时偶尔会有小坑。我的建议是统一用你项目里实际安装版本的官方文档写法不要混用。原来 4.x 的项目也别急着升 5除非你确定业务里没有用到那些被移除的老 API。2.3 画布初始化一个 canvas 元素对应一个实例fabric 的入口是fabric.Canvas或 5.x 的Canvas。它接收两个参数第一个是 canvas 元素的 DOM id 或者元素本身第二个是配置对象。最简写法const canvas new fabric.Canvas(c);初始化之后就有一个 800x600 的画布实例了。注意fabric 会在这个 canvas 外层再包两个辅助层一个用于选中框和控制点的渲染一个用于交互事件处理。所以你在 DevTools 里会看到 canvas 元素旁边多了兄弟节点这是正常现象不用慌。如果你要给画布设置自适应尺寸用canvas.setDimensions()而不是直接改 canvas 元素的 width/height否则会导致控制点错位。3. 核心 API 拆解fabric.Line 的参数到底怎么传3.1 四个坐标参数的本质fabric.Line构造函数长这样new fabric.Line(points, options)points是一个数组必须是四个数字const line new fabric.Line([x1, y1, x2, y2], options);这四个数字分别是直线起点和终点在画布全局坐标系里的坐标。比如[50, 50, 250, 50]就是从画布坐标 (50, 50) 画一条到 (250, 50) 的水平线。你可以连续写多条直线对比const line1 new fabric.Line([50, 50, 250, 50], { stroke: red }); const line2 new fabric.Line([50, 80, 250, 120], { stroke: blue }); canvas.add(line1, line2);第二条是一条斜线起点在 (50, 80)终点在 (250, 120)。理解这个数组时最关键的思维转变是它不是“对象的宽高”而是“对象在空间中的两端位置”。fabric 内部会根据这两个点计算出一个包围盒bounding rect对象的left等于min(x1, x2)top等于min(y1, y2)对象宽度是|x2 - x1|高度是|y2 - y1|。所以一个[250, 50, 50, 50]的直线虽然起点写在前面但它的left依然是 50top是 50。坐标数组里谁前谁后不影响最终渲染结果只有两个点的绝对位置决定一切。3.2 必须知道的样式属性直线的样式属性全部集中在第二个参数options里。最常用的几个const line new fabric.Line([50, 50, 250, 50], { stroke: #ff6600, // 线条颜色 strokeWidth: 4, // 线条粗细单位是像素 strokeLineCap: round, // 线帽butt平头、round圆头、square方头 strokeDashArray: [8, 4], // 虚线实线段长度、空白段长度可以传多个值做复杂虚线 opacity: 0.8, // 透明度 selectable: true, // 是否可选中默认为 true evented: true, // 是否响应事件默认 true id: line-001 // 自定义属性序列化和业务标记都靠它 });这里重点聊聊容易忽略的三个方面第一fill对直线几乎无意义。矩形那种封闭图形有填充色直线没有内部区域可言。fabric 对直线的内部渲染也会自动处理为不填充。如果你给直线设置fill可能在某些版本里根本不显示或者只在特定交互下出现异常干脆别设。第二strokeLineCap的实际影响。很多人画完直线发现线头总是方方正正的尤其是斜线场景下显得僵硬就是没注意到线帽属性。round会让直线两端变成半圆弧视觉上更平滑在标注类工具里几乎必备。第三strokeDashArray的数组语义。传[8, 4]意思是实线画 8 像素空 4 像素再实线 8 像素再空 4 像素循环往复。传[10, 5, 2, 5]就是更复杂的节奏。这个属性在做测量线、辅助线、裁剪框时很常用。3.3 坐标变换与边界框为什么我的直线“跑偏”了给矩形设置left/top是直接指定位子但直线没有left/top这个直接入口——至少在你创建它时不需要。你给的是两点坐标然后 fabric 自动推导包围盒。这个机制带来一个常见疑问我设定[100, 100, 200, 100]直线确实画出来了但选中它之后选中框左上角在 (100, 100)宽 100高 0。这是一条水平线高度是 0选中框看起来就是一条 1 像素的细线。此时你拖拽选中框的角去缩放效果会非常奇怪——宽度方向缩放没问题高度方向几乎没有空间让你操作。fabric 对高度为 0 的直线做了特殊处理它允许你通过缩放产生高度或者拖拽控制点来改变线的位置。但在视觉上你需要知道这条直线对象的width和height是虚的它们是通过坐标计算出来的派生值。实际操作时你更多接触的是x1/y1/x2/y2这四个原始坐标。所以直线对象有一个独特的操作习惯想移动它拖拽选中框想改长度和方向拖拽端点控制点想精确调整直接 set 坐标。这三者各有场景我后面细讲。4. 实操从静态直线到可交互直线4.1 一个完整示例先把基础跑通把前面所有知识点拼起来一个最小可运行的页面是这样的!DOCTYPE html html langzh-CN head meta charsetUTF-8 titlefabric.js 直线入门/title script srchttps://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.0/fabric.min.js/script style body { margin: 0; padding: 20px; background: #f5f5f5; } #c { border: 1px solid #ddd; background: #fff; } /style /head body canvas idc width800 height600/canvas script const canvas new fabric.Canvas(c); const line new fabric.Line([100, 100, 500, 100], { stroke: #ff4455, strokeWidth: 4, strokeLineCap: round, strokeDashArray: [10, 5], selectable: true }); canvas.add(line); canvas.requestRenderAll(); /script /body /html最后那行canvas.requestRenderAll()值得单独说明。fabric 内部有自己的渲染调度但很多操作尤其是刚 add 对象后并不会自动触发重绘。老教程里常见canvas.renderAll()在 5.x 里推荐用requestRenderAll()——它内部做了渲染合并避免同一帧内多次操作导致性能浪费。如果你看到画面没更新十有八九是忘了调它。打开页面你应该能看到一条红色虚线可以用鼠标点选、拖动、缩放、旋转。不需要写任何监听器fabric 已经把所有交互逻辑绑好了。这就是“对象化”带来的最直观体验。4.2 动态修改直线set 方法与 setCoords 的坑实际业务里你不会只是静态画一条线更多的是根据用户操作去改线。fabric 对象提供了set()方法可以单个属性设置也可以传对象批量设置// 修改颜色和线宽 line.set(stroke, #00aa00); line.set(strokeWidth, 6); // 批量修改坐标这里才是关键 line.set({ x1: 150, y1: 150, x2: 450, y2: 300 }); // 改完之后必须做两件事 line.setCoords(); canvas.requestRenderAll();setCoords()是我见过初学者最容易忽略的。它用来重新计算对象的选中框和控制点坐标。set()改变了对象内部的数据但控制点的位置还是旧数据算出来的直接渲染就会出现“线已经变了但选中框还留在原地”的错位。每当你修改了left/top/width/height或直线的坐标属性、旋转角度、缩放比例之后都要调一次setCoords()。如果你要获取直线当前状态可以分别读x1/y1/x2/y2const x1 line.get(x1); const y2 line.get(y2);也可以用line.getBoundingRect()拿到它在画布上的实际矩形范围这在做碰撞检测和视口适配时非常有用。4.3 进阶体验鼠标实时绘制直线静态示例跑通后最能体现 fabric.js 价值的场景是“让用户在画布上用鼠标画线”。这个过程本质上是 mousedown 时记下起点、创建一条临时的零长度直线mousemove 时更新终点mouseup 时落定。fabric 的事件系统让这个功能变得很简单let isDrawing false; let currentLine null; canvas.on(mouse:down, function (opt) { const pointer canvas.getPointer(opt.e); isDrawing true; currentLine new fabric.Line([pointer.x, pointer.y, pointer.x, pointer.y], { stroke: #333333, strokeWidth: 2, strokeLineCap: round, selectable: false, evented: false }); canvas.add(currentLine); }); canvas.on(mouse:move, function (opt) { if (!isDrawing || !currentLine) return; const pointer canvas.getPointer(opt.e); currentLine.set({ x2: pointer.x, y2: pointer.y }); canvas.requestRenderAll(); }); canvas.on(mouse:up, function () { isDrawing false; currentLine null; });这里有个细节非常值得注意绘制过程中的临时直线我给selectable: false, evented: false。否则每次 mousemove 更新坐标后fabric 会重新计算选中状态可能导致拖动过程中出现意外选中干扰绘制。绘制完成后再把selectable改回 true或者干脆新建一条正式直线替换掉临时线交互体验完全不同。canvas.getPointer(opt.e)是另一个容易被忽略的点。它把浏览器原生鼠标事件中的坐标转换为相对画布左上角的坐标已经自动处理了 canvas 可能存在的缩放、偏移等样式问题。鼠标事件里如果用e.clientX直接减canvas.offsetLeft一旦页面滚动或 canvas 被 CSS 缩放就会算错。fabric 替你处理了这一切直接用它返回的坐标就行。5. 直线相关的常见问题和实战经验5.1 缩放后线宽变粗strokeUniform 的锅这是直线使用中反馈最多的一个坑。用户选中一条直线拖动角落控制点整体放大结果线的“宽度”也跟着成倍上涨3 像素变成 9 像素整个图形走样。原因在 fabric 的缩放机制交互缩放时它调整的是对象的scaleX/scaleY属性而不是重新计算x1/y1/x2/y2。strokeWidth作为对象的一个属性按常理说应该跟着缩放一起变大于是线宽被拉伸了。解决办法是给直线实际上所有图形都可以加一个属性const line new fabric.Line([100, 100, 300, 100], { stroke: #333, strokeWidth: 3, strokeUniform: true });strokeUniform从 fabric 3.x 开始支持作用是让描边宽度在对象缩放时保持视觉一致。开了之后无论你怎么拉直线线宽始终是 3 像素。这在做标注工具时几乎是必须的——用户缩放一条测量线肯定不希望线的粗细也跟着变那样画面会非常乱。但记住这个属性会带来额外的渲染计算成本。如果画布上有几百条直线且每条都开了strokeUniform某些低端设备上重绘帧率会受影响。我的建议是编辑场景开纯展示场景不开。5.2 直线太细点不准命中区域问题另一高频问题是strokeWidth: 1或 2 的细线用户鼠标很难精准选中。fabric 的默认命中检测基于对象的渲染路径一条 2 像素的细线鼠标稍微偏一点就点不中。几种可行方案增加strokeWidth最直接但改变了视觉样式不适合所有业务。用透明粗线包裹创建两条直线一条可见细线用于渲染另一条不可见但strokeWidth: 20、stroke: transparent或opacity: 0两者组成 Group让透明线负责命中。缺点是多一个对象代码复杂一点。利用perPixelTargetFind与命中缓冲区fabric 提供了targetFindTolerance属性设置在多大像素范围内都算命中。不过这个属性的支持情况和具体行为在版本间有差异5.x 中可用性一般。需要精确控制的业务我更推荐透明粗线方案它最可控。// 透明粗线方案示例 const visibleLine new fabric.Line([100, 100, 300, 100], { stroke: #333, strokeWidth: 2 }); const hitAreaLine new fabric.Line([100, 100, 300, 100], { stroke: rgba(0,0,0,0), // 完全透明 strokeWidth: 20, selectable: true, evented: true }); const group new fabric.Group([hitAreaLine, visibleLine], { selectable: true, evented: true }); canvas.add(group);注意 Group 内部对象的坐标要一致否则两条线会错位。这个方案在标注白板类产品里很实用。5.3 序列化与重绘直线被存成什么样图形编辑器的数据持久化一般通过 fabric 的 JSON 序列化实现。canvas.toJSON()导出整个画布的数据canvas.loadFromJSON()恢复。直线对象在 JSON 里长这样{ type: line, version: 5.3.0, originX: left, originY: top, left: 100, top: 100, width: 200, height: 0, stroke: #ff4455, strokeWidth: 4, strokeLineCap: round, strokeDashArray: [10, 5], x1: 100, y1: 100, x2: 300, y2: 100, scaleX: 1, scaleY: 1, angle: 0, selectable: true }恢复后功能完整但注意一个细节如果用户在交互层缩放或旋转过直线JSON 里记录的可能是变形后的scaleX/scaleY/angle以及原始的x1/y1/x2/y2。在你自己的业务代码里读取这些数据时别以为x2就是视觉终点。视觉终点要考虑缩放、旋转、加上left/top偏移计算麻烦。所以我的习惯是设计业务数据模型时不要直接把 fabric 的 JSON 当数据库表存。而是把核心业务字段起点、终点、颜色、线宽、ID单独抽出来fabric 对象上的id自定义属性做关联。这种“业务层数据与渲染层数据分离”的思路能让你在后期换库、做数据迁移时省一大笔力气。5.4 多条直线与事件处理从单兵到协同真实项目很少只有一条线。批量添加多条线、监听选中、删除、修改是绕不开的。给每条直线设独立id是第一步const lineA new fabric.Line([10, 10, 150, 10], { id: user-line-001, stroke: #333 }); const lineB new fabric.Line([10, 30, 150, 60], { id: user-line-002, stroke: #333 }); canvas.add(lineA, lineB);监听选中的对象canvas.on(selection:created, (e) { const target e.selected[0]; console.log(选中了:, target.get(id)); }); canvas.on(object:modified, (e) { const target e.target; // 用户拖拽、旋转、缩放结束后触发此时可以同步业务数据 if (target.get(type) line) { // 感知直线最终状态可以在这里把坐标存回后端 } });批量删除canvas.getObjects().filter(obj obj.get(type) line).forEach(line { canvas.remove(line); }); canvas.requestRenderAll();这里有个真实业务中常见的性能问题当画布上直线数量超过几百条时全量canvas.getObjects()遍历每次都会执行逻辑一旦复杂就会卡。我建议在业务层维护一个独立的lineMap: Mapstring, fabric.Line用id做 key查、改、删都走这个 Map而不是每次都向 fabric 要全量对象。把 fabric 当“渲染器”用把业务状态放自己手里这是大型画布应用的核心架构思路。5.5 直线与群组Group 里直线的特殊行为把多条直线放进一个fabric.Group能实现整体移动、缩放、旋转。这个能力在做“一组导线”“一组标注箭头”时很常用const line1 new fabric.Line([0, 0, 100, 0], { stroke: #333, strokeWidth: 2 }); const line2 new fabric.Line([0, 0, 50, 60], { stroke: #333, strokeWidth: 2 }); const group new fabric.Group([line1, line2], { left: 200, top: 200, selectable: true }); canvas.add(group);一个容易忽略的细节Group 里子对象的坐标是相对于 Group 自身局部坐标系的。也就是说上面示例里line1的[0, 0, 100, 0]是在 Group 内部坐标系里画的Group 整体放在全局 (200, 200) 位置。如果你把已经添加在画布上的直线再塞进 Group坐标会发生偏移很多初学者在这里栽跟头。我的建议是先构造子对象再创建 Group一起 add 到画布。如果已经 add 了又想分组需要先记录每个子对象的绝对坐标在创建 Group 时用absolutePositioned之类的配置处理逻辑比较绕不如一开始就设计好层级关系。5.6 直线渲染不显示或样式异常的排查顺序最后整理一个快速排查清单。当你的直线在画布上“隐身”或显示异常时按下面顺序查是否设置了stroke直线没有颜色就真的啥也看不见。默认stroke为空字符串时部分版本渲染空白而矩形至少还有填充。检查line.stroke是否有值。坐标是否在可视范围内[8000, 8000, 9000, 8000]画在画布外当然看不到。查看line.left/top和画布尺寸。是否忘了canvas.add()创建对象不等于显示对象这步漏了最常见。是否需要requestRenderAll()尤其在你的操作不经过 fabric 官方交互链时。strokeWidth是否为 0为 0 时在某些浏览器渲染逻辑下不显示。透明度是否为 0排查opacity和全局canvas透明度。对象是否被锁定了被visible: false会影响渲染。检查line.visible。这个排查表在实战中救了我很多次尤其是“config 里配了 stroke 但还是看不见”的案例十有八九是stroke名称拼错或者颜色值格式不合法。6. 直线还能怎么玩几个真实业务扩展思路把基础直线玩熟之后有几条常见的扩展方向可以直接上手把直线改造成箭头。fabric 没有内置现成的 Arrow 类但你可以通过两条直线叠加实现一条主线一条用三角函数算出箭头两翼的角度和长度的小线段。或者继承fabric.Line自定义一个 Arrow 类在_render方法里用 canvas 原生 API 补画箭头。这属于 fabric 高级玩法里性价比很高的一类学会了几乎能应付所有连线场景。给直线加标记和规则。测量线场景下除了线本身还要在线中间显示长度数字。这可以通过监听object:modified更新一个跟随直线位置移动的 Text 对象来实现。计算文字位置要用到直线两点的中点以及当前旋转角度的三角函数。这个需求看着小实际做起来非常锻炼你的对象坐标运算能力。自定义控制点。默认直线只有两端的缩放控制点。你可以通过fabric.Object的controls机制给直线增加中点控制点用来把一条直线拗成折线这就是进一步做连线编辑器的基础。这些方向每一条都可以单独写一篇长文但共同的底层功底就是你今天在这根直线上建立起来的理解坐标数组、理解边界框、理解setCoords()、理解事件与状态同步。fabric.js 的很多“高级功能”本质上都是这些基础能力的排列组合。我在实际项目里体会最深的一点是不要急着去抄网络上那些花哨的路径绘制代码把直线这个最小图形从创建到修改再到序列化完整走一遍你对整个对象模型的理解会扎实很多。后面就算遇到再复杂的图形你也会本能地先拆成“坐标、样式、交互、数据”四个维度去想问题而不是对着 API 文档发呆。