最终展示效果
github
1、ER图选型
- React-Flow:reactflow-cn.js.org/
- jsPlumb:jsplumbtoolkit.com/
- GoJS:gojs.net/latest/inde…
- React-Digraph:github.com/uber/react-…
- antv/x6:x6.antv.antgroup.com/
- @projectstorm/react-diagrams:github.com/projectstor…
流程图的插件有很多,看了很多插件,还是觉得antv/x6的更容易上手,并且对于小白,做一些定制化功能比较简单。 @projectstorm/react-diagrams这个插件也比较不错,针对这次需求,它天生自带输入输出节点,功能比较简单,如果需要定制化功能的话,工作量比较多。 React-Flow也是比较简单的流程图插件,同样太简单,不便于很多定制开发。 GoJS跟jsPlumb功能强大,但是上手难度比较高,学习起来比较困难,高级功能收费,当然,一般的功能社区版就足够了。由于1024的时间问题,就不去花时间研究了。
2、react-ace实现代码编辑器,并同步高亮词汇
CodeEditor
import React, { useEffect, useRef, useState } from 'react';
import { Select, Space, Button, message } from 'antd';
import AceEditor, { IAceEditorProps } from 'react-ace';
import * as sqlFormatter from 'sql-formatter';
import { useSqlStore } from '@/models/sql';
import { parse } from '@/services/common';
import { Range } from 'ace-builds';
import 'ace-builds/webpack-resolver';
import 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/theme-chrome';
import 'ace-builds/src-noconflict/theme-tomorrow';
import 'ace-builds/src-noconflict/mode-mysql';
import './index.less';
interface ICodeEditor extends IAceEditorProps {
disabled?: boolean;
}
export default React.forwardRef((props: ICodeEditor) => {
const { mode, height, width, name, theme, placeholder, value, onChange, disabled = false, ...rest } = props;
const editorRef = useRef<AceEditor>(null);
const highlightWords = useSqlStore((state) => state.highlightWords);
const highlightTableWords = useSqlStore((state) => state.highlightTableWords);
const highlightFeildWords = useSqlStore((state) => state.highlightFeildWords);
const mockData = useSqlStore((state) => state.mockData);
const loading = useSqlStore((state) => state.loading);
const curMockData = useSqlStore((state) => state.curMockData);
const [sqlValue, setSqlValue] = useState(curMockData.data.sql);
const handleClick = async () => {
if (mockData.find((item) => item.data.sql === sqlValue)) {
useSqlStore.setState({
loading: true,
});
const obj = mockData.find((item) => item.data.sql === sqlValue);
setTimeout(() => {
useSqlStore.setState({
curMockData: obj,
sqlInfo: {
desc: '',
graph: undefined,
lineage: undefined,
sql: '',
},
});
obj?.data?.sql && setSqlValue(obj.data.sql);
useSqlStore.setState({
loading: false,
});
}, 2000);
} else {
useSqlStore.setState({
loading: true,
});
const res = await parse({
originalSql: sqlValue,
}).finally(() =>
useSqlStore.setState({
loading: false,
}),
);
if (res.code === '0') {
console.log(res.data);
useSqlStore.setState({
sqlInfo: res.data,
});
} else {
message.error('服务端异常,请稍后再试');
}
}
};
const handleChange = (v: any) => {
setSqlValue(v);
};
const handleHighlightWords = (words: string[], tableWords: string[], feildWords: string[]) => {
const editor = editorRef.current?.editor;
if (!editor) return;
const session = editor.getSession();
// 清除现有的 markers
const markers = session.getMarkers();
Object.keys(markers).forEach((key: any) => {
if (['highlight-marker', 'highlight-table', 'highlight-feild'].includes(markers[key].clazz)) {
session.removeMarker(markers[key].id);
}
});
// 为每个单词添加新的 marker
feildWords.forEach((word) => {
const content = session.getValue();
let match;
const regex = new RegExp(word, 'g');
while ((match = regex.exec(content)) !== null) {
const startPosition = session.doc.indexToPosition(match.index, 0);
const endPosition = session.doc.indexToPosition(match.index + word.length, 0);
const range = new Range(startPosition.row, startPosition.column, endPosition.row, endPosition.column);
session.addMarker(range, 'highlight-feild', 'text');
}
});
// 为每个单词添加新的 marker
tableWords.forEach((word) => {
const content = session.getValue();
let match;
const regex = new RegExp(word, 'g');
while ((match = regex.exec(content)) !== null) {
const startPosition = session.doc.indexToPosition(match.index, 0);
const endPosition = session.doc.indexToPosition(match.index + word.length, 0);
const range = new Range(startPosition.row, startPosition.column, endPosition.row, endPosition.column);
session.addMarker(range, 'highlight-table', 'text');
}
});
// 为每个单词添加新的 marker
words.forEach((word) => {
const content = session.getValue();
let match;
const regex = new RegExp(word, 'g');
while ((match = regex.exec(content)) !== null) {
const startPosition = session.doc.indexToPosition(match.index, 0);
const endPosition = session.doc.indexToPosition(match.index + word.length, 0);
const range = new Range(startPosition.row, startPosition.column, endPosition.row, endPosition.column);
session.addMarker(range, 'highlight-marker', 'text');
}
});
};
const handleSelectChange = (v: string) => {
const obj = mockData.find((item) => item.name === v);
if (obj) {
setSqlValue(obj?.data.sql ?? '');
useSqlStore.setState({
curMockData: {
name: obj?.name,
data: {
desc: '',
graph: undefined,
lineage: undefined,
sql: '',
},
},
});
}
};
useEffect(() => {
handleHighlightWords(highlightWords, highlightTableWords, highlightFeildWords);
}, [highlightWords, highlightTableWords, highlightFeildWords]);
return (
<div className="code-editor">
<div className="code-editor-tool">
<Space>
<Select
style={{
width: 200,
}}
options={mockData.map((item) => ({
label: item.name,
value: item.name,
}))}
value={curMockData.name}
onChange={handleSelectChange}
/>
<Button loading={loading} onClick={handleClick}>
执行
</Button>
</Space>
</div>
<AceEditor
ref={editorRef}
width="100%"
mode="mysql"
theme="tomorrow"
placeholder=""
onChange={handleChange}
name="ace-editor"
value={sqlValue}
editorProps={{ $blockScrolling: true }}
fontSize={14}
showGutter // 显示行号
highlightActiveLine
showPrintMargin={false}
setOptions={{
enableBasicAutocompletion: true, // 启用基本自动完成功能
enableLiveAutocompletion: true, // 启用实时自动完成功能 (比如:智能代码提示)
enableSnippets: true, // 启用代码段
showLineNumbers: true,
showGutter: true,
tabSize: 2,
useWorker: false,
}}
readOnly={disabled}
debounceChangePeriod={500} // 防抖时间
{...rest}
/>
</div>
);
});
<!---->
.code-editor {
height: 100%;
display: flex;
flex-direction: column;
.code-editor-tool {
height: 46px;
display: flex;
background-color: #7f56d9;
align-items: center;
padding: 0 20px 0 50px;
}
.ace_editor {
flex: 1;
height: 0;
.highlight-marker {
position: absolute;
background-color: rgb(255, 132, 152);
z-index: 20;
}
.highlight-table {
position: absolute;
background-color: rgb(194, 215, 255);
z-index: 20;
}
.highlight-feild {
position: absolute;
background-color: rgb(191, 255, 189);
z-index: 20;
}
}
}
注意点
如果需要高亮多组词汇,需要单独设置不同的样式。 然后要么对词汇进行重新分割排列优先级,确保多组词汇中并无重复。 如果不想做这份工作,那么注意高亮词组的设置顺序,后设置的会覆盖前面的词组。
坑
起初使用vite构建,但是打包构建的时候,ace-builds这个插件始终构建不了,换了umi之后没问题
3、使用antv/x6实现数据血缘关系图
SQL Flow
// @ts-nocheck
import { Graph, Cell } from '@antv/x6';
import { DagreLayout } from '@antv/layout';
import { useCallback, useEffect, useRef } from 'react';
import { initGraph } from './logic';
import { useSqlStore } from '@/models/sql';
import { debounce, throttle } from 'lodash';
export const LINE_HEIGHT = 24;
export const NODE_WIDTH = 220;
export const COLOR_MAP = {
fontColor: '#ffffff',
hoverBg: '#e5c2ff',
activeBg: '#1febf6',
defaultBg: '#ebfdec',
defaultEdge: '#A2B1C3',
sourceTable: {
primary: '#fa541c',
bg: '#fff2e8',
},
targetTable: {
primary: '#a0d911',
bg: '#fcffe6',
},
selectColumns: {
primary: '#1677ff',
bg: '#e6f4ff',
},
viewTable: {
primary: '#eb2f96',
bg: '#fff0f6',
},
};
// 初始化画布
initGraph();
function SqlFlowView2() {
const graphRef = useRef<Graph>();
const sqlInfo = useSqlStore((state) => state.sqlInfo);
const curMockData = useSqlStore((state) => state.curMockData);
const init = () => {
graphRef.current = new Graph({
container: document.getElementById('container')!,
autoResize: true,
panning: true,
mousewheel: true,
// background: {
// color: '#eee',
// },
// grid: {
// visible: true,
// type: 'doubleMesh',
// args: [
// {
// color: '#eee', // 主网格线颜色
// thickness: 1, // 主网格线宽度
// },
// {
// color: '#ddd', // 次网格线颜色
// thickness: 1, // 次网格线宽度
// factor: 4, // 主次网格线间隔
// },
// ],
// },
interacting: {
nodeMovable: true, // 节点是否可以被移动
vertexAddable: false, // 边的路径点是否可以被删除
vertexDeletable: false, // 是否可以添加边的路径点
vertexMovable: false, // 边的路径点是否可以被移动
arrowheadMovable: false, // 边的起始/终止箭头(在使用 arrowhead 工具后)是否可以被移动
edgeLabelMovable: false, // 边的标签是否可以被移动
edgeMovable: false, // 边是否可以被移动
magnetConnectable: false, // 当在具有 magnet 属性的元素上按下鼠标开始拖动时,是否触发连线交互。
},
connecting: {
router: {
name: 'er',
args: {
offset: 25,
direction: 'H',
},
},
},
});
};
// 处理数据绘制图表
const generate = (data: any) => {
const cells: Cell[] = [];
let num = 1;
const res1 = data.source.map((item, index) => {
if (num < item?.columns?.length) {
num = item?.columns?.length;
}
return {
id: item.name,
shape: item.type,
label: item.name,
width: NODE_WIDTH,
height: LINE_HEIGHT,
ports: item?.columns?.map((_item) => ({
id: item.name + '&' + _item,
group: 'list',
attrs: {
portNameLabel: {
text: _item,
},
},
})),
};
});
const res2 = data.statements.map((item, index) => {
if (item?.mappings?.length > 0) {
return [
...item?.mappings.map((_item, _index) => {
return {
id: index + '-' + _index,
shape: 'edge',
source: {
cell: item.source,
port: item.source + '&' + _item.sourceColumn,
},
target: {
cell: item.target,
port: item.target + '&' + _item.targetColumn,
},
connector: { name: 'rounded' },
attrs: {
line: {
stroke: COLOR_MAP.defaultEdge,
strokeWidth: 1,
// sourceMarker: {
// name: 'circle',
// size: 2,
// },
targetMarker: {
name: 'classic',
size: 6,
},
},
},
zIndex: 0,
};
}),
];
} else {
return [
{
id: index,
shape: 'edge',
source: {
cell: item.source,
},
target: {
cell: item.target,
},
connector: { name: 'rounded' },
attrs: {
line: {
stroke: COLOR_MAP.defaultEdge,
strokeWidth: 1,
// sourceMarker: {
// name: 'circle',
// size: 2,
// },
targetMarker: {
name: 'classic',
size: 6,
},
},
},
zIndex: 0,
},
];
}
});
[...res1, ...res2.flat(Infinity)].forEach((item: any) => {
if (item.shape === 'edge') {
cells.push(graphRef.current?.createEdge(item));
} else {
cells.push(graphRef.current?.createNode(item));
}
});
console.log('renderCells', cells);
// 渲染图形
graphRef.current?.clearCells();
graphRef.current?.resetCells(cells);
graphRef.current?.centerPoint();
applyDagreLayout(graphRef.current, num);
};
// 自动排布
const applyDagreLayout = (graph: Graph, num: number) => {
const nodes = graph.getNodes();
const edges = graph.getEdges();
const dagreLayout = new DagreLayout({
type: 'dagre',
rankdir: 'LR',
align: 'UL',
ranksep: 80,
nodesep: num * 8,
controlPoints: true,
});
const model = {
nodes: nodes.map((node) => ({
id: node.id,
width: node.size().width,
height: node.size().height,
})),
edges: edges.map((edge) => ({
source: edge.getSourceCellId(),
target: edge.getTargetCellId(),
})),
};
const newPositions = dagreLayout.layout(model);
newPositions.nodes.forEach((node) => {
const cell = graph.getCellById(node.id);
if (cell.isNode()) {
// @ts-ignore
cell.setPosition(node.x, node.y);
}
});
graph.centerPoint();
};
// 往前找关联节点
const highlightRelatedCellPrev = (cell, port, arr) => {
cell.setPortProp(port, 'attrs/rect', {
fill: COLOR_MAP.hoverBg,
});
const connectedEdges = graphRef.current?.getConnectedEdges(cell, { incoming: true }).filter((edge) => edge.getSourcePortId() === port || edge.getTargetPortId() === port);
const edge = connectedEdges[0];
if (!edge) return;
// 高亮边
edge.attr('line/stroke', COLOR_MAP.hoverBg);
edge.attr('line/strokeWidth', 4);
// 高亮边的源端口和目标端口
const sourceCell = edge.getSourceCell();
const sourcePort = edge.getSourcePortId();
if (sourceCell && sourcePort && sourceCell.isNode()) {
highlightRelatedCellPrev(sourceCell, sourcePort, arr);
}
let str1 = sourcePort;
const arr2 = str1.split('&');
arr.push([
{
type: 'table',
value: arr2[0],
},
{
type: 'feild',
value: arr2[1],
},
]);
};
// 往后找关联节点
const highlightRelatedCellNext = (cell, port, arr) => {
cell.setPortProp(port, 'attrs/rect', {
fill: COLOR_MAP.hoverBg,
});
const connectedEdges = graphRef.current?.getConnectedEdges(cell, { outgoing: true }).filter((edge) => edge.getSourcePortId() === port || edge.getTargetPortId() === port);
const edge = connectedEdges[0];
if (!edge) return;
// 高亮边
edge.attr('line/stroke', COLOR_MAP.hoverBg);
edge.attr('line/strokeWidth', 4);
// 高亮边的源端口和目标端口
const targetCell = edge.getTargetCell();
const targetPort = edge.getTargetPortId();
if (targetCell && targetPort && targetCell.isNode()) {
highlightRelatedCellNext(targetCell, targetPort, arr);
}
let str2 = targetPort;
const arr2 = str2.split('&');
arr.push([
{
type: 'table',
value: arr2[0],
},
{
type: 'feild',
value: arr2[1],
},
]);
};
const highlightRef = useRef('');
const handlePortMouseEnter = ({ node, view, cell, port }) => {
if (highlightRef.current === port) {
console.log('=======================>', highlightRef.current);
return;
}
highlightRef.current = port;
handleGraphMouseEnter();
console.log('+++++++++++++++++++++++>', highlightRef.current);
let str3 = port;
const arr2 = str3?.split('&');
let arr = [{ type: 'table', value: arr2[0] }];
highlightRelatedCellPrev(cell, port, arr);
highlightRelatedCellNext(cell, port, arr);
useSqlStore.setState({
highlightWords: [arr2[1]],
highlightTableWords: arr
.flat(Infinity)
.filter((item) => item.type === 'table')
.map((item) => item.value),
highlightFeildWords: arr
.flat(Infinity)
.filter((item) => item.type === 'feild')
.map((item) => item.value),
});
};
const handleGraphMouseEnter = () => {
console.log('handleGraphMouseEnter');
useSqlStore.setState({
highlightWords: [],
highlightTableWords: [],
highlightFeildWords: [],
});
const cells = graphRef.current?.getCells();
const edges = graphRef.current?.getEdges();
cells?.forEach((c) => {
if (c.isNode()) {
const ports = c.getPorts();
ports.forEach((p) => {
c.setPortProp(p.id, 'attrs/rect', {
fill: COLOR_MAP[c.shape].bg,
});
});
}
});
edges?.forEach((e) => {
e.attr('line/stroke', COLOR_MAP.defaultEdge);
e.attr('line/strokeWidth', 1);
});
};
const handleNodeMouseLeave = () => {
highlightRef.current = '';
handleGraphMouseEnter();
};
useEffect(() => {
if (graphRef.current) {
if (sqlInfo.graph && Object.keys(sqlInfo.graph).length > 0) {
console.log('render sqlInfo', sqlInfo);
generate(sqlInfo.graph);
} else {
if (curMockData?.data?.graph) {
console.log('render curMockData', curMockData);
generate(curMockData.data.graph);
}
}
graphRef.current?.on('node:port:mouseenter', handlePortMouseEnter);
graphRef.current?.on('graph:mouseenter', handleGraphMouseEnter);
graphRef.current?.on('node:mouseleave', handleNodeMouseLeave);
return () => {
graphRef.current?.off('node:port:mouseenter', handlePortMouseEnter);
graphRef.current?.off('graph:mouseenter', handleGraphMouseEnter);
graphRef.current?.off('node:mouseleave', handleNodeMouseLeave);
};
}
}, [sqlInfo, curMockData]);
useEffect(() => {
init();
}, []);
return (
<div
style={{
width: '100%',
height: '100%',
}}
>
{(sqlInfo.desc || curMockData?.data.desc) && (
<div
className="desc"
style={{
padding: 16,
fontSize: 16,
}}
>
{sqlInfo.desc || curMockData?.data.desc}
</div>
)}
<div id="container" />
</div>
);
}
export default SqlFlowView2;
注意点
- 在做鼠标
hover效果的时候,需要高亮关联的source、target的node以及edge,仅仅高亮左右邻近的节点还不够,需要高亮整条链路的节点跟线,这就要求要递归寻找出所有节点跟线。 在递归查找的时候,注意区分方向,建议往前跟往后的方法分开,并及时return,不然会出现死循环。 - 在做鼠标
hover效果的时候,注册了node:port:mouseenter事件,起初我的做法是在node:port:mouseenter事件中高亮相关节点,在node:port:mouseleave事件中取消掉所有高亮效果。但是发现node:port:mouseleave事件的触发有问题,鼠标移动过快的话不会触发leave事件,直接触发enter事件。 尝试过在许多事件中去处理这个事情,效果都不是很理想,所以决定在node:port:mouseenter事件触发的时候,取消掉页面中所有节点的高亮,再重新设置,这也是常规的做法。 这时候就遇到了一个问题,在进行取消页面中所有节点高亮的时候,会无限触发node:port:mouseenter事件,导致页面不断的重复渲染,进而卡顿。 解决办法:使用一个highlightRef.current存储当前hover的节点id,在重新触发的时候进行对比是否相同,如果相同的话就直接return。用了这个方法后解决了这个问题,并且也不是说一直触发,只是在这个节点return掉了。根据观察,应该第二或者第三次就不会触发了,因为没有继续重置所有节点的缘故。
难点
一般来说,这个ER图的节点大小都不太好确定,我们即使可以通过计算port的数量来算出大小,但是我们仍然很难对节点进行排布,不大好做到完美的位置排布,由后端设置好放数据里是最好的结果。 但是后端说,他们也不清楚这个坐标位置,只能提供关联关系。那只能通过前端来进行排布。 关于这个图形的排布算法,我找到了两种解决方案:
- dagre 功能强大的DAG布局库,兼容各种graph。 github.com/dagrejs/dag…
- @antv/layout antv提供的官方布局工具,跟x6/g6配合起来用比较丝滑。 github.com/antvis/layo…
因为只是简单的试用,并没有深入的了解,所以对dagre使用不熟悉,初步使用发现,它只能保证节点之间不重叠,排布上面不能做到数据血缘关系的前后顺序。
使用dagre的效果:
使用@antv/layout的效果:
ReactJSON
import ReactJson from 'react-json-view';
import { useSqlStore } from '@/models/sql';
function ReactJsonView() {
const curMockData = useSqlStore((state) => state.curMockData);
const sqlInfo = useSqlStore((state) => state.sqlInfo);
return <ReactJson src={sqlInfo.lineage || curMockData.data.lineage} collapsed={3} iconStyle="square" theme="monokai" />;
}
export default ReactJsonView;
### 技术栈
// package.json
{
"name": "react",
"private": true,
"version": "0.0.0",
"scripts": {
"start": "node server.js",
"dev": "umi dev",
"build": "umi build",
"postinstall": "umi setup",
"setup": "umi setup"
},
"dependencies": {
"@ant-design/pro-components": "^2.7.9",
"@antv/layout": "0.3.25",
"@antv/x6": "^2.18.1",
"@projectstorm/react-diagrams": "^7.0.4",
"@types/lodash": "^4.17.12",
"ace-builds": "^1.36.2",
"antd": "^5.18.0",
"axios": "^1.7.2",
"dayjs": "^1.11.11",
"file-loader": "^6.2.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"react": "^18.2.0",
"react-ace": "^12.0.0",
"react-dom": "^18.2.0",
"react-json-view": "^1.21.3",
"react-router-dom": "^6.23.1",
"sql-formatter": "^15.4.5",
"umi": "^4.3.27",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.14.0",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"cookie-parser": "^1.4.6",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"express": "^4.19.2",
"http-proxy-middleware": "^3.0.0",
"less": "^4.2.0",
"typescript": "^5.2.2"
}
}