如何在SVG画布里添加React组件?

327 阅读3分钟

产品B:我想在编辑器里添加一个小组件,用来选日期,想办法实现一下?
这是近期遇到的一个有意(奇)思(葩)需求。我们使用了一款自研的在线编辑器(基于SVG引擎、React作为前端框架),可以绘制一些内部流程图、UML图等(图元都是定制的)。有一天产品突然突发奇想,想让图“动起来”,能够让用户在图上做一些“简单”交互,比如选个候选项、选个日期什么的。
看到这个需求我第一感觉是想放在编辑器头部不就行了嘛,但是B说不行,一定要在图元的右上角位置,紧跟着图元。

调研方案

没办法,快年底了,为了安稳度过这一年,得按要求做呀。

原生方式

最开始想到的方式是原生React组件动态渲染+绝对定位+跟随图元移动这个方案。编辑器嘛,大家懂得,编辑器上的元素可以在画布里自由拖动,整个画布也可以自由拖动。
实践下来发现问题很多:

  1. 画布整体移动时图元位置变化为频繁,跟随算法比较耗费性能,有点卡顿;加了节流进行优化后又会出现跟随不及时问题;
  2. 图元多的场景下,小组件的跟随效率大大降低,很容易出现位置错位问题。

foreignObject插槽方式

通过查阅MDN文档,找到了一个SVG元素(可能更像是一个容器)——foreignObject

SVG中的  <foreignObject>  元素允许包含来自不同的 XML 命名空间的元素。在浏览器的上下文中,很可能是 XHTML / HTML。

并且其兼容性也是完全OK的:

image.png

支持插入HTML,那不就简单了。WEB前端领域有句俗话说得好:给我一个Div,我还你一个App!
最终设想的方案如下:

  1. 在图元创建时在<g></g>标签里提前预制一个<foreignObject></foreignObject>标签并且将其DOM引用暂存;
  2. 当需要创建组件时,动态创建一个div并将其挂载到 foreignObject标签内;
  3. 调用ReactDOM.createRoot(div).render(<Component />)方法将React组件挂载到动态创建的Div里。

Demo代码如下(本文Demo使用React18+Vite5作为项目基本框架),为了简化对Svg的操作,使用了svgjs

// 编辑器代码需要脱敏,文中用一个简单的网格模拟编辑器
  const initDraw = () => {
    const $dom = svgDom.current;
    if (!$dom || init.current) {
      return;
    }
    init.current = true;
    const svgDraw = SVG().addTo($dom).size(500, 500);
    const pattern = svgDraw.pattern(20, 20, function (add) {
      const polyline = add
        .polyline("0,0 0,20 20,20 20,0 0,0")
        .fill("none")
        .stroke({ width: 2 });
      polyline.stroke({
        color: "#141516",
        width: 1,
        linecap: "round",
        linejoin: "round",
      });
    });
    svgDraw.rect(500, 500).fill(pattern);
    setDraw(svgDraw);
  };
  
  useEffect(() => {
    initDraw();
  }, []);

效果如下:

image.png

然后是动态挂载组件与卸载部分代码:

  // 创建foreignObject,并且吧React组件挂载到里面
  const mountComponent = () => {
    // 这里的draw是initDraw方法创建的,通过setState暂存起来
    if (!draw) {
      return;
    }
    const foreignObject = draw.foreignObject(200, 80).move(60, 60);
    const div = document.createElement("div");
    div.setAttribute("class", "foreign-object-div");
    div.style.setProperty("background-color", "white");
    div.style.setProperty("width", "100%");
    div.style.setProperty("height", "100%");
    foreignObject.addClass("foreign-object").add(div as unknown as Dom);
    mountDiv.current = div;
    const root = ReactDOM.createRoot(mountDiv.current);
    root.render(
      <>
        <Select placeholder="Select" style={{ width: 154 }} allowClear>
          {options.map((option, index) => (
            <Option key={option} disabled={index === 3} value={option}>
              {option}
            </Option>
          ))}
        </Select>
        <TimePicker style={{ marginTop: "10px" }} />
      </>
    );
    setRoot(root);
    setForeignObject(foreignObject);
  };

卸载组件很简单,直接调用ReactDOM.Rootunmount()方法:

  const unmountComponent = () => {
    // 卸载React组件
    root?.unmount();
    // 删除foreignObject
    foreignObject?.remove();
  };

看下效果:

Kapture 2024-11-04 at 19.37.29.gif

小结

上面以一个Demo展示了如何在一个SVG画布上动态添加React组件,其实把React换成Vue也是完全可以的,在WEB端只要有个挂载点,你想往里面填充任何内容都可自由发布。
此类场景第一时间能想到的方案是动态渲染组件+跟随定位,虽然也可以满足需求,但是性能会差很多,毕竟在页面上计算位置是一件很麻烦的事情。相反此类场景可以更多去查看MDN文档,看看“官方”提供了哪些API给开发者使用。尽量用贴合原生的方式实现,可以起到事半功倍的效果。