1. 从一次导出需求说起:为什么我最终选了 jsPDF
前端做导出功能,绕不开一个经典场景:用户填完表单、看完报表,点一下按钮,希望拿到一份 PDF 文件。我最早接触这类需求时,第一反应是"后端生成不就行了",但实际项目里经常遇到几个尴尬点:后端接口还没排期、导出内容依赖前端实时渲染的 DOM、或者干脆就是个纯前端的小工具站,根本没有服务端。这时候,浏览器端生成 PDF 就成了刚需。
jsPDF 就是在这个背景下进入我视野的。它是一个纯 JavaScript 的 PDF 生成库,能在浏览器里直接把文本、图形、图片拼装成 PDF 文件并触发下载,不需要任何服务端参与。它的定位很明确:轻量、API 直观、上手快。我第一次用它只花了不到半小时就跑通了"导出带标题和表格的 PDF",这也是它能长期占据这类需求首选方案的原因。
但"能跑通"和"能上线"之间隔着一条鸿沟。我在实际项目里踩过的坑包括:中文变成一堆乱码方块、表格跨页后表头消失、图片导出后模糊得像马赛克、移动端 Safari 下载没反应、大文件导出直接把页面卡死。这些问题在官方文档里往往一笔带过,但每一个都足以让功能在验收时被打回。
这篇内容就是把这些年用 jsPDF 的经验系统整理一遍。我会从它的核心工作方式讲起,把中文处理、表格绘制、图片嵌入、分页控制、性能优化这几个最容易出问题的环节逐个拆开,每个环节都给出可直接复用的代码和参数说明。不管你是第一次接触 jsPDF,还是已经用过但被某些问题卡住,应该都能从里面找到能直接抄的答案。
2. jsPDF 的工作方式与几个必须先搞懂的概念
2.1 它到底是怎么"画"出一份 PDF 的
很多人以为 jsPDF 是"把 HTML 转成 PDF",这是个常见误解。jsPDF 本质上是一个绘图指令的封装层:你调用text()、rect()、line()、addImage()这些方法,它把这些调用翻译成 PDF 文件格式里的底层指令,最后拼成一个符合 PDF 规范的二进制文件。它不理解 CSS,不理解 DOM 布局,更不会自动帮你排版。
这个认知非常关键,因为它决定了你后面所有的操作逻辑。你写doc.text('hello', 10, 10),意思是"在坐标 (10,10) 的位置画一段文字",而不是"在某个元素里放一段文字"。坐标原点在页面左上角,单位默认是毫米(mm),x 向右增大,y 向下增大。这一点和 Canvas 的坐标系是一致的,如果你有 Canvas 经验会觉得很亲切。
理解了这个模型,你就能明白为什么 jsPDF 里没有"自动换行""自动分页"这种贴心功能——因为它压根不知道你的内容有多长。所有排版逻辑都得你自己算。这也是它灵活的地方:你完全掌控每一个元素的位置,代价是你得自己当排版引擎。
2.2 单位、页面尺寸与坐标系
jsPDF 构造函数接受一个配置对象,最常用的三个参数是orientation(方向)、unit(单位)、format(纸张格式):
const doc = new jsPDF({ orientation: 'portrait', // 'portrait' 纵向 | 'landscape' 横向 unit: 'mm', // 'mm' | 'pt' | 'cm' | 'in' | 'px' format: 'a4' // 'a4' | 'a3' | 'letter' | [宽, 高] });单位的选择直接影响你后面所有坐标的数值。我个人的习惯是统一用 mm,因为 A4 纸是 210mm × 297mm,这个数字好记,算边距、算内容宽度都很直观。如果你用 pt(点),A4 就是 595 × 842,数字大且不直观,容易算错。
页面尺寸可以通过doc.internal.pageSize.getWidth()和getHeight()拿到,这两个方法在动态计算布局时特别有用。比如你想让一段文字水平居中,就得先知道页面宽度:
const pageWidth = doc.internal.pageSize.getWidth(); const textWidth = doc.getTextWidth('要居中的文字'); doc.text('要居中的文字', (pageWidth - textWidth) / 2, 20);getTextWidth()这个方法值得单独记一下,它返回给定文字在当前字号下的实际宽度,是做居中、右对齐、自动换行的基础工具。没有它,你几乎没法做任何精确排版。
2.3 字体、字号与文本基线
jsPDF 内置了十几种标准字体,比如helvetica、times、courier,通过setFont()切换,setFontSize()设置字号。这里有个容易忽略的点:文本的 y 坐标默认是基线位置,不是文字顶部。也就是说,如果你在 y=20 的位置画文字,文字的主体其实在 y=20 的上方一点点。
这个特性在单行文字时问题不大,但在做多行排版、表格单元格文字垂直居中时就会暴露出来。我的经验是:做表格时,单元格文字的位置要用"单元格顶部 + 字号对应的偏移量"来算,而不是直接用单元格中心。具体偏移量大概是字号(pt)乘以 0.35 左右,这个系数是我实测下来比较接近视觉居中的值。
另外,setFont()的第三个参数可以设置字体样式,比如doc.setFont('helvetica', 'bold')就是加粗。但要注意,标准字体只支持normal、bold、italic、bolditalic这几种,而且不是每种字体都支持全部样式。
3. 中文乱码:jsPDF 最大的坑,没有之一
3.1 为什么中文会变成方块
这是新手用 jsPDF 遇到的第一个、也是最致命的问题。你兴冲冲地写:
doc.text('你好,世界', 10, 10); doc.save('test.pdf');打开一看,全是方块或者空白。原因很简单:jsPDF 内置的标准字体(helvetica 等)根本不包含中文字形。PDF 标准字体是西方那套 14 种基础字体,中文字符在里面的映射是缺失的,所以渲染出来就是乱码。
要显示中文,你必须嵌入一个包含中文字形的字体文件。这就引出了 jsPDF 中文方案的核心:把 TTF 字体转成 jsPDF 能识别的格式,注册进去,然后使用。
3.2 字体转换的完整流程
jsPDF 不能直接用 TTF 文件,需要先转成它自己的格式。官方提供了一个转换工具,流程大致是:
- 准备一个支持中文的 TTF 字体文件(比如思源黑体、文泉驿等开源字体)。
- 用转换脚本把 TTF 转成 jsPDF 需要的
.js文件,里面是 base64 编码的字体数据。 - 在项目里引入这个
.js文件,它会往全局挂一个字体变量。 - 用
doc.addFileToVFS()和doc.addFont()注册字体。 - 用
doc.setFont()切换到该字体。
转换后的代码大概长这样:
// 假设转换后得到 base64 字符串 fontBase64 doc.addFileToVFS('SourceHanSans.ttf', fontBase64); doc.addFont('SourceHanSans.ttf', 'SourceHanSans', 'normal'); doc.setFont('SourceHanSans'); doc.text('你好,世界', 10, 10);这里有几个实操要点必须强调。第一,字体文件体积是个大问题。一个完整的中文字体动辄十几 MB,转成 base64 后还要膨胀约 33%,直接打进前端包会让首屏加载爆炸。我的做法是:如果只需要显示少量固定文字,用字体子集化工具裁剪出用到的字符;如果文字是动态的,那就把字体文件放到 CDN,按需异步加载,加载完成后再执行导出。
第二,注册字体的时机。addFont必须在setFont之前调用,而且如果字体是异步加载的,你得确保加载完成后再创建文档或执行绘制,否则会报"字体未找到"。我一般会封装一个ensureFontLoaded()的 Promise,导出按钮点击后先 await 它。
第三,字体样式要分别注册。如果你需要加粗的中文,光注册normal是不够的,得把粗体字体文件也转换并注册成bold样式,否则setFont('SourceHanSans', 'bold')会回退到默认字体,中文又变方块了。
3.3 一个更省事的替代思路
如果你的中文内容不多,或者对字体没有严格要求,还有一个取巧的办法:把中文内容先用 Canvas 渲染成图片,再把图片贴进 PDF。这样完全绕开了字体嵌入的问题,因为图片里的文字是像素,不依赖 PDF 的字体系统。
具体做法是用一个离屏 Canvas,设置好字体和字号,用fillText把文字画上去,然后canvas.toDataURL()拿到图片,再用doc.addImage()贴进去。这个方案的优点是简单、不挑字体、显示效果和浏览器一致;缺点是文字变成图片后不可选中、不可搜索,而且分辨率固定,放大会糊。所以它适合"标题、印章、固定文案"这类场景,不适合大段正文。
我一般会根据内容性质做取舍:需要复制、搜索的正文用嵌入字体,纯展示的标题或图形化文字用 Canvas 方案。两者混用也完全没问题。
4. 表格绘制:从手动画线到用 autotable
4.1 手动画表格为什么让人崩溃
jsPDF 本身没有表格组件。你要画一个表格,得自己算列宽、画横线竖线、处理文字换行、处理跨页。我最早手写过一次,代码大概长这样:
// 画表头背景 doc.setFillColor(230, 230, 230); doc.rect(margin, y, tableWidth, rowHeight, 'F'); // 画每一列的文字 let x = margin; headers.forEach((h, i) => { doc.text(h, x + 2, y + 6); x += colWidths[i]; }); // 画边框线 doc.line(margin, y, margin + tableWidth, y);单页、几行数据还能忍,一旦数据超过一页,你就得处理"什么时候换页""换页后表头要不要重画""行高不一致怎么办"。这些逻辑写下来轻松几百行,而且极易出 bug。我踩过最坑的一次是:某一行文字太长自动换行后,行高没跟着变,结果文字和下一行重叠了。
4.2 jspdf-autotable 的正确打开方式
后来我全面转向了jspdf-autotable这个插件。它专门解决表格问题,和 jsPDF 配合得非常好。基本用法:
import jsPDF from 'jspdf'; import 'jspdf-autotable'; const doc = new jsPDF(); doc.autoTable({ head: [['姓名', '部门', '入职时间']], body: [ ['张三', '研发部', '2023-01-15'], ['李四', '市场部', '2022-08-20'] ], startY: 30, styles: { font: 'SourceHanSans', fontSize: 10 }, headStyles: { fillColor: [41, 128, 185], textColor: 255 }, theme: 'grid' });startY指定表格从哪个 y 坐标开始,这个参数很重要,因为你的 PDF 上面通常还有标题、logo 之类的内容。styles.font指定字体,这里必须用你注册过的中文字体,否则表格里的中文照样乱码。
autotable 最省心的地方在于它自动处理分页:内容超过一页时自动换页,而且默认会在新页重复表头。这个"重复表头"的行为可以通过showHead: 'everyPage'显式控制,默认就是每页都显示,对长表格非常友好。
4.3 表格样式的精细控制
autotable 的样式配置分几个层级,理解这个层级能省很多事:
| 配置项 | 作用范围 | 常用值示例 |
|---|---|---|
styles | 全局默认 | { fontSize: 10, cellPadding: 3 } |
headStyles | 表头 | { fillColor: [41,128,185], textColor: 255 } |
bodyStyles | 表体 | { valign: 'middle' } |
columnStyles | 指定列 | { 0: { cellWidth: 30 } } |
alternateRowStyles | 隔行 | { fillColor: [245,245,245] } |
列宽控制是另一个高频需求。默认情况下 autotable 会根据内容自动分配列宽,但有时候你希望某一列固定宽度。用columnStyles按列索引设置cellWidth即可。如果所有列都要固定,也可以在head里用对象形式指定:
head: [[ { content: '姓名', styles: { cellWidth: 40 } }, { content: '备注', styles: { cellWidth: 'auto' } } ]]cellWidth: 'auto'表示这一列自动撑满剩余空间,这个技巧在做"固定列 + 弹性列"的布局时特别有用。
还有一个我踩过的坑:表格文字换行。autotable 默认会按列宽自动换行,但如果你的字体是自定义中文字体,换行计算依赖getTextWidth,而某些情况下中文字体的宽度计算会有偏差,导致换行位置不理想。解决办法是给styles加上overflow: 'linebreak',并适当增加cellPadding,给文字留出呼吸空间。
5. 图片、分页与坐标计算:那些算错就白干的地方
5.1 图片嵌入的尺寸与清晰度
doc.addImage()是嵌入图片的方法,签名是addImage(imageData, format, x, y, width, height)。这里最容易出问题的是尺寸计算。
如果你直接写死宽高,图片很可能被拉伸变形。正确做法是先拿到图片的原始宽高比,再按目标宽度算出高度:
const img = new Image(); img.src = dataUrl; img.onload = () => { const ratio = img.height / img.width; const targetWidth = 100; // mm const targetHeight = targetWidth * ratio; doc.addImage(dataUrl, 'PNG', 10, 10, targetWidth, targetHeight); };清晰度方面,PDF 里的图片是矢量容器里的位图,放大后会糊。所以嵌入的图片分辨率要足够高。我的经验是:按最终打印尺寸的 2 倍分辨率准备图片。比如你要在 PDF 里显示 100mm 宽的图,按 300dpi 算,100mm ≈ 3.94 英寸,需要约 1180 像素宽。低于这个值,打印出来就会发虚。
另外,addImage支持 PNG、JPEG 等格式。PNG 支持透明,但体积大;JPEG 体积小但不支持透明。如果图片没有透明需求,优先用 JPEG,能显著减小 PDF 体积。
5.2 分页逻辑:什么时候该换页
jsPDF 不会自动分页,你得自己判断。核心逻辑是:每次绘制前,检查当前 y 坐标加上即将绘制内容的高度,是否超过了页面底部安全线。如果超过,就addPage()换页,并把 y 重置到顶部边距。
const pageHeight = doc.internal.pageSize.getHeight(); const bottomMargin = 20; function checkPageBreak(doc, currentY, neededHeight) { if (currentY + neededHeight > pageHeight - bottomMargin) { doc.addPage(); return 20; // 新页的起始 y } return currentY; }这个函数是我每个项目都会封装的工具。用的时候在每次text或addImage之前调用一下,传入预估高度,返回新的 y 坐标。
预估高度是个技术活。单行文字的高度约等于字号(pt)乘以 0.35(换算成 mm 的话还要再乘 0.3528)。多行文字要先算行数再乘行高。图片就是它的显示高度。表格的话,autotable 会自己处理分页,你只需要在表格开始前用startY指定位置,表格结束后用doc.lastAutoTable.finalY拿到表格底部坐标,继续往下画。
5.3 页眉页脚的统一处理
多页 PDF 通常需要每页都有页眉页脚,比如公司名、页码。jsPDF 没有"页面模板"的概念,所以你得在每次addPage()之后手动重画页眉页脚。我的做法是封装一个drawHeaderFooter(doc, pageNum)函数,在生成第一页和每次换页后调用。
页码可以用doc.internal.getNumberOfPages()拿到总页数,但要注意这个值在文档生成过程中是动态变化的,所以最好在所有内容绘制完成后再统一遍历所有页面补页码:
const totalPages = doc.internal.getNumberOfPages(); for (let i = 1; i <= totalPages; i++) { doc.setPage(i); doc.setFontSize(9); doc.text(`第 ${i} 页 / 共 ${totalPages} 页`, pageWidth - 30, pageHeight - 10); }setPage(i)是切换当前操作页面的方法,这个"先画完内容再补页码"的模式非常实用,避免了页码在生成过程中算不准的问题。
6. 性能、兼容性与几个真实踩坑记录
6.1 大文件导出卡死页面怎么办
jsPDF 是同步执行的,当内容量很大(比如几千行表格、几十张高清图)时,主线程会被长时间占用,页面直接卡死,用户以为程序崩了。我遇到过一次导出 5000 行数据的报表,浏览器直接无响应了十几秒。
解决思路有两个方向。一是分片处理:把大任务拆成小块,用setTimeout或requestAnimationFrame分批执行,每批之间让出主线程,页面就不会完全卡死。二是上 Web Worker:把整个 PDF 生成过程放到 Worker 里,主线程完全不受影响。不过 jsPDF 在 Worker 里用需要处理字体加载和 DOM 依赖,配置稍麻烦,适合对体验要求高的场景。
对于大多数项目,我推荐先用分片方案,成本低、见效快。具体做法是把数据按每 200 行一组,用递归的setTimeout逐组绘制,同时在界面上显示进度条。
6.2 移动端下载的那些坑
桌面浏览器上doc.save('file.pdf')直接触发下载,很省心。但移动端,尤其是某些内置浏览器,save()可能没反应。原因是这些环境对 Blob 下载的支持不一致。
更稳的做法是用doc.output('bloburl')拿到一个 blob URL,然后自己创建一个<a>标签触发下载,或者在新窗口打开:
const blobUrl = doc.output('bloburl'); const link = document.createElement('a'); link.href = blobUrl; link.download = 'export.pdf'; link.click();如果是在某些 App 的内置 WebView 里,下载行为可能被拦截,这时候可以考虑把 blob 转成 base64 传给原生层处理,或者提示用户"在浏览器中打开"。这块没有万能方案,得根据实际运行环境测试。
6.3 几个容易被忽略的细节
第一,save()的文件名不要带特殊字符。中文文件名在某些系统上会乱码,建议用英文或拼音,或者做 URL 编码处理。
第二,注意内存释放。生成的 blob URL 用完要URL.revokeObjectURL()释放,否则大量导出会累积内存。
第三,字体注册是全局的。如果你在同一个页面多次创建 jsPDF 实例,字体只需要注册一次,重复注册会浪费内存甚至报错。我一般把字体注册封装成一个只执行一次的初始化函数。
第四,注意addPage()后的状态。换页后,之前设置的字体、字号、颜色等状态会保留,但当前坐标不会自动重置,你得手动把 y 设回顶部。这个特性容易让人误以为换页后一切归零,结果第二页的内容画到了奇怪的位置。
第五,测试时一定要用真实数据。我见过太多"用两行假数据测试完美,上线后真实数据一多就崩"的案例。空字符串、超长文本、特殊符号、emoji,这些都是要专门测的边界情况。
7. 我封装的一套可复用导出方案
把上面这些经验揉在一起,我通常会封装一个导出工具模块,核心结构大概是:字体加载器(异步、只加载一次)、页面断点检查函数、页眉页脚绘制函数、表格封装(预设好中文字体和常用样式)、图片等比缩放函数。这样每次新项目接入,只需要改改样式配置和数据结构,不用重新踩一遍坑。
有一点我特别想强调:导出功能一定要在真实设备上测。桌面 Chrome 上跑通不代表移动端没问题,开发环境跑通不代表生产环境没问题。我现在的习惯是,导出功能开发完后,至少在桌面 Chrome、移动端 Safari、以及目标 App 的内置浏览器上各测一遍,重点看中文显示、分页位置、下载行为这三项。这三项过了,基本就稳了。
jsPDF 这个库本身不算复杂,难的是它把排版的控制权完全交给了你,而排版恰恰是最琐碎、最容易出错的部分。把中文、表格、分页、图片这四块吃透,剩下的就是耐心和测试了。