Echarts图表导出图片:三条路径、避坑与通用封装
发布时间:2026/9/30 13:06:41来源:尧图网络
做数据看板的人迟早都会遇到这个需求这张图能不能给我导出来产品经理要把它塞进周报运营要发到群里财务要贴进 Excel甲方爸爸甚至要求点一下就能下载高清图别糊。Echarts 图表导出为图片这件事说简单也简单三行配置就能出一个下载按钮说麻烦也真麻烦一旦碰上高清屏、异步数据、容器隐藏、SVG 渲染、地图类图表这些情况导出来的东西能让你怀疑人生。我前后在四五个项目里反复折腾过这套东西从最开始只会抄官网的saveAsImage到后来自己封装一套导出工具函数中间踩的坑足够写满两页纸。这篇就把我实际用过的三条路完整拆开讲每条路的原理边界在哪、参数怎么定、什么时候会翻车、翻车了从哪查最后给一套可以直接复制进项目的封装思路。不管你是刚接手的 Vue3 后台还是 React 大屏或者是 Node 服务端批量出图都能找到对应的做法。1. 三条导出路径的分工先搞清楚再动手很多人一上来就问Echarts 怎么导出图片其实这个问题本身太笼统。真正决定技术选型的是你要导出的东西里面有什么。是三张图还是要连标题、图例说明、右下角的水印一起导出是用户手动点一下还是后台定时批量生成这些答案不同路径完全不同。1.1 三条路各自的位置从实现原理上看能走的路其实只有三条主线。第一条是 Echarts 自带的工具栏按钮toolbox.feature.saveAsImage。它的本质是 Echarts 内部调用自己的 canvas 上下文把画好的图形直接转成位图。因为是自己画的东西自己导出所以保真度最高不存在任何兼容性问题也不会出现文字错位。第二条是实例方法getDataURL()。它和第一条其实共用同一套底层逻辑区别在于它不给你按钮只给你一个 Base64 字符串。拿到这个字符串之后你是用它触发下载、上传后端、嵌进 PDF 还是塞进邮件完全由你决定。自由度最高也是我实际项目里用得最多的一条。第三条是 DOM 截图典型代表是 html2canvas 这类库。它不关心 Echarts它把整个 DOM 容器当成一张画来重绘一遍。覆盖面最广容器的背景色、外层的标题、浮在上面的 tooltip、甚至是自定义的 HTML 角标都能一起进图。代价是它要靠自己解析 CSS遇到复杂样式就容易失真。一条条说清楚它们的能力半径比背 API 重要得多。我见过不少项目明明只需要一张纯图表却引入了 html2canvas白白多了几十 KB 的体积还换来一堆颜色失真问题。1.2 一个判断顺序帮你少走弯路我习惯用下面这个顺序做决策。导出物只有图表本身且接受默认按钮样式直接用saveAsImage零成本。导出物只有图表本身但需要自己控制触发时机、文件名、清晰度或者要把图片数据传给后端用getDataURL()。导出物包含了 Echarts 之外的 DOM标题栏、图例说明、水印、多个图表拼一张长图用 html2canvas 这类 DOM 截图。导出发生在没有浏览器界面的环境定时任务、服务端接口生成报表走 Node 侧渲染这是另一套玩法后面单独讲。判断顺序的关键在于导出物里有什么而不是哪个方案更高级。选错方向后面调参数调到崩溃也救不回来。我有个同事为了给图表加个自定义水印硬是去改 Echarts 的graphic组件画文字其实直接用 DOM 截图三行就搞定了绕了一大圈。提示先把导出物画在一张纸上标注哪些是 canvas 里画的、哪些是普通 DOM。这个清单直接决定你用第几条路。2. 方案一saveAsImage零代码但边界明显这是所有教程都会先讲的一个也确实该先讲因为它的配置成本几乎为零。2.1 最少三行就出按钮在option里挂上toolbox就完事了。option { toolbox: { show: true, feature: { saveAsImage: { title: 保存为图片, name: 销售趋势图, type: png, pixelRatio: 2, backgroundColor: #ffffff } } }, // ...其他配置 };页面上就会自动出现一个小图标点一下浏览器就开始下载。注意title是鼠标悬浮提示name才是文件名。这两个参数经常被搞混导出后文件名不对十有八九是只改了title。2.2 pixelRatio 才是清晰度的命门默认pixelRatio是 1也就是按 CSS 像素导出。如果你的图表容器是 800×400导出来就是 800×400 的图。放到 Word 或者 PPT 里一拉伸糊得没法看。把它设成 2导出尺寸翻倍到 1600×800设成 3 就是 2400×1200。那是不是越大越好不是。pixelRatio每翻一倍canvas 的像素数量是平方级增长。2400×1200 的图大约 288 万像素换成 3 倍比率再做大屏尺寸单张图的内存占用能到几十 MB 量级低端设备或者移动端浏览器很容易直接崩掉标签页。我的一般做法是面向文档和打印的场景给 2 到 3面向聊天分享的场景给 2 就够。因为主流社交软件都会二次压缩给再高也是白费。backgroundColor也值得单独说。如果不设导出的 PNG 背景是透明的。透明背景在深色主题的看图器里会显得很难看图表上的浅灰网格线也会消失不见。所以除非你明确要透明图否则永远显式给一个背景色。2.3 它真正解决不了的三件事用起来爽但边界要提前知道否则做到一半才发现要推翻重来。第一文件名不方便动态化。name是初始化时就写死的字符串。你想在文件名里带上当前时间戳比如销售趋势图_20260115saveAsImage做不到除非你在导出前动态setOption去改这个字段但那样每次导出都要跑一遍配置合并体验很怪。第二拿不到图片数据。它只负责触发浏览器下载Base64 字符串在你手里没有任何副本。如果业务上还要导出的同时上传一份到后端存档这条路直接走不通。第三按钮样式受制于 Echarts。图标长什么样、放在哪、hover 什么效果都在 Echarts 的工具栏体系里。现在的后台系统大多有自己的设计规范一个格格不入的小图标往往过不了 UI 走查。而且工具栏会一起出现在导出的图里除非通过excludeComponents排除这在纯净导出的诉求下也是个小麻烦。所以我的经验是saveAsImage适合原型验证、内部工具、对交互没要求的场景。一旦进入正式产品基本都要切到第二种方式。3. 方案二getDataURL把导出主动权拿回自己手里这是我实际项目里用得最多的一条路。核心思想很简单Echarts 把图渲染成 Base64剩下的下载逻辑我自己写。3.1 getDataURL 和 getConnectedDataURL 的区别getDataURL()返回当前实例画布的数据 URL。getConnectedDataURL()返回的是所有通过connect关联在一起的实例合并后的图像。如果你做过多个图表联动的功能想把它们拼成一张图导出后者就派上用场了。echarts.connect(group-a); const chart1 echarts.init(dom1, null, { renderer: canvas }); const chart2 echarts.init(dom2, null, { renderer: canvas }); chart1.group group-a; chart2.group group-a; const dataURL chart1.getConnectedDataURL({ type: png, pixelRatio: 2, backgroundColor: #fff });注意这里必须两个实例都是 canvas 渲染器。这也是我踩过的坑项目里为了做无障碍和 SEO 把渲染器换成了 SVG结果getDataURL返回的是data:image/svgxml;base64,...文件名后缀还叫.png双击打开直接报错。原因很简单SVG 渲染器下画布本来就是矢量 DOM没有位图可导。规避方式有两条要么把渲染器退回 canvas要么在导出时临时指定type: pngEcharts 会内部把 SVG 再绘一遍成位图这条路径的性能开销更大且在不同版本上行为有细微差别生产环境建议实测确认。3.2 从 Base64 到真实文件的完整链路拿到 dataURL 之后触发下载需要自己写一小段代码。function downloadDataURL(dataURL, filename) { const link document.createElement(a); link.href dataURL; link.download filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); } // 使用 const chart echarts.init(document.getElementById(chart), null, { renderer: canvas }); function exportChart() { const dataURL chart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #ffffff, excludeComponents: [toolbox] }); const stamp new Date().toISOString().slice(0, 10); downloadDataURL(dataURL, 销售趋势_${stamp}.png); }excludeComponents这个参数很有用。它可以在导出时把工具栏从图里去掉避免出现图里有个下载按钮的尴尬。可排除的组件名包括toolbox、legend、title等具体能排除哪些以官方文档为准我在不同版本上测出来的支持范围略有差异。另外要留意getDataURL生成的是 Base64 字符串它本身的体积大概比原始二进制大三分之一。一张 2MB 的图Base64 之后接近 2.7MB。如果还要把它塞进localStorage或者作为请求体发给后端很容易触发存储配额或者请求体超限。大图优先走canvas.toBlob()转成二进制再交给下载或者上传逻辑。3.3 别急着下载Base64 还能做很多事拿回主动权之后你会发现这条路的价值不只是下载。把 Base64 上传到后端能实现导出记录的归档塞进模板字符串能生成带图的邮件正文配合 PDF 库比如 jsPDF逐个页面添加图片能做出一份图文报告甚至可以在导出前给 canvas 叠加一层水印再取数。还有一个容易被忽略的点导出前的准备动作。如果图表正在播放动画getDataURL抓到的可能是动画中间帧柱子只画了一半。可靠的写法是导出前先chart.setOption(option, { notMerge: false })重绘一次或者等finished事件触发后再执行导出。chart.on(finished, () { // 此时渲染已完成可以安全导出 const dataURL chart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #fff }); });finished事件在动画结束时触发对于数据量大、动画时间长的图表这是防止导出半成品最省事的办法。4. 方案三html2canvas把图表之外的 DOM 也要进来前面两条路都在 Echarts 的圈子里打转一旦需求变成把整个看板区域导成一张图就得出圈了。4.1 什么时候必须用它举个很典型的场景。页面上是一个统计卡片里面包含标题文字、右上角的时间范围说明、中间的 Echarts 图表、右下角数据来源内部系统的备注。产品要的是这一整块导出去而不是光秃秃一张图。用getDataURL就得自己拿 canvas 去拼文字麻烦且排版难控。这时候把整个卡片容器交给 html2canvas它会把这棵 DOM 树连同里面的 canvas 一起重绘成一张位图。import html2canvas from html2canvas; async function exportCard() { const card document.getElementById(dashboard-card); const canvas await html2canvas(card, { scale: 2, backgroundColor: #ffffff, useCORS: true, allowTaint: false, logging: false }); canvas.toBlob((blob) { const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download 数据看板.png; link.click(); URL.revokeObjectURL(url); }, image/png); }这里我特意用toBlob而不是toDataURL就是为了避开大图 Base64 带来的内存膨胀。用URL.createObjectURL拿到临时链接下载完记得revokeObjectURL释放否则反复导出几次内存就肉眼可见地涨。4.2 scale 参数和 devicePixelRatio 是两回事很多人把 html2canvas 的scale和屏幕的devicePixelRatio混为一谈。scale: 2表示把渲染结果的像素尺寸放大两倍和屏幕无关是导出清晰度的直接控制项。而屏幕的像素比只影响你在屏幕上看到的清晰度。我一般的经验值普通桌面场景scale: 2大屏或者需要打印的场景可以试scale: 3但要注意内存。如果容器本身很宽比如 1920 像素宽的大屏scale: 3出来就是 5760 像素宽某些浏览器在 canvas 超过一定尺寸后会直接返回空白这是浏览器的硬限制不是库的问题。4.3 常见的失真问题从哪来html2canvas 的核心工作方式是读 CSS 然后自己画一遍所以它必然无法 100% 还原浏览器渲染。颜色失真多半来自渐变和阴影。线性渐变、box-shadow、mix-blend-mode这些在截图里经常出现色带或者直接丢失。解决思路是尽量用纯色或者放弃渐变。文字位置偏移通常和字体加载有关。如果页面用了自定义字体而字体还没加载完就截图就会用退化字体渲染字宽不一样位置自然错位。稳妥做法是配合document.fonts.ready等字体就绪。await document.fonts.ready; const canvas await html2canvas(card, { scale: 2 });图片空白基本是跨域问题。截图里如果引用了别的域名的图片需要目标服务器允许 CORS同时开启useCORS: true。如果开着allowTaint: true只会污染 canvas导致后面toBlob报安全错误所以这两项一般不要同时开。还有一个隐藏更深的点Echarts 渲染出来的 canvas 在 html2canvas 眼里就是一张已经画好的位图它会直接绘制。所以图表部分不会失真失真的永远是周围的 CSS 部分。搞清楚这一点排查方向就很明确了。5. 三种方式摆在一张表里对比纸上谈兵不如直接对照。下面这张表是我自己在选型时实际用过很多次的判断依据。维度saveAsImagegetDataURLhtml2canvas实现成本极低改配置即可低需自己写下载中需引入依赖导出保真度最高最高依赖 CSS 复杂度可能失真是否包含图表外 DOM否否是文件名可控否初始化写死是是能拿到图片数据否是是多图表合并导出否支持 connect 合并可以靠容器布局额外体积00数十 KB 量级主要风险样式不可控、拿不到数据SVG 渲染器下格式不符预期渐变阴影失真、跨域、大尺寸空白从表里能看出一个规律保真度和覆盖面基本是互斥的。要么图表自己导出保真但覆盖面窄要么整块 DOM 截图覆盖面广但保真度要看运气。做技术选型的时候先问清楚业务到底要哪一头比纠结库的版本重要得多。6. 导出翻车的六个真实场景与排查链路前面讲的是怎么做这一段讲为什么做完之后图还是不对。这些都是我在真实项目里遇到过的顺序大致按出现频率排列。6.1 导出的图糊成一片症状很一致屏幕上看很清楚导出来放大就糊。排查链路是这样的。先确认导出的像素尺寸把图下载下来看属性里的宽高。如果宽高和容器 CSS 尺寸一样那就是pixelRatio或者scale没设默认按 1 倍导出了。如果宽高翻倍了但还是糊那就要看数据源本身——有些图表的数据点密集在缩小视图下靠重采样看起来平滑导出后放大反而暴露了原始分辨率不足。还有一种容易被忽略的情况图表容器的宽度用了rem或者vw单位而根字号在导出时被某些布局逻辑改动了。Echarts 的 canvas 尺寸是在resize时按容器实际像素算的如果导出前后容器尺寸变了导出的图就会和屏幕上看到的不一致。我遇到过一次导出前切了个弹窗弹窗里为了适配改了大屏缩放的transform: scale()结果导出的 canvas 尺寸完全对不上。容器尺寸在导出前后必须保持一致这是硬要求。6.2 数据还没回来就点了导出异步接口返回慢的时候用户点导出按钮图表还是空的或者只有坐标轴。这不是导出功能的问题是时序问题。我一般的处理是给导出按钮加禁用态等数据渲染完成的finished事件触发后再放开。如果接口本身很慢至少要在请求返回后的nextTick里再执行导出让 Echarts 有机会完成一次渲染。async function handleExport() { if (loading.value) return; const data await fetchData(); chart.setOption(buildOption(data)); chart.once(finished, () { doExport(); }); }用once而不是on避免重复绑定导致同一个事件触发多次导出下载目录里瞬间多出好几张图。6.3 容器被隐藏时导出是空白的这个坑特别隐蔽。为了做导出长图有人会把一个宽高更大的图表实例渲染到屏幕外面用display: none或者visibility: hidden藏起来。问题在于display: none的容器尺寸算出来是 0canvas 尺寸也是 0导出自然是一片空白。visibility: hidden虽然保留了尺寸但某些截图库会把不可见元素跳过结果也是空白。可靠的做法是用绝对定位把它挪出视口而不是隐藏。.export-target { position: fixed; left: -99999px; top: 0; width: 1200px; height: 600px; }尺寸保留、渲染保留、用户看不见三件事同时满足。6.4 地图类图表的额外注意点做过地图可视化的人都知道地图需要先注册对应的 GeoJSON 数据图表才能画出来。导出这件事本身不会因为地图而变复杂getDataURL一样能用。但有两点值得注意。一是地图上的散点、飞线如果用了透明度叠加导出成 JPEG 时边缘会出现明显的色块因为 JPEG 不支持透明通道半透明区域会被强制填充。这类图表一定导出 PNG。二是地图上的区域名称文字如果比较多pixelRatio给 1 的时候会糊成一团给 2 以上才看得清。6.5 文件名里的中文和特殊字符文件名带中文本身没问题浏览器支持。但带/、\、:、*、?、、、、|这些字符就会被系统拒绝或者被替换成下划线。如果文件名来自用户输入比如项目名称一定要做一次清洗。function sanitizeFilename(name) { return name.replace(/[\\/:*?|]/g, _).trim() || chart; }另外时间戳建议用YYYYMMDD-HHmmss这种格式不要用带冒号的 ISO 字符串冒号在 Windows 上是非法字符。6.6 大图导出与内存一张 4000×3000 的 PNG在内存里展开大概是 4000×3000×4 字节接近 48MB。如果同时导出好几张或者浏览器同时开着十几个标签页很容易卡顿甚至崩溃。我处理大图导出的做法是一次只导一张导完立刻释放。用 Blob createObjectURL的方式下载完成后马上revokeObjectURL。如果确实需要批量导出用队列串行执行每张之间加个很短的延时给浏览器回收内存的机会。7. 封装成通用导出工具的思路三种方式各写一遍之后你会发现大量逻辑是重复的构建 dataURL、生成 Blob、触发下载、清理资源。封装起来能省很多事。7.1 一个可复用的导出函数我把常用的部分抽成了两个函数一个负责从图表实例拿图一个负责把图落到本地。export function chartToBlob(chart, options {}) { const { type png, pixelRatio 2, backgroundColor #ffffff, excludeComponents [toolbox] } options; const dataURL chart.getDataURL({ type, pixelRatio, backgroundColor, excludeComponents }); return dataURLToBlob(dataURL); } export function dataURLToBlob(dataURL) { const [meta, base64] dataURL.split(,); const mime meta.match(/:(.*?);/)[1]; const binary atob(base64); const bytes new Uint8Array(binary.length); for (let i 0; i binary.length; i 1) { bytes[i] binary.charCodeAt(i); } return new Blob([bytes], { type: mime }); } export function saveBlob(blob, filename) { const url URL.createObjectURL(blob); const link document.createElement(a); link.href url; link.download filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() URL.revokeObjectURL(url), 1000); }setTimeout里延迟释放是必要的。立刻释放某些浏览器会来不及开始下载导致下载失败延迟一秒是个成本很低保险。7.2 组件层面的封装要点在 Vue 或者 React 组件里用的时候有几个点建议固定下来。图表实例要挂到ref上导出时直接取不要通过echarts.getInstanceByDom再去查一遍多一次查找多一个出错点。导出按钮的禁用状态跟数据加载状态绑定。导出的默认参数type、pixelRatio、backgroundColor抽成组件的 props 或者统一配置别散落在各个业务页面里。如果项目里同时存在 SVG 渲染器和 canvas 渲染器的图表导出函数里要先判断渲染器类型给出对应的提示或者降级方案。还有一点图表实例在组件卸载时一定要dispose否则那些 canvas 和事件监听会一直挂在内存里反复进出页面之后导出会越来越卡。8. 没有浏览器界面时怎么导出最后说一个很多人都会遇到的场景定时任务每天早上生成一份带图表的日报发到群里。这时候没有用户点按钮页面也不一定存在。8.1 Node 侧渲染的基本思路Echarts 支持在 Node 环境里渲染需要配合一个 canvas 实现。const echarts require(echarts); const { createCanvas } require(canvas); function renderChartToBuffer(option, width 1000, height 600) { const canvas createCanvas(width, height); const chart echarts.init(canvas, null, { renderer: canvas, width, height }); chart.setOption(option); const buffer canvas.toBuffer(image/png); chart.dispose(); return buffer; }有几个关键差异要记住。屏幕上的 2 倍图是靠pixelRatio实现的在 Node 里没有这个概念想要高清就自己把宽高乘上倍数同时把 Echarts 初始化时的width、height和 canvas 的尺寸都按放大后的值给。三处尺寸必须一致少改一处就会出现图形被裁切或者留白。另外中文字体需要服务端环境里有对应的字体文件否则中文会变成方块或者直接消失这是服务端渲染最常被投诉的问题。8.2 另一种兜底无头浏览器如果服务端渲染的样式和前端对不上或者是那种依赖大量 CSS 的复杂看板也可以考虑用无头浏览器去访问页面再截图。它的好处是所见即所得前端怎么写的导出就什么样代价是资源占用高、启动慢不适合高频调用。我一般把它当作兜底方案只在服务端渲染确实做不出效果时才上。至于选哪种我的判断是图表结构简单、样式统一走 Node 渲染图表样式复杂、强依赖页面 CSS走无头浏览器。中间地带可以两种都做接口层面暴露一个参数让调用方选。到这里三条路和一条兜底路都讲完了。实际用下来我最大的体会是导出这个功能看起来是最后加个按钮的小事但它牵扯到渲染时机、像素密度、内存释放、跨域限制这些底层问题任何一个没想清楚都会在验收当天暴露出来。我现在的习惯是在项目一开始就把导出能力设计好而不是等业务提了需求再补因为补的时候往往要改图表实例的组织方式改起来比重写还麻烦。如果非要给一个最省心的默认组合那就是图表实例统一用 canvas 渲染器导出走getDataURL加 Blob 下载清晰度给 2 倍背景色永远显式指定白色需要连周边 DOM 一起导出时再上 DOM 截图库。这套组合我在几个项目里用了两年多除了业务本身的特殊样式基本没再出过幺蛾子。
网站建设高端定制企业官网