简介:面向需要将ECharts图表导出为PDF的前端开发者,这套压缩包汇总了ECharts、html2canvas、jsPDF三个关键JavaScript库及一个可直接运行的示例页面,解决在数据分析报表场景中浏览器端生成含图表PDF的常见需求。压缩包共4个文件,以三个js库为主,另有一个html示例,整体仅345KB,轻量易下载,适合有基础前端知识、希望快速集成导出功能的读者参考。配套说明详细给出了从初始化ECharts、通过html2canvas将图表转为Canvas,再到用jspdf输出PDF的完整思路,并提示了图片质量、多页处理及跨域Base64编码等注意事项。示例页面可以帮读者免去自行组合配置的繁琐,直接对照演示代码修改数据和选项即可。目前已有1393人浏览学习,对于需要处理可视化报告导出的学习者,是一份可直接上手的小工具包。
1. ECharts 图表生成 PDF 所需文件:三个 JS 就能打通导出链路
做过报表导出的人都有印象:页面里 ECharts 图画得再漂亮,到了 PDF 里不是白屏就是模糊。最常见的解法不是后端截图,而是前端拼三个文件——echarts.min.js 负责画图,html2canvas 负责把图表区域拍成 canvas,jspdf.min.js 负责把 canvas 排进 PDF。这个资源包就是把生成 ECharts 图表 PDF 所需的三个文件按可用版本配好,拿到就能直接引入项目。适合正在给后台管理、数据大屏加导出功能的前端开发者,也适合不知道依赖怎么配的学生项目。下面我把完整的调用逻辑、参数设置和踩过的坑按实现顺序拆开说。
2. 三件套分工:echarts.min.js、html2canvas、jspdf.min.js 各自要干什么
这套方案的链路非常直白:ECharts 把数据画成图表,html2canvas 把图表所在区域复制成一张图片,jsPDF 把这张图片粘贴进 PDF 文件。三个库各管一段,边界清晰。
2.1 echarts.min.js:容器、实例与渲染器选择
ECharts 本身是个绘图库,它把配置项转换成图形绘制在 canvas 或 SVG 上。默认渲染逻辑是 canvas,也就是页面里能看到的是一个<canvas>元素,ECharts 通过canvas.getContext('2d')在上面绘制柱形、折线、饼图等。
在做 PDF 导出时,最终要拿到的是 canvas 上的像素数据,所以建议保持默认的 canvas 渲染器。如果你在项目里特意设置了renderer: 'svg',html2canvas 虽然也能识别 SVG 并截图,但 SVG 内部的文字、渐变、跨域图片处理会比 canvas 多一层额外转化,导出的边界情况更难排查。因此我一般在初始化 ECharts 时不改 renderer,保证导出链路一致。
ECharts 实例必须绑定在一个有实际宽高的容器上。如果容器是display:none或者被v-if卸载后再初始化,echarts.init()会拿到宽高为 0 的 canvas,后续截图必然是空的。常见做法是给容器固定宽高:
const chartDom = document.getElementById('chart'); const chart = echarts.init(chartDom); chart.setOption({ xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: [120, 200, 150] }] });echarts.init的第二个参数可以传主题,第三个参数可以传renderer。这里保持默认即可。需要注意的是,setOption是异步渲染的,代码执行到下一行时画布不一定已经完成绘制,这个问题在导出时会放大,下文会有对应的处理方式。
2.2 html2canvas:把 DOM 拍成 canvas 快照的原理
html2canvas 并不是浏览器原生的截图接口,它做的事情是递归遍历目标 DOM 节点,把每个元素的位置、尺寸、颜色、边框、字体等样式计算出来,再利用 canvas 的绘图 API 重新画一遍。也就是说,它模拟了一次浏览器渲染。
这个特性决定了两个重要结论:第一,它能看到 ECharts 画出的<canvas>元素,但只会把这个 canvas 当成一张普通图片贴上去,不会重新生成图表内的柱形和文字;第二,如果 ECharts 的 canvas 还没有绘制完成,html2canvas 贴上去的就是一张空白画布。所以导出函数里经常能看到setTimeout或等待finished事件,本质上是给 ECharts 留够绘制时间。
html2canvas 的常用配置项里,我几乎每次都带这几项:
html2canvas(document.getElementById('chart'), { scale: 2, useCORS: true, backgroundColor: '#ffffff', logging: false });scale控制输出 canvas 的分辨率倍率,默认是 1。如果直接以这个分辨率放进 A4 PDF,图片宽度会被拉伸到 595 磅左右,等效 DPI 不够,打印出来文字全是锯齿。设成 2 或 3 能明显改善清晰度。useCORS允许跨域图片参与绘制,否则 canvas 会被污染,导致toDataURL()直接抛错。backgroundColor强制白色背景,避免透明背景在 PDF 阅读器里显示成黑色。
2.3 jspdf.min.js:页面坐标、单位和图片格式
jsPDF 的职责是创建一个 PDF 文档,然后通过addImage把图片放到页面上。实例化时可以指定单位,常用pt(磅)或mm。A4 纸在pt单位下是 595.28 x 841.89,在mm下是 210 x 297。我个人习惯用pt,因为前端代码里的像素单位换算起来更直观:1 像素约等于 0.75 磅,导出时通常会做 2 倍缩放。
addImage的签名是addImage(imageData, format, x, y, width, height)。imageData可以是图片 URL,也可以是 canvas 的 DataURL。format要对应,传了 PNG 就写'PNG',传了 JPEG 就写'JPEG'。如果图片本身是透明背景,用 JPEG 会把透明区域变成黑色,所以要么用 PNG,要么在 html2canvas 阶段就垫白底。
坐标原点是页面左上角,y 轴向下增加。很多人第一次写addImage时把 y 坐标当成了数学里的纵轴,结果图片跑到了页面右上角或直接被裁掉。下面这段代码是一个最基础的导出动作:
const { jsPDF } = window.jspdf; const pdf = new jsPDF('p', 'pt', 'a4'); const imgData = canvas.toDataURL('image/jpeg', 0.92); const pageWidth = pdf.internal.pageSize.getWidth(); const margin = 20; const imgWidth = pageWidth - margin * 2; const imgHeight = (canvas.height * imgWidth) / canvas.width; pdf.addImage(imgData, 'JPEG', margin, margin, imgWidth, imgHeight); pdf.save('chart.pdf');canvas.height / canvas.width保持图片原始宽高比,避免图表被拉扁。
2.4 为什么不直接用 ECharts 的 getDataURL
这里有个很容易被忽视的替代方案:ECharts 实例自己就有getDataURL()方法,能拿到 canvas 的 base64。那为什么还要引入 html2canvas?
因为getDataURL()只能拿到图表自身的 canvas,图表外面的标题、边框、自定义 HTML 模板、水印、统计卡片都不会包含进去。如果需求只是“把这张图导出去”,可以直接用chart.getDataURL({ pixelRatio: 2 });但如果需求是“把整个报表卡片导出去”,就必须靠 html2canvas 截取 DOM 区域。
所以这套三件套的适用边界很清楚:导出内容里除了 ECharts 图表,还有周边 DOM 元素。纯图表导出用不上三个文件,反过来也说明这三个文件组合是报表导出场景的标配。
3. 最小可用导出链路:从 ECharts 实例到 PDF 下载
这一章给出一个可以完整跑通的最小实现。页面里只有一个图表和一个导出按钮,点击按钮后生成 PDF。
3.1 先准备一个固定宽高的图表容器
HTML 部分不需要复杂结构,一个带 id 的 div 就行。建议加上明确的宽高和白色背景,避免后续截图时拿到 0 尺寸或透明底:
<div id="report" style="width: 800px; height: 450px; background: #fff; padding: 12px;"> <div id="chart" style="width: 100%; height: 400px;"></div> </div>注意外层容器带 padding,这样导出的图片会把内边距也带进去,视觉上更像一张报表卡片。#chart是 ECharts 的挂载点,宽高由外层撑开。
3.2 完成图表渲染
在页面脚本里初始化 ECharts 并塞入一份测试数据:
const chart = echarts.init(document.getElementById('chart')); chart.setOption({ title: { text: '一周访问量趋势' }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] }, yAxis: { type: 'value' }, series: [{ name: '访问量', type: 'line', data: [820, 932, 901, 934, 1290] }] });初始化时如果容器刚被创建但尚未布局完成,echarts.init会计算出错误的尺寸。我的习惯是在window.requestAnimationFrame或setTimeout回调里再 init,确保浏览器已完成当前帧的样式计算。这个项目里如果页面是静态的,一般不会碰到,但静态页面转成动态框架后就很容易踩。
3.3 导出按钮触发截图与 PDF 生成
按钮点击事件里做的事其实就三步:截图、计算尺寸、写入 PDF。完整代码如下:
document.getElementById('exportBtn').addEventListener('click', function () { const target = document.getElementById('report'); html2canvas(target, { scale: 2, useCORS: true, backgroundColor: '#ffffff' }).then(function (canvas) { const { jsPDF } = window.jspdf; const pdf = new jsPDF('p', 'pt', 'a4'); const imgData = canvas.toDataURL('image/jpeg', 0.92); const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 24; const imgWidth = pageWidth - margin * 2; const imgHeight = (canvas.height * imgWidth) / canvas.width; let y = margin; if (imgHeight > pageHeight - margin * 2) { // 单张图超过一页高度时先切页,下面第 4 章会展开 pdf.addPage(); y = margin; } pdf.addImage(imgData, 'JPEG', margin, y, imgWidth, imgHeight); pdf.save('report.pdf'); }); });这段代码里的scale: 2保证输出图片的物理像素足够,imgHeight按宽高比计算,防止图表变形。pdf.save会触发浏览器下载,文件名可以按当前日期动态拼接,这里省略了。
3.4 导出时机:ECharts 渲染完成的判断
上面代码里,如果点击按钮时 ECharts 动画还没播完,html2canvas截到的可能是一半柱形。最稳的处理方式是监听 ECharts 的finished事件,但这个事件只触发一次,不适合每次导出都绑定。
我一般会在导出函数开头调用一次chart.resize(),把尺寸强制刷新,然后用requestAnimationFrame给一个帧的等待时间:
chart.resize(); requestAnimationFrame(function () { html2canvas(target, { scale: 2, useCORS: true }).then(...); });chart.resize()会同步更新 canvas 尺寸,requestAnimationFrame保证浏览器完成一次绘制。如果图表数据量很大,可以把requestAnimationFrame换成 200~300ms 的setTimeout,但没必要一上来就固定死。
4. 多图表与分页输出:把一整张报表排成多页 PDF
真实业务里不可能只有一个图表。一个数据看板往往有 4 到 6 个图表,如果全部截到一张图片里,要么文字被压缩,要么导出后看不清。正确的做法是每个图表单独截图,在 PDF 里按顺序排列,放不下就自动加页。
4.1 布局与选择器
先给所有图表加一个统一样式类,例如每个卡片都带chart-panel:
<div class="chart-panel" style="width: 100%; height: 380px; background: #fff; margin-bottom: 12px;" id="panel1"></div> <div class="chart-panel" style="width: 100%; height: 380px; background: #fff; margin-bottom: 12px;" id="panel2"></div>每个面板都已经初始化好 ECharts 实例,导出时只需要把这些面板的 DOM 元素收集起来,逐一截图。
4.2 用 Promise.all 处理异步截图
html2canvas是异步任务,如果在一个循环里逐个调用再最后生成 PDF,会因为任务还没结束就执行save而出错。推荐用Promise.all把所有截图任务并行执行,全部完成后统一写入 PDF:
const panels = Array.from(document.querySelectorAll('.chart-panel')); Promise.all( panels.map(el => html2canvas(el, { scale: 2, useCORS: true, backgroundColor: '#ffffff' })) ).then(function (canvases) { const { jsPDF } = window.jspdf; const pdf = new jsPDF('p', 'pt', 'a4'); const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 24; let offsetY = margin; canvases.forEach(function (canvas) { const imgWidth = pageWidth - margin * 2; const imgHeight = (canvas.height * imgWidth) / canvas.width; // 剩余空间不足时,切换到新页面 if (offsetY + imgHeight > pageHeight - margin) { pdf.addPage(); offsetY = margin; } pdf.addImage(canvas.toDataURL('image/jpeg', 0.92), 'JPEG', margin, offsetY, imgWidth, imgHeight); offsetY += imgHeight + 16; // 16 为图表间距 }); pdf.save('dashboard.pdf'); });offsetY是当前页的纵向光标,每放一张图就累加图片高度和间距。当光标接近页底时,先加一页再重置为margin,这样就不会出现“第二张图画在第一张图上”的错乱。16 这个间距值可以调整,也可以从 CSS 样式里读取,但因为 html2canvas 已经把 margin 画进了截图里,这里最好用一个独立间距,避免与截图内的边距混淆。
4.3 单图超高时的图片拆分
如果某个图表本身高度超过一页,例如一张超长的河流图或时序全景图,直接塞到 PDF 会超出页面边界,后半截内容被裁掉。处理办法是把大 canvas 切成多段,每段对应 PDF 的一页。
function splitCanvasToPdf(canvas, pdf) { const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 24; const viewableHeight = pageHeight - margin * 2; const imgWidth = pdf.internal.pageSize.getWidth() - margin * 2; const ratio = imgWidth / canvas.width; const imgHeightPerPage = viewableHeight; let renderedHeight = 0; const slice = document.createElement('canvas'); slice.width = canvas.width; slice.height = Math.floor(viewableHeight / ratio); while (renderedHeight < canvas.height) { const curHeight = Math.min(slice.height, canvas.height - renderedHeight); slice.getContext('2d').drawImage( canvas, 0, renderedHeight, canvas.width, curHeight, 0, 0, canvas.width, curHeight ); if (renderedHeight > 0) { pdf.addPage(); } pdf.addImage(slice.toDataURL('image/jpeg', 0.92), 'JPEG', margin, margin, imgWidth, curHeight * ratio); renderedHeight += curHeight; } }这里用drawImage做按行切分,renderedHeight记录已经裁出的部分。要注意pdf.addImage传入的 height 必须与切分区域的curHeight按比例换算,否则页面上下会留出错误空白。这个方法也可以和前面的多图表逻辑合并,在遍历图表时先判断高度是否超出一页。
4.4 横向或自定义页面尺寸
如果图表整体很长,使用纵向 A4 可能需要 3 到 4 页,阅读起来不连贯。常见做法是把 PDF 改成横向 A4,new jsPDF('l', 'pt', 'a4')第一参数传'l'表示横向。页面宽高会交换,pageWidth仍然是internal.pageSize.getWidth(),代码逻辑不用变,但截图里内容的呈现方向需要前端先确认。
5. 避坑与常见问题:导出白屏、模糊与多页错位的排查记录
这一章记录我在实际项目中反复遇到过的 5 个问题,每条都按“现象 → 原因 → 解决”的顺序给出,排查时可以照顺序对一遍。
5.1 导出时图表区域一片空白
现象:html2canvas 返回的 canvas 尺寸正常,但图表区域是纯白或透明,PDF 里看不到柱形和文字。
原因:绝大多数情况是导出时机太早,ECharts 的 canvas 还没完成绘制;另一种常见原因是容器被弹窗或隐藏 tab 包裹,初始化时宽度为 0,canvas 虽然存在但没有任何像素。
解决:导出前先调用chart.resize(),如果图表所在的 Tab 刚切换到激活状态,resize会重新计算容器尺寸。接着再等一帧:
chart.resize(); requestAnimationFrame(() => { html2canvas(target, { scale: 2, useCORS: true }).then(...); });如果容器本身是display:none,需要先把它设为display:block,等浏览器完成布局后再执行截图。隐藏容器是 Canvas 白屏的头号原因,建议所有图表初始化前都确认offsetWidth大于 0。
5.2 生成的 PDF 图片模糊,文字发虚
现象:PDF 打开后,图表内的中文文字边缘有严重锯齿,放大 200% 后模糊不清。
原因:html2canvas 的scale默认是浏览器设备像素比,一般就是 1。图片在 PDF 里被拉伸到页宽,相当于把一张 800px 宽的图画在 600pt 宽的页面上,物理像素不够。
解决:把scale提到 2 或 3。如果图表本身的 DOM 宽度是 800px,scale: 2会得到 1600px 宽的 canvas,放进 PDF 后每个 pt 对应约 2.7 像素,清晰度足够。注意scale不是越高越好,超过 3 后生成时间和 PDF 体积会明显上升,收益却很小。
5.3 多图表导出时内容叠加或第二张图被截断
现象:多个图表依次导出后,第二张图覆盖在第一张图上面,或者第二张图只有上半部分,下半部分消失。
原因:offsetY没有正确累加。如果导出逻辑里用的是循环内html2canvas的then,每张图完成后都去addImage,由于截图任务并发完成,写 PDF 的顺序会乱掉,后完成的图可能覆盖先完成的图。
解决:统一用Promise.all收集所有 canvas,再按数组顺序写入 PDF。offsetY在forEach里累加,并加上图表间距。判断语句必须是“当前偏移 + 图片高度 > 页面高度 - 边距”,否则正好卡在页面边界时也会被截断。
5.4 图表使用了远程图片时报跨域错误
现象:ECharts 的series或背景图引用了 CDN 图片,浏览器控制台报canvas has been tainted by cross-origin data,导出按钮点击后没有反应。
原因:html2canvas 把远程图片画进 canvas 时,会污染 canvas。污染后的 canvas 调用toDataURL()会抛出安全错误。
解决:给 html2canvas 设置useCORS: true,同时确认远程图片的响应头里带有Access-Control-Allow-Origin或*。如果图片服务器不受控制,一种变通方案是在渲染 ECharts 前先用fetch把图片转成 base64 再传给图表,这样 html2canvas 拿到的就是同源数据。图标、标记类图片建议直接内嵌 base64,能少一件事。
5.5 导出后的 PDF 页面背景是黑色或透明
现象:PDF 里图表周围的背景是黑色,或者打开后页面底色是透明的,打印出来变成深色。
原因:html2canvas 默认backgroundColor是透明。如果页面或容器的底色是深色,生成的 canvas 背景会保留深色;如果代码里没给容器背景,canvas 就是透明,而多数 PDF 阅读器把透明区域渲染成黑色。
解决:在 html2canvas 配置里显式写backgroundColor: '#ffffff',或者给目标 DOM 加一个background: #fff的样式。两种方式任选其一,我倾向在配置里写,因为这样不污染页面样式。
排查顺序建议:先打开浏览器控制台看有没有跨域报错,再看导出的 canvas 宽度和高度是否为 0,最后看 PDF 生成的坐标参数。80% 的问题都集中在两个点:截图时机太早和 scale 设置过低。
6. 进阶:封装一个可复用的导出函数,处理多图表、水印和分页
如果项目里到处都要用导出功能,把逻辑封装成一个函数会省掉很多重复代码。下面是我最近在某个跨平台系统里用的一个版本,入口参数只有四个:选择器、文件名、清晰度、水印文字。
async function exportDashboardToPdf({ selector = '.chart-panel', fileName = 'dashboard.pdf', scale = 2, watermark = '' } = {}) { const containers = Array.from(document.querySelectorAll(selector)); if (containers.length === 0) { throw new Error('没有匹配到任何图表容器'); } const canvases = await Promise.all( containers.map(el => html2canvas(el, { scale, useCORS: true, backgroundColor: '#ffffff' })) ); const { jsPDF } = window.jspdf; const pdf = new jsPDF('p', 'pt', 'a4'); const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 24; let offsetY = margin; canvases.forEach(canvas => { const imgWidth = pageWidth - margin * 2; const imgHeight = (canvas.height * imgWidth) / canvas.width; if (offsetY + imgHeight > pageHeight - margin) { pdf.addPage(); offsetY = margin; } pdf.addImage(canvas.toDataURL('image/jpeg', 0.92), 'JPEG', margin, offsetY, imgWidth, imgHeight); offsetY += imgHeight + 16; }); if (watermark) { pdf.setGState(pdf.GState({ opacity: 0.12 })); pdf.setFontSize(72); pdf.setTextColor(120, 120, 120); pdf.text(watermark, pageWidth / 2, pageHeight / 2, { align: 'center', angle: 30 }); } pdf.save(fileName); }使用时的调用方式很简单:
document.getElementById('exportBtn').addEventListener('click', () => { exportDashboardToPdf({ selector: '.chart-panel', fileName: '运营报表.pdf', scale: 3, watermark: '内部预览' }); });这个函数把前面所有关键点收了进来:Promise.all保证多图顺序,offsetY控制分页,scale暴露成参数后不用再改源码调清晰度,水印用GState设置透明度避免遮挡数据。
最后说一个我自己的教训。第一次做这类导出时,我把所有图表都放在一个弹窗里,用户点击导出时弹窗刚打开,ECharts 还在初始化,结果是第一张图永远白屏。后来我在html2canvas前强制判断图表容器offsetHeight > 0,不满足就拦截导出并提示重试。从那以后我每次导出前都会走一遍这个检查,再顺手调一下chart.resize(),再也没有交付过白底 PDF。这个习惯看起来多写了三行代码,但在用户那少挨不少骂。希望帮到你。
本文还有配套的精品资源,点击获取