☰
前端PDF导出实战:jsPDF中文乱码、分页与性能优化全解析
2026/10/18 16:20:00 网站建设 项目流程

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),文字会从y=10的位置往上画,实际顶部可能在y=6左右。如果想让文字顶部对齐某个位置,需要根据字号调整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?因为字体文件几MB,import会打进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压缩到合适尺寸再塞进去。我一般把图片最长边限制在1200px,JPEG质量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字重并setFont

6. 几个我踩过之后才明白的经验

第一个经验是字体加载一定要做缓存。我最早每次导出都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基本都能扛住。它的学习曲线不算陡,但细节确实多,尤其是中文和分页这两块,不踩几次坑很难写稳。希望这篇记录能帮你少走点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询