(精)可视化组件开发的教训与技巧-D3版


presentation:
theme: blood.css
width: 800
height: 600
progress: true


@import “可视化组件开发的教训与技巧/custom.css”

可视化组件开发的教训与技巧

该PPT通过VSCode的Markdown Preview Enhanced扩展生成

教训类

数据驱动:没有弄清楚D3的坐标轴结构

这些没搞清楚,导致的结果就是设置样式不生效。

另外需要注意,D3画的坐标轴,会默认添加一些class,比如class=domain、class=tick;另外还用到了CSS的一个变量currentColor,比如stroke=currentColor等。

坐标轴结构

DOM class 预定义的style 名称 数量
g-path domain stroke=”currentColor” 轴线 1
g-g tick 刻度 多个
g-g-line stroke=”currentColor” 刻度线 多个
g-g-text fill=”currentColor” 刻度值 多个
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<svg width="500" height="300">
<g class="myYaxis" color="rgb(255, 0, 0)" transform="translate(50, 0)" fill="none" font-size="10" text-anchor="end">
<path class="domain" stroke="currentColor" d="M0.5,250.5V50.5" style=" stroke-width: 5;">
</path>
<g class="tick" color="rgb(0, 255, 0)" opacity="1" transform="translate(0,244.61405523443116)">
<line stroke="currentColor" x2="435" style=" opacity: 0.5; stroke-dasharray: 4, 4;"></line>
<text fill="currentColor" x="-10" dy="0.32em"
style=" opacity: 1; font-weight: bold; font-size: 15px;">100</text>
</g>
<g class="tick" opacity="1" transform="translate(0,215.55813580998242)">
<line stroke="currentColor" x2="435" style=" opacity: 0.5; stroke-dasharray: 4, 4;"></line>
<text fill="currentColor" x="-10" dy="0.32em"
style=" opacity: 1; font-weight: bold; font-size: 15px;">200</text>
</g>
</g>
</svg>

数据驱动:没有理解enter、update、exit的设计理念

这三个数据绑定的设计,会很大程度上影响我们的程序设计,必须要彻底摸透。

否则代码很容易就写成流水账形式的业务代码了,而不是一个具有设计的组件代码。

硬编码

有时候我们为了图省事,会写一些硬编码,这其实相当于欠债,到后面都是会还的。

硬编码会导致别人频繁找你修改,还会引发各种奇怪的问题,让你难以排查。

1
2
3
4
5
6
7
// 图标的圆圈部分
legendGroup.append('circle')
.attr('fill', this.option.series.line.style.fill)
// 硬编码导致无法调整图标大小
.attr('r', 10)
.attr('cx', lineWidth / 2)
.attr('cy', lineHeight / 2);

没有弄清楚元素的定位原理

1
2
3
4
5
6
<g style="font-size: 14pt;">
<path d="M 100 10 100 100" style="stroke: gray; fill: none;"/>
<text x="100" y="30" style="text-anchor: start">Start</text>
<text x="100" y="60" style="text-anchor: middle">Middle</text>
<text x="100" y="90" style="text-anchor: end">End</text>
</g>

文本对齐

没有提前把动画实现逻辑想清楚

大家看下这个效果,如果由你来实现,你会采用什么方案?

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
30
31
32
33
34
path
.transition()
// TODO:我是根据帧数来画图的,但是duration的参数可能并不是完整的帧数的整数倍,这就可能导致最后一帧的图没画上去,进而导致阴影缺一截
// .duration(state.option.play.intervalTime - 10)
.duration(duration)
.ease((t) => {
const currentX = xTweenScale(t);
const currentY = yTweenScale(t);
that._drawOneArea(name, data, currentX, currentY);

// 更新最新一个数据点的圆圈
if (isMax) {
that.pointer.attr('cx', currentX).attr('cy', currentY).style('display', 'block');
}

// 通过这个t,以及前后2个点的位置,计算阴影区域的path的d属性
return d3.easeLinear(t);

})
// 动画结束后,补上缺少的一帧
.on('end', () => {
// 如果不需要动画,就直接绘制最终的结果
const currentX = xTweenScale(1);
const currentY = yTweenScale(1);
that._drawOneArea(name, data, currentX, currentY);

// 更新最新一个数据点的圆圈
if (isMax) {
that.pointer.attr('cx', currentX).attr('cy', currentY).style('display', 'block');
}

})
.attr('stroke-dashoffset', 0);

这个也跟技术经验不足有关系,想到一点写一点,然后被卡主。

应该提前把动画方案确定下来,最好可以找个人讲一讲你的逻辑,帮助自己理清楚,也可以让别人帮助你及早发现问题。

没有理解帧数的重要性

帧数在前端中特别重要。很多动画都是基于帧数和浏览器渲染机制来设计的。

比如duration、比如ease函数、比如requestAnimationFrame等。

理解了帧数原理,就很容易将一些规律性的动画实现出来了。

没有给同名不同作用的元素设置标识符

我之前就遇到过一个问题,我程序里面执行了remove操作,误把其他功用的同名元素删除了,感觉莫名其妙,排查问题搞了半天。

可以通过不同的class名来区分,比如柱子左侧的排名icon和柱子右侧的公司logo,都是rect

没有注意页面元素初始化的顺序

1
2
3
4
5
this.axis.init(this.xScale, this.yScale);
this.label.init();
this.initSplitLine();
this.bar.init();
this.tooltip.init();

导致大元素把小元素遮住了,还以为没画出来。

滥用transform

坐标轴结构

有位置关联性的元素,尽量不要随意用transform

比如上面的效果,由于bar和splitLine的位置是具有关联性的(splitLine是根据最长的一根柱子来计算间隔宽度的),因此不建议对这2个元素设置transform属性,否则会导致二者的相对位置出现问题;如果必须设置,则二者的transform的值需要完全一致。

没有合理设计元素排列的结构

这个组件大家会如何设置页面元素结构?

坐标轴结构

rank图一行的元素的案例:将里面每个小元素的Y值单独设置了,导致重复代码很多。

我应该给每个柱子的g设置y,而不是给每个g内部的内容单独设置y

但是g本身不支持x、y,需要通过transform或者用子svg来处理

采用了low办法去计算文本的高宽

浏览器自身有API可以计算text的位置、高宽信息。

没有同步编写文档和注释

到后面我自己都不知道逻辑了,不用太久,隔一天就记不住的。然后自己从代码中去慢慢摸索,效率极低。

没有打造自己的发动机引擎-基础组件库

DynamicHistogram属于应用层
应该在这里面new其他的小组件,进行一些定制,然后形成业务组件。

折线图自定义坐标刻度数量,必须使用tickValues

因为我们的坐标轴采用的是scalePoint比例尺,这种比例尺是不支持通过axis.ticks()来设置刻度数量的,默认会把所有刻度都显示出来。

另外tickValues设置的数据,必须在你绘图的真实数据范围内,比如你的真实数据是2010年到2020年的,那么tickValues就不能设置为2000

UI稿的确认和还原

1、认可UI稿;如果不认可,就反馈给产品和UI,改到认可为止;否则起点低了,再怎么优化都没用

2、严格根据UI的标注进行还原,避免频繁返工

(误)不同SVG元素设置颜色的差异

fill:填充,理解为background-color
针对的是2维元素
注意:text也是二维元素,是有宽度的,因此也要用fill

stroke和stroke-width:描边,理解为border
针对的是1维元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function drawArc()
{
let arc = d3.arc()
.startAngle(0)
// 注意这个参数不是直接写角度数值,而是通过PI常量进行计算
// 周长=2*PI*R,因此PI=180°,即半圆
.endAngle(Math.PI)
.innerRadius(50)
.outerRadius(70)

d3.select('#arc')
.append('path')
.attr('d', arc)
// 圆弧是2维元素,因此用fill设置填充颜色
.attr('fill', (d, i) => {
return 'red'
})
// 将圆心位置移动到画布中间
.attr('transform', 'translate(250, 250)')
}
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
/**
* 画关系线条
*/
function drawRelationships(relationships)
{
let svg = getSVG();

svg.selectAll('line')
.data(relationships)
.enter()
.append('line')
.attr('x1', r => {
return nodeMap[r.from].x;
})
.attr('y1', r => {
return nodeMap[r.from].y;
})
.attr('x2', r => {
return nodeMap[r.to].x;
})
.attr('y2', r => {
return nodeMap[r.to].y;
})
// 直线是【一维】的东西,没有任何的【宽度】,所以fill在这里丝毫不适用
// 所以这里需要用到stroke、stroke-width
.attr('stroke', 'black')
.attr('stroke-width', '2px')
}

误用比例尺

这部分的内容比较多,D3提供了多种比例尺。

我们用得比较多的是scaleLinear和scaleBrand、scalePoint

引用传递导致原始数据被修改的问题

将数据回传给了回调函数;用户在回调函数中修改了这个数据;后续的程序又用到了这个数据作图。

data.pop();

未清理默认配置项

defaultOption设置了默认的label,导致页面上出现了多余的label文本

没有对传入的数据和配置项做初始校验

经常在绘图时报错,然后再去排查具体是什么数据问题。

应该一开始就做好校验,给出友好的人性化提示。

未考虑兼容性问题

主要的兼容性问题,一般出现在低版本的Android手机上,比如Android5.1,其内核是Chrome44版本。这个版本的Chrome是不支持ES6语法的(比如ES6的箭头函数,就是从45版本才开始支持的)。

以我们这个动态柱状图为例,编写完成后,就发现无法在Android5.1上使用,经过排查,有如下几个问题:

依赖的库d3-array中,有箭头函数

我们在webpack中配置babel时,一般都会默认只编译项目下的src目录的代码,即rules下针对js进行类似这样的配置:

1
2
3
4
5
{
test: /\.(js)$/,
include: path.resolve(__dirname, 'src'),
loader: 'babel-loader',
}

这样配置的话,node_modules下的依赖包,默认是不编译的。所以如果依赖的包中有ES6语法,最终编译出来的代码就会保留这些ES6语法,导致低版本浏览器报错。

解决方案就是把node_modules下的包,也加入编译选项:

1
2
3
4
5
6
7
8
9
{
test: /\.(js)$/,
exclude: {
test: /node_modules/,
// d3相关的目录,以及依赖的internmap|delaunator目录,都需要进行转换
not: [/(d3\-.*)$/, /(internmap|delaunator)/],
},
loader: 'babel-loader',
}

注意:include和exclude只能设置一个,如果2个都设置,后面的exclude是不生效的。

页面的HTML中,script标签用了type=”module”

之前加这个,主要是为了在html中直接引用JS的模块。

这会导致script标签的内容无法被正常识别,JS直接不执行了。这样页面没有任何报错,但是也不会有任何JS代码逻辑的效果。

解决方案:去掉script标签的type=”module”即可。

页面的HTML中,用了let、const等

会提示非严格模式无法使用这些块级作用域变量:

1
Uncaught SyntaxError: Block-scoped declarations (let, const, function, class) not yet supported outside strict mode

let是Chrome49才支持的,const则在Chrome21就支持了。

解决方案:加上”use strict”

建议:任何时候你编写JS,第一行都应该写上”use strict”,以避免一些低级错误。使用TypeScript也建议开启TS的严格模式。

面的HTML中,用了箭头函数

ES6的箭头函数,是从45版本才开始支持的。

解决方案:用传统的function替代箭头函数。

一些解决兼容性问题的工具

可以从这个网站,下载各个历史版本的Chrome浏览器进行调试:

https://www.portablesoft.org/subscribe/google-chrome/

也可以自己写个页面,测试当前的浏览器的版本信息,比如像这个:

https://t.zhouchangju.com/tool/version.html

技巧类

【重要】测试驱动:数据先行

每次写组件,一定要先把测试数据给准备好。

测试数据需要涵盖如下几种情况:

  • 正数
  • 负数
  • 0
  • 空值(null、undefined、’’)
  • 大数据量(比如高密度柱状图)
  • 小数据量(比如就一个数据,或者没数据)

这些测试数据,可以搞一个库,便于后面复用。

如果考虑得更细致一点,我们还可以约束组件的入参形式,这样可以通过程序自动注入各种数据进行测试,通过截屏工具自动截屏验证结果。

思考:组件验收的时候,应该测哪些内容(数据、兼容性、Network中各个请求的大小和数量…)?

生命周期管理

就像Vue框架中针对组件的处理一样,我们需要对组件的生命周期进行管理,方便应用方控制组件。

初始化-init

用户可以在初始化的时候做一些前置操作

销毁-destroy

去掉组件的DOM结构,以及销毁组件实例化的对象,便于用户重新创建组件。

(TODO)resize&zoomable

resize

resize是非常常见的操作,因此编写组件的时候也必须考虑这个功能。

以D3.js编写的组件为例,可以这样实现resize:

1、所有元素的宽度和高度基于一个变量进行计算(一般这个变量就是作图元素的外围DIV的大小)

2、每个组件里面,实现一个redraw函数,在每次resize的时候,重新绘制该元素

这是一个别人的demo:

https://bl.ocks.org/curran/3a68b0c81991e2e94b19

注意:resize后,可能会导致有的元素在对齐上出现问题,比如你是通过距离左上角的固定像素来定位元素的,那么当resize后,整体容器变大了,就可能导致无法居中对齐。解决这个问题的办法就是元素的默认对齐方式,都改为按照居中的位置进行设置

zoomable

https://observablehq.com/@engjptyo/zoomable-sunburst

(TODO)动画

要想实现好动画,必须了解D3和浏览器的动画设计和实现体系。

帧动画(Frame)

我们的智能短视频的合成、虚拟主播的动效,就是典型的帧动画应用案例。

补间动画(Tween)

在一定的时间内使 View 完成四种基本的动画:平移、缩放、透明度、旋转

画动态折线图

通过补间动画,实现一些连贯性很好的效果。

理解帧数插值器。

开闭原则

Software entities like classes,modules and functions should be open for extension but closed for modifications.

一个软件实体如类,模块和函数应该对扩展开放,对修改关闭。

软件实现应该对扩展开放,对修改关闭,其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化的。

开放配置和能力,隐藏实现细节。

扩展一份配置项优于修改具体的实现代码。

结合CSS三大精髓:定位(position)、移动(transform)、美化(style),这些都是需要开放出去的配置项。

一个原则:看得见的,都需要开放出去。

附:设计模式的六大原则(SOLID)

Single Responsibility Principle:单一职责原则
Open Closed Principle:开闭原则,六大原则的基础
Liskov Substitution Principle:里氏替换原则,对继承的限制,解决继承的一些弊端
Law of Demeter:迪米特法则,可以理解为中介,比如之前我们设计的视频模板的模板字符串如何自动获取数据源的这个,就是迪米特法则的应用案例(数据库就是数据源和模板之间的中介)
Interface Segregation Principle:接口隔离原则,将冗余的大接口拆分为细粒度的单一职责接口,微服务、微前端都是源于这个理念
Dependence Inversion Principle:依赖倒置原则,上层模块不应该依赖底层模块,它们都应该依赖于抽象。参考披萨店的比喻。

高内聚、低耦合

每个组件都是一个完全独立的class,可以自己运行,可以自己测试。

用写后端的思维去写前端组件元素。

class、main方法、test方法等

应该抽取接口类基类,不同组件进行不同的实现。

如何解决全局option的耦合问题:将配置以构造函数参数的方式传入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Bar{
constructor(DynamicHistogram dynamicHistogram, BarOption option){
super();
this.dynamicHistogram = dynamicHistogram;
this.option = option;
this.init(this.option);
}

init(option) {
// Initialize bars with the option.
}

update() {
this.dynamicHistogram.doSomething();
}
}

这种情况下,TypeScript的优势就体现出来了,可以指明参数的对象类型,极大增强代码可读性。

观察者模式

注册、分发、消息通知机制

总线(DynamicHistogram)管理消息+分发到各个组件(Bar、Axis、Label等)进行差异化的交互和视觉呈现

init+update

init展示初始样式,update实现动画效果。

class优于函数

将可用独立抽取的组件,都封装为class,然后通过new创建实例,这样页面上有多个元素的时候,方便扩展和操控、差异化显示。

日期与坐标轴的处理

善用DIV

比如页面上固定位置的各种文本,都可以用DIV来实现。

这部分DIV的样式,可以采用一个通用的方法来处理。

父相子绝定位

针对页面上的DIV元素,可以给整个父容器设置为position:relative,然后针对里面的子元素,设置为position:absolute,这样就可以自由定位每个子元素,实现高度个性化。

常用的一些计算逻辑

icon的cx、cy定位

文本定位

文本对齐方式

项目ID、样式前缀

option.prefix

避免和应用方的样式名冲突。

使用TypeScript

解决配置项undefined的问题

鼠标事件穿透

pointer-events

其他

D3Charts的文档如何补充

http://172.20.1.247/gitlab/datav/chart-config-doc