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 拿 DOMonMounted 里 initonBeforeUnmount 里 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转换 CSSJS 动态设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-pxtoremCSS 里的字号都跟着根字号缩放了为什么图表里的坐标轴文字、图例文字纹丝不动答案很简单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), $1br/) 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 reflineEl classchart-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)) }) /script4.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这三个基础件搭好后面每加一个图表都是填空加班的时间也就省下来了。图表终究只是工具决定一块屏好不好用的是你在动手前那半小时的信息架构思考。
返回列表