尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Echarts导出图片实战:getDataURL、多图合并与服务端出图

Echarts导出图片实战:getDataURL、多图合并与服务端出图 1. 三种导出路径的整体设计与选型逻辑先把结论摆在前头Echarts 图表导出为图片本质上是把 canvas 或 SVG 上的像素/矢量信息转成浏览器能下载、后端能落盘、Office 能粘贴的二进制文件。听着简单做起来分叉很多——是在浏览器里让用户点一下下载还是在服务端定时跑批生成日报图还是在数据大屏上把七八张图拼成一张长图发给老板这三件事的技术路线完全不一样。我在过去几年里做过报表平台、监控大屏、自动化周报这几类项目导出图片这个需求几乎每次都会出现而且几乎每次都是在项目后期才被提出来。原因也很朴素页面上看着挺好看但用户要拿去写报告、贴 PPT、发群消息截图不够清晰裁切还容易带上一堆不需要的按钮和滚动条。所以一个稳定、清晰、可控的导出能力其实是数据可视化产品从能用到好用的分水岭。这篇内容面向的读者比较宽如果你是刚接触 Echarts 的前端想知道getDataURL到底怎么用、参数怎么填第 2 节可以照着抄如果你在大屏项目里需要把多个图表拼成一张图第 3 节讲的是getConnectedDataURL如果你做的是报表自动化、定时任务、无人工干预的批量出图第 4 节的服务端方案会更贴合。中间穿插的参数计算、踩坑记录和排查表都是实际项目里真金白银换来的。1.1 三条路线的能力边界与成本对比在动手写代码之前先想清楚一个问题这张图片最终是给谁用的、在哪里生成的、生成频率有多高。这三个问题的答案直接决定了你该选哪条路。用户手动点击下载单张图走前端最省事一个按钮、一次getDataURL调用就完事服务器一点压力都没有。但如果是每天凌晨两点把昨天的销售趋势图生成 PNG 存进对象存储早上八点推送链接给业务方前端方案就不成立了因为那时候没人开着浏览器。这时候就必须把渲染搬到 Node 端。还有一类场景介于两者之间大屏上有十几个图表需要一键合并成一张图片供存档。用前端方案逐个导出再拼接处理起来相当磨人而 Echarts 本身就提供了连接图表的机制可以让多个实例共享一次导出动作这条路很多人不知道但它确实存在。对比维度前端单图导出前端多图合并导出服务端渲染导出核心 APIchart.getDataURL()chart.getConnectedDataURL()renderToSVGString()/ 无头浏览器截图运行位置浏览器浏览器Node 服务触发方式用户点击用户点击定时任务 / 接口调用是否需要浏览器需要需要不需要SSR 模式跨域图片风险有有无服务端可自行拉取中文字体依赖跟随用户系统跟随用户系统需要自己装字体适合场景单图下载、卡片分享大屏拼图、报告归档批量出图、自动推送这张表看着平平无奇但里面藏着一个关键判断只要生成过程需要脱离浏览器就一定要走服务端路线别再纠结前端方案能不能凑合。我见过有团队为了省事用一台常驻的机器开着浏览器页面靠定时刷新来实现自动截图结果页面一崩溃整个流程就断了维护成本远高于一开始就上 SSR。1.2 一张图从渲染到文件的完整链路不管选哪条路图片生成都会经过这么几个阶段Echarts 把 option 渲染到画布上画布上的内容被序列化成 base64 或者二进制流最后通过下载或者写文件的方式落盘。前端路线的序列化由getDataURL完成它内部会创建一个离屏 canvas把你指定的图表区域重新绘制一遍然后调用toDataURL返回一个data:image/png;base64,xxxx格式的长字符串。这个字符串有个特点长度约等于实际文件字节数的 4/3因为 base64 每 3 个字节编码成 4 个字符。一张 500KB 的 PNG对应的 base64 字符串大概有 680KB 左右这个量级放在内存里没问题但如果一次导出几十张再一起处理就要留意内存占用了。服务端路线的链路更长一些Node 端要么用 Echarts 的 SSR 能力直接产出 SVG 字符串要么用 node-canvas 提供画布实现后渲染成 PNG再要么起一个无头浏览器打开页面截图。三条支路各有取舍第 4 节会展开说。注意不管哪条路线导出的清晰度都取决于像素比pixelRatio。默认值是 1意味着导出图片的像素尺寸等于图表的 CSS 尺寸。一个 800×400 的容器导出就是 800×400放到 PPT 里稍微放大就糊了。2. 前端方案一getDataURL 单图直出够用且好用getDataURL是 Echarts 实例上的方法调用之后直接返回图片的 base64 字符串不需要自己碰 canvas也不需要额外引入任何库。这是最常用、最容易上手的一条路八成以上的单图导出需求靠它就够了。先看一个最小可运行的例子把图表挂到页面上之后加一个下载按钮// 初始化图表容器需要真实存在且有宽高 const chart echarts.init(document.getElementById(main)); chart.setOption({ backgroundColor: #ffffff, title: { text: 近七日活跃用户趋势 }, xAxis: { type: category, data: [周一, 周二, 周三, 周四, 周五, 周六, 周日] }, yAxis: { type: value }, series: [{ type: line, smooth: true, data: [820, 932, 901, 1290, 1330, 1120, 980] }] }); function downloadChart() { const dataUrl chart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #ffffff }); const link document.createElement(a); link.href dataUrl; link.download 活跃用户趋势_${Date.now()}.png; document.body.appendChild(link); link.click(); document.body.removeChild(link); }这段代码很短但每一行都有讲究下面拆开说。2.1 getDataURL 的参数逐项拆解getDataURL接收一个配置对象常用的参数有下面这些type图片格式支持png、jpeg、svg。PNG 支持透明背景适合贴到深色底色的文档里JPEG 体积小但不支持透明适合照片类内容SVG 是矢量格式放到 Word 里可以无损缩放但部分旧版 Office 对 SVG 支持一般。pixelRatio像素比决定导出图片的清晰度默认 1。backgroundColor背景色不填就是透明。这一点极其重要后面会专门讲坑。excludeComponents要排除的组件数组形式常见值有toolbox、dataZoom、legend、tooltip。name在部分版本里能影响下载文件名但兼容性不稳定我一般还是自己设置download属性。还有一个容易被忽略的细节excludeComponents只能排除 Echarts 自己的组件如果你在图表容器上叠加了自定义的 HTML 元素比如自己在图例旁边加的小标签、悬浮的水印层这些是纯 DOM 节点getDataURL根本感知不到自然也不会出现在导出结果里。反过来如果你的水印是用 Echarts 的graphic组件画的它就会被导出这也是很多监控大屏会在graphic里加公司水印的原因——导出即带水印不用额外处理。2.2 pixelRatio 到底该填几一次实打实的计算很多人对pixelRatio的取值是凭感觉其实它可以用简单的乘法算清楚。假设你的图表容器 CSS 尺寸是 800×400pixelRatio填 1导出图片分辨率就是 800×400 像素填 2 就是 1600×800填 3 就是 2400×1200。手动算一下就知道填 3 之后像素总量是填 1 时候的 9 倍内存占用和渲染耗时会跟着涨但清晰度的肉眼提升幅度远没有 9 倍那么夸张。我的经验取值是这样的使用目的建议 pixelRatio容器 800×400 导出的像素尺寸页面内二次展示、聊天分享1.5 ~ 21200×600 ~ 1600×800放进 PPT、Word 报告2 ~ 31600×800 ~ 2400×1200印刷、大幅面海报3 ~ 42400×1200 ~ 3200×1600移动端页面不超过 2超过后容易内存紧张再算一笔账方便理解内存压力。一张 3200×1600 的 RGBA 位图占用内存是 3200 × 1600 × 4 字节约等于 19.5MB。虽然 PNG 会被压缩到几百 KB 存到磁盘上但在生成的那一刻这块位图内存是实打实存在的。如果遇到大屏上十几个图表批量导出同时挂在内存里的位图叠加起来很容易破百兆低配设备直接卡死。所以批量导出时我习惯在每次导出完成后把中间引用置空并加一点时间间隔让浏览器有机会回收。2.3 前端直出的踩坑记录第一个坑是透明背景。backgroundColor不填导出的 PNG 就是透明底。这在网页上看不出问题但一旦粘进深色主题的 PPT 或者微信聊天窗口透明区域会显示成黑色或者深灰整张图看起来像是糊了一层。解决办法很简单在setOption里给图表设置backgroundColor: #ffffff同时在getDataURL里再传一次backgroundColor: #ffffff双保险。第二个坑是导出内容不完整。如果图表配置了入场动画而用户在图还没画完的时候就点了下载按钮导出的可能是一张半成品。稳妥的做法是监听finished事件或者干脆在导出前把动画关掉chart.setOption(option, { notMerge: false }); chart.on(finished, () { // 动画结束后再允许导出 exportBtn.disabled false; });还有一种更隐蔽的情况图表所在的容器被隐藏了。比如图表放在一个 Tab 页里用户切换到了别的 Tab原容器display: none此时 canvas 的尺寸可能是 0 或者上一次的旧值导出来的图片要么空白要么是过期内容。遇到这种布局导出前先调用一次chart.resize()或者把容器改成视觉上隐藏但保留布局尺寸的方式处理。第三个坑是跨域图片污染画布。这个坑最致命因为报错信息很不直观通常是SecurityError: Failed to execute toDataURL on HTMLCanvasElement。原因是你往图表里塞了跨域图片比如柱状图的柱子上用了自定义图片填充、饼图用了外部图标、地图的自定义背景图这些图片如果不是同源、又没有正确的跨域响应头浏览器就会认为画布被污染拒绝导出。// 反例跨域图片直接塞进 series导出时会抛 SecurityError series: [{ type: bar, data: [120, 200, 150], itemStyle: { // 这个 URL 如果不是同源且服务端没开跨域画布就会被污染 decal: { symbol: url(https://other-domain.com/pattern.png) } } }]解决办法有三个方向把图片挪到同源服务器上让图片服务端返回Access-Control-Allow-Origin头或者在加载图片时加crossOrigin anonymous并配合服务端的跨域头。三者里同源部署最省心跨域头最灵活具体怎么选看你的资源和部署条件。提示本地开发时经常用file://协议打开页面这种情况下几乎必然触发跨域限制。别在本地环境怀疑代码写错了先起一个本地服务再测。3. 前端方案二getConnectedDataURL 多图合并导出大屏项目里有个非常典型的需求页面上分布着八张图表用户希望一键导出一张完整的长图用来存档或者发给客户。如果按单图方案来做就是循环调用八次getDataURL拿到八个 base64再用 canvas 手动拼接。这个思路能跑通但代码量不小还要自己处理排版、间距、标题对齐维护起来挺痛苦。Echarts 提供了一个原生能力来解决这个问题连接图表。多个图表实例加入同一个 group 之后任意一个实例调用getConnectedDataURL就能把所有同组图表合并导出成一张图。3.1 连接图表机制的完整实现实现分两步。第一步初始化每个图表时通过group参数把它们归到同一组const lineChart echarts.init(document.getElementById(chart-line), null, { group: reportGroup }); const barChart echarts.init(document.getElementById(chart-bar), null, { group: reportGroup }); const pieChart echarts.init(document.getElementById(chart-pie), null, { group: reportGroup }); // 建立组内连接让同组图表可以共享导出 echarts.connect(reportGroup); lineChart.setOption(lineOption); barChart.setOption(barOption); pieChart.setOption(pieOption);第二步调getConnectedDataURL。注意这个方法是从组内任意一个实例上调用但导出结果包含整组function exportWholeReport() { const dataUrl lineChart.getConnectedDataURL({ type: png, pixelRatio: 2, backgroundColor: #ffffff, connectedBackgroundColor: #ffffff, excludeComponents: [toolbox] }); const link document.createElement(a); link.href dataUrl; link.download 数据报告全图_${new Date().toISOString().slice(0, 10)}.png; link.click(); }这里有两个参数值得单独说。connectedBackgroundColor控制的是各个图表之间的缝隙区域填充色如果不设置导出的长图里图表之间的空白可能是透明的拼在一起看着像被切碎了。backgroundColor则控制每个图表自身的底色。两个都设成白色出来的图才是干净的一整块。3.2 多图合并时容易出问题的几个地方布局顺序问题。合并导出的排列顺序跟图表在 DOM 里的位置和初始化顺序有关不是随机的但也不完全可控。如果你的大屏布局是用绝对定位做的图表容器的位置关系比较松散导出的排列可能跟你想象的不一样。遇到这种情况我一般会额外放一个专门用于导出的隐藏容器按固定顺序排好图表导出走这个容器页面展示走原来的布局。多花一点代码换来结果稳定。尺寸差异问题。合并导出时如果各个图表容器的宽高差别很大合并出来的图片比例会有点怪异。建议在同一组里保持图表宽度一致高度可以不同这样纵向堆叠出来的长图会比较规整。实测下来一个 1200 宽、高 320 的四张图堆叠导出的长图是 2400×2560pixelRatio 为 2体积大概在 1.2MB 左右微信可以直接发送不需要压缩。性能问题。图表数量多了以后合并导出耗时明显上升。我测过一组数据三个图表合并导出大概 200 毫秒左右十个图表会到 800 毫秒以上而且这期间主线程会被占住页面会明显卡顿一下。所以批量导出前最好给个加载提示或者放到requestIdleCallback里执行别让用户以为页面死掉了。数据量大的图表。如果某个图表有上万个数据点、又开着动画导出时可能出现部分内容缺失。我的处理习惯是导出前先临时关掉动画导出完成后再恢复function safeExport(chart, opts {}) { const original chart.getOption(); chart.setOption({ animation: false }); const url chart.getConnectedDataURL({ pixelRatio: 2, ...opts }); chart.setOption({ animation: original.animation }); return url; }这个写法不复杂但能躲掉一大类导出的图少了几个点的疑难杂症非常值得抄进工具函数里。4. 前端方案三服务端渲染脱离浏览器的自动出图前两种方案都有个共同前提——页面得开着。而报表自动化、每日推送、批量归档这些场景恰恰是没人看着页面的时候要跑。这就必须把 Echarts 搬到服务端。服务端出图有三条实现支路我按推荐程度排一下。4.1 支路一Echarts SSR 直接产 SVG从 Echarts 5.3 开始官方支持了服务端渲染用起来相当干净。核心是初始化时传ssr: true、renderer: svg并且把容器参数传null然后调renderToSVGString()拿到 SVG 字符串。const echarts require(echarts); function renderChartToSVG(option, width 1200, height 600) { const chart echarts.init(null, null, { renderer: svg, ssr: true, width, height }); chart.setOption(option); const svgString chart.renderToSVGString(); // 用完必须销毁否则服务端内存会持续增长 chart.dispose(); return svgString; }这个方案的优点是依赖轻不需要装浏览器出图速度也快几百毫秒就能生成一张复杂图。SVG 是矢量格式缩放到任意尺寸都不糊贴进 Word 效果特别好。缺点是 SVG 里如果引用了外部图片需要你自己处理成内联的 base64另外部分场景下用户需要 PNG就得再做一次格式转换。需要 PNG 的话可以配合sharp之类的图像库把 SVG 转成位图const sharp require(sharp); async function svgToPng(svgString, outputPath, scale 2) { await sharp(Buffer.from(svgString)) .resize({ width: 1200 * scale }) .png({ quality: 100 }) .toFile(outputPath); }4.2 支路二node-canvas 直接渲染 PNG如果你不想经过 SVG 中转可以直接给 Echarts 提供一个服务端的 canvas 实现。这需要装canvas这个原生依赖编译过程在部分环境下会有点折腾尤其是 Windows 和精简版 Linux 镜像但装上之后用起来很顺。const echarts require(echarts); const { createCanvas } require(canvas); // 关键一步把 canvas 的创建方式交给 Echarts echarts.setPlatformAPI({ createCanvas: () createCanvas(1200, 600) }); function renderChartToPngBase64(option) { const chart echarts.init(null, null, { renderer: canvas, ssr: true, width: 1200, height: 600 }); chart.setOption(option); const base64 chart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #fff }); chart.dispose(); return base64; }这里有个绕不开的坑中文字体。服务端环境默认很可能没有中文字体导出的图片里所有中文会变成方块或者干脆消失。解决的思路是在部署镜像里装上中文字体包或者在node-canvas注册字体文件。我踩过一次排查了半天以为是编码问题最后发现是容器镜像里根本没装字体。所以服务端出图这件事字体一定是上线前必须验证的一项。4.3 支路三无头浏览器截图前两条支路适合图表结构规整、样式可控的场景。但如果页面里有大量自定义 DOM、复杂 CSS 特效、第三方组件用服务端渲染很难还原视觉效果这时候无头浏览器就是兜底方案起一个浏览器实例打开页面等图表渲染完成后截图。大致的流程是启动浏览器、打开目标页面、等待图表的渲染完成信号、对指定元素截图、关闭页面。这里的难点不在代码而在等多久。等太短图还没画完等太久批量任务的耗时不可控。我的做法是在页面里埋一个钩子图表finished后往window上挂一个标记无头浏览器轮询这个标记出现即截图比死等固定时间靠谱得多。三条支路的选型我总结了这么个判断顺序能用 SSR 就用 SSR它最轻最稳SSR 还原不了视觉效果再退到 node-canvas页面复杂度真的降不下来才上无头浏览器。反过来的顺序会让部署复杂度和资源开销一路飙升。5. 常见问题与排查技巧实录导出图片这件事出问题的表现往往很相似——导出来是黑的、是白的、是不全的但背后的原因可能天差地别。这一节把我在项目里真实遇到过的几类问题整理出来配上一张速查表方便你对照排查。5.1 导出结果异常的排查思路现象一图片全透明粘到深色背景上变黑。八成是backgroundColor没设。检查两处setOption里的backgroundColor以及getDataURL参数里的backgroundColor。两者都补上白色问题基本消失。现象二图片是空白的什么都没有。先看容器尺寸。在浏览器控制台执行一下获取容器元素并打印它的宽高如果是 0说明图表是在隐藏状态下初始化的。Echarts 一旦在 0 尺寸下初始化后续不改尺寸就会一直画不出来。解决办法是等容器可见后再初始化或者初始化后主动调用chart.resize()。再检查初始化时机。如果图表的 option 是异步接口返回后才设置的而导出按钮在数据回来之前就能点也会导出空白。加上按钮的可用状态控制就能避开。现象三导出的图里有 toolbox 工具按钮、dataZoom 滑块。用excludeComponents排除掉。默认情况下这些组件都会出现在导出结果里因为它们本身就是图表的一部分。chart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #ffffff, excludeComponents: [toolbox, dataZoom, brush, legend] });现象四控制台报 SecurityError画布被污染。前面 2.3 节详细说过跨域图片是元凶。优先解决图片的同源问题其次配置跨域响应头。现象五中文全是方块。服务端出图的典型症状装字体即可。前端出图一般不会遇到因为用的是用户系统字体。现象六导出图片模糊。pixelRatio默认 1放大就糊。按使用目的调到 2 到 3同时注意内存压力。5.2 高频问题速查表问题现象最可能的原因处理方式导出全透明未设置 backgroundColorsetOption与getDataURL双处补白色导出空白容器尺寸为 0 或数据未就绪可见后初始化导出前resize()报 SecurityError画布被跨域图片污染图片同源部署或配置跨域头中文显示为方块服务端缺少中文字体镜像内安装字体或注册字体文件图片模糊pixelRatio 过低调到 2 至 3兼顾体积带出工具按钮未排除组件excludeComponents: [toolbox]内容不完整动画未结束就导出监听finished或临时关动画移动端导出崩溃像素比过高内存不足pixelRatio 降到 2 以内多图合并有透明缝未设 connectedBackgroundColor显式设为白色批量导出页面卡死主线程被连续占用加间隔或用空闲时段执行这张表我建议直接存成项目里的一份排查清单。实际遇到问题时对着现象往下找比漫无目的地翻文档快得多。5.3 两个容易被忽略的细节文件名与编码。下载文件名里如果带中文部分浏览器会出现乱码。稳妥的做法是文件名保持在 ASCII 范围内或者用时间戳加业务英文标识。如果非要中文先确认download属性在目标浏览器上的表现再做兼容处理。导出频率控制。用户手速快的时候可能连点好几次导出按钮每次都触发一次完整渲染很容易卡顿。给按钮加个短暂的禁用状态或者用防抖处理是成本最低的优化。6. 实操心得把导出做成一个可复用的小工具走过这么多项目之后我基本会把导出能力封装成一个独立模块而不是散落在各个页面的点击事件里。原因很简单参数要统一坑要集中处理不同页面重复写一遍太浪费。我的封装习惯是提供三个方法exportSingle处理单图导出exportGroup处理多图合并exportWithWatermark处理需要加水印的场景。水印的做法是在graphic里画一个半透明的文字位置放在右下角这样getDataURL会把它一起导出。function withWatermark(option, text) { return { ...option, graphic: [ { type: text, right: 20, bottom: 12, style: { text, fontSize: 14, fill: rgba(0, 0, 0, 0.18), fontWeight: bold }, z: 100 } ] }; } // 使用 chart.setOption(withWatermark(baseOption, 内部资料 请勿外传));用graphic做水印有个好处它是 Echarts 自身的组件导出、缩放、重绘都跟着图表走不需要额外维护一层 DOM 覆盖。而且我在项目里实测下来graphic加在z: 100的位置不会跟tooltip冲突鼠标悬浮时提示框依然正常显示。另外一个值得分享的经验是关于清晰度和体积的平衡。曾经有个项目要求导出的图片直接作为附件发邮件业务方反馈说图是清楚了但一封邮件好几兆。后来我做了个折中默认pixelRatio用 2针对打印场景提供一个高清导出选项用 3同时在导出 JPEG 时把质量参数压在 0.85 到 0.9 之间。实测这样出来的图片文档阅读完全够清晰单张体积能控制在 300KB 以内邮件附件大小的问题也解决了。最后说个后续可以继续扩展的方向把导出结果和图片归档打通。前端导出的是 base64服务端出图写的是文件两者都可以接到对象存储上形成一套图表即资产的机制。业务方在页面上看到哪张图有价值点一下导出图片自动带上时间戳和业务维度存进去后续做季度汇报、年度总结的时候可以直接翻历史归档不用再重新跑一遍数据。这个思路我在一个报表项目里落地过接手的人反馈说省了大量翻截图的时间算是导出功能从工具变成基础设施的一个小尝试。
返回列表