Vue3 + Echarts + TypeScript按需导入组建封装—— 鱼(编码体验)和熊掌(构建优化)俺都要

2,419 阅读7分钟

背景

鱼我所欲也

书接上文,最近在做的这个项目中,其中首页使用了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
>;

该组件主要帮我们做了三件事:

  1. options响应性,即echarts数据驱动更新。
  2. 导入echarts库。
  3. 导出了可供使用的 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>

image.png

使用时仅需操作options且有类型提示,肥肠的方便与丝滑!

熊掌亦我所欲也

But! 在打包后一个醒目的数值亮瞎了我的眼睛 2.94MB 首页模块打包后的大小竟恐怖如斯。

image.png

其中用户代码仅占了 130KBzrender(echart的依赖库) 550KBecharts 本体 2.2MB

image.png

也就是说,基本上都给 echarts 占了,不过这也恒河里,毕竟我们封装的 Chart 组件,为了通用性,直接用的全量引入。

image.png

改造Chart组件,动态按需引入

了解了上面这点后,改进的思路就明了了 —— 改全量引入为“按需引入”

但是要做到这一点,并不轻松,最直接的思路是,预设好我们当前项目中需要用到哪些图表,直接到 use-chart.tsecharts.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组件后,我们最后打个包看看效果,是否降低了包大小

image.png

可以看到 index.vue虽然还是最大的那块,但已经明显小了很多,其大小也降低到了 1.87MB(原来是2.94MB),减少了1MB,减少了36%的大小。

image.png

进一步分析,我们的改造其实大部分缩减内容在于 渲染工具组件component图表组件 chart

chart 的变化

改造前:647KB

image.png

改造后:58KB 我们只引入了 BarMap

image.png

component 的变化

改造前:671KB 比图表还大

image.png

改造后:393KB 地图组件使用了 VisualMaptoolbox 比较大,加上基础的渲染工具,只减少了不到 300KB

image.png

总结

总的来说,这次Chart组件改造的效果还是不错的,在确保组件使用的便利的基础上,减少了 44%echart打包大小,Gzip 后我们的首页大小降低到了 500KB 还可以接受。

不过通过对上面的构建报告图的观察,其实还是能够发现更多优化点的,比如抽离公共组件包 ElementPlus,甚至 echarts 本身也可以抽出来,过分点的话甚至可以把 类型 和 我们设置的基础 渲染工具去掉,使得Chart组件本身更精简。

简单的一篇文章,希望能起到抛砖引玉的作用,不知道各位掘友有什么优化建议分享呢?

demo仓库