作者:千梦凯
前言
古茗加盟商在经营店铺的过程中,需要一款可视化的工具来将门店的经营情况。通过图表将数据呈现得更加直观、易懂;并且可以根据数据趋势,分析门店经营情况。
目前古茗主要通过钉钉小程序来辅助加盟商经营,并且开发使用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相关依赖
PowerShell
npm install @antv/f2 --save
npm install @antv/f2-react --save
在index文件中复制F2官网Demo
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绘图上下文,调整相关代码
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宽高
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对象
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方案设置
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元素设置高分辨率宽高来解决
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文件
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差异
图表虽然可以正常展示了,不过并没有坐标信息。当我们尝试给图标添加坐标信息时,发现页面代码报错会报错
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差异。
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容器组件事件时,触发图表组件事件
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获取当前的选中的元素信息,可以拿到对应的位置信息与数据信息。从而可以自定义显示对应文本
ini
// 自定义配置
<Tooltip custom ref={tooltipRef} triggerOn="click" onChange={handleTooltipChange} />
View标签位置获取
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方法
arduino
show(point, _ev?) {
const { props } = this;
const { chart } = props;
// 该代码获取坐标相关位置
const snapRecords = chart.getSnapRecords(point, true); // 超出边界会自动调整
this.showSnapRecords(snapRecords);
}
Chart的getSnapRecords方法
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
arduino
getSnapRecords(point, inCoordRange?): any[] {
// 该处理没有对value等于0的判断,导致没有返回坐标轴相关信息
if (!value) {
return rst;
}
}
解决方案,重写Geometry的getSnapRecords方法
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()获取了对应的记录点
折线图没有处理坐标反转时的坐标
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方法
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前端团队」,获取更多干货实践,欢迎交流分享。