(精)可视化组件设计

经验之谈

数据、图形/渲染、交互

这是数据可视化的三大核心。

我们的组件设计也应该将重点放在这三块上。

生命周期的设计

应该先画图,有哪几个生命周期,每个阶段应该做什么,不应该做什么,把这个想清楚,形成原则,就是设计了,框架也就出来了。

如何管理图元的实例化:静态对象 VS. 类

静态对象+动态加载+注册+域

这是平台类(比如短视频平台)管理组件的最优解决方案,因为对平台而言,组件是独立的资产,是需要按需引入按需加载的。

静态对象的弊端,在于它是全局唯一的,因此如果你想在一个页面上画多个图形,就会存在问题,因此我们需要引入的概念,即对每个图形,指定一个域来区分其下的对象,保证不同图表相互不影响。

Class + 单例

这是组件库的解决方案,因为对组件库而言,你支持哪些图形元素,是预先就知道的,是固定不变的,不需要动态加载。

基于组件的程序设计

可以参考这个文章

有几个要注意的地方:

  • 组件的实例化不再是明文new,而是必须通过反射来加载

  • 组件需要先注册反射机制

  • 使用组件的实体(Entity/GameObject)必须有一个数组容器,存放所有的组件实例

  • 组件内部需要有个entity或者gameObject属性,存放实体或者对象的引用

提升扩展性-必不可少的IoC

摘自这个文章:

https://zhuanlan.zhihu.com/p/53832991

IoC 的全称叫做 Inversion of Control,可翻译为为「控制反转」或「依赖倒置」,它主要包含了三个准则:

  1. 高层次的模块不应该依赖于低层次的模块,它们都应该依赖于抽象
  2. 抽象不应该依赖于具体实现,具体实现应该依赖于抽象
  3. 面向接口编程 而不要面向实现编程

依赖注入(Dependency Injection)

所谓的依赖注入,简单来说就是把高层模块所依赖的模块通过传参的方式把依赖「注入」到模块内部

use机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class App {
static modules = []
constructor(options) {
this.options = options;
this.init();
}
init() {
window.addEventListener('DOMContentLoaded', () => {
this.initModules();
this.options.onReady(this);
});
}
static use(module) {
Array.isArray(module) ? module.map(item => App.use(item)) : App.modules.push(module);
}
initModules() {
App.modules.map(module => module.init && typeof module.init == 'function' && module.init(this));
}
}

从这个方法中可以看出,要实现一个可以被 App.use() 的模块,就必须满足两个约定

  1. 模块必须包含 init 属性
  2. init 必须是一个函数

这其实就是 IoC 思想中对「面向接口编程 而不要面向实现编程」这一准则的很好的体现。App 不关心模块具体实现了什么,只要满足对 接口 init 的「约定」就可以了。

(精)ECS架构:实体-组件-系统

https://zhuanlan.zhihu.com/p/30538626

这个设计太经典了,对于动画类的组件,应该算是最优解了。

云风关于ECS的博文:

https://blog.codingnow.com/eo/ecs/

资料:

C#版本的Entitas:

https://github.com/sschmid/Entitas-CSharp

TS版本的Entitas:

https://github.com/darkoverlordofdata/entitas-ts

TS在线Demo(加入了dat.UI进行调试):

https://darkoverlordofdata.com/entitas-ts-example/

初级架构师的性能三要素

把数据放一起

尽量顺序访问

尽量一起读写

程序设计考虑到这三点,并将其做好,就可以成为一名初级的架构师了。

可视化设计的最优解只有一个

可视化程序设计的发展路线:计算机图形学->游戏开发->数据可视化开发

可以看到,做可视化开发的高手,都关注计算机图形学,且可能超过50%的可视化开发高手,都关注游戏领域。

好的可视化程序设计,一定有这几个元素:

  • 底层技术的隔离(Canvas、SVG、DOM、WebGL)
  • 图元(Element),在这一层实现事件、动画,且图元一定有Group的设计
  • 一定有图层(Layer)的设计
  • Chart层

归一化(normalize)

归一化就是计算单位向量

可视化页面可能会展示在不同的终端上,因此我们在计算布局和图元位置的时候,就不能写死固定的长度。所以我们引入了归一化的概念来解决这个问题。

我们将绘图区域看做一个容器,所有的元素,在计算阶段,都是以容器中的百分比作为距离衡量单位。等所有的计算都完成后,我们再用图元的归一化坐标值,乘以容器实际的高宽值,就可以得到最终这个图元在当前终端下的绘图坐标值了。

归一化可以看成是坐标系的跨终端抽象,或者我们可以将其称为虚拟坐标系

归一化 + 比例尺 = 跨终端自适应

数据锚点

数据锚点其实和归一化是强关联的,即每组图形元素在虚拟坐标系的坐标,范围是[0, 1]。

图形元素的绘制

牢记一个原则:图形元素都是以组为单位进行绘制的。

比如我们要画一个散点图,每一个散点,虽然只是一个圆圈,但是实际绘制的时候,我们一定会用Group来画,而不是直接画一个Circle。这是为了扩展考虑的。

试想一下:如果后面需要给每个散点,在鼠标悬浮的时候绘制一个标签,如果没有这个Group的设计,你会怎么绘制?如何解决数据绑定的问题?如何解决平移缩放后这个标签和散点的跟随移动问题?

事件机制

图元模式(图形语法学)和配置模式(D3Charts)在设计上的差异

图元模式,需要传入layer,layer和EventManager绑定,在layer.add(Element)的时候,执行事件的绑定操作

配置模式,则是直接将EventManager挂到了整体的图形对象(chart)上面。

从程序设计的角度来讲,肯定是图形语法学的模式更好,耦合度更低,扩展性更强。

图层layer的设计

图层的设计很好,判断事件的时候,应该是先判断触发了哪一层,然后判断触发了该图层的哪个元素(目前XCharts没有做这个处理,叠加的情况是无法准确判断的,会点中的所有元素都触发事件)

这也是典型的Canvas对浏览器事件的冒泡捕获的实现

目前的浏览器、DOM、JS的很多实现,本身就蕴含了很强的设计,把这些设计搞懂,然后在合适的场合重新实现,比如Canvas的事件,就是很不错的设计了。

XChart的EventManager中,就是将DOM的事件都实现了一遍。

很多不同软件的设计都是相通的,比如图层的设计,在PS中就有很好的体现。

先有鸡还是先有蛋的问题

图元初始化如果没有绑定canvas,那么图元的事件注册,就必须在layer.add(element)之后才能进行,否则此时图元内部没有关联到layer,也就没有eventManager,也就无法将事件加入eventManager。

但是如果初始化图元的时候就传入canvas,那么后面还得执行一次layer.add(element),就显得冗余了。

这是图形语法学设计的一个弊端,会导致耦合度升高。

设计模式

对于每种设计模式想要解决的问题、应用的场景,如果没有真正理解,就会不明确该不该用、该用哪种,最终导致为了用而用,不解决问题。

不解决实际问题的设计模式,对项目来说等于毒药。

装饰器模式

这可以说是可视化开发中应用最多的设计模式了。

因为可视化的开发,基本上都是先确定绘图位置(数据锚点),然后在该位置上绘制图形元素,因此我们可以给数据锚点引用装饰器模式,来实现各种各样的可视化效果。

装饰器模式有个很好的地方,在于它可以针对已有的组件(即使这个组件不是你写的),扩展新的表现形式。这在应对业务方个性化需求的时候,非常有用。比如我们的散点图,默认是一个数据锚点只能画一个散点,但是我们通过装饰器模式,可以对它进行扩展,额外再绘制一个标签,展示更多信息。

多态和工厂的区别

多态一定有传入对象,是针对传入对象的类型进行判断,调用对象的方法

工厂是针对传入参数去实例化对象

这是多态和工厂的区别,一定要牢记。

代理和AOP的区别

代理是为了让管理者可以加入自定义逻辑;代理所做的操作,一定都是用户不需要关注的。

AOP是为了让用户可以加入自定义逻辑

控制反转(IoC)

控制反转是针对小模块拼装大模块的场景,更多的是组装,与结构相关(比如组装一个汽车)。

别把控制反转和代理弄混了。

单例

单例是为了复用,比如用户切换布局。

配置项的设计

配置项的开放其实也是有套路的,比如ZRender,就是把绘图的所有CSS属性给开放出来,这样自由度就非常高了,想实现什么样式都可以。

D3Charts也是,开放的配置项很多,自由度很高,不过对用户来说,成本也会高一些。

我们将配置分类,比如基于文字的配置、基于形状的配置、定位等等,这样就容易理解了,这也属于业务建模吧。

立即触发事件和延迟触发事件(DelayAction、DelayAnimation)

之前看mir3的代码,里面通过DelayAction实现了连续型的技能效果。

ECharts也有DelayAnimation的设计,用于实现延迟动画,可以做很多炫酷的效果,比如历史轨迹。

图解方案

我们需要找个好用的画几何图形的工具,方便解释原理。

比如我画圆角三角形,就会涉及几何计算,如果没有图形作为解释,光看代码是非常难以理解的。

正面案例

分红融资旭日图

组件大致长这样:

fhrz

一开始我以为要耗费不少功夫才行,不过鉴于最近做其他组件的经历,我准备先设计下数据结构,将数据处理和渲染分开。

然后我按照这个流程去做了下:

  • 用Markdown罗列了所需的各种数据结构(下面只是个示例,实际上和这个有出入):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 原始数据
const capitalisations = {
'同花顺': 50000000000,
'东方财富': 300000000000,
};
const dividends = [
{
name: '同花顺',
date: '2019',
dividend: 10000000,
profit: 1000000000,
}
]

// 绘图数据
const arc = {
弧度:
角度:

}

// 配置信息
const arcStyleOption = {
color: '#F00',
thickness: '2px',
};
  • 代码中先设计好几个Class
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Sunburst{
constructor () {

}

dealData() {

}

computeArc() {

}

render() {

}

}
class Arc{

}

class InnerArc extends Arc{

}

class OuterArc extends Arc{

}
  • 主流程方法中,先通过注释把要做的几个事情写出来
1
2
3
4
5
6
7
8
9
dealData() {
// 计算每个公司的市值占比,进而转换为弧度

// 计算每个公司每一年的分红/利润比,以及最大的分红/利润比

// 计算每个公司每一年的分红/利润比和最大分红/利润比的比值(相对分红比),通过该比值和当年的弧度,计算绘图所需弧度

......
}

这些分析好了之后,结果我居然在一个小时左右就把这个图给画出来了,效率远超我预期。

反面案例

循环依赖导致难以扩展和维护

写动态柱状图组件的时候,柱状图的配置依赖timeline,timeline的配置依赖柱状图,形成了循环依赖,结果导致很难控制,修改了一个地方,不知道其他关联地方会有什么问题,就像打地鼠一样。

一定要保证依赖的单向性,而最好的解决单向性的,就是生命周期控制(就像人的一生,是不可倒转的)+数据渲染分离。

我们做设计,很多时候都是为了避免形成网状关系;我们应该尽量形成树状关系。数据结构还是很重要的。

资料

AntV/G的设计:

https://zhuanlan.zhihu.com/p/469977477

这个文章非常好,里面有很多的设计:canvas、group、shape的三层设计,节流设计,更新队列设计,裁剪设计,拾取的分组识别,以及树的设计,很值得参考。