初试 reactflow

本文主要记录 reactflow 的一个使用

reactflow.dev/

实现的功能主要是官网的一个 Pro 功能

官网 reactflow.dev/docs/exampl…

笔者实现 rick-chou.github.io/react-flowc…

v1.png

主要就是 拖拽节点 + 自动布局

我在这个基础上又加了一些

  • 图表管理

  • 图表新增 / 删除 / 复制 / 编辑

  • Node 折叠

  • Github Actions 自动部署 Github Page

  • 集成 PWA

下面开始介绍吧 ~

本文只涉及实现思路 具体代码可移步

github.com/rick-chou/o…

技术方案

数据层面 我用了 Redux 的封装库 @reduxjs/toolkit 来 mock 增删改查

然后用 redux-persist 来将 Redux 和 LocalStorage 打通 mock 数据持久化

reactflow lib

首先简单的介绍一下这个库 这是一个高可定制化的第三方流程图库

笔者感觉这是 React 生态中使用感觉/体验感觉比较好的一个库了

详细的用法可以参考官网

reactflow.dev/

import React, { useState, useEffect, useCallback } from 'react';
import ReactFlow, {
  useNodesState,
  useEdgesState,
  addEdge,

  MiniMap,
  Controls,
} from 'reactflow';

// 别忘了引入 css
import 'reactflow/dist/style.css';



// 定义 node 格式
// id / data / position 为必传属性
// data 中的 label 为该 node 上的文本 其他自定义的属性可以在此处传递
const initNodes = [
  {
    id: '1',
    data: { label: 'Node A' },
    position: { x: 0, y: 50 },
  },
  {
    id: '2',
    data: { label: 'Node B' },
    position: { x: 250, y: 50 },
  },
];


const initEdges = [
  {
    id: 'e1-2',
    source: '1',
    target: '2',
    animated: true,
  },
];

const Flow = () => {
  // 官方提供的状态管理hooks 多了一个onChange 如果你不想用 也可以用自己的set
  const [nodes, setNodes, onNodesChange] = useNodesState(initNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges);


  // 手动连接Node之间的Edge时 这个函数会触发
  const onConnect = useCallback(
    params =>
      setEdges(eds =>
        addEdge({ ...params, animated: true, style: { stroke: '#fff' } }, eds),
      ),
    [],
  );
  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      onNodesChange={onNodesChange}
      onEdgesChange={onEdgesChange}
      onConnect={onConnect}
      fitView>
      {/* 缩略图 */}
      <MiniMap />
      {/* 控制器 */}
      <Controls />
    </ReactFlow>
  );
};

export default Flow;

这是一个很简单的 demo 就可以生成一个 flowchart

效果可以访问 codesandbox

codesandbox codesandbox.io/s/reactflow…

类型定义

// 支持的三种交互 新增 / 复制 / 编辑
export enum Action {
  add = 'add',
  copy = 'copy',
  edit = 'edit',
}


// 支持的自定义节点类型
export enum NodeType {
  normal = 'normal',
  log = 'log',
  alert = 'alert',
  flow = 'flow',
  img = 'img',
}



// mock 后端数据
export type NodeDataType = {
  id: number;
  chartId: string;
  label: string;
  branchLabel: string;
  nodeType: NodeType;
  children: number[];
};

// 加上一些交互需要的字段
export type NodeDataTypeWrapper = {
  // 是否是根路径
  root: boolean;
  // 是否可编辑
  editable: boolean;
  // 是否折叠
  collapsed: boolean;
  // 是否展示
  show: boolean;
  // 删除函数
  onDelete: (id: number) => void;
  // 折叠函数
  onCollapse: (node: NodeDataTypeWrapper) => void;
} & NodeDataType;


export type FlowChartType = {
  id: string;
  title: string;
  nodes: NodeDataType[];
};

数据管理

import { combineReducers, configureStore } from '@reduxjs/toolkit';
import logger from 'redux-logger';
import {
  FLUSH,
  PAUSE,
  PERSIST,
  PURGE,
  REGISTER,
  REHYDRATE,
  persistReducer,
  persistStore,
} from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import { name } from '../../package.json';
import flowchartSlice from './slice/flowchartSlice';



export const persistKey = name;

const persistConfig = {
  key: persistKey,
  storage,
};



const persistedReducer = persistReducer(
  persistConfig,
  combineReducers({
    flowcharts: flowchartSlice,
  }),
);

export const store = configureStore({
  devTools: import.meta.env.DEV,
  reducer: persistedReducer,
  // 引入 redux-logger
  // redux-persist 未序列化的action会引起报错 通过 ignoredActions 隐藏报错
  middleware: getDefaultMiddleware =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }).concat(logger),
});


export const persistor = persistStore(store);


// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch;

flowchartSlice.ts

import { type FlowChartType } from '@/interface';

import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { produce } from 'immer';

const initialState: Record<string, FlowChartType> = {};


export type AddPayloadType = {
  type: 'add';
  data: FlowChartType;
};



export type DeletePayloadType = {
  type: 'delete';
  id: string;
};


export type UpdatePayloadType = {
  type: 'update';
  data: FlowChartType;
  id: string;
};



type PayloadType = AddPayloadType | UpdatePayloadType | DeletePayloadType;



export const pageSlice = createSlice({
  name: 'update-flow',
  initialState,
  reducers: {
    update(state, action: PayloadAction<PayloadType>) {
      switch (action.payload.type) {
        case 'add':
          return {
            ...state,
            [action.payload.data.id]: action.payload.data,
          };
        case 'update':
          return {
            ...state,
            [action.payload.id]: action.payload.data,
          };
        case 'delete':
          return produce(state, draft => {
            delete draft[(action.payload as DeletePayloadType).id];
          });

        default:
          return state;
      }
    },
  },
});


export const { update } = pageSlice.actions;

export default pageSlice.reducer;

然后用一个自定义 hook 来操作 redux 模拟接口

useFlowchart.ts

import { type FlowChartType } from '@/interface';

import { update } from '@/store/slice/flowchartSlice';
import { useCallback, useMemo } from 'react';
import { v4 as uuidV4 } from 'uuid';
import { useRickDispatch, useRickSelector } from './useStore';



export const useFlowchart = () => {
  const flowcharts = useRickSelector(state => state.flowcharts);
  const dispatch = useRickDispatch();


  const addChart = (data: FlowChartType) => {
    const id = uuidV4();
    dispatch(update({ type: 'add', data: { ...data, id: data.id ?? id } }));
    return id;
  };



  const updateChart = (data: FlowChartType, id: string) => {
    dispatch(update({ type: 'update', id, data }));
  };



  const deleteChart = (id: string) => {
    dispatch(update({ type: 'delete', id }));
  };

  const getFlowchart = (id: string) => {
    return flowcharts[id];
  };


  return {
    flowcharts: useMemo(() => flowcharts, [flowcharts]),
    getFlowchart: useCallback(getFlowchart, [flowcharts]),
    addChart: useCallback(addChart, [dispatch]),
    updateChart: useCallback(updateChart, [dispatch]),
    deleteChart: useCallback(deleteChart, [dispatch]),
  };
};

最后用自定义 hook 来管理 reactflow 中的数据就可以了

代码隐藏细节

import { NodeType, type NodeDataTypeWrapper } from '@/interface';
import { genEdges, genNodes, getLayoutedElements } from '@/utils';
import { useCallback, useEffect, useRef } from 'react';
import {
  addEdge,

  useEdgesState,
  useNodesState,
  type Connection,
  type Edge,
  type Node,
  type ReactFlowInstance,
} from 'reactflow';
import { useFlowchart } from './useFlowchart';


type FlowStateParams = {
  // 每一个chart的唯一id
  id: string;
  // 每一个chart的instance
  instance: ReactFlowInstance;
  // 区分是预览/编辑
  editable?: boolean;
  // 延迟 模拟loading
  delay?: number;
};



const initNodeId = 1;


export const useFlowState = ({
  id,
  executionNodes = [],
  instance,
  editable = false,
  delay = 0,
}: FlowStateParams) => {
  const [nodes, setNodes, onNodesChange] = useNodesState<NodeDataTypeWrapper>(
    [],
  );
  const currentNodeId = useRef(initNodeId);
  const [edges, setEdges, onEdgesChange] = useEdgesState([]);
  const nodesSnapshot = useRef<typeof nodes>([]);

  const { getFlowchart } = useFlowchart();


  const onCollapse = (node: NodeDataTypeWrapper) => {};


  const onDelete = (id: number) => {};

  const onConnect = ({ target, source }: Connection) => {};

  const addNode = (sourceNodeId: number, nodeType = NodeType.normal) => {};

  const initChart = async (_nodes: NodeDataTypeWrapper[]) => {};


  useEffect(() => {
    if (id) {
      void initChart(getFlowchart(id).nodes as NodeDataTypeWrapper[]);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [id]);

  // 看需要 可以用 useCallback / useMemo 包括对象
  return {
    nodes,
    edges,
    setNodes,
    onNodesChange,
    setEdges,
    onEdgesChange,
    onConnect,
    onDelete,
    addNode,
  };
};

实现拖拽

给需要拖拽的 node 加上 onDragStart / draggable 属性

export const siderbarNodeWrapperStyle = (editable: boolean) => css`
  user-select: none;
  cursor: ${editable ? 'grab' : 'not-allowed'};
  margin-bottom: 1rem;
  display: flex;
  justify-content: center;
  // 加上这一行 不然拖拽的时候会有白边
  transform: translate(0, 0);
`;


const onDragStart = (
  event: React.DragEvent<HTMLDivElement>,
  nodeType: NodeType,
) => {
  // 在这里设置拖拽节点的nodeType
  // 在画布的 onDrop 事件中可以用对应的key读取nodeType
  event.dataTransfer.setData('application/reactflow', nodeType);
  event.dataTransfer.effectAllowed = 'move';
};



<div
  css={siderbarNodeWrapperStyle(editable)}
  onDragStart={event => {
    onDragStart(event, i.type);
  }}
  draggable={editable}>
  <DragNode />
</div>;

画布

// 死写法 不用动
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
  event.preventDefault();
  event.dataTransfer.dropEffect = 'move';
}, []);



// 拖拽松手的时候触发
const onDrop = useCallback(
  async (event: React.DragEvent<HTMLDivElement>) => {
    event.preventDefault();
    const nodeId = currentDragoverDom.current?.dataset.nodeId;



    // addNode
  },
  [addNode, instance, nodes],
);


// 拖拽进入其他元素的时候触发 可以在这里做拖拽节点重叠的颜色改变
const onDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
  const domTarget = event.target as HTMLDivElement;
  if (domTarget.classList.contains(FlowContainerClassname)) {
    currentDragoverDom.current?.classList.remove(FlowNodeDragoverClassname);
    currentDragoverDom.current = undefined;
  }



  if (domTarget.classList.contains(FlowNodeClassname)) {
    domTarget.classList.add(FlowNodeDragoverClassname);
    currentDragoverDom.current = domTarget;
  }
};

<ReactFlow onDrop={onDrop} onDragEnter={onDragEnter} onDragOver={onDragOver} />;

Github action 自动部署

需要在 Settings / Developer settings 下生成一个 token 放在

对应仓库的 Settings / Actions secrets and variables / Action 下

用 ${{ secrets.xxx }} 就可以引用

注意 : 用 echo 这些命令在 action log 中是看不到的 secrets 会以 *** 显示

需要注意的是放在 github page 上时 项目的根目录要和仓库名一致 不能用 /

name: FLOWCHART
on:
  push:
    branches:
      - main



jobs:
  Build:
    runs-on: ubuntu-latest
    steps:
      # https://github.com/actions/checkout
      - name: Checkout ?️
        uses: actions/checkout@v3


      - name: Install and Build
        run: |
          npm ci
          npm run build
          cp dist/index.html dist/404.html



      - name: Upload ?
        # https://github.com/JamesIves/github-pages-deploy-action
        uses: JamesIves/github-pages-deploy-action@v4
        with:
          folder: dist
          token: ${{ secrets.ACCESS_TOKEN }}


  Deploy:
    runs-on: ubuntu-latest
    needs: Build
    steps:
      - name: Deploy ?
        # https://github.com/peaceiris/actions-gh-pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          personal_token: ${{ secrets.ACCESS_TOKEN }}
          publish_dir: .
          publish_branch: gh-pages
          keep_files: true
          full_commit_message: ${{ github.event.head_commit.message }}
          user_name: 'github-actions[bot]'
          user_email: 'github-actions[bot]@users.rick-chou.github.com'

集成 PWA

项目使用的是 vite 所以直接使用 vite-plugin-pwa 就可以了

vite.config.ts 的 plugin 中加入

因为项目中没有用到真正的后端服务 所以就当是一个离线功能 不需要用 Service Worker 来缓存接口数据

VitePWA({
    injectRegister: 'auto',
    workbox: {
      maximumFileSizeToCacheInBytes: 500 * 1024 * 1024,
      globPatterns: ['**/*.{html,js,css,ico,png,svg}'],
    },
    registerType: 'autoUpdate',
    devOptions: {
      enabled: true,
    },
    useCredentials: true,
    includeAssets: ['public/*'],
    manifest: {
      name: name,
      start_url: `${VITE_BASE_URL}?mode=pwa`,
      short_name: name,
      description,
      theme_color: '#ffffff',
      icons: [
        {
          src: 'icon-64.png',
          sizes: '64x64',
          type: 'image/png',
        },
        {
          src: 'icon-192.png',
          sizes: '192x192',
          type: 'image/png',
        },
        {
          src: 'icon-512.png',
          sizes: '512x512',
          type: 'image/png',
        },
        {
          src: 'icon.png',
          sizes: '1280x1280',
          type: 'image/png',
        },
      ],
    },
  }),

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYpCTaqq' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片