5

题目中的“新一代”是个相对的概念,事实上本文即将介绍的方法已经有了生产环境可用的实现方案(这也侧面佐证了其可行性),但考虑到此方法与现在大部分前端项目中所使用的数据可视化方案相比仍有一些优势,因此仍以“新一代”进行描述。

前端生态中的几座大山

在进入主题之前,我们应该先明确需要解决的问题是什么。

当我们进行技术选型的时候,经常会提到一个关键词——周边生态,其中包含了社区活跃程度、项目稳定性、代码质量、维护者身份等多个因素。这是因为更好的生态圈意味着有更多的“最佳实践”,这些实践一方面指导我们如何更好地开发,另一方面也提供了很多现成可用的方案加速开发。

当技术发生整体性的跃进时(例如vanilla javascript到jQuery,jQuery到新一代前端框架等)往往伴随着旧生态的废弃与新生态的建立,在这个漫长而煎熬的过程中我们发现有几座大山总是新生态中最难被填补的短板:表单系统、数据可视化、复杂交互(如拖拽、手势等),当然也会有“旧大山”被移走(如兼容性问题)以及“新大山”的出现(如数据流管理),但这些暂时不在本文讨论范围之内。

在生态迁移的过程中也会出现一些连接新旧生态圈的过渡方案,但这些方案往往不能完美契合新技术,因此注定无法成为“最佳实践”。本文则将逐步讲解在数据可视化领域内我们如何去旧迎新,发挥新一代前端框架的最大威力。

数据可视化 VS 图表

图表作为数据可视化最常见的表现形式之一,往往被以偏概全的认为图表就是数据可视化。事实上数据可视化的概念极其宽泛,图表只是它众多表现形式的一个子集。严格来说,数据可视化应该是连接数据与视觉的一个映射关系,将数据映射成人更容易感知其规律的可视化结果。

图片摘自D3.js Gallery

我们也可以根据数据可视化与图表的不同对过去的可视化方案进行分类(仅列举一些常见库):

数据可视化 图表
D3.js Highchart.js
- Echart.js
- Chart.js

即使你对以上表格中的JS库完全没概念也不用担心,因为接下来的内容与它们关系并不大。

我们对这些JS库的使用方式及实现方法分类后会发现:图表库大多是通过“配置”的方式工作,用户无法修改配置中没有列出的部分,并且大部分的主流图表库基于Canvas实现;而作为数据可视化类库的佼佼者——D3.js则采用“数据驱动”的工作方式,用户自定义程度很高的同时伴随着相对较多的代码量,其大部分的实现则围绕SVG展开。

SVG VS Canvas

在上文中我们引出了前端可视化的两种主流实现方式——SVG与Canvas,我们的“新一代方案”同样需要从中做出选择,因此我们要对两者进行一定的对比:

SVG Canvas
基于形状 基于像素
每个基础图形对应一个DOM元素 所有图形最终只对应同一个HTML标签
可通过JS和CSS控制 只能通过JS控制
抽象的事件模型 颗粒化的事件模型
大区域、少对象时性能较好 小区域、多对象时性能较好

通过以上的对比我们可以看到两者在实现数据可视化时都没有什么硬伤,性能差异在绝大部分数据可视化场景中并不产生明显的影响。

但由于Canvas的事件模型是颗粒化的(例如我们无法直接监听某个图形的点击事件,而是需要判断鼠标点击的坐标是否“落在”该图形内来判断),因此基于Canvas的实现方式往往伴随着大量的底层代码用于封装一些常用方法,这也使得最终用户层面上灵活性不足,只能通过繁多的配置来弥补。

服务于现代前端框架

之前的铺垫都是为了最终的目的——服务于现代前端框架。我们暂时放下SVG与Canvas,先来看看这些前端框架的几个共同优点,我们在现在热度较高的几个框架——React、Angular、Vue的文档中都可以找到这三个关键词:

  • Declarative(声明式)

  • Reactive(响应式)

  • Component(组件化)

这三者都是这些框架能够脱颖而出的立身之本,因此能够充分发挥这几大优点的方案必然会很好的服务于这些框架。

横向对比之下,组件化对于数据可视化领域来说不是什么新鲜的概念,所以我们着重在声明式响应式上做文章。

过渡方案

注意:从此处起我们将引用一些代码作为示例,涉及框架时使用React作为代表,但对其它框架有一定理解的读者可以很容易地进行类比。

在文章的开头我们说过在生态迁移的过程中会产生一些过渡方案,以下写法就是一种常见的数据可视化过渡方案:

this.chart = new Highcharts[this.props.type || 'Chart'](
  this.chartContainer,
  options,
);
// Just a wrapper of API!

在需要引入可视化图形的组件的某个生命周期中初始化一个图表,传入对应的DOM节点(通过ref之类的方式获取),再将一些配置项对应传入,生成最终的结果。

这类过渡方案如注释所写,只是对API的一次再封装,与声明式、响应式的设计思想难以结合,但因为迁移/学习成本低这一优势也能帮助用户快速完成需求。

尝试数据驱动

当配置式的写法不太灵时我们不妨看看数据驱动的方式。以下是一段D3.js绘制柱状图的代码:

const svg = d3.select('svg');
svg.selectAll('.bar')
  .data(data)
  .enter()
  .append('rect')
    .attr('class', 'bar')
    .attr('x', d => d.x)
    .attr('y', d => d.y)
    .attr('width', d => d.width)
    .attr('height', d => d.height);
/*
<svg>
  <rect class="bar" x=x1 y=y1 width=w height=h/>
  <rect class="bar" x=x2 y=y2 width=w height=h/>
  <rect class="bar" x=x3 y=y3 width=w height=h/>
<svg>
*/

抛开较为特殊的data方法和enter方法不谈(它们的作用可以简单理解为将数据遍历),这是一段典型的“命令式”编程代码:依次选择DOM节点、附加元素(各种SVG图形,示例中为rect即矩形)、为元素附上各种属性。

尽管还是与我们想要的代码有很大的区别,但与过渡方案相比却有两个明显的亮点:

  • 和数据结合的更紧密,将数据解析成单个元素所需属性的做法和现代框架很相似

  • 最终生成的是一个多DOM节点结构,而现代框架一个共通点就是通过各种方法更好的处理DOM

基于框架改写代码

基于数据驱动的写法进行改写,我们可以写出这样的代码:

<Chart
  width={400}
  height={400}
  margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
  <XAxis dataKey="name" />
  <Tooltip />
  {data.map(item => <rect
    className="bar"
    {...item} // width, height, x, y ...
  />)}
</Chart>

为了充分显示前端框架的特点,还以组件的形式添加了X轴、提示条等功能,还在Chart组件上展示了怎样将属性添加到元素上,最后通过ES6提供的解构语法将数据中的属性动态的加在了对应的<rect />元素上(其效果与x={item.x} y={item.y} width={item.width} height={item.height}一致)。

通过这样的改写,我们就将声明式和响应式的特点充分发挥,这也就意味着以下好处:

  • 使用JavaScript的全部能力

  • 更高的灵活性

  • 更可读、易懂的代码

  • 更好的性能(实时动态数据可视化展示等场景)

如何应对更复杂的场景

当然不是所有的情况都和最基本的柱状图一样简单,我们经常需要绘制更复杂的可视化图形,好在我们并不需要万丈高楼平地起,而是站在巨人的肩膀上更进一步。

复杂的场景之所以复杂,是因为我们无法将SVG图形简单的实现成目标图形,例如一个的折线图我们就需要用<path />元素“依次通过”各个坐标点完成绘制。在这个场景里,有两个比较明显的需求:

  1. 将数据的属性根据对应的比例转化成坐标点的属性

  2. 编写一些方法用于动态的生成<path />元素所需的路径属性d

如果这样基本的需求都要重新构思,那么工作量无疑是巨大的,好在我们可以站在“巨人”——D3.js的肩膀上,学习它是如何实现这些方法的,甚至可以直接使用。

import { lineTo, moveTo, closePath } from 'path';
import { linearScale } from 'scale';
const d = [moveTo(0, height)];
const xScale = linearScale([0, width], [minX, maxX]);
const yScale = linearScale([0, height], [minY, maxY]);
<Chart
  width={width}
  height={height}
>
  <path
    d={d.concat(
        data.map((item, index) => lineTo(xScale(item.x), yScale(item.y)))
        .concat([
          lineTo(item[item.length -1].x, height),
          moveTo(0, height),
          closePath()
        ])
      ).join(',')}
  />
</Chart>

以上代码只是一个示例,用于说明我们如何结合已有的工具更加轻松的解决问题。

生态继承

文中讲解的这套解决方案之所以可行,也是因为它可以很好的实现对过去生态圈中已有成果的继承,最大程度地复用各种轮子取长补短。

我们将这套方案归纳为以下几个步骤:

  1. 是否理解并掌握将要使用的框架的基本概念?如果没做到,先去掌握框架本身。

  2. 对于SVG各种图形元素的作用及属性是否了解?如果不了解,可以通过文档进行基本的认知。

  3. 对你需要处理的数据有可视化的思路吗?如果没思路,可以从庞大的社区中寻找灵感(例如D3.js的Gallery)。

  4. 实现过程中需要用到一些工具方法吗?如果需要并且自己实现有困难,可以参考D3.js的API及对应的实现方式。

  5. 需要复用或拓展?如需要,请充分发挥框架带来的组件化开发方式,并且细心的设计属性接口。

  6. 完成对应组件的开发。

本文内容整理自XSKY前端组组内技术分享,转载需著名出处。


aryu
2.8k 声望602 粉丝