简介
XFlow 是 AntV 旗下, 基于 X6 图编辑引擎、面向 React 技术栈用户的图编辑应用级解决方案, 旨在让复杂的图编辑应用开发简单高效。
类比antd体系, X6 是图编辑场景的 antd, 提供图编辑的各种原子能力。而 XFlow 是图编辑场景的 ProComponent, 通过 App 扩展系统/状态管理/命令模式来实现对 X6 的原子能力的组合封装, 最终实现应用级场景的开箱即用。
网址:xflow.antv.vision/zh-CN/docs/…
XFlow 提供了三种解决方案:
以下介绍以DAG 图编辑解决方案为例
使用方法
一、XFlow
在 XFlow 中, 一切都是React组件。XFlow工作台组件是 XFlow 的核心组件之一, 可以理解为是一个图编辑应用的工作空间, 它包含了画布组件、各种交互组件等。
<XFlow
className="dag-user-custom-clz"
hookConfig={graphHooksConfig}//hook
modelServiceConfig={modelServiceConfig}//全局状态钩子
commandConfig={cmdConfig}//命令钩子
onLoad={onLoad}//初始化完成回调
meta={meta}//元信息,XFlow支持在工作台初始化之前传入Meta元信息, 该元信息会被存储在全局的ModelService实例中, 在整个XFlow工作空间可用。
>
//DAG方案的Hook集合
<DagGraphExtension />
//……省略剩余组件
</XFlow>
onload:XFlow初始化完成后会回调onLoad方法,在onLoad中可以执行各种业务逻辑操作, 比如从服务端获取数据、执行布局算法、渲染画布内容、监听画布相关事件等。
const onLoad: IAppLoad = async (app) => {
//返回会返回XFlow实例,即app
const graph = await app.getGraphInstance()//获取画布实例
const graphConfig = await app.getGraphConfig()//获取画布配置项
/** 缩放画布 */
graph.zoom(-0.2)
/** Mock从服务端获取数据 */
const graphData = await MockApi.loadGraphData()
/**执行XFlow内置的命令:渲染画布数据 */
await app.executeCommand(XFlowGraphCommands.GRAPH_RENDER.id, {
graphData,
} as NsGraphCmd.GraphRender.IArgs)
/** 监听画布相关事件 */
graph.on('node:mousedown', ({ e, x, y, node, view }) => {})
};
命令钩子CommandConfig
在实际业务中, 可能有很多操作都需要与服务端做交互, 将数据保存在服务端, 比如往画布中添加一个节点、修改节点的信息、拖拽出一条连线等操作。XFlow提供了执行命令的钩子, 允许用户提前预设好service层的行为, 在触发某个具体命令时, 会自动调用钩子里的service逻辑。需要扩展时可以使用hook。
如何获取:
- React 组件内部使用: 通过 useXFlowApp 来获取 CommandService
- XFlow 组件的配置项中使用:通过函数的参数可以获得 CommandService
xflow.antv.vision/api/command…
//使用
const app = useXFlowApp()
app.executeCommand<NsGraphCmd.GraphMeta.IArgs>(XFlowGraphCommands.LOAD_META.id, {})
export const useGraphConfig = createGraphConfig(config => {
const event: IEvent<'node:click'> = {
eventName: 'node:click',
callback: (eventArgs, commandService) => {
commandService.executeCommand<NsGraphCmd.GraphMeta.IArgs>(XFlowGraphCommands.LOAD_META.id, {})
},
}
/** 这里绑定事件 */
config.setEvents([event])
})
全局状态钩子 ModelServiceConfig
在实际业务中, 可能有很多画布与交互组件联动的需求, 比如画布选中一个节点, 交互组件里展示该节点信息, 同时修改节点信息, 修改后的节点信息实时同步到画布节点中。XFlow内置了若干全局状态, 比如画布当前选中的节点/连线、 画布的缩放比例等, 这些全局状态可以在画布中使用、在配套的交互组件中使用, 方便实现画布与交互组件的联动效果。但是也可以扩展需要保存的全局状态, 以实现业务需要的效果。
import { MODELS } from '@antv/xflow'
// 使用models
const getModel = async () => {
/** value */
const graphScale = await MODELS.GRAPH_SCALE.useValue(modelService)
/** model */
const graphScaleModel = await MODELS.GRAPH_SCALE.getModel(modelService)
console.log(graphScale, graphScaleModel)
}
//生产modals
import type { IModelService } from '@antv/xflow'
import { XFlow, createModelServiceConfig } from '@antv/xflow'
export namespace NS_LOADING_STATE {
export const id = 'custom-loading'
export interface IState {
loading: boolean
}
export const getValue = async (contextService: IModelService) => {
const ctx = await contextService.awaitModel<NS_LOADING_STATE.IState>(NS_LOADING_STATE.id)
return ctx.getValidValue()
}
}
export const useModelServiceConfig = createModelServiceConfig(config => {
config.registerModel(registry => {
return registry.registerModel({
id: NS_LOADING_STATE.id,
getInitialValue: () => {
loading: true
},
})
})
})
export const Demo = () => {
const modelServiceConfig = useModelServiceConfig()
return <XFlow modelServiceConfig={modelServiceConfig}></XFlow>
}
hook
xflow.antv.vision/zh-CN/api/h…
在 XFlow 中扩展逻辑都是通过 Hook 来完成,XFlow 内部可以注册 Hook 逻辑来完成对 Graph 配置和 Command 的 扩展
graph配置项:x6.antv.vision/zh/docs/api…
graph event:x6.antv.vision/zh/docs/tut…
//GraphHook:配置 Graph 相关的配置项
type IHooks = {
/* x6 graph 配置项*/
graphOptions: HookHub<Graph.Options>
/* 绑定X6的事件 */
x6Events: HookHub<IEventCollection, IEventSubscription>
/* 自定义节点React组件 */
reactNodeRender: HookHub<Map<string, NsGraph.INodeRender>>
/* 自定义连线label的React组件 */
reactEdgeLabelRender: HookHub<Map<string, NsGraph.IEdgeRender>>
/* 在Graph 实例化后执行的逻辑 */
afterGraphInit: HookHub<IGeneralAppService>
/* 在Graph 销毁前执行的逻辑 */
beforeGraphDestroy: HookHub<IGeneralAppService>
}
//CommandHook:配置可以修改 Command 参数的逻辑
type IHooks = INodeHooks & IEdgeHooks & IGroupHooks & IGraphHooks & IModelHooks
/** 定义一个hook,注册的逻辑放在handler中 */
export interface IHook<Args = any, Result = any> {
/** hook id */
name: string
/** 注入的逻辑 */
handler: (
args: Args,
mainHandler?: IMainHandler<Args, Result>,
) => Promise<null | void | IMainHandler<Args, Result>>
/** 在某个hook后执行 */
after?: string
/** 在某个hook前执行 */
before?: string
}
//Graph配置扩展
export const useGraphHookConfig = createHookConfig<IProps>((config, proxy) => {
// 获取 Props
const props = proxy.getValue()
console.log('get main props', props)
config.setRegisterHook(hooks => {
const disposableList = [
// 注册增加 react Node Render
hooks.reactNodeRender.registerHook({
name: 'add react node',
handler: async renderMap => {
renderMap.set(DND_RENDER_ID, AlgoNode)
renderMap.set(GROUP_NODE_RENDER_ID, GroupNode)
},
}),
// 注册修改graphOptions配置的钩子
hooks.graphOptions.registerHook({
name: 'custom-x6-options',
after: 'dag-extension-x6-options',
handler: async options => {
options.grid = false
options.keyboard = {
enabled: true,
}
},
}),
// 注册增加 graph event
hooks.x6Events.registerHook({
name: 'add',
handler: async events => {
events.push({
eventName: 'node:moved',
callback: (e, cmds) => {
const { node } = e
cmds.executeCommand<NsNodeCmd.MoveNode.IArgs>(XFlowNodeCommands.MOVE_NODE.id, {
id: node.id,
position: node.getPosition(),
})
},
} as NsGraph.IEvent<'node:moved'>)
},
}),
]
const toDispose = new DisposableCollection()
toDispose.pushAll(disposableList)
return toDispose
})
})
//cmd扩展
export const useCmdConfig = createCmdConfig(config => {
/** 设置hook */
config.setRegisterHookFn(hooks => {
const list = [
hooks.addNode.registerHook({
name: 'addNodeHook',
handler: async args => {
args.createNodeService = MockApi.addNode
},
}),
hooks.addEdge.registerHook({
name: 'addEdgeHook',
handler: async args => {
args.createEdgeService = MockApi.addEdge
},
}),
]
const toDispose = new DisposableCollection()
toDispose.pushAll(list)
return toDispose
})
})
//MockApi.addNode
addNode: async (args: NsNodeCmd.AddNode.IArgs) => {
const { id, ports, groupChildren, type } = args.nodeConfig;
const portItems = [
{
id: `${id}-input-1`,
type: NsGraph.AnchorType.INPUT,
group: NsGraph.AnchorGroup.TOP,
tooltip: '输入桩'
},
{
id: `${id}-output-1`,
type: NsGraph.AnchorType.OUTPUT,
group: NsGraph.AnchorGroup.BOTTOM,
tooltip: '输出桩'
}
] as NsGraph.INodeAnchor[];
let realPorts = ports;
if (!realPorts) {
realPorts =
type === 4
? ([
{
id: `${id}-input-1`,
type: NsGraph.AnchorType.INPUT,
group: NsGraph.AnchorGroup.TOP,
tooltip: '输入桩'
}
] as NsGraph.INodeAnchor[])
: portItems;
}
const nodeId = id || uuidv4();
/** 这里添加连线桩 */
const node: NsNodeCmd.AddNode.IArgs['nodeConfig'] = {
...NODE_COMMON_PROPS,
...args.nodeConfig,
id: nodeId,
ports: realPorts
};
/** group没有链接桩 */
if (groupChildren && groupChildren.length > 0) {
node.ports = [];
}
return node;
},
二、XFlowCanvas 画布组件
//画布
<XFlowCanvas position={{ top: 40, left: 230, right: 290, bottom: 0 }}>
//缩放工具栏
<CanvasScaleToolbar position={{ top: 12, right: 12 }} />
//右键菜单
<CanvasContextMenu config={menuConfig}
//对齐线
<CanvasSnapline color="#faad14" />
<CanvasNodePortTooltip />
</XFlowCanvas>
ScaleToolbar 画布缩放工具栏
在工具栏上提供放大画布,缩小画布,1:1比例 , 缩放到画布大小 这 4 个常用的画布缩放操作。
ContextMenu 右键菜单
负责在用户触发右键事件时,渲染一个菜单作为操作入口用于执行命令/操作 UI 组件/打开链接
Snapline 辅助对齐线
在画布上添加对齐线的交互
三、NodeDndPanel 节点拖拽面板
提供通过拖拽来快速新建节点的能力
主要配置项:
- nodeDataService:配置菜单节点类型;
- searchService:提供面板内节点的搜索功能,可以在其中对菜单list进行过滤,返回需要的节点;
- onNodeDrop:拖拽节点到画布后的回调;
- 画布节点和拖拽面板的节点默认使用renderkey定义的类型,但如果画布和面板的节点渲染不一致,可以使用renderComponent字段来自定义;
//节点拖拽面板,通过拖拽来快速新建节点
<NodeCollapsePanel
className="xflow-node-panel"
searchService={dndPanelConfig.searchService}//搜索功能
nodeDataService={dndPanelConfig.nodeDataService}
onNodeDrop={dndPanelConfig.onNodeDrop}//拖拽
position={{ width: 230, top: 0, bottom: 0, left: 0 }}
footerPosition={{ height: 0 }}
bodyPosition={{ top: 40, bottom: 0, left: 0 }}
/>
//菜单配置项
export const nodeDataService: NsNodeCollapsePanel.INodeDataService = async (meta: any) => {
const { disableList = [], isEdit } = meta?.meta;
return [
{
id: '数据来源',
header: '数据来源',
children: [
{
id: '8',
label: 'HIVE', // 菜单左侧展示节点名称
renderComponent: props => (
<div className="react-dnd-node react-custom-node-1"> {props.data.label} </div>
),//画布渲染的节点样式
renderKey: DND_RENDER_ID,//渲染的节点类型
popoverContent: <NodeDescription name='HIVE' />,//hover时展示的描述信息
},
{
id: '数据处理',
header: '数据处理',
children: [
{
}
]
}]
//菜单搜索功能
export const searchService: NsNodeCollapsePanel.ISearchService = async (
nodes: NsNodeCollapsePanel.IPanelNode[] = [],
keyword: string
) => {
const list = nodes.filter((node) => node.label?.includes(keyword));
return list;
};
//拖拽生成节点
export const onNodeDrop: NsNodeCollapsePanel.IOnNodeDrop = async (nodeConfig, commandService) => {
commandUtils.addNode(commandService, nodeConfig)
}
四、CanvasToolbar 画布工具栏
工具栏组件负责渲染按钮提供操作入口,在工具栏中可以执行命令/操作 UI 组件/打开链接的方式实现各种产品功能。
- 使用的图标可以用icon 和 iconName来设置,iconName图标名称需事先在 IconStore 注册;若同时设置 icon(ReactElement) 和 iconName, icon 优先级更高。
- 单击时触发onClick回调,返回commandService, modelService等方便的执行 xflow 的命令;
- 可以使用render来自定义渲染,render会返回包含onClick和children等的props,便于扩展;
//工具栏
<CanvasToolbar
className="xflow-workspace-toolbar-top"
layout="horizontal"
config={toolbarConfig}
position={{ top: 0, left: 230, right: 290, bottom: 0 }}
/>
const toolbarConfig = createToolbarConfig((toolbarConfig) => {
/** 生产 toolbar item */
toolbarConfig.setToolbarModelService(async (toolbarModel, modelService, toDispose) => {
updateToolbarModel = async () => {
const state = await getToolbarState(modelService);
const toolbarItems = await getToolbarItems(state, dispatch);
toolbarModel.setValue((toolbar) => {
toolbar.mainGroups = toolbarItems;
});
};
const models = await getDependencies(modelService);
const subscriptions = models.map((model) => {
return model.watch(async () => {
updateToolbarModel();
});
});
toDispose.pushAll(subscriptions);
});
})();
const cache = React.useMemo<{ app: IApplication | null }>(
() => ({
app: null
}),
[]
);
/** toolbar依赖的状态,在getToolbarItems中的state */
export const getToolbarState = async (modelService: IModelService) => {
const { isEnable: isMultiSelctionActive } = await MODELS.GRAPH_ENABLE_MULTI_SELECT.useValue(
modelService
);
const isGroupSelected = await MODELS.IS_GROUP_SELECTED.useValue(modelService);
const isNormalNodesSelected = await MODELS.IS_NORMAL_NODES_SELECTED.useValue(modelService);
const statusInfo = await NsGraphStatusCommand.MODEL.useValue(modelService);
const meta = await MODELS.GRAPH_META.useValue(modelService);
const { isEdit, unEditAble = true } = meta?.meta || {};
return {
isNodeSelected: isNormalNodesSelected,
isGroupSelected,
isMultiSelctionActive,
isEdit,
unEditAble: unEditAble || isEdit,
isProcessing: statusInfo.graphStatus === NsGraphStatusCommand.StatusEnum.PROCESSING
} as IToolbarState;
};
/** 注册icon 类型*/
IconStore.set('SaveOutlined', SaveOutlined);
IconStore.set('CloudSyncOutlined', CloudSyncOutlined);
IconStore.set('GatewayOutlined', GatewayOutlined);
export const getToolbarItems = async (state: IToolbarState, dispatch: any) => {
/** 获取toobar配置项,不同的分组用|分隔 */
const toolbarGroup1: IToolbarItemOptions[] = [];
const toolbarGroup2: IToolbarItemOptions[] = [];
const toolbarGroup3: IToolbarItemOptions[] = [];
/** 保存数据 */
toolbarGroup1.push(
{
id: XFlowGraphCommands.SAVE_GRAPH_DATA.id,
iconName: 'SaveOutlined',
tooltip: '保存数据',
isVisible: true,
isEnabled: state.isEdit,
onClick: async (props) => {
const { commandService, modelService } = props;
commandService.executeCommand<NsGraphStatusCommand.IArgs>(
XFlowDagCommands.QUERY_GRAPH_STATUS.id,
{
graphStatusService: async (args: any) => {
const graphMeta = await MODELS.GRAPH_META.useValue(modelService);
const newMeta = { ...graphMeta?.meta };
dispatch({
type: 'SET_META',
payload: {
meta: { ...newMeta, mapLoading: true }
}
});
const res = await postSaveExp(false, newMeta);
if (res.code === 0) {
message.success('保存成功');
}
return MockApi.switchEdit(args, dispatch);
},
loopInterval: 5000
}
);
},
render: (props) => {
return <SaveApp {...props} />;
}
},
{
id: `${XFlowGraphCommands.SAVE_GRAPH_DATA.id}edit`,
iconName: 'FormOutlined',
tooltip: '编辑',
isVisible: true,
isEnabled: !state.unEditAble,
onClick: async ({ commandService }) => {
commandService.executeCommand<NsGraphStatusCommand.IArgs>(
XFlowDagCommands.QUERY_GRAPH_STATUS.id,
{
graphStatusService: (args) => MockApi.switchEdit(args, dispatch),
loopInterval: 5000
}
);
}
},
{
iconName: 'CaretRightOutlined',
tooltip: '开始运行',
isEnabled: !state.isEdit,
id: `${XFlowGraphCommands.SAVE_GRAPH_DATA.id}play`,
onClick: async ({ commandService, modelService }) => {
},
render: (props) => {
return <RunExpApp {...props} />;
}
},
{
iconName: 'InsertRowAboveOutlined',
tooltip: '运行记录',
isEnabled: true,
id: `${XFlowGraphCommands.SAVE_GRAPH_DATA.id}history`,
onClick: async ({ commandService, modelService }) => {
}
}
);
return [
{ name: 'graphData', items: toolbarGroup1 },
{ name: 'groupOperations', items: toolbarGroup2 },
{
name: 'customCmd',
items: toolbarGroup3
}
];
};
//render
const RunExpApp = (props: any) => {
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
return (
<Popconfirm
title='确定执开始执行?'
okButtonProps={{ loading }}
visible={visible}
onConfirm={async () => {
// setLoading(true);
await props.onClick();
// setLoading(false);
setVisible(false);
}}
onCancel={() => {
setVisible(false);
}}
>
<span
onClick={() => {
setVisible(true);
}}
style={{ display: 'flex', flexDirection: 'row' }}
>
{props.children}
</span>
</Popconfirm>
);
};
五、JsonForm 配置式表单
通过配置一个 JSONSchema 协议渲染一个可以交互表单,用于根据画布选中状态的不同,动态的渲染不同的表单, 表单的初始值通过 JSONSchema 传入,在表单值变化时触发保存的回调。
xflow.antv.vision/zh-CN/docs/…
流程:
- 点击节点后,触发formSchemaService,使用 formSchemaService 函数返回的数据作为form的schema,根据其中的某些属性(如节点画布等的区分targetType,或是自定义属性)来决定渲染的菜单类型;
- 在使用XFlow自带的表单时,用户修改表单项后会触发 formValueUpdateService 的回调,在回调中可以保存数据;而使用自定义的菜单getCustomRenderComponent时,数据的保存和节点信息的更新需要自行设置(使用cmd.executeCommand的UPDATE_NODE和LOAD_META);
注:触发菜单更新的默认类型是canvas(画布)和node,可以通过配置targetType属性增加如group(群组)和edge等类型。
//配置式表单
<JsonSchemaForm
controlMapService={controlMapService}//自定义form控件
targetType={['canvas', 'node', 'group']}//触发更新的类型
formSchemaService={formSchemaService}//根据选中的节点更新formSchema
formValueUpdateService={formValueUpdateService}//保存form的values
getCustomRenderComponent={getCustomRenderComponent}//自定义菜单
bodyPosition={{ top: 0, bottom: 0, right: 0 }}
position={{ width: 290, top: 0, bottom: 0, right: 0 }}
footerPosition={{ height: 0 }}
/>
/** 根据选中的节点更新formSchema */
export const formSchemaService: NsJsonSchemaForm.IFormSchemaService = async args => {
const { targetData } = args
console.log(`formSchemaService args:`, args)
if (!targetData) {
return {
tabs: [
{
/** Tab的title */
name: '画布配置',
groups: [],
},
],
}
}
return {
/** 配置一个Tab */
tabs: [
{
/** Tab的title */
name: '节点配置',
groups: [
{
name: 'group1',
controls: [
{
name: 'label',
label: '节点Label',
shape: ControlShape.INPUT,
value: targetData.label,
},
{
name: 'x',
label: 'x',
shape: ControlShape.FLOAT,
value: targetData.x,
},
{
name: 'y',
label: 'y',
shape: ControlShape.FLOAT,
value: targetData.y,
},
],
},
],
},
],
}
}
}
/** 保存form的values */
export const formValueUpdateService: NsJsonSchemaForm.IFormValueUpdateService = async args => {
const { values, commandService, targetData } = args
const updateNode = (node: NsGraph.INodeConfig) => {
return commandService.executeCommand<NsNodeCmd.UpdateNode.IArgs>(
XFlowNodeCommands.UPDATE_NODE.id,
{ nodeConfig: node },
)
}
console.log('formValueUpdateService values:', values, args)
const nodeConfig: NsGraph.INodeConfig = {
...targetData,
}
values.forEach(val => {
set(nodeConfig, val.name, val.value)
})
updateNode(nodeConfig)
}
// 自定义菜单
const getCustomRenderComponent: NsJsonSchemaForm.ICustomRender = (
targetType: any,
targetData: any,
modelService: any,
cmd: any
) => {
const { type, datasourceType, id, storageType } = targetData || {};
if (targetType === 'node') {
return () => (
<div className="custom-form-component"> node: {targetData?.label} custom componnet </div>
)
}
if (targetType === 'canvas') {
return () => <div className="custom-form-component"> canvas custom componnet </div>
}
return null
};
Q&A
图实例
Q:xflow的功能不够/命令不好使(拿不到图里的所有节点,比如用命令增加的节点,或者删除之类的不好使) 可以用app.getGraphInstance()获取图实例,在上面可以用X6的api
A:app是当前xflow工作空间(类型:xflow.antv.vision/api/interfa…):
const cache = React.useMemo<{ app: IApplication | null }>(
() => ({
app: null
}),
[]
);
const onLoad: IAppLoad = async (app) => {
cache.app = app;
initGraphCmds(cache.app, newMeta);
};
<XFlow
className={`dag-user-custom-clz${editStr}`}
hookConfig={graphHooksConfig}
modelServiceConfig={modelServiceConfig}
commandConfig={cmdConfig}
onLoad={onLoad}
meta={newMeta}
>
群组
Q:初始化里塞进群组节点数据布局函数会将其当作新的节点来布局,不能将子节点包裹
A:在所有节点加载并布局完后再增加群组节点
Q:需要在流程图内的群组下方增加群组节点,将其子节点包裹。但群组节点不能作为流程图中的节点与其他节点相连,因为其他节点是群组的子节点,需要被包裹在群组节点内部,会造成连线混乱(和子节点移动时群组节点的外形混乱)
A:采用群组和群组节点一起移动的方式(获取位置不要直接取绑定事件内部的x,y,而是使用)
- 子节点移动会触发群组节点的宽高变化--->移动群组
- 群组节点自身移动--->异动群组
- 群组移动--->
改变群组节点外形( 群组要越过内部节点的时候不得不移动群组节点和内部节点)移动群组节点和其内部节点 - 移动内部节点可以通过 getPosition({ relative: true }) 获取内部节点相对于父节点的位置,而不是直接获取其位置