可视化图标库如何选择?
鉴于可视化技术已经相对成熟了,市面上的开源可视化图表库也是繁华缭乱的,那我们应该怎么选择呢?
下面是市面上常见的一些图表库:
1.ECharts.js
Echarts.js文档界面相对友好,支持 SVG 、Canvas 双擎渲染,图表示例也比较全,而且文档是支持中英文的,使用的人也比较多,所以相关资料也很丰富。我们的项目中选择的是ECharts.js
2.Charts.js
英文原版:https://www.chartjs.org 中文版:https://chartjs.bootcss.com
文档整体结构和界面都是非常友好的,也拥有相当数量的基础内置图表,对于常规开发来说,这个库也是比较不错的选择,Api 学习难度低于 ECharts
但是如果有复杂图表比如仪表盘或者地图相关渲染的时候,这个库就不支持了,不过也不影响这个库的好用,大家可以根据自己的业务需求来选择,混合选择多款图表库也是可行的
3.Antv
Antv的产品系列划分很多,根据不同的图表类型也分了很多不同的产品线
- G2(可视化图形)、G2Plot(通用图表库)
 - S2(多维可视分析表格)
 - G6(关系数据图分析工具)
 - X6(图编辑引擎)
 - L7(地理空间数据可视化)
 - F2(专注移动端的可视化解决方案)
 
文档非常完善,但是服务器部署以后不太稳定,会发生一些未知的bug,导致图像无法渲然,所以不选择。
4.D3.js
纯英文文档,相信这一条或许会劝退很多人,GitHub 有国内开发者翻译的 Api 中文手册,没有内置图表
但是其定义的绘图开发框架可以让你用 Api 的方式来进行 SVG 绘图,这一点比使用原生 SVG 要好很多,如果大家有需求要进行自定义绘制的,可以考虑使用 D3.js。
一句话:很底层,但是足够灵活,可满足绝大部分图表内容的绘制。
总结
综合选择稳定性,上手难度,对各种图表支持的友好性,我们选择ECharts.js来开发。
组件如何设计?
为什么要用组件抽离
- 如果我们直接上手画图的话,感觉上是非常的快速,每种图标的配置都来一份,每个框框标题都用 Div 一把梭,完全没有心智负担。但是来第二套、第三套、第四套图的时候,就显得非常的冗余。
 - 用过 ECharts 的小伙伴肯定知道,每一个图表都需要单独实例化一个对象用来作为图表管理和绘制,而大屏项目往往会有很多个这样的图表,所以用组件的方式来自动生成和管理这样的 echarts 对象,我们只需要通过传入 options 配置即可完成图表的绘制。
 - 其次,我们还可以通过组件来统一管理当屏幕变化时造成的图表 resize 的情况,封装了组件之后,都可以在组件内部进行统一的事件监听
 
组件设计
下来看一下我们的大屏效果:
上面有大屏幕、大标题、每个图表的边框、每个图表的文字说明、绘图引擎、屏幕缩放的时候画面跟着等比缩放等等。于是我们对现有的可视化大屏项目做了这样几个拆分:
:::tips
- Screen - 大屏
 - Screen Title - 大屏标题
 - Card - 数据内容卡片
 - Card Title - 数据卡片标题
 - Swiper - 大屏内容滚动
 - Dance Number - 跳动的数字
 - ECharts - 图表 :::
 
通过这样拆解,将 UI 视觉稿的内容细分为这几大模块并分别管理和实现,虽然不多,但足以应对目前的需求内容了,灵活度也足够,可以随视觉稿调整随时替换。
上面是我们对几大模块的划分,那么ECharts图表组件我们如何设计呢?
ECharts图表组件设计
每一个图表都需要单独实例化一个对象用来作为图表管理和绘制,而大屏项目往往会有很多个这样的图表,所以用组件的方式来自动生成和管理这样的 echarts 对象,我们只需要通过传入options即可完成图表的绘制。由此我们确认,在Echarts组件中需要做到两点:
- 实例化图表组件
 - 根据传入的options渲然不同的图表,其中options包含data跟opts,即渲染数据和配置
 
组件设计代码如下:
<template><div :id="uid" class="echart"></div></template><script setup lang="ts">import * as echarts from "echarts";import { randomString } from "../../utils/string.util";import { nextTick, onMounted, watch } from "vue";import { EventBusUtils } from "../../utils/eventbus.util";const uid = randomString();interface Props {opts: any;geoJson?: any;}const props = withDefaults(defineProps<Props>(), {opts: Object.create(null),geoJson: null,});let chartInstance: echarts.ECharts | null = null;onMounted(() => {// 初始化echarts实例const div = document.getElementById(uid)!;chartInstance = echarts.init(div, undefined, {renderer: "canvas",});EventBusUtils.addObserve("WindowResize", () => {chartInstance?.resize();});if (props.geoJson) {echarts.registerMap("ZheJiang", props.geoJson);}chartInstance.setOption(props.opts);nextTick(() => {chartInstance?.resize();});});const updateOptions = (opts: any) => {if (!chartInstance) {return;}chartInstance.setOption(opts);};watch(() => props.opts,() => {// 先 resize,再 setOptions 否则动画会丢失chartInstance?.resize();chartInstance?.setOption(props.opts);});defineExpose({chartInstance,updateOptions,});</script><style scoped>.echart {width: 100%;height: 100%;}</style>
抽象options
常见的图表有柱状图、折线图、面积图、饼图、环形图
我们要做的就是按图的类型、坐标轴、颜色这几个维度来进行考虑
我们依据设计稿的风格,对 xAxis、yAxis、color 这三个较为通用的配置进行了统一管理,设置一套默认的配置,用于支持常规的柱状图及折线图。
再根据 Options 的使用习惯,按照图表基本配置和数据源配置做工厂方法动态生成
比如Bar如下代码所示:
export enum BarDirectionEnum {Horizontal = "Horizontal",Vertical = "Vertical",}/*** 快捷创建柱状图配置,需自行根据需求设置 category* @param colors 普通颜色或渐变色* @param categorys 坐标轴的类别数据源 可选* @param direction 垂直或水平 默认 垂直* @returns*/export function createBarOpts(colors: BBColors[], categorys?: string[], direction = BarDirectionEnum.Vertical): any {return {grid: {top: 30,bottom: 30,left: 40},color: initColorOpts(colors, direction),xAxis: init_xAxis(direction, categorys),yAxis: init_yAxis(direction, categorys),};}
同样的,其他风格的我们也可以用统一的api风格扩展为createLineOpts、createLineSeriesItem、createPieOpts、createPieSeriesItem。
封装好之后的实际开发场景代码就会变成这样:
const opts = computed(() => {const categorys: string[] = [];const values: string[] = [];props.shiftList?.forEach((item) => {categorys.push(DateUtils.string2string(item.dateFormat, "YYYY-MM-DD", "M.DD"));values.push(item.count);});const barOpts = createBarOpts([createGradientColors(["#04FEAC", "#1EE554"])], categorys);barOpts.series = [createBarSeriesItem(values)];return barOpts;});
传入echarts组件
<zt-echart :opts="opts"></zt-echart>
这样一下子节省了好多代码,业务上逻辑就会清晰不少,当然,barOpts 实际上也是一个常规的对象,如果有定制的修改配置的情况,也是完全支持够用的。
基于以上的设计,我们就实现了图表的可配置化操作,我们要做的仅仅是知道某种类型的图表需要什么样的配置数据即可。
大屏缩放问题解决?
大屏缩放问题,简单来说就收我们的屏幕内容要能随着浏览器缩放而自适应(按照比例)。
对于CSS 的值与单位问题不了解的,可以去看看MDN上解释的。我们这里采用的是vw(视窗宽度的 1%)来做自适应。
课外阅读:vh,vw参考
下面是适配方案
安装 postcss-px-to-viewport 插件
pnpm add postcss-px-to-viewport -D
配置 postcss-px-to-viewport config
import { defineConfig } from "vite";import pxtovw from "postcss-px-to-viewport";// postcss-px-to-viewport configconst pxtovw_config = pxtovw({unitToConvert: "px", // 要转化的单位viewportWidth: 1920, // UI设计稿的宽度unitPrecision: 6, // 转换后的精度,即小数点位数propList: ["*"], // 指定转换的css属性的单位,*代表全部css属性的单位都进行转换viewportUnit: "vw", // 指定需要转换成的视窗单位,默认vwfontViewportUnit: "vw", // 指定字体需要转换成的视窗单位,默认vwselectorBlackList: ["ignore-"], // 指定不转换为视窗单位的类名,minPixelValue: 1, // 默认值1,小于或等于1px则不进行转换mediaQuery: false, // 是否在媒体查询的css代码中也进行转换,默认falsereplace: true, // 是否转换后直接更换属性值});// https://vitejs.dev/config/export default defineConfig({base: process.env.NODE_ENV === "production" ? "/vue-datav/" : "/",css: {postcss: {plugins: [pxtovw_config],}},});
上面的配置在直接写css的时候非常完美,但是在ts中使用代码动态添加css的时候就无法设置了,所以我们需要手动进行转化一下,代码如下:
export function px2vw(px: number, root: number = 1920, fixed = 6) {const res = (px / root) * 100;return `${res.toFixed(fixed)}vw`;}
这样就可以把px转化成为vw,比如我们的css中可以这样写:
<template><div class="date_stamp">{{ dateStamp }}</div></template><style scoped lang="less">.date_stamp {height: 66px; // 这里的单位可以按照设计师给的UI搞写font-size: 22px;font-weight: normal;color: #11ebd7;line-height: 66px;position: absolute;top: 0;right: 0;}</style>
效果如下图所示:
全局监听 window.resize 事件
最后就是来解决图表自身的resize,我们建立一个消息中心,通过事件发布的方式通知每个图表进行自身的 resize,建议 window.resize 做 throttle 处理,提高性能。
eventbus添加
export const EventBusUtils = {_map: new Map<string, Function[]>(),addObserve(key: string, action: Function) {let actions = this._map.get(key);if (actions) {actions.push(action);} else {actions = [action];}this._map.set(key, actions);},removeObserve(key: string) {if (this._map.has(key)) {this._map.delete(key);}},post(key: string, params?: any) {if (this._map.has(key)) {let actions = this._map.get(key);actions?.forEach((action) => {action(params);});}},};
在入口文件处添加resize事件
let realtimeInterval: number | undefined = 0;onMounted(() => {realtimeInterval = window.setInterval(() => {accumulatedSaleTicket.value += Math.floor(Math.random() * 1000);}, 5 * 1000);});onUnmounted(() => {if (realtimeInterval) window.clearInterval(realtimeInterval);});const onResize = throttle(() => {EventBusUtils.post("WindowResize");}, 500);window.onresize = onResize;
在图表中,监听resize事件
onMounted(() => {const div = document.getElementById(uid)!;chartInstance = echarts.init(div, undefined, {renderer: "canvas",});EventBusUtils.addObserve("WindowResize", () => {chartInstance?.resize();});nextTick(() => {chartInstance?.resize();});});watch(() => props.opts,() => {// 先 resize,再 setOptions 否则动画会丢失chartInstance?.resize();chartInstance?.setOption(props.opts);});
使用技术栈
:::info
- vue3
 - vite
 - typescript
 - pnpm+workspace
 - echarts
 - postcss-px-to-viewport
:::
Demo
预览地址:https://ztstory.github.io/vue-datav/#/
源码地址:https://github.com/xiumubai/datav-vue
视频地址:https://www.bilibili.com/video/BV11v4y1U7cc/ 
