拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ECharts可视化大屏开发:适配、封装与经典图表案例汇总

ECharts可视化大屏开发:适配、封装与经典图表案例汇总

做可视化大屏这件事,我前后经手过十几块屏,从最早用原生 JS 拼 DOM、jQuery 手动算坐标,到后来整套 Vue + ECharts 三件套,再到现在 Vue3 + Vite + ECharts 5 外面再包一层统一的图表组件,踩过的坑差不多能编成一本小册子。这篇就把我在 ECharts 可视化大屏项目里反复用到、反复翻车的经典案例做个系统汇总:大屏适配怎么选方案、折线图 x 轴刻度挤成一团怎么救、tooltip 长文本怎么自动换行、饼图 labelLine 末尾那个小圆点为什么会偏移、柱状图能不能用自定义图片当柱子、中国地图和省份温度这类专题图怎么搭,以及企业级数据可视化项目里怎么把图表封装成真正能复用的东西。刚上手 ECharts 的同学可以把它当一份带注释的速查手册直接抄,做过几块屏的老手也可以对照着看看有没有更省事的写法。

1. 大屏项目的整体设计思路与选型拆解

1.1 为什么大屏这个场景 ECharts 依然是首选

可视化这个大方向里,图表库换了一茬又一茬,但落到"大屏"这个具体场景上,ECharts 开源库实现绘图依然是最稳的选择,原因不在于它画得最好看,而在于它把大屏最需要的那几件事都做完了:一是图表类型足够全,从折线、柱状、饼图这些基础款,到地图、关系图、桑基图、自定义系列(custom),基本不用换库;二是配置项颗粒度够细,大屏最烦的就是"默认样式太素",而 ECharts 的 itemStyle、label、labelLine、rich 富文本这些配置能把每个像素都掰开揉碎地调;三是它对非地理坐标系和地理坐标系的统一抽象,地图叠散点、地图叠飞线这种刚需场景,geo 和 series 之间用 coordinateSystem 一挂就通。

更现实的一点是社区生态。遇到一个奇怪的需求,比如"饼图 labelLine 末尾想加小圆点",去官方示例库和社区里搜一下,八成有人已经写过 demo 了。这在大屏这种交付周期极短、要求极刁钻的项目里,能省下大量时间。相比之下,那些更"现代"的图形语法库在定制化程度上往往要绕更远的路,做大屏反而更慢。

1.2 技术栈组合怎么选:Vue3 + Vite 还是原生三件套

把原生 js、jquery、ajax、echarts 结合制作网页这套路现在还有人在用,维护老项目的时候你绕不开。但如果是从零开始的新项目,我基本不会再推荐这条路了。原因很直白:大屏的本质是"一个页面里塞二十个图表 + 一堆定时器 + 一堆 resize 逻辑",用 jQuery 管理这些状态,很快就会变成几百行的 DOM 操作泥潭,改一处崩一片。

Vue3 可视化大屏是目前最舒服的组合。ref 拿 DOM,onMounted 里 init,onBeforeUnmount 里 dispose,生命周期天然对齐图表的创建和销毁;把 ECharts 实例用 shallowRef 或 markRaw 包起来,就不会被 Proxy 劫持导致性能雪崩——这个坑非常隐蔽,早期我用 ref 存实例,图表一多风扇就开始转。Vite 负责开发体验和构建,按需引入 ECharts 模块把包体压下来,大屏首屏加载能省好几百 KB。

至于 React、Svelte 还是纯 TypeScript 项目,思路是一样的:图表实例不要放进深度响应式系统里,生命周期要对齐,resize 要统一收口。技术栈不是重点,重点是别让框架的响应式去碰 ECharts 的内部对象。

1.3 信息架构:先把"看什么"定下来,再谈"怎么画"

我见过很多人一拿到设计稿就开始写 option,写着写着发现数据对不上、布局放不下,然后返工。正确的顺序是先拆信息架构:这块屏给谁看、在什么场景下看、看完要做什么决策。比如森林防火可视化大屏,看的人是值班调度,核心诉求是"哪里有热点、火险等级多高、最近的力量在哪、多久能到",那么中间那块地图就必须是视觉重心,热点用带涟漪效果的 effectScatter,力量部署用图标散点,蔓延预测用 lines 飞线,周边三块放火险等级分布、告警列表、物资库存。

架构定完之后,布局其实就是填空:1920×1080 的画布切成 12 或 24 栅格,主轴区域占 40% 到 50%,两侧各占 25% 左右,顶部一条标题和数据总览,底部一条趋势图。这个比例不是拍脑袋来的,是因为人眼在看大屏时中心视野最敏锐,把最重要的信息放在中心区域,两侧放辅助信息,符合大屏"远距离扫读"的使用方式。定完这些再去写 option,返工率会低很多。

2. 大屏适配:从 1920 设计稿到任意分辨率的落地方法

2.1 三种主流适配方案的横向对比

大屏适配这块,行业里基本就三条路,我把它们的原理、优缺点和适用场景整理成一张表,你可以直接对号入座。

方案实现方式优点缺点适用场景
整体等比缩放固定 1920×1080 画布,外层transform: scale(s)实现最简单,布局绝对不跑偏,开发体验和设计稿一致放大时文字发虚,缩小后字号偏小;缩放容器内的交互需留意交付周期紧、分辨率固定的汇报大屏
rem 动态根字号postcss-pxtorem转换 CSS,JS 动态设html字号CSS 布局能自适应,字体大小统一对 ECharts 内部字号无效,需要额外补偿(见 2.2)中等复杂度、需要真实自适应的项目
vw/vh + 手动缩放系数CSS 用相对单位,JS 算出scale传给 ECharts option文字清晰,图表字号可精确控制需要写一套字号计算工具函数,工作量最大长期维护、多分辨率适配的企业级项目

我自己的默认选择是方案一打底、方案三兜底:先用整体等比缩放快速把屏搭起来,如果甲方后面开始提"要在 2K 和 4K 上都清晰",再把字号计算这一层补上。不要一上来就追求最完美的方案,交付节奏比技术洁癖重要。

整体等比缩放的实现细节值得说清楚。画布固定 1920×1080,外层包一个容器,用transform-origin: left top配合left: 50%; top: 50%; margin-left: -960px; margin-top: -540px居中,或者更省事一点用transform: translate(-50%, -50%) scale(s)配合left: 50%; top: 50%。计算 scale 的时候取宽高比的最小值,保证内容不溢出:

// useScale.js import { ref, onMounted, onBeforeUnmount } from 'vue' export function useScale(designW = 1920, designH = 1080) { const scale = ref(1) let raf = null const compute = () => { const w = window.innerWidth const h = window.innerHeight // 取最小值,保证宽高都能装下,不裁切 scale.value = Math.min(w / designW, h / designH) document.documentElement.style.setProperty('--screen-scale', scale.value) } const onResize = () => { // 用 rAF 节流,避免拖拽窗口时高频触发导致卡顿 if (raf) cancelAnimationFrame(raf) raf = requestAnimationFrame(compute) } onMounted(() => { compute() window.addEventListener('resize', onResize) }) onBeforeUnmount(() => { window.removeEventListener('resize', onResize) if (raf) cancelAnimationFrame(raf) }) return { scale } }

注意:window.addEventListener('resize', onResize)里的 onResize 必须是命名函数,不能用匿名函数,否则removeEventListener移除不掉,频繁切换页面时会累积监听器,这是大屏内存泄漏最常见的原因之一。

还有一个很少有人提的细节:整体缩放之后,canvas 是按 devicePixelRatio 渲染再被 CSS 拉伸的,scale 大于 1 或者在 4K 屏上会明显发虚。解决办法是初始化时把 dpr 手动放大一点,用缩放系数做补偿:

const dpr = Math.min(window.devicePixelRatio * scale.value, 2.5) chart = echarts.init(dom, null, { renderer: 'canvas', devicePixelRatio: dpr })

这里的 2.5 是一个上限,不是随便定的。dpr 翻倍会让 canvas 的像素数量变成四倍,内存占用同步翻四倍,一块屏上十几个图表,如果不封顶,低配一体机直接卡死。实测在 1080P 一体机上,dpr 控制在 2 以内体验最好,文字锐度也够。

2.2 为什么 postcss-pxtorem 对 ECharts 图表的字号没效果

这个问题在 Vue3 项目里被问得特别多:明明配了 postcss-pxtorem,CSS 里的字号都跟着根字号缩放了,为什么图表里的坐标轴文字、图例文字纹丝不动?答案很简单:postcss-pxtorem处理的是样式文件里的 px,而 ECharts 的字体大小是写在JavaScript 的 option 对象里的数字,两者根本不在一个处理链路上。ECharts 拿到fontSize: 12之后,会在 canvas 上按 12 个物理像素绘制,它不知道什么叫 rem,也不知道根字号变了。

所以正确的做法是留一个"缩放系数"的出口,让 option 里的字号乘上这个系数。最简单的实现是挂一个全局变量,在setOption之前算一遍:

// chartScale.js let fontScale = 1 export const setFontScale = (s) => { fontScale = s } export const fs = (px) => Math.round(px * fontScale)

然后在图表的 option 里,所有和尺寸相关的数字都走fs():

export function lineOption(data) { return { grid: { top: fs(40), left: fs(50), right: fs(30), bottom: fs(40) }, xAxis: { type: 'category', data: data.categories, axisLabel: { fontSize: fs(12), color: 'rgba(200,225,255,.7)' } }, yAxis: { type: 'value', axisLabel: { fontSize: fs(12) }, splitLine: { lineStyle: { color: 'rgba(120,170,255,.12)' } } }, series: [{ type: 'line', data: data.values, smooth: true }] } }

这个fs()覆盖的范围要包括:grid 的内边距、坐标轴字号和 margin、图例字号和 itemGap、tooltip 字号、series 的 symbolSize、柱状图的 barWidth、饼图的 radius 和中心点偏移、label 的 padding 和 distance。漏掉任何一项,在非 1920 分辨率下都会出现"图能撑开但字挤在一起"的诡异现象,排查起来非常费时间。我的经验是:新项目一开始就把 fs() 用上,别等到适配阶段再回来补,补这个的时间成本至少是写的时候的三倍。

2.3 容器尺寸变化与 resize 的正确处理姿势

大屏上图表不复位、被压缩成一条线、或者切换全屏后糊掉,十有八九是 resize 没处理好。ECharts 自己不会监听容器尺寸,你必须主动调用chart.resize()。但什么时候调用、调用几次,是有讲究的。

老写法是监听window的 resize,然后遍历所有图表实例挨个 resize。这个做法的问题是:只有窗口变化才会触发,如果容器是因为侧边栏收起、路由切换、tab 切换而变宽的,window 根本没动,图表就僵在那里。现代浏览器都支持ResizeObserver,直接观察图表容器本身,这才是正解:

const ro = new ResizeObserver(() => { // 加一层 rAF,避免 ResizeObserver loop 警告和频繁重绘 requestAnimationFrame(() => chart && chart.resize()) }) ro.observe(containerEl)

ResizeObserver有个著名的坑是 "ResizeObserver loop completed with undelivered notifications" 报错,原因是在回调里改了被观察元素的尺寸,导致无限循环。上面那层requestAnimationFrame就是为了把写操作推到下一帧,绕开这个循环检测。另外ro.disconnect()一定要在组件卸载时调用,配合chart.dispose()一起做,否则被销毁的 DOM 仍然挂在观察列表里,内存会一点点涨上去。

还有一种情况是容器初始尺寸为 0,比如图表在v-if的弹窗里、或者父级用display: none隐藏着。这时候echarts.init会拿到 0 宽高,实例虽然建起来了,但什么都不显示。处理办法是在容器真正显示之后再 init,或者await nextTick()之后再 init,实在不好控制就chart.resize()补一刀。

3. 高频经典图表案例逐个拆解

3.1 折线图:x 轴刻度挤成一团怎么救

折线图是大屏上最常用的图表,而 x 轴刻度是折线图最常出问题的部位。当分类有 24 个甚至 30 个以上时,默认的自动隐藏策略会把标签隔一个显示一个,看起来还行;但一旦你把interval设成 0 想全部显示,立刻变成一片黑糊糊的色块。这里有几套按优先级排列的处理手法。

第一优先级是换排列方式。rotate倾斜 30 到 45 度是最省事的,但注意倾斜之后标签的左端会贴着刻度线,看起来歪歪扭扭,需要配align和margin微调:

xAxis: { type: 'category', boundaryGap: false, axisTick: { alignWithLabel: true, lineStyle: { color: 'rgba(120,170,255,.5)' } }, axisLabel: { interval: 0, rotate: 35, margin: 12, align: 'right', // 倾斜后右对齐,让文字贴近刻度线 fontSize: fs(11), color: 'rgba(200,225,255,.7)', formatter: (v) => (v.length > 6 ? v.slice(0, 6) + '…' : v) } }

第二优先级是分两行显示。ECharts 的轴标签支持在 formatter 里返回数组,数组的每个元素会渲染成一行,这对于"2024-06-11 08:00"这种带日期时间的标签特别有用,比转 45 度更好读:

formatter: (v) => { const [d, t] = v.split(' ') return [d, t] // 返回数组即为多行 }

第三优先级是只留关键点。24 小时的数据里,其实只有几个时段值得标注,可以用 formatter 判断值来选择性显示:

formatter: (v, index) => (index % 4 === 0 ? v : '')

这里有个经验:interval: 0和hideOverlap: true不要同时开。前者是"强制全部显示",后者是"重叠就自动隐藏",语义是打架的,最终表现取决于 ECharts 内部的执行顺序,容易出现"在 A 电脑上是全显示、在 B 电脑上被隐藏了"这种玄学问题。选一个就好。

如果实在放不下,还有个"降维"思路:把折线图的 x 轴换成时间轴(type: 'time'),配dataZoom只展示最近一段,让用户自己拖——不过大屏是无人值守的展示场景,没有鼠标交互,这个方案一般只用在带操作台的监控屏上。

3.2 tooltip 长文本自动换行的两种可靠写法

tooltip 自动换行是大屏上另一个高频问题。大屏空间紧张,tooltip 一旦遇到长文案就往屏幕外跑,或者撑成一条横线。ECharts 默认的 tooltip 是不换行的,因为它的容器默认white-space: nowrap。有两种可靠的处理方式。

第一种是用 HTML 字符串 + CSS 强制换行,顺便用正则做定宽切分:

tooltip: { trigger: 'axis', confine: true, // 关键:把 tooltip 限制在图表容器内,不跑出屏幕 extraCssText: 'max-width: 320px; white-space: normal; word-break: break-all; line-height: 20px; box-shadow: 0 4px 16px rgba(0,0,0,.5);', formatter(params) { // 按固定长度插入换行符,中文场景每行 16~18 个字比较顺眼 const wrap = (str, len = 16) => String(str).replace(new RegExp(`(.{${len}})`, 'g'), '$1<br/>') return params .map((p) => `${p.marker}${p.seriesName}:${wrap(p.value)}`) .join('<br/>') } }

extraCssText是这套写法的核心,white-space: normal解开了不换行的限制,word-break: break-all保证长英文串或者长数字也能断行,confine: true解决贴边溢出。三个缺一个都会出问题。

第二种是不写 HTML,直接在字符串里用\n。ECharts 在处理字符串返回值时会把\n转成<br/>,所以可以这样写:

formatter: (params) => { const p = Array.isArray(params) ? params[0] : params const label = p.name const value = String(p.value) // 手动折行,避免超长 const lines = value.match(/.{1,16}/g) || [value] return `${label}\n${lines.join('\n')}` }

两种写法我更推荐第一种,因为 CSS 层面可控的东西更多:圆角、内边距、边框、阴影、行高,都能在extraCssText里一次性配完。第二种适合在用renderMode: 'richText'的场景,注意富文本模式不支持 HTML 标签,也不能用 CSS,所以那种情况下只能靠\n和textStyle.width配合。

提示:大屏通常同时开好几个 tooltip 触发源(axis、item),如果发现 tooltip 的层级被地图或者某个position: absolute的装饰层盖住,给 tooltip 加z: 9999或者把extraCssText里补上z-index: 9999,比改 DOM 结构快得多。

3.3 柱状图:用自定义图片做柱子(含 3D 观感)

"柱状图柱子能不能用自定义图片显示"这个问题,答案是能,而且有两种做法,效果和适用场景不太一样。

第一种是pictorialBar(象形柱图),把symbol设成image://图片地址,这是最常用的做法,能做出"整根柱子就是一张图片"的效果:

option = { xAxis: { type: 'category', data: ['一月', '二月', '三月', '四月'] }, yAxis: { type: 'value' }, series: [ // 底槽:用半透明色块占位,视觉上把柱子的"高度基线"补出来 { type: 'bar', barWidth: 18, itemStyle: { color: 'rgba(80,150,255,.12)', borderRadius: 9 }, silent: true, data: [100, 100, 100, 100] }, // 图片柱:symbolBoundingData 决定图片拉伸的基准高度 { type: 'pictorialBar', symbol: 'image:///assets/bar-liquid.png', symbolRepeat: 'fixed', symbolMargin: 2, symbolClip: true, // 关键:超出的部分被裁掉,形成"填充"效果 symbolBoundingData: 100, symbolSize: [18, 6], // 每一段图片切片的高度 data: [62, 78, 45, 91], z: 3 } ] }

symbolClip: true是这套写法的灵魂。不写它,图片会整张画在柱子顶端,看起来像贴了个标签;写了它,图片会沿着 y 轴方向被"填充",柱子的高度信息才被正确表达。symbolSize的第二个值(切片高度)决定了图片的重复密度,一般取图片高度的 1/2 到相等,太小会看到明显的横向纹理。

第二种是itemStyle.color直接用图片填充,ECharts 5 支持{ image, repeat }这种对象写法:

series: [{ type: 'bar', itemStyle: { color: { image: document.getElementById('barBg'), // 也可以是 new Image() 或者 canvas repeat: 'repeat' // repeat-x / no-repeat / repeat-y / repeat } }, data: [62, 78, 45, 91] }]

这种写法适合做"渐变 + 纹理"的柱子背景,但它不会像 pictorialBar 那样按数据高度裁剪,图片会铺满整根柱子。所以如果只是想要一个有质感的柱子,用它更简单;如果想做"液体填充""进度条"那种效果,还是得用 pictorialBar。

顺带说下 3D 观感。ECharts 官方主包里没有真正的 3D 柱状图(bar3D属于 GL 扩展),如果不想引入额外的 GL 包(它会显著增加体积,而且对低配一体机的显存有要求),可以用三个 series 叠出来:背景用稍宽的深色柱、前景用主色柱、顶部用一个细的亮色柱当"高光面",再配合左右两侧的渐变方向,视觉上就有立体感了。这个土办法在交付周期紧的项目里非常实用,因为没有额外的渲染开销。

3.4 饼图与环形图:labelLine 末尾小圆点偏移的排查顺序

饼图 labelLine 末尾要不要加小圆点,是个典型的"设计稿上好看、实现起来别扭"的需求。ECharts 5 的labelLine本身是支持symbol和symbolSize的,但实际写出来经常会出现"小圆点和引线末端对不上、看着像飘出去了"的现象。我把排查顺序整理成下面这几步,基本能覆盖八成以上的情况。

第一步,先确认版本。labelLine.symbol这套配置是在 ECharts 5.3 之后才比较完善的,如果你在 5.0 或者 4.x 上写这个配置,它可能被静默忽略,或者只画了一部分。升级到 5.4 以上的稳定版本再调,能省掉一堆自我怀疑的时间。

第二步,把smooth关掉再调位置。引线开启平滑(smooth: true)之后变成曲线,曲线末端的切线方向和水平方向有一个夹角,圆点画在末端时就容易看着偏。调试阶段统一用直线(smooth: false),把位置对准了再决定要不要加平滑。

第三步,理解"末端位置"是由几个参数共同决定的,不要只盯着length2一个调:

series: [{ type: 'pie', radius: ['46%', '62%'], center: ['50%', '52%'], avoidLabelOverlap: true, minAngle: 6, // 小占比扇区强制占一个最小角度,防止标签挤到一起 labelLine: { show: true, length: 14, // 第一段(从扇区边缘出发的水平段) length2: 26, // 第二段(转折到文字的斜线段) smooth: false, minTurnAngle: 90, // 转折角小于该值时强制拉直,避免"折角过尖" symbol: 'circle', symbolSize: 6, symbolKeepAspect: true }, label: { show: true, alignTo: 'edge', // none | labelLine | edge,控制标签对齐基准 edgeDistance: 8, // 开启 edge 对齐后,标签距离容器边缘的间距 bleedMargin: 4, // 标签超出容器时的可溢出量 formatter: '{b|{b}}\n{c|{c} 万元}', rich: { b: { fontSize: fs(12), color: 'rgba(200,225,255,.75)', lineHeight: fs(18) }, c: { fontSize: fs(14), color: '#fff', fontWeight: 600, lineHeight: fs(22) } } }, data: pieData }]

这里的关键点在于alignTo。alignTo: 'edge'会让所有标签在左右两侧静默对齐,这时候引线的第二段长度会自动伸缩,你在length2里写死一个值,实际渲染出来的长度可能不一致,圆点的位置自然也跟着变。这种"看起来偏移"的情况,本质上不是配置写错了,而是布局规则决定的。如果你要的是每个标签的圆点都严格等距于文字起点,那就把alignTo设成'labelLine',让标签跟着引线走。

第四步,处理小扇区。数据里一旦有占比小于 2% 的项,引线和标签会全部挤在一起,圆点叠成一团,视觉上就像"偏移"了。这时候两个手段一起上:minAngle给每个扇区兜底一个最小角度,avoidLabelOverlap让 ECharts 自动上下推挤标签。如果还是乱,就只能做数据聚合,把小于 3% 的项合并成"其他"——这是最有效也最难看出来的处理方式。

注意:饼图的label用了rich富文本之后,formatter里的大括号写法就不能和普通字符串混用。{b|{b}}里的{b}是数据名,外面的b是rich里定义的样式名,两个含义不一样,写反了会直接渲染成空白。

3.5 地图专题:中国地图、省份温度、森林防火这类场景怎么搭

地图是大屏的"门面",也是最容易踩坑的部分。首先要明确一件事:ECharts 主包从 4.9 开始就不再内置地图边界数据了,echarts/map/js/china.js这种引用方式在新版本里直接失效。所以第一步永远是拿到 GeoJSON 并手动注册:

import * as echarts from 'echarts/core' import chinaJson from '@/assets/geo/china.json' echarts.registerMap('china', chinaJson) // 或者网络请求的方式,适合需要按需加载省份的场景 // fetch('/geo/100000_full.json').then(r => r.json()).then(json => { // echarts.registerMap('china', json) // })

地图边界数据可以从一些公开的数据服务获取,也可以用内部 GIS 平台导出的标准 GeoJSON。这里我建议把 GeoJSON 放到本地静态资源里,不走外网请求:一是大屏经常部署在内网环境,外网请求会失败;二是几 MB 的 JSON 每次刷新都拉一遍,首屏会明显变慢;三是离线环境下更稳。

注册完之后,中国地图的基础配置大概是这个样子:

option = { geo: { map: 'china', roam: false, // 大屏固定视角,禁止误拖拽 zoom: 1.15, label: { show: false }, itemStyle: { areaColor: '#0a2murky', // 注意:具体色值按主题走,别用高饱和纯色 borderColor: 'rgba(90,170,255,.55)', borderWidth: 0.8, shadowColor: 'rgba(0,120,255,.6)', shadowBlur: 20, shadowOffsetY: 6 }, emphasis: { label: { show: true, color: '#fff', fontSize: fs(11) }, itemStyle: { areaColor: '#1b6bff' } } }, series: [ // 省份温度可视化:散点或者 effectScatter 叠在地图上 { type: 'effectScatter', coordinateSystem: 'geo', data: temperaturePoints, // [{ name, value: [lng, lat, temp] }] symbolSize: (val) => Math.max(6, (val[2] + 30) / 6), rippleEffect: { brushType: 'stroke', scale: 3 }, itemStyle: { color: '#ffd43b' } } ] }

省份温度这类"按数值上色"的需求,用visualMap来做是最正统的,别手动一个个算颜色:

visualMap: { type: 'continuous', min: -20, max: 40, left: 24, bottom: 40, text: ['高温', '低温'], calculable: true, inRange: { color: ['#2f6cff', '#38d9a9', '#ffd43b', '#ff6b3d'] } }

这里有一个非常容易踩的坑:visualMap和itemStyle.areaColor会打架。只要你给地图 series 挂了visualMap,它就会接管所有区域的填充色,你写的areaColor会被覆盖,表现为"配了半天颜色没变化"。要么统一用visualMap上色,要么手动算色值写areaColor,不要两套一起上。如果两者都需要——比如底层要深色底、上层要按数据高亮——正确的做法是用两个 series 叠加:底下一个map配深色areaColor且不挂 visualMap,上面再叠一个同map的 series 挂 visualMap,配silent: true并且透明度调低。

森林防火可视化大屏这种场景,是地图专题里配置最密集的一类。我通常这样拆:地图底图用geo(不挂数据,只做背景和投影基准),火险等级用第二个mapseries 配合 visualMap 做区域填充,监测热点用effectScatter(带涟漪,远距离也能注意到),救援力量和摄像头点位用普通scatter配不同symbol(用image://图标区分类型),蔓延预测和调度路径用lines:

{ type: 'lines', coordinateSystem: 'geo', polyline: true, // 关键:把 coords 当成折线而不是两点之间的曲线 effect: { show: true, period: 4, trailLength: 0.3, symbol: 'arrow', symbolSize: 6, color: '#ff6b3d' }, lineStyle: { color: 'rgba(255,107,61,.6)', width: 1.4, curveness: 0.15 }, data: [{ coords: [[lng1, lat1], [lng2, lat2], [lng3, lat3]] }] }

polyline: true是做管线绘制的关键开关,不写它,lines会把每两个点连成一条独立的曲线,做不出管道那种连续拐弯的效果。另外trailLength建议控制在 0.2 到 0.4 之间,太长会像一条糊掉的拖影,太短则看不出流动方向。如果管线拐角需要圆角,lines做不到,得换成custom系列自己算路径,或者用 SVG 资源叠在 geo 上——这两种方案我在管线复杂的项目里都试过,custom的性能更好,SVG 的调试更方便。

4. 一套可复用的企业级大屏骨架实操

4.1 目录结构与依赖清单

企业级数据可视化项目和 demo 的区别,在于"能不能被下一个人接手"。所以目录结构要按职责分层,而不是按页面堆文件。我常用的结构是这样:

src/ ├── assets/ │ ├── geo/ # 地图 GeoJSON,按行政区编号命名 │ └── images/ # 图片柱、图标等静态资源 ├── charts/ # 纯 option 工厂函数,不依赖 Vue │ ├── lineOption.js │ ├── pieOption.js │ ├── mapOption.js │ └── index.js ├── composables/ │ ├── useECharts.js # 图表生命周期封装 │ ├── useScale.js # 大屏缩放 │ └── useChartScale.js # 字号缩放系数 ├── components/ │ ├── ChartBox.vue # 带标题边框的图表容器 │ └── ScreenHeader.vue ├── api/ │ └── screen.js # 数据接口统一收口 └── views/ └── Screen.vue

这个结构里最关键的一条是:charts/目录下的文件是纯函数,不 import 任何 Vue 的东西。它们只接收数据、返回 option 对象。这样做的好处是这些文件可以直接在 node 环境里跑单元测试,做回归的时候不用起整个页面;而且换个框架,比如从 Vue 换到 React,这层完全不用改。很多项目把 option 写在组件的 data 里,结果逻辑和视图纠缠在一起,后面想抽公共图表就得重写一遍。

依赖方面,核心是echarts,如果确定不用 GL 相关的图表,可以不装echarts-gl。构建工具用 Vite,按需引入配置如下:

// main.js 或单独的 echarts.js import * as echarts from 'echarts/core' import { LineChart, BarChart, PieChart, MapChart, EffectScatterChart, LinesChart, PictorialBarChart } from 'echarts/charts' import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, VisualMapComponent, GeoComponent, DataZoomComponent, GraphicComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' echarts.use([ LineChart, BarChart, PieChart, MapChart, EffectScatterChart, LinesChart, PictorialBarChart, GridComponent, TooltipComponent, LegendComponent, TitleComponent, VisualMapComponent, GeoComponent, DataZoomComponent, GraphicComponent, CanvasRenderer ]) export default echarts

按需引入之后,包体通常能从 1MB 左右压到 400KB 上下。注意registerMap挂在 core 导出的实例上,别再从echarts主包 import 一遍,否则会出现"地图注册了但图表读不到"的诡异问题——本质上是引入了两份 echarts,注册到 A 实例上,图表用的是 B 实例。

4.2 封装 useECharts:初始化、响应式、销毁一条龙

封装的目标是把"init、setOption、resize、dispose"这四件必须做的事收进一个地方,页面里只关心数据。下面是我用下来最顺手的版本:

// composables/useECharts.js import { shallowRef, onMounted, onBeforeUnmount, nextTick } from 'vue' import echarts from '@/utils/echarts' export function useECharts(elRef, getOption, { theme = null, onReady } = {}) { const chart = shallowRef(null) // 关键:shallowRef,避免 Proxy 劫持 let ro = null let raf = null const render = (opt) => { if (!chart.value) return // 第二个参数 notMerge 默认 false,做增量更新 chart.value.setOption(opt) } const resize = () => { if (raf) cancelAnimationFrame(raf) raf = requestAnimationFrame(() => chart.value && chart.value.resize()) } onMounted(async () => { await nextTick() if (!elRef.value) return chart.value = echarts.init(elRef.value, theme, { renderer: 'canvas', useDirtyRect: true // 5.3+ 局部重绘,大屏图表多时收益明显 }) render(getOption()) onReady && onReady(chart.value) ro = new ResizeObserver(resize) ro.observe(elRef.value) }) onBeforeUnmount(() => { if (ro) { ro.disconnect(); ro = null } if (raf) { cancelAnimationFrame(raf); raf = null } if (chart.value) { chart.value.dispose(); chart.value = null } }) return { chart, render, resize } }

这里有几个点是踩过坑才加上的。第一,实例必须用shallowRef,绝不能用ref。ref会把整个 ECharts 实例转成响应式代理,实例内部有大量循环引用和几何对象,代理之后不仅性能掉得厉害,还可能直接报栈溢出。第二,useDirtyRect: true是 ECharts 5.3 引入的增量重绘开关,在十几个图表同时跑定时刷新的场景下,CPU 占用能降三成左右。第三,dispose()一定要调,它不只是销毁 DOM,还会清掉内部的动画帧、事件监听和缓存,漏掉这一步是长时间运行后页面越来越卡的头号原因。

在页面里用起来就很清爽:

<template> <div ref="lineEl" class="chart-box"></div> </template> <script setup> import { ref, onMounted } from 'vue' import { useECharts } from '@/composables/useECharts' import { lineOption } from '@/charts/lineOption' import { fetchTrend } from '@/api/screen' const lineEl = ref(null) const data = ref({ categories: [], values: [] }) const { render } = useECharts(lineEl, () => lineOption(data.value)) onMounted(async () => { data.value = await fetchTrend() render(lineOption(data.value)) }) </script>

4.3 数据接入与增量刷新

大屏的数据刷新有两类:一类是"全量换一批",比如地图上的热点分布,数据量小但整体变化,直接setOption覆盖即可;另一类是"高频追加",比如时序折线每秒加一个点,这类场景如果每次都全量setOption,几十秒之后动画就会开始卡。

高频场景的正确姿势是只更新 series 的 data,让 ECharts 走 diff:

// 增量更新:只传变化的部分,其他配置不动 chart.setOption({ series: [{ data: [...data.value] }] })

注意这里的series数组要和初始化时的顺序一一对应,如果初始化时有三个 series,增量更新时只写第一个,ECharts 会按索引匹配,把第一个覆盖掉,后两个保持不变。这个特性很好用,但也容易出错:如果你在初始化时 series 的顺序是 [背景槽, 图片柱],增量更新时写反了,就会看到柱子突然消失。

刷新频率上,我的一般建议是:地图和统计类图表 30 秒到 60 秒一次,时序类图表按数据源的实际频率来,最快不超过 1 秒。低于 1 秒的刷新在大屏上人眼分辨不出来,只会白白消耗 CPU。另外定时器一定要在onBeforeUnmount里用clearInterval清掉,而且要保存setInterval的返回值,用匿名函数是清不掉的。

还有一个容易被忽略的问题是:如果图表在数据还没回来的时候就被销毁了,定时器回调里访问chart.value会拿到 null,直接报错。所以刷新函数的第一行永远是if (!chart.value) return,这个防御性判断看着多余,实际上能避免大量控制台红字。

4.4 大屏轮播高亮与主题配色

大屏是无人值守的,用户不会去 hover,所以"自动轮播高亮"是刚需——尤其是饼图和地图。ECharts 提供了dispatchAction来程序化触发交互,配合setInterval就能做出轮播效果:

let idx = -1 let timer = null const startLoop = (chart, len, seriesIndex = 0) => { stopLoop() timer = setInterval(() => { chart.dispatchAction({ type: 'downplay', seriesIndex, dataIndex: idx }) idx = (idx + 1) % len chart.dispatchAction({ type: 'highlight', seriesIndex, dataIndex: idx }) chart.dispatchAction({ type: 'showTip', seriesIndex, dataIndex: idx }) }, 3000) } const stopLoop = () => { if (timer) { clearInterval(timer); timer = null } }

这套写法的两个要点:一是downplay一定要配对着写,只highlight不downplay,几次之后所有扇区都亮着;二是highlight的实际效果取决于 series 里的emphasis配置,如果不配emphasis,触发之后几乎没有视觉变化,会让人误以为是代码没生效。

配色方面,我的建议是每个项目只维护一套主题,用registerTheme注册后全局使用,而不是每个 option 都写一遍颜色:

import echarts from '@/utils/echarts' const theme = { color: ['#2f6cff', '#38d9a9', '#ffd43b', '#ff6b3d', '#9b7bff', '#24c8db'], backgroundColor: 'transparent', textStyle: { fontFamily: 'PingFang SC, Microsoft YaHei, sans-serif' }, title: { textStyle: { color: '#e8f3ff' } }, legend: { textStyle: { color: 'rgba(200,225,255,.75)' } } } echarts.registerTheme('screen-dark', theme) // 初始化时:echarts.init(dom, 'screen-dark')

配色的经验是:大屏底色用深蓝黑系(饱和度低、明度低),数据色用高饱和的亮色,这样对比度最强,远距离也能分辨。同一块屏上的主色不要超过五种,多了就变成"彩虹图",看不出重点。相邻扇区或相邻柱子的颜色差异要有明显明度差,不能只靠色相区分,因为很多大屏在现场是投影或者拼接屏,色相还原度不可靠,明度差才是最稳的区分手段。

5. 常见问题与排查技巧实录

5.1 图表空白、只显示一部分、控制台还没报错

这几种情况看起来症状相似,原因却完全不同,排查顺序很重要。

先看容器尺寸。打开控制台选中图表容器,看它的clientWidth和clientHeight是不是 0。是 0 就说明初始化时容器还没被撑开,通常是v-if或者display: none导致的,解法是等容器可见后再 init,或者 init 之后补一次resize()。这一条能解决大约一半的"图表空白"问题。

再看 option 结构。ECharts 对错误配置的容忍度很高,写错的字段会被静默忽略,不报错也不生效。典型的是xAxis写成了对象而不是数组——虽然单个坐标轴也可以传对象,但一旦和一个数组混用(比如双 y 轴场景),就会出问题。还有series.type拼错('line'写成'lines'),前者是折线图,后者是飞线图,一个字母之差,画面上什么都没有。遇到"什么都不显示"先逐个核对series.type和坐标轴配置,比盯着代码看更快。

还有一种隐蔽情况:数据格式不对。地图的 data 需要是[{ name: '省份名', value: 数值 }],如果 name 和数据里的行政区名称对不上(多了"省"字、少了"自治区"、或者用了简称),那块区域就是没有颜色的,看起来像"地图没加载出来"。排查办法是打印一下 GeoJSON 里的properties.name,和你的数据做一次名称比对,这个坑我至少踩过三次。

5.2 内存泄漏与长时间运行的性能衰减

大屏的典型工作状态是"7×24 小时挂着",所以性能衰减问题会特别明显:开机第一天很流畅,第三天开始动画掉帧,一周后拖窗口都卡。我遇到过的情况,原因基本集中在下面几类。

第一类是 ECharts 实例没销毁。路由切换或者弹窗关闭时只把 DOM 移除了,chart.dispose()没调,实例还挂在 ECharts 内部的实例列表里,内部的动画帧循环还在跑。一块屏来回切十次,就有十个僵尸实例在耗 CPU。检查办法是在控制台里调echarts.getInstanceByDom(dom),如果 DOM 已经不在页面上还能拿到实例,那就是没销毁干净。

第二类是定时器没清理。轮播的setInterval、数据刷新的setInterval、自己写的动画requestAnimationFrame,只要有一个没清,就会持续执行。尤其是requestAnimationFrame循环,如果不加停止条件,它会在组件销毁后继续跑,并且持有组件的引用,导致整棵组件树都回收不掉。我的做法是所有的定时器都挂在一个数组里统一管理,卸载时遍历清理,不靠人记。

第三类是ResizeObserver没 disconnect。被观察的 DOM 已经移除,但 observer 还在,浏览器会一直持有这个元素和它的回调闭包。数量少的时候看不出来,长时间运行加上频繁切换,内存曲线就是一条斜向上的直线。

第四类是渲染器选型不当。数据量大(几万点以上)的时候用 canvas 是对的,但如果每个图表只有几十个数据点,用的是 SVG 渲染器,DOM 节点数量会随图表数量线性增长。一块屏二三十个图表,几千个 SVG 节点,布局计算就能把主线程占满。我的经验是:数据点多、动画多就用 canvas(默认),图表多但每个数据都很少、且需要放大时不糊,就用 SVG,然后严格控制图表数量在十五个以内。一屏塞二三十个图表本来就是设计问题,不是技术问题。

排查内存泄漏最直接的办法是 Chrome DevTools 的 Memory 面板,做三次"进入页面—离开页面"的操作,然后手动 GC,对比三次之后的堆快照。如果 Detached DOM 节点数量每次都增加,那就是销毁没做干净。这个排查方式比读代码快十倍。

5.3 常见问题速查表

现象大概率原因快速处理
图表空白,无报错容器尺寸为 0容器可见后再 init,或 init 后补 resize()
地图着色不生效visualMap 覆盖了 areaColor二选一,或用双 series 叠加
地图某个省份没颜色名称与 GeoJSON 不匹配打印 properties.name 做名称比对
折线图 x 轴标签重叠interval 与 hideOverlap 冲突只保留一个,或改用 rotate / 多行
tooltip 跑出屏幕未限制宽度和边界confine: true + extraCssText 换行
tooltip 不换行默认 white-space: nowrapextraCssText 里加 white-space: normal
饼图引线圆点偏移alignTo 与 length2 规则冲突smooth 关掉后重调,或改 alignTo
图片柱子高度不对少了 symbolClip加 symbolClip: true
缩放后文字发虚canvas 被 CSS 拉伸按缩放系数补偿 devicePixelRatio 并封顶
图表字号不随 rem 变option 里的数字不走 CSS统一走 fs() 缩放函数
页面越用越卡实例/定时器/观察器未清理dispose + clearInterval + disconnect
切换页面后图表错位只监听了 window resize改用 ResizeObserver 观察容器
手写 option 太啰嗦未使用 dataset用 dataset + encode 收敛配置
首屏加载慢全量引入了 echarts按需引入图表和组件

这张表里的每一条,背后都对应着一次真实的返工。我个人在实际操作中的体会是,大屏项目里真正难的不是把某个图表画出来,demo 阶段半天就能出效果,难的是把它稳定地跑一周、在客户现场那台配置一般的一体机上不掉帧、在甲方临时要求换分辨率的时候能半小时改完。所以我会在项目一开始就把useECharts、fs()、useScale这三个基础件搭好,后面每加一个图表都是填空,加班的时间也就省下来了。图表终究只是工具,决定一块屏好不好用的,是你在动手前那半小时的信息架构思考。

返回列表