背景
鱼我所欲也
书接上文,最近在做的这个项目中,其中首页使用了echarts
来展示图表,为了方便编码(自动响应性,resize,options支持类型推测等)与统一项目代码规范,按照国际惯例,我把echarts
组件化了,如下:
<!-- chart.vue -->
<template>
<div ref="chartDOMRef"></div>
</template>
<script setup lang="ts">
import { useChart } from './use-chart';
import { toRefs } from 'vue';
import { ECOption } from './types';
const props = defineProps<{
options: ECOption | undefined;
noInit?: boolean;
noResize?: boolean;
}>();
const { options, noInit, noResize } = toRefs(props);
const { chart, chartDOMRef, loading, done, initChart } = useChart(
options,
!!noInit?.value,
!!noResize?.value
);
// 导出外部可能需要的
defineExpose({
chart,
loading,
done,
initChart
});
</script>
// use-chart.ts
/**
* echart二次封装
*/
import globalEventBus, { EVENT_NAME } from '@/utils/global-event-bus';
import * as echarts from 'echarts';
import { ECOption } from './types';
import { onMounted, Ref, ref, watch } from 'vue';
// 图表调色盘
export const BASE_COLORS = [
'#73ACFF',
'#FDD56A',
'#FDB36A',
'#FD866A',
'#9E87FF',
'#58D5FF',
'#ff557f',
'#ff00ff',
'#5500ff',
'#55ffff',
];
/**
* 初始化一个图表:自动实现图表options响应式 | resizeHandler
* @param options echartOptions
* @param noInit 禁用自动在onMounted钩子初始化 【静态,无响应式】
* @param noResize 禁用自动resize重绘图表 【静态】
*/
const useChart = (
options: Ref<ECOption | undefined>,
noInit: boolean,
noResize: boolean
) => {
const chartDOMRef = ref<HTMLElement>(); // echarts挂载的DOM Reference
// echarts对象 --> 这里不能用响应式对象否则会出现 tooltip 在 axis 模式下不显示的问题
let chart: echarts.ECharts;
// 初始化图表
const initChart = () => {
if (!chartDOMRef.value) {
return;
}
chart = echarts.init(chartDOMRef.value);
if (!options.value) {
return;
}
chart.setOption(options.value);
!noResize &&
globalEventBus.on(EVENT_NAME.WINDOW_RESIZE, () => {
chart?.resize();
});
};
// 更新配置
const updateOptions = () =>
options.value && chart?.setOption(options.value);
const loading = () => {
chart?.showLoading({
text: '数据加载中',
textColor: '#fff',
effect: 'whirling',
maskColor: 'rgba(255 255 255 / 10%)',
});
};
const done = () => {
chart?.hideLoading();
};
onMounted(() => !noInit && initChart());
watch(options, updateOptions, {
deep: true,
});
return {
chart,
chartDOMRef,
loading,
done,
initChart,
};
};
export default useChart;
export { useChart };
// types.ts
import { ComposeOption } from 'echarts/core';
// 引入类型系统
import {
BarSeriesOption,
BoxplotSeriesOption,
CandlestickSeriesOption,
CustomSeriesOption,
EffectScatterSeriesOption,
FunnelSeriesOption,
GaugeSeriesOption,
GraphSeriesOption,
HeatmapSeriesOption,
LineSeriesOption,
LinesSeriesOption,
MapSeriesOption,
ParallelSeriesOption,
PictorialBarSeriesOption,
PieSeriesOption,
RadarSeriesOption,
SankeySeriesOption,
ScatterSeriesOption,
SunburstSeriesOption,
ThemeRiverSeriesOption,
TreeSeriesOption,
TreemapSeriesOption,
} from 'echarts/charts';
import {
AriaComponentOption,
AxisPointerComponentOption,
BrushComponentOption,
CalendarComponentOption,
DataZoomComponentOption,
GeoComponentOption,
GraphicComponentOption,
GridComponentOption,
LegendComponentOption,
MarkAreaComponentOption,
MarkLineComponentOption,
MarkPointComponentOption,
PolarComponentOption,
RadarComponentOption,
SingleAxisComponentOption,
TimelineComponentOption,
ToolboxComponentOption,
VisualMapComponentOption,
TitleComponentOption,
TooltipComponentOption,
DatasetComponentOption
} from 'echarts/components';
// EchartOptions类型
export type ECOption = ComposeOption<
// 组件
| AriaComponentOption
| AxisPointerComponentOption
| BrushComponentOption
| CalendarComponentOption
| DataZoomComponentOption
| GeoComponentOption
| GraphicComponentOption
| GridComponentOption
| LegendComponentOption
| MarkAreaComponentOption
| MarkLineComponentOption
| MarkPointComponentOption
| PolarComponentOption
| RadarComponentOption
| SingleAxisComponentOption
| TimelineComponentOption
| ToolboxComponentOption
| VisualMapComponentOption
| TitleComponentOption
| TooltipComponentOption
| DatasetComponentOption
// 图表
| BarSeriesOption
| BoxplotSeriesOption
| CandlestickSeriesOption
| CustomSeriesOption
| EffectScatterSeriesOption
| FunnelSeriesOption
| GaugeSeriesOption
| GraphSeriesOption
| HeatmapSeriesOption
| LineSeriesOption
| LinesSeriesOption
| MapSeriesOption
| ParallelSeriesOption
| PictorialBarSeriesOption
| PieSeriesOption
| RadarSeriesOption
| SankeySeriesOption
| ScatterSeriesOption
| SunburstSeriesOption
| ThemeRiverSeriesOption
| TreeSeriesOption
| TreemapSeriesOption
>;
该组件主要帮我们做了三件事:
options
响应性,即echarts
数据驱动更新。- 导入
echarts
库。 - 导出了可供使用的
echarts.options
的通用类型ECOption
。
PS:特别注意,echart 实例不可以用响应式对象包裹,否则 echart 内部逻辑无法正常再次操作实例对象,导致 tooltip 在 axios 模式下不显示。
这样,在业务中需要使用echarts
时,将变得肥肠的方便,我们只需要把options
数据传入就可以了:
<template>
<chart
class="w-[550px] h-[600px]"
:options="barOptions"
:no-resize="true"
/>
</template>
<script setup lang="ts">
import Chart from '@/components/chart/index.vue';
import { ECOption } from '@/components/chart/use-chart';
import { ref, onMounted } from 'vue';
const barOptions = ref<ECOption>();
const composeBarOptions = (data: FinanceStatistics[]) => {
const x = data.map((v) => v.loanMoney);
const y = data.map((v) => v.name);
const unit = data[0]?.unit;
barOptions.value = {
xAxis: {
type: 'value',
name: unit
},
yAxis: {
type: 'category',
data: y,
axisLabel: {
margin: 2
}
},
grid: {
left: '60px'
},
tooltip: {
show: true,
trigger: 'item',
axisPointer: {
type: 'shadow'
}
},
series: [
{
type: 'bar',
data: x,
colorBy: 'data',
barMaxWidth: 24,
itemStyle: {
borderRadius: [0, 12, 12, 0]
}
}
]
};
};
onMounted(async () => {
const res = await getFinanceMap();
if (res?.data) {
composeBarOptions(res.data);
}
});
</script>
使用时仅需操作options
且有类型提示,肥肠的方便与丝滑!
熊掌亦我所欲也
But!
在打包后一个醒目的数值亮瞎了我的眼睛 2.94MB
首页模块打包后的大小竟恐怖如斯。
其中用户代码仅占了 130KB
,zrender
(echart的依赖库) 550KB
,echarts
本体 2.2MB
。
也就是说,基本上都给 echarts
占了,不过这也恒河里,毕竟我们封装的 Chart
组件,为了通用性,直接用的全量引入。
改造Chart
组件,动态按需引入
了解了上面这点后,改进的思路就明了了 —— 改全量引入为“按需引入”
但是要做到这一点,并不轻松,最直接的思路是,预设好我们当前项目中需要用到哪些图表,直接到 use-chart.ts
用 echarts.use()
加入。
但这样又与我们组件设计的初衷相悖,不够封装,用户甚至还需要进入到组件代码里面研究去修改组件,显然是不合适的。
稍稍冥思后,我想到了一个很平平无奇的方法,“将用户需要使用的图表模块当作props传递,在组件内部自动进行use”,这样既能满足按需引入,减少体积的要求,又能够满足组件封装性。
直接开干:
// use-chart.ts
// - import * as echarts from 'echarts';
// 只引入echarts使用时必须的核心模块
import * as echarts from 'echarts/core';
// 一些通用的必备的echarts预设
import { LabelLayout, UniversalTransition } from 'echarts/features'; // 标签自动布局、全局过渡动画等特性
import { CanvasRenderer } from 'echarts/renderers'; // 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
// 一些项目中通用的图表渲染部件(也可以不预设引入,也可以引入更多预设的方便使用)
import {
TooltipComponent, // tooltips
TitleComponent, // title
GridComponent, // grid 布局
} from 'echarts/components';
// 预先导入一般通用工具组件
echarts.use([
LabelLayout,
UniversalTransition,
CanvasRenderer, // 使用canvas渲染器
TooltipComponent, // tooltips工具
TitleComponent, // title
GridComponent, // grid布局
]);
// Echart导入组件缓存记录,已导入过的剔除掉,不做重复导入操作
const EchartUsedSet = new WeakSet([TooltipComponent, TitleComponent, GridComponent]);
function setAndRemoveDuplicatesEchartUsed(usedComponents: any[]): any[] {
const restComponents: any[] = [];
for (const component of usedComponents) {
if (!EchartUsedSet.has(component)) {
restComponents.push(component);
EchartUsedSet.add(component);
}
}
return restComponents;
}
const useChart = (
...
usedComponents: any[] // 新增参数,当前绘制图表需要使用的组件
) => {
// 按需动态地导入所需要的组件
echarts.use([...setAndRemoveDuplicatesEchartUsed(usedComponents)]);
// ...
}
<!--chart.vue-->
<template>
<div ref="chartDOMRef"></div>
</template>
<script setup lang="ts">
import { useChart } from './use-chart';
import { toRefs } from 'vue';
import { ECOption } from './types';
const props = defineProps<{
...
usedComponents: any[]; // 新增props
}>();
const { chart, chartDOMRef, loading, done, initChart } = useChart(
options,
props.usedComponents,
!!noInit?.value,
!!noResize?.value
);
// ...
</script>
这样就完成了我们的按需导入Chart
组件地封装,写个小demo试试~
demo
简单写个bar
柱状图
<script setup lang="ts">
import Chart from '@/components/chart/index.vue';
import { BarChart } from 'echarts/charts';
import { onMounted, ref } from 'vue';
import { BASE_COLORS } from '@/components/chart/use-chart';
import { ECOption } from '@/components/chart/types'
const options = ref<ECOption>();
const mockData = [
{
name: '小明',
value: 120,
},
{
name: '小红',
value: 170,
},
{
name: '小华',
value: 420,
},
{
name: '小军',
value: 64,
},
{
name: '喜羊羊',
value: 150,
},
];
onMounted(() => {
// 简单模拟下请求数据,异步设置options
setTimeout(() => {
options.value = {
title: {
show: true,
text: '终极一班同学身价图',
},
xAxis: {
type: 'value',
name: '万元',
},
yAxis: {
type: 'category',
data: mockData.map((v) => v.name),
axisLabel: {
margin: 2,
},
},
grid: {
left: '60px',
},
tooltip: {
show: true,
trigger: 'item',
axisPointer: {
type: 'shadow',
},
},
series: [
{
type: 'bar',
data: mockData.map((v) => v.value),
colorBy: 'data',
barMaxWidth: 24,
itemStyle: {
borderRadius: [0, 12, 12, 0],
},
},
],
};
});
});
</script>
<template>
<chart
class="bar-chart__demo"
:used-components="[BarChart]"
:options="options"
/>
</template>
<style scoped>
.bar-chart__demo {
width: 500px;
height: 400px;
}
</style>
很完美!
改造后打包测试
改造了我们项目中的Chart
组件后,我们最后打个包看看效果,是否降低了包大小
可以看到 index.vue
虽然还是最大的那块,但已经明显小了很多,其大小也降低到了 1.87MB
(原来是2.94MB
),减少了1MB
,减少了36%
的大小。
进一步分析,我们的改造其实大部分缩减内容在于 渲染工具组件component
和 图表组件 chart
chart 的变化
改造前:647KB
改造后:58KB
我们只引入了 Bar
和 Map
component 的变化
改造前:671KB
比图表还大
改造后:393KB
地图组件使用了 VisualMap
和 toolbox
比较大,加上基础的渲染工具,只减少了不到 300KB
总结
总的来说,这次Chart
组件改造的效果还是不错的,在确保组件使用的便利的基础上,减少了 44%
的 echart
打包大小,Gzip
后我们的首页大小降低到了 500KB
还可以接受。
不过通过对上面的构建报告图的观察,其实还是能够发现更多优化点的,比如抽离公共组件包 ElementPlus
,甚至 echarts
本身也可以抽出来,过分点的话甚至可以把 类型 和 我们设置的基础 渲染工具去掉,使得Chart
组件本身更精简。
简单的一篇文章,希望能起到抛砖引玉的作用,不知道各位掘友有什么优化建议分享呢?