前端PDF导出实战:jsPDF中文乱码、分页与性能优化全解析
1. 前端导出PDF这件事为什么我最终选了jsPDF前端做导出功能十个需求里有八个绕不开PDF。最早我试过让后端生成好文件再丢个链接过来但产品经理一句“能不能前端直接导别麻烦后端了”就把锅甩了回来。后来也调研过几款方案有的依赖服务端渲染有的对中文支持稀碎有的打包体积大到离谱。折腾一圈下来jsPDF算是平衡得比较好的一个纯前端运行、API直白、社区资料多配合几个插件还能覆盖表格、图片、分页这些常见场景。这篇文章不是官方文档的搬运而是把我自己在项目里反复踩坑之后沉淀下来的东西整理一遍。jsPDF本质上是一个用JavaScript在浏览器里生成PDF文件的库它不依赖任何服务端环境你给它内容它给你一个可以下载的PDF。适合谁看前端开发、需要做报表导出/合同生成/发票下载这类功能的同学以及被“前端直接导PDF”这个需求折磨过的人。读完你能搞清楚它怎么用、哪些地方容易翻车、中文乱码怎么解、表格和图片怎么塞进去、大文件怎么不卡死页面。我下面会从整体设计思路讲到具体实操再到问题排查尽量把每个“为什么这么做”都说明白。你如果正要做类似功能可以直接抄作业如果已经踩过坑也可以对照看看有没有遗漏的地方。2. 整体设计思路与方案选型拆解2.1 为什么是jsPDF而不是其他方案前端生成PDF的方案其实不少我大致分过几类。一类是html2canvas jsPDF的组合先把DOM截成图片再塞进PDF优点是所见即所得缺点是文字变图片、体积大、不可选中。另一类是pdfmake声明式写法很优雅但中文支持需要自己配字体文件而且自定义绘制能力不如jsPDF灵活。还有一类是直接调浏览器打印接口window.print()让用户自己选“另存为PDF”这招最省事但不可控页眉页脚、分页位置全看浏览器心情。我最终选jsPDF核心原因是它足够底层。你可以精确控制每一行文字的位置、每一张图片的尺寸、每一页的边距。对于发票、合同、报表这种对排版有要求的场景底层控制力比“所见即所得”更重要。另外它的插件生态也够用jspdf-autotable处理表格html2canvas处理复杂DOM基本能覆盖八成需求。注意jsPDF不是万能的。如果你的需求是“把整个网页原封不动导成PDF”那html2canvas截图方案更合适如果你要的是“生成结构化文档”jsPDF才是正解。2.2 核心工作流长什么样jsPDF的使用逻辑其实很简单我把它拆成四步。第一步是创建文档实例你可以指定纸张大小、方向、单位。第二步是写入内容文字用text()图片用addImage()线条用line()表格用插件。第三步是分页管理内容超出一页时要么手动addPage()要么用插件自动分页。第四步是输出save()直接下载output()拿到blob或datauri做后续处理。这个流程里最容易出问题的是第二步和第三步。文字写入涉及字体编码中文不处理就是乱码分页涉及坐标计算算错了内容就跑到页面外面去了。我后面会重点讲这两块。2.3 单位与坐标系统别被默认值坑了jsPDF默认单位是mm默认纸张是a4默认方向是portrait。这三个默认值看起来人畜无害但实际用起来很容易出问题。比如你从设计稿拿到的尺寸是像素直接填进去就会偏得离谱。我的习惯是统一用pt或者mm并且在创建实例时就显式声明避免后面换算来换算去。坐标原点在左上角x向右递增y向下递增。这点和Canvas一致但和数学坐标系相反第一次用容易搞混。页面宽高可以通过doc.internal.pageSize.getWidth()和getHeight()拿到我强烈建议不要硬编码210和297因为一旦换纸张尺寸硬编码的地方全要改。// 推荐写法显式声明单位动态获取页面尺寸 const doc new jsPDF({ unit: mm, format: a4, orientation: portrait }); const pageWidth doc.internal.pageSize.getWidth(); const pageHeight doc.internal.pageSize.getHeight();2.4 字体问题是最大的拦路虎jsPDF内置的字体只有标准PDF字体比如Helvetica、Times、Courier这些字体不支持中文。你直接doc.text(你好, 10, 10)出来的要么是乱码要么是空白。解决办法只有一个自己嵌入中文字体。嵌入字体的流程是把ttf文件转成jsPDF能识别的格式然后通过addFileToVFS()和addFont()注册。转换工具有jsPDF-CustomFonts-support或者在线转换服务。字体文件动辄几MB打包进前端会影响首屏加载所以我的做法是按需加载用户点了导出按钮再去请求字体文件。提示字体子集化能大幅减小体积。如果只需要常用汉字可以用工具把字体裁剪成只包含用到的字符体积能从几MB降到几百KB。3. 核心细节解析与实操要点3.1 文字写入的坐标与对齐陷阱doc.text()的签名是text(text, x, y, options)。这里有个新手必踩的坑y坐标是文字基线的位置不是文字顶部的位置。你写text(标题, 10, 10)文字会从y10的位置往上画实际顶部可能在y6左右。如果想让文字顶部对齐某个位置需要根据字号调整y值。对齐方式通过options.align控制可选left、center、right。但要注意center是相对于传入的x坐标居中不是相对于页面居中。想让文字在页面水平居中x要传页面宽度的一半。// 页面水平居中 doc.text(居中标题, pageWidth / 2, 20, { align: center }); // 右对齐到页面右边距 doc.text(右对齐, pageWidth - 10, 20, { align: right });还有一个坑是多行文字。doc.text()默认不换行你传一个长字符串它会一直往右画超出页面也不管。要换行得用doc.splitTextToSize(text, maxWidth)先切分再逐行写入或者用options.maxWidth让它自动换行。但自动换行的行高控制不如手动灵活我一般用splitTextToSize。3.2 分页逻辑手动还是自动内容少的时候一页搞定内容多了就必须分页。jsPDF本身不提供自动分页你得自己判断。我的做法是维护一个currentY变量每写一行就累加行高当currentY pageHeight - margin时调用doc.addPage()并把currentY重置为顶部边距。let currentY 20; const lineHeight 7; const margin 15; function writeLine(text) { if (currentY lineHeight pageHeight - margin) { doc.addPage(); currentY 20; } doc.text(text, margin, currentY); currentY lineHeight; }如果用jspdf-autotable插件它自带分页能力表格内容超出一页会自动开新页并重复表头。但自动分页的触发时机是插件内部算的你如果有自定义内容穿插在表格前后需要手动同步currentY否则会重叠。注意addPage()之后之前设置的字体、字号、颜色都会保留但如果你用了setFillColor之类的状态最好重新设置一遍避免状态错乱。3.3 图片插入的尺寸与比例计算doc.addImage()支持base64、Image元素、Canvas等多种输入。关键参数是x, y, width, height。如果你只传width不传height图片会按原始比例缩放但jsPDF不会自动帮你算你得自己算。const imgWidth 100; const imgHeight (img.naturalHeight / img.naturalWidth) * imgWidth; doc.addImage(imgData, JPEG, 10, currentY, imgWidth, imgHeight); currentY imgHeight 5;图片格式方面JPEG体积小但质量有损PNG支持透明但体积大。如果是截图我一般用JPEG质量0.8平衡体积和清晰度。另外图片跨页是个麻烦事jsPDF不会自动把图片切分到两页如果图片高度超过剩余空间要么缩小图片要么手动切图。我的做法是判断剩余空间不够就addPage()再放。3.4 表格插件的参数配置jspdf-autotable是我用得最多的插件。它的核心是doc.autoTable(options)options里可以配表头、表体、样式、列宽、分页行为。doc.autoTable({ head: [[姓名, 部门, 金额]], body: [ [张三, 技术部, 1000], [李四, 市场部, 2000] ], startY: currentY, styles: { font: SourceHanSans, fontSize: 10 }, headStyles: { fillColor: [41, 128, 185] }, margin: { top: 20, bottom: 20 } });这里有几个关键点。startY指定表格起始y坐标不传的话默认从当前光标位置开始。styles.font必须是你注册过的字体名否则中文还是乱码。headStyles.fillColor用RGB数组不是十六进制。分页后表头会自动重复这个行为由showHead: everyPage控制默认就是每页显示。表格列宽如果不指定插件会根据内容自动分配但有时候分配得不合理长文本列被挤得很窄。可以用columnStyles手动指定每列的cellWidth。4. 实操过程与核心环节实现4.1 环境准备与依赖安装我假设你用的是现代前端工程npm管理依赖。核心依赖就两个jspdf和jspdf-autotable。如果要做DOM截图再加html2canvas。npm install jspdf jspdf-autotable html2canvas字体文件我放在public/fonts/目录下运行时用fetch请求转成base64后注册。为什么不直接import因为字体文件几MBimport会打进bundle拖慢首屏。按需fetch虽然多一次网络请求但只在用户点导出时才发生体验更好。4.2 中文字体注册的完整流程第一步准备一个支持中文的ttf文件比如思源黑体、文泉驿微米黑。第二步把ttf转成jsPDF需要的格式。我用的工具是jsPDF-CustomFonts-support转换后会得到一个js文件里面是base64字符串。第三步在代码里注册。import { jsPDF } from jspdf; async function loadFont(doc) { const fontUrl /fonts/SourceHanSans.ttf; const response await fetch(fontUrl); const buffer await response.arrayBuffer(); const base64 arrayBufferToBase64(buffer); doc.addFileToVFS(SourceHanSans.ttf, base64); doc.addFont(SourceHanSans.ttf, SourceHanSans, normal); doc.setFont(SourceHanSans); } function arrayBufferToBase64(buffer) { let binary ; const bytes new Uint8Array(buffer); for (let i 0; i bytes.length; i) { binary String.fromCharCode(bytes[i]); } return btoa(binary); }注册完之后所有doc.text()和autoTable里的styles.font都要指定为SourceHanSans否则还是用默认字体中文照样乱码。提示字体注册是异步的导出逻辑必须等字体加载完再执行。我一般把导出按钮的点击处理写成async函数先await字体加载再走后续流程。4.3 一个完整发票导出示例我拿一个简化版发票场景来演示。需求是顶部公司名和发票号中间一个商品表格底部合计金额和备注。async function exportInvoice(data) { const doc new jsPDF({ unit: mm, format: a4 }); await loadFont(doc); const pageWidth doc.internal.pageSize.getWidth(); const margin 15; let currentY 20; // 标题 doc.setFontSize(18); doc.text(销售发票, pageWidth / 2, currentY, { align: center }); currentY 12; // 发票信息 doc.setFontSize(10); doc.text(发票号${data.invoiceNo}, margin, currentY); doc.text(日期${data.date}, pageWidth - margin, currentY, { align: right }); currentY 10; // 表格 doc.autoTable({ head: [[商品, 数量, 单价, 金额]], body: data.items.map(item [ item.name, item.qty, item.price, item.qty * item.price ]), startY: currentY, styles: { font: SourceHanSans, fontSize: 10 }, headStyles: { fillColor: [52, 73, 94] }, margin: { left: margin, right: margin } }); currentY doc.lastAutoTable.finalY 10; // 合计 doc.setFontSize(12); doc.text(合计${data.total}元, pageWidth - margin, currentY, { align: right }); currentY 15; // 备注 doc.setFontSize(9); doc.setTextColor(128); doc.text(备注本发票仅供演示使用, margin, currentY); doc.save(invoice_${data.invoiceNo}.pdf); }这个例子里有几个细节值得说。doc.lastAutoTable.finalY是表格结束后的y坐标用它来定位后续内容比手动累加靠谱。setTextColor(128)传一个参数是灰度值传三个参数是RGB。save()会直接触发浏览器下载文件名可以动态拼。4.4 大数据量导出的性能处理如果表格有几千行一次性生成PDF会卡死页面。我的处理方式是分片生成。先把数据按每页50行切分每50行调用一次autoTable中间用addPage()分页。这样虽然总耗时差不多但页面不会长时间无响应。另一个优化点是图片压缩。如果PDF里要插很多图片先把图片用Canvas压缩到合适尺寸再塞进去。我一般把图片最长边限制在1200pxJPEG质量0.8这样单张图片能控制在200KB以内。function compressImage(img, maxSize 1200, quality 0.8) { const canvas document.createElement(canvas); let { width, height } img; if (width maxSize || height maxSize) { const ratio Math.min(maxSize / width, maxSize / height); width * ratio; height * ratio; } canvas.width width; canvas.height height; const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, width, height); return canvas.toDataURL(image/jpeg, quality); }注意压缩是有损的如果PDF里有二维码或者条形码压缩后可能扫不出来。这种情况要单独处理二维码图片不要压缩。5. 常见问题与排查技巧实录5.1 中文乱码问题速查中文乱码是jsPDF最高频的问题没有之一。表现是文字显示为空白、方框或者乱码字符。原因永远是没有注册中文字体或者注册了但没设置。排查顺序如下。现象可能原因解决方法文字完全空白字体未注册调用addFont注册字体文字显示方框字体注册了但未setFont调用setFont切换字体部分文字乱码字体子集不包含该字符重新生成完整字体子集表格内乱码autoTable未指定fontstyles.font设为中文字体名加粗失效只注册了normal字重注册bold字重并setFont我踩过最坑的一次是字体注册了、setFont也调了但表格里还是乱码。查了半天发现是autoTable的styles.font没设它默认用helvetica跟全局的setFont不联动。这个点文档里没明说只能靠踩坑记住。5.2 内容溢出与坐标错位内容跑到页面外面通常是坐标算错了。常见原因有三个。一是y坐标累加时忘了加行高导致文字重叠。二是分页判断条件写错比如用了而不是边界情况多开一页。三是单位混用一会儿mm一会儿pt换算错了。我的排查方法是画辅助线。在开发阶段用doc.setDrawColor(255, 0, 0)和doc.line()把页边距画出来肉眼确认内容有没有越界。上线前再把辅助线去掉。// 开发阶段画页边距辅助线 doc.setDrawColor(255, 0, 0); doc.line(margin, 0, margin, pageHeight); doc.line(pageWidth - margin, 0, pageWidth - margin, pageHeight);5.3 文件体积过大的优化PDF体积大一般是两个原因字体文件大、图片多。字体方面前面说了用子集化。图片方面压缩质量、限制尺寸、能用JPEG就不用PNG。还有一个隐藏原因是重复嵌入字体如果你每次导出都重新注册字体jsPDF可能会把字体数据重复写入。解决办法是把字体base64缓存起来全局只注册一次。我实测过一个案例原始PDF 8MB字体子集化后降到1.2MB图片压缩后降到600KB效果非常明显。5.4 浏览器兼容性与内存问题jsPDF在主流浏览器上都能跑但有几个兼容性坑。Safari对btoa处理大字符串会报错需要用分块转换。移动端浏览器内存有限生成大PDF容易崩溃建议限制单次导出的数据量。另外save()在部分安卓WebView里不触发下载需要改用output(blob)再手动创建a标签下载。// 兼容性更好的下载方式 const blob doc.output(blob); const url URL.createObjectURL(blob); const a document.createElement(a); a.href url; a.download export.pdf; a.click(); URL.revokeObjectURL(url);提示URL.revokeObjectURL()要及时调用否则blob占用的内存不会释放连续导出多次会内存泄漏。5.5 常见问题速查表问题排查方向快速解决中文乱码字体注册与设置addFont setFont autoTable指定font内容溢出坐标与分页逻辑画辅助线检查currentY累加文件过大字体与图片字体子集化图片压缩导出卡死数据量分片生成限制单次数据量移动端崩溃内存减少图片分批导出下载无反应浏览器兼容改用blob a标签下载表格错位startY与finalY用lastAutoTable.finalY定位加粗无效字重注册注册bold字重并setFont6. 几个我踩过之后才明白的经验第一个经验是字体加载一定要做缓存。我最早每次导出都fetch字体文件用户连续导出三次就请求三次浪费带宽还慢。后来改成全局缓存base64字符串只fetch一次后续直接用。这个改动让二次导出的耗时从2秒降到200毫秒。第二个经验是分页逻辑要抽成独立函数。我一开始把分页判断散落在各处后来发现改一个地方要同步改五六个地方容易漏。抽成checkPageBreak(requiredHeight)之后所有写入操作前都调一次逻辑统一维护成本大幅降低。第三个经验是导出前先做数据校验。有次用户传了个空数组表格渲染出来是空的PDF打开一片白用户以为导出失败了。后来我在导出前加了校验数据为空就弹提示不生成PDF。这种边界情况文档里不会写但实际项目中一定会遇到。第四个经验是测试要用真实数据。我用mock数据测的时候一切正常上线后用户导出了一万行数据直接卡死。后来我专门造了大数据量的测试用例才发现分片生成的必要性。测试数据一定要覆盖极端情况长文本、空值、特殊字符、超大数据量这些才是bug的高发区。最后分享一个小技巧如果PDF里需要页码可以在所有内容写完后统一遍历页面添加。doc.internal.getNumberOfPages()拿到总页数doc.setPage(i)切换到指定页然后在页脚位置写页码。这样不用在生成过程中维护页码计数逻辑更干净。const totalPages doc.internal.getNumberOfPages(); for (let i 1; i totalPages; i) { doc.setPage(i); doc.setFontSize(9); doc.text(第 ${i} 页 / 共 ${totalPages} 页, pageWidth / 2, pageHeight - 10, { align: center }); }这套东西我前后迭代了三个项目从最简单的文字导出到复杂的发票报表jsPDF基本都能扛住。它的学习曲线不算陡但细节确实多尤其是中文和分页这两块不踩几次坑很难写稳。希望这篇记录能帮你少走点弯路。