图表组件响应式探索
# POM BI 项目图表组件响应式探索(VueGridLayout、Echarts)
# 响应式布局常规解决方案
# px2rem px2vw
对于响应式布局,最容易联想到的就是用postcss等工具把css中的px转换rem或者vw,然后根据浏览器视窗的大小,字体大小也会响应式变化。(使用rem时需要根据窗口大小变化不断调整根节点的font-size
)
但是这两种方案有个缺点,就是它只能针对生成的css样式去改变css的值,对于一些引入进来的组件生成的行内样式,是无法生效的。就比如element-plus
,很多组件的width属性,如果你直接传数字的话,它会直接转成px放到生成组件的行内样式上,它无法被插件转换单位。
el-avatar的size,想要自定义大小,只能传number,number又被转成px:
同样的 BI 项目还用到 vue-grid-layout
和echarts
这两个组件,默认单位其实都是px,无法更改。
vue-grid-layout
echarts
# VueGridLayout 响应式组件封装
这个组件的解决方案其实很简单:它的大小是由这几个属性决定的:
- 宽度是完全由容器的宽度决定,只需要用css修改宽度即可
- 高度由rowHeight和margin两个值,加上组件layout共同决定
这个组件会根据rowHeight和margin的值变化,自动调整页面元素的高度。所以只要在浏览器窗口大小变化的时候,重新计算这两个值就行。
这里可以使用vueuse
库中的useWindowSize,快速拿到浏览器窗口大小。
用vue的计算属性,根据窗口大小计算rowHeight和margin,然后组件接收到的值就会更新:
响应式组件中计算出的值:
GridLayoutDynamic.vue
<template>
<grid-layout :row-height="rowHeightDynamic" :margin="marginDynamic">
<slot></slot>
</grid-layout>
</template>
<script setup>
import { computed } from 'vue';
import { useWindowSize } from '@vueuse/core';
const props = defineProps({
rowHeight: {
type: Number,
default: 150,
},
margin: {
type: Array,
default: () => [10, 10],
},
viewPortWidth: {
type: Number,
default: 1920, // 0 关闭自适应
},
viewPortHeight: {
type: Number,
default: (rawProps) => (rawProps.viewPortWidth === undefined ? 1080 : (rawProps.viewPortWidth / 16) * 9), // 0 关闭自适应,画面默认比例 16 / 9
},
});
const { width, height } = useWindowSize();
const usingDynamic = props.viewPortHeight !== 0 && props.viewPortWidth !== 0;
const rowHeightDynamic = computed(() => (usingDynamic
? height.value * (props.rowHeight / props.viewPortHeight)
: props.rowHeight
));
const marginDynamic = computed(() => (usingDynamic
? [width.value * (props.margin[0] / props.viewPortWidth), height.value * (props.margin[1] / props.viewPortHeight)]
: props.margin
));
</script>
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
# Echarts 响应式组件封装
在echarts官网,关于响应式的应该就这两点
- 响应容器大小的变化 (opens new window)(echartsInstance.resize)
- 移动端自适应 (opens new window)(Media Query)
# 响应容器大小的变化
对于官网的响应容器大小的变化
,官网采用监听window resize事件,其实是有几个缺点的:
- 如果窗口大小没有变化,但是页面中容器大小变了,echarts并不会resize。比如收起侧边栏,echarts容器变宽
- 如果echarts所在的dom上级被隐藏起来(display: none),这时候去缩放窗口(echarts实例的resize被调用),然后再让回到echarts显示的状态,你会发现echarts的大小已经变成
10px \* 10px
(或者其他奇怪的大小)了,因为resize事件触发的时候,echarts并没有在页面中显示,这时候获取不到dom元素的宽高,然后就resize成默认的10px \* 10px
(或者其他奇怪的大小)了。比如element-plus
中的el-tabs,不是当前页面的echarts就不会显示
el-tab 未激活的页面:
比如在官网实例中,你给iframe里面的body加上display: none之后,缩放窗口,然后再把display: none取消掉,你就会发现echarts没了:
为了解决以上两点,你可能会在页面上多写一些东西,在相应的时刻调用resize。
所以有没有一种方法可以统一的解决上面的两个问题呢?
答案就是echarts外部容器加上容器大小的监听(而不是windows的resize),这样就不会出现上述两种问题。加上容器大小的监听有两种方法:ResizeObserver
(opens new window)、element-resize-detector
(opens new window),使用方法,可以看看我的这篇博客 (opens new window)。
# 移动端自适应
对于官网的移动端自适应
,也就是媒体查询,需要给出在不同分辨率下的option(/样式),这种方法写起来可能比较繁琐,也不能很好的和px2vw这种插件配合。
# 解决方法
所以,可以不可以让echarts也能有px2vw这种类似的效果呢?
比如想让echarts的fontSize动态响应,那么就应该拿到option中所有的fontSize,然后再窗口大小变化的时候,重新给收集到的这些fontSize赋值。
有了这个思路,配合上上面提到给容器加上大小变化监听,就可以抽出一个echarts的通用组件。
fontSize自适应效果:
# Echarts组件封装
这个组件有几个特点:
- 使用echarts时,只需要传入option,就会自动更新option,无需自己选择dom元素、调用init
- echarts的点击事件会转成vue组件的事件
- 监听容器大小并且resize,使用时不在需要关心什么时候需要resize
- 传入屏幕宽度,所有fontSize就会自动根据屏幕宽度响应式改变大小
- 你甚至可以传入虚假的viewPortWidth让图表整体字体大小改变
提示:
- 这个组件setOption采用不合并的模式,需要全量传入option
- 对于echarts普通合并模式,有些数据项需要给到name、id,不然走的是增量更新,无法重设字体大小,参考
echartsInstance.setOption
(opens new window)
可以改造扩展的地方:
- 现在只是针对fontSize做响应式,后续可以加上其他的,比如margin,padding,itemWidth等等
- 现在是根据屏幕宽度响应式,如果想根据容器宽度响应式也是可以的
Echarts.vue
<template>
<div ref="echartsRef" class="echarts" />
</template>
<script setup>
import { debounce, get, set, cloneDeep } from 'lodash';
import * as echarts from 'echarts';
import {
ref, onMounted, onUnmounted, watch,
} from 'vue';
import { getObjectPaths } from '@/utils/utils';
const props = defineProps({
option: {
type: Object,
required: true,
},
viewPortWidth: {
type: Number,
default: 1920, // 0 关闭自适应
},
});
const emit = defineEmits(['resize', 'mouseover', 'mouseout', 'click', 'legendselectchanged']);
let chart;
const echartsRef = ref();
let destroyFunc;
const defaultResizingOption = {}; // 自适应配置(echarts 普通合并模式,需要用到name、id)
const fontSizePaths = new Set(); // fontSize 需要的 key
const getEchartsInstance = () => chart;
const setOption = (option) => {
option && chart.setOption({ ...option }, true);
// 这里没有走echarts合并模式,所以就不传参数,直接拿到echarts实例的option。
// 如果是echarts合并模式,需要传入option,并且要想办法拿到echarts默认的option。
collectFontSizePath();
};
// 注册echarts事件到Vue组件
const initChartEvent = () => {
const emitEvents = ['mouseover', 'mouseout', 'click', 'legendselectchanged'];
emitEvents.forEach((eventName) => {
chart.on(eventName, (params) => {
emit(eventName, params, chart);
});
});
};
// echarts 容器大小自适应
const makeChartsResponsive = () => {
const targetNode = echartsRef.value;
const resizeDebounced = debounce(() => {
chart.resize();
setFontSize(); // resize的时候也要重新设置字体大小
}, 50);
const resizeObserver = new ResizeObserver((targets) => {
resizeDebounced();
const [target] = targets;
const { width, height } = target.contentRect;
emit('resize', { width, height }, chart);
});
resizeObserver.observe(targetNode);
destroyFunc = () => {
resizeObserver.disconnect();
};
};
// 拿到option中所有的fontSize(包括基础的id、name)
const collectFontSizePath = (option) => {
option === undefined && (option = chart.getOption());
const pathList = getObjectPaths(option);
const optionPathList = pathList.filter((path) => !path.includes('data'));
const basePathList = optionPathList.filter((path) => path.endsWith('.name') || path.endsWith('id'));
const fontSizePathList = optionPathList.filter((path) => path.endsWith('.fontSize'));
basePathList.forEach((path) => set(defaultResizingOption, path, get(option, path)));
fontSizePathList.forEach((path) => {
let fontSize = get(option, path);
if (typeof fontSize === 'string' && fontSize.endsWith('px')) {
fontSize = Number(fontSize.replace('px', '')) || 12;
}
set(defaultResizingOption, path, fontSize);
fontSizePaths.add(path);
});
setFontSize();
};
// 遍历收集到的fontSize,根据页面宽度重新设置字体大小
const setFontSize = () => {
if (!props.viewPortWidth) return;
const width = document.body.clientWidth;
const ratio = width / props.viewPortWidth;
const fontSizeOption = cloneDeep(defaultResizingOption);
fontSizePaths.forEach((path) => {
const fontSize = get(defaultResizingOption, path);
const newFontSize = Number((ratio * fontSize).toFixed(5)); // 精度
set(fontSizeOption, path, newFontSize);
});
chart.setOption(fontSizeOption);
};
onMounted(() => {
chart = echarts.init(echartsRef.value);
setOption(props.option);
initChartEvent();
makeChartsResponsive();
});
onUnmounted(() => {
chart && chart.dispose();
destroyFunc && destroyFunc();
});
watch(
() => props.option,
(option) => {
setOption(option);
},
// { deep: true }, // 如果默认不走echarts合并模式,就不需要deep了
);
defineExpose({
getEchartsInstance,
});
</script>
<style scoped>
.echarts {
width: 100%;
height: 100%;
}
</style>
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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
取到对象所有的path
const getObjectPaths = (object) => {
const paths = [];
const getPaths = (obj, path) => {
if (obj && typeof obj === 'object') {
Object.keys(obj).forEach((key) => {
getPaths(obj[key], path ? `${path}.${key}` : key);
});
} else {
paths.push(path);
}
};
getPaths(object, '');
return paths;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
至此,两个组件的响应式就已经弄好了