【Vue3】day76-echarts-component

作者:mario 发布时间: 2026-09-03 阅读量:5 评论数:0

Day 76 · ECharts Vue 组件封装 — VChart:图表基座的三个老大难

ECharts 与 Vue 的结合是工业可视化的高频面试题,也是三个老大难的聚集地:0 尺寸容器 init 失败(Day 52 坑 1 的 Vue 版)、数据变化驱动更新(watch 的正确姿势)、dispose 与内存(Day 69 排查位的组件化解法)。今天把三者收进一个 VChart 基座组件——它一旦写对,屏 B 的五个图表全部变成"传 option 的标签"。


目录


一、方案选型:自己封装 vs vue-echarts

方案 优点 缺点
直接在组件里 init(不封装) 上手最快 五个图表五份重复代码,resize/dispose 各写各的
vue-echarts 官方库 功能全(loading/主题/按需) 学习期把它当黑盒,三个老大难的原理被屏蔽
自己封装 VChart(本周选择) 三个老大难亲手解决一遍 需要一天(就是今天)

决策:学习阶段自己封装——面试聊到"你们怎么封装图表组件"时,你有一手答案。生产里用 vue-echarts(它解决的是同一组问题,看源码能对上你自己的实现,此时才配"会用库")。封装完成的标准:屏 B 五个图表零差异复用


二、VChart 组件实现

<!-- src/components/VChart.vue -->
<script setup lang="ts">
/**
 * ECharts 基座组件:任何图表 = 传 option
 * 解决三个老大难(详见第三节):
 *   1. 0 尺寸容器 → nextTick + ResizeObserver 双保险
 *   2. 数据驱动   → watch props.option,引用变化即 setOption
 *   3. 内存泄漏   → ResizeObserver/图表实例 在 onUnmounted 成对销毁
 *
 * 约定:
 *   - option 每次更新必须是"新对象"(父级 computed 天然满足)
 *   - 组件不持业务状态:不认识"温度"也不认识"告警",只认识 option
 */
import { onMounted, onUnmounted, ref, watch } from "vue";
import * as echarts from "echarts";   // Week 8 已按需注册,这里从注册模块导出

const props = defineProps<{
  option: echarts.EChartsOption;
  /** 空状态占位(断线且无数据时显示) */
  empty?: boolean;
}>();

const el = ref<HTMLDivElement>();            // 模板引用(容器 DOM)
let chart: echarts.ECharts | null = null;    // 非 responsive:实例无需响应式包裹
let ro: ResizeObserver | null = null;        // 容器尺寸观察器

onMounted(() => {
  // 老大难 1:等 DOM 上屏后再 init(v-show 的 display:none 已解除,
  // 若仍为 0 尺寸,由 ResizeObserver 兜底——见第三节)
  chart = echarts.init(el.value!, "industrial-dark");   // Day 55 主题
  chart.setOption(props.option);

  // 容器尺寸变化自动 resize(scale 壳、窗口变化、屏切换全覆盖)
  ro = new ResizeObserver(() => chart?.resize());
  ro.observe(el.value!);
});

// 老大难 2:option 引用变化 → 增量更新
watch(
  () => props.option,
  (opt) => chart?.setOption(opt),   // setOption 默认 merge:只打差异补丁(第五节)
);

// 老大难 3:成对销毁(Day 69 排查位 3 的结构性解法)
onUnmounted(() => {
  ro?.disconnect();       // 观察器摘除
  chart?.dispose();       // 实例销毁(内部 canvas/事件监听一并释放)
  chart = null;
});
</script>

<template>
  <div class="vchart">
    <div ref="el" class="vchart__canvas" />
    <div v-if="empty" class="vchart__empty">暂无数据</div>
  </div>
</template>

<style scoped>
.vchart { position: relative; width: 100%; height: 100%; }
.vchart__canvas { width: 100%; height: 100%; }
.vchart__empty {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  color: var(--text-dim); font-size: var(--fs-axis);
}
</style>

三、三个老大难的解法拆解

3.1 0 尺寸容器 init

三个时机可能拿到 0 尺寸:

时机 A:组件首次挂载时父级还是 display:none(v-show 隐藏屏)
   → onMounted 里 el 已在 DOM,但 offsetWidth = 0
时机 B:scale 壳动画进行中(transform 未稳定)
时机 C:布局抖动(grid 尚未完成分配)

双保险策略:init 前实测尺寸,尺寸为 0 则推迟到 ResizeObserver 首次回调

onMounted(() => {
  const tryInit = (): void => {
    if (!el.value || el.value.offsetWidth === 0) return;   // 0 尺寸:不 init
    chart = echarts.init(el.value, "industrial-dark");
    chart.setOption(props.option);
    ro = new ResizeObserver(() => chart?.resize());
    ro.observe(el.value!);
  };
  nextTick(tryInit);                       // 保险 1:DOM 上屏后
  // 保险 2:尺寸出现时(ResizeObserver 捕获任何 0→非0)
  const boot = new ResizeObserver(() => { if (!chart) tryInit(); });
  boot.observe(el.value!);
  onUnmounted(() => boot.disconnect());    // 引导观察器也要清理
});

3.2 数据驱动更新

// watch 源用 getter:() => props.option
// 追踪的是"props.option 的引用变化"——父级每次 computed 重算产生新对象即触发
watch(() => props.option, (opt) => chart?.setOption(opt));

为什么不用 deep: true:option 是大对象(曲线 120 点 × 多系列),deep 遍历的成本每次都是 O(节点数);而"父级整体换引用"的约定让 watch 只做一次引用比较。这延续了 Day 72 的 shallowRef 哲学:大对象永远整体替换,绝不深挖

3.3 dispose 与内存

Day 69 的排查位:ECharts 实例泄漏的三条路——组件卸载不 dispose、重复 init、游离实例。VChart 的结构性保证:

  • 实例存在组件闭包里,生命周期与组件同生共死(onUnmounted 必 dispose)
  • init 只发生在 tryInit(if (!chart) 门闩),不可能重复
  • ResizeObserver 与图表实例同步清理,无游离观察器

四、实战:温度曲线的 VChart 版

对照 Day 69 迁移版(createLineChart 工厂 + 手动 update/resize),Vue 版的样子:

<!-- components/TempLineChart.vue:温度曲线(业务壳,薄薄一层) -->
<script setup lang="ts">
/**
 * 温度曲线:Day 56/69 曲线模块的 Vue 版
 * 职责只剩"把 Store 窗口翻译成 option"——init/resize/dispose 全部外包给 VChart
 */
import { computed } from "vue";
import VChart from "./VChart.vue";
import type { PanelSnapshot } from "../core/types";

const props = defineProps<{
  windows: Map<string, { ts: number; v: number }[]>;
  deviceIds: string[];        // 曲线对应的设备
  names: string[];            // 图例名
  threshold?: number;         // 告警阈值(markLine)
}>();

// 派生 option:windows 变 → computed 重算 → 新引用 → VChart 自动 setOption
const option = computed(() => ({
  grid: { left: 50, right: 20, top: 40, bottom: 30 },
  tooltip: { trigger: "axis" },
  legend: { top: 5 },
  xAxis: { type: "time" },
  yAxis: { type: "value", min: 60, max: (v: { max: number }) => Math.max(100, v.max + 5) },
  series: props.deviceIds.map((id, i) => ({
    type: "line" as const,
    name: props.names[i],
    showSymbol: false,
    smooth: true,
    animation: false,                      // Day 54 性能纪律原样保留
    data: (props.windows.get(id) ?? []).map((p) => [p.ts, p.v] as [number, number]),
    markLine: props.threshold
      ? { silent: true, symbol: "none", lineStyle: { color: "#ff4d4f", type: "dashed" },
          data: [{ yAxis: props.threshold }] }
      : undefined,
  })),
}));
</script>

<template>
  <VChart :option="option" />
</template>

使用处(屏 B)——对照 Day 69 的 createMonitorScreen,DOM 手艺全部消失

<TechCard title="实时温度监控">
  <TempLineChart :windows="tempWindows" :device-ids="['f1','f2']" :names="['1号炉','2号炉']" :threshold="92" />
</TechCard>

五、性能细节:setOption 的 merge 纪律

chart.setOption(opt) 默认是 merge 模式:新旧 option 树合并,只更新变化分支。两条推论:

  1. 每次传完整 option 是安全的——merge 保证只打差异补丁,120 点曲线的更新开销与手写"只 setOption series.data"几乎一致(Day 54 的优化自动继承)
  2. 曾被移除的系列要显式清理——如果系列数会变(如设备动态上下线),merge 不会删除旧系列,需 setOption(opt, { replaceMerge: ["series"] })。本周设备固定,先记待办

⚠️ notMerge: true(全量替换)在本项目禁用:它会让 markLine、axis 等静态配置每帧重建,性能倒退——这是从 vue-echarts 示例抄代码时最常见的引入错误。


六、常见坑点

坑 1:v-if 切换导致 VChart 反复 init/dispose

v-if 控制图表显隐时,每次切换都走完整的 init(冷启动 100ms+ 白屏)。轮播场景一律 v-show(Day 72 第五节结论),v-if 只用于"真正不常看"的弹窗类。

坑 2:props.option 传了同一个引用

父级直接 const option = { ... }(非 computed、非响应式重建),改内部字段不触发 watch。规范:option 一律 computed 派生——引用变化天然成立。

坑 3:dispose 之后 setOption 报错

组件已卸载但父级的 watch 还在触发(异步竞态)。VChart 内部用 chart?.setOption(可选链)兜底;更严格的场景可在 onUnmounted 里置 chart = null 后所有访问自然短路。

坑 4:ResizeObserver 回调里做重活

resize 回调高频触发(scale 壳拖拽窗口时每帧一次),回调里只允许 chart.resize() 一个动作;任何"resize 时重算数据"的需求都改成 watch 容器尺寸的 ref + debounce。

坑 5:主题注册时机

industrial-dark 主题(Day 55)必须在 init 之前 echarts.registerTheme。VChart 从统一注册模块 import,main.ts 里先注册再挂载(Day 71 的"先主题后挂载"纪律在图表层的延续)。


七、自测挑战

T1 · VChart 组件(70 分钟)

完成第二节(含 3.1 的双保险版 init),在屏 B 挂一个静态 option 的柱状图,验证:切换 v-show 显示隐藏后图表正常、拖拽窗口 resize 生效、组件卸载无泄漏(Performance 三快照法)。

T2 · 温度曲线接入真数据(40 分钟)

完成第四节 TempLineChart,接 Day 75 的 useScreenStore().tempWindows,曲线实时推进。对照原生版录屏:曲线行为、markLine、动画关闭——三项一致。

T3 · 0 尺寸复现实验(20 分钟)

故意把 VChart 放进 v-if 的初始 false 分支再改 true,观察"不 init"现象与 ResizeObserver 兜底过程(Console 打点验证),理解双保险为什么缺一不可。


八、总结

老大难 解法 出处
0 尺寸容器 nextTick 实测 + ResizeObserver 引导,双保险 Day 52 坑 1 的 Vue 版
数据驱动 watch 引用变化 + setOption merge shallowRef 哲学延续
dispose 实例/观察器与组件同生命周期,onUnmounted 成对销毁 Day 69 排查位结构性解法
业务壳 组件只剩"翻译 option"一层 createLineChart 对照

基座就位。明天 BOSS 战:屏 B 五卡片整屏迁移 + 原生 vs Vue 全面对比。

评论