前言
首先, 让我们简单了解下antv/x6是干嘛的. 专业点说, 叫图编辑器. 他可以用来画各种图.
在本篇文章中, 并不会介绍"从0-1如何使用x6", 这没多大意义. 文档里有的东西, 再重复抄一遍真没什么技术含量, 除非我写的比官方网站好. 但是在这里, 我不得不夸下x6官网, 写的是真不错. 可以说, 跟着官网走, 基本上都可以无障碍自助开发.
那么我今天想要给大家分享的, 是关于@antv/x6-angular-shape背后的故事, 想聊聊它是如何被开发出来的.
遇见x6 遇见更美好的未来
2021年年中, 因为业务需要, 开始寻找一款能够实现下面效果的第三方插件.
之后就找到了x6, 但是x6提供的样式和我们完全不一样, 我如何能够定制化呢? 直到我看到x6官网有这么个demo.
wow~如果可以使用html作为节点的话, 那任何样式都是没问题的, 画就完事了. 就这样, 确定了x6作为我们的第三方插件, 并开始进入迭代开发.
别人有的, 我不允许你没有
在前文我们提到, 节点, 是可以使用html进行绘制的. x6官方非常贴心, 不仅提供了html的版本, 还提供其他的版本.
HTML
、React
、Vue
, 诶, Angular
呢? 于是我就在issue里搜了下.
果然有人提过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
的核心源码, 其实你并不需要把每一行都读懂, 看这命名和语法, 你大概就能猜到这个函数干了什么.
- 获取渲染必要的内容, 比如渲染需要的组件、容器、画布
- 将组件渲染到容器中去.
那么, 我们需要做的事情, 代码大概应该长下面这样
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组件, 需要哪些条件?
- 一个待插入的
Component
(有) - 一个通过
@ViewChild
捕获到的ViewContainerRef
实例化对象 (无) - 一个
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.
官方团队也非常效率, 很快和我讨论了一番.
简单来说, 就是官方早就把这些功能集成了. 但是他给的案例确实还是和我不太一样, 因为他还是使用到了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的热爱, 也是对于长期享受开源社区带来的便利, 因而作出对社区的一点回馈罢了.
需求一: 能够支持输入属性
很快, 有人提出了新的需求
原来是想在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的值进行改变
很快, 又出现了一个新的需求
他的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);
}
效果却是下面这样的
简单分析下, 也就是说用户提供了一个初始化的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的作者聊了聊.
卧槽, 破案了! 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优先考虑)
- 具备较强的沟通能力和团队合作精神;具备较强的责任感
- 有较强的自驱力、学习力, 关注前端生态发展
- 高考本科及以上学历