2023-05-16
Fabric.js 本身给了很多基础对象:`Rect`、`Circle`、`Textbox`、`Image`、`Group`。但业务里常见的不是“画一个矩形”这么简单,而是“拖一个商品卡片”“放一个头像徽章”“编辑一个流程节点”“把组件保存成 JSON 下次还能恢复”。
在 Fabric.js 里,一个业务组件通常不只是一个对象,而是这几层东西的组合:
基础图形:背景、边框、图标、文本
组件元数据:id、类型、业务属性
交互控件:复制、删除、锁定、缩放、样式编辑
序列化规则:保存后能恢复成原组件
布局规则:尺寸变化时内部元素能跟着调整
如果只是一个普通视觉块,优先用 Group 组合多个 Fabric 对象;如果组件有自己的绘制逻辑、复杂命中规则或特殊属性,再考虑扩展类。业务项目里,先组合再抽象,通常更稳。
先准备一个基础画布,并统一处理选择框、缩放角、对象缓存等默认行为。
import * as fabric from "fabric";
const canvas = new fabric.Canvas("stage", {
width: 1200,
height: 720,
backgroundColor: "#fff7fb",
preserveObjectStacking: true,
selection: true,
});
fabric.FabricObject.ownDefaults = {
...fabric.FabricObject.ownDefaults,
transparentCorners: false,
cornerColor: "#fb7185",
cornerStrokeColor: "#ffffff",
borderColor: "#fb7185",
cornerStyle: "circle",
};
preserveObjectStacking 很适合编辑器场景:选中对象时不强行改变层级。组件编辑器里,层级稳定比“选中后浮到前面”更符合预期。
Fabric 对象能转成 JSON,但你加的业务字段需要进入序列化名单,否则保存后可能丢失。官方文档里也强调了自定义属性要显式加入 customProperties。
fabric.FabricObject.customProperties = [
"componentId",
"componentType",
"componentProps",
"lockedByRule",
];
这样每个组件都能带上自己的身份:
function patchComponentMeta(object, meta) {
object.set({
componentId: meta.componentId,
componentType: meta.componentType,
componentProps: meta.componentProps || {},
lockedByRule: Boolean(meta.lockedByRule),
});
return object;
}
后面保存时,直接 canvas.toJSON() 就能保留这些字段。
先做一个商品卡片:包含粉色背景、标题、价格、按钮。它的本质是 Group,但我们给它贴上组件类型和属性。
function createProductCard(options = {}) {
const props = {
title: "Pink Headphone",
price: "¥399",
badge: "HOT",
...options.props,
};
const background = new fabric.Rect({
width: 280,
height: 170,
rx: 22,
ry: 22,
fill: "#fff1f7",
stroke: "#fb7185",
strokeWidth: 3,
originX: "center",
originY: "center",
});
const title = new fabric.Textbox(props.title, {
width: 210,
left: -110,
top: -58,
fill: "#831843",
fontSize: 22,
fontWeight: "700",
});
const price = new fabric.Text(props.price, {
left: -110,
top: -12,
fill: "#be123c",
fontSize: 28,
fontWeight: "800",
});
const badge = new fabric.Text(props.badge, {
left: 70,
top: 48,
fill: "#ffffff",
fontSize: 15,
fontWeight: "800",
backgroundColor: "#fb7185",
padding: 8,
});
const group = new fabric.Group([background, title, price, badge], {
left: options.left || 120,
top: options.top || 100,
subTargetCheck: false,
objectCaching: true,
});
return patchComponentMeta(group, {
componentId: options.id || crypto.randomUUID(),
componentType: "product-card",
componentProps: props,
});
}
canvas.add(createProductCard({ left: 160, top: 140 }));
canvas.requestRenderAll();
这里有个细节:子对象的坐标是相对 Group 的中心点安排的。这样组件整体移动、旋转、缩放都比较自然。
如果页面里有很多组件,直接散落 new fabric.Rect() 会很快变乱。更好的方式是做一个 registry,按类型创建组件。
const componentRegistry = {
"product-card": createProductCard,
"coupon-label": createCouponLabel,
"avatar-badge": createAvatarBadge,
};
function addComponent(type, options = {}) {
const factory = componentRegistry[type];
if (!factory) {
throw new Error(`Unknown component type: ${type}`);
}
const object = factory(options);
canvas.add(object);
canvas.setActiveObject(object);
canvas.requestRenderAll();
return object;
}
这样工具栏只需要调用:
addComponent("product-card", {
left: 240,
top: 180,
props: {
title: "Milk Tea Coupon",
price: "¥18",
badge: "NEW",
},
});
组件的创建逻辑集中后,后续加校验、默认样式、埋点、权限控制都更好处理。
Fabric 的控制点不只能做缩放旋转,你可以给对象挂自定义 Control。比如左上角删除、右上角复制、右下角打开样式面板。
先写一个通用圆形按钮渲染函数:
function renderRoundButton(label, fill) {
return function render(ctx, left, top) {
ctx.save();
ctx.beginPath();
ctx.arc(left, top, 15, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = "#ffffff";
ctx.stroke();
ctx.fillStyle = "#ffffff";
ctx.font = "bold 15px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, left, top + 1);
ctx.restore();
};
}
再给组件安装控件:
function installComponentControls(object) {
object.controls.deleteControl = new fabric.Control({
x: -0.5,
y: -0.5,
offsetX: -18,
offsetY: -18,
cursorStyle: "pointer",
mouseUpHandler: (_, transform) => {
const target = transform.target;
target.canvas.remove(target);
target.canvas.requestRenderAll();
return true;
},
render: renderRoundButton("×", "#fb7185"),
cornerSize: 30,
});
object.controls.cloneControl = new fabric.Control({
x: 0.5,
y: -0.5,
offsetX: 18,
offsetY: -18,
cursorStyle: "copy",
mouseUpHandler: async (_, transform) => {
const target = transform.target;
const cloned = await target.clone();
cloned.set({
left: target.left + 32,
top: target.top + 32,
componentId: crypto.randomUUID(),
});
target.canvas.add(cloned);
target.canvas.setActiveObject(cloned);
target.canvas.requestRenderAll();
return true;
},
render: renderRoundButton("+", "#a78bfa"),
cornerSize: 30,
});
return object;
}
创建组件时装上控件:
const card = installComponentControls(
createProductCard({
left: 260,
top: 180,
})
);
canvas.add(card);
自定义控件的重点是:行为要放进 control handler,而不是等对象改完后再靠事件补救。事件更适合通知外层 UI,比如刷新属性面板、同步选中状态。
很多组件不是单纯改 fill,而是改业务属性,比如标题、价格、徽章。此时建议把组件当成“可重建的 group”。
function updateProductCard(target, nextProps) {
const nextMeta = {
id: target.componentId,
props: {
...target.componentProps,
...nextProps,
},
left: target.left,
top: target.top,
scaleX: target.scaleX,
scaleY: target.scaleY,
angle: target.angle,
};
const canvas = target.canvas;
const index = canvas.getObjects().indexOf(target);
canvas.remove(target);
const fresh = installComponentControls(
createProductCard(nextMeta)
);
canvas.insertAt(index, fresh);
canvas.setActiveObject(fresh);
canvas.requestRenderAll();
return fresh;
}
为什么不直接去找 group 里的第几个子对象然后改文本?因为组件一复杂,子对象顺序很容易变。用 componentProps 当唯一数据源,重建视图会更干净。
默认缩放会把组件整体拉伸。对于卡片、流程节点、表格块这类组件,拉伸文字和按钮可能很丑。更稳的做法是把尺寸写进 props,然后根据尺寸重新布局。
function resizeProductCard(target, width, height) {
const nextWidth = Math.max(width, 180);
const nextHeight = Math.max(height, 120);
return updateProductCard(target, {
width: nextWidth,
height: nextHeight,
});
}
工厂里根据 props.width、props.height 创建背景和文本宽度,这样组件看起来像真的 UI,而不是被强行拉变形的图片。
保存:
function exportCanvasJSON() {
return canvas.toJSON([
"componentId",
"componentType",
"componentProps",
"lockedByRule",
]);
}
const json = exportCanvasJSON();
恢复时要注意 loadFromJSON 返回的是 Promise,恢复完成后再绑定控件、刷新画布。
async function loadCanvasJSON(json) {
await canvas.loadFromJSON(json);
canvas.getObjects().forEach((object) => {
if (object.componentType) {
installComponentControls(object);
}
});
canvas.requestRenderAll();
}
如果你希望恢复后完全按组件工厂重建,可以保存精简版组件数据,而不是 Fabric 的全量 JSON。
function exportComponentList() {
return canvas.getObjects()
.filter((object) => object.componentType)
.map((object) => ({
id: object.componentId,
type: object.componentType,
props: object.componentProps,
left: object.left,
top: object.top,
scaleX: object.scaleX,
scaleY: object.scaleY,
angle: object.angle,
}));
}
业务编辑器更推荐“双存储”:一份 Fabric JSON 用来完整恢复画布,一份组件列表用来给后端检索、审核、生成缩略图。
前端做画布交互,Python 后端可以负责组件模板、参数白名单和保存接口。比如用 Pydantic 约束组件结构:
from typing import Any, Literal
from pydantic import BaseModel, Field
class ProductCardProps(BaseModel):
title: str = Field(min_length=1, max_length=40)
price: str = Field(min_length=1, max_length=16)
badge: str = Field(default="NEW", max_length=12)
width: int = Field(default=280, ge=180, le=520)
height: int = Field(default=170, ge=120, le=360)
class ComponentItem(BaseModel):
id: str
type: Literal["product-card"]
props: ProductCardProps
left: float
top: float
scaleX: float = 1
scaleY: float = 1
angle: float = 0
class CanvasDraft(BaseModel):
title: str = Field(min_length=1, max_length=60)
components: list[ComponentItem]
fabric_json: dict[str, Any]
这样前端传来的内容不是“想存啥就存啥”,而是必须符合组件规则。组件系统越复杂,后端校验越重要。
再写一个保存接口:
from fastapi import FastAPI
app = FastAPI()
draft_store: dict[str, CanvasDraft] = {}
@app.post("/canvas-drafts/{draft_id}")
def save_draft(draft_id: str, draft: CanvasDraft):
draft_store[draft_id] = draft
return {
"ok": True,
"draft_id": draft_id,
"component_count": len(draft.components),
}
@app.get("/canvas-drafts/{draft_id}")
def get_draft(draft_id: str):
return draft_store[draft_id]
给前端提供组件模板也可以放在 Python:
@app.get("/component-presets")
def component_presets():
return [
{
"type": "product-card",
"label": "商品卡片",
"props": {
"title": "Pink Headphone",
"price": "¥399",
"badge": "HOT",
"width": 280,
"height": 170,
},
}
]
前端拿到 preset 后调用 addComponent(type, { props }),组件创建就能统一收口。
Fabric 事件很方便,比如对象选中后刷新右侧属性面板:
canvas.on("selection:created", syncPanel);
canvas.on("selection:updated", syncPanel);
canvas.on("selection:cleared", clearPanel);
function syncPanel(event) {
const target = event.selected?.[0];
if (!target || !target.componentType) {
clearPanel();
return;
}
renderPropertyPanel({
id: target.componentId,
type: target.componentType,
props: target.componentProps,
});
}
但事件不要滥用。比如删除、复制、缩放这种确定动作,应该在按钮、control handler 或命令函数里直接完成。事件适合“通知发生了什么”,不适合“到处补一刀”。
画布编辑器最好加命令层:所有会改变画布的行为都从命令函数走,顺手记录历史栈。
const historyStack = [];
function commitCanvasState(label) {
historyStack.push({
label,
json: exportCanvasJSON(),
});
}
function runCommand(label, action) {
action();
commitCanvasState(label);
canvas.requestRenderAll();
}
runCommand("add product card", () => {
addComponent("product-card", {
left: 220,
top: 160,
});
});
撤销重做会牵涉到对象恢复、选区恢复、外部面板同步,文章里不展开太多。但有命令层,后面加这些能力不会太痛。