当卧龙遇上凤雏:钉钉小程序+F2图表库的踩坑指南

作者:千梦凯

前言

古茗加盟商在经营店铺的过程中,需要一款可视化的工具来将门店的经营情况。通过图表将数据呈现得更加直观、易懂;并且可以根据数据趋势,分析门店经营情况。

目前古茗主要通过钉钉小程序来辅助加盟商经营,并且开发使用Taro+React的方式进行开发小程序,本文将介绍如何使用Taro在钉钉小程序中实现数据图表化展示。

可视化图表库技术选型

目前市面上比较流行的可视化工具库有

  • ECharts:一个基于 JavaScript 的开源可视化图表库官网地址
  • Chart.js:基于HTML5的简单易用的JavaScript图表库 官方网站
  • D3.js:一个数据驱动的可视化库,通过使用HTML、SVG和CSS来操作数据,创造出交互式和动态的图表官方网站
  • AntV:蚂蚁企业级数据可视化解决方案官网地址

在wbe端我们使用任何一个库都可以实现可视化展示。不过针对钉钉小程序,我们要根据一下几个关键字来进行筛选使用的图表库(移动端小程序手势交互

  • Echarts上面有丰富的图表库,但是代码体积过大。全量代码将近1000k,选择常用功能也要500k左右,在小程序主包只有2m的情况下还是占用了太多的内容。
  • Chart.js 依赖于DOM API,而钉钉小程序的环境并不提供完整的DOM API。所以无法直接使用
  • D3.js是通过使用SVG来支持图表,但是目前小程序不支持使用SVG,所以无法在小程序中使用
  • Antv/F2 F2 是一个专注于移动端,面向常规统计图表,开箱即用的可视化引擎,完美支持 H5 环境同时兼容多种环境(Node, 小程序)

对比之下,F2图表方案更适合在小程序中使用。

由于我们在接入图表库时,4.x版本尚未更新,所以第一版本使用了3.x版本。在后续F2的更新迭代中,由于图表库是在主包中展示,考虑到文件大小的原因,最终使用了4.x版本。

如果是初次接入,推荐直接使用最新版本,功能更加强大。

在Taro+React中使用F2 4.x

在查看F2的文档时,发现文档中有React和小程序的接入,并没有教程说明如何使用Taro的接入。既然可以在小程序和React工程中使用,理论上在Taro+React工程中也可以使用。说干就干,让我们先跟着文档上面的React教程进行接入。

创建Taro+React工程

根据Taro文档,创建React工程,并且新增钉钉小程序编译选项文档教程

安装F2相关依赖

1
2
PowerShell复制代码npm install @antv/f2 --save
npm install @antv/f2-react --save

在index文件中复制F2官网Demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typescript复制代码import Canvas from '@antv/f2-react';
import { Chart, Interval } from '@antv/f2';

const data = [
{ genre: 'Sports', sold: 275 },
{ genre: 'Strategy', sold: 115 },
{ genre: 'Action', sold: 120 },
{ genre: 'Shooter', sold: 350 },
{ genre: 'Other', sold: 150 },
];

export default () => <Canvas>
<Chart data={data}>
<Interval x='genre' y='sold' />
</Chart>
</Canvas>

执行代码

通过上述代码,执行后,钉钉小程序控制台报错
控制台错误信息

创建Canvas绘图上下文

查看错误代码,发现错误文件为@antv/f2-react报错。通过查看代码,发现钉钉小程序是通过调用dd.createCanvasContext(canvasId) 创建canvas绘图上下文,调整相关代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typescript复制代码
class ReactCanvas extends React.Component<CanvasProps> {
// 81行
getProps = () => {
const { canvasRef, props } = this;
// 删除改代码 start
// const canvasEl = canvasRef.current;
// const context = canvasEl.getContext('2d');
// 删除结束 end
// context根据canvasId获取
const context = Taro.createCanvasContext(props.id ?? 'canvasId');
};
render: () => {
const { props } = this;

return React.createElement(TaroCanvas,
// 添加canvas属性Id,获取上下文
id: props.id ?? 'f2Canvas',
});
}
}

调整代码后,控制台不报错了,并且也渲染出来图表的,不过图表渲染的有点奇怪,只有左上角一点点图形

通过查看F2源码,发现在初始化图表时,需要获取Canvas的宽高。如果外部没有传入宽高,代码中通过DOM API获取元素宽高,但是小程序不支持该方式,所以显示图形异常。以下为相关代码

获取Canvas宽高

1
2
3
4
5
6
7
8
9
10
11
scala复制代码class Canvas extends EventEmit {

_initCanvas() {
// 获取canvas的宽高,对于小程序,如果外部不传入,则宽高为0
const width = this.get('width') || getWidth(canvas) || canvas.width;

const height = this.get('height') || getHeight(canvas) || canvas.height;
}
}

export default Canvas;

初始化设置canvas宽高

既然无法默认获取元素宽高,那我们可以使用小程序提供获取元素宽高的方式,来手动的获取元素的宽高,并且赋值给Props对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
scala复制代码// 调整f2-canvas相关代码
class ReactCanvas extends React.Component<CanvasProps> {

renderOrUpdateCanvas = async (type: 'init' | 'update') => {
// 接收一个id参数,并且通过小程序API获取元素宽高
const canvasId = props.id ?? 'f2Canvas'

// 伪代码,获取元素的宽高
const { width, height } = await getWidthAndHeight(canvasId)

return {
width,
height,
};
};

componentDidMount() {
// 元素可能没有渲染出来,等下一帧执行
nextTick(() => {
this.renderOrUpdateCanvas("init")
})
}
}

设置宽高后,柱状图可以正常显示

虽然柱状图正常显示出来了,不过图表很模糊,像是带了老花镜看似的。通过查看F2文档,发现可以通过配置pixelRatio来设置图表清晰度。并且钉钉小程序可以通过getSystemInfoSync获取设备的分辨率

pixelRatio方案设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
scala复制代码// 调整f2-canvas相关代码
class ReactCanvas extends React.Component<CanvasProps> {

pixelRatio: number;

renderOrUpdateCanvas = async (type: 'init' | 'update') => {
const config = {
// 已经有高清方案,这里使用设备分辨率
pixelRatio: this.pixelRatio,
...props,
};
};

componentDidMount() {
// 获取设备的分辨率
this.pixelRatio = Taro.getSystemInfoSync().pixelRatio
// 元素可能没有渲染出来,等下一帧执行
nextTick(() => {
this.renderOrUpdateCanvas("init")
})
}
}

设置完成之后,查看柱状图,直接不显示数据了。

通过查找钉钉小程序API,没有找到任何关于Canvas精确度的问题。不过最终通过查看钉钉小程序老大哥支付宝小程序的开发文档,发现了相关内容 支付宝小程序文档-canvas画布问题,通过给Canvas元素设置高分辨率宽高来解决

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
35
36
37
38
39
40
kotlin复制代码// 调整f2-canvas相关代码
class ReactCanvas extends React.Component<CanvasProps> {
pixelRatio: number;
canvasIsInit: boolean;

// 保存变量值
state: Readonly<{ width: number; height: number }>;

renderCanvas = async () => {
return {
// 已经有高清方案,这里默认用1
pixelRatio: this.pixelRatio,
};
};

componentDidMount() {
this.pixelRatio = Taro.getSystemInfoSync().pixelRatio

nextTick(() => {
getWidthAndHeight(this.props.id ?? 'f2Canvas').then(res => {
// 设置高精度宽高
this.setState({
width: res.width * this.pixelRatio,
height: res.height * this.pixelRatio,
})
})
})
}

render() {
const { props } = this;

return React.createElement(TaroCanvas, {
id: props.id ?? 'f2Canvas',
// 设置Canvas的宽高,用于高清展示图表
width: this.state.width || '100%',
height: this.state.height || '100%',
});
}
}

设置完成之后,图表就可以高清展示了

notice:设置Canvas宽高后,TS会报错,width属性不存在。是因为Taro中没有定义Canvas的width和height属性。可以手动添加一下ts文件

1
2
3
4
5
6
7
typescript复制代码declare module '@tarojs/components' {
export * from '@tarojs/components/types/index';

import { CanvasProps } from '@tarojs/components/types/Canvas';

export const Canvas: ComponentType<CanvasProps & { width?: number | string; height?: number | string }>;
}

抹平context差异

图表虽然可以正常展示了,不过并没有坐标信息。当我们尝试给图标添加坐标信息时,发现页面代码报错会报错

1
2
3
4
5
6
typescript复制代码    <F2Canvas id='wrap'>
<Chart data={data}>
<Interval x='genre' y='sold' />
<Axis field='genre' />
</Chart>
</F2Canvas>

通过查看在小程序中使用F2相关文档,发现F2 是基于 CanvasRenderingContext2D 的标准接口绘制的,但是小程序中给的 context 对象不是标准的 CanvasRenderingContext2D , 所以需要将context对象进行封装兼容处理,详情可见: github.com/antvis/f2-c…, 其他小程序也可以按同样的思路封装。继续修改相关代码,抹平小程序context差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
scala复制代码import { my } from '@antv/f2-context'
class ReactCanvas extends React.Component<CanvasProps> {

renderCanvas = async () => {
// 抹平context实例,引用f2-context文件
const context = my(Taro.createCanvasContext(canvasId));
return {
// 上下文
context,
};
};

componentDidUpdate() {
this.renderCanvas()
}
}

当我们完成所有操作后,图表和坐标信息就可以完整的展示出来了

事件传递

图表已经可以正常展示,不过当我们使用Tooltip组件时,我们所有的事件都没有作用。

通过查看f-my代码时,我们需要当触发Canvas容器组件事件时,触发图表组件事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
typescript复制代码
function wrapEvent(e) {
if (e && !e.preventDefault) {
e.preventDefault = function () {};
}
return e;
}

class ReactCanvas extends React.Component<CanvasProps> {
canvasEl;
handleTouchStart = (e) => {
const canvasEl = this.canvasEl;
if (!canvasEl) {
return;
}
canvasEl.dispatchEvent('touchstart', wrapEvent(e));
};
render() {
return React.createElement(TaroCanvas, {
onTouchStart: this.handleTouchStart,
});
}
}

事件传递后,可以正常显示文案提示

小结

本章节在Taro+React中接入F2的过程中遇到了不少问题。通过查看React与小程序的接入方式,一步一步的解决下面的问题

  • 在小程序中Canvas的上下文获取方式和web不一致
  • 每次执行代码时需要手动获取Canvas的宽高(无法通过DOM API获取)
  • 小程序模糊问题(pixelRatio的设置)
  • 小程序中Canvas的context不是标准的CanvasRenderingContext2D对象,需要对齐添加补丁。目前查询支付宝小程序文档,发现最新版本已经调整为标准对象了,不过钉钉小程序目前还不支持
  • 小程序事件需要显式定义,并且传递给图表库

目前已经有人封装好了对应的接入代码,我们可以直接在github中查看使用
Taro+React+F2

实战使用

当我们完成上述代码后,就可以正常的根据F2的官网示例在钉钉小程序中使用图表了。不过部分功能需要额外开发

自定义Tooltip

目前F2中默认的Tooltip提示都是在图表顶部显示,并且展示上面只能设置部分属性,不太满足这边的UI规范。好在Tooltip提供了自定义实现的方式,让我们可以自定义显示Tooltip提示。目前古茗通过使用View标签,并且绝对定位的方式来显示对应的文本信息

自定义配置方式

通过设置属性custom,F2不会显示默认Tooltip。通过onChange获取当前的选中的元素信息,可以拿到对应的位置信息与数据信息。从而可以自定义显示对应文本

1
2
ini复制代码// 自定义配置 
<Tooltip custom ref={tooltipRef} triggerOn="click" onChange={handleTooltipChange} />

View标签位置获取

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
ini复制代码import { View } from '@tarojs/components';
import { nextTick } from '@tarojs/taro';
import classNames from 'classnames';
import { Ref, forwardRef, useImperativeHandle, useRef, useState } from 'react';
import { CustomTooltipRef, TooltipChangeEvent } from './types';
import { PREFIX, getEleClientRect } from '../../../../utils';
import './index.less';

/**
样式设置
.custom-tooltip {
position: absolute;
z-index: 2;
display: none;
padding: 16px 20px;
color: #ffffff;
font-weight: 400;
background: rgba(12, 12, 12, 0.7);
border-radius: 4px;
transform: translateY(50%);
pointer-events: none;
}

*/

let num = 0;
const cls = `custom-tooltip`;
export const CustomTooltip = forwardRef(
<T extends {}>(
props: {
children: React.ReactNode;
placement?: 'center';
},
ref?: Ref<CustomTooltipRef>
) => {
const { children, placement } = props;
const customTooltipRef = useRef<HTMLDivElement>(null);
const [classNum] = useState(num++);

const handleTooltipChange = (_items: Array<TooltipChangeEvent<T> & T>) => {
// 优先取第一条数据
const [{ x, y, yMax }] = _items;
const otherY = _items?.[1]?.y;
let yTop = y || otherY;
/* 在图片中间位置显示 */
if (placement === 'center') {
yTop = (yMax || _items?.[1]?.yMax || y) / 2;
}
// 处理 props 并更新状态
// 更新 Tooltip 样式
const elStyle = customTooltipRef.current?.style;
if (elStyle) {
elStyle.left = '0';
elStyle.top = String(yTop);
elStyle.visibility = 'hidden';
elStyle.display = 'block';
nextTick(() => {
getEleClientRect(`.${cls}-${classNum}`).then((res) => {
elStyle.left = String(x > res.width ? x - res.width : x);
elStyle.visibility = 'visible';
});
});
}
};

const hide = () => {
const elStyle = customTooltipRef.current?.style;
if (elStyle) {
elStyle.display = 'none';
}
};

useImperativeHandle(ref, () => ({
handleTooltipChange,
hide,
}));

return (
<View ref={customTooltipRef} className={classNames(cls, `${cls}-${classNum}`)}>
{children}
</View>
);
}
);

实现效果

使用过程中存在的“坑”

示例

横坐标为0,无法触发Tooltip事件

当Axis坐标轴的值为0时,Tooltip点击事件不能点击执行

折线图反转后表现不一致

折线图反转后,空值直接链接了。没有截断处理

解决方案

一般上解决三方库中的问题

  • 对于不在维护的库,通过patch的方式进行处理
  • 升级库版本解决
  • 对于class组件,可以通过本地覆盖式更新代码

目前F2上面所有的组件都是class组件,所以可以通过继承或者修改原型链的方式来解决上述相关问题。
因为上面两个问题属于明显的bug,在我们这边采用修改原型链上面的方法来解决bug。

横坐标为0问题分析

因为是Tooltip的show方法没有执行,通过寻找代码,找到最终原因为判断date值时,没有处理0导致的。5.x已优化该问题

Tooltip中withTooltip的show方法

1
2
3
4
5
6
7
8
arduino复制代码  show(point, _ev?) {
const { props } = this;
const { chart } = props;
// 该代码获取坐标相关位置
const snapRecords = chart.getSnapRecords(point, true); // 超出边界会自动调整

this.showSnapRecords(snapRecords);
}

Chart的getSnapRecords方法

1
2
3
4
5
6
kotlin复制代码  getSnapRecords(point, inCoordRange?) {
const geometrys = this.getGeometrys();
if (!geometrys.length) return;
// geometrys[0]为点击的相关线Line
return geometrys[0].getSnapRecords(point, inCoordRange);
}

Line的getSnapRecords方法继承Geometry中的getSnapRecords

1
2
3
4
5
6
arduino复制代码  getSnapRecords(point, inCoordRange?): any[] {
// 该处理没有对value等于0的判断,导致没有返回坐标轴相关信息
if (!value) {
return rst;
}
}
解决方案,重写Geometry的getSnapRecords方法
1
2
3
4
5
6
7
8
9
10
11
javascript复制代码const resetGeometryGetSnapRecords = () => {
Geometry.prototype.getSnapRecords = function (point, inCoordRange?) {
// 省略代码
const value = this._getXSnap(invertPoint.x);
// 放过value为0的场景
if (!value && value !== 0) {
return rst;
}
return rst;
}
}

折线图反转后表现不一致

该问题为折线图展示的线不一致问题。第一个图为两条线,第二个图为一条线。查看Line相关代码,发现折线图在render的时候通过this.mapping()获取了对应的记录点

折线图没有处理坐标反转时的坐标

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
javascript复制代码import { jsx } from '../../jsx';
import { isArray } from '@antv/util';
import Geometry from '../geometry';
import { LineProps } from './types';

export default (View) => {
return class Line extends Geometry<LineProps> {

splitNulls(points, connectNulls) {
// 该方法只是判断了y轴是否为空,但是坐标轴反转的时候需要判断x轴是否为空
for (let i = 0, len = points.length; i < len; i++) {
const point = points[i];
const { y } = point;
if (isArray(y)) {
if (isNaN(y[0])) {
if (tmpPoints.length) {
result.push(tmpPoints);
tmpPoints = [];
}
continue;
}
tmpPoints.push(point);
continue;
}
if (isNaN(y)) {
if (tmpPoints.length) {
result.push(tmpPoints);
tmpPoints = [];
}
continue;
}
tmpPoints.push(point);
}
if (tmpPoints.length) {
result.push(tmpPoints);
}
return result;
}

mapping() {

return records.map((record) => {
// 获取坐标点位
const splitPoints = this.splitNulls(points, connectNulls);
});
}

render() {
// 获取点位信息
const records = this.mapping();
// 省略其他代码...
return <View {...props} coord={coord} records={records} clip={clip} />;
}
};
};
解决方案,重写Line的splitNulls方法
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
ini复制代码  Line.prototype.splitNulls = function (points, connectNulls) {
const result = [];
let tmpPoints = [];
for (let i = 0, len = points.length; i < len; i++) {
const point = points[i];
const { x, y } = point;
/* start 打补丁,处理坐标轴转换引起折线渲染链接问题 */
if (this.props.coord.transposed) {
if (Array.isArray(x)) {
if (isNaN(x[0])) {
if (tmpPoints.length) {
result.push(tmpPoints);
tmpPoints = [];
}
continue;
}
tmpPoints.push(point);
continue;
}
if (isNaN(x)) {
if (tmpPoints.length) {
result.push(tmpPoints);
tmpPoints = [];
}
continue;
}
}
/* end 打补丁,处理坐标轴转换引起折线渲染链接问题 */
}
return result;
};

总结

以上我们通过分析世面上的图表库,选择了在钉钉小程序中使用F2图表库进行开发。因为小程序不支持DOM相关API和Canvas不是标准的CanvasRenderingContext2D对象,接入过程中踩了不少的坑,对新人不太友好。不过最后还是根据官方的接入文档完成了小程序的接入。强烈建议F2官网可以在官网中加入Taro的接入,降低使用门槛

小茗推荐

最后

关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

0%