那些将数据、信息用视图形象展示出来的技术都叫可视化。前端常见的可视化场景有:数据图表、地图、关系图、3维园区、文本编辑,对应的库则有:echarts、openLayer、antv、three.js等等(音视频解码播放、游戏、ui库、框架不属于可视化范畴但也有使用到可视化技术)
得益于现在开源的可视化库一大堆,我们几乎不用自己从头写了,但基于可视化库做业务开发还是会遇到不少问题。我想,梳理一下这类库的设计特点,其中一些思想也能应用到可视化业务的开发中,有所益处。
1. 可视化库的常见问题
每个可视化场景都会有自己的需求,这些需求又分化出许多功能。比如在界面上绘制1个设备,用图片还是用代码绘制,选中时用框选还是变色。我们就把这种具体功能的实现问题称做第一类问题。
功能实现的过程中我们可能还会想,要不要数据单独放一边,逻辑单独放一边,开发就可以标准化一些,以后代码量增多了,调试、查找起来也能较为方便。这一类可以算作是维护的问题,属于软件开发中都比较普遍的问题。
另一个比较普遍的问题是灵活性和易用性的问题。一个复杂的功能要做得易用,就意味着需要更多的提前封装。而封装得太深,里面一些细节的地方通常是很难支持外面再去做控制的。所以易用和灵活存在较强的对立关系。
除以上3类问题外,可视化场景比其它应用场景因为多出来一个“视图”,所以常多出来一个视图卡顿的问题,这种卡顿可能有以下三个方面原因:
- 数据多,处理耗时,又是处理完之后才触发渲染,视图出来的自然就慢。
- 要渲染的图形元素多,这中间还涉及到一些属性计算,视图出现明显卡顿。
- 也有可能是数据处理、渲染过程中发生了交互事件,当前程序执行完成后才能响应交互事件,用户就会感觉到卡顿。
不同的可视化场景,在数据、视图、交互方面会有不同偏向,视图卡顿的主要原因也就不一样。
- 科学计算、数据图表可能要处理的数据量大,主要是数据处理部分慢;
- 3D场景视图计算多、渲染量大;
- 一些标绘场景又是交互比较频繁;
- 以上3种也可能同时出现;
视图卡顿问题其实可以算做性能问题,只不过表现在视图方面。它和以上几类问题之间都存在关系,而且会互相影响。
你可能会因为视图卡顿而再写一套更新逻辑,只更新那些变化的元素;或者是因为1个功能使用起来不方便,引入更多的代码来支持。这样做都是引入了新的代码,必定导致后续维护难度增加。你可能又为了方便维护,要修改一下开发标准(功能的实现流程、新的规范等),如果方式不当的话又可能导致某个功能调用链过长、要查询更多数据,反向造成性能方面的困境。
这3类问题其实都是在解决第一类问题(功能实现)的过程中产生的。但是在处理这3类问题过程中也可能再导致第一类问题(某个功能的实现不准确),所以它们完全是互相影响着的,或者说问题本身不会消失,它只是在各方面转移。
这样看来我们似乎并不能解决所有问题,甚至要解决其中几个都很困难。不过好在我们对维护难度、性能高低这些有不同的容忍程度,只要各方面问题不超出对应的那个程度就还是可以接受的。
其中第一类问题是我们容忍程度最低的,通常都需要我们实现全部功能,且不能有偏差。而维护方面的问题,我们的容忍度最高,因为它不是使用过程中的问题。其它问题则看各自的应用场景决定。
按照这个容忍程度排序,我们就有了一个大致的设计方向,即在解决第一类问题的前提下再去解决其它问题,设计过程中发现冲突的话按照这个容忍程度的排序进行问题的转移(一般最后都被转移到维护方面的问题上)
第一类问题是各可视化库独有的问题,情况很多,所以这里我们也列不出它们的解决方式。下面涉及的设计也多是维护、灵活与易用、性能这三方面的问题相关的。(其余的其实还有稳定性、内存占用大小等方面的问题,但不主要,并未列出)
2. 渲染方式
普通dom渲染:普通的html元素操作方便,但很难绘制特殊的图形。渲染要求不高的场景可以考虑(如文本编辑、代码编辑、任务进度)
svg渲染:比普通dom更强大的图形绘制,兼具易操、打印和导出,移动端设备显示比canvas更稳定。但图形元素很多时容易卡顿。渲染量不是较大的场景都可以考虑使用。
canvas(2D/webgl/webgpu)渲染:渲染效果和性能最好,大数据量渲染、复杂场景必备。只是实现和管理方面比较麻烦。
多数前端的可视化库中,都是用多种渲染方式。像标题、hover提示、右键菜单、控件这一类ui元素,与主视图的逻辑、渲染关联不强,比较独立,多是用普通dom实现。主视图渲染也可能会支持多渲染方式,供开发者在不同的需求环境中切换使用。
混合渲染:主视图使用svg/dom,内部图形可用svg元素渲染,或者foreReginObject元素中嵌套普通dom、canvas来渲染。这种方式属于以上3种渲染方式的折中使用,通常是用在报表配置、深入结合了具体业务这些场景。如果各图类差异大,图形间关联比较弱时可以考虑使用。
3. 分层设计
可视化中的许多功能,在实现的时候都会用到一些普遍的技术,比如:多数功能都涉及到各自的数据、要展示出不同的视图效果,有各自的交互控制。
这些功能的执行方向也比较固定,可能都是从数据到视图,或者发生交互到修改数据。把各功能中数据、视图这些普遍性的单独抽象出来,然后每个功能的实现都分散到这些抽象体当中,执行功能也是按固定的顺序执行(一个抽象体到另一个抽象体)。
对数据、视图这些的抽象就像是楼栋中的楼层,功能的执行方向则是电梯的运行方向。分层不仅是分化出来好管理,而且还固定了功能的执行方向。固定则意味着好标准化,容易维护。
3.1 经典的MVC分层
Model(模型层)放置原始数据或中间生成的数据,数据的获取和设置一般通过定义的接口来管理。模型层不主动调用视图层和控制层的接口。
View(视图层)进行直接与渲染相关的操作,或者是对视图描述。数据可以从模型层取,可以从控制层传来。但只是被动渲染,不修改模型层数据,也不调用控制层操作。
Controller(控制层)用来协调View和Model,作为两者之间的映射。一个暴露的接口通常就是一个功能的实现(调用Model层和View层接口)。监听视图层事件,事件处理逻辑都放在这一层。
MVC的这种分层非常契合可视化功能中的数据、视图、交互3要素,现代的可视化的库几乎都有使用这种分层方式。
简单的MVC示例代码(线图和树图绘制):
// 模型层
class Model {
constructor(){
// 存放的数据
this.lineData = {};
this.treeData = {};
}
getData(){ ... }
setData(){ ... }
}
// 视图层
class View {
// 传入 Model层实例
constructor(model){ this.model = model; }
drawLine(){ ... }
drawTree(){ ... }
}
// 控制层
class Controller{
// Model层和view层实例
constructor(model,view){
this.model = model;
this.view = view;
}
render(option){
this.model.setData(option.data); // 设置 线条、树 所用的数据
... 其它映射逻辑
this.view.drawLine();
this.view.drawTree();
}
}
// =====使用入口=====
class Mvc {
constructor(){
this.model = new Model();
this.view = new View(this.model);
this.controller = new Controller(this.model,this.view);
}
getData(){ return this.model.getData(); }
setOption(option){
this._option = option;
this.controller.render(option);
}
}
这样,在增加一个新功能时,就在Model层放置该功能用到的数据,View层实现对应图形的绘制,Controller层做两者的映射,管理起来比较方便。
当然,MVC也有它的缺点——分层较粗糙,应用体系较大时各层显得臃肿,各层之间的边界模糊。所以我们常见到的可视化库设计也不完全只有MVC分层。
3.2 其它分层
MVP与MVVM属于MVC的变体,也专用于带视图的开发设计中,但在可视化场景中使用却不常见。
其中MVP是把View层多数的逻辑都抽离了,几乎都留在了P(Presenter)层,View层也不能直接访问Model层,数据都是从P层传过来,失去了很多灵活性,这导致Presenter层要为交互定义很多的接口来维护与view层的通信,非常繁琐。
MVVM在UI框架中使用多,但可视化中视图的更新逻辑往往很复杂,不仅是数据变化,还包括过渡动画、图形重绘等。将这些复杂逻辑都塞进ViewModel,容易使其变得臃肿、难以维护。
视具体情况,还可以再分出其它的层。比如Model层中,数据可能来自网络请求、本地缓存、中间生成,这种数据的获取就可以考虑抽象为一个数据访问层。一些科学计算的场景基本都要对数据做处理,可以考虑抽象出一个数据处理层。这两种在后端架构里更常见。
另外可视化库应用到具体业务场景时,功能通常是具体的业务功能,可以考虑在Model层和Controller层之间放一个业务层。视图层太大也可以考虑再分化出一个渲染层。
3.3 渲染层
无论什么可视化场景,绘制的总是图片、点、线、几何图形(少数是像素级的控制),或者是它们组合出来的复杂图形图像。又上面渲染方式里说了,支持多种渲染方式可适应不同的场景。例如小场景用svg渲染,大场景切换canvas 2D渲染。或者是用webgl渲染,环境对webgl支持较差时用canvas 2D做降级方案。
基于以上两点,将它们从View层分离出来作为1个渲染层是比较合理的,View层则可以做一些复杂视图的描述(比如一个树图有哪些基本几何图形,位置关系等),和对渲染层的具体调用。
以下是一个可切换svg/canvas 2D的渲染层组成示例:
path元素:path可以绘制圆、矩形、椭圆等其它几何图形,统一用path绘制方便管理。通常将path抽象为1个基类,其它几何元素继承使用。Group分组:用于添加子图形或子分组,但本身属于元素,只是不绘制出图形。像svg中的<g>一样。可使用组合模式实现,对Group进行位移、缩放等更新时,Group递归自身子元素调用相同接口进行位移、缩放操作。- Layer分层:每个分层是一个svg或canvas元素,更新较频繁的单独放一层,既方便管理也用于节省渲染开销。
canvas渲染器和svg渲染器:管理各Layer,包含具体的渲染逻辑实现,各渲染器暴露相同的绘制接口。- Animation动画:动画的启、停、插帧,多动画的管理。
- 事件响应:每帧的更新事件、元素和层级的事件等等。
4. 模块化设计
分层是对功能执行过程进行划分,模块则是对单个功能的划分。一个功能可能会包含多个小功能,一个模块也就可能包含多个小模块。模块和分层可以结合使用,一个模块中可做几个分层。层里面也可以再分化出模块,两者互相交叠、嵌套,可以组成很复杂的结构。这在系统架构里面就比较明显,系统架构图表现的也就是这种关系。
分层一般不会太多,一个大的可视化库 通常就3~5层,层之间的调用关系较固定。模块则根据功能的数量,很容易抽出大量模块,而且模块间的依赖、
调用关系也不那么固定。
场景中有一些较为直观的,能看到的,较容易被我们模块化出来:
- 工作区/场景:整个可视化容器的管理,一般就作为主实例使用。
- 图形/图例:场景中绘制的图形、节点、折线图视图等单独抽象出来。
- 控件、工具栏、菜单这些辅助性工具。
另一类则是要通过思考才能抓取到它们的特征,抽象出来,通常不容易想到。比如:
- 调度器:控制任务执行顺序,安排优先级。
- 拖动、鼠标绘制等控制功能。
- 资源管理器:文件、图片等数据的加载、缓存、清除。
let tree = new Enter.Tree(配置数据);
let line = new Enter.Line(配置数据);
// 场景容器
let work = Enter.Workspace();
work.add(tree);
work.add(line);
抽取模块之后,在使用时变得很灵活、直观,还有就是方便维护。
5. 分层和分块的问题
分层和模块划分看似能让我们的开发有序地进行了,但复杂到一定程度后许多问题就突显出来:
- 过多的模块间通信都要实现对应的接口,维护成本高。
- 模块或层之间通信带来额外的调用开销,数据在其间传递可能产生复制成本。
- 许多简单的功能,执行时也需要经过所有层次,效率低下。
循环引用的问题:模块和分层的设计中都会有一个模块调用另一个模块,一个层调用另一个层的方法的情况,因此在创建实例时会保留一个引用,如下:
class Workspace {
nodes = [node1,node2,..]; // 存了子节点列表
addNode(node){
node._workspace = this; // 子节点上挂载 App实例。
this.nodes.push(node);
}
}
class Tree {
attr(key,val){
this.setAttr(key,val);
this._workspace.render();// 调用了主实例方法
}
}
以上两个模块存在互相引用的关系,如果两个模块互相调用对方的方法,且进一步引发对方更新,这很可能就会出现死循环的情况。复杂场景的模块划分很多,这种互相依赖形成一个环形的问题更难发现。
除了在执行过程中可能出现死循环外,开发过程中也可能出现。某个模块逻辑或接口做调整,与其牵涉的模块多半也要改动,糟糕的时候就会一级一级地影响下去。
上面的块/层间通信开销大、维护耗时、循环引用这些问题在复杂的应用程序里是无法避免的,只能尽量减轻,死循环这种严重问题则最好不要出现。
关系管理:无论是模块还是层,分化出来后,它们就处在各种各样的关系之中。最容易出问题,最难管理的也是它们的关系问题。
而凡是问题都涉及到两方面,这里则是模块/层次太多和我们未能很好的管理这些模块和层级间的关系。这也衍生出两个方面的解决思路,一是考虑合并部分模块或层,删去不合理的功能(技术之外的方法)。 另一方面是增强管理,可以如下考虑:
- 精准的抽象一个模块/层:如果把不属于该模块/层的功能抽象进去,在调用关系上就容易混乱;
- 关系尽量保持单向:模块/层之间的调用尽量是单向的,减少互相影响。
- 循环引用时避免调用的接口中出现对其它模块/层再做修改、更新这样的操作。
6. 驱动型设计
分层和模块化更偏向于静态的应用程序的管理方面。驱动型设计则偏向于像动态的程序的执行方面。
数据驱动:数据发生变化就去渲染。通常是Controller层整合相应的数据变动,Model层合并新旧数据,视图层监听Model变化,进行渲染更新。这种驱动方式很适合可视化的“数据到视图”的特点,使用起来也很简便。但存在以下问题,通常就中小场景考虑。
- 为了精准的描述视图要存许多额外状态。
- 复杂的图形效果和行为也很难仅用数据描述实现。
- 只更新其中一个值时,也会进行整个数据的合并更新。
事件驱动:在交互性强的场景(用户的交互、系统应用的交互),事件驱动的方式执行是比较方便的。可视化场景的事件主要是用户触发的鼠标/键盘事件,应用内也会自定义事件来进行模块或层之间的解耦。触发对应事件时,执行预先定义好的逻辑就行。只是事件流动混乱、事件间执行无顺序,难预测,不好调试。
状态驱动:程序应用多需要自己上的执行规则,不同情况下相同的输入执行的结果会不一样。可视化里比较常见的是编辑和查看这两种状态下交互操作会不一样。
- 可以通过
定义状态、状态的转移、触发转移的条件、转移的规则来刻画这些行为。 - 应用每个时刻都处在一个确定的状态中,各状态下可接收的
输入/事件不一样。状态的转移则由触发的事件/条件和当前的状态决定,然后执行对应的逻辑,再切换状态。 - 状态少可以考虑用简单的分支条件方式实现,或使用状态模式设计。
- 状态管理复杂时可以使用有限状态机的设计方式实现。
指令驱动:像jquery那样,封装成很细小的功能接口,每个接口几乎只实现一个单一作用,可链式调用。大的功能逻辑完全由开发者自行组合实现。如果要追求极大的灵活性的话可以考虑使用。
结合使用:以上的几种驱动方式并不冲突,实际中也常是结合使用。比如用数据驱动方式绘制视图,又留一些接口可单独设置视图,事件监听响应用户操作等等。
7. 事件系统
也就是事件驱动的设计。可视化库中,事件的设计几乎是避不了的,可考虑如下设计:
使用观察者模式:一个观察者列表,发布事件时直接遍历列表中的观察者,传入事件类型和数据。这种实现耦合较高,直接控制,实现简单。适用于小范围的,或关系明确的对象间通信,如GUI事件监听、响应式数据绑定。
使用发布订阅模式:只监听自己想要的事件,事件发布方法通常向外暴露,所以事件监听者和执行者互相不知道对方的存在,耦合低。单个事件的监听者较多时,可考虑异步执行每个监听函数。更适合跨模块、系统这种范围大的情况。
许多模块、层都要维护自己的事件,一般事件管理单独实现作为一个基类使用。
// 事件类
class Event {
on(eventName,fn){ }
publish(eventName, data){ }
off(eventName,fn){}
}
// 其它类继承使用
class Workspace extends Event {}
使用原生的dom事件:对于使用 普通dom/svg渲染的可视化库,可直接用 原生的事件系统。自定义类型事件可用 CustomEvent扩展。
事件类型、数据格式的标准化、特殊事件的节流、防重复触发这些也是需要的。
8. 渲染模式
即时渲染和延迟渲染最早是来自计算机图形学的两种概念,本质都是为了快速渲染,在不同情况下考虑使用。
即时渲染:执行绘制时,直接计算相关图形属性,然后渲染显示。特点就是简单直接。
- 初始渲染和更新视图几乎用同一套逻辑接口。更新操作时,直接
清空所有视图,全部进行重绘。 - 这样做
无需进行数据对比,不需要保存多少数据状态,而且只用维护一套逻辑。 - 整个过程同步执行,不用担心中途插进一个异步任务来降低实时性。
这种追求实时渲染而使用简单逻辑的方式,在复杂场景反而不能实时。数据多,处理慢,或视图元素多的时候,全部重绘容易出现一瞬的空白。因为是同步执行的,只要某一次执行耗时,期间用户发生了交互,就不能及时响应。因为保存的状态少,所以动画效果很难支持,整个体验过程比较生硬。
对以上问题,可以适当做一些状态保留,再写一套更新逻辑,只重绘有变化的图形。可以将更多控制逻辑交给开发者,让其自行优化。或者你也可以考虑用下面这种模式。
延迟渲染:针对复杂场景、大数据量情况比即时渲染更快。
- 触发交互时
引入一定的延迟(<16ms),这可以剔除中那些无效的交互,只执行最后一次有效的事件。 - 数据更新时,同样引入延迟,将多次变更的数据合并计算,可以省掉一些重复计算,最后一次才触发视图更新。
- 保存更多的数据状态、元素状态,在更新时只重绘变动的部分。
- 对任务(数据处理、视图设置等)进行调度,
异步执行,保证能及时响应用户的交互。
这种渲染模式和实时渲染几乎是相反的,要保存许多数据状态支持管理,架构也会变得比较复杂。在有些实时要求高的场景,还是不大适合,可能部分要考虑单独实现即时渲染方式。
一些可视化库也会提供两种渲染模式切换使用,小场景、实时性要求高的点用即时渲染,复杂场景切换为延迟渲染。
9. 调度
复杂场景数据多、更新频繁,短时间内要处理许多任务(数据处理、渲染、交互事件),不想卡顿就只能对任务异步执行,异步任务太多的话,它们的执行顺序不一定按预想的一样。调度则是一种管理任务执行顺序的一种技术,几乎都是与延迟渲染结合使用。
实现调度前要先进行以下设计:
- 任务划分足得够小:js是单线程的,虽然对任务异步执行,但如果有一个任务很耗时,仍然要等该任务执行完毕才能响应鼠标事件和其它程序。所以要将一个大的任务划成多个足够小的任务,这得在编写程序时就做好预估。
- 数据分片:面对大量的数据,即使一个不复杂的程序也会要执行很长时间。对于数据要选择能分批处理的方式,每个任务只处理一部分数据,保证每一个任务不会占用过多时间。
- 给任务分优先级:定义哪些属于高优先级任务(交互事件、渲染任务),按优先级顺序排列执行。
- 任务队列:任务按优先级放到队列中,执行完一个任务后出队列。通常分高优先级队列,低优先级队列。
- 任务间依赖:一个系列任务有自己的执行顺序。每个任务用两个指针链接到前后任务,按此顺序添加到队列执行。
- 任务间数据传递:多个任务共同完成一个数据处理时,常在最后一个任务才将数据更新到Model中,中间的产生的数据就需要传递。可以建立任务上下文(包含要调用的api、模型、输出的数据)。执行当前任务时获取上一个任务的输出数据。
以上设计好之后,就可以开始调度了。使用requestAnimationFrame,每一帧执行多个任务,超过指定时长(一般<16.6ms,超过会感到界面卡顿)就暂停执行后面的任务,在下一帧回调中再次执行。下面是个简易的调度示例:
// 任务的结构
Task {
id: string;
fn: () => void | Promise<void>; // 任务的具体执行函数
priority: number;
output: any,// 输出数据存放
head: Task, // 前置任务
next: Task, // 下一个任务
deadline?: number; // 截止时间戳
}
// 每帧回调
scheduleFrame(){
this.rafId = requestAnimationFrame((timestamp) => {
// 高优先级任务处理
this.processHighPriorityTasks(timestamp);
// 低优先级任务使用 requestIdleCallback
if (typeof requestIdleCallback !== 'undefined') {
this.ricId = requestIdleCallback((deadline) => {
this.processLowPriorityTasks(deadline);
}, { timeout: 1000 });
}else {
// requestIdleCallback 不可用则 降级处理:使用setTimeout
setTimeout(() => {
this.processLowPriorityTasks({ timeRemaining: () => 50, didTimeout: false });
},0);
}
});
// 判断各任务队列中是否还有任务
if (!this.highPriorityQueue.isEmpty() || !this.lowPriorityQueue.isEmpty()) {
this.scheduleNextFrame();
}
}
// 高优先级队列处理
processHighPriorityTasks(timestamp) {
const frameStart = performance.now();
// 超过指定时间就停止执行,避免一帧耗时太久产生卡顿
while((!this.highPriorityQueue.isEmpty() && performance.now() - frameStart) < 12){
// 出队,执行任务
const taskItem = this.lowPriorityQueue.dequeue();
this.executeTask(taskItem.task);
}
}
// 低优先级队列处理
processLowPriorityTasks(deadline) {
// 任务不为空,空闲时间>0 或者 未超时 就执行
while(!this.lowPriorityQueue.isEmpty() &&
(deadline.timeRemaining() > 0 || deadline.didTimeout)){
const taskItem = this.lowPriorityQueue.dequeue();
this.executeTask(taskItem.task);
}
}
一帧执行结束后,浏览器会响应刚才发生的交互事件,这些交互中产生的新任务被添加到队列中,下一帧再次按优先级执行。
更大的数据量或者不能分片的数据处理可以考虑结合Worker使用。
10. 扩展和插件
很多可视化库支持扩展,这让开发人员可以开发出自己需要的功能,还方便打造自己的社区。另外,它本有的一些功能也当成扩展独立了出去,各使用场景可以按自己的需求添加,减少了不必要的包,增加了灵活性。
要支持扩展,一般是要在核心部分较为标准化之后考虑。哪些是核心,哪些又可以扩做成展呢? 功能层面上来说就是数据到视图,加上必要的交互。程序上则是数据、视图、交互涉及到的基础的模块和层,以及它们之间的关系,其它的则都可以考虑做成扩展使用。
扩展可以是函数方法的扩展,通过函数传参、函数内直接this指针访问到扩展所在的那个对象,完成单一功能。
或者是扩展一个模块,同一类模块中,留下基础的模块,其它都能以扩展的方式加入进来,比如在已有的基础元素类上扩展一个自定义图形。
层级则较为固定,通常不支持再扩展出新的层级。
插件:属于一种特殊的扩展。函数和模块的扩展通常是在应用实例化之前,插件则更多是依赖于一个已经实例化了的应用,可以动态添加和卸载。所以,插件一般会设计生命周期函数(初始化、更新、卸载等)。初始化时向插件传入应用实例,插件调用应用暴露的方法实现能力,进行事件监听等。
无论是扩展方法、模块还是插件,最好都提供统一的注册入口,让主实例能够知晓这些扩展的存在,便于管理。插件还可以考虑支持设置初始化顺序,对外提供插件查询的接口,这样能应对插件之间有互相依赖的情况。
// =====都通过 1个 includes 方法扩展到 App上========
Enter.Workspace.includes({
removeAllElements(){ this.getElements()... }
...
});
// =====基于已有的视图模块,实现一个 Bar模块==========
class CollapseTree extends Enter.Tree{ }
// 用一个入口注册模块
Enter.register('CollapseTree',CollapseTree);