ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ECharts 3.8.5 老项目实战:配置、避坑与版本共存

ECharts 3.8.5 老项目实战:配置、避坑与版本共存

简介:面向前端开发者的 ECharts 3.8.5 数据可视化资源包,适合需要快速搭建图表页面、学习配置项与 API 的初中级开发者。包内共 3 个文件,以 2 个 JavaScript 库文件和 1 个压缩包为主,整体约 15.33MB,可用于项目引入、离线查阅与版本对照学习。资源涵盖柱状图、折线图、饼图、地图等常见图表,以及悬停高亮、点击事件、缩放、响应式布局、Canvas 高性能渲染和模块化按需加载等能力。配套文档对图表类型、配置项、API 与交互处理做了系统说明,便于从实例入手理解数据驱动绘图流程;同时含有不同版本库文件,可对照了解 3.x 与 4.x 在性能和接口上的差异。已有 461 人学习,适合作为前端数据可视化入门与查阅工具。

1. 为什么现在还有人装 echart 3.8.5:一份老版本发行包的适用边界

接手一套跑了七八年的内网报表后台,前端是 jQuery 加模板字符串,浏览器版本由运维统一锁死,第一件事不是重构,而是先把图表跑起来。搜 echart 能翻到的现成包,大多是 3.x 时代的 dist 文件,其中 echart 3.8.5 被问得最多。它属于 3.x 这条线的末段发行版:canvas 渲染、normal/emphasis 分层配置、地图注册、扩展目录都在,不依赖构建工具,一个 script 标签就能落地。适合两类人,一类是维护老系统、只能加不能改的;另一类是要在离线环境里快速出图的。边界也清楚,dataset、SVG 渲染器、按需 tree-shaking 这些都没有,想要就得往上换大版本。

2. 把 3.8.5 跑起来:三种引入方式与一个最小可跑配置

老版本的坑,一半出在“包选错了”,另一半出在“容器没准备好就 init”。这一章先把引入方式和初始化流程固定下来,后面所有图表都复用这套骨架。

2.1 完整包、common 包、simple 包怎么选

3.8.5 的 dist 目录里不止一个 echarts.js,官方按体积裁了三档。选之前先确认你实际要用到的图表类型,别凭感觉。

构建文件内含的图表与组件适合的场景
echarts.js / echarts.min.js全部图表类型与全部组件图表类型不确定,或要用地图、关系图、桑基图、日历
echarts.common.js常用图表与基础组件,裁剪过一部分后台报表,只有折线、柱状、饼图、散点这几类
echarts.simple.js折线、柱状、饼图加最基础坐标系首屏体积敏感、图表类型单一的老门户

我一般直接用完整包。原因很实际:精简包省下的那点体积,在内网环境里几乎感觉不到,但一旦某个图表类型没打进去,series.type写上去就是空白,排查方向还会被带偏到“是不是数据问题”上。如果确实要用 common 包,务必在引入后先跑一遍你实际要用的每个type,跑不通就换完整包,别硬调。

2.2 最小可跑:容器、init、setOption 三段式

下面的例子是后面所有配置的基线,HTML 和 JS 放在一起,直接存成 .html 双击就能看。

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <!-- 老内核浏览器必须显式声明字符集,否则中文标签会乱码 --> <title>3.8.5 最小示例</title> </head> <body> <!-- 容器宽高必须由 CSS 给出确定值,否则 init 拿到的是 0 --> <div id="chart" style="width: 100%; height: 420px;"></div> <script src="./dist/echarts.min.js"></script> <script> // 1. 先取容器再 init,避免脚本在节点挂载前执行 var dom = document.getElementById('chart'); var chart = echarts.init(dom, null, { renderer: 'canvas', // 3.8.5 只有 canvas 渲染器 devicePixelRatio: window.devicePixelRatio || 1 // 高分屏下不糊 }); // 2. option 是纯数据描述,里面不写任何 DOM 操作 var option = { title: { text: '日活趋势' }, tooltip: { trigger: 'axis' }, legend: { data: ['访问量'] }, grid: { left: 48, right: 24, top: 64, bottom: 40, containLabel: true }, xAxis: { type: 'category', boundaryGap: false, data: ['周一', '周二', '周三', '周四', '周五'] }, yAxis: { type: 'value', name: '人次' }, series: [{ name: '访问量', type: 'line', smooth: true, data: [820, 932, 901, 934, 1290] }] }; // 3. 第二个参数 notMerge,这里传 false 表示合并 chart.setOption(option, false); // 4. 页面卸载时释放实例 window.addEventListener('beforeunload', function () { chart.dispose(); }); </script> </body> </html>

逻辑说明:echarts.init做了三件事——按容器尺寸建画布、按 devicePixelRatio 决定实际像素、把实例挂到该 DOM 上。所以容器尺寸必须在 init 之前就是最终值,这一条后面会反复出现。第二个参数是主题名,传null就是默认主题,自定义主题名要先registerTheme才有意义。

参数说明:renderer在 3.8.5 里只有'canvas'一个合法值,写它纯粹是为了将来换到 5.x 时不用改代码。devicePixelRatio不传时框架会自己取window.devicePixelRatio,但在 iframe 或被缩放过的页面里取到的值可能不对,显式传更稳。containLabel: true让 grid 的边距把坐标轴标签也算进去,中文轴名很长时不开这个,标签直接被裁掉。

2.3 resize、dispose 与实例复用

容器尺寸变化后图表不会自己跟着变,这是 3.8.5 最常见的使用缺口。

var dom = document.getElementById('chart'); var chart = echarts.init(dom); function bindResize(el, instance) { if (window.ResizeObserver) { var ro = new ResizeObserver(function () { // 回调里只做 resize,不要在里面重新 setOption instance.resize(); }); ro.observe(el); } else { // 老内核没有 ResizeObserver,退回窗口级监听 window.addEventListener('resize', function () { instance.resize(); }); } } bindResize(dom, chart); // 侧边栏折叠、tab 切换这类"非窗口尺寸变化"要手动补一刀 function afterLayoutChange() { // 等过渡动画结束、DOM 尺寸稳定后再调,立刻调多半拿到旧宽高 setTimeout(function () { chart.resize(); }, 300); }

逻辑说明:resize()会重新读取容器宽高并重建画布,代价比setOption低,可以放心高频调用。但在 CSS 过渡动画没结束时调用,读到的是中间态的宽度,图会缩成一条,所以上面用setTimeout兜一下。

参数说明:resize()支持对象入参{width, height, silent},在容器被display: none隐藏、拿不到尺寸时,可以显式传宽高让它先按固定值渲染;silent: true表示不触发resize事件回调,用于你不想让监听器连锁触发的场景。

注意:单页应用里切换路由时,一定要对已经不用的实例调dispose()。3.8.5 不会自动回收,实例挂在 DOM 上,DOM 被移除后实例还在内存里,路由切十几次标签页就能明显感觉到卡。

3. option 的分层写法:把柱线混合、饼图、地图三张图配到位

3.8.5 的配置结构和 5.x 差别最大的地方,是样式必须写在normal和emphasis两层里。这一层不写对,改颜色、改高亮全是白费劲。

3.1 normal 与 emphasis:这套层级不能省

3.8.5 里itemStyle、lineStyle、label、labelLine、areaStyle这些样式对象,内部都分成两个状态键:normal是常态,emphasis是鼠标悬停或dispatchAction高亮后的状态。直接写成itemStyle: { color: '#3b7dd8' }在部分图表上能蒙对,是因为框架内部做了兜底合并,但一旦你在emphasis里也写了同名属性,兜底顺序就会让人抓狂——典型的“玄学”现场:颜色明明写了却不生效。

我一般把两个状态成对写,哪怕暂时没区别:

itemStyle: { normal: { color: '#3b7dd8', borderColor: '#2f66b0', borderWidth: 1 }, emphasis: { color: '#2f66b0', borderWidth: 2 } }

这样做的另一个好处是迁移成本低:4.x 仍然认这套层级,写习惯了不会踩错;等真要升到 5.x 时,把normal这一层拆掉、把里面的属性上提一层就是等价改写,机械替换即可,不需要重新设计样式。

3.2 柱线混合 + 双 Y 轴 + dataZoom 的完整 option

双 Y 轴最容易出的问题是网格线打架和坐标轴绑错。下面这份配置可以直接抄,改数据和单位就能用。

var option = { color: ['#3b7dd8', '#f2a93b'], tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, // formatter 每条分支都必须 return 字符串,返回 undefined 会渲染出 "undefined" formatter: function (params) { var head = params[0].axisValue; var lines = params.map(function (p) { var unit = p.seriesIndex === 0 ? ' 单' : ' %'; return p.marker + p.seriesName + ':' + p.value + unit; }); return head + '<br/>' + lines.join('<br/>'); } }, legend: { data: ['订单量', '转化率'], top: 8 }, grid: { left: 56, right: 56, top: 64, bottom: 72, containLabel: true }, xAxis: { type: 'category', data: ['1月', '2月', '3月', '4月', '5月', '6月'], axisLabel: { interval: 0 } // interval:0 强制全部显示,默认会隔一个藏一个 }, yAxis: [ { type: 'value', name: '订单量', min: 0 }, { type: 'value', name: '转化率', min: 0, max: 10, axisLabel: { formatter: '{value} %' }, splitLine: { show: false } // 第二根轴关掉网格线,否则和主轴叠成双线 } ], dataZoom: [ { type: 'inside', start: 0, end: 100 }, { type: 'slider', height: 24, bottom: 16, start: 0, end: 100 } ], series: [ { name: '订单量', type: 'bar', barMaxWidth: 28, itemStyle: { normal: { color: '#3b7dd8', barBorderRadius: [4, 4, 0, 0] }, emphasis: { color: '#2f66b0' } }, data: [1200, 1380, 1010, 1560, 1720, 1900] }, { name: '转化率', type: 'line', yAxisIndex: 1, // 关键:绑到第二根 y 轴 smooth: true, symbol: 'circle', symbolSize: 8, lineStyle: { normal: { width: 3 } }, data: [3.2, 4.1, 2.8, 4.6, 5.1, 5.8] } ] };

逻辑说明:yAxis是数组时,系列通过yAxisIndex指定挂哪根轴,不写就是 0。tooltip.trigger: 'axis'下 formatter 收到的是数组,顺序按series声明顺序来,所以上面用seriesIndex === 0判断单位是安全的。

参数说明:barMaxWidth限制柱子最大宽度,数据只有三五条时柱子不会撑成一堵墙。barBorderRadius在 3.8.5 是itemStyle.normal下的合法属性,注意拼写,5.x 改成了borderRadius,迁移时这里会静默失效而不报错。dataZoom的inside类型支持鼠标滚轮缩放,slider类型给一条可拖的滑块,两个都开时要显式给 slider 留底部空间,否则滑块压在 x 轴标签上。

3.3 饼图标签与引导线:把重叠的 label 拉回来

饼图翻车基本集中在标签挤成一团和最小扇区点不中。

var pieOption = { tooltip: { trigger: 'item', formatter: '{b}<br/>{c} 单({d}%)' }, legend: { orient: 'vertical', right: 16, top: 'middle', type: 'scroll' }, series: [{ name: '渠道构成', type: 'pie', radius: ['38%', '62%'], // 数组形态是环形图,单值则是实心饼 center: ['40%', '50%'], avoidLabelOverlap: true, // 默认 true,节点密集时千万别关 minAngle: 3, // 小于 3 度的扇区强制给个可见角度 label: { normal: { show: true, formatter: '{b}\n{d}%', lineHeight: 16 }, emphasis: { show: true, textStyle: { fontSize: 14, fontWeight: 'bold' } } }, labelLine: { normal: { length: 12, length2: 16, smooth: false } }, data: [ { value: 435, name: '自然搜索' }, { value: 310, name: '活动页' }, { value: 234, name: '外部投放' }, { value: 155, name: '直接访问' }, { value: 42, name: '其他' } ] }] };

逻辑说明:avoidLabelOverlap会做一轮位置微调,代价是渲染时多一次计算,条目特别多(几十个扇区)时有感知;minAngle是给“其他”这类小数值兜底的,不设的话 0.3% 的扇区在图上根本点不中。

参数说明:labelLine.length是从扇区边缘到折点的长度,length2是折点到文字的横向长度,两个值调小会让标签更贴图表。radius的百分比是相对容器短边计算的,所以容器宽高比极端时环形会变形,这种场景改用像素值更稳。

3.4 地图:registerMap 的两步走

3.8.5 不内置任何地理数据,地图必须自己注册。

// 1. 先用 geoJSON 注册,名字自己定,之后 series.map 用同一个名字 fetch('./geo/region.json') .then(function (r) { return r.json(); }) .then(function (geoJson) { echarts.registerMap('region-custom', geoJson); var mapChart = echarts.init(document.getElementById('map')); mapChart.setOption({ tooltip: { trigger: 'item', // 区域内没有数据时 value 是 NaN,直接拼会显示 NaN formatter: function (p) { return p.name + ':' + (isNaN(p.value) ? '暂无数据' : p.value); } }, visualMap: { type: 'continuous', min: 0, max: 1000, left: 16, bottom: 16, text: ['高', '低'], calculable: true, inRange: { color: ['#e8f1fb', '#3b7dd8'] } }, series: [{ type: 'map', map: 'region-custom', // 必须与 registerMap 的第一个参数完全一致 roam: true, label: { normal: { show: false } }, itemStyle: { normal: { borderColor: '#c9d6e4', borderWidth: 0.6 }, emphasis: { areaColor: '#f2a93b', label: { show: true } } }, data: [] }] }); });

逻辑说明:registerMap只是把地理边界塞进内部注册表,不建实例;series.map按名字取。注册必须在setOption之前完成,异步加载时容易写成“先 setOption 再注册”,结果是空白地图加一句控制台警告。

参数说明:visualMap.inRange.color是渐变的两端,值域用min/max卡死,不卡的话每次数据变化配色都会漂。roam: true开缩放拖拽,低配机器上建议关掉,地图的缩放重绘比折线图重得多。

4. 避坑:3.8.5 上五次翻车记录与排查顺序

这些坑我基本都踩过一遍,按“先看渲染、再看配置、最后看构建”的顺序排查,命中率最高。

4.1 渲染与容器类

现象:图表区域一片空白,控制台干干净净没有报错。原因:容器在隐藏的 tab 页里,clientWidth和clientHeight都是 0,init 按 0 尺寸建了画布,之后即使显示出来也不会自动重算。解决:切到可见之后再 init;已经在隐藏态 init 的,在 tab 显示回调里补一次chart.resize()。如果容器是用display: none控制的,别指望 resize 能救,它读到的还是 0,必须显式传宽高。

现象:图表宽度只有一百来像素,或者右侧一截被裁掉。原因:容器写的是width: 100%,但父级没有确定宽度(常见于 flex 布局里没写min-width: 0的子项),宽度塌陷了;另一类是被grid默认边距裁掉了轴标签。解决:给父级链路定宽,或给容器一个像素级最小宽度;grid上开containLabel: true。

4.2 option 与交互类

现象:第二次setOption之后旧的系列还在,图例越点越多,或者柱状图和折线图叠在了一起。原因:3.8.5 的默认行为是合并,series按数组下标对齐,新数据少一项就只覆盖前几项,多出来的原样保留。解决:结构变化时用setOption(option, true)整体替换;结构稳定的场景则保证每次series的name和顺序完全一致,靠合并换数据,性能更好。

现象:tooltip 里冒出一个undefined,或者整块提示框不出现。原因:formatter的某个分支没写 return,函数返回 undefined;另一种是trigger为'axis'时误把params当成对象用,实际它是数组。解决:formatter 保证所有分支都返回字符串,需要换行用<br/>,不要返回 DOM 节点。写的时候先console.log(params)看一眼结构,比猜快得多。

现象:formatter里拼了用户填的文本,提示框渲染出错或者内容错位。原因:3.8.5 的 tooltip 内容是当 HTML 塞进去的,未转义的尖括号会被当标签解析。解决:拼字符串前把<、>、&、"替换成实体,或者干脆只用{a}、{b}、{c}这类占位符,别自己拼用户输入。

4.3 构建与版本类

现象:本地一切正常,发到内网某台机器上报echarts is not defined,或者某个图表类型直接不出现。原因:八成是引了 simple 或 common 精简包,缺对应的图表模块;少数情况是脚本 404 被浏览器缓存掩盖了。解决:先console.log(echarts.version)确认版本对不对,再换完整包验证。这条血泪经验值一个固定流程——上线前在目标机器上过一遍所有图表页面,别只在自己机器上看。

提示:把echarts.version打在页面角落或者控制台首行,运维报障时能直接排除掉一大半“版本不对”的可能。

5. 版本边界:3.8.5 与 4.x、5.x 的差异清单和共存方案

很多项目不是不想升,而是升不动——依赖它的页面太多,动一处要回归一片。搞清楚差异点,才能判断哪些是能绕过的,哪些是必须忍的。

5.1 被移除与被新增的 API

能力3.8.54.x5.x
setOption签名(option, notMerge, lazyUpdate)兼容旧签名,推荐对象入参只接受对象入参
样式层级必须写normal/emphasis仍兼容normal层已移除
dataset组件不支持支持支持
SVG 渲染器无,仅 canvas有独立构建内置可切换
柱条圆角itemStyle.normal.barBorderRadius兼容旧名改名borderRadius
自定义系列有custom有有,扩展更多

这张表里最要命的是第一行和第三行。setOption(option, true)这种写法在 3.8.5 是标准做法,升到 5.x 会直接把第二个参数当配置对象解析,true被忽略,图表变成合并刷新——表现出来就是“升完级数据不刷新了”。dataset的缺失影响更大:老项目里数据到 option 的映射是手写的,一行行map拼series.data,升级时这部分代码理论上可以留着不动,但要享受 dataset 就得重写数据层。

至于样式层级,短期建议统一按 3.8.5 的写法写,因为 4.x 全兼容,等于给自己留了条后路。等哪天真的只跑 5.x 了,做一次机械替换即可。

5.2 同一页面共存两个版本

老系统里新加一个模块,新模块想用新版,老模块不能动,最省事的办法是让两个版本共存。

<!-- 老模块:先引入 3.8.5,它会把 echarts 挂到 window 上 --> <script src="./vendor/echarts-3.8.5/echarts.min.js"></script> <script> // 立刻把引用收进自己的命名空间,随后全局名被覆盖也不影响 window.LegacyChart = window.echarts; </script> <!-- 新模块:再引入新版,同样用全局名,但换个命名空间保存 --> <script src="./vendor/echarts-5/echarts.min.js"></script> <script> window.ModernChart = window.echarts; </script> <script> // 之后各用各的,不要互相传实例 var c1 = window.LegacyChart.init(document.getElementById('old')); var c2 = window.ModernChart.init(document.getElementById('neo')); </script>

逻辑说明:两个版本都是 UMD 构建,加载时都会写window.echarts,所以必须在各自加载完成后立刻保存引用,顺序不能乱。这样做的代价是页面里有两份完整的图表库,首屏体积翻倍,只适合过渡期。

参数说明:如果两个模块的加载时机不可控(懒加载、微前端子应用),用 iframe 隔离更干净——每个 iframe 里各引一个版本,互不干扰,代价是跨 iframe 拿数据和调方法要走postMessage。我一般优先选命名空间方案,实在冲突了才上 iframe。

5.3 锁版本的工程做法

老版本最怕的不是功能不够,是“某天装完依赖发现版本变了”。

{ "dependencies": { "echarts": "3.8.5" } }

版本号前面不要加^或~,这是唯一有效的锁法。^3.8.5允许装到 3.x 的最新版,而 3.x 内部小版本之间也存在行为差异,某些setOption的合并细节在不同小版本上表现不一致。装完之后把 lock 文件一起提交,并在 CI 里加一条校验:

# 版本不符直接让流水线失败,避免"本地好好的、线上不对"这种黑匣子问题 node -e "var v=require('echarts/package.json').version; if(v!=='3.8.5'){console.error('echarts 版本漂移:',v);process.exit(1)}"

离线部署的场景再加一层回退,本地包缺失时不至于白屏:

<script src="./vendor/echarts-3.8.5/echarts.min.js"></script> <script> // 本地文件缺失时退回备用目录 if (typeof window.echarts === 'undefined') { document.write('<script src="/static/backup/echarts.min.js"><\/script>'); } </script>

这段写法在模块化工程里看着很原始,但内网老项目里它确实能救命——静态资源服务器抽风的时候,至少图表还能出来。用document.write是因为它同步执行,能保证后续脚本拿到已加载的 echarts;异步方案在老代码里容易引出入顺序问题。

6. 把 3.8.5 用到生产:主题注册、导出与两个收尾技巧

老版本要长期用下去,得把“每张图单独配色”和“导出图片糊成一片”这两个高频返工点一次性解决掉。前者靠主题,后者靠导出参数。

// 注册主题:键名随意,但必须在 init 之前调用 echarts.registerTheme('corp-blue', { color: ['#3b7dd8', '#f2a93b', '#5cb85c', '#d9534f', '#8e6fd8'], backgroundColor: '#ffffff', // 不设的话导出是透明底 textStyle: { fontFamily: 'Microsoft YaHei, sans-serif', fontSize: 12 }, title: { textStyle: { color: '#1f2d3d', fontSize: 15 } }, legend: { textStyle: { color: '#5a6b80' } }, categoryAxis: { axisLine: { lineStyle: { color: '#c9d6e4' } } }, valueAxis: { splitLine: { lineStyle: { color: '#eef2f7' } } } }); var chart = echarts.init(document.getElementById('chart'), 'corp-blue');

主题对象的键名和 option 里的组件名一一对应,写错了不会报错,只是不生效,所以改完一定要肉眼比对一次。配好之后所有图表统一走init(dom, 'corp-blue'),新同事加页面不用再抄色值。

导出图片这块,用getDataURL而不是截屏:

var url = chart.getDataURL({ type: 'png', // png 或 jpeg pixelRatio: 2, // 2 倍图,贴进文档放大不糊 backgroundColor: '#fff' // 不传是透明底,贴到 Word 里会变黑 });
参数作用建议值
type输出格式图表用png,照片底色多的用jpeg
pixelRatio输出倍率2,报表打印场景给 3
backgroundColor背景色#fff,透明底在多数文档里显示异常
excludeComponents排除组件导出时排除['toolbox', 'dataZoom']

pixelRatio调大有代价,2000 个点的散点图给到 3 倍,导出耗时能到一两秒,界面上得配个 loading 提示,否则用户会连点。另一个技巧是导出前先dispatchAction把dataZoom复位到全量,不然导出来的只是当前视窗那一截数据——这个坑我在一个报表项目里翻过车,客户拿着导出的图问“后面几个月的数据去哪了”。

从那以后我每次做导出功能,都强制走一遍「复位 dataZoom → 关闭动画 → 生成 URL → 恢复原视窗」这个顺序,多写四行代码,省掉一轮扯皮。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进