产品B:我想在编辑器里添加一个小组件,用来选日期,想办法实现一下?
这是近期遇到的一个有意(奇)思(葩)需求。我们使用了一款自研的在线编辑器(基于SVG引擎、React作为前端框架),可以绘制一些内部流程图、UML图等(图元都是定制的)。有一天产品突然突发奇想,想让图“动起来”,能够让用户在图上做一些“简单”交互,比如选个候选项、选个日期什么的。
看到这个需求我第一感觉是想放在编辑器头部不就行了嘛,但是B说不行,一定要在图元的右上角位置,紧跟着图元。
调研方案
没办法,快年底了,为了安稳度过这一年,得按要求做呀。
原生方式
最开始想到的方式是原生React组件动态渲染+绝对定位+跟随图元移动这个方案。编辑器嘛,大家懂得,编辑器上的元素可以在画布里自由拖动,整个画布也可以自由拖动。
实践下来发现问题很多:
- 画布整体移动时图元位置变化为频繁,跟随算法比较耗费性能,有点卡顿;加了节流进行优化后又会出现跟随不及时问题;
- 图元多的场景下,小组件的跟随效率大大降低,很容易出现位置错位问题。
foreignObject插槽方式
通过查阅MDN文档,找到了一个SVG元素(可能更像是一个容器)——foreignObject:
SVG中的
<foreignObject>元素允许包含来自不同的 XML 命名空间的元素。在浏览器的上下文中,很可能是 XHTML / HTML。
并且其兼容性也是完全OK的:
支持插入HTML,那不就简单了。WEB前端领域有句俗话说得好:给我一个Div,我还你一个App!
最终设想的方案如下:
- 在图元创建时在
<g></g>标签里提前预制一个<foreignObject></foreignObject>标签并且将其DOM引用暂存; - 当需要创建组件时,动态创建一个
div并将其挂载到foreignObject标签内; - 调用
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();
}, []);
效果如下:
然后是动态挂载组件与卸载部分代码:
// 创建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.Root的unmount()方法:
const unmountComponent = () => {
// 卸载React组件
root?.unmount();
// 删除foreignObject
foreignObject?.remove();
};
看下效果:
小结
上面以一个Demo展示了如何在一个SVG画布上动态添加React组件,其实把React换成Vue也是完全可以的,在WEB端只要有个挂载点,你想往里面填充任何内容都可自由发布。
此类场景第一时间能想到的方案是动态渲染组件+跟随定位,虽然也可以满足需求,但是性能会差很多,毕竟在页面上计算位置是一件很麻烦的事情。相反此类场景可以更多去查看MDN文档,看看“官方”提供了哪些API给开发者使用。尽量用贴合原生的方式实现,可以起到事半功倍的效果。