最近在开发一个需要可视化流程编排的系统时,遇到了一个有趣的挑战:如何在LogicFlow这个流程图编辑引擎中,实现带有发光和流动动画效果的自定义折线边。这种效果在传统BPMN工具中很少见,但对于需要突出关键路径或展示数据流动的场景特别有用。
LogicFlow本身提供了基础的边(Edge)绘制能力,但默认的折线边(PolylineEdge)和直线边(LineEdge)都只支持静态样式。要让边"活"起来,需要深入理解其自定义机制和React的动画实现原理。经过两周的摸索和调试,我总结出一套完整的实现方案,效果堪比专业流程图工具的特效。
LogicFlow是一个专注于流程图编辑的轻量级引擎,相比GoJS等重型工具,它的优势在于:
实现边动画主要有三种技术路线:
最终选择方案3,因为:
bash复制# 创建React项目
npx create-react-app lf-animation-edge --template typescript
# 安装核心依赖
npm install @logicflow/core @logicflow/extension
npm install d3-ease # 用于动画缓动效果
LogicFlow允许通过继承内置边类来实现自定义边。我们基于PolylineEdge扩展:
typescript复制import { PolylineEdge, PolylineEdgeModel } from '@logicflow/core';
class AnimatedEdge extends PolylineEdge {
// 覆盖getEdge方法实现自定义SVG
getEdge() {
const { model } = this.props;
const { points } = model;
// 基础折线路径
const path = this.getPolylinePath();
return (
<>
{/* 基础边 */}
<path
d={path}
stroke="#999"
strokeWidth={2}
fill="none"
/>
{/* 动画层将在后续添加 */}
</>
);
}
}
class AnimatedEdgeModel extends PolylineEdgeModel {
// 可在此定义自定义边属性
}
发光效果通过SVG的filter实现,这是性能最好的方案:
typescript复制// 在LF画布初始化时添加滤镜定义
const lf = new LogicFlow({
container: document.getElementById('container'),
grid: true,
});
lf.setTheme({
defs: {
filter: [
{
id: 'edge-glow',
width: '300%',
height: '300%',
x: '-100%',
y: '-100%',
filterUnits: 'userSpaceOnUse',
children: [
{
name: 'feGaussianBlur',
in: 'SourceGraphic',
stdDeviation: '5',
result: 'blur',
},
{
name: 'feColorMatrix',
in: 'blur',
mode: 'matrix',
values: '1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7',
result: 'glow',
},
],
},
],
},
});
然后在边的render中应用这个filter:
typescript复制<path
d={path}
stroke="#4af" // 使用亮色效果更明显
strokeWidth={3}
filter="url(#edge-glow)"
fill="none"
/>
流动动画的核心是stroke-dasharray和stroke-dashoffset的配合:
typescript复制class AnimatedEdge extends PolylineEdge {
private animationId: number = 0;
private offset: number = 0;
componentDidMount() {
this.startAnimation();
}
componentWillUnmount() {
cancelAnimationFrame(this.animationId);
}
startAnimation = () => {
const { model } = this.props;
const path = this.getPolylinePath();
const length = this.getPathLength(path);
const animate = () => {
this.offset = (this.offset + 1) % length;
const dashArray = [length * 0.2, length * 0.8];
const dashOffset = -this.offset;
// 更新动画元素
const animatedPath = document.getElementById(`animated-path-${model.id}`);
if (animatedPath) {
animatedPath.setAttribute('stroke-dasharray', dashArray.join(' '));
animatedPath.setAttribute('stroke-dashoffset', String(dashOffset));
}
this.animationId = requestAnimationFrame(animate);
};
this.animationId = requestAnimationFrame(animate);
};
getPathLength(path: string): number {
// 简化实现,实际项目应该用更精确的计算
return path.length * 0.5;
}
getEdge() {
const { model } = this.props;
const path = this.getPolylinePath();
return (
<>
{/* 基础边 */}
<path d={path} stroke="#ddd" strokeWidth={8} fill="none" />
{/* 动画层 */}
<path
id={`animated-path-${model.id}`}
d={path}
stroke="#4af"
strokeWidth={4}
fill="none"
/>
</>
);
}
}
当画布中有大量动画边时,需要控制帧率:
typescript复制// 全局动画控制器
class AnimationManager {
static fps = 30;
static lastTime = 0;
static shouldUpdate(): boolean {
const now = performance.now();
const interval = 1000 / this.fps;
if (now - this.lastTime >= interval) {
this.lastTime = now;
return true;
}
return false;
}
}
// 在animate函数中
const animate = () => {
if (AnimationManager.shouldUpdate()) {
// 更新逻辑...
}
this.animationId = requestAnimationFrame(animate);
};
避免频繁计算路径长度:
typescript复制class AnimatedEdgeModel extends PolylineEdgeModel {
private _pathLength: number = 0;
get pathLength() {
if (!this._pathLength) {
this._pathLength = this.calculatePathLength();
}
return this._pathLength;
}
private calculatePathLength(): number {
// 使用SVG的getTotalLength()获取精确长度
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', this.getPolylinePath());
return path.getTotalLength();
}
}
只对可视区域内的边执行动画:
typescript复制// 在LF实例上监听视口变化
lf.on('viewport:change', ({ translateX, translateY, scale }) => {
edges.forEach(edge => {
edge.checkVisibility(translateX, translateY, scale);
});
});
// 边类中实现可见性检查
class AnimatedEdge extends PolylineEdge {
isVisible = true;
checkVisibility(translateX: number, translateY: number, scale: number) {
// 根据边位置和视口参数计算是否可见
this.isVisible = /* 计算逻辑 */;
if (this.isVisible && !this.animationId) {
this.startAnimation();
} else if (!this.isVisible && this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = 0;
}
}
}
根据边属性动态改变颜色:
typescript复制// 在getEdge方法中
const gradientId = `gradient-${model.id}`;
return (
<>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4af" />
<stop offset="100%" stopColor="#f4a" />
</linearGradient>
</defs>
<path
stroke={`url(#${gradientId})`}
{/* 其他属性 */}
/>
</>
);
鼠标悬停时加速动画:
typescript复制class AnimatedEdge extends PolylineEdge {
private speed: number = 1;
getEdge() {
return (
<g
onMouseEnter={() => this.speed = 3}
onMouseLeave={() => this.speed = 1}
>
{/* 边元素 */}
</g>
);
}
// 在animate函数中使用this.speed
this.offset = (this.offset + this.speed) % length;
让文字跟随边路径流动:
typescript复制// 使用SVG的textPath元素
<path
id={`path-${model.id}`}
d={path}
fill="none"
visibility="hidden"
/>
<text fill="#fff" fontSize="12">
<textPath
href={`#path-${model.id}`}
startOffset="50%"
textAnchor="middle"
>
流动的文字
</textPath>
</text>
在OA审批系统中,我们使用不同动画效果表示:
实现方式是为不同状态边添加数据属性:
typescript复制class AnimatedEdgeModel extends PolylineEdgeModel {
getEdgeStyle() {
const style = super.getEdgeStyle();
if (this.properties.urgent) {
style.stroke = '#ff0';
style.animationType = 'blink';
}
return style;
}
}
在ETL工具中展示数据流动:
typescript复制// 根据数据更新边样式
lf.updateEdge(edgeId, {
properties: {
speed: data.recordsPerSecond / 1000,
width: Math.log10(data.size) + 1,
color: interpolateColor(
'#4af',
'#f4a',
data.freshness
)
}
});
现象:当画布中有超过50条边时动画明显卡顿
解决方案:
现象:多条发光边重叠时视觉效果混乱
解决方案:
css复制.edge-container {
isolation: isolate;
}
.edge-glow {
mix-blend-mode: screen;
}
现象:导出的PNG中动画效果丢失
解决方案:
javascript复制function captureFrame(edgeId) {
const edge = document.getElementById(`animated-path-${edgeId}`);
const dashArray = edge.getAttribute('stroke-dasharray');
const dashOffset = edge.getAttribute('stroke-dashoffset');
return { dashArray, dashOffset };
}
javascript复制html2canvas(element, {
ignoreElements: (el) => el.classList.contains('lf-animation-control'),
onclone: (doc) => {
// 冻结所有动画
doc.querySelectorAll('.animated-edge').forEach(edge => {
edge.style.animationPlayState = 'paused';
});
}
});
以下是一个可运行的React组件示例:
typescript复制import React, { useEffect, useRef } from 'react';
import { LogicFlow } from '@logicflow/core';
import { PolylineEdge, PolylineEdgeModel } from '@logicflow/extension';
class AnimatedEdge extends PolylineEdge {
private animationId = 0;
private offset = 0;
private speed = 1;
componentDidMount() {
this.startAnimation();
}
componentWillUnmount() {
cancelAnimationFrame(this.animationId);
}
startAnimation = () => {
const { model } = this.props;
const path = this.getPolylinePath();
const length = (model as AnimatedEdgeModel).pathLength;
const animate = () => {
if (AnimationManager.shouldUpdate()) {
this.offset = (this.offset + this.speed) % length;
const dashArray = [length * 0.2, length * 0.8];
const dashOffset = -this.offset;
const animatedPath = document.getElementById(`animated-path-${model.id}`);
if (animatedPath) {
animatedPath.setAttribute('stroke-dasharray', dashArray.join(' '));
animatedPath.setAttribute('stroke-dashoffset', String(dashOffset));
}
}
this.animationId = requestAnimationFrame(animate);
};
this.animationId = requestAnimationFrame(animate);
};
getEdge() {
const { model } = this.props;
const path = this.getPolylinePath();
const gradientId = `gradient-${model.id}`;
return (
<g
onMouseEnter={() => this.speed = 3}
onMouseLeave={() => this.speed = 1}
>
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4af" />
<stop offset="100%" stopColor="#f4a" />
</linearGradient>
</defs>
{/* 背景边用于更好的点击区域 */}
<path d={path} stroke="transparent" strokeWidth={20} fill="none" />
{/* 基础边 */}
<path d={path} stroke="#ddd" strokeWidth={8} fill="none" />
{/* 动画边 */}
<path
id={`animated-path-${model.id}`}
d={path}
stroke={`url(#${gradientId})`}
strokeWidth={4}
fill="none"
filter="url(#edge-glow)"
/>
</g>
);
}
}
class AnimatedEdgeModel extends PolylineEdgeModel {
private _pathLength = 0;
get pathLength() {
if (!this._pathLength) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', this.getData().points.map(p => `${p.x},${p.y}`).join(' '));
this._pathLength = path.getTotalLength();
}
return this._pathLength;
}
}
const AnimationManager = {
fps: 24,
lastTime: 0,
shouldUpdate() {
const now = performance.now();
const interval = 1000 / this.fps;
if (now - this.lastTime >= interval) {
this.lastTime = now;
return true;
}
return false;
},
};
export const FlowAnimationDemo = () => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerRef.current) return;
const lf = new LogicFlow({
container: containerRef.current,
width: 800,
height: 600,
grid: true,
edgeType: 'animated-edge',
});
lf.register({
type: 'animated-edge',
view: AnimatedEdge,
model: AnimatedEdgeModel,
});
lf.setTheme({
defs: {
filter: [
{
id: 'edge-glow',
/* 滤镜定义同上 */
},
],
},
});
lf.render({
nodes: [
{ id: '1', type: 'rect', x: 100, y: 100 },
{ id: '2', type: 'rect', x: 300, y: 200 },
],
edges: [
{
id: 'edge1',
type: 'animated-edge',
sourceNodeId: '1',
targetNodeId: '2',
},
],
});
}, []);
return <div ref={containerRef} style={{ width: '100%', height: '600px' }} />;
};