百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术文章 > 正文

Vue3 流程图组件库 :Vue Flow

myzbx 2025-01-09 14:48 13 浏览

Vue Flow 是一个轻量级的 Vue 3 组件库,它允许开发者以简洁直观的方式创建动态流程图。本篇文章记录一下Vue Flow的基本用法


安装

npm add @vue-flow/core


流程图的构成

Nodes、Edges、Handles

主题

默认样式

通过导入样式文件应用

/* these are necessary styles for vue flow */
@import '@vue-flow/core/dist/style.css';

/* this contains the default theme, these are optional styles */
@import '@vue-flow/core/dist/theme-default.css';


对默认主题进行调整

1.可以使用css类名去覆盖

    .vue-flow__node-custom {
        background: purple;
        color: white;
        border: 1px solid purple;
        border-radius: 4px;
        box-shadow: 0 0 0 1px purple;
        padding: 8px;
    }


2.可以在组件上使用style或者class属性进行替换

    <VueFlow
    :nodes="nodes"
    :edges="edges"
    class="my-diagram-class"  
    :style="{ background: 'red' }"
    />


3.通过在全局的css文件中对组件的样式变量进行覆盖

    :root {
        --vf-node-bg: #fff;
        --vf-node-text: #222;
        --vf-connection-path: #b1b1b7;
        --vf-handle: #555;
    }


具体的css类名和变量名可以通过查阅官方文档确认 Theming | Vue Flow

Nodes

Nodes是流程图中的一个基本组件,可以在图表中可视化任何类型的数据,独立存在并通过edges互连从而创建数据映射

1.展示节点

节点的渲染是通过给VueFlow组件的nodes参数传入一个数组实现的


<script setup>
import { ref, onMounted } from 'vue'
import { VueFlow, Panel } from '@vue-flow/core'

const nodes = ref([
  {
    id: '1',
    position: { x: 50, y: 50 },
    data: { label: 'Node 1', },
  }
]);

function addNode() {
  const id = Date.now().toString()
  
  nodes.value.push({
    id,
    position: { x: 150, y: 50 },
    data: { label: `Node ${id}`, },
  })
}
</script>

<template>
  <VueFlow :nodes="nodes">
    <Panel>
      <button type="button" @click="addNode">Add a node</button>
    </Panel>
  </VueFlow>
</template>


2.节点的增删

对于节点的增加和删除,我们可以通过直接改变nodes参数来实现,也可以使用 useVueFlow 提供的方法addNodes 和removeNodes直接改变组件内部的状态实现

3.节点的更新

节点的更新同样可以使用改变nodes参数来实现,也可以使用useVueFlow得到的实例instance上的updateNodeData,传入对应组件的id和数据对象来更新;

instance.updateNode(nodeId, { selectable: false, draggable: false })


通过对实例的findNode方法拿到的节点实例直接修改组件state同样能够起到更新节点的效果

const node = instance.findNode(nodeId) 
node.data = { ...node.data, hello: 'world', }


4.节点的类型

节点的类型通过nodes数组中对应节点项的type属性确定

默认节点(type:'default')

input节点(type:'input')

output节点(type:'output')

自定义节点(type:'custom', type:'special',...)

除了默认的节点类型,用户也可以创建自定义的节点类型

模板插槽模式

<script setup>
import { ref } from 'vue'
import { VueFlow } from '@vue-flow/core'

import CustomNode from './CustomNode.vue'
import SpecialNode from './SpecialNode.vue'

export const nodes = ref([
  {
    id: '1',
    data: { label: 'Node 1' },
    // this will create the node-type `custom`
    type: 'custom',
    position: { x: 50, y: 50 },
  },
  {
    id: '1',
    data: { label: 'Node 1' },
    // this will create the node-type `special`
    type: 'special',
    position: { x: 150, y: 50 },
  }
])
</script>

<template>
  <VueFlow :nodes="nodes">
    <template #node-custom="customNodeProps">
      <CustomNode v-bind="customNodeProps" />
    </template>
    
    <template #node-special="specialNodeProps">
      <SpecialNode v-bind="specialNodeProps" />
    </template>
  </VueFlow>
</template>
<script setup>
import { ref } from 'vue'
import { VueFlow } from '@vue-flow/core'

import CustomNode from './CustomNode.vue'
import SpecialNode from './SpecialNode.vue'

export const nodes = ref([
  {
    id: '1',
    data: { label: 'Node 1' },
    // this will create the node-type `custom`
    type: 'custom',
    position: { x: 50, y: 50 },
  },
  {
    id: '1',
    data: { label: 'Node 1' },
    // this will create the node-type `special`
    type: 'special',
    position: { x: 150, y: 50 },
  }
])
</script>

<template>
  <VueFlow :nodes="nodes">
    <template #node-custom="customNodeProps">
      <CustomNode v-bind="customNodeProps" />
    </template>
    
    <template #node-special="specialNodeProps">
      <SpecialNode v-bind="specialNodeProps" />
    </template>
  </VueFlow>
</template>


在配置了自定义组件后,VueFlow会将节点类型字段和插槽名字进行动态匹配,从而正确渲染。

Node-types对象模式

直接将引入的组件对象通过VueFlow的nodeTypes参数传入,需要注意的是要去除组件对象的响应式

<script setup>
import { markRaw } from 'vue'
import CustomNode from './CustomNode.vue'
import SpecialNode from './SpecialNode.vue'

const nodeTypes = {
  custom: markRaw(CustomNode),
  special: markRaw(SpecialNode),
}

const nodes = ref([
  {
    id: '1',
    data: { label: 'Node 1' },
    type: 'custom',
  },
  {
    id: '1',
    data: { label: 'Node 1' },
    type: 'special',
  }
])
</script>

<template>
  <VueFlow :nodes="nodes" :nodeTypes="nodeTypes" />
</template>


节点事件

参考:Nodes | Vue Flow

Edges

Edges就是节点之间的连线部分,每一条连线都是从一个handle到另一个handle,其拥有独立的id;

展示Edges

Edges的渲染是通过给VueFlow组件的edges参数传入一个数组实现的,需要配合nodes一起确定节点之间的连线关系;

<script setup>
import { ref, onMounted } from 'vue'
import { VueFlow } from '@vue-flow/core'

const nodes = ref([
  {
    id: '1',
    position: { x: 50, y: 50 },
    data: { label: 'Node 1', },
  },
  {
    id: '2',
    position: { x: 50, y: 250 },
    data: { label: 'Node 2', },
  }
]);

const edges = ref([
  {
    id: 'e1->2',
    source: '1',
    target: '2',
  }
]);
</script>

<template>
  <VueFlow :nodes="nodes" :edges="edges" />
</template>


增删和更新Edges

和节点的类似,可以通过直接改变edges传参实现,同时useVueFlow也提供了对Edges的操作方法[addEdges],(vueflow.dev/typedocs/in…) removeEdges

Edges的更新

同样和节点类型类似,可以通过useVueFlow拿到实例,使用实例的updateEdgeData方法进行更新,也可以使用findEdge拿到的edge直接修改对应的state进行更新

instance.updateEdgeData(edgeId, { hello: 'mona' }) 
edge.data = { ...edge.data, hello: 'world', }


Edges类型

默认连线(type:'default')

阶梯连线(type:'step')

直线连接(type:'straight')

自定义连接

用法和自定义节点类似,只是插槽名变为edge-开头,参数名由nodeTypes变为edgeTypes

edge事件

参考:Edges | Vue Flow

Handles

节点边缘上的小圆圈,使用拖拽的方式进行节点之间的连接

使用Handle

Handle是以组件的方式在节点中引入的

<script setup>
import { Handle } from '@vue-flow/core'
  
defineProps(['id', 'sourcePosition', 'targetPosition', 'data'])
</script>

<template>
  <Handle type="source" :position="sourcePosition" />
  
  <span>{{ data.label }}</span>
  
  <Handle type="target" :position="targetPosition" />
</template>


Handle 位置

可以通过Handle组件的position参数来调整其位置

<Handle type="source" :position="Position.Right" /> 
<Handle type="target" :position="Position.Left" />


多个Handle使用时需要注意组件需要有唯一id

<Handle id="source-a" type="source" :position="Position.Right" /> <Handle id="source-b" type="source" :position="Position.Right" />


多个Handle在同一侧时需要手动调整位置防止重叠

<Handle id="source-a" type="source" :position="Position.Right" style="top: 10px" /> 
<Handle id="source-b" type="source" :position="Position.Right" style="bottom: 10px; top: auto;" />


Handle的隐藏

需要使用样式opacity,不能使用v-if和v-show

<Handle type="source" :position="Position.Right" style="opacity: 0" />


是否限制连接可以使用Handle组件的connectable参数,传入一个布尔值

<Handle type="source" :position="Position.Right" :connectable="handleConnectable" />


连接模式

<VueFlow :connection-mode="ConnectionMode.Strict" />


配置了ConnectionMode.Strict后只允许在相同类型的Handle之间进行连接

动态位置

在需要动态处理Handle的位置时,需要调用updateNodeInternals方法传入需要更新的节点id数组去应用,防止边缘未对其的情况出现。

import { useVueFlow } from '@vue-flow/core'

const { updateNodeInternals } = useVueFlow()

const onSomeEvent = () => {
  updateNodeInternals(['1'])
}


Composables

Vue Flow提供了一些用于获取流程图及其内部组件相关数据的API,可以参考文档 Composables | Vue Flow

Controlled Flow

Vue Flow同样提供了一些API用于对流程图的更新过程进行手动控制并且监听对应事件 受控流量 |Vue 流程 (vueflow.dev)

来看一下官方文档Demo

Layouting | Vue Flow 这个demo较全的使用到了Vue Flow中的一些基本用法:

1.主流程:App.vue:

import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import { nextTick, ref } from "vue";
import { Panel, VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import Icon from "./icon.vue";
import ProcessNode from "./processNode.vue";
import AnimationEdge from "./animationEdge.vue";
import { initialEdges, initialNodes } from "./initialElements";
import { useRunProcess } from "./useRunProcess";
import { useShuffle } from "./useShuffle";
import { useLayout } from "./useLayout";

// 节点的初始化数据
const nodes = ref(initialNodes);

// 节点的连接关系
const edges = ref(initialEdges);

// 打乱节点之间的连接关系
const shuffle = useShuffle();

// useLayout 处理节点布局对齐等
const { graph, layout, previousDirection } = useLayout();

const { fitView } = useVueFlow();

// 将节点和连线随机化
async function shuffleGraph() {
  await stop();

  reset(nodes.value);

  edges.value = shuffle(nodes.value);

  nextTick(() => {
    layoutGraph(previousDirection.value);
  });
}

// 进行重新排版
async function layoutGraph(direction) {
  await stop();

  reset(nodes.value);

  nodes.value = layout(nodes.value, edges.value, direction);

  nextTick(() => {
    fitView();
    run(nodes.value);
  });
}


<template>
  <div class="layout-flow">
    <VueFlow :nodes="nodes" :edges="edges" @nodes-initialized="layoutGraph('LR')">
    <!--    以插槽方式传入节点和连线    -->
      <template #node-process="props">
        <ProcessNode 
        :data="props.data" 
        :source-position="props.sourcePosition" 
        :target-position="props.targetPosition" />
      </template>

      <template #edge-animation="edgeProps">
        <AnimationEdge
          :id="edgeProps.id"
          :source="edgeProps.source"
          :target="edgeProps.target"
          :source-x="edgeProps.sourceX"
          :source-y="edgeProps.sourceY"
          :targetX="edgeProps.targetX"
          :targetY="edgeProps.targetY"
          :source-position="edgeProps.sourcePosition"
          :target-position="edgeProps.targetPosition"
        />
      </template>

      <Background />

      <Panel class="process-panel" position="top-right">
        <div class="layout-panel">
          <button v-if="isRunning" class="stop-btn" title="stop" @click="stop">
            <Icon name="stop" />
            <span class="spinner" />
          </button>
          <button v-else title="start" @click="run(nodes)">
            <Icon name="play" />
          </button>

          <button title="set horizontal layout" @click="layoutGraph('LR')">
            <Icon name="horizontal" />
          </button>

          <button title="set vertical layout" @click="layoutGraph('TB')">
            <Icon name="vertical" />
          </button>

          <button title="shuffle graph" @click="shuffleGraph">
            <Icon name="shuffle" />
          </button>
        </div>

        <div class="checkbox-panel">
          <label>Cancel on error</label>
          <input v-model="cancelOnError" type="checkbox" />
        </div>
      </Panel>
    </VueFlow>
  </div>
</template>


.layout-flow {
  background-color: #1a192b;
  height: 100%;
  width: 100%;
}

.process-panel,
.layout-panel {
  display: flex;
  gap: 10px;
}

.process-panel {
  background-color: #2d3748;
  padding: 10px;
  border-radius: 8px;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
  display: flex;
  flex-direction: column;
}

.process-panel button {
  border: none;
  cursor: pointer;
  background-color: #4a5568;
  border-radius: 8px;
  color: white;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}

.process-panel button {
  font-size: 16px;
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.checkbox-panel {
  display: flex;
  align-items: center;
  gap: 10px;
}

.process-panel button:hover,
.layout-panel button:hover {
  background-color: #2563eb;
  transition: background-color 0.2s;
}

.process-panel label {
  color: white;
  font-size: 12px;
}

.stop-btn svg {
  display: none;
}

.stop-btn:hover svg {
  display: block;
}

.stop-btn:hover .spinner {
  display: none;
}

.spinner {
  border: 3px solid #f3f3f3;
  border-top: 3px solid #2563eb;
  border-radius: 50%;
  width: 10px;
  height: 10px;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}


2.useShuffle.js

该文件提供的方法主要是用来随机打乱节点以及连线的关系

// 打乱数组的顺序
function shuffleArray(array) {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
}

// 根据节点数组生成一个可能的节点之间的映射关系
function generatePossibleEdges(nodes) {
  const possibleEdges = [];

  for (const sourceNode of nodes) {
    for (const targetNode of nodes) {
      if (sourceNode.id !== targetNode.id) {
        const edgeId = `e${sourceNode.id}-${targetNode.id}`;
        possibleEdges.push({
          id: edgeId,
          source: sourceNode.id,
          target: targetNode.id,
          type: "animation",
          animated: true
        });
      }
    }
  }

  return possibleEdges;
}

// 返回新的节点连接关系;
export function useShuffle() {
  return nodes => {
    const possibleEdges = generatePossibleEdges(nodes);
    shuffleArray(possibleEdges);

    const usedNodes = new Set();
    const newEdges = [];

    for (const edge of possibleEdges) {
      if (
        !usedNodes.has(edge.target) &&
        (usedNodes.size === 0 || usedNodes.has(edge.source))
      ) {
        newEdges.push(edge);
        usedNodes.add(edge.source);
        usedNodes.add(edge.target);
      }
    }

    return newEdges;
  };
}


3.useLayout.js

使用dagre对节点进行排版,返回排版后的图数据;

import dagre from "dagre";
import { ref } from "vue";
import { Position, useVueFlow } from "@vue-flow/core";

export function useLayout() {
  const { findNode } = useVueFlow();

  const graph = ref(new dagre.graphlib.Graph());

  const previousDirection = ref("LR");

  function layout(nodes, edges, direction) {
    const dagreGraph = new dagre.graphlib.Graph();

    graph.value = dagreGraph;

    // 设置默认的边标签
    dagreGraph.setDefaultEdgeLabel(() => ({}));

    const isHorizontal = direction === "LR";

    // 设置图布局
    dagreGraph.setGraph({ rankdir: direction });

    previousDirection.value = direction;

    for (const node of nodes) {
      // 查找到节点的信息
      const graphNode = findNode(node.id);
      // 设置节点
      dagreGraph.setNode(node.id, {
        width: graphNode.dimensions.width || 150,
        height: graphNode.dimensions.height || 50
      });
    }

    // 设置边
    for (const edge of edges) {
      dagreGraph.setEdge(edge.source, edge.target);
    }

    // 排版
    dagre.layout(dagreGraph);

    // 排版结束后返回新的节点状态
    return nodes.map(node => {
      const nodeWithPosition = dagreGraph.node(node.id);

      return {
        ...node,
        targetPosition: isHorizontal ? Position.Left : Position.Top,
        sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
        position: { x: nodeWithPosition.x, y: nodeWithPosition.y }
      };
    });
  }

  return { graph, layout, previousDirection };
}


4.useRunProcess.js

用于模拟流程运行过程中的各种状态

import { ref, toRef, toValue } from "vue";
import { useVueFlow } from "@vue-flow/core";

export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) {
  const { updateNodeData, getConnectedEdges } = useVueFlow();

  const graph = toRef(() => toValue(dagreGraph));

  // 是否正在运行
  const isRunning = ref(false);

  //已执行的节点
  const executedNodes = new Set();

  // 当前正在执行的节点
  const runningTasks = new Map();

  // 即将执行的节点
  const upcomingTasks = new Set();

  async function runNode(node, isStart = false) {
    if (executedNodes.has(node.id)) {
      return;
    }

    // 加入到即将执行的节点
    upcomingTasks.add(node.id);

    // 过滤出指向当前节点的连线
    const incomers = getConnectedEdges(node.id).filter(
      connection => connection.target === node.id
    );

    // 等待进入动画全部执行完成
    await Promise.all(
      incomers.map(incomer => until(() => !incomer.data.isAnimating))
    );

    // 清空
    upcomingTasks.clear();

    if (!isRunning.value) {
      return;
    }

    // 节点加入到已经执行的节点
    executedNodes.add(node.id);

    // 更新节点的状态
    updateNodeData(node.id, {
      isRunning: true,
      isFinished: false,
      hasError: false,
      isCancelled: false
    });

    const delay = Math.floor(Math.random() * 2000) + 1000;

    return new Promise(resolve => {
      const timeout = setTimeout(
        async () => {
          // 获取当前节点的所有后续子节点
          const children = graph.value.successors(node.id);

          // 随机抛出错误
          const willThrowError = Math.random() < 0.15;

          // 模拟错误的情况
          if (!isStart && willThrowError) {
            updateNodeData(node.id, { isRunning: false, hasError: true });

            if (toValue(cancelOnError)) {
              // 跳过错误节点后续子节点的处理
              await skipDescendants(node.id);
              // 删除节点对应正在执行的任务
              runningTasks.delete(node.id);

              // @ts-expect-error
              resolve();
              return;
            }
          }

          // 更新节点的状态未结束
          updateNodeData(node.id, { isRunning: false, isFinished: true });

          runningTasks.delete(node.id);

          // 递归执行后续节点
          if (children.length > 0) {
            await Promise.all(children.map(id => runNode({ id })));
          }
          resolve();
        },
        isStart ? 0 : delay
      );
      // 将当前任务加入到运行任务
      runningTasks.set(node.id, timeout);
    });
  }

  // 从起始节点开始执行的情况
  async function run(nodes) {
    if (isRunning.value) {
      return;
    }

    reset(nodes);

    isRunning.value = true;

    // 过滤出起始节点
    const startingNodes = nodes.filter(
      node => graph.value.predecessors(node.id)?.length === 0
    );

    // 调用runNode从起始节点执行
    await Promise.all(startingNodes.map(node => runNode(node, true)));

    clear();
  }

  //重置
  function reset(nodes) {
    clear();

    for (const node of nodes) {
      updateNodeData(node.id, {
        isRunning: false,
        isFinished: false,
        hasError: false,
        isSkipped: false,
        isCancelled: false
      });
    }
  }

  async function skipDescendants(nodeId) {
    const children = graph.value.successors(nodeId);

    for (const child of children) {
      updateNodeData(child, { isRunning: false, isSkipped: true });
      await skipDescendants(child);
    }
  }

  // 暂停运行
  async function stop() {
    isRunning.value = false;

    for (const nodeId of upcomingTasks) {
      clearTimeout(runningTasks.get(nodeId));
      runningTasks.delete(nodeId);
      updateNodeData(nodeId, {
        isRunning: false,
        isFinished: false,
        hasError: false,
        isSkipped: false,
        isCancelled: true
      });
      await skipDescendants(nodeId);
    }

    for (const [nodeId, task] of runningTasks) {
      clearTimeout(task);
      runningTasks.delete(nodeId);
      updateNodeData(nodeId, {
        isRunning: false,
        isFinished: false,
        hasError: false,
        isSkipped: false,
        isCancelled: true
      });
      await skipDescendants(nodeId);
    }

    executedNodes.clear();
    upcomingTasks.clear();
  }

  function clear() {
    isRunning.value = false;
    executedNodes.clear();
    runningTasks.clear();
  }

  return { run, stop, reset, isRunning };
}

// 等待直到condition为true
async function until(condition) {
  return new Promise(resolve => {
    const interval = setInterval(() => {
      if (condition()) {
        clearInterval(interval);
        resolve();
      }
    }, 100);
  });
}


5.processNode.js

流程图节点组件,根据节点状态显示不同的样式

import { computed, toRef } from 'vue'
import { Handle, useHandleConnections } from '@vue-flow/core'

const props = defineProps({
  data: {
    type: Object,
    required: true,
  },
  sourcePosition: {
    type: String,
  },
  targetPosition: {
    type: String,
  },
})

const sourceConnections = useHandleConnections({
  type: 'target',
})

const targetConnections = useHandleConnections({
  type: 'source',
})

// 判断是发送节点还是接收节点
const isSender = toRef(() => sourceConnections.value.length <= 0)

const isReceiver = toRef(() => targetConnections.value.length <= 0)

// 根据节点的数据参数来确定节点的背景颜色
const bgColor = computed(() => {
  if (isSender.value) {
    return '#2563eb'
  }

  if (props.data.hasError) {
    return '#f87171'
  }

  if (props.data.isFinished) {
    return '#42B983'
  }

  if (props.data.isCancelled) {
    return '#fbbf24'
  }

  return '#4b5563'
})

const processLabel = computed(() => {
  if (props.data.hasError) {
    return '?'
  }

  if (props.data.isSkipped) {
    return ''
  }

  if (props.data.isCancelled) {
    return ''
  }

  if (isSender.value) {
    return ''
  }

  if (props.data.isFinished) {
    return ''
  }

  return ''
})
</script>

<template>
  <div class="process-node" :style="{ backgroundColor: bgColor, boxShadow: data.isRunning ? '0 0 10px rgba(0, 0, 0, 0.5)' : '' }">
    <!-- 使用Handle组件处理连接点的样式 -->
    <Handle v-if="!isSender" type="target" :position="targetPosition">
      <span v-if="!data.isRunning && !data.isFinished && !data.isCancelled && !data.isSkipped && !data.hasError"> </span>
    </Handle>

    <Handle v-if="!isReceiver" type="source" :position="sourcePosition" />

    <div v-if="!isSender && data.isRunning" class="spinner" />
    <span v-else>
      {{ processLabel }}
    </span>
  </div>
</template>

<style scoped>
.process-node {
  padding: 10px;
  border-radius: 99px;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.process-node .vue-flow__handle {
  border: none;
  height: unset;
  width: unset;
  background: transparent;
  font-size: 12px;
}


6.AnimationEdge.js

处理节点之间连线的动画效果

<script lang="ts" setup>
import { computed, nextTick, ref, toRef, watch } from "vue";
import { TransitionPresets, executeTransition } from "@vueuse/core";
import {
  Position,
  BaseEdge,
  useVueFlow,
  useNodesData,
  getSmoothStepPath,
  EdgeLabelRenderer
} from "@vue-flow/core";

const props = defineProps({
  id: {
    type: String,
    required: true
  },
  source: {
    type: String,
    required: true
  },
  target: {
    type: String,
    required: true
  },
  sourceX: {
    type: Number,
    required: true
  },
  sourceY: {
    type: Number,
    required: true
  },
  targetX: {
    type: Number,
    required: true
  },
  targetY: {
    type: Number,
    required: true
  },
  sourcePosition: {
    type: String,
    default: Position.Right
  },
  targetPosition: {
    type: String,
    default: Position.Left
  }
});

const { findEdge } = useVueFlow();

// 获取被当前edge连接的两个节点
const nodesData = useNodesData([props.target, props.source]);

const targetNodeData = toRef(() => nodesData.value[0].data);

const sourceNodeData = toRef(() => nodesData.value[1].data);

// edge动画开始位置等相关信息
const edgePoint = ref(0);

const edgeRef = ref();

const labelPosition = ref({ x: 0, y: 0 });

const currentLength = ref(0);

// edge当前的状态

const isFinished = toRef(() => sourceNodeData.value.isFinished);

const isCancelled = toRef(() => targetNodeData.value.isCancelled);

// 显示动画flag
const isAnimating = ref(false);

// edge颜色
const edgeColor = toRef(() => {
  if (targetNodeData.value.hasError) {
    return "#f87171";
  }

  if (targetNodeData.value.isFinished) {
    return "#42B983";
  }

  if (targetNodeData.value.isCancelled || targetNodeData.value.isSkipped) {
    return "#fbbf24";
  }

  if (targetNodeData.value.isRunning || isAnimating.value) {
    return "#2563eb";
  }

  return "#6b7280";
});

// 得到edge的路径
const path = computed(() => getSmoothStepPath(props));


// 重置动画
watch(isCancelled, isCancelled => {
  if (isCancelled) {
    reset();
  }
});

// 更新edge数据
watch(isAnimating, isAnimating => {
  const edge = findEdge(props.id);

  if (edge) {
    edge.data = {
      ...edge.data,
      isAnimating
    };
  }
});

// 监听edgePoint变化
watch(edgePoint, point => {
  const pathEl = edgeRef.value?.pathEl;

  if (!pathEl || point === 0 || !isAnimating.value) {
    return;
  }

  const nextLength = pathEl.getTotalLength();

  // 当currentLength路径没有被更新时启动动画
  if (currentLength.value !== nextLength) {
    runAnimation();
    return;
  }

  // 更新label的位置
  labelPosition.value = pathEl.getPointAtLength(point);
});

watch(isFinished, isFinished => {
  if (isFinished) {
    runAnimation();
  }
});

// 开启动画
async function runAnimation() {
  
  // 获取edge路径
  const pathEl = edgeRef.value?.pathEl;

  if (!pathEl) {
    return;
  }

  const totalLength = pathEl.getTotalLength();

  const from = edgePoint.value || 0;

  // 更新label的位置
  labelPosition.value = pathEl.getPointAtLength(from);

  // 更新动画flag
  isAnimating.value = true;

  // 更新当前路径总长度
  if (currentLength.value !== totalLength) {
    currentLength.value = totalLength;
  }

  // 使用vueUse的executeTransition处理缓动动画
  await executeTransition(edgePoint, from, totalLength, {
      transition: TransitionPresets.easeInOutCubic,
        duration: Math.max(1500, totalLength / 2),
          abort: () => !isAnimating.value
    });

  reset();
}

//重置动画
function reset() {
  nextTick(() => {
    edgePoint.value = 0;
    currentLength.value = 0;
    labelPosition.value = { x: 0, y: 0 };
    isAnimating.value = false;
  });
}
</script>

<template>
  <BaseEdge
    :id="id"
    ref="edgeRef"
    :path="path[0]"
    :style="{ stroke: edgeColor }"
  />

  <EdgeLabelRenderer v-if="isAnimating">
    <div
      :style="{
        transform: `translate(-50%, -50%) translate(${labelPosition.x}px,${labelPosition.y}px)`
      }"
      class="nodrag nopan animated-edge-label"
    >
      <span class="truck">
        <span class="box"></span>
        
      </span>
    </div>
  </EdgeLabelRenderer>
</template>

<style scoped>
.animated-edge-label {
  position: absolute;
  z-index: 100;
}

.truck {
  position: relative;
  display: inline-block;
  transform: scaleX(-1);
}

.box {
  position: absolute;
  top: -10px;
}
</style>



总结

文章主要介绍了如何使用 Vue Flow 库的基本概念和使用:

1.安装

2.基础组件:

  • Nodes:图中的基本单元,用于表示数据。
  • Edges:连接节点的连线。
  • Handles:节点上的小圆圈,用于连接节点。

3.主题定制

可以通过以下方式调整默认样式:

  • 覆盖 CSS 类名:通过 CSS 类名来自定义节点样式。
  • 组件属性:在 Vue 组件上使用 style 或 class 属性。
  • 全局 CSS 变量:在全局 CSS 文件中覆盖样式变量。

4.节点(Nodes)

  • 节点展示:通过传入 nodes 数组到 VueFlow 组件来展示节点。
  • 节点增删:可以通过改变 nodes 参数或使用 useVueFlow 提供的 addNodes 和 removeNodes 方法。
  • 节点更新:可以直接修改 nodes 参数或使用 updateNodeData 方法。
  • 节点类型:包括默认节点、输入节点、输出节点和自定义节点。

5.连线(Edges)

  • 连线展示:通过传入 edges 数组到 VueFlow 组件来展示连线。
  • 连线增删和更新:类似于节点,可以通过改变 edges 参数或使用 useVueFlow 提供的方法。
  • 连线类型:支持默认连线、阶梯连线、直线连线和自定义连线。

6.Handles

Handles 用于连接节点,可以自定义位置、多个 Handle 配置、动态更新和显示/隐藏等。


作者:Lumen丶
链接:https://juejin.cn/post/7407683752712388608

相关推荐

攀升战境S5电竞主机评测:NVIDIA RTX 3060实力助阵,光追游戏走起

此次笔者将为玩家们推荐一款游戏主机——攀升战境S5。该主机是攀升电脑今年力推的游戏装备,主机采用一线品牌配件,特别是在显卡选用上严苛把关,精选GeForceRTX30系列显卡,玩家们大可以放心选购...

慎买-神牛闪光灯兼容性问题:神牛V350&amp;松下S5M2

神牛V350和松下S5M2的兼容性问题。大家好,我是向往闪光灯人像的Fish。国庆期间,我购买了神牛V350闪光灯和神牛X2T引闪器,但这成为了我的噩梦。我原以为客服和松友们说这款闪光灯在松下S5M2...

Acer蜂鸟持续办公一整天(acer 蜂鸟s5)

移动办公在工作节奏日益加快的今天越来越普遍,目前大部分工作无法在手持设备上完成,笔记本依然是移动办公最明智的选择。为了实现移动办公,很多笔记本越做越轻薄,性能也越来越强,而续航却一直没有很大提升。笔者...

职业车手明年会骑什么?2021赛季各大世巡赛车队使用器材一览

新年的钟声即将敲响,意味着充满魔幻色彩的2020年即将过去。受新冠肺炎的影响,2020年的赛季非常不同寻常。因这一原因不得不延迟举行的各种比赛导致许多车队的赞助商无法得到足够曝光,这也间接导致了许多车...

三星部分手机系统升级路线图流出(三星系统在哪升级)

三星包括Note3和S5在内的手机在升级到4.4.2系统之后一直没有什么系统升级的消息,而最近流出的一张三星的系统升级路线图中出现了一共13台手机升级KTU84P(也就是Android4.4.4)...

索尼Xperia Z3配置大曝光:升级并不大

IT之家(www.ithome.com):索尼XperiaZ3配置大曝光:升级并不大索尼明天就会在IFA2014大会上发布其下代旗舰XperiaZ3智能手机,目前网上曝光了其原型机,并且机身背后...

不进反退 三星Exynos 5433只能运行32位模式?

三星GalaxyNote4将带有两个版本,除了国行使用的骁龙805以外,还有三星自家的Exynos5433版本。而这颗SoC的详细信息三星并没有公布,据外媒Anandtech称,他们从源码中确认...

尼康Z6III测评:对比EOS R6 II、A7M4、S5IIX

摄影器材测评网站DPReview刚刚发布了尼康Z6III的完整图文测评,该机获得金奖评级,得分达到91%。以下是该文章的摘录——尼康Z6III核心规格:2400万像素“部分堆栈式”传感器RAW连拍:机...

赛默飞Ion S5首批数据公布,玩爆前任PGMTM系列

北美时间9月1日,赛默飞发布了两款最新的NGS系统IonS5和IonS5XL,旨在提供更加简捷的靶向测序流程。10月29日IonS5测序仪的首批实验数据产生于阜外医院。阜外医院研究人员选用了主...

Excel技巧:快速制作批量文件夹,省时省力,加强工作效率

大家好,如果公司领导要求按人员姓名制作文件夹,以一人一档的形式呈现人员档案,办公人员一个一个制作费时费力,而且效力低下,今天为大家介绍快捷制作批量文件夹的方法下面我们用图片来进行演示操作打开表格,选...

国行、港版、美版Apple watch各版本售价一览

今天凌晨,苹果牌手表正式发布,苹果开始正式进入可穿戴设备领域,除了功能和外观,我相信大家更关心的是价格问题了,小编就将国行、港版、美版的Applewatch售价做一总结,以供参考。国行:美版:港版:...

松下全画幅微单S5和S1到底哪里不一样?

Hello,我是ET,欢迎大家来到我的“相机笔记”。————9月2日晚,松下正式发布了第4款全画幅微单LUMIXS5。这一篇,我们主要来说松下LUMIXS5和LUMIXS1到底有哪些区别...

融会贯通之典范 神舟S7-2021S5评测

便携、性能、续航,这简简单单的六个字道出了这么些年来笔记本电脑的设计方向,可是由于底层技术、模具设计等等原因,这三点并不能很好的融合在一起。虽说闻道有先后,术业有专攻,但能够有一台融会贯通的产品,不是...

三国志战略版:S5赛季装X指南,开荒不是一成不变,需要因地制宜

大家好我是零氪玩家花席,S5赛季已经开始,因为S5赛季的野地阵容和S4赛季没有区别,所以S5赛季开荒相对不难。你在S4有经验,并且多了很多武将和战法,还能用150赛季功勋兑换7500战法点。S5赛季新...

聊聊松下S5M2和S5M2X的区别(松下s5k和s5c有什么区别)

先简单说下哪里不同:12bitRAWHDMI外录支持直接将视频录制到USB-SSD上多了All-Intra和ProRes编码支持有线/无线IP推流,USB网络连接黑化的机身不过要特别强调一下,S5...