Day 76 · ECharts Vue 组件封装 — VChart:图表基座的三个老大难
ECharts 与 Vue 的结合是工业可视化的高频面试题,也是三个老大难的聚集地:0 尺寸容器 init 失败(Day 52 坑 1 的 Vue 版)、数据变化驱动更新(watch 的正确姿势)、dispose 与内存(Day 69 排查位的组件化解法)。今天把三者收进一个
VChart基座组件——它一旦写对,屏 B 的五个图表全部变成"传 option 的标签"。
目录
- 一、方案选型:自己封装 vs vue-echarts
- 二、VChart 组件实现
- 三、三个老大难的解法拆解
- 四、实战:温度曲线的 VChart 版
- 五、性能细节:setOption 的 merge 纪律
- 六、常见坑点
- 七、自测挑战
- 八、总结
一、方案选型:自己封装 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 树合并,只更新变化分支。两条推论:
- 每次传完整 option 是安全的——merge 保证只打差异补丁,120 点曲线的更新开销与手写"只 setOption series.data"几乎一致(Day 54 的优化自动继承)
- 曾被移除的系列要显式清理——如果系列数会变(如设备动态上下线),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 全面对比。