- 前端
- 图形学
【免费下载链接】fabric.js
Javascript Canvas Library, SVG-to-Canvas (& canvas-to-SVG) Parser
导读
本文以 fabric.js 核心包中的 LayoutManager 官方文档 为主线,系统讲解 fabric.js 6.x 中负责 Group(组合对象)尺寸与位置计算的 LayoutManager 模块:它的三种布局策略(FitContent、Fixed、ClipPath)各自的行为语义、performLayout的完整调用链与上下文(context)结构、以及对象增删/变换时布局如何被触发与冒泡。读完本文,你将能够理解 Group 的自动布局行为,并根据业务需求在初始化时选择合适的布局策略,或通过triggerLayout手动触发布局。
一、LayoutManager 是什么
LayoutManager(布局管理器)是 fabric.js 核心包中负责 Group 布局计算的模块。它对外暴露一个主公开方法performLayout(context),用于"给定目标 Group 与目标对象,计算并落实布局"(原文:"exposes a main public method that performs group layout given a target group and target objects")。对应实现位于 LayoutManager.ts。
从源码结构看,该模块由四部分构成,统一从 index.ts 导出:
- LayoutManager.ts —— 布局编排主类,负责生命周期调度、事件订阅、结果提交;
- LayoutStrategies/LayoutStrategy.ts —— 策略抽象基类,定义
calcLayoutResult/calcBoundingBox等钩子; - 三种具体策略:FitContentLayout.ts、FixedLayout.ts、ClipPathLayout.ts;
- 配套的 constants.ts(布局触发类型)与 types.ts(上下文类型定义)。
Group 在构造时默认挂载一个LayoutManager(Group.ts),并在初始化、增删对象、对象被变换等时机调用它。
二、非交互式 Group 的布局预期
文档明确了非交互式 Group(interactive: false,默认即为此类)的布局预期:
- 创建或更新时自动计算:LayoutManager 会在 Group 被创建或内容发生变化时计算其尺寸与位置;
- FitContent 是经典行为:默认策略 FitContent 延续了 fabric 5.x 的 Group 语义——对象加入/移除时,Group 自动"长大或缩小";
- 视觉位置不变的承诺:一般情况下,把对象包进 Group 而不指定新位置或变换,不会改变对象的视觉位置——它们在画布上该在哪还在哪,只是其坐标会改写以适配 Group 的结构(对象围绕 Group 中心组织,原文档明确提示:把对象位置与对齐想象成场景坐标,分组后相对位置会被保持);
- 明确指定变换时该预期失效:如果你是从零构建 Group 并放到指定位置、或指定了 scaleX/angle 等变换,那么对象的原始位置无意义,会被你的变换覆盖。
这一"包一层 Group 视觉不动"的保证,正是由 commitLayout 与 layoutObject 计算出的offset向量对每个子对象执行left/top平移来实现的。
三、三种布局策略
3.1 FitContent 策略(默认,fit-content)
文档描述:Group 创建时,FitContent 会收集所有子对象,求其包围盒(bounding box)与包围盒中心,并将两者赋给 Group;新增/移除对象时重新计算包围盒与新中心,所有剩余对象按偏移量平移以围绕新中心重新排布。
源码实现非常简单——FitContentLayout.ts 中FitContentLayout extends LayoutStrategy,唯一的覆写是shouldPerformLayout()恒返回true,即所有触发类型都执行布局。真正的计算逻辑在基类 LayoutStrategy.calcBoundingBox:
- 对每个子对象调用
getObjectBounds(utils.ts),得到其 tl/br 两个角点;该函数考虑了strokeUniform/strokeWidth的描边影响、对象自身矩阵以及跨 Group 的平面变换; - 合并所有角点后经
makeBoundingBoxFromPoints求出包围盒的 left/top/width/height; - 计算包围盒中心
bboxCenter,在初始化时返回{ center, size },非初始化时还需把中心经target.calcOwnMatrix()变换到 Group 的包含平面(containing plane)。
3.2 Fixed 策略(fixed)
文档描述:
- 开发者可以只指定 width或height,另一维自动计算;若两个维度都指定,包围盒仍会被计算以取得中心;
- Group 一旦以 fixed 布局初始化,之后新增对象不会改变 Group 的尺寸,因此位置也不会变;
- 布局是"固定"的,被包围盒本身裁剪;当内容比固定框大或小时,Group 以初始对象集合的初始中心为中心,固定框从中心向两侧延伸;
- Fixed 是唯一开发者可以随意改 width/height 且不会在增删对象时被重置的策略;其他策略下改尺寸只是"临时修改",下一次
performLayout即被重置。
源码中该策略只有一个关键覆写 getInitialSize:
getInitialSize({ target }, { size }): Point { return new Point(target.width || size.x, target.height || size.y); }即初始化时若目标已有width/height则尊重之(||兜底用包围盒尺寸),之后增删对象时基类 shouldPerformLayout 的判定(仅initialization/imperative/ 策略切换时执行)保证尺寸保持固定。
3.3 ClipPath 策略(clip-path)
文档描述:ClipPath 布局让 Group围绕裁剪路径而非子对象:Group 大小等于 clipPath 大小,Group 中心等于 clipPath 中心;该布局是 clipPath 依赖的。
⚠️ 文档明确标注WARNING needs fix:当前该布局行为"broken"——Group 中心在视觉上是 clipPath 中心,但 clipPath 相对于画布上对象的位置目前难以预测。这是已知待修复问题,选用此策略前需评估风险。
源码实现 ClipPathLayout.ts:
shouldPerformLayout要求目标存在clipPath;shouldLayoutClipPath返回false(clipPath 自身不被平移);calcLayoutResult用makeBoundingBoxFromPoints(getObjectBounds(target, clipPath))求 clipPath 包围盒作为 Group 尺寸;对absolutePositioned的 clipPath 直接取其在包含平面的中心,否则把 clipPath 相对中心经target.calcOwnMatrix()变换并与对象包围盒中心叠加。
3.4 如何选择与使用
LayoutManager构造函数接受一个策略实例,默认new FitContentLayout()(LayoutManager.ts)。使用时通过 Group 构造选项传入:
import { Group } from 'fabric'; import { FixedLayout, ClipPathLayout } from 'fabric'; // 经由核心包导出 const fixedGroup = new Group([rectA, rectB], { layoutManager: new LayoutManager(new FixedLayout()), width: 300, // 只给宽度,高度自动计算 }); const clipGroup = new Group([rectA, rectB], { clipPath: somePath, layoutManager: new LayoutManager(new ClipPathLayout()), });策略类均通过classRegistry.setClass(...)注册(见各策略文件末尾),因此 Group.fromObject 反序列化时能够按序列化对象中的layoutManager.strategy名还原对应策略类。
四、performLayout:调用链与上下文
4.1 调用链
文档给出核心调用流:
performLayout -> onBeforeLayout -> getLayoutResult -> commitLayout -> onAfterLayout对应 LayoutManager.performLayout 的实现:先构造StrictLayoutContext(含bubbles: true、当前strategy、prevStrategy与可用的stopPropagation()),依次调用四个生命周期方法;getLayoutResult返回空(策略跳过布局)时不执行commitLayout,但onAfterLayout仍执行。
四个阶段的职责(文档原文要点):
- onBeforeLayout:向每个对象订阅触发布局的事件(unsubscribe 回调存入 LayoutManager 内部的
_subscriptionsMap),并触发用户事件(layout:before); - getLayoutResult:返回
{ result(即 strategy.calcLayoutResult 的结果), nextCenter, prevCenter(初始化时为 0,0), offset },其中offset由prevCenter - nextCenter + correction变换到目标平面后再加上relativeCorrection得到; - commitLayout:设置 Group 的
width/height;再通过left/top(初始化时取传入的x/y或 nextCenter)或setPositionByOrigin(nextCenter, CENTER, CENTER)设置 Group 位置;随后使dirty置真以触发重绘; - onAfterLayout:触发
layout:after用户事件;Group 为空时重置其变换(源码中留有why?的疑问注释);并将 performLayout冒泡到父 Group。
4.2 上下文(context)结构
文档说明:每次performLayout调用都拿到一个 context,它始终包含:
type:字符串,用于行为分支切换;target:正在执行布局的 Group,会被修改;targets:参与布局操作的对象,会被修改。
其余属性随type变化。源码 types.ts 定义了六种触发类型(constants.ts):
| type 常量 | 值 | 触发场景 |
|---|---|---|
LAYOUT_TYPE_INITIALIZATION | initialization | Group 创建时的首次布局 |
LAYOUT_TYPE_ADDED | added | 对象被 add/insertAt 进 Group |
LAYOUT_TYPE_REMOVED | removed | 对象被 remove 出 Group |
LAYOUT_TYPE_IMPERATIVE | imperative | 开发者手动调用triggerLayout |
LAYOUT_TYPE_OBJECT_MODIFIED | object_modified | 子对象触发modified事件(变换结束) |
LAYOUT_TYPE_OBJECT_MODIFYING | object_modifying | 子对象触发moving/resizing/rotating/scaling/skewing/changed/modify_poly/modify_path等过程中事件 |
InitializationLayoutContext额外携带targets、可选的x/y(显式放置位置);ImperativeLayoutContext还支持overrides(覆盖布局结果)、bubbles、deep等选项。
4.3 事件订阅机制
attachHandlers 为每个子对象订阅 9 类修改事件:modified、moving、resizing、rotating、scaling、skewing、changed、modify_poly、modify_path。订阅时modified映射为LAYOUT_TYPE_OBJECT_MODIFIED,其余映射为LAYOUT_TYPE_OBJECT_MODIFYING(过程中不断触发布局,保证 Group 边框跟随子对象实时更新)。onBeforeLayout 中按类型处理订阅/退订:initialization与added时subscribeTargets,removed时unsubscribeTargets。
ActiveSelection(多选临时组合)使用专门的 ActiveSelectionLayoutManager:它不订阅 activeSelection 的子对象,而是把activeSelection 自身订阅到其各子对象所属父 Group 的 layoutManager上,使变换 activeSelection 能反向触发父 Group 重排。
4.4 冒泡与嵌套
onAfterLayout末尾(LayoutManager.ts)实现冒泡:若bubbles === true且target.parent也有layoutManager,则把当前 target 推入context.path后对父 Group 再执行performLayout。因此嵌套 Group 中任意层级的布局变更都会逐层向上传播,保证最外层 Group 包围盒始终正确。imperative + deep的调用则相反,会向下遍历子 Group 逐个触发(onBeforeLayout)。
五、对象增删与多选的交互行为
文档"notes"部分记录了 Group 交互语义(多数由选择/变换相关代码约束实现):
- 可以选中 Group 内的单个对象;
- 可以选中并对 Group 内对象做 rotate、scale、resize、move、flip、editText 等变换;
- 可以多选 Group 内的多个对象;选中对象 A、B 后,无法再选中 A、B 的任何父级 Group——此行为被代码显式阻止;
- 跨 Group 多选:可以从两个不同 Group 各选一个对象(A、B),并像单 Group 一样对它们做变换;
- 单对象 Group 的交互性仍有待定义(TBD)。
5.1 ADD(加入对象)
文档:把对象加入已有 Group 时,对象被加入且其余对象视觉上不移动,已有 Group 的包围盒扩展以覆盖新对象,但Group 的旋转不变。
5.2 REMOVE(移除对象)
文档:group.remove(A)移除 A 时,A 的变换状态会吸收(absorb)Group 的变换状态——这样把 A 重新放回场景后其视觉状态不变。源码可见 Group.remove 走_onAfterObjectsChange(LAYOUT_TYPE_REMOVED, ...)触发重排。文档还强调:一个节点不能同时处于两棵树上(同一对象不能属于两个 Group)。
六、事件(Events)
文档在"Events"一节仅留下一句"Resolve the namespace off issue"(待解决的命名空间问题,TBD)。但结合源码可以确认布局相关事件的实际面貌:
- Group 自身触发
layout:before/layout:after,事件载荷为{ context }/{ context, result }(类型定义见 types.ts 的LayoutBeforeEvent/LayoutAfterEvent); - 若 Group 在画布上,画布还会触发
object:layout:before/object:layout:after。
这些事件在 onBeforeLayout 与 onAfterLayout 中触发,测试 LayoutManager.spec.ts 对此有断言覆盖。开发者可借此在布局前后挂钩自定义逻辑(如约束 Group 最大尺寸)。
七、手动触发布局与已知待办
7.1 triggerLayout(imperative)
除了事件驱动的自动布局,Group 暴露 triggerLayout,内部以LAYOUT_TYPE_IMPERATIVE调用layoutManager.performLayout,可传入{ strategy, overrides, bubbles, deep }:
group.triggerLayout(); // 默认策略重排 group.triggerLayout({ deep: true }); // 递归触发子 Group group.triggerLayout({ overrides: { center: new Point(x, y), size: new Point(w, h) }, }); // 用覆盖结果强制布局7.2 设计议题(TBD)
文档列出了三个开放设计问题,社区/后续版本待定:
- 布局是否也应对
strokeWidth、fontSize等对象属性变化触发? - clip-path 策略下,clipPath 变化是否应触发布局?
layoutObjects/layoutObject是否应从 LayoutManager 移到 LayoutStrategy?订阅逻辑又该如何归属?
7.3 序列化
LayoutManager提供 toObject/toJSON,输出{ type: 'layoutManager', strategy: '<策略type>' };Group.toObject 会把layoutManager一并序列化(仅当策略非默认fit-content或includeDefaultValues为真时),fromObject再据其还原(反序列化过程中先用 NoopLayoutManager 防止触发布局,再按注册的策略类重建)。
结语
LayoutManager 是 fabric.js 从 5.x 到 6.x 架构演进的核心抽象:它将"Group 尺寸/位置如何计算"从 Group 本体中抽离为可插拔策略,并统一了初始化、增删、变换、手动触发四类布局入口,配合事件订阅与向上冒泡保证嵌套场景下布局的实时一致。理解本文的调用链与三种策略语义,即可在业务中按需选择布局模式,并在layout:before/after事件或triggerLayout上扩展自定义布局逻辑。
参考资源
- LayoutManager 官方文档(本文主体)
- LayoutManager 实现
- 策略抽象基类
- FitContent / Fixed / ClipPath 三种策略、FixedLayout.ts、ClipPathLayout.ts
- 上下文与结果类型定义、触发类型常量
- Group 中的布局接入与 triggerLayout
- 布局生命周期测试
- 前端
- 图形学
【免费下载链接】fabric.js
Javascript Canvas Library, SVG-to-Canvas (& canvas-to-SVG) Parser
相关推荐
Terminal.Gui 布局深入:Dim.Auto 自动尺寸计算全解析
Terminal.Gui 布局深入:Dim.Auto 自动尺寸计算全解析 导读 Dim.Auto 是 Terminal.Gui v2 中一类特殊的 Dim ,它
UI组件跨平台桌面应用cytoscape.js 布局尺寸详解:深入理解 `layoutDimensions()` 与 `nodeDimensionsIncludeLabels`
cytoscape.js 布局尺寸详解:深入理解 layoutDimensions 与 nodeDimensionsIncludeLabels 导读 layou
数据可视化深入解析 OpenPencil 的 `useLayout()`:构建自动布局、尺寸与网格轨道编辑面板的核心 Composable
深入解析 OpenPencil 的 useLayout :构建自动布局、尺寸与网格轨道编辑面板的核心 Composable useLayout 是 OpenPe
前端桌面应用AI 应用MCP 服务
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考