资讯动态

ECharts图表生成PDF全指南:前端jsPDF与后端puppeteer双方案

发布时间:2026/10/14 16:21:49 来源:尧图企业网站定制
简介面向网页前端与数据报表场景的轻量级资源包用于解决ECharts图表无法直接导出为PDF的常见需求适用于BI报表、数据大屏、运营周报等需要生成可视化文档的场景有一定前端基础的开发者可直接上手。压缩包共4个文件包含ECharts、html2canvas、jsPDF三个核心JavaScript库与一个示例HTML页面整体仅345KB可直接引入项目使用。三个库分工明确ECharts负责图表渲染html2canvas负责将图表区域截取为Canvas图片jsPDF负责生成PDF文档所有转换均在浏览器本地完成免去服务器端介入提升数据安全性。已有1393人学习下载。示例页面完整演示了从ECharts实例化、配置柱状图、折线图等图表到利用html2canvas将图表区域转换为Canvas图片再通过jsPDF生成PDF并保存的完整链路同时覆盖图片质量调整、PDF页面布局、多页导出及跨域图片Base64处理等扩展注意事项便于开发者快速掌握客户端一键导出图表报告的方法适合需要制作数据报告、管理看板导出的中高级前端开发者。1. 先搞清楚echarts 图表生成 pdf 所需文件到底指哪一套依赖A同学接了个报表需求十几个 ECharts 图表要塞进一份 PDF 里发给客户。他拿着echarts 图表生成 pdf 所需文件这个关键词去搜得到的答案五花八门有人说装 html2canvas有人说用 puppeteer还有人直接甩过来一个几十 MB 的字体压缩包。问题根源在于——ECharts 自己只管画 canvas 或 SVG它不负责产出 PDF所以中间必须有一套桥接文件而这套文件具体是什么完全取决于你走哪条渲染链路。本文把两条最常见的链路一次讲透前端 canvas 导出 jsPDF 组页以及后端无头浏览器渲染 HTML 模板直接打 PDF。每条链路需要哪些文件、目录怎么组织、参数怎么设、坑在哪里我都会按实际落地顺序写清楚。适合正在做报表导出、定时生成 PDF 附件、或者给 BI 系统加下载功能的人读。从工程角度看就一句话先定链路再列文件最后写代码别拿着依赖清单挨个装。2. 两条主流渲染链路前端 canvas 导出与后端无头浏览器文件清单先在这里定2.1 为什么不能指望一个 ECharts 库直接出 PDFECharts 默认用 canvas 渲染图表你拿到的 DOM 是一个canvas元素。canvas 本身不是图片文件更不是 PDF。想把它变成 PDF 的一页你得先把 canvas 内容导出成图片数据再交给 PDF 组装库去排版。这是几乎所有前端方案的底层逻辑。如果你把 renderer 配成svgECharts 输出的就是一个svg元素这时可以拿到 SVG 字符串走矢量转换路线PDF 放大不糊文字还能被选中。但矢量路线需要额外的转换库支持而且 ECharts 生成的 SVG 结构复杂转换库不一定完整支持所有图形特性实际项目里位图路线仍然是绝对主流。所以所需文件这件事的本质是你至少需要三样东西——图表产物canvas 或 SVG 字符串、一个把产物放进 PDF 的组装器、一套保证显示效果的字体文件。网上那些让你装一堆包的清单大多是没分清这些角色把可选项也写成了必选项。理解了这个你看到需要 echarts、html2canvas、jspdf、html2pdf、xlsx这种大杂烩推荐时就知道其中哪些是多余的。2.2 前端链路要哪些文件一张清单说清楚前端链路的核心思路是页面里 ECharts 画好图 → 用getDataURL()拿 base64 图片 → 用 jsPDF 或 pdfmake 把图片排进 PDF。这条链路要用到的文件角色如下表文件/依赖是否必需作用echartsnpm 包或本地 js 文件必需渲染图表jspdf 或 pdfmake二选一必需组装 PDF 文件html2canvas按需只有当你要截整页布局时才需要单图导出不需要中文字体文件.ttf/.otf按需jsPDF 默认字体不支持中文中文报告必需嵌入字体图表数据 JSON按需与导出逻辑解耦方便复跑初学者最容易在这里翻车看到别人的项目里出现了 html2canvas就以为它是必需品于是把整页截图拿去做 PDF结果样式错位、字体丢失、加载变慢。其实只要你的 PDF 内容是图表 少量文字完全可以分开处理——图表用 ECharts 导出图片文字用 jsPDF 直接绘制两个环节都不需要碰 html2canvas。另一个高频误解是装了 echarts 就够了。ECharts 不管 PDFjsPDF 只管 PDF 不管画图两者缺一不可。如果你要生成的是中文报告jsPDF 默认的标准字体里没有中文字形你必须额外准备一个中文字体文件并在初始化时嵌入。这一步最容易遗漏而且遗漏的报错不是运行崩溃而是 PDF 里中文全变成空心方块。2.3 后端链路要哪些文件和你想的不太一样后端链路通常指 Node 环境下用 puppeteer 启动无头浏览器加载一个 HTML 模板页面等图表渲染完成后调用page.pdf()直接打印 PDF。它比前端方案更接近所见即所得因为页面上的 CSS、布局、图表交互状态都会被浏览器完整还原。后端链路需要的文件清单是文件/依赖是否必需作用puppeteer 或 puppeteer-core必需控制无头浏览器渲染和打印HTML 模板文件必需页面结构、图表容器、打印样式echarts.min.js 本地副本必需模板里引用避免依赖外网 CDN数据 JSON 文件必需注入模板驱动图表渲染中文字体包部署环境级必需无头浏览器所在系统需要支持中文渲染headerTemplate/footerTemplate按需页眉页脚片段由 PDF 接口消费这条链路的坑多集中在运行环境上。比如你把服务部署在一台最小化的 Linux 容器里系统没有安装任何中文字体那 ECharts 画出来的中文标签在截图/打印时直接变方块。这个坑不体现在代码报错里只体现在 PDF 成品上排查起来相当费劲。前端链路适合给你的项目加一个下载报告按钮用户点一下PDF 在浏览器里生成并下载。后端链路适合定时任务、批量导出、服务端生成附件这类场景因为 puppeteer 本身要下载浏览器内核放在前端应用里太重了。2.4 选型判断三句话锁定方向判断该走哪条链路我的习惯是问三个问题。第一PDF 是在用户浏览器里生成还是在服务器上生成前者选前端后者选后端。第二PDF 内容是单张图表、几张图各占一页还是会混排大量文本和表格混排内容越复杂backend 方案越省心。第三运行环境能不能装浏览器内核很多公司内网服务器对依赖管控很严装 puppeteer 时要审浏览器二进制这种情况只能走纯前端合成。三个问题问完方向基本就定了。不要因为听说了 puppeteer 更强大就硬上如果你的场景只是前端页面加个导出按钮puppeteer 引入的体积和复杂度完全没必要。反过来如果你要每天凌晨批量生成 50 份带图表和周报数据的 PDF纯前端方案就得开着浏览器等用户操作显然不合理。3. 前端方案落地ECharts 导出图片后用 jsPDF 组出多页 PDF 的最小实操3.1 最小目录结构与依赖安装我用一个最简单的项目结构来演示你可以直接照抄pdf-project/ ├── package.json ├── index.html ├── chart.js └── export.js初始化项目并安装两个依赖就够npm init -y npm install echarts jspdf第一行是初始化package.json第二行安装 ECharts 和 jsPDF。这里刻意不装 html2canvas因为我们的方案是单图导出 jsPDF 排布html2canvas 在链路里没有位置装了反而容易诱导你走整页截图那条老路。目录里chart.js负责在页面里初始化图表并把 canvas 导出成 base64 图片export.js负责接收图片数据并生成 PDF。两个文件各管一段职责清晰。后面前端几个常见问题也大多出在这两个文件的边界上——比如导出时机、参数遗漏、页面单位换算。3.2 页面里初始化图表并把 canvas 转成 base64先看chart.js。这段代码做完三件事初始化图表、设置数据、导出 base64 图片。// chart.js import * as echarts from echarts; // 初始化图表容器需要显式给宽高 const chartDom document.getElementById(chart); const myChart echarts.init(chartDom, null, { renderer: canvas, devicePixelRatio: 2 // 关键参数导出清晰度 }); // 设置图表配置和数据 myChart.setOption({ title: { text: 月度销量趋势 }, xAxis: { type: category, data: [1月, 2月, 3月, 4月] }, yAxis: { type: value }, series: [{ type: line, data: [820, 932, 901, 934, 1290] }] }); // 导出 base64 图片 const imageData myChart.getDataURL({ type: png, // 图片格式可选 jpeg pixelRatio: 2, // 导出像素比 backgroundColor: #ffffff, // 背景色避免透明底 excludeComponents: [toolbox] // 排除工具栏等无关组件 }); console.log(imageData); // data:image/png;base64,...setOption执行完图表就画出来了getDataURL拿到的是当前渲染状态的快照。如果你在动画还没结束时调用导出截到的可能是半程画面所以要么关掉动画要么在动画完成后再导出。pixelRatio是这里最重要的参数——不设的话导出图就是 1 倍像素放在高分辨率屏幕上放大看全是虚的设成 2 或 3 后图片更清晰但体积也成倍涨。backgroundColor建议显式指定。ECharts 默认背景透明如果你后续把图片贴到深色页面或 PDF 模板上透明区域会透出底色观感很差。用白色背景最稳妥PDF 阅读器对白底兼容性也最好。excludeComponents用来去掉图上自带的工具栏、图例开关等交互组件这些在 PDF 里没有意义。3.3 jsPDF 分页组装一张图一页还是按内容流排拿到 base64 后就交给export.js。一个比较常用的做法是按 A4 页面宽度缩放图片然后逐张判断剩余空间空间不够就开新页。// export.js import { jsPDF } from jspdf; const margin 12; // 页面左右边距单位 mm const images [imageData1, imageData2, imageData3]; // 从 chart.js 收集来的数据 const doc new jsPDF({ unit: mm, format: a4 // A4210mm x 297mm }); const pageWidth doc.internal.pageSize.getWidth(); const pageHeight doc.internal.pageSize.getHeight(); const contentWidth pageWidth - margin * 2; let cursorY margin; // 当前绘制位置从顶部留白开始 images.forEach((dataUrl, index) { const imgProps doc.getImageProperties(dataUrl); const imgWidth contentWidth; const imgHeight imgProps.height * (imgWidth / imgProps.width); // 剩余空间不够就换页 if (cursorY imgHeight pageHeight - margin) { doc.addPage(); cursorY margin; } doc.addImage(dataUrl, PNG, margin, cursorY, imgWidth, imgHeight); cursorY imgHeight 8; // 8mm 间距 }); doc.save(report.pdf);这段代码的核心是cursorY游标模型。每张图放完后游标向下移动图片高度 间距下一张图接着放这样多张图会像文档一样自然向下排。当剩余空间放不下下一张图时addPage()开新页并把游标重置回顶部 margin。如果你不维护游标而是写死坐标图表一多必然出现重叠。getImageProperties用来读取图片的原始宽高比这是防止图片拉伸变形的关键。A4 页面宽 210mm减去左右 margin 后得到可用宽度图片按这个宽度等比缩放高度。注意这里全程用 mm 单位不需要也不应该做像素到毫米的换算因为 PDF 的坐标系是固定的物理单位跟屏幕 DPI 没有关系。图片格式方面PNG支持透明但文件大JPEG体积小三到五倍但不支持透明。如果你已经把backgroundColor设成了白色那直接用JPEG更划算。多个图表导出的 base64 字符串会占不少内存建议每张图addImage之后立刻把对应变量置空让垃圾回收尽早接手。3.4 什么时候才需要补上 html2canvas如果你的 PDF 页面上除了图表还有标题、说明文字、卡片背景等元素而且这些元素必须和图表拼成一张完整的图那才需要 html2canvas 出场。它的工作方式是把指定 DOM 节点整个拍成一张 canvas 图片然后同样交给 jsPDF 插入。实际用起来你会发现它比单图导出敏感得多——CSS 渐变、投影、模糊滤镜、跨域图片都可能让截图结果和屏幕显示不一致这是 html2canvas 的老毛病。所以我的建议是能拆就不截。标题文字用 jsPDF 的text()方法直接绘制描述段落用splitTextToSize()自动换行图表单独导出图片三者组合起来质量和可控性都远胜整页截图。如果你发现手头的 PDF 组件里图表位置总差几个像素、背景上莫名多出黑边多半是走了整页截图这条路。折回来用组件拆分方案问题基本消失。4. 后端方案落地puppeteer 渲染 HTML 模板一个脚本产出整份 PDF 文件4.1 模板文件让页面在无头浏览器里自解释后端方案要先准备一个 HTML 模板它既是图表的载体也是 PDF 的版式来源。模板里引用本地的echarts.min.js不要用 CDN 外链——无头浏览器首次加载 CDN 需要联网等待时间不可控内网部署直接失败。!-- template.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 title月度经营报告/title script src./echarts.min.js/script style page { size: A4; margin: 18mm; } body { font-family: Noto Sans CJK SC, Microsoft YaHei, sans-serif; color: #333; } .chart-box { width: 100%; height: 220px; margin-bottom: 16px; page-break-inside: avoid; /* 防止图表被分页截断 */ } /style /head body h1 idreport-title月度经营报告/h1 div classchart-box idchart1/div div classchart-box idchart2/div /body /htmlpage控制打印时的纸张大小和页边距这个和 PPT 里的页面设置是一个概念。.chart-box显式给高度ECharts 初始化时如果容器高度为 0渲染完是空白。page-break-inside: avoid是打印 CSS 里最实用的一条属性它告诉浏览器这个盒子的内容不要跨页拆分否则一个图表可能上半截在第 1 页、下半截在第 2 页非常难看。模板里不要写死数据。图表的setOption数据由外部注入模板只保留容器和 ID这样同一套模板可以复用给不同数据源。4.2 Node 脚本读模板、注入数据、调 page.pdf()服务端脚本做的事情看起来很直白启动浏览器、打开模板页、把数据灌进去、等图表画完、打印 PDF。// export-pdf.js const puppeteer require(puppeteer); const fs require(fs); const path require(path); (async () { const browser await puppeteer.launch({ headless: new, args: [--no-sandbox, --disable-setuid-sandbox] // 容器内运行需要 }); const page await browser.newPage(); // 读取模板 HTML 和注入数据 const htmlPath path.join(__dirname, template.html); const chartData JSON.parse(fs.readFileSync(data.json, utf-8)); // 用 file:// 协议加载本地模板 await page.goto(file:// htmlPath, { waitUntil: networkidle0 // 等所有网络请求结束 }); // 把数据注入页面并驱动 ECharts 渲染 await page.evaluate((data) { const chart1 echarts.init(document.getElementById(chart1)); chart1.setOption({ xAxis: { type: category, data: data.months }, yAxis: { type: value }, series: [{ type: bar, data: data.sales }] }); }, chartData.salesTrend); // 等 canvas 真正出现在 DOM 里再打印 await page.waitForSelector(#chart1 canvas); // 打印 PDF await page.pdf({ path: output/report.pdf, format: A4, printBackground: true, // 不设会丢失 CSS 背景色 displayHeaderFooter: true, // 开启页眉页脚 headerTemplate: div stylefont-size:10px;width:100%;text-align:center;内部资料/div, footerTemplate: div stylefont-size:10px;width:100%;text-align:center;第 span classpageNumber/span 页/div, margin: { top: 20mm, bottom: 20mm, left: 15mm, right: 15mm }, preferCSSPageSize: true // 优先使用 page 里定义的尺寸 }); await browser.close(); })();waitUntil: networkidle0是等页面里所有网络连接都结束避免本地脚本还在执行就抢跑打印。page.evaluate里的逻辑是在浏览器环境里跑的所以可以直接调用页面里已经通过script引入的echarts全局对象。waitForSelector(#chart1 canvas)是第二个保险——ECharts 初始化完成后 DOM 里才会出现canvas元素等它出现就说明渲染逻辑跑完了。两个等待叠加基本能杜绝白屏 PDF。printBackground: true很重要Chrome 打印默认不输出背景色和背景图如果你的模板里有浅色卡片背景、图表背景色不设这个参数打出来就是纯白一片。preferCSSPageSize让浏览器优先遵循你在 CSSpage里的尺寸设置而不是脚本参数里的format。4.3 页眉页脚与页码pdf 接口里被低估的两个参数上面代码里已经开了displayHeaderFooter页眉页脚内容由headerTemplate和footerTemplate两个 HTML 片段定义。Chrome 会往这两个模板里注入两个特殊类名pageNumber当前页码和totalPages总页数你只需要把它们放到span标签里即可。这两个模板有个隐藏限制不能引用外部 CSS 文件所有样式必须内联写在标签的style属性里字体大小、颜色、边距都得写死。有人在这里踩坑写了style标签或 class 引用结果页脚样式完全不生效因为打印模板渲染时根本没有加载页面 CSS。部署到 Linux 服务器时还要注意一点headerTemplate里如果显示中文系统必须装了中文字体否则页眉内部资料四个字会变成方块。这和页面正文的中文渲染是两套独立的依赖容易漏。5. 生成 PDF 时的 5 个高频坑字体乱码、模糊、白屏与内存溢出5.1 中文全变方块或直接消失现象PDF 打开后标题、图例、坐标轴里的中文全部渲染成空心方块英文和数字正常。原因分两种情况。前端方案里jsPDF 自带的标准字体只覆盖拉丁字符中文字形需要额外嵌入字体文件才能显示后端方案里Linux 容器本身可能没装中文字体或者装的字体名和 HTML 模板里font-family写的对不上。两者都是环境问题代码层面不会报任何错误。解决前端方案在初始化 jsPDF 时嵌入字体文件。常见做法是用doc.addFileToVFS与doc.addFont注册你的中文字体文件然后在所有text()调用前setFont。后端方案在 Dockerfile 里安装系统字体包比如fonts-noto-cjk或者把字体文件复制到容器并执行fc-cache刷新。验证方法很简单PDF 生成后先放大 200% 看中文边缘是否清晰平滑方块和正常字体一眼能分出来。5.2 导出的图放大模糊、线是虚的现象PDF 在屏幕上看着正常一旦放大到 150% 以上折线变成虚线、文字边缘出现锯齿。原因ECharts 导出图片时pixelRatio默认是 1也就是一个图表像素对应一个物理像素。在普通笔记本屏幕上看还可以但 PDF 是矢量文档基础阅读器里随意缩放位图一旦放大就露馅。解决在getDataURL里显式传pixelRatio: 2或3。提高导出分辨率PDF 放大后依然清晰。代价是 base64 字符串体积增大内存占用上升。我的习惯是先设 2看体积和清晰度的平衡还嫌糊再上 3。后端 puppeteer 方案则通过page.pdf()的scale参数控制默认 1调成 2 可以得到同等效果。注意scale会影响整页内容不只是图表区域。5.3 puppeteer 模式下图表区白屏PDF 只有页眉页脚现象PDF 生成成功但页面中间一大块空白标题文字在图表区域啥也没有。原因最常见的两个——脚本没等 ECharts 渲染完就调了page.pdf()图表容器当时还是空的或者容器高度为 0比如模板里div没设高度ECharts 初始化完成但画布尺寸异常。解决按 4.2 里的顺序来先把waitUntil设为networkidle0再加waitForSelector(#chart1 canvas)。如果你用 SVG renderer选择器改成#chart1 svg。这个坑的特点是不报错PDF 生成的日志一切正常只有打开文件才看到空白。我一般会在脚本里加一步打印页面里canvas的数量数量不符合预期就直接中止避免产出废文件。5.4 10MB 大 PDF内存暴涨甚至渲染进程崩溃现象图表数量一多Node 进程内存飙升小图表十几张就能涨几百 MB严重时浏览器渲染进程直接崩溃。原因Base64 格式的 PNG 图片每张可能几百 KB 到几 MB多张图片全存内存里再一次性写入 PDF内存占用自然失控。加上有人贪清晰度把pixelRatio拉到 4单张图能到十几 MB。解决三个手段组合使用。第一图片格式改JPEG体积能小一个量级第二每张图addImage之后立即释放变量引用不要囤在数组里第三pixelRatio控制在合理范围不要无脑拉高。puppeteer 方案里还要注意一次浏览器实例只开少量页面导完一张 PDF 关掉这个 page再处理下一张。日志里看到Render process gone或Out of memory基本都是这一步没做好。5.5 图片被裁切或上下页错位现象多图排布时上一张图压到页脚区域下一张图直接叠在上面或者图表上下半截分到了两页。原因addImage的坐标计算没考虑剩余空间。常见错误是每张图都从固定的 y 坐标开始图片一多必然重叠另一类是把图片高度算错超出页面高度却没有触发换页。解决严格按照 3.3 里的游标模型——每次addImage完成后把cursorY加上图片高度和间距下一张图从游标位置开始插入前判断剩余空间不够就addPage()并重置游标。后端方案则用 CSS 的page-break-inside: avoid和容器高度来控制不要在脚本里硬编码每个图表的位置。6. 进阶矢量输出、批量导出与固定模板的三个实用技巧6.1 想彻底告别模糊切到 SVG renderer 再转 PDF如果客户对清晰度要求极高位图方案已经满足不了可以走矢量链路。做法是初始化 ECharts 时把renderer设为svg图表渲染结果就是一个svg元素你可以拿到它的outerHTML。SVG 是矢量格式放进 PDF 后任意缩放都是锐利的文字还能被选中复制。拿到 SVG 字符串之后前端用 svg2pdf.js 配合 jsPDF 把 SVG 绘制到页面上代码大致长这样const svgText document.getElementById(chart-svg).outerHTML; const svgElement new DOMParser().parseFromString(svgText, image/svgxml).documentElement; doc.svg(svgElement, { x: margin, y: cursorY, width: contentWidth });要注意的是ECharts 生成的 SVG 包含大量滤镜、渐变和阴影定义svg2pdf.js 不一定能完整支持实际渲染出来可能有细微偏差比如阴影丢失、渐变变纯色。所以走这条路线前一定要拿真实图表样式做一轮A/B 对比这类转换玄学成分偏高不可盲目全量替换生产逻辑。6.2 几十张图的批量导出循环里做好资源回收批量场景下正确姿势是一个浏览器实例处理多份模板每份模板结束就关闭对应 page让内存及时回落。模板里数据采用占位符替换或者page.evaluate注入不要为每份 PDF 单独写一个 HTML。你还应该在循环里把每次的 PDF 输出路径、处理时间、失败原因写进日志而不是让脚本遇到一张坏图就整体崩掉。我的习惯是 catch 住单页异常记录后继续下一份等全部跑完再统一看失败列表。6.3 固定模板 页码把 PDF 做成正式报告正式对外交付的 PDF 最好统一页眉、页脚和页码。puppeteer 的headerTemplate/footerTemplate是现成方案但两个模板都必须用内联样式且页脚要放页码就插入span classpageNumber/span和span classtotalPages/span。前端 jsPDF 方案则手动在每页底部text()画页码逻辑上自己维护pageNumber变量。我吃过一次大亏为了追求清晰把pixelRatio拉到 4结果 20 页的 PDF 直接撑爆了渲染进程重启后老老实实把像素比降回 2并且每张图生成后立即释放内存。后来养成了习惯——凡是处理文件导出类需求先列文件清单再写代码上线前先导一份小样本看体积和清晰度。希望这些经验帮到你少走弯路先想清楚链路再动手PDF 导出没那么玄乎。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑