@antv/x6-angular-shape: 为x6的节点类型增添一个新的选择

3,523 阅读13分钟

前言

首先, 让我们简单了解下antv/x6是干嘛的. 专业点说, 叫图编辑器. 他可以用来画各种图.

图片.png

在本篇文章中, 并不会介绍"从0-1如何使用x6", 这没多大意义. 文档里有的东西, 再重复抄一遍真没什么技术含量, 除非我写的比官方网站好. 但是在这里, 我不得不夸下x6官网, 写的是真不错. 可以说, 跟着官网走, 基本上都可以无障碍自助开发.

那么我今天想要给大家分享的, 是关于@antv/x6-angular-shape背后的故事, 想聊聊它是如何被开发出来的.

遇见x6 遇见更美好的未来

2021年年中, 因为业务需要, 开始寻找一款能够实现下面效果的第三方插件.

图片.png

之后就找到了x6, 但是x6提供的样式和我们完全不一样, 我如何能够定制化呢? 直到我看到x6官网有这么个demo.

图片.png

wow~如果可以使用html作为节点的话, 那任何样式都是没问题的, 画就完事了. 就这样, 确定了x6作为我们的第三方插件, 并开始进入迭代开发.

别人有的, 我不允许你没有

在前文我们提到, 节点, 是可以使用html进行绘制的. x6官方非常贴心, 不仅提供了html的版本, 还提供其他的版本.

图片.png

HTMLReactVue, 诶, Angular呢? 于是我就在issue里搜了下.

图片.png

果然有人提过feat, 但很遗憾官方明确说了不会支持. 其实我只需要HTML的版本就够了, 我压根就不需要支持Angular. But! 这时我起了一位现代文学奠基人曾说过的话.

其他社区有的, Angular也必须有 —— 鲁迅

官方不支持, 那我就自己写. 就这么的, 我就决定了要自行研究一个包, 能够让x6将Angular的组件作为节点.

抄一下...哦不, 借鉴一下

首先, 一开始我肯定是不知道该咋实现的. 但是官方提供了react的包呀, 去看看他们是咋写的.

protected renderReactComponent() {
  this.unmountReactComponent()
  const root = this.getComponentContainer()
  const node = this.cell
  const graph = this.graph
  if (root) {
    const component = this.graph.hook.getReactComponent(node) // 获取react组件
    const elem = React.createElement(Wrap, { graph, node, component }) // 创建虚拟dom
    if (Portal.isActive()) {
      Portal.connect(this.cell.id, ReactDOM.createPortal(elem, root))
    } else {
      ReactDOM.render(elem, root) // 将创建好的虚拟dom挂在到root中, 也就是x6的画布里
    }
  }
}

以上是@antv/x6-react-shape的核心源码, 其实你并不需要把每一行都读懂, 看这命名和语法, 你大概就能猜到这个函数干了什么.

  1. 获取渲染必要的内容, 比如渲染需要的组件、容器、画布
  2. 将组件渲染到容器中去.

那么, 我们需要做的事情, 代码大概应该长下面这样

  protected renderAngularContent() {
    const root = this.getComponentContainer(); // 需要插入组件的位置
    if (root) {
      const node = this.cell;
      // getAngularContent是挂载在Graph.Hook的原型链上的方法, 通过他可以拿到用户传递过来的一些数据. 
      // 当然了, 这个方法需要我们自己去实现, 不难的 借鉴就完事了.
      // 通过getAngularContent拿到的不一定是组件, 这里是用户在Angular项目中传入的内容.
      // 先假定拿到的是个组件.
      const component = this.graph.hook.getAngularContent(node); // 获取到Angular component
      // 现在的条件有 root, 是个容器. component也拿到了, 如何把component插入到root中?
    }
  }

在Angular中, 想要在html中加上一个component, 通常是通过选择器的方式.

<app-child></app-child>

但是显然我们这里不能用这种方式, 那么如果你对Angular比较熟练, 你应该了解动态组件的概念.

<ng-container #componentRef></ng-container>
@ViewChild('componentRef', { read: ViewContainerRef }) componentRef: ViewContainerRef;

constructor(
    private componentFactoryResolve: ComponentFactoryResolver
) {}

// 创建待插入组件的组件工厂
const componentFactory = this.componentFactoryResolve.resolveComponentFactory(XXXComponent);
// 执行插入
const componentRef = this.componentRef.createComponent(componentFactory);
// 触发变更检测
componentRef.changeDetectorRef.detectChanges();

非常滴麻烦啊~. 麻烦也就算了, 关键是我特喵的是在X6的仓库里实现的. X6是纯TypeScript的项目, 就算有点框架的影子, 那也是React. 所以我是无法使用依赖注入的.

其实最难的地方也就在这里了, 在一个非Angular的环境里, 如何去动态插入一个Angular组件呢? 问的好! 我真不知道. 为此, 我通过stack overflow等多个渠道发帖询问, 也没人知道. 最后我梳理了下我已有的线索.

我们需要插入一个Angular组件, 需要哪些条件?

  1. 一个待插入的Component (有)
  2. 一个通过@ViewChild捕获到的ViewContainerRef实例化对象 (无)
  3. 一个ComponentFactoryResolver的实例化对象 (无) 其实针对第三点, 我可以让用户传入进来. 就像下面这样.
constructor(
    private componentFactoryResolve: ComponentFactoryResolver
) {}

addAngularComponent(): void {
  Graph.registerAngularContent(
    'demo-component',
    // 下面是个对象, 这里的内容都可以通过前文中的getAngularContent拿到
    {
      ComponentFactoryResolver: this.componentFactoryResolve,
      content: NodeComponent // 这个就是需要作为节点的组件
    }
  );
  const node = this.graph.addNode({
    x: 40,
    y: 40,
    width: 160,
    height: 30,
    shape: 'angular-shape',
    componentName: 'demo-component'
  });
}

但是这个方案有两个问题, 第一个问题是冗余. 每次使用的时候都需要通过依赖注入拿到ComponentFactoryResolver的实例. 第二个问题就是无法解决第二点. 也就是root自始至终它都是一个普普通通的HTMLElementRef, 而不是ViewContainerRef.

因此, 如果有一个方法能够将一个普通的DOM转化为ViewContainerRef就好了. 因此我就向Angular官方提出了这个issue.

图片.png

官方团队也非常效率, 很快和我讨论了一番.

图片.png

简单来说, 就是官方早就把这些功能集成了. 但是他给的案例确实还是和我不太一样, 因为他还是使用到了Template的语法. 而我前文多次强调过, 我是在X6的环境里的, 一个纯纯的TypeScript项目, Angular的特性我都是无法使用的. 因此我向ant design of Angualr - ng zorro求助. 这是在国内Angular组件库用户最多的一个产品. 他们给了我一个关键词 CDK DomPortalOutlet.

在查阅一番资料后, 我发现, 如果想使用这个东西,那么必须通过依赖注入的方式拿到以下对象的实例化.

  • ApplicationRef
  • ViewContainerRef
  • ComponentFactoryResolver 这下好了. 我前面才说过, 让用户传入一个ComponentFactoryResolver是很不优雅的事情, 现在还要用户传入3个? 这波啊, 这波是反向优化 Orz. 此时我又想起了伟人曾经说过的话.

谁说依赖注入只能通过构造函数了? 你把Injector放哪了? —— 鲁迅

想到这, 我激动万分, 仿佛是经过了长途跋涉, 只剩下最后一公里一般, 赶紧写下灵感爆发的代码.

constructor(
    private inject: Injector
) {}

addAngularComponent(): void {
  Graph.registerAngularContent(
    'demo-component',
    {
      Injector: this.inject, // 一生二 二生三 三生万
      content: NodeComponent
    }
  );
  // 略
}

那么我们回到X6实现这个功能的核心代码处.

  protected renderAngularContent() {
    const root = this.getComponentContainer(); // 需要插入组件的位置
    if (root) {
      const node = this.cell;
      // 此时我们知道, content就是个组件, 而injector是用户通过依赖注入拿到的注入器
      const { injector, content } = this.graph.hook.getAngularContent(node);
      // 通过 injector 拿到更多依赖注入的对象
      const applicationRef = injector.get(ApplicationRef);
      const viewContainerRef = injector.get(ViewContainerRef);
      const componentFactoryResolver = injector.get(ComponentFactoryResolver);
      // 柴米油盐都有了, 开始炒菜!
      const domOutlet = new DomPortalOutlet(root, componentFactoryResolver, applicationRef, injector);
      const portal = new ComponentPortal(content, viewContainerRef);
      const componentRef = domOutlet.attachComponentPortal(portal);
    }
  }

之后就大功告成! 就在我准备提PR的时候, 我仿佛听到了一声冷笑.

只支持Component? 连Template都没考虑到, 也好意思PR? 就这? —— 鲁迅

对啊, 我咋忘记了这茬了呢. 不过我都能把组件渲染出来了, 再改一下以支持Template还不是洒洒水?

首先用户端, 传入的content, 可以是通过ViewChild捕获到的模板实例, 那么x6如何判断用户传入的是组件还是Template呢?

  protected renderAngularContent() {
    const root = this.getComponentContainer(); // 需要插入组件的位置
    if (root) {
      const node = this.cell;
      // 此时我们知道, content就是个组件, 而injector是用户通过依赖注入拿到的注入器
      const { injector, content } = this.graph.hook.getAngularContent(node);
      // 通过 injector 拿到更多依赖注入的对象
      const applicationRef = injector.get(ApplicationRef);
      const viewContainerRef = injector.get(ViewContainerRef);
      const componentFactoryResolver = injector.get(ComponentFactoryResolver);
      // 不同类型走不同的方法
      if (content instanceof TemplateRef) {
        const portal = new TemplatePortal(content, viewContainerRef);
        domOutlet.attachTemplatePortal(portal);
      } else {
        const portal = new ComponentPortal(content, viewContainerRef);
        domOutlet.attachComponentPortal(portal);
      }
    }
  }

在这里, 如何判断是TemplateRef, 我是参考了ngZorro的一些组件源码的. 但是如何判断是Component, 这个好像真没办法, 反正我是没找到相关的写法. 还好这里是二选一, 直接else就行.

就这样, 我正式提交了PR. 很快, 官方也合并了并且发布了@antv/x6-angular-shape@1.0.0.

好用不好用, 我是不知道的, 因为前文说了, 我只需要HTML渲染就可以了, 我纯粹是看到社区里有小伙伴需要这样的package, 也是出于对Angular的热爱, 也是对于长期享受开源社区带来的便利, 因而作出对社区的一点回馈罢了.

需求一: 能够支持输入属性

很快, 有人提出了新的需求

图片.png

原来是想在Template中使用data属性. 这里给大家补充以下关于data的知识.

this.graph.addNode({
  // data属性和其他属性不一样, 其他属性都是有用的,  比如xy坐标. 而data则是原封不动的, 用于一些业务逻辑判断.
  data: {
    type: 'node'
  },
  x: 40,
  y: 40,
  width: 160,
  height: 30,
  shape: 'angular-shape',
  componentName: 'demo-component'
});

那么也就是说, 用户通过设置data属性, 可以让x6读到data里的数据, 但是一直是不做什么处理的. 那么这个issue里的需求就是想去读data属性. 那么我们怎么做呢?

protected renderAngularContent() {
 // 前面代码省略
 if (content instanceof TemplateRef) {
   const data = node.data; // 此时的data就是用户传入的data
   const portal = new TemplatePortal(content, viewContainerRef, { data });
   domOutlet.attachTemplatePortal(portal);
 }
}

之后用户就可以通过他预期的那样去在模板中使用data

<ng-template #template let-data="data">
    {{ data.type }}
</ng-template>

其实如果只是针对Template的场景, 这样写是没什么问题的. 但是! 有了之前的经验, 我知道肯定还会有用户希望支持组件的@Input属性. 那么组件的部分该怎么做呢?

protected renderAngularContent() {
 // 前面代码省略
 if (content instanceof TemplateRef) {
 } else {
    const portal = new ComponentPortal(content, viewContainerRef);
    const componentRef = domOutlet.attachComponentPortal(portal);
    // 比如组件有个输入属性是@input type: string; 我们可以读出data中的type赋值给组件的type
    const data = node.data;
    componentRef.instance.type = data.type;
 }
}

当然了, 这里的type属性是我们拟定的场景, 用户需要的是哪个字段的赋值是不定的. 那么我们是不是应该遍历data, 把每个data的属性都赋值给组件呢?

protected renderAngularContent() {
 // 前面代码省略
 if (content instanceof TemplateRef) {
 } else {
    const portal = new ComponentPortal(content, viewContainerRef);
    const componentRef = domOutlet.attachComponentPortal(portal);
    const data = node.data;
    // 遍历赋值
    Object.keys(data).forEach(v => (componentRef.instance[v] = data[v]));
 }
}

这肯定是可以完成我们的目标的. 但是我想这应该是非常不优雅的. 前文说过, data属性是用户自己对节点的一些标记, 并不代表着data的输入属性在组件中都是输入属性. 举个例子. data中有5个属性, 而Component@Input可能只是5个中的1个, 那么按照上面这段代码, 会把剩余4个属性也赋值给了组件, 这很不优雅!

所以我就只能对data中的属性进行分隔了. 因为是我开发的包, 所以API的规则还是我说了算. 我规定, 如果需要让Component或者Template读到data中的属性. 需要将属性放在data.ngArguments当中.

this.graph.addNode({
  data: {
    type: 'node', // 不会被赋值
    ngArguments: {
      title: 'Angular Component', // 会被赋值
    }
  },
  x: 40,
  y: 40,
  width: 160,
  height: 30,
  shape: 'angular-shape',
  componentName: 'demo-component'
});
protected renderAngularContent() {
 // 前面代码省略
 const ngArguments = (node.data?.ngArguments as { [key: string]: any }) || {};
 if (content instanceof TemplateRef) {
    const portal = new TemplatePortal(content, viewContainerRef, { ngArguments });
    domOutlet.attachTemplatePortal(portal);
 } else {
    const portal = new ComponentPortal(content, viewContainerRef);
    const componentRef = domOutlet.attachComponentPortal(portal);
    Object.keys(ngArguments).forEach(v => (componentRef.instance[v] = ngArguments[v]));
    // 在这个地方修改组件的值, 是不会引起变更检测的, 所以需要手动触发下.
    componentRef.changeDetectorRef.detectChanges();
 }
}

至此, 大功告成了. @antv/x6-angular-shape支持了输入属性的特性.

需求二: 支持组件值跟随data的值进行改变

很快, 又出现了一个新的需求

图片.png 他的demo代码是这样的

  addAngularComponent(): void {
    Graph.registerAngularContent('demo-component', { injector: this.injector, content: NodeComponent });
    let node = this.graph.addNode({
      data: {
        ngArguments: {
          title: 'Angular Component'
        }
      },
      x: 40,
      y: 40,
      width: 160,
      height: 30,
      shape: 'angular-shape',
      componentName: 'demo-component'
    });

    setTimeout(() => {
      node.setData({
        ngArguments: {
          title: 'XXX' + Math.random()
        }
      });
      console.log(node.getData())
    }, 1000);
  }

效果却是下面这样的 图片.png

简单分析下, 也就是说用户提供了一个初始化的data.ngArguments, 后来通过定时器执行了node.setData, 但是组件并没有做出相应的变化. 核心诉求就是重新执行一次下面的代码

const ngArguments = (node.data?.ngArguments as { [key: string]: any }) || {};
Object.keys(ngArguments).forEach(v => (componentRef.instance[v] = ngArguments[v]));

这就难办了. 我的renderAngularContent是只会在初始化节点的时候才执行的. 后面data的修改并不会让他再执行一次. 那该怎么办呢? 我一开始的想法是让用户在data中传递一个Subject, 我会进行监听. 后面修改完data后再执行next. 不演示代码了, 能看到这个地方的读者都不是普通人, 一定能懂我的意思.

这个办法的问题就在于又增加了用户的成本, 每次都要传递一个Subject, 而且我试了以后发现居然还不生效, 非常的奇怪. 我通过断点调试, 发现传递的Subject和x6拿到的Subject居然不是同一个实例? 这我有点蒙了. 所以我就找x6的作者聊了聊.

图片.png

图片.png

图片.png

图片.png

卧槽, 破案了! x6会替换之前的data. 所以每次得到的Subject都不是同一个实例. 同时作者提了一句node.on('change:data'), 这就是x6的众多优势之一, API一看就会. 随后马上就实现了需求.

protected renderAngularContent() {
 // 前面代码省略
  if (content instanceof TemplateRef) {
    const ngArguments = (node.data?.ngArguments as { [key: string]: any }) || {};
    const portal = new TemplatePortal(content, viewContainerRef, { ngArguments });
    domOutlet.attachTemplatePortal(portal);
 } else {
    const portal = new ComponentPortal(content, viewContainerRef);
    const componentRef = domOutlet.attachComponentPortal(portal);
    // renderComponentInstance 在初始化和状态变化都需要执行, 所以封成了一个funciton
    const renderComponentInstance = () => {
    const ngArguments = (node.data?.ngArguments as { [key: string]: any }) || {};
    Object.keys(ngArguments).forEach(v => (componentRef.instance[v] = ngArguments[v]));
       componentRef.changeDetectorRef.detectChanges();
     };
    renderComponentInstance();
    // 监听用户调用setData方法
    node.on('change:data', () => renderComponentInstance());
 }
}

总结

至此, @antv/x6-angular-shape应该算真的写完了. 在写这个包的过程中还是比较艰辛的, 难度也比较高, 涉及到的点也很多, 需要根据自己的工作经验灵活去应对. 但是好在结果还是满意的, 毕竟做失败的事情也不少. 还是很开心能够做成一件事情的~

招聘

团队介绍

  • 轻流是一款在线业务流程搭建网站, 在 "无代码" 方向深耕
  • 团队扁平、年轻, 高速发展中, 每年的员工人数都在double
  • 流程规范, 产品、设计、研发、测试, 每一个环节都有专人负责, 让你真正地focus自己的领域以高效工作
  • 研发中心坐落在上海市闵行区剑川路(上海交大附近), 在上海的郊区, 消费较低
  • 入职就发MBP、朝9晚6、双休、五险一金、法定节假日、年假、病假、团建、年终奖、期权
  • 更多关于轻流的介绍可戳这里我眼中的轻流是怎样的?

要求

  • 扎实的前端基础, 精通HTML、CSS、JavaScript
  • 熟悉TypeScript、SCSS/LESS、工程化
  • 熟练使用Angular、React、Vue中的一个(Angular优先考虑)
  • 具备较强的沟通能力和团队合作精神;具备较强的责任感
  • 有较强的自驱力、学习力, 关注前端生态发展
  • 高考本科及以上学历

联系

图片.png