React Flow源码阅读

1,387 阅读9分钟

什么是ReactFlow

基本概念

官网介绍:A highly customizable React component for building node-based editors and interactive diagrams

一个轻便的流程图展示、编辑库,具有轻量化、定制性高的特点

官网地址:reactflow.dev/

image

一些定义

  • 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中的数据驱动,架构图如下

image.png

架构分析

  • 传入的节点和边数据经过处理后放到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被触发时将节点与边粘贴到被点击的最后一个位置,这个操作主要分以下几步

  1. 获取点击的位置,将点击的位置转化为画布的position

  2. 获取每个节点的位置偏移量,这里我们以nodes中的第一个为基准点计算其他节点的偏移量

  3. 计算每个节点的新位置,并修改他们的新id。边不需要计算位置,只需要更新id即可,位置会自动跟随锚点位置

  4. 将新的节点与边数据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>
  );
}