XFlow|2022.9

619 阅读8分钟

简介

XFlow 是 AntV 旗下, 基于 X6 图编辑引擎、面向 React 技术栈用户的图编辑应用级解决方案, 旨在让复杂的图编辑应用开发简单高效。

类比antd体系, X6 是图编辑场景的 antd, 提供图编辑的各种原子能力。而 XFlow 是图编辑场景的 ProComponent, 通过 App 扩展系统/状态管理/命令模式来实现对 X6 的原子能力的组合封装, 最终实现应用级场景的开箱即用。

网址:xflow.antv.vision/zh-CN/docs/…

XFlow 提供了三种解决方案:

image.png 以下介绍以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 }) => {})
};

image.png

命令钩子CommandConfig

在实际业务中, 可能有很多操作都需要与服务端做交互, 将数据保存在服务端, 比如往画布中添加一个节点、修改节点的信息、拖拽出一条连线等操作。XFlow提供了执行命令的钩子, 允许用户提前预设好service层的行为, 在触发某个具体命令时, 会自动调用钩子里的service逻辑。需要扩展时可以使用hook。

如何获取:

  1. React 组件内部使用: 通过 useXFlowApp 来获取 CommandService
  2. 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内置了若干全局状态, 比如画布当前选中的节点/连线、 画布的缩放比例等, 这些全局状态可以在画布中使用、在配套的交互组件中使用, 方便实现画布与交互组件的联动效果。但是也可以扩展需要保存的全局状态, 以实现业务需要的效果。

xflow.antv.vision/api/models

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 个常用的画布缩放操作。

image.png

ContextMenu 右键菜单

负责在用户触发右键事件时,渲染一个菜单作为操作入口用于执行命令/操作 UI 组件/打开链接

image.png

Snapline 辅助对齐线

在画布上添加对齐线的交互

image.png

三、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,便于扩展;

image.png

	//工具栏
   <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/…

流程:

  1. 点击节点后,触发formSchemaService,使用 formSchemaService 函数返回的数据作为form的schema,根据其中的某些属性(如节点画布等的区分targetType,或是自定义属性)来决定渲染的菜单类型;
  2. 在使用XFlow自带的表单时,用户修改表单项后会触发 formValueUpdateService 的回调,在回调中可以保存数据;而使用自定义的菜单getCustomRenderComponent时,数据的保存和节点信息的更新需要自行设置(使用cmd.executeCommand的UPDATE_NODE和LOAD_META);

注:触发菜单更新的默认类型是canvas(画布)和node,可以通过配置targetType属性增加如group(群组)和edge等类型。

image.png

			//配置式表单
      <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 }) 获取内部节点相对于父节点的位置,而不是直接获取其位置