什么是ReactFlow
基本概念
官网介绍:A highly customizable React component for building node-based editors and interactive diagrams
一个轻便的流程图展示、编辑库,具有轻量化、定制性高的特点
官网地址:reactflow.dev/
一些定义
-
Node:流程图中的节点,可以支持拖拽,放大缩小等操作
-
Handle:节点中可供连线的锚点,在ReactFlow中,锚点<Handle/>作为节点的子组件,是归属于节点的一部分
-
Edge:流程图中链接节点或锚点之间的边
React Flow代码阅读
代码结构
/packages/core/src 项目代码文件夹
~/container
~/ReactFlow ReactFlow本体
~/GraphView 画布管理,用来组织FlowRender、EdgeRender和NodeRender
~/FlowRenderer flow-render是整个画布的render,zoom管理在这一层,包裹在GraphView
~/NodeRender 节点渲染,被GraphView包在FlowRender下
~/EdgeRender 边渲染,同样被GraphView包在FlowRender下
~/ZoomPane
~/Pane
...
~/components
~/Edges 预置的边类型
~/Nodes 预置的节点类型
~/Handle 锚点
~/ReactFlowProvider
...
~/store 基于zustand的Model
~/types
~/hooks
....
整体架构
架构图
整体采用MVVM模式,视图层渲染完全由Model中的数据驱动,架构图如下
架构分析
-
传入的节点和边数据经过处理后放到Model中,然后交给对应的Render渲染到视图。可以理解为节点和边在view中的表现是完全Model受控的。
-
用户传入的事件回调分为两种分别绑定到不同的位置
-
用户传入的自定义边和节点(NodeTypes、EdgeTypes)会经过Wrapper包裹处理成视图层可用的Components,在这一层会绑定好事件回调,同时格式化一些数据和处理一些render相关的逻辑。最后包裹好的组件会被渲染在视图层上
细节代码分析
状态管理
React Flow的状态维护在由zustand创建的model中,使用ReactFlow Provider向子组件提供store的ref,ReactFlow Provider代码如下
import { StoreApi } from 'zustand';
import { Provider } from '../../contexts/RFStoreContext';
import { createRFStore } from '../../store';
import type { ReactFlowState } from '../../types';
const ReactFlowProvider: FC<PropsWithChildren<unknown>> = ({ children }) => {
const storeRef = useRef<StoreApi<ReactFlowState> | null>(null);
if (!storeRef.current) {
storeRef.current = createRFStore(); // 在这里创建store
}
return <Provider value={storeRef.current}>{children}</Provider>;
};
这个ReactFlowProvider向外export出来,使用时可以直接手动的包裹在外层,方便的创建自己的数据流。
在子组件中则使用useStore从model取数据,在源码中可以经常看到这个hook。
import { useStore as useZustandStore } from 'zustand';
import type { StoreApi } from 'zustand';
import StoreContext from '../contexts/RFStoreContext';
function useStore<StateSlice = ExtractState>(
selector: (state: ReactFlowState) => StateSlice,
equalityFn?: (a: StateSlice, b: StateSlice) => boolean
) {
const store = useContext(StoreContext);
if (store === null) {
throw new Error(zustandErrorMessage);
}
return useZustandStore(store, selector, equalityFn);
// zustand提供的hooks
}
画布的渲染
画布主要在GraphView中管理,简化过的GraphView如下
<FlowRenderer 挂载d3-zoom
onPaneClick={onPaneClick}
...
noPanClassName={noPanClassName}
disableKeyboardA11y={disableKeyboardA11y}
>
<ViewportWrapper> // 实际的缩放在这里完成
<EdgeRenderer
edgeTypes={edgeTypes}
...
disableKeyboardA11y={disableKeyboardA11y}
rfId={rfId}
>
<ConnectionLine
style={connectionLineStyle}
type={connectionLineType}
component={connectionLineComponent}
containerStyle={connectionLineContainerStyle}
/>
</EdgeRenderer>
<NodeRenderer
nodeTypes={nodeTypes}
...
rfId={rfId}
/>
</ViewportWrapper>
</FlowRenderer>
FlowRenderer
FlowRenderer是整个画布的render,画布的缩放拖拽等都在这一层管理,代码如下
const FlowRenderer = ({
children,
...
}: FlowRendererProps) => {
const nodesSelectionActive = useStore(selector);
...
const isSelecting = selectionKeyPressed || (selectionOnDrag && panOnDrag !== true);
useGlobalKeyHandler({ deleteKeyCode, multiSelectionKeyCode });
return (
<ZoomPane
onMove={onMove}
...
>
<Pane
onSelectionStart={onSelectionStart}
...
>
{children}
{nodesSelectionActive && (
<NodesSelection
onSelectionContextMenu={onSelectionContextMenu}
noPanClassName={noPanClassName}
disableKeyboardA11y={disableKeyboardA11y}
/>
)}
</Pane>
</ZoomPane>
);
};
- ZoomPane
这一层容器负责管理缩放和拖拽,用一个div元素包裹了children(实际上就是<Pane/>)。这个div挂载了d3-zoom的ref,会处理监听对应的拖拽和缩放操作。
实际在缩放的元素并不是这个div,它只负撑满整个容器,监听缩放操作计算transform,然后将对应的transform更新到Model(监听和计算transform都是由d3-zoom完成的)。如下代码
d3Zoom.on('zoom', (event: D3ZoomEvent<HTMLDivElement, any>) => {
const { onViewportChange } = store.getState();
store.setState({ transform: [event.transform.x, event.transform.y, event.transform.k] });
// 在这里把transform更新到store
zoomedWithRightMouseButton.current = !!(
onPaneContextMenu && isRightClickPan(panOnDrag, mouseButton.current ?? 0)
);
if (onMove || onViewportChange) {
const flowTransform = eventToFlowTransform(event.transform);
// 触发一些回调
onViewportChange?.(flowTransform);
onMove?.(event.sourceEvent as MouseEvent | TouchEvent, flowTransform);
}
});
实际的元素缩放会由ViewportWrapper完成,ViewportWrapper代码如下
const selector = (s: ReactFlowState) => `translate(${s.transform[0]}px,${s.transform[1]}px) scale(${s.transform[2]})`;
function Viewport({ children }: ViewportProps) {
const transform = useStore(selector);
return (
<div className="react-flow__viewport react-flow__container" style={{ transform }}>
{children}
</div>
);
可以看到这个容器监听model中的transform,并设到style上来实现viewport中的内容缩放
- Pane
在这一层处理的工作主要是管理节点的多选
NodeRender与EdgeRender
NodeRenderer和EdgeRenderer被ViewportWrapper包裹后,作为children直接传给FlowRenderer。渲染出来的Node和Edge不需要画布去干涉,元素自己拥有自己的位置等信息,下边节点的渲染与边的渲染部分会详细介绍
节点的渲染
节点渲染的基本逻辑是首先Render的处理参数,然后把处理好的参数传给wrapper包裹好的组件完成
节点的Render部分逻辑比较简单,node属性中已经有了xy位置与必要的数据,做的工作是根据约束检查一些参数和获取统一注册的事件回调等,与渲染工作关系不太大,因此直接看Wrapper部分
NodeWrapper
(去掉部分重复类型代码)
export default (NodeComponent: ComponentType<NodeProps>) => {
const NodeWrapper = ({
id,
....
rfId,
}: WrapNodeProps) => {
const store = useStoreApi();
const nodeRef = useRef<HTMLDivElement>(null);
// 用Ref存一些prev状态
const prevSourcePosition = useRef(sourcePosition);
const prevTargetPosition = useRef(targetPosition);
...
// 统一注册到view的handler
const onDoubleClickHandler = getMouseHandler(id, store.getState, onDoubleClick);
....
useEffect(() => {
// 锚点位置改动时触发re-render
const typeChanged = prevType.current !== type;
const sourcePosChanged = prevSourcePosition.current !== sourcePosition;
const targetPosChanged = prevTargetPosition.current !== targetPosition;
if (nodeRef.current && (typeChanged || sourcePosChanged || targetPosChanged)) {
if (typeChanged) {
prevType.current = type;
}
if (sourcePosChanged) {
prevSourcePosition.current = sourcePosition;
}
if (targetPosChanged) {
prevTargetPosition.current = targetPosition;
}
store.getState().updateNodeDimensions([{ id, nodeElement: nodeRef.current, forceUpdate: true }]);
// 这里的updateNodeDimensions会捞取node中的锚点<Handle/>把锚点信息注册到model中去
}, [id, type, sourcePosition, targetPosition]);
const dragging = useDrag({ // 管理节点拖拽,useDrag中绑定d3-drag.drag的监听API
nodeRef,
disabled: hidden || !isDraggable,
...
selectNodesOnDrag,
});
if (hidden) {
return null;
}
return (
<div
ref={nodeRef}
style={{
zIndex,
transform: `translate(${xPosOrigin}px,${yPosOrigin}px)`, // 控制节点位置
pointerEvents: hasPointerEvents ? 'all' : 'none',
visibility: initialized ? 'visible' : 'hidden',
...style,
}}
data-id={id}
data-testid={`rf__node-${id}`}
onMouseEnter={onMouseEnterHandler} // 事件绑定在Wrapper上
....
onDoubleClick={onDoubleClickHandler}
>
<Provider value={id}>
<NodeComponent // 这里的NodeComponent就是自定义的节点,作为参数被传进来
id={id}
data={data}
....
zIndex={zIndex}
/>
</Provider>
</div>
);
};
可以注意到节点直接用div包裹渲染在画布上,这个包裹层做了以下工作
-
node传入参数直接含有位置信息,在包裹层中用设置style的transform来控制节点的位置
-
所有的事件回调都直接挂在包裹层上
-
节点的拖拽用d3-drag管理,在useDrag中了d3-drag的drag listener,拖拽****ref也是挂在包裹层,直接将拖动中的位置更新位置到Model触发re-render。
-
防止节点内容re-render,在缩放或者其他操作时只会触发wrapper层的re-render而不会触发内层组件的re-render,用户自定义的内层可以包裹比较复杂的组件而不会影响性能。
同时在wrapper中也会将锚点****handle数据提取放到model中,好用来计算边的实际开始出发位置。
关于被包裹的自定义组件,可以直接参考文档中的Custom Node。react Flow也内置了几个写好的node,传入type就可用。
边的渲染
边的渲染要从对应的锚点中获取边的开始结束实际位置,渲染较节点来说复杂了一些
EdgeRenderer
const EdgeRenderer = ({
defaultMarkerColor,
...
children,
}: EdgeRendererProps) => {
const { edgesFocusable, edgesUpdatable, elementsSelectable, width, height, connectionMode, nodeInternals, onError } =
useStore(selector, shallow);
const edgeTree = useVisibleEdges(onlyRenderVisibleElements, nodeInternals, elevateEdgesOnSelect);
// 从store中获取可见的edge
return (
<>
{edgeTree.map(({ level, edges, isMaxLevel }) => (
<svg
key={level}
style={{ zIndex: level }}
width={width}
height={height}
className="react-flow__edges react-flow__container"
>
{isMaxLevel && <MarkerDefinitions defaultColor={defaultMarkerColor} rfId={rfId} />}
<g>
{edges.map((edge: Edge) => {
// 获取source与target节点信息,包括其中的handle
const [sourceNodeRect, sourceHandleBounds, sourceIsValid] = getNodeData(nodeInternals.get(edge.source));
const [targetNodeRect, targetHandleBounds, targetIsValid] = getNodeData(nodeInternals.get(edge.target));
const EdgeComponent = edgeTypes[edgeType] || edgeTypes.default
const targetNodeHandles =
connectionMode === ConnectionMode.Strict
? targetHandleBounds!.target
: (targetHandleBounds!.target ?? []).concat(targetHandleBounds!.source ?? []);
// getHandle的逻辑抽象在外层
const sourceHandle = getHandle(sourceHandleBounds!.source!, edge.sourceHandle);
const targetHandle = getHandle(targetNodeHandles!, edge.targetHandle);
const sourcePosition = sourceHandle?.position || Position.Bottom;
const targetPosition = targetHandle?.position || Position.Top;
const isFocusable = !!(edge.focusable || (edgesFocusable && typeof edge.focusable === 'undefined'));
const isUpdatable =
typeof onEdgeUpdate !== 'undefined' &&
(edge.updatable || (edgesUpdatable && typeof edge.updatable === 'undefined'));
if (!sourceHandle || !targetHandle) {
onError?.('008', errorMessages['error008'](sourceHandle, edge));
return null;
}
const { sourceX, sourceY, targetX, targetY } = getEdgePositions(
// 计算出实际开始结束的XY坐标
sourceNodeRect,
sourceHandle,
sourcePosition,
targetNodeRect,
targetHandle,
targetPosition
);
return (
// 这里的EdgeComponent是wrapper处理过的edge
<EdgeComponent
key={edge.id}
....
onEdgeUpdateEnd={onEdgeUpdateEnd}
/>
);
})}
</g>
</svg>
))}
{children}
</>
);
};
在EdgeRender里做的比较重要的事情是计算实际的出发结束位置,然后处理一些属性和事件回调
EdgeWrapper
export default (EdgeComponent: ComponentType<EdgeProps>) => {
const EdgeWrapper = ({
id,
...
interactionWidth,
}: WrapEdgeProps): JSX.Element | null => {
const edgeRef = useRef<SVGGElement>(null);
const store = useStoreApi();
....
// 注册一些事件,和nodeRender差不多
const onEdgeDoubleClickHandler = getMouseHandler(id, store.getState, onEdgeDoubleClick);
...
const onEdgeUpdaterSourceMouseDown = (event: React.MouseEvent<SVGGElement, MouseEvent>): void =>
handleEdgeUpdater(event, true);
...
return (
<g
onClick={onEdgeClick}
onDoubleClick={onEdgeDoubleClickHandler}
onKeyDown={isFocusable ? onKeyDown : undefined}
tabIndex={isFocusable ? 0 : undefined}
role={isFocusable ? 'button' : undefined}
>
{!updating && (
// Custom EdgeComponents,可以是用户传进来的或者几个内置类型
<EdgeComponent
id={id}
source={source}
target={target}
....
sourceX={sourceX}
sourceY={sourceY}
targetX={targetX}
targetY={targetY}
interactionWidth={interactionWidth}
/>
)}
{isUpdatable && (
<>
// 用于管理updataAble的不可见元素
{(isUpdatable === 'source' || isUpdatable === true) && (
<EdgeAnchor
position={sourcePosition}
...同下方
type="source"
/>
)}
{(isUpdatable === 'target' || isUpdatable === true) && (
<EdgeAnchor
position={targetPosition}
centerX={targetX}
centerY={targetY}
radius={edgeUpdaterRadius}
onMouseDown={onEdgeUpdaterTargetMouseDown}
onMouseEnter={onEdgeUpdaterMouseEnter}
onMouseOut={onEdgeUpdaterMouseOut}
type="target"
/>
)}
</>
)}
</g>
);
};
在这一层处理的内容和nodeWrapper差不多,同样是将包裹自定义并挂载一些监听事件。
与node不同的是edge是用svg渲染的,所以包裹元素变成<g />, 可以理解为一个group。同时节点由于链接的是锚点所以有
同样关于自定义edge的写法可以直接参考文档 Custom Edge
功能拓展
自定义的Hooks
React Flow提供了比较友好的API可以自己开发插件来支持一些不支持的功能,包括hooks与一些监听回调。对于已经有的hooks可以直接使用,没有提供现有hooks的可以绑定到对应的监听方法自己维护。注意已有hooks的使用需要<ReactFlow/> 或者 <ReactFlowProvider/>二者其一包裹才可以使用,我们的插件如果使用了对应的hooks也要包裹在其中
在这里做一个多节点复制粘贴的hooks举例
useMultiCopy多节点复制粘贴
基本思路:使用useOnSelectionChange监听并保存被选中的节点与边。在onCopy触发时将选中的节点与边信息存到剪贴板上。监听点击事件,在onPaste被触发时将节点与边粘贴到被点击的最后一个位置,这个操作主要分以下几步
-
获取点击的位置,将点击的位置转化为画布的position
-
获取每个节点的位置偏移量,这里我们以nodes中的第一个为基准点计算其他节点的偏移量
-
计算每个节点的新位置,并修改他们的新id。边不需要计算位置,只需要更新id即可,位置会自动跟随锚点位置
-
将新的节点与边数据set到model,视图层会自动更新,粘贴完成
代码如下
import { useClipboardCopy } from '@byted/hooks';
import { useCallback, useEffect, useState } from 'react';
import { useKeyPress, useOnSelectionChange, useReactFlow } from 'reactflow';
const jsonParseSafe = (text: string) => {
let jsonRes;
try {
jsonRes = JSON.parse(text);
} catch {
jsonRes = {};
}
return jsonRes;
};
const randomName = (): string => Math.random().toString(36).slice(2, 7);
function useMultiCopy(): {
copy: () => void;
paste: () => void;
onPaneClick: (value: React.MouseEvent<Element, MouseEvent>) => void;
} {
const { setNodes, setEdges, project } = useReactFlow();
const [position, setPosition] = useState<{ x: number; y: number }>();
const [selectedNodeAndEdges, setSelectEdgesNodeAndEdges] =
useState<Record<'nodes' | 'edges', any[]>>();
const { copyText, copy, clear } = useClipboardCopy();
useOnSelectionChange({
onChange: ({ nodes, edges }) => {
setSelectEdgesNodeAndEdges({ nodes, edges });
// 保存选中的节点和边
},
});
const handleCopy = useCallback(() => {
copy(JSON.stringify(selectedNodeAndEdges));
}, [selectedNodeAndEdges]);
const handlePaste = useCallback(() => {
const copiedJson = jsonParseSafe(copyText);
const { nodes, edges = [] } = copiedJson;
if (!nodes?.length || !position) {
return;
}
const idMap: Record<string, string> = {};
// 将点击位置转化为画布位置
const { x: clickOffsetX, y: clickOffsetY } = project({
x: position.x - 300,
y: position.y,
});
const {
position: { x:firstNodeX, y:firstNodeY },
} = nodes[0];
const offsetX = clickOffsetX - firstNodeX;
const OffsetY = clickOffsetY - firstNodeY;
// 计算节点的偏移位置
// 处理新的nodes与edges
const copiedNodes = nodes?.map((item: any) => {
const {
position: { x, y },
} = item;
// 获取新的id
const newId = randomName();
idMap[item.id] = newId;
return {
...item,
id: newId,
position: { x: x + offsetX, y: y + OffsetY },
};
});
const copiedEdges = edges
?.map((item: any) => {
const { source, target } = item;
const newSourceId = idMap[source];
const newTargetId = idMap[target];
// 只复制有节点连接的线
return newSourceId && newTargetId
? {
...item,
source: newSourceId,
target: newTargetId,
id: `${item.id}_${randomName()}`,
}
: null;
})
.filter((item: any) => Boolean(item));
// setNodes、setEdges完成复制
copiedNodes &&
setNodes((prevNodes: any[]) => [...prevNodes, ...copiedNodes]);
copiedEdges &&
setEdges((prevEdges: any[]) => [...prevEdges, ...copiedEdges]);
clear();
}, [copyText, position]);
const onPaneClick = useCallback(
(event: React.MouseEvent<Element, MouseEvent>) => {
setPosition({ x: event.clientX, y: event.clientY });
},
[],
);
return { copy: handleCopy, paste: handlePaste, onPaneClick };
}
export default useMultiCopy;
实际效果
实际要在项目中使用的话还需要注意一些问题,比如新id的获取(目前是随机值),写死的点击事件对于窗口的偏移量等,以及一些与使用场景相关的问题,例如是否允许复制无头连线、复制后是否清空剪贴板等,实际使用还需要稍加修改
插件
同时也可以直接向<ReactFlow/> 传子组件作为插件,这个插件会被渲染在<GraphView/>同级,不会跟着画布一起缩放。在这个插件中可以直接用useStore获取model的所有数据,需要缩放的话可以监听model中的transform和其他缩放属性来手动支持缩放(<Background />就是这么实现的),需要其他功能扩展也可以直接在useStore中直接获取或者操作数据,扩展性比较好。
<Background />的代码实现如下
Background插件
function Background({
id,
variant = BackgroundVariant.Dots,
// only used for dots and cross
gap = 20,
// only used for lines and cross
size,
lineWidth = 1,
offset = 2,
color,
style,
className,
}: BackgroundProps) {
const ref = useRef<SVGSVGElement>(null);
const { transform, patternId } = useStore(selector, shallow);
// 使用useStore,获取计算好的transform
const patternColor = color || defaultColor[variant];
const patternSize = size || defaultSize[variant];
const scaledSize = patternSize * transform[2];
....
// 在这里用transform中的scale计算dot之间的间距
const gapXY: [number, number] = Array.isArray(gap) ? gap : [gap, gap];
const scaledGap: [number, number] = [gapXY[0] * transform[2] || 1, gapXY[1] * transform[2] || 1];
const scaledSize = patternSize * transform[2];
const patternOffset = isDots
? [scaledSize / offset, scaledSize / offset]
: [patternDimensions[0] / offset, patternDimensions[1] / offset];
return (
<svg
className={cc(['react-flow__background', className])}
style={{
...style,
position: 'absolute',
width: '100%',
height: '100%',
top: 0,
left: 0,
}}
ref={ref}
data-testid="rf__background"
>
<pattern
id={patternId+id}
x={transform[0] % scaledGap[0]}
y={transform[1] % scaledGap[1]}
width={scaledGap[0]}
height={scaledGap[1]}
patternUnits="userSpaceOnUse"
patternTransform={`translate(-${patternOffset[0]},-${patternOffset[1]})`}
>
{isDots ? (
<DotPattern color={patternColor} radius={scaledSize / offset} />
) : (
<LinePattern dimensions={patternDimensions} color={patternColor} lineWidth={lineWidth} />
)}
</pattern>
<rect x="0" y="0" width="100%" height="100%" fill={`url(#${patternId+id})`} />
</svg>
);
}