最近在开发一个流程编排系统时,遇到了一个有趣的视觉需求:如何让流程图中的连接线具备动态效果,比如发光或流动动画?传统的流程图工具通常只提供静态线条,而我们需要通过LogicFlow这个开源流程图引擎结合React来实现自定义的动态边效果。
LogicFlow作为一款轻量级的流程图编辑框架,其核心优势在于高度可定制的节点和边。通过继承内置的折线类(PolylineEdge),我们可以完全控制边的渲染逻辑和交互行为。这种动态边效果特别适合需要突出显示关键路径、实时数据流向或重要状态变更的业务场景,比如:
首先确保项目已经配置好React环境(建议使用React 17+)并安装LogicFlow核心库:
bash复制npm install @logicflow/core --save
# 如需要扩展功能
npm install @logicflow/extension --save
创建基础流程图组件:
javascript复制import LogicFlow from '@logicflow/core';
import { useEffect, useRef } from 'react';
function FlowChart() {
const containerRef = useRef(null);
const lfRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return;
const lf = new LogicFlow({
container: containerRef.current,
grid: true,
width: 800,
height: 600
});
lfRef.current = lf;
lf.render();
}, []);
return <div ref={containerRef} style={{ height: '100%' }} />;
}
实现动态边效果主要有三种技术路线:
SVG滤镜+CSS动画:
Canvas绘制+requestAnimationFrame:
SVG+SMIL动画:
经过对比,我们选择方案2作为基础,因为它提供了最大的灵活性和控制力,能够实现我们需要的各种复杂动态效果。
首先创建自定义边类,继承自LogicFlow的PolylineEdge:
javascript复制import { PolylineEdge, PolylineEdgeModel } from '@logicflow/core';
class CustomEdgeModel extends PolylineEdgeModel {
getEdgeStyle() {
const style = super.getEdgeStyle();
return {
...style,
stroke: '#1890ff',
strokeWidth: 2,
};
}
}
class CustomEdge extends PolylineEdge {
// 后续将在这里实现自定义渲染逻辑
}
export default {
type: 'custom-edge',
view: CustomEdge,
model: CustomEdgeModel,
};
发光效果可以通过在原始路径上叠加一个带有模糊滤镜的路径来实现:
javascript复制class CustomEdge extends PolylineEdge {
private glowPath: SVGPathElement;
getEdge() {
const edge = super.getEdge();
// 保存原始路径元素引用
const path = edge.querySelector('path');
if (path && !this.glowPath) {
// 创建发光路径
this.glowPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
this.glowPath.setAttribute('d', path.getAttribute('d') || '');
this.glowPath.setAttribute('stroke', '#69c0ff');
this.glowPath.setAttribute('stroke-width', '8');
this.glowPath.setAttribute('fill', 'none');
this.glowPath.setAttribute('filter', 'url(#glow-filter)');
this.glowPath.setAttribute('opacity', '0.7');
// 添加SVG滤镜定义
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter');
filter.setAttribute('id', 'glow-filter');
filter.setAttribute('x', '-30%');
filter.setAttribute('y', '-30%');
filter.setAttribute('width', '160%');
filter.setAttribute('height', '160%');
const blur = document.createElementNS('http://www.w3.org/2000/svg', 'feGaussianBlur');
blur.setAttribute('stdDeviation', '3');
blur.setAttribute('result', 'blur');
const blend = document.createElementNS('http://www.w3.org/2000/svg', 'feBlend');
blend.setAttribute('in', 'SourceGraphic');
blend.setAttribute('in2', 'blur');
blend.setAttribute('mode', 'screen');
filter.appendChild(blur);
filter.appendChild(blend);
defs.appendChild(filter);
edge.insertBefore(defs, edge.firstChild);
edge.insertBefore(this.glowPath, edge.firstChild);
// 启动动画
this.startGlowAnimation();
}
return edge;
}
startGlowAnimation() {
let direction = 1;
let opacity = 0.3;
const animate = () => {
opacity += 0.02 * direction;
if (opacity >= 0.8) direction = -1;
if (opacity <= 0.3) direction = 1;
this.glowPath.setAttribute('opacity', opacity.toString());
requestAnimationFrame(animate);
};
animate();
}
}
流动动画需要沿着路径绘制一个移动的标记,这里我们使用SVG的stroke-dasharray和stroke-dashoffset技术:
javascript复制class CustomEdge extends PolylineEdge {
private flowMarker: SVGPathElement;
private animationId: number;
// 在getEdge方法中添加流动标记
getEdge() {
const edge = super.getEdge();
const path = edge.querySelector('path');
if (path && !this.flowMarker) {
// 创建流动标记
this.flowMarker = document.createElementNS('http://www.w3.org/2000/svg', 'path');
this.flowMarker.setAttribute('d', path.getAttribute('d') || '');
this.flowMarker.setAttribute('stroke', '#ffffff');
this.flowMarker.setAttribute('stroke-width', '3');
this.flowMarker.setAttribute('fill', 'none');
this.flowMarker.setAttribute('stroke-dasharray', '10, 30');
this.flowMarker.setAttribute('stroke-dashoffset', '0');
edge.appendChild(this.flowMarker);
this.startFlowAnimation();
}
return edge;
}
startFlowAnimation() {
let offset = 0;
const pathLength = this.flowMarker.getTotalLength();
const animate = () => {
offset += 1;
if (offset > pathLength) offset = 0;
this.flowMarker.setAttribute('stroke-dashoffset', offset.toString());
this.animationId = requestAnimationFrame(animate);
};
animate();
}
// 组件卸载时取消动画
destroy() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
super.destroy();
}
}
将发光和流动效果组合起来,并添加控制参数:
javascript复制class CustomEdge extends PolylineEdge {
// 添加配置参数
getAttributes() {
const attrs = super.getAttributes();
return {
...attrs,
glowEnabled: attrs.glowEnabled !== false,
flowEnabled: attrs.flowEnabled !== false,
glowColor: attrs.glowColor || '#69c0ff',
flowColor: attrs.flowColor || '#ffffff'
};
}
// 更新getEdge方法
getEdge() {
const edge = super.getEdge();
const { glowEnabled, flowEnabled } = this.getAttributes();
if (glowEnabled) this.setupGlowEffect(edge);
if (flowEnabled) this.setupFlowEffect(edge);
return edge;
}
}
javascript复制// 在动画循环中添加帧率控制
const targetFPS = 30;
const interval = 1000 / targetFPS;
let lastTime = 0;
const animate = (time) => {
if (time - lastTime > interval) {
// 执行动画逻辑
lastTime = time;
}
requestAnimationFrame(animate);
};
javascript复制// 只在边可见时运行动画
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.startAnimations();
} else {
this.stopAnimations();
}
});
});
observer.observe(this.container);
javascript复制// 缓存路径长度等计算密集型结果
private pathLengthCache: number | null = null;
getPathLength() {
if (this.pathLengthCache === null) {
this.pathLengthCache = this.flowMarker.getTotalLength();
}
return this.pathLengthCache;
}
在LogicFlow中注册并使用我们的自定义边:
javascript复制import CustomEdge from './CustomEdge';
// 注册自定义边
lf.register(CustomEdge);
// 使用自定义边
lf.setDefaultEdgeType('custom-edge');
// 创建带有自定义边的连接
lf.addEdge({
type: 'custom-edge',
sourceNodeId: 'node1',
targetNodeId: 'node2',
properties: {
glowEnabled: true,
flowEnabled: true,
glowColor: '#ff7875',
flowColor: '#ffec3d'
}
});
问题1:动画不流畅或卡顿
问题2:边更新后动画异常
javascript复制updatePath() {
super.updatePath();
const path = this.getEdge().querySelector('path');
if (this.glowPath) {
this.glowPath.setAttribute('d', path.getAttribute('d') || '');
}
if (this.flowMarker) {
this.flowMarker.setAttribute('d', path.getAttribute('d') || '');
this.pathLengthCache = null;
}
}
问题3:自定义边与其他插件冲突
问题4:移动端性能问题
javascript复制const isMobile = /Mobi|Android/i.test(navigator.userAgent);
lf.setDefaultEdgeType(isMobile ? 'polyline' : 'custom-edge');
javascript复制// 根据数据值改变动画速度
setAnimationSpeed(speed) {
this.animationSpeed = speed;
// 重新启动动画以应用新速度
}
// 在动画循环中使用
offset += this.animationSpeed;
javascript复制// 使用HSL色彩空间创建平滑过渡
let hue = 0;
const animateColor = () => {
hue = (hue + 1) % 360;
this.glowPath.setAttribute('stroke', `hsl(${hue}, 100%, 70%)`);
requestAnimationFrame(animateColor);
};
javascript复制// 在自定义边模型中添加
class CustomEdgeModel extends PolylineEdgeModel {
setHovered(hovered) {
super.setHovered(hovered);
this.properties.hovered = hovered;
// 触发视图更新
}
}
// 在视图中响应悬停状态
getEdge() {
const edge = super.getEdge();
const { hovered } = this.getAttributes();
if (hovered) {
edge.style.filter = 'drop-shadow(0 0 8px rgba(24, 144, 255, 0.8))';
} else {
edge.style.filter = '';
}
return edge;
}
javascript复制// 添加点击波纹效果
handleClick() {
const edge = this.getEdge();
const ripple = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
// 设置波纹动画属性
edge.appendChild(ripple);
setTimeout(() => {
edge.removeChild(ripple);
}, 1000);
}
javascript复制// 根据业务状态改变边样式
updateEdgeStatus(status) {
const colors = {
normal: '#1890ff',
warning: '#faad14',
error: '#ff4d4f',
success: '#52c41a'
};
this.glowPath.setAttribute('stroke', colors[status] || colors.normal);
this.setAnimationSpeed(status === 'error' ? 3 : 1);
}
javascript复制// 模拟数据包沿边移动
class DataPacket {
constructor(edge, speed) {
this.edge = edge;
this.progress = 0;
this.speed = speed;
this.element = this.createPacketElement();
}
animate() {
this.progress += this.speed;
if (this.progress > 1) this.progress = 0;
const point = this.edge.getPointAtLength(this.progress * this.edge.getTotalLength());
this.element.setAttribute('cx', point.x);
this.element.setAttribute('cy', point.y);
}
}