动态(按需)加载异步子组件

之前说过 ECharts 如何封装,今天来讲一讲 ECharts 如何做性能优化。

对于之前 ECharts 的封装子组件,我们可以使用 component 动态组件的方式进行渲染,并传参。

并且使用 import 动态导入搭配 defineAsyncComponent 实现打包代码分割,import 实现组件按需加载,并将这些异步组件单独打包,减小主包的体积。使用 defineAsyncComponent 主要是为了加载状态/错误状态处理(已内置)。

而且统一封装图表组件,统一管理图表配置 options,可以实现业务数据与图表样式配置分离。

在这里插入图片描述

以下代码均只保留核心代码,已脱敏。

index.vue 中去实现动态渲染:

 <component
   class="w-full"
   :is="echartsComponent"
   v-if="echartsComponent"
   v-bind="echartsProps"
 />
  // 接收参数
  const props = defineProps({
    confgData: {
      type: Object,
      required: true,
      default: () => ({}),
    },
    boardData: {
      type: Object,
      required: true,
      default: () => ({}),
    },
    // componentKey: {
    //   type: String,
    //   required: true,
    // },
  });
  // ...

  // 所有的图表按需加载
  const allEcharts = [
    {
      type: 'bar',
      component: defineAsyncComponent(() => import('./BarEcharts/index.vue')),
    },
    {
      type: 'pie',
      component: defineAsyncComponent(() => import('./PieCharts/index.vue')),
    },
    {
      type: 'line',
      component: defineAsyncComponent(() => import('./LineCharts/index.vue')),
    },
    {
      type: 'progress',
      component: defineAsyncComponent(() => import('./Progress/index.vue')),
    },
  ];
 
  // 抛出一个父组件使用该组件修改内部配置的方式
  const emit = defineEmits(['updates']);
  
  // 针对不同图表做不同的参数传递
  const echartsProps = computed(() => {
    if (confgData.value?.type === 'bar') {
      return {
        xAxisLabels: props.boardData.x_axis_labels,
        yAxisLabels: props.boardData.y_axis_labels,
        seriesData: props.boardData.series_data,
        isHorizontal: props.boardData?.y_axis_labels?.length >= 1,
      };
    } else if (confgData.value?.type === 'pie') {
      return {
        data: props.boardData.data,
        title: props.boardData?.title,
      };
    } else if (confgData.value?.type === 'progress') {
      return {
        percent: props.boardData.percent,
        title: props.boardData?.title,
      };
    } else {
      // 折线图
      // obj 特殊化配置
      return {
        xAxisLabels: props.boardData.x_axis_labels,
        yAxisLabels: props.boardData.y_axis_labels,
        seriesData: props.boardData.series_data,
        isHorizontal: props.boardData?.y_axis_labels?.length > 1,
        ...obj,
      };
    }
  });

  // 计算得到所需组件
  const echartsComponent = computed(() => {
    return allEcharts.find((item) => item.type === confgData.value?.type)?.component;
  });

按需导入 ECharts 包

按需引入 ECharts,而非全量引入,减小打包体积大小。

在全局入口文件中引用。

lib/echarts.ts

import * as echarts from 'echarts/core';

import {
  BarChart,
  LineChart,
  // ...
} from 'echarts/charts';

import {
  TitleComponent,
  TooltipComponent,
  // ... 
} from 'echarts/components';

import { SVGRenderer } from 'echarts/renderers';

echarts.use([
  LegendComponent,
  TitleComponent,
  // ...
]);

export default echarts;

然后创建一个基础 BaseECharts.vue

myChart.vue
<template>
  <div ref="chartRef"  class="my-chart"></div>
</template>
<script setup lang="ts">
import { defineProps, onBeforeUnmount, onMounted, ref } from "vue";
import echarts from "../lib/echarts";
const props = defineProps(["options"]);
const chartRef = ref<HTMLDivElement>();
let chart: echarts.ECharts | null = null;
const resizeHandler = () => {
  chart?.resize();
};
onMounted(() => {
  setTimeout(() => {
    initChart();
  }, 20);
  window.addEventListener("resize", resizeHandler);
});

const initChart = () => {
  chart = echarts.init(chartRef.value as HTMLDivElement);
  chart.setOption({
    ...props.options,
  });
};

onBeforeUnmount(() => {
  window.removeEventListener("resize", resizeHandler);
  chart?.dispose();
});
</script>

<style lang="scss" scoped>
.my-chart {
  width: 100%;
  height: 400px;
}
</style>

然后使用:

<template>
  <div>
    <EchartsDemo :options="options7" class="h-right-three-chart"></EchartsDemo>
  </div>
</template>
<script setup lang="ts">
import EchartsDemo from './components/EchartsDemo.vue'

const options7 = {
  title: {
    text: 'ECharts 入门示例',
  },
  tooltip: {},
  xAxis: {
    data: ['衬衫', '羊毛衫', '雪纺衫', '裤子', '高跟鞋', '袜子'],
  },
  yAxis: {},
  series: [
    {
      name: '销量',
      type: 'bar',
      data: [5, 20, 36, 10, 10, 20],
    },
  ],
}
</script>
<style scoped>
.h-right-three-chart {
  width: 100%;
  height: 352px;
}
</style>

在这里插入图片描述

依赖预构建

依赖预构建 (Dependency Pre-Bundling)

vite.config.ts 文件中,相关的配置是 optimizeDeps.include

// vite.config.ts
export default defineApplicationConfig({
  overrides: {
    optimizeDeps: {
      include: [
        'echarts/core',
        'echarts/charts',
        'echarts/components',
        'echarts/renderers',
        // ... other dependencies
      ],
    },
    // ...
  },
});

这个配置是如何优化 ECharts 的?

  1. 优化对象:此优化主要针对 开发环境 (dev server),旨在提升开发时的页面加载速度和热更新性能。

  2. 工作原理

    • 问题背景
      1. ECharts 库本身是由大量的小模块组成的(例如,core 是核心,charts 目录下有各种图表类型,components 目录下有提示框、图例等组件)。
      2. 当我们在代码中按需引入时(如 import { BarChart } from 'echarts/charts'),在开发模式下,浏览器需要根据 import 语句逐个去请求这些零散的模块文件。
      3. 如果一个复杂的图表需要几十个模块,就会导致浏览器发起大量的网络请求,形成“请求瀑布流”,严重拖慢页面首次加载速度。
    • Vite 的解决方案:Vite 在启动开发服务器时,会先扫描项目代码,找出所有依赖。对于 optimizeDeps.include 中明确列出的依赖,Vite 会使用速度极快的 esbuild 工具,提前将这些零散的模块 “预构建” 成一个或少数几个大的 JavaScript 文件。
      • 具体到 ECharts:配置中的 'echarts/core', 'echarts/charts', 'echarts/components', 'echarts/renderers' 告诉 Vite:“请提前把这几个路径下的所有 ECharts 模块都找到,并将它们打包成一个整体。”
  3. 带来的好处

    • 减少网络请求:经过预构建,当浏览器需要加载 ECharts 时,不再是请求几十个零散的小文件,而是只请求一个或几个已经打包好的大文件。这极大地减少了 HTTP 请求开销。
    • 模块格式转换esbuild 会将可能存在的 CommonJS 或 UMD 格式的模块(一些旧的库可能还在使用)统一转换为浏览器原生支持的 ESM (ES Modules) 格式,避免了在浏览器端进行复杂的模块解析。
    • 更快的页面加载:最终结果就是,在开发环境中,包含 ECharts 图表的页面加载速度会得到显著提升,让开发体验更加流畅。

注意:这个优化主要作用于 开发环境。在 生产环境打包 (build) 时,Vite 会利用 Rollup 和 Tree Shaking 机制,确保最终打包出的代码只包含实际用到的 ECharts 模块,以实现生产环境包体积的最小化。这里的 optimizeDeps 配置与最终的打包体积没有直接关系。

打包优化

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue(), vueJsx(), vueDevTools()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  optimizeDeps: {
    include: ['echarts/core', 'echarts/charts', 'echarts/components', 'echarts/renderers'],
  },
  build: {
    rollupOptions: {
      output: {
        manualChunks: (id) => {
          // 将 echarts 相关模块打包到单独的 chunk
          if (id.includes('echarts')) {
            return 'echarts-vendor'
          }
          // 将 Vue 相关库打包到单独的 chunk
          if (id.includes('vue') || id.includes('vue-router')) {
            return 'vue-vendor'
          }
          // 将 node_modules 中的其他依赖打包到 vendor chunk
          if (id.includes('node_modules')) {
            return 'vendor'
          }
        },
        // 优化 chunk 大小
        chunkFileNames: (chunkInfo) => {
          const facadeModuleId = chunkInfo.facadeModuleId
          if (facadeModuleId) {
            return '[name].[hash].js'
          }
          return 'chunks/[name].[hash].js'
        },
      },
    },
    // 设置 chunk 大小警告限制
    chunkSizeWarningLimit: 1000,
  },
})

在这里插入图片描述

图表的屏幕适配

统一解决了窗口缩放时图表的适应问题,避免因窗口缩放导致的 ECharts 图表变形。(主要针对大屏)

  1. 使用 scale,因为是根据 ui 稿等比缩放,当大屏跟 ui 稿的比例不一样时,会出现周边留白情况;当缩放比例过大时候,字体会有一点点模糊;当缩放比例过大时候,事件热区会偏移。
  2. 推荐使用 vw/vh,动态计算宽高比例,字体等,比较灵活,避免大片留白。缺点:每个图表都需要单独做字体、间距、位移的适配,比较麻烦。

vw/vh 方案

假设设计稿尺寸为 1920*1080

即:
网页宽度=1920px
网页高度=1080px
网页宽度=100vw
网页宽度=100vh

所以,在1920×*1080p×的屏幕分辨率下
1920px=100vw
1080px=100vh

这样一来,以一个宽300px和200px的div来说,其作所占的宽高,以vw和vh为单位,计算方式如下:
vw=(300px/1920px)*100vw
vh=(200px/1080px)*100vh

所以,就在1920*1080的屏幕分辨率下,计算出了单个div的宽高。

当屏幕放大或者缩小时,div还是以vw和vh作为宽高的、就会自动适应不同分辨率的屏幕。

单位转换(宽高、字体大小、间距大小)的方法可以封装为一个统一的函数。

/**
 * 响应式样式工具函数和变量
 */

@use 'sass:math';

// 设计稿尺寸配置
$design-width: 1920;
$design-height: 1080;

// px转vw函数
@function vw($px) {
  @return math.div($px, $design-width) * 100vw;
}

// px转vh函数
@function vh($px) {
  @return math.div($px, $design-height) * 100vh;
}

// 响应式字体大小函数
@function responsive-font($px, $min: 12px, $max: 48px) {
  $vw-value: math.div($px, $design-width) * 100vw; 
  // clamp() 用来设置随窗口大小改变的字体大小,会把值限定在最小值和最大值之间(中间参数为默认值)
  @return clamp(#{$min}, #{$vw-value}, #{$max});
}

// 响应式间距函数
@function responsive-space($px) {
  @return clamp(#{$px * 0.5}px, #{vw($px)}, #{$px * 1.5}px);
}

// 响应式断点变量
$breakpoint-mobile: 768px;
$breakpoint-tablet: 1024px;
$breakpoint-desktop: 1366px;
$breakpoint-large: 1920px;
$breakpoint-ultra: 2560px;

// 主题颜色变量
$primary-color: #00d4ff;
$secondary-color: #0066cc;
$success-color: #4caf50;
$error-color: #f44336;
$text-color: #fff;
$text-secondary: #ccc;
$background-primary: rgba(0, 0, 0, 0.4);
$background-secondary: rgba(0, 0, 0, 0.6);
$border-color: rgba(0, 212, 255, 0.3);

// 通用混入
@mixin glass-effect {
  background: $background-primary;
  border: vw(1) solid $border-color;
  border-radius: vw(19);
  backdrop-filter: blur(vw(10));
}

@mixin gradient-text {
  background: linear-gradient(45deg, $primary-color, $secondary-color);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

@mixin glow-effect($color: $primary-color) {
  text-shadow: 0 0 vw(10) rgba(0, 212, 255, 0.5);
}

使用:

<template>
  <div class="data-screen">
    <!-- 头部标题区域 -->
    <header class="screen-header">
      <h1 class="screen-title">智慧数据大屏监控系统</h1>
      <div class="screen-time">{{ dataStore.currentTime }}</div>
    </header>

    <!-- 主要内容区域 -->
    <main class="screen-main">
      <!-- 左侧区域 -->
      <section class="left-panel">
        <ChartPanel title="实时销售数据" :options="salesChartOptions" />
        <ChartPanel title="地区分布" :options="regionChartOptions" />
      </section>

      <!-- 中间区域 -->
      <section class="center-panel">
        <div class="center-top">
          <DataCard
            title="今日销售额"
            :value="dataStore.todaySales"
            prefix="¥"
            :trend="{ value: 12.5, type: 'up' }"
          />
          <DataCard
            title="订单总数"
            :value="dataStore.totalOrders"
            :trend="{ value: 8.3, type: 'up' }"
          />
          <DataCard
            title="用户访问"
            :value="dataStore.userVisits"
            :trend="{ value: 2.1, type: 'down' }"
          />
        </div>
        <div class="center-main">
          <h3 class="panel-title">销售趋势分析</h3>
          <EchartsDemo :options="trendChartOptions" class="main-chart" />
        </div>
      </section>

      <!-- 右侧区域 -->
      <section class="right-panel">
        <ChartPanel title="产品销量排行" :options="rankingChartOptions" />
        <ChartPanel title="用户来源分析" :options="sourceChartOptions" />
      </section>
    </main>

    <!-- 底部状态栏 -->
    <StatusBar :last-update="dataStore.lastUpdate" :online-users="dataStore.onlineUsers" />
  </div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useDataScreenStore } from '@/stores/dataScreen'
import {
  createSalesChartOptions,
  createRegionChartOptions,
  createTrendChartOptions,
  createRankingChartOptions,
  createSourceChartOptions,
} from '@/lib/charts/chartConfigs'
import EchartsDemo from '@/components/EchartsDemo.vue'
import DataCard from '@/components/DataCard.vue'
import ChartPanel from '@/components/ChartPanel.vue'
import StatusBar from '@/components/StatusBar.vue'

// 使用数据存储
const dataStore = useDataScreenStore()

// 图表配置
const salesChartOptions = createSalesChartOptions()
const regionChartOptions = createRegionChartOptions()
const trendChartOptions = createTrendChartOptions()
const rankingChartOptions = createRankingChartOptions()
const sourceChartOptions = createSourceChartOptions()

onMounted(() => {
  dataStore.startTimer()
})

onUnmounted(() => {
  dataStore.stopTimer()
})
</script>

<style lang="scss" scoped>
.data-screen {
  width: 100vw;
  height: 100vh;
  background: linear-gradient(135deg, #0c1e35 0%, #1a2a4a 100%);
  color: $text-color;
  font-family: 'Microsoft YaHei', sans-serif;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* 头部样式 */
.screen-header {
  height: vh(86);
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0 vw(38);
  background: rgba(0, 0, 0, 0.3);
  border-bottom: vw(2) solid $primary-color;
}

.screen-title {
  font-size: responsive-font(48, 24px, 60px);
  font-weight: bold;
  margin: 0;
  @include gradient-text;
  @include glow-effect;
}

.screen-time {
  font-size: responsive-font(27, 16px, 32px);
  color: $primary-color;
  font-weight: bold;
}

/* 主要内容区域 */
.screen-main {
  flex: 1;
  display: flex;
  gap: vw(19);
  padding: vh(11) vw(19);
}

/* 左右面板样式 */
.left-panel,
.right-panel {
  width: vw(480);
  display: flex;
  flex-direction: column;
  gap: vh(11);
}

.center-panel {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: vh(11);
}

/* 中间区域样式 */
.center-top {
  height: vh(162);
  display: flex;
  gap: vw(19);
}

.center-main {
  flex: 1;
  @include glass-effect;
  padding: vh(11) vw(19);
}

.panel-title {
  font-size: responsive-font(27, 18px, 32px);
  margin: 0 0 vh(11) 0;
  color: $primary-color;
  text-align: center;
  font-weight: bold;
}

.main-chart {
  height: calc(100% - vh(43));
  min-height: vh(378);
  max-height: vh(486);
}

/* 响应式调整 */
@media (max-width: $breakpoint-tablet) {
  .screen-main {
    flex-direction: column;
    gap: vh(22);
  }

  .left-panel,
  .right-panel {
    width: 100%;
    flex-direction: row;
    height: auto;
  }

  .center-top {
    flex-direction: column;
    height: auto;
    gap: vh(11);
  }

  .screen-title {
    font-size: responsive-font(77, 32px, 80px);
  }
}

@media (max-width: $breakpoint-mobile) {
  .screen-header {
    flex-direction: column;
    height: auto;
    padding: vh(11) vw(38);
    gap: vh(11);
  }

  .screen-title {
    font-size: responsive-font(115, 24px, 120px);
  }

  .screen-time {
    font-size: responsive-font(58, 18px, 64px);
  }

  .screen-main {
    padding: vh(11) vw(38);
  }

  .left-panel,
  .right-panel {
    flex-direction: column;
  }

  .center-top {
    height: auto;
  }
}

/* 超宽屏优化 */
@media (min-width: $breakpoint-ultra) {
  .screen-title {
    font-size: responsive-font(29, 20px, 40px);
  }
}

/* 确保图表在所有尺寸下都能正确显示 */
@media (orientation: portrait) {
  .screen-main {
    flex-direction: column;
  }

  .left-panel,
  .right-panel {
    width: 100%;
    height: auto;
  }
}
</style>

便于转换函数在全局使用,可以在 vite 中进行配置:

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue(), vueJsx(), vueDevTools()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `@use "@/assets/styles/responsive.scss" as *;`,
      },
    },
  },
})

在这里插入图片描述

该demo完整代码:自适应智慧大屏

Logo

更多推荐