Fabric.js自定义组件




2023-05-16

blog_main_img

Fabric.js 本身给了很多基础对象:`Rect`、`Circle`、`Textbox`、`Image`、`Group`。但业务里常见的不是“画一个矩形”这么简单,而是“拖一个商品卡片”“放一个头像徽章”“编辑一个流程节点”“把组件保存成 JSON 下次还能恢复”。

什么才算一个Fabric 组件

在 Fabric.js 里,一个业务组件通常不只是一个对象,而是这几层东西的组合:

基础图形:背景、边框、图标、文本
组件元数据:id、类型、业务属性
交互控件:复制、删除、锁定、缩放、样式编辑
序列化规则:保存后能恢复成原组件
布局规则:尺寸变化时内部元素能跟着调整

Fabric.js 组件分层

如果只是一个普通视觉块,优先用 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 组合最省心

先做一个商品卡片:包含粉色背景、标题、价格、按钮。它的本质是 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

如果页面里有很多组件,直接散落 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。比如左上角删除、右上角复制、右下角打开样式面板。

Fabric.js 自定义控件

先写一个通用圆形按钮渲染函数:

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,比如刷新属性面板、同步选中状态。

样式面板:改 props,再重建内部结构

很多组件不是单纯改 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.widthprops.height 创建背景和文本宽度,这样组件看起来像真的 UI,而不是被强行拉变形的图片。

保存和恢复:JSON 是组件的存档

保存:

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 负责模板和校验

前端做画布交互,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]

这样前端传来的内容不是“想存啥就存啥”,而是必须符合组件规则。组件系统越复杂,后端校验越重要。

Fabric.js 与 Python 协作

再写一个保存接口:

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,
  });
});

撤销重做会牵涉到对象恢复、选区恢复、外部面板同步,文章里不展开太多。但有命令层,后面加这些能力不会太痛。