最近在做若依分离版的一个二次开发项目,需求是在生产管理模块里加几个可视化看板,把设备状态、订单进度、质量合格率这些数据用图表展示出来。折腾了一周多,把ECharts在若依分离版框架里跑通了,顺带把图表组件化的封装思路也整理了一遍。这篇接着上一篇学习记录,聊聊如何通过组件实现数据可视化,给正在做同类需求或者准备上手若依二次开发的朋友做个参考。
先交代一下背景。我这次用的是 RuoYi-Vue3 版本,前端 Vue 3 + Element Plus + TypeScript,后端 Spring Boot + MyBatis + Redis。数据源是生产管理模块里的设备状态表、工单进度表、质量检验表。需求方最初的说法很简单:"看板上能实时反映车间运行情况。"听起来一句话,真拆开做的时候才发现牵扯到数据来源、图表渲染、权限控制、定时刷新、大屏适配一堆问题。我把自己踩过的坑和最终落地的方案都整理在下面,按从设计到实现的顺序讲。
1. 项目概述与需求拆解
1.1 先搞清楚若依分离版的技术底座
若依分离版(RuoYi-Vue)是目前国内使用量很大的一个前后端分离脚手架,基于 Spring Boot 和 Vue 构建。它把登录认证、权限管理、字典管理、操作日志、代码生成这些通用能力都提前做好了,二次开发时不需要再从零搭建基础设施。说白了,它就是一个已经把"地基"和"水电管道"装好的毛坯房,你只需要按自己的业务去砌墙、贴砖。
我之前用过老的 RuoYi-Vue(Vue2 + Element UI)版本,这次切到 RuoYi-Vue3 也是不得已,新项目的前端规范要求 TypeScript。Vue3 版本在组件化、类型推导、编译性能上的优势比较明显,但也因此多了一堆 TS 类型上的坑,这个后面专门用一节讲。如果你只是做一个内部管理后台,Vue2 版本其实够用;如果团队已经统一了 Vue3 + TS 的规范,那直接上 RuoYi-Vue3 更合适。
还有一点要提醒:若依的生态里还有一个 RuoYi-Plus 增强版,多了一些额外的组件和功能,但官方分离版的学习资料最多,社区案例也最丰富。第一次接触若依的话,建议先用分离版把核心逻辑跑通,再考虑要不要上增强版。
1.2 可视化需求在真实项目里的三种形态
接手需求后,我没有直接开写图表代码,而是先花了一天把需求拆成了三种类型。因为不同形态的处理方式完全不同,混在一起做必然返工。
第一种是概览型看板,核心是让管理者一眼看清全局。比如车间地图上标注每台设备的运行/停机/维修状态,旁边放环形图展示订单按时交付率,用柱状图对比不同产线的产量。这类页面信息密度高,一屏要放四到六个图表,但交互很少,数据刷新周期可以长一些。
第二种是分析型报表,核心是筛选和钻取。按时间维度筛选、按班组维度对比、点击某个柱子弹出对应明细数据。这种更接近传统报表的需求,但是要嵌在若依的权限体系里跑,不同角色看到的范围还不一样。
第三种是告警监控型,核心是实时性和自动刷新。比如设备温度超限、质检合格率跌破阈值,要在看板上高亮弹出提示。这里轮询和状态切换是重点,图表本身反而简单。
拆完之后我意识到一个共同点:如果不做组件化,每接一个需求都重新写一遍 ECharts 初始化代码,改改数据、调调样式,短期内似乎很快,但项目里看板数量一旦超过三个,维护成本就会成倍上涨。所以方案选型上,我直接定了组件化封装的路子。
1.3 方案选型:为什么选择组件化封装而不是页面内直写
ECharts 本身的用法其实不复杂,无非就是 init、setOption、resize、销毁这几步。但问题在于它的配置项体系非常庞大,一个完整的 option 可能上百行,而且不同图表之间 tooltip、legend、grid 这些公共配置高度相似。如果每个页面都从零写一遍,代码会极度冗余。
举个例子,一个简单柱状图的配置就包含 xAxis、yAxis、series、tooltip、grid 五个核心模块,折线图又多了 smooth、symbol 之类的字段,饼图则完全换了一套配置逻辑。页面一旦多起来,你改一个公共样式可能要同时改五六个文件,漏掉一个就很尴尬。
所以我定下的方案是:写一个通用的 BaseChart 组件,对外只暴露 dataset、type、loading、optionCover 这几个参数,内部统一处理初始化、数据更新、窗口自适应和实例销毁。业务页面不再直接碰 ECharts,只需要把数据按约定格式传进来,配置一个图表类型,剩下的交给组件去处理。
技术选型上没有太多纠结,直接用了 ECharts,没有考虑 D3 或者 AntV。原因也很简单:ECharts 社区成熟、文档全、图表类型丰富,尤其在大屏场景下,地图、关系图、桑基图这些复杂图表的默认表现力很强。团队里其他人也都有 ECharts 的基础,换成别的库学习成本会高很多。
2. 数据可视化组件的核心设计
在写第一个图表之前,我先把组件的边界、通信方式、数据格式、权限集成这四块设计好。这一步看着繁琐,但做完了之后,后面每个业务页面的开发时间基本能缩短一半以上。
2.1 组件封装必须划清的三个边界
封装组件最怕的就是越写越"万能",最后什么需求都想往组件里塞,组件变成一个大杂烩。我在动手前给自己划了三条边界。
第一个边界是组件只负责画图,不负责拿数据。数据由父页面通过 props 传入,或者由组件内部调用统一的 fetch 函数获取,但组件内部绝不放置具体业务逻辑。这样图表和业务完全解耦,换个数据源不需要动组件本身。
第二个边界是组件对外只暴露必要的事件和方法。我除了透传 ECharts 的 click 等事件之外,还暴露了一个 getChart 方法,让父页面在极特殊情况下可以拿到图表实例直接操作。这个是兜底方案,常规业务不应该依赖它。
第三个边界是样式和主题由组件统一管理。图表的配色、字体大小、间距统一收敛在组件的公共配置模块里,不允许散落在各个业务页面。这样一个主题调整,全局所有图表跟着变,不用一个个页面去改。
上面这三点听起来抽象,落到代码上其实就是在组件内部把 option 的生成逻辑拆成两块:一块是图表类型对应的公共配置,比如 tooltip、grid、legend;另一块是业务数据对应的 series 数据。组件负责把两块合并后交给 ECharts 渲染,业务方只关心数据本身。
2.2 组件通信设计:父传子、子传父、事件解耦
Vue 组件通信的方式很多,props、emit、provide/inject、Pinia 都可以用。我在这个可视化组件里的设计是这样的:
父传子用 props,传递的数据主要有三个:dataset 表示图表数据,type 表示图表类型,loading 控制骨架屏显隐。dataset 是一个统一格式的数组结构,不分柱状图还是饼图;type 决定组件内部调用哪个 option 生成函数。
子传父用 emit,主要事件是 chart-click(图表点击)、chart-ready(图表初始化完成)、error(数据处理失败)。chart-click 特别关键,因为很多场景下点击柱状图的某一根柱子,需要联动右侧的明细表格刷新。这种交互如果不用事件抛出去,父页面就只能通过全局变量传值,代码会非常别扭。
如果页面里有多个图表需要联动,比如总览页面上一个时间范围筛选器要同时控制六个图表,我用 Pinia 来做状态同步。筛选条件统一写入 store,各个图表组件监听 store 里的变化后各自重新请求数据。这样比事件链式的 A 传 B、B 传 C 要清爽得多,调试的时候也能直接在 DevTools 里看到 store 的状态变化。
这里有个非常容易踩的坑:ECharts 实例一定不要直接放进响应式数据里。一旦把 chart 实例赋值给 ref 或者 reactive,Vue 的响应式系统就会去劫持 chart 对象内部的属性,很容易触发循环更新甚至控制台报错。正确做法是用 shallowRef 保存实例,只把它当作一个普通引用,不做深层响应式代理。
2.3 数据格式与后端接口的约定
组件设计好了,前后端数据格式不约定清楚,照样乱套。我用的是一个比较通用的 JSON 结构,几乎覆盖了我遇到的图表场景:
{ "categories": ["1月", "2月", "3月"], "series": [ { "name": "订单数", "type": "line", "data": [120, 200, 150] }, { "name": "交付数", "type": "bar", "data": [98, 180, 132] } ] }categories 是 X 轴的类目数据,series 数组对应 ECharts 里的一个或多个系列。组件拿到这个结构后,根据 type 参数生成对应的 option,再把 series 里的数据填进去。这个结构的好处是折线、柱状、饼图都能覆盖。饼图只需要把 categories 当作 legend 的类目,series 里的 data 映射成 value 就可以。
如果将来要支持双 Y 轴,在 series 的某一项里加一个 yAxisIndex 字段,组件内部稍微扩展一下就能兼容。这就是提前约定数据格式的好处:前端组件和后端接口可以并行开发,只要接口返回的结构符合约定,前端进度完全不受后端影响。
后端这里走的就是若依的普通 REST API,接口上加 @PreAuthorize 权限注解,前端调用用若依封装的 request 工具。整个链路不需要额外处理跨域和 token 传递,脚手架自带能力直接吃透,这也是用若依做二次开发最省心的地方。
2.4 和若依权限体系的深度绑定
很多人做若依二次开发时容易忽略一件事:菜单权限和接口权限是两套系统。前端路由是通过 v-hasPermi 指令控制按钮显隐,后端接口是通过 @PreAuthorize 注解控制访问。可视化页面往往涉及多个业务模块的数据,所以图表组件背后每一个数据接口都要单独配置权限标识,不能因为页面做了权限控制,就默认接口也安全了。
我在这个项目里的做法是:在若依的菜单管理里创建一个"生产看板"的菜单目录和子菜单,权限标识填 production:dashboard:list,然后在后端 Controller 对应查询方法上加上 @PreAuthorize("@ss.hasPermi('production:dashboard:list')")。这样没有权限的用户连菜单都看不到,即使手动输入路由,后端也会返回 403。
再说一个小细节:v-hasPermi 自定义指令只能作用在真实 DOM 元素上,ECharts 渲染出来的 canvas 内部是管不到的。所以如果图表上有导出、刷新这类按钮,而且这些按钮要根据权限显隐,就把它们放到 ECharts 的 toolbox 配置里,同时配合前端的权限判断单独控制。我一开始没注意,结果后端接口权限没问题,前端却把没有权限的刷新按钮渲染到了 canvas 里,很难看。
3. 实操过程与核心代码实现
设计做完,就开始落地。这一节我从零开始写一个可用的 BaseChart 组件,配套后端统计接口、页面组装和菜单配置,最后讲大屏适配和定时刷新。代码部分是完整的,可以直接在若依项目里复用。
3.1 从安装依赖到基础图表组件落地
先装依赖,这里注意版本锁定:
npm install echarts@5 --save我建议按需引入,不要全量引入。全量引入会把 ECharts 里所有图表和组件都打包进来,体积能大到几百 KB,首屏加载明显变慢。按需引入的做法是:
import * as echarts from 'echarts/core' import { BarChart, LineChart, PieChart } from 'echarts/charts' import { GridComponent, TooltipComponent, LegendComponent, TitleComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' echarts.use([ BarChart, LineChart, PieChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent, CanvasRenderer ])然后用 TypeScript 写一个 BaseChart.vue。核心逻辑是:接收 dataset 和 type,监听 dataset 变化后调用 setOption,初始化时绑定 resize 事件,卸载时销毁实例并解绑事件。
<script setup lang="ts"> import { ref, onMounted, onBeforeUnmount, watch, shallowRef } from 'vue' import * as echarts from 'echarts/core' import type { EChartsType } from 'echarts/core' const props = defineProps<{ dataset: any type: string loading?: boolean }>() const emit = defineEmits(['chart-click', 'chart-ready', 'error']) const chartEl = ref<HTMLDivElement | null>(null) const chart = shallowRef<EChartsType | null>(null) const buildOption = (dataset: any, type: string) => { // 根据 type 生成不同 option,这里以柱状图为例 return { tooltip: { trigger: 'axis' }, grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true }, xAxis: { type: 'category', data: dataset.categories || [] }, yAxis: { type: 'value' }, series: (dataset.series || []).map((s: any) => ({ name: s.name, type: s.type || type, data: s.data || [], smooth: true, barMaxWidth: 32 })) } } const renderChart = () => { if (!chartEl.value) return if (!chart.value) { chart.value = echarts.init(chartEl.value) chart.value.on('click', (params: any) => { emit('chart-click', params) }) } chart.value.setOption(buildOption(props.dataset, props.type), true) emit('chart-ready', chart.value) } onMounted(() => { renderChart() window.addEventListener('resize', handleResize) }) onBeforeUnmount(() => { window.removeEventListener('resize', handleResize) chart.value?.dispose() chart.value = null }) watch(() => props.dataset, () => { renderChart() }, { deep: true }) const handleResize = () => { chart.value?.resize() } </script> <template> <div> <div ref="chartEl" class="base-chart" :style="{ height: '360px' }"></div> <el-skeleton v-if="loading" :rows="6" animated class="chart-skeleton" /> </div> </template>组件里有些细节值得说明一下。watch 里加 deep: true 是因为 dataset 通常是嵌套对象,如果父页面在原有引用上直接改字段,浅比较会失效。不过 deep watch 在数据量大时有性能开销,所以如果每次接口返回的都是全新对象,用不到 deep。我后面的写法就是让父页面每次赋值一个新对象,这里的 deep 其实可以去掉,留着只是为了应对一些粗糙的写法。
setOption 的第二个参数传了 true,表示 notMerge。意思就是每次设置都完全替换掉旧配置,而不是增量合并。这样在图表类型切换或者数据字段变化比较大的场景下,不会残留上一次的旧状态下拉,导致显示错乱。如果只是更新数据且图表类型不变,也可以传 false,ECharts 会做平滑的动画过渡。
3.2 编写后端统计接口与数据组装
前端组件就绪,开始写后端接口。数据库查询我用的还是 MyBatis,Mapper XML 里写统计 SQL。举一个例子,统计本月每日的订单数量:
<select id="selectDailyOrderCount" resultType="java.util.Map"> SELECT DATE_FORMAT(order_date, '%Y-%m-%d') AS category, COUNT(*) AS count FROM production_order WHERE order_date BETWEEN #{startDate} AND #{endDate} GROUP BY DATE_FORMAT(order_date, '%Y-%m-%d') ORDER BY category </select>Service 层把查询结果组装成前端约定的 JSON 结构:
public AjaxResult getDashboardSummary(DashboardQuery query) { List<Map<String, Object>> rows = productionOrderMapper.selectDailyOrderCount(query); List<String> categories = new ArrayList<>(); List<Object> values = new ArrayList<>(); for (Map<String, Object> row : rows) { categories.add(String.valueOf(row.get("category"))); values.add(row.get("count")); } Map<String, Object> result = new HashMap<>(); result.put("categories", categories); List<Map<String, Object>> series = new ArrayList<>(); Map<String, Object> orderSeries = new HashMap<>(); orderSeries.put("name", "订单数"); orderSeries.put("type", "bar"); orderSeries.put("data", values); series.add(orderSeries); result.put("series", series); return AjaxResult.success(result); }这段代码逻辑很简单,核心工作就是数据形态转换。实际项目里可能要多表查询、多个指标,我会在 Service 里组合查询或分别查询后再组装。需要提醒一个老坑:从 MyBatis 返回的 Map 里取字段时,字段名受数据库列命名策略影响。如果数据库字段是下划线命名,比如 order_date,而 MyBatis 没有开启驼峰映射,取出来的 key 还是 order_date,不是 orderDate。所以要么在 SQL 里给字段起别名,要么在若依的配置文件里打开 map-underscore-to-camel-case。我一开始就踩了这个,前端一直说 categories 为空,排查了半天结果是字段名对不上。
3.3 页面组装与菜单配置
后端接口有了,组件也有了,把它们组合成一个业务页面就很快了。我在若依的 src/views 目录下创建了 production/dashboard/index.vue,页面结构大概是:
<template> <el-card> <el-row :gutter="16"> <el-col :span="12"> <BaseChart :dataset="orderData" type="bar" :loading="loading" /> </el-col> <el-col :span="12"> <BaseChart :dataset="deliveryData" type="line" :loading="loading" /> </el-col> </el-row> </el-card> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' import { getDashboardSummary } from '@/api/production/dashboard' import BaseChart from '@/components/BaseChart/index.vue' const orderData = ref({ categories: [], series: [] }) const deliveryData = ref({ categories: [], series: [] }) const loading = ref(false) onMounted(async () => { loading.value = true const res = await getDashboardSummary({}) // res 是若依封装的响应,data 字段里是 categories 和 series orderData.value = { categories: res.data.categories, series: res.data.series } deliveryData.value = { categories: res.data.categories, series: res.data.series } loading.value = false }) </script>这里要注意,若依的 request 封装统一返回 { code, msg, data } 结构,code 为 200 才表示成功。所以取数据要用 res.data,不要直接拿整个 res 赋给 dataset,不然图表组件拿到的结构完全不对。
菜单配置走若依后台的"系统管理 -> 菜单管理"。新增目录"生产看板",再往下加具体菜单:路由地址填 dashboard,组件路径填 production/dashboard/index,菜单类型选"菜单",权限标识填 production:dashboard:list。配置完让管理员重新登录或者刷新路由,菜单就出现在侧边栏了。
这里有个新手经常绕不明白的点:如果角色没有分配菜单权限,即使前端文件存在,路由也不会生效,没有任何报错,页面就是打不开。所以测试时不要只盯着文件看,先检查当前用户的角色有没有挂这个菜单。我带过好几个新人,他们都说"我明明写了页面为什么路由不显示",十有八九是漏了角色分配。
3.4 大屏适配与定时刷新的处理
可视化做出来只是第一步,真正让人挠头的是大屏适配和定时刷新。先说大屏。大屏上如果还用手工写死的 px 单位,在不同分辨率的屏幕上字体会忽大忽小,图表比例也会失调。我的处理方案是:页面设定一个基准宽度 1920,根节点的 font-size 按实际宽度除以基准宽度来动态计算,图表和文字统一用 rem 单位。ECharts 的 option 里凡是涉及字体大小的,都用自定义的 px2rem 函数转一遍。
这种方案不是唯一选择,也可以用 scale 缩放整体布局,但对多图联动和自适应高度不太友好。rem 方案在若依项目里实施成本最低,只需要在大屏页面入口写一段动态计算根字号代码即可。
定时刷新方面,核心是数据自动更新但页面不能闪。我在组件外部维护一个 setInterval 定时请求接口,每次拿到新数据后先更新 loading 状态,再用一个全新的对象覆盖 dataset。这样图表会通过 setOption 自动做过渡动画,视觉上不会有白屏或者闪烁。
关键点是定时器必须在组件卸载时清理掉。很多人用的是路由钩子,但在若依的多页签场景下,菜单页可能被缓存也可能被销毁,生命周期和普通路由不完全一样。我最终的做法是只在组件实例里管理:onMounted 里开定时器,onBeforeUnmount 里清理,同时监听若依 TagView 的关闭事件,手动关闭页签时也主动清理定时器。否则你切到别的菜单再切回来,旧的定时器还在后台跑,数据一直刷新,白耗资源,严重的还会导致图表实例冲突。
4. 常见问题与排查技巧实录
这一节把我在折腾过程中遇到的典型问题整理成一份速查表,方便以后直接照着排查。
| 现象 | 可能原因 | 快速处理办法 |
|---|---|---|
| 图表白屏 | 容器高度为 0 | 给容器设置 min-height,组件加载后再 init |
| 图表不更新 | dataset 引用没有变化 | 每次赋值一个新对象,触发 watch |
| TS 编译报错 | ECharts 类型引用错误 | 用 EChartsType,不要用 ECharts |
| 图表变形 | 窗口尺寸变化没处理 | 绑定 resize 事件,Tab 切换时手动 resize |
| 定时器重复请求 | 组件缓存未清理 | onBeforeUnmount 里 clearInterval |
| 接口 403 | 权限标识未配置 | 检查菜单权限和后端 @PreAuthorize |
| 数据解析为空 | 后端字段名下划线 | SQL 起别名或开启驼峰映射 |
下面挑几个高频问题详细展开,都是实际写过跑过之后才总结出来的东西。
4.1 Vue3 和 TypeScript 的报错与类型坑
若依 Vue3 版本 + TS 最让人头疼的问题集中体现在 ECharts 实例类型的写法上。很多老教程里写的是:
import * as echarts from 'echarts' const chart = ref<echarts.ECharts | null>(null)这句在 Vue3 的 TS 环境里很容易报错,因为 ECharts 5 按需引入之后,类型入口变成了 echarts/core 里的 EChartsType,而不是命名空间下的 ECharts。正确写法是:
import type { EChartsType } from 'echarts/core' const chart = shallowRef<EChartsType | null>(null)还有一个常见问题是 props 的类型定义。如果直接用 any 定义 dataset,虽然省事,但后续接口字段一旦变更,编译器完全帮不上忙。我建议至少定义一个松散的接口类型,把 categories 和 series 的结构固定下来,即便某些业务需要额外字段,也可以在这个基础上扩展。
4.2 图表白屏和 Tab 切换后不显示的怪异现象
图表初始化时容器宽度为 0,是白屏最常见的原因。在若依的布局体系里,菜单切换时页面经常处于隐藏状态,隐藏状态下容器宽度天然就是 0,ECharts 初始化出来自然什么都画不出来。你切走再切回来,如果不主动调用 resize,chart 也不会自动恢复。
我排查这种问题时的固定套路是:先在浏览器控制台打印 chartEl 的 offsetWidth,如果是 0,说明容器布局还没出来就 init 了。解决方案有两个方向,一个是给容器设置最小高度,另一个是在 Tab 切换事件里调用 resize()。若依的 Layout 组件有 tab 切换逻辑,可以在主页面 watch 当前 TagView 的 path,变化时对页面内所有图表实例统一 resize。我在 BaseChart 组件里也对 window resize 做了监听,但 Tab 隐藏导致的高度变化不触发 window resize,所以必须手动补一发。
另外,按需引入 ECharts 时漏组件也会导致白屏。比如用了饼图但只注册了 BarChart,没有引入 PieChart,ECharts 会在控制台打警告,但图表区域依然是空白。排查时先看控制台有没有这类警告,一般一眼就能定位。
4.3 组件通信失效的根源
有一阵子图表不更新,排查了很久,最后发现是父页面用响应式对象直接改了内部字段,而不是整体替换引用。Vue 的 watch 默认只比较对象的引用地址,虽然 deep 监听可以在某些情况下触发,但依赖 deep 来感知变化总是不那么可靠。
我在项目里定的规矩是:接口数据返回后,一律生成一个新对象再赋值给 ref,让子组件稳定感知变化。比如:
const res = await getDashboardSummary({}) orderData.value = { categories: res.data.categories, series: res.data.series }不要写成 orderData.value.categories = res.data.categories。前者每次都是新引用,后者只是在原对象上改了属性,watch 如果不加 deep 就不会触发。这个习惯在数据量大、嵌套层级深的时候尤其重要,deep watch 的性能隐患很大。
4.4 时序与资源释放问题
图表页里最容易埋雷的是 setInterval 的资源释放。我见过一个项目,页面上有三个图表,每个图表组件内部都开了一个 30 秒的定时器,组件被销毁后定时器没有清理,结果用户退出页面后接口请求还在继续,一天能产生几千条无意义的请求日志。
正确的写法是用生命周期钩子成对管理:onMounted 里开,onBeforeUnmount 里关。如果你用了 keep-alive 缓存组件,那么 deactivated 和 activated 也要配合处理。在若依的场景里,简单粗暴地每次进入页面都重新初始化、离开页面就销毁,反而最不容易出问题。
4.5 和若依框架本身相关的几个小坑
最后说几个我在集成过程中遇到的若依框架特有的问题。第一个是接口响应结构的兼容问题,特别是当后端接口通过网关转发时,响应结构可能被包装一层,直接取 res.data 会拿到错误的数据结构。稳妥做法是通过 response 拦截器统一处理后再返回,或者在接口定义层就做一次解包。
第二个是字典翻译。若依前端用 dicts 组件做字典翻译很方便,但 ECharts 的图表内部没法直接用这个组件。如果要在图表的 label 或者 tooltip 里显示"设备状态:运行"这种文案,得在页面取数据时就把字典标识翻译成中文,或者后端直接下发字典标识,前端再通过 useDict 映射成文案后传给图表组件。不然图表上显示一堆 0、1、2 的数字,领导看了也看不懂。
第三个是打包兼容。若依 Vue3 项目默认没装 ECharts,装完依赖后运行 npm run dev 可能遇到一些版本兼容警告。ECharts 本身问题不大,但如果你装了最新版可能和 vite 的预构建缓存有冲突,常见现象是启动后图表区域白屏、控制台报 module 解析错误。解决方法是固定 ECharts 版本,清理 node_modules 和 vite 缓存后重新安装,基本就能解决。
5. 组件化思路的延伸与一点心得
这一节不写太多代码,聊一聊我在完成第一个可视化页面之后的延伸思考。毕竟做一次项目,最后留下来的不该只是一两个页面,而是一套可以复制的方法。
5.1 从单图组件到可视化页面引擎
当项目里图表组件多起来之后,我慢慢发现很多页面长得越来越像:上方一排筛选器,中间几个图表,下方一张明细表格。这种页面如果每个都手写模板和逻辑,其实是在重复造轮子。
我后来把 BaseChart 又升级成了 ChartBoard,通过一组 JSON 配置来描述页面结构,包括图表类型、位置、数据接口、刷新频率、联动关系。业务人员只需要改配置,就能生成一个新的看板页面,不需要写前端代码。当然,这个抽象不是第一天就做的,是在做了四五个看板页面之后总结出来的规律。如果你只是快速做个 demo,不建议一上来就建设这种引擎,先泡一段时间的重复劳动,再判断是否需要抽象,更稳妥。
5.2 和若依代码生成器的配合使用
若依自带的代码生成器可以帮助快速生成单表 CRUD 的前后端代码,这个能力在二次开发里非常实用。我的经验是,先通过代码生成器把业务表对应的 Service、Mapper、列表管理页面生成出来,再在生成的管理页面里嵌入图表组件。这样既能保证基本的增删改查功能可用,又能在列表上方提供统计可视化,两边互不干扰。
比如订单管理页面生成好之后,我在页面顶部加了一行图表,筛选条件同时控制列表和图表。点击柱状图时,通过之前设计的 chart-click 事件,可以把列表的查询条件改成对应日期,然后去刷新列表。这个交互比较顺滑,体验也好。
有一点要注意:代码生成器生成的页面里,查询表单的初始化逻辑是固定的,图表加入后需要同步调整查询按钮的事件,确保点击查询时同时刷新列表和图表。如果忘了,用户会看到列表数据变了、图表没变,非常影响体验。
5.3 性能优化与懒加载细节
可视化页面往往图表多、数据量大,性能优化不能等到上线前才做。我这边落地了三个优化动作。
第一个是按需引入 ECharts,只注册用到的图表和组件,打包体积能缩小一半以上。第二个是数据量大的时候开启采样,ECharts 的折线图默认采样效果还可以,1000 条以上的数据点能保持视觉无损的同时明显降低渲染压力。第三个是用 v-if 控制非核心图表的渲染时机,首屏只渲染关键指标,等数据返回后再插入其他图表,减少首屏白屏等待时间。
还有一个容易被忽略的点:路由懒加载组件时,如果给图表容器设置了 v-if 条件渲染,一定要给容器设定一个 min-height,否则懒加载的瞬间容器高度可能为 0,图表 init 之后是空的,后面即使数据来了也不一定渲染。这个在低配服务器上访问大屏的时候尤其容易出现,一卡就是白屏,用户感知很糟糕。
5.4 组件化过程中的一些心得
最后说点个人体会。做组件化的初衷,从来不是为了显得代码高级,而是为了让业务需求变化时你能用最小的代价去响应。我在最开始其实也想着"直接把页面做出来拉到",但后来随着看板数量增加,复制粘贴的成本越来越高,才真正理解了封装的意义。
组件化也不是一步到位的。我的 BaseChart 前后重构了三次:第一次只做了初始化和销毁的封装,第二次加入了 loading 和事件透传,第三次才加了主题管理和按需渲染。每一次重构都是被真实需求逼出来的,不是为了设计模式而设计模式。
如果你也在做若依的二次开发,我的建议是先找一个小需求,完整实现一个图表组件从封装到使用再到改版的过程,跑通了再横向推广。这样踩坑的成本最低,也最能体会框架能力和业务需求之间的边界在哪里。数据可视化这个需求本身并不复杂,复杂的永远是对数据的理解、对权限的控制,以及对资源释放和性能的敬畏。把这些问题都处理干净,图表本身反而是最简单的那一部分。