
“图表画得再漂亮导不出来就是白干。”这句话是我做数据可视化这几年体会最深的一条。Echarts 图表导出为图片这件事看着像个小功能真落到项目里——数据可视化大屏要支持一键存图、周报要把折线图塞进文档、运营要把饼图搬进汇报幻灯片、定时报表要批量生图归档——每一个场景都会让“导出”两个字从可有可无变成必须交付的需求。我手上跑过几个大屏项目验收阶段被追问最多的三个问题永远是导出按钮在哪、为什么导出后糊成一片、为什么地图那一块是空白。这篇就把 Echarts 图表导出为图片的三种方式完整拆一遍浏览器端用getDataURL直接取图、实例内置的toolbox.saveAsImage保存图片、以及把渲染搬到服务端做截图。三种方式各自解决什么问题、关键参数怎么配、清晰度怎么算、哪些坑必须提前躲开都会写清楚。不管你是刚接需求的前端新手还是被批量报表折腾过的老手看完应该都能直接照着抄。1. 三种导出方案的整体选型与思路拆解1.1 先弄明白业务要的到底是什么很多同学一接到“导出图片”需求就去找代码片段结果写完了被产品打回来重做。问题不在代码在于需求本身没有被拆开看。同样是导出下面这四种诉求对应的技术方案完全不同。第一种是单图交互导出。用户在页面上看着某个图表点一下按钮把当前这张图存到本地。这是最常见的一种特点是数据已经在浏览器里渲染完了直接取像素就行。第二种是图文混排导出。页面上有标题、说明文字、筛选条件、三张图要合成一整张长图。这时候单张图表取图只是原料后面还得用 canvas 做二次拼装。第三种是批量无感导出。后端定时任务跑一遍把前一天所有指标图生成好塞进邮件或者归档到对象存储。这种场景里根本没有浏览器窗口给你用。第四种是分享图导出。移动端分享出去的卡片图尺寸固定、带品牌底图、要求清晰。这种对像素比和背景色的要求最严。把这四类想清楚方案基本就定了。第一、第二类在浏览器端解决最省事第三类必须走服务端第四类是前两类的特化版本。1.2 三种方式的边界与对照先上一张我平时用来快速判断的对照表省得每次都要重新推一遍。对比维度getDataURLtoolbox.saveAsImage服务端渲染导出执行位置浏览器内存浏览器内存服务器进程接入成本低十几行代码最低一个配置项高需要搭环境用户操作自己挂按钮触发图表右上角自带无需用户参与清晰度控制pixelRatio自由设定pixelRatio可配置视口缩放或矢量转位图能否批量不擅长不能天然擅长依赖用户在线必须在线必须在线不依赖典型场景单图导出、二次合成内部工具、快速交付定时报表、归档、分享图这张表里我最想强调的是最后一行。很多人一上来就想搞个大而全的统一导出服务结果开发量翻了三倍实际上线后发现 80% 的导出请求都是用户在页面上手动点的用getDataURL二十行就能搞定。1.3 我的优先选择顺序按我自己的习惯遇到导出需求先按这个顺序判断如果用户就在页面上、只要能存一张图直接用getDataURL可控性最好。如果这是个内部使用的图表工具、没人愿意为导出再写按钮打开toolbox的saveAsImage就够了。只有当出现“定时”“批量”“用户不在页面”这三个关键词之一时才考虑把渲染搬到服务端。这个顺序的好处是把复杂度后置。我先用最低成本把需求接住等真的出现了服务端批量导出的诉求再把渲染逻辑抽出来独立成服务前端的代码完全不用动。还有一个容易被忽略的点如果项目里有多个页面都要导出别每处各写一遍getDataURL。抽一个exportChart(chartInstance, options)的工具方法把背景色、像素比、文件命名规则统一收在里面后续调整清晰度时只改一个地方。2. 浏览器端 getDataURL最轻量也最可控的取图方式2.1 getDataURL 到底做了什么Echarts 实例上的getDataURL方法本质上是把这个实例当前渲染出来的画布内容按指定格式编码成一段 data URL 字符串。它拿的是“此刻画布上的像素”而不是重新渲染一遍。这一点非常关键直接决定了后面很多坑的成因。因为拿的是当前状态所以你在页面上做的任何操作都会体现在导出结果里图例被点掉了、某个 series 通过legend隐藏了、通过dispatchAction高亮了某个数据点导出时都会保留下来。这既是优点也是陷阱——用户如果先把图例关了两条再看图导出时那两条就真的没了。getDataURL的常用参数有四个我逐个说清楚。参数类型作用我的常用值typestring输出格式png/jpeg/svgpngpixelRationumber像素倍率决定清晰度2 或 3backgroundColorstring导出图的底色#ffffffexcludeComponentsarray导出时排除的组件[toolbox]type这一项有个细节必须提醒想要拿到 SVG 矢量结果实例初始化时renderer就必须设成svg。如果用的是默认的 canvas 渲染器type: svg拿不到有价值的矢量内容。反过来SVG 渲染器下拿到的是一段data:image/svgxml;charsetUTF-8,开头的字符串有些浏览器不允许直接用它触发下载需要先转成 Blob 再下载。2.2 背景色不设导出图在深色环境里就是灾难backgroundColor不传的情况我见过太多次了。默认情况下导出的是透明背景把这张图贴到深色主题的 PPT 或者深色模式的文档里浅色的坐标轴文字直接看不见。更麻烦的是项目里常见的“深色大屏”需求。页面背景是深蓝渐变图表配置里没有单独设背景色用户点导出拿到一张透明底的图贴到白底文档里白色的坐标文字又消失了。这类工单我至少处理过五次。正确做法是在导出时手动指定底色const url chart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #ffffff, // 导出为白底与文档环境匹配 });如果大屏本身就是深色的、导出后要发给同事在深色环境里看那就传深色const url chart.getDataURL({ type: png, pixelRatio: 2, backgroundColor: #0b1a33, });一个更省心的方案是让图表容器本身有背景色CSS 设置同时在setOption里也配一份backgroundColor这样页面显示和导出结果是一致的不用在导出时特殊处理。2.3 pixelRatio 到底该填多少pixelRatio是导出清晰度的唯一开关也是最容易配错的地方。它的含义是导出的图片宽度 图表容器宽度 × pixelRatio。举个具体的数容器宽 800pxpixelRatio: 2导出的 PNG 就是 1600px 宽。这个尺寸放进 Word 文档里基本够用塞进 PPT 单页也能撑住。如果这张图要用于打印就需要倒推。A4 纸宽 210mm按 300dpi 打印质量换算需要约 2480px 宽。容器是 800px那么pixelRatio至少要 3.1取 4 比较稳妥。代价是文件体积。1500px 宽的折线图 PNG 大概 200KBpixelRatio从 2 提到 4宽度翻倍像素总量变成四倍文件可能直接涨到 1MB 以上。如果导出后还要走接口上传建议控制在pixelRatio: 2到3之间再大就考虑输出 JPEG。2.4 一份可以直接抄的完整导出代码下面这段是我在多个项目里迭代过的版本处理了下载、命名、异常提示三个环节/** * 导出 Echarts 实例为图片 * param {Object} chart echarts 实例 * param {Object} opts 配置项 */ function exportChartAsImage(chart, opts {}) { const { fileName 图表_${formatDate(new Date())}, type png, pixelRatio 2, backgroundColor #ffffff, } opts; let url; try { url chart.getDataURL({ type, pixelRatio, backgroundColor, excludeComponents: [toolbox], }); } catch (err) { console.error([导出失败] getDataURL 抛出异常, err); // 常见原因canvas 被跨域图片污染 return; } if (!url || url data:, || url.length 100) { console.warn([导出失败] 返回内容为空检查图表是否已完成渲染); return; } const a document.createElement(a); a.href url; a.download ${fileName}.${type jpeg ? jpg : type}; a.style.display none; document.body.appendChild(a); a.click(); document.body.removeChild(a); // 及时回收避免在低端机上堆积 window.setTimeout(() document.body.removeChild(a), 0); } function formatDate(d) { const p (n) String(n).padStart(2, 0); return ${d.getFullYear()}${p(d.getMonth() 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}; }调用就是一行exportChartAsImage(myChart, { fileName: 月度销售趋势, pixelRatio: 3 });注意getDataURL必须在图表已经完成渲染之后调用。如果放在setOption的紧后面某些异步数据场景下图表还在动画过程中导出的可能是半透明的中间帧。稳妥做法是监听finished事件。myChart.on(finished, () { // 首次渲染完成可以安全导出 exportChartAsImage(myChart); });finished事件在每次动画结束后都会触发所以记得加个一次性标记不然会重复执行。3. toolbox 内置保存图片零代码可用但得会改3.1 打开它只需要一个配置项Echarts 的toolbox组件自带了一个saveAsImage功能配置上去之后图表右上角就会出现一个小相机图标用户点一下就能存图。option { toolbox: { show: true, feature: { saveAsImage: { show: true, title: 保存为图片, type: png, name: 销售趋势图, backgroundColor: #ffffff, pixelRatio: 2, }, }, }, // ... 其他配置 };这是所有导出方案里接入成本最低的几乎等于白送。内部工具、后台管理系统、给运营同事用的自助分析页面用它完全够。我做过一个只有七八个图表的内部看板全程没写一行导出逻辑就靠这个配置项交付的用了一个季度没人反馈问题。3.2 常用的几个配置项该怎么设saveAsImage支持的配置项不算多但有几个必须调。name是导出文件名默认是chart导出一堆文件之后全是chart(1).png、chart(2).png找都找不着。建议用图表主题命名比如月度活跃用户。pixelRatio默认值是 1这是很多人抱怨“导出图很糊”的根源。调到 2 或 3清晰度立刻上一个台阶。注意这个值和屏幕的devicePixelRatio是两回事别混淆。excludeComponents默认就是[toolbox]也就是导出时会把工具箱本身隐掉。这个默认值是合理的保留它。如果你额外加了dataZoom、brush之类组件也可以加进去让导出图更干净。backgroundColor默认是auto会读取图表配置的backgroundColor。如果图表没设背景导出的还是透明底。所以显式写一个颜色是最保险的。type除了png还能选jpeg。JPEG 不支持透明所以用 JPEG 时backgroundColor必须显式指定否则透明区域会被填成黑色。这个坑我踩过一次导出的一批图全部黑底排查了半天才发现是格式问题。3.3 三个高频踩坑点第一个坑是异步渲染。用户手快页面刚打开图表还在动画中就把相机点了导出来的是残缺图。解决办法是给 toolbox 加一点限制或者在数据加载完成后延迟几百毫秒再让 toolbox 显示toolbox: { show: false, // 初始隐藏 feature: { saveAsImage: { /* ... */ } }, } // 数据到位后再打开 myChart.setOption({ toolbox: { show: true } });第二个坑是 SVG 渲染器下的坐标偏移。项目里如果初始化用的是renderer: svg在某些 Echarts 版本下saveAsImage导出的 SVG 会出现labelLine末端小圆点位置偏移、饼图标签位置对不上的问题。这类问题通常和渲染器在导出时对坐标系的重算有关如果图表对位置精度要求很高建议实测后再交付必要时切回 canvas 渲染器。第三个坑是 tooltip 不会被导出。tooltip 是挂在图表容器上的 DOM 节点不在画布里面saveAsImage捕获不到它。如果业务方期望“导出的图要带着数据提示框”用这个方案做不到得自己合成。3.4 什么时候不该用它toolbox的按钮在图表右上角位置固定样式和交互都不好改。如果你的页面设计稿里导出按钮需要放在页面顶部的工具栏里、需要和其他按钮保持统一风格那么这个自带的相机图标就没法用了。另外一个限制是它只能导出当前这一个实例。如果是多图导出或者需要把标题、筛选条件一起导出还是得回到getDataURL体系自己拼装。我的做法是内部工具放开用对外产品一律自己写按钮。这样交付质量和设计规范都能兜住。4. 服务端渲染导出批量与自动化场景的正解4.1 什么情况下必须把渲染搬到后端判断标准很简单出现下面任意一条前端方案就不合适了需要在没有用户操作的情况下生成图片比如每天凌晨生成日报图。需要一次性生成几十上百张图前端逐张点击不现实。生成的图片要直接存进对象存储或者作为附件发邮件不经过用户浏览器。需要生成带品牌底图、带二维码的分享卡片前端拼装逻辑太散。这类需求我在做运营报表系统时遇到过。当时的要求是每天早八点把前一天的核心指标生成六张图拼成一张长图通过邮件发给业务负责人。这个链路里根本没有浏览器只能走服务端。4.2 Node 侧用无头浏览器截图的完整流程最通用的服务端方案是跑一个无头浏览器打开预先部署好的图表页面等渲染完成后截图。这个方式的优点是所见即所得——页面长什么样导出的图就什么样前端怎么调的颜色、字体、间距后端完全不用管。先装依赖npm install puppeteer完整脚本const puppeteer require(puppeteer); const path require(path); async function exportChartPage(pageUrl, selector, outFile, scale 2) { const browser await puppeteer.launch({ headless: new, args: [--no-sandbox, --disable-dev-shm-usage], }); try { const page await browser.newPage(); // deviceScaleFactor 等价于前端的 pixelRatio await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: scale }); await page.goto(pageUrl, { waitUntil: networkidle0, timeout: 60000 }); // 页面里渲染完成后打个标记避免截到动画中间帧 await page.waitForFunction(() window.__CHART_READY__ true, { timeout: 30000, }); const el await page.$(selector); if (!el) throw new Error(找不到元素${selector}); await el.screenshot({ path: path.resolve(outFile), type: png, omitBackground: false, }); return outFile; } finally { await browser.close(); } } module.exports { exportChartPage };对应的前端页面里加上渲染完成标记const chart echarts.init(document.getElementById(main)); chart.setOption(option); chart.on(finished, () { window.__CHART_READY__ true; });这里有两个参数值得展开说。deviceScaleFactor的作用和前端pixelRatio一样设成 2 就是两倍分辨率截图配合waitForFunction能拿到非常干净的图。--disable-dev-shm-usage这个参数在容器环境里几乎是必须的不加的话并发跑几张图就容易因为共享内存不足而崩掉这个坑我在 Docker 里调了整整一个下午。waitUntil: networkidle0表示等网络请求全部停下来。如果图表页面会拉取较大的数据建议改成domcontentloaded配合自定义的waitForFunction否则容易在这步超时。4.3 无浏览器方案SSR 加矢量转位图无头浏览器虽然万能但启动一个浏览器进程的内存开销不小跑个几十张图还能接受如果要高频调用或者部署在资源受限的机器上就有点吃力。Echarts 从 5.3 版本开始提供了服务端渲染能力可以完全脱离浏览器生成图片。思路是这样的用 Echarts 的服务端渲染模式直接把图表渲染成 SVG 字符串再交给图像处理库转成 PNG。const echarts require(echarts); const { createCanvas } require(canvas); const sharp require(sharp); // 让 Echarts 知道用什么来创建画布 echarts.setPlatformAPI({ createCanvas: () createCanvas(1200, 700), }); async function renderToPng(option, outFile, width 1200, height 700) { const chart echarts.init(null, null, { renderer: svg, ssr: true, width, height, }); chart.setOption(option); const svgStr chart.renderToSVGString(); chart.dispose(); // 交给 sharp 转位图density 控制清晰度 await sharp(Buffer.from(svgStr), { density: 200 }) .png() .toFile(outFile); return outFile; }这套方案的优点很明显不依赖浏览器内存占用低启动速度快很适合放在容器里做批量生成。renderer: svg配合ssr: true是官方推荐组合注意chart.dispose()一定要调不然跑批量任务时内存会一直涨。sharp的density参数控制栅格化时的清晰度默认 72我一般设 150 到 300 之间。设太高会导致输出文件巨大实际意义不大。需要提醒的是服务端渲染对type: custom的自定义系列支持有限涉及复杂自定义绘制时建议还是回退到无头浏览器方案。另外如果图表依赖浏览器的字体环境服务端也需要装好对应字体文件不然会出现文字位置偏移或者方框乱码。4.4 后端拿到的图片怎么存怎么回传服务端生成图片后通常会遇到两个处理分支。如果只是给接口调用方返回最省事的是直接返回 base64 字符串前端拿到之后转 Blob 下载// Node 侧 const buffer await sharp(Buffer.from(svgStr), { density: 200 }).png().toBuffer(); res.json({ code: 0, data: data:image/png;base64,${buffer.toString(base64)} });// 前端侧 function base64ToBlob(base64, mime image/png) { const arr base64.split(,); const bstr atob(arr[1]); const u8 new Uint8Array(bstr.length); for (let i 0; i bstr.length; i) u8[i] bstr.charCodeAt(i); return new Blob([u8], { type: mime }); }如果是要归档直接写进对象存储返回一个可访问的地址更合适。base64 内联在 JSON 里会让响应体膨胀约三分之一几十张图的场景下这个开销不能忽略。顺带说一句有些后端同学会想在自己熟悉的语言里直接画图。比如 PHP 项目里想生成图表图片通常不建议重写渲染逻辑——图表的视觉效果在前端已经调好了后端重画一遍必然对不上。常见做法是把渲染交给一个独立的 Node 渲染服务PHP 只负责传数据和存文件职责清晰维护成本也低。5. 常见问题与排查技巧实录5.1 导出结果是空白的这是被问得最多的问题。按我的经验原因基本集中在三类。第一类是图表还没渲染完就调用了导出。表现是图片有背景色但没有内容。处理办法就是前面说的监听finished事件或者至少延迟一个动画周期。如果图表设置了animation: false渲染是同步的这个问题就不会出现但生产环境里大部分图表都开着动画。第二类是容器尺寸为零。图表容器如果用display: none隐藏着或者父元素没有高度画布的实际宽高是 0导出的自然也是空图。常见于“导出前先隐藏其他标签页”的场景。解决办法是先让容器可见、拿到真实尺寸之后再导出导完再隐藏回去。第三类是 canvas 被跨域资源污染。这个下面单独说。5.2 跨域图片导致的导出失败项目里用到pictorialBar配合自定义图片比如柱状图上贴一个小图标、或者地图上的散点用外部图标图片域名和页面域名不一致时画布就会被标记为“污染”状态。这时候调getDataURL会直接抛安全异常。处理方式有两个。一是让图片服务的响应头带上允许跨域的字段同时在 Echarts 配置里显式声明让请求带上匿名标记// 服务器响应头需要包含 Access-Control-Allow-Origin series: [{ type: pictorialBar, symbol: image://https://cdn.example.com/icon.png, symbolSize: [40, 40], crossOrigin: anonymous, }]二是把小图标直接转成 base64 内联到代码里。这个方式绕过了跨域问题代价是包体积变大。图标数量少、体积小的时候我一般选这个最省事。还有一种情况是本地开发环境图片能显示、导出也正常部署到生产就不行。这时候先检查生产的 CDN 有没有配跨域头别急着改代码。5.3 导出后文字发虚、线条模糊发虚几乎总是pixelRatio设得太低。默认的saveAsImage是 1getDataURL不传也是 1在很多高分屏上放大看就会糊。但也有一种情况是调高了还糊这时候要检查是不是导出的图片被二次缩放。比如导出 1600px 宽的图又用 CSS 强制显示成 300px 宽然后再截图结果自然糊。这类问题出在合成环节而不是导出环节。还有一种是 SVG 转位图时密度不够。服务端方案里sharp的density默认只有 72转出来的位图文字边缘会有明显锯齿调到 150 以上就顺畅了。5.4 关于 rem 布局下图表尺寸的坑有同学问过项目里用pxtorem做了移动端适配为什么图表导出的尺寸和预期不一致。原因在于 Echarts 画在 canvas 上canvas 内部的像素尺寸由初始化时传入的宽高决定rem 缩放只影响 CSS 显示尺寸不会改变画布的实际像素。所以导出时算尺寸要读容器的offsetWidth和offsetHeight这两个是布局后的真实像素值而不是 CSS 里写的 rem 数值。如果图表需要跟着屏幕缩放正确做法是监听窗口大小变化后调chart.resize()让 Echarts 重新计算画布尺寸而不是指望 CSS 缩放自动生效。5.5 问题速查表现象大概率原因处理办法导出图全白渲染未完成或容器尺寸为 0监听finished事件检查容器可见性抛安全异常canvas 被跨域图片污染配置跨域响应头加crossOrigin或改用 base64 图标文字模糊pixelRatio太低或二次缩放调到 2 至 3排查合成环节背景透明未设backgroundColor显式指定底色JPEG 格式必须设导出的 JPEG 全黑JPEG 不支持透明必须先设backgroundColor图例凭空少了几条用户手动隐藏过导出前用dispatchAction恢复图例状态饼图标签偏移SVG 渲染器兼容问题实测后考虑切回 canvas 渲染器批量导出内存暴涨实例未释放每次导出后调用dispose()tooltip 没出现tooltip 是 DOM 不在画布内需要则单独合成不要指望导出捕获服务端文字变方框缺少字体文件容器内安装所需字体这张表我放在项目 Wiki 里团队里新人遇到导出问题先自查一遍能解决八成的情况。5.6 几个提升交付质量的小技巧导出的文件名带时间戳能省掉很多“哪张是最新的”这类沟通成本。我一般用指标名_YYYYMMDD_HHmm.png的格式。导出前把 toolbox 从结果里排除掉excludeComponents: [toolbox]就够了免得导出的图右上角带着一个孤零零的相机图标。如果是给汇报用的图导出后可以顺手加个标题栏。做法是创建一个离屏 canvas先画上标题文字和日期再把图表图片画在下面。多花二十分钟交付质感完全不一样。图表数量多的时候给导出按钮加个 loading 状态防止用户连点导致重复下载。这个细节看起来小但能避免不少“为什么我点了三次下载了三张一样的图”的追问。6. 我踩过几次坑之后的真实体会最开始做导出功能的时候我总想着一步到位直接上服务端方案觉得那样最“专业”。结果一个只有三个图表的后台页面为了导出功能额外搭了一套渲染服务运维复杂度上去了收益却没多少。后来我把方案退回到getDataURL二十行代码解决问题团队里谁都能维护。真正需要服务端方案的场景其实很有限通常是那种“用户不在场”的定时任务。这时候我的判断标准变得很干脆只要导出动作需要人来触发就留在前端只要导出动作不需要人参与就放到后端。按这个标准走技术选型几乎不会错。另一个体会是导出这件事的难点从来不在代码而在细节。背景色、像素比、文件名、图例状态、跨域图片、字体环境每一样单独拎出来都是小事凑在一起就是交付质量的分水岭。我现在的习惯是导出功能写完之后一定要在四种环境里实测一遍浅色文档、深色文档、打印预览、移动端查看。这四个场景里都没问题才算真正做完。最后一个建议给正在接这类需求的朋友动手之前先问清楚一句话——这张图最终是要放进哪里。答案会直接告诉你该用什么格式、什么像素比、什么背景色。问清楚这一句能省掉后面两轮返工。