2023/01/10

使用 React Flow 构建思维导图应用

Moritz Klack
联合创始人

在本教程中,您将学习如何使用 React Flow 创建一个简单的思维导图工具,该工具可用于头脑风暴、组织想法或以可视化的方式映射您的想法。为了构建这个应用程序,我们将使用状态管理、自定义节点和边等等。

演示时间!

在我们动手之前,我想向您展示在本教程结束时我们将拥有的思维导图工具。

如果您想冒险并直接深入代码,您可以在 Github 上找到源代码。

入门

要完成本教程,您需要具备一些 ReactReact Flow (嗨,就是我们! 它是一个用于构建基于节点的 UI 的开源库,例如工作流程工具、ETL 管道以及 更多。)的知识。

我们将使用 Vite 来开发我们的应用程序,但您也可以使用 Create React App 或任何其他您喜欢的工具。要使用 Vite 构建一个新的 React 应用程序,您需要执行以下操作

npm create vite@latest reactflow-mind-map -- --template react

如果您想使用 Typescript

npm create vite@latest reactflow-mind-map -- --template react-ts

在初始设置后,您需要安装一些包

npm install reactflow zustand classcat nanoid

我们使用 Zustand 来管理应用程序的状态。它有点像 Redux,但更小,而且编写所需的样板代码更少。React Flow 也使用 Zustand,因此安装没有额外成本。(在本教程中,我们使用 Typescript,但您也可以使用纯 Javascript。)

为了保持简单,我们将所有代码都放在 src/App 文件夹中。为此,您需要创建 src/App 文件夹并添加一个包含以下内容的索引文件

src/App/index.tsx

import { ReactFlow, Controls, Panel } from '@xyflow/react';
 
// we have to import the React Flow styles for it to work
import '@xyflow/react/dist/style.css';
 
function Flow() {
  return (
    <ReactFlow>
      <Controls showInteractive={false} />
      <Panel position="top-left">React Flow Mind Map</Panel>
    </ReactFlow>
  );
}
 
export default Flow;

这将是我们渲染思维导图的主要组件。目前还没有节点或边,但我们添加了 React Flow Controls 组件和一个 Panel 来显示应用程序的标题。

为了能够使用 React Flow 钩子,我们需要在我们的 main.tsx(Vite 的入口文件)中使用 ReactFlowProvider 组件来包装应用程序。我们还将导入新创建的 App/index.tsx 并在 ReactFlowProvider. 内渲染它。您的主文件应如下所示

src/main.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { ReactFlowProvider } from '@xyflow/react';
 
import App from './App';
 
import './index.css';
 
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <ReactFlowProvider>
      <App />
    </ReactFlowProvider>
  </React.StrictMode>,
);

React Flow 组件的父容器需要宽度和高度才能正常工作。我们的应用程序是一个全屏应用程序,因此我们将这些规则添加到 index.css 文件中

src/index.css

body {
  margin: 0;
}
 
html,
body,
#root {
  height: 100%;
}

我们将应用程序的所有样式都添加到 index.css 文件中(您也可以使用 CSS-in-JS 库,例如 Styled ComponentsTailwind)。现在您可以使用 npm run dev 启动开发服务器,您应该会看到以下内容

用于节点和边的存储

如上所述,我们使用 Zustand 来进行状态管理。为此,我们在 src/App 文件夹中创建一个名为 store.ts 的新文件

src/App/store.ts

import {
  Edge,
  EdgeChange,
  Node,
  NodeChange,
  OnNodesChange,
  OnEdgesChange,
  applyNodeChanges,
  applyEdgeChanges,
} from '@xyflow/react';
import { createWithEqualityFn } from 'zustand/traditional';
 
export type RFState = {
  nodes: Node[];
  edges: Edge[];
  onNodesChange: OnNodesChange;
  onEdgesChange: OnEdgesChange;
};
 
const useStore = createWithEqualityFn<RFState>((set, get) => ({
  nodes: [
    {
      id: 'root',
      type: 'mindmap',
      data: { label: 'React Flow Mind Map' },
      position: { x: 0, y: 0 },
    },
  ],
  edges: [],
  onNodesChange: (changes: NodeChange[]) => {
    set({
      nodes: applyNodeChanges(changes, get().nodes),
    });
  },
  onEdgesChange: (changes: EdgeChange[]) => {
    set({
      edges: applyEdgeChanges(changes, get().edges),
    });
  },
}));
 
export default useStore;

看起来代码很多,但大部分是类型 该存储跟踪节点和边并处理更改事件。当用户拖动节点时,React Flow 会触发更改事件,然后存储会应用更改,并渲染更新后的节点。(您可以在我们的 状态管理库指南 中阅读更多相关内容。)

如您所见,我们从一个初始节点开始,该节点位于 { x: 0, y: 0 } 处,类型为 ‘mindmap’。为了将存储与我们的应用程序连接起来,我们使用 useStore 钩子

src/App/index.tsx

import { ReactFlow, Controls, Panel, NodeOrigin } from '@xyflow/react';
import { shallow } from 'zustand/shallow';
 
import useStore, { RFState } from './store';
 
// we have to import the React Flow styles for it to work
import '@xyflow/react/dist/style.css';
 
const selector = (state: RFState) => ({
  nodes: state.nodes,
  edges: state.edges,
  onNodesChange: state.onNodesChange,
  onEdgesChange: state.onEdgesChange,
});
 
// this places the node origin in the center of a node
const nodeOrigin: NodeOrigin = [0.5, 0.5];
 
function Flow() {
  // whenever you use multiple values, you should use shallow to make sure the component only re-renders when one of the values changes
  const { nodes, edges, onNodesChange, onEdgesChange } = useStore(
    selector,
    shallow,
  );
 
  return (
    <ReactFlow
      nodes={nodes}
      edges={edges}
      onNodesChange={onNodesChange}
      onEdgesChange={onEdgesChange}
      nodeOrigin={nodeOrigin}
      fitView
    >
      <Controls showInteractive={false} />
      <Panel position="top-left">React Flow Mind Map</Panel>
    </ReactFlow>
  );
}
 
export default Flow;

我们从存储中访问节点、边和更改处理程序,并将它们传递给 React Flow 组件。我们还使用 fitView 属性来确保初始节点居中于视图中,并将节点原点设置为 [0.5, 0.5] 以将原点设置为节点的中心。完成此操作后,您的应用程序应如下所示

export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

您可以移动节点并放大和缩小,我们正在取得进展 现在让我们添加更多功能。

自定义节点和边

我们想对节点使用名为 ‘mindmap’ 的自定义类型。我们需要为此添加一个新组件。让我们在 src/App 下创建一个名为 MindMapNode 的新文件夹,其中包含以下内容的索引文件

src/App/MindMapNode/index.tsx

import { Handle, NodeProps, Position } from '@xyflow/react';
 
export type NodeData = {
  label: string;
};
 
function MindMapNode({ id, data }: NodeProps<NodeData>) {
  return (
    <>
      <input defaultValue={data.label} />
 
      <Handle type="target" position={Position.Top} />
      <Handle type="source" position={Position.Bottom} />
    </>
  );
}
 
export default MindMapNode;

我们使用输入来显示和编辑思维导图节点的标签,并使用两个句柄来连接它们。这是 React Flow 工作所必需的;句柄用作边的起始和结束位置。

我们还在 index.css 文件中添加一些 CSS,以使节点看起来更漂亮

src/index.css

.react-flow__node-mindmap {
  background: white;
  border-radius: 2px;
  border: 1px solid transparent;
  padding: 2px 5px;
  font-weight: 700;
}

(有关更多信息,您可以阅读我们文档中有关 自定义节点 的指南。)

让我们对自定义边也执行相同的操作。在 src/App 下创建一个名为 MindMapEdge 的新文件夹

src/App/MindMapEdge/index.tsx

import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
 
function MindMapEdge(props: EdgeProps) {
  const { sourceX, sourceY, targetX, targetY } = props;
 
  const [edgePath] = getStraightPath({
    sourceX,
    sourceY,
    targetX,
    targetY,
  });
 
  return <BaseEdge path={edgePath} {...props} />;
}
 
export default MindMapEdge;

我将在下一节详细介绍自定义节点和边。现在,重要的是我们可以在应用程序中使用这些新类型,方法是在我们的 Flow 组件中添加以下内容

import MindMapNode from './MindMapNode';
import MindMapEdge from './MindMapEdge';
 
const nodeTypes = {
  mindmap: MindMapNode,
};
 
const edgeTypes = {
  mindmap: MindMapEdge,
};

然后将新创建的类型传递给 React Flow 组件。

export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

太棒了!我们现在可以通过点击输入框并输入内容来更改节点的标签了。

新节点

我们希望让用户能够快速创建新节点。用户应该能够通过点击节点并拖动到新节点应该放置的位置来添加新节点。此功能不是 React Flow 的内置功能,但我们可以通过使用 onConnectStartonConnectEnd 处理程序来实现它。

我们使用开始处理程序来记住被点击的节点,使用结束处理程序来创建新节点。

添加到 src/App/index.tsx

const connectingNodeId = useRef<string | null>(null);
 
const onConnectStart: OnConnectStart = useCallback((_, { nodeId }) => {
  connectingNodeId.current = nodeId;
}, []);
 
const onConnectEnd: OnConnectEnd = useCallback((event) => {
  // we only want to create a new node if the connection ends on the pane
  const targetIsPane = (event.target as Element).classList.contains(
    'react-flow__pane',
  );
 
  if (targetIsPane && connectingNodeId.current) {
    console.log(`add new node with parent node ${connectingNodeId.current}`);
  }
}, []);

由于我们的节点由存储库管理,因此我们创建一个操作来添加新节点及其边。这是我们的 addChildNode 操作的样子。

src/store.ts 中的新操作

addChildNode: (parentNode: Node, position: XYPosition) => {
  const newNode = {
    id: nanoid(),
    type: 'mindmap',
    data: { label: 'New Node' },
    position,
    parentNode: parentNode.id,
  };
 
  const newEdge = {
    id: nanoid(),
    source: parentNode.id,
    target: newNode.id,
  };
 
  set({
    nodes: [...get().nodes, newNode],
    edges: [...get().edges, newEdge],
  });
};

我们使用传递的节点作为父节点。通常此功能用于实现 分组子流程。在这里,我们使用它来移动所有子节点,当它们的父节点被移动时。它使我们能够清理和重新排序思维导图,因此我们不必手动移动所有子节点。让我们在我们的 onConnectEnd 处理程序中使用新操作。

src/App/index.tsx 中的调整

const store = useStoreApi();
 
const onConnectEnd: OnConnectEnd = useCallback(
  (event) => {
    const { nodeLookup } = store.getState();
    const targetIsPane = (event.target as Element).classList.contains(
      'react-flow__pane',
    );
 
    if (targetIsPane && connectingNodeId.current) {
      const parentNode = nodeLookup.get(connectingNodeId.current);
      const childNodePosition = getChildNodePosition(event, parentNode);
 
      if (parentNode && childNodePosition) {
        addChildNode(parentNode, childNodePosition);
      }
    }
  },
  [getChildNodePosition],
);

首先,我们通过 store.getState() 从 React Flow 存储库中获取 nodeLookupnodeLookup 是一个包含所有节点及其当前状态的映射。我们需要它来获取被点击节点的位置和尺寸。然后我们检查 onConnectEnd 事件的目标是否为 React Flow 面板。如果是,我们希望添加一个新节点。为此,我们使用我们的 addChildNode 和新创建的 getChildNodePosition 辅助函数。

src/App/index.tsx 中的辅助函数

const getChildNodePosition = (event: MouseEvent, parentNode?: Node) => {
  const { domNode } = store.getState();
 
  if (
    !domNode ||
    // we need to check if these properites exist, because when a node is not initialized yet,
    // it doesn't have a positionAbsolute nor a width or height
    !parentNode?.computed?.positionAbsolute ||
    !parentNode?.computed?.width ||
    !parentNode?.computed?.height
  ) {
    return;
  }
 
  const panePosition = screenToFlowPosition({
    x: event.clientX,
    y: event.clientY,
  });
 
  // we are calculating with positionAbsolute here because child nodes are positioned relative to their parent
  return {
    x:
      panePosition.x -
      parentNode.computed?.positionAbsolute.x +
      parentNode.computed?.width / 2,
    y:
      panePosition.y -
      parentNode.computed?.positionAbsolute.y +
      parentNode.computed?.height / 2,
  };
};

此函数返回我们要添加到存储库的新节点的位置。我们使用 project 函数 将屏幕坐标转换为 React Flow 坐标。如前所述,子节点相对于其父节点定位。这就是为什么我们需要从子节点位置中减去父节点位置的原因。这需要考虑很多,让我们看看它的实际效果。

export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

要测试新功能,你可以从一个句柄开始连接,然后在面板上结束连接。你应该会看到一个新节点被添加到思维导图中。

保持数据同步

我们已经可以更新标签,但我们没有更新节点数据对象。这对保持应用程序同步非常重要,如果我们想要将节点保存到服务器(例如)。为了实现这一点,我们在存储库中添加了一个名为 updateNodeLabel 的新操作。此操作接受一个节点 ID 和一个标签。实现很简单:我们遍历现有节点,并使用传递的标签更新匹配的节点。

src/store.ts

updateNodeLabel: (nodeId: string, label: string) => {
  set({
    nodes: get().nodes.map((node) => {
      if (node.id === nodeId) {
        // it's important to create a new object here, to inform React Flow about the changes
        node.data = { ...node.data, label };
      }
 
      return node;
    }),
  });
},

让我们在我们的 MindmapNode 组件中使用新的操作。

src/App/MindmapNode/index.tsx

import { Handle, NodeProps, Position } from '@xyflow/react';
 
import useStore from '../store';
 
export type NodeData = {
  label: string;
};
 
function MindMapNode({ id, data }: NodeProps<NodeData>) {
  const updateNodeLabel = useStore((state) => state.updateNodeLabel);
 
  return (
    <>
      <input
        // from now on we can use value instead of defaultValue
        // this makes sure that the input always shows the current label of the node
        value={data.label}
        onChange={(evt) => updateNodeLabel(id, evt.target.value)}
        className="input"
      />
 
      <Handle type="target" position={Position.Top} />
      <Handle type="source" position={Position.Top} />
    </>
  );
}
 
export default MindMapNode;

太快了!自定义节点的输入框现在显示了节点的当前标签。你可以获取你的节点数据,将它保存到服务器,然后再次加载它。

更简单的 UX 和更漂亮的样式

从功能方面来说,我们的思维导图应用程序已经完成了!我们可以添加新节点,更新它们的标签并移动它们。但 UX 和样式可以改进。让我们让拖动节点和创建新节点变得更容易!

1. 节点作为句柄

让我们使用整个节点作为句柄,而不是显示默认句柄。这使得创建节点更容易,因为你可以开始新连接的区域变大了。我们需要将源句柄的样式设置为节点的大小,并隐藏目标句柄的视觉效果。React Flow 仍然需要它来连接节点,但我们不需要显示它,因为我们通过将边放在面板上进行拖放来创建新节点。我们使用普通的 CSS 来隐藏目标句柄并将其放置在节点的中心。

src/index.css

.react-flow__handle.target {
  top: 50%;
  pointer-events: none;
  opacity: 0;
}

为了使整个节点成为句柄,我们还要更新源的样式。

src/index.css

.react-flow__handle.source {
  top: 0;
  left: 0;
  transform: none;
  background: #f6ad55;
  height: 100%;
  width: 100%;
  border-radius: 2px;
  border: none;
}
export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

这有效,但我们不能再移动节点了,因为源句柄现在是整个节点,覆盖了输入框。我们通过使用 dragHandle 节点选项 来解决这个问题。它允许我们为 DOM 元素指定一个选择器,该选择器应该用作拖动句柄。为此,我们稍微调整一下自定义节点。

src/App/MindmapNode/index.tsx

import { Handle, NodeProps, Position } from '@xyflow/react';
 
import useStore from '../store';
 
export type NodeData = {
  label: string;
};
 
function MindMapNode({ id, data }: NodeProps<NodeData>) {
  const updateNodeLabel = useStore((state) => state.updateNodeLabel);
 
  return (
    <>
      <div className="inputWrapper">
        <div className="dragHandle">
          {/* icon taken from grommet https://icons.grommet.io */}
          <svg viewBox="0 0 24 24">
            <path
              fill="#333"
              stroke="#333"
              strokeWidth="1"
              d="M15 5h2V3h-2v2zM7 5h2V3H7v2zm8 8h2v-2h-2v2zm-8 0h2v-2H7v2zm8 8h2v-2h-2v2zm-8 0h2v-2H7v2z"
            />
          </svg>
        </div>
        <input
          value={data.label}
          onChange={(evt) => updateNodeLabel(id, evt.target.value)}
          className="input"
        />
      </div>
 
      <Handle type="target" position={Position.Top} />
      <Handle type="source" position={Position.Top} />
    </>
  );
}
 
export default MindMapNode;

我们添加了一个带有类名 inputWrapper 的包装 div 和一个带有类名 dragHandle 的 div,它充当拖动句柄(令人惊讶!)。现在我们可以为新元素设置样式。

src/index.css

.inputWrapper {
  display: flex;
  height: 20px;
  z-index: 1;
  position: relative;
}
 
.dragHandle {
  background: transparent;
  width: 14px;
  height: 100%;
  margin-right: 4px;
  display: flex;
  align-items: center;
}
 
.input {
  border: none;
  padding: 0 2px;
  border-radius: 1px;
  font-weight: 700;
  background: transparent;
  height: 100%;
  color: #222;
}
export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

2. 在聚焦时激活输入

我们快完成了,但还需要调整一些细节。我们希望从节点的中心开始我们的新连接。为此,我们将输入的指针事件设置为“none”,并检查用户是否在节点上释放了按钮。只有这样,我们才希望激活输入框。我们可以使用我们的 onConnectEnd 函数来实现这一点。

src/App/index.tsx

const onConnectEnd: OnConnectEnd = useCallback(
  (event) => {
    const { nodeLookup } = store.getState();
    const targetIsPane = (event.target as Element).classList.contains(
      'react-flow__pane',
    );
    const node = (event.target as Element).closest('.react-flow__node');
 
    if (node) {
      node.querySelector('input')?.focus({ preventScroll: true });
    } else if (targetIsPane && connectingNodeId.current) {
      const parentNode = nodeLookup.get(connectingNodeId.current);
      const childNodePosition = getChildNodePosition(event, parentNode);
 
      if (parentNode && childNodePosition) {
        addChildNode(parentNode, childNodePosition);
      }
    }
  },
  [getChildNodePosition],
);

如你所见,如果用户在节点上释放鼠标按钮,我们将聚焦输入框。我们现在可以添加一些样式,以便只有在输入框获得焦点时才激活它(pointerEvents: all)。

/* we want the connection line to be below the node */
.react-flow .react-flow__connectionline {
  z-index: 0;
}
 
/* pointer-events: none so that the click for the connection goes through */
.inputWrapper {
  display: flex;
  height: 20px;
  position: relative;
  z-index: 1;
  pointer-events: none;
}
 
/* pointer-events: all so that we can use the drag handle (here the user cant start a new connection) */
.dragHandle {
  background: transparent;
  width: 14px;
  height: 100%;
  margin-right: 4px;
  display: flex;
  align-items: center;
  pointer-events: all;
}
 
/* pointer-events: none by default */
.input {
  border: none;
  padding: 0 2px;
  border-radius: 1px;
  font-weight: 700;
  background: transparent;
  height: 100%;
  color: #222;
  pointer-events: none;
}
 
/* pointer-events: all when it's focused so that we can type in it */
.input:focus {
  border: none;
  outline: none;
  background: rgba(255, 255, 255, 0.25);
  pointer-events: all;
}
export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

3. 动态宽度和自动聚焦

快完成了!我们希望根据文本的长度为节点设置动态宽度。为了简单起见,我们根据文本的长度进行计算。

在 src/app/MindMapNode.tsx 中添加的效果

useLayoutEffect(() => {
  if (inputRef.current) {
    inputRef.current.style.width = `${data.label.length * 8}px`;
  }
}, [data.label.length]);

我们还希望在节点创建后立即聚焦/激活它。

在 src/app/MindMapNode.tsx 中添加的效果

useEffect(() => {
  setTimeout(() => {
    if (inputRef.current) {
      inputRef.current.focus({ preventScroll: true });
    }
  }, 1);
}, []);
export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

现在,当你调整节点标签时,节点的宽度会相应调整。你还可以创建一个新节点,它会立即获得焦点。

4. 居中的边和样式细节

你可能已经注意到边没有居中。我们一开始创建了一个自定义边,现在我们可以稍微调整一下,以便边从节点的中心开始,而不是从句柄的顶部开始(默认行为)。

src/App/MindMapEdge.tsx

import { BaseEdge, EdgeProps, getStraightPath } from '@xyflow/react';
 
function MindMapEdge(props: EdgeProps) {
  const { sourceX, sourceY, targetX, targetY } = props;
 
  const [edgePath] = getStraightPath({
    sourceX,
    sourceY: sourceY + 20,
    targetX,
    targetY,
  });
 
  return <BaseEdge path={edgePath} {...props} />;
}
 
export default MindMapEdge;

我们向 getStraightPath 辅助函数传递所有道具,但调整 sourceY,使其位于节点的中心。

此外,我们希望标题更微妙一些,并选择一个背景颜色。我们可以通过调整面板的颜色(我们添加了类名 "header")和主体元素的背景颜色来做到这一点。

body {
  margin: 0;
  background-color: #f8f8f8;
  height: 100%;
}
 
.header {
  color: #cdcdcd;
}

做得好! 你可以在这里找到最终代码。

export default function App() {
  const data: string = "world"

  return <h1>Hello {data}</h1>
}

只读

最后的想法

真是一段旅程!我们从一个空白面板开始,最终创建了一个功能完备的思维导图应用程序。如果你想继续前进,你可以尝试以下一些功能。

  • 通过点击面板添加新节点。
  • 保存和恢复按钮,将当前状态存储到本地存储。
  • 导出和导入 UI。
  • 协作编辑。

希望你喜欢本教程,并学到了一些新东西!如果你有任何问题或反馈,请随时在 Twitter 上联系我,或者加入我们的 Discord 服务器。React Flow 是一家由用户资助的独立公司。如果你想支持我们,你可以 在 Github 上赞助我们订阅我们的 Pro 计划之一

使用 React Flow Pro 获取 Pro 示例、优先错误报告、来自维护者的 1:1 支持等等。