上个月一个同事跑来找我说这个月在系统里导出了六十多份带签名的合同全靠人工一页一页折腾打印。他想让我看看能不能做一个“一键导出全部合同为PDF”的功能。听着简单真动手才发现HTML转PDF这个需求看着容易做好不容易。合同里有大量动态表格和图文段落导出来的PDF经常在表格中间断掉、页码乱跳几十份合并在一个文件里的时候顺序还容易乱。后来我在Vue项目里用html2canvas jsPDF整套方案把这套需求收了口核心就两个事智能分页和批量合并。这套方案纯前端就能跑不需要后端介入非常适合报表导出、合同签署存档、工单打印这类场景。这篇博文我把整个思路、算法、Vue封装代码和踩过的坑都整理出来给正在做类似功能或者准备入坑的同学一个参考。1. 先想清楚需求导出场景的三个硬约束1.1 这类导出场景的特征需要做HTML转PDF的页面大概率不是那种纯文本的文章页而是带着复杂排版的内容表格、卡片、图表、签名图片、标题段落混着来。比如我做的合同导出每个合同包含客户信息表、产品明细表、价格说明、签署区域。这些内容有一个共同特点——每个“块”都是独立的表格不能从中间劈开签名区域要保持完整图片不能被腰斩。这种特征决定了我们不能拿整个页面当成一张长图直接输出必须要做“块级别”的分页控制。换句话说智能分页的粒度不是“行”而是“语义块”。1.2 三个硬约束接需求的时候我先列了三条硬约束后面所有技术选型都围绕这三条来。第一条分页断点必须可控。合同里的表格不能被切成两半图片和标题要能绑定在一起。如果某个块放不下当前页的剩余空间整块要挪到下一页。第二条批量合并后要像一份完整文档。六十多份合同导出来应该是一个PDF页码连续每份合同之间在视觉上有一个清晰的起始位置外人看起来就像一本书。第三条纯前端实现不能依赖后端服务。项目组不想为了导出功能额外搭一个Node服务也不想把页面发给后端渲染那样来回沟通成本高而且合同数据本身比较敏感不希望经过太多中间层。1.3 方案选型为什么是html2canvas jsPDF市面上做前端PDF的方案我大概列了一下各有各的适用场景。方案优点缺点适用场景浏览器打印 window.print无需额外依赖样式用 media print 控制分页行为由浏览器决定无法精确控制断点用户必须手动选择保存路径批量合并基本做不到不需要批量、不需要精确分页的简单页面html2pdf.js封装库上手快API简单封装太厚分页和合并的灵活性被限制出了问题难以排查对复杂表格支持一般快速原型、简单报告html2canvas jsPDF可控性强分页算法自由定制可以批量写入同一个PDF需要自己写分页逻辑canvas截图不是矢量文本检索和复制会受限复杂动态排版、批量导出、对清晰度有要求的场景服务端 puppeteer 渲染PDF质量高支持真实文本元数据需要Node服务部署和运维成本上升高并发、强一致性要求的专业场景我选了html2canvas jsPDF组合核心原因是“控制权”。分页算法我可以自己写批量合并我可以用同一个jsPDF实例连续写入页面这些都是封装库很难给到我的灵活度。代价就是得多写一些代码但这对一个要求批量合并和智能分页的项目来说是值得的。2. 智能分页把DOM切成一张张页面2.1 先破除一个误区不能整页截图后再裁剪刚接触这个需求的人最常问的一句话是html2canvas不是能把整个容器截图吗那我截图之后按A4高度切成几段再放进PDF不就行了表面看逻辑通顺实际上完全不行。CSS布局是流式的页面上的元素在垂直方向连续排列如果你在canvas上按固定高度硬切切出来的每一段都会在某个文字的行中间、表格的行中间或者图片的中间断掉。生成的PDF看起来就像把一张长图撕开了一样每一页的内容都是残缺的用户体验非常差。智能分页正确的思路是在DOM阶段就把内容按“块”切分好保证每个块在视觉上是完整的切分点永远落在块与块之间而不是块内部。2.2 A4页面容量与scale的计算关系要分页先得知道一页能装多少内容。A4纸的物理尺寸是210mm × 297mmjsPDF默认使用pt作为单位1mm约等于2.8346pt所以A4横竖都换算一下pdfWidth 595.28ptpdfHeight 841.89pt。这是PDF页面自身的尺寸。但html2canvas截图用的是CSS像素。这里有个关键点如果你的页面渲染宽度是800px实际PDF页面宽度是595.28pt那么最终图片写入PDF时会发生一次缩放。缩放比例大概是 800 / 595.28 ≈ 1.34字体实际显示的大小也会跟着变化。所以为了让最终PDF文字大小和预览感觉一致渲染容器的宽度最好和PDF页面宽度保持一定的比例关系。我习惯的做法是渲染容器固定宽度为 794px这正好是A4在96dpi下的CSS像素宽度210 / 25.4 × 96 ≈ 794。上下页边距各留40px一页可用的内容高度就是 1123 - 40 × 2 1043px左右。然后html2canvas的scale参数设为2。这样canvas的实际像素宽度是 794 × 2 1588px高度是 1123 × 2 2246px写入PDF时addImage的尺寸固定为595.28 × 841.89pt整体清晰度在屏幕上和打印出来都足够。2.3 分页断点判定算法核心算法其实不复杂一句话总结就是把容器里的子元素按顺序累计高度超过单页可用高度时在当前块之前切开。但这里有一个细节容易被忽略——不能直接用每个元素的offsetHeight累加。因为CSS里相邻元素上下margin会塌陷直接相加会算多。更稳妥的方式是用元素相对容器顶部的偏移量来计算。我用的方法是先记录每个块相对容器顶部的top和bottom然后通过bottom相减得到真实占用的布局高度。下面这个函数可以计算出分页断点位置function computePageBreaks(container, pageContentHeight) { const blocks Array.from(container.children); const containerRect container.getBoundingClientRect(); const positions blocks.map((el) { const rect el.getBoundingClientRect(); return { top: rect.top - containerRect.top, bottom: rect.bottom - containerRect.top, }; }); const breaks []; let pageStart 0; for (let i 0; i blocks.length; i) { const pageHeight positions[i].bottom - positions[pageStart].top; if (pageHeight pageContentHeight i pageStart) { breaks.push(i); pageStart i; } } return breaks; }这里break数组存的是“从哪个索引开始新的一页”。比如返回 [3, 7]表示索引0到2是一页3到6是一页7到最后是一页。还有一个特殊情况要处理如果一个块本身高度就超过了一页这个算法会直接跳过它结果那一块里依然会被腰斩。这种块的典型代表就是超长表格。处理方式我在2.4节单独讲。2.4 表格跨页表头重复与行级拆分表格是所有分页方案里最头疼的没有之一。一个十几行的产品明细表如果刚好跨在两页之间从中间斩断整份合同的可读性直接崩掉。我的处理策略分两层。第一层优先保整能在断点之前完整放下就整张表格放到同一页实在放不下再把表格内部的tr进行行级拆分。行级拆分的思路是拿到表格后确定一页能放多少行。通过计算表头高度和平均行高估算可容纳的行数然后按最大行数把表格克隆成多个表格每个表格保留同一个theadtbody里放对应的tr片段。这里有一个很重要的注意点如果按“最大行数”拆分可能会出现第二页就剩两三行的情况很不美观。所以更智能一点的逻辑是先尝试不拆表把整表塞到下一页如果下一页也放不下再进行行级拆分。判断条件就是看整表高度加上当前页已用高度是否超过页面容量。深克隆表格时用cloneNode(true)可以保住样式但有一个坑原始表格里的input、canvas这类动态内容cloneNode复制不了实际渲染结果。如果表格里有canvas柱状图这类内容需要在克隆前先把canvas转成dataURL赋给一个img元素再克隆。这个坑我在第五章还会展开讲。2.5 插入分页标记的实战写法算出断点之后下一个问题是怎么把断点生效到实际的截图流程里。我的做法是不直接对原始大容器整体截图而是根据断点索引把DOM节点分组成多个独立的“临时页容器”再对每个临时页容器分别调用html2canvas截图。这样做的原因在于html2canvas截图时如果你传入一个包含了超出视口的元素它也能截但分页标记对它来说毫无意义——它不认page-break-before这类CSS属性。所以必须在逻辑层面就把节点分组。下面这个函数把容器子节点按断点索引分组并生成新的页面容器function splitContainerToPages(container, breakIndexes) { const blocks Array.from(container.children); const pages []; let start 0; const bounds [...breakIndexes, blocks.length]; for (const end of bounds) { const pageWrapper document.createElement(div); pageWrapper.style.width getComputedStyle(container).width; pageWrapper.style.position relative; pageWrapper.style.overflow visible; pageWrapper.style.background #ffffff; blocks.slice(start, end).forEach((node) { pageWrapper.appendChild(node); }); pages.push(pageWrapper); start end; } return pages; }这里有一个经验之谈给pageWrapper设置position: relative和overflow: visible非常重要。如果不设置某些环境下html2canvas计算子元素位置时会出现偏移。另外因为我这里是直接把原始节点移动到了新容器里所以传进来的container本身会被掏空所以生成页面容器前建议先对原始DOM做一次cloneNode(true)深克隆在副本上操作避免影响用户正在看的预览页。3. 批量合并串行渲染与单PDF写入3.1 批量合并的完整流水线批量合并比单页导出多了一个维度数据列表。整个执行流程是一套流水线遍历要导出的数据数组比如六十份合同。每一项数据通过模板渲染成HTML字符串挂到隐藏容器里。对这份HTML做智能分页得到多页DOM容器。逐页调用html2canvas截图把canvas转成图片数据。将图片写入同一个jsPDF实例每写完一页就addPage。处理完所有项后pdf.save触发下载。这里最关键的一点是从头到尾只用了一个jsPDF实例。这样所有合同才会被合并到同一个PDF文件里页码是连续编排的。核心骨架是这样async function exportItemsToPdf(items, renderTemplate, options) { const pdf new jsPDF({ orientation: portrait, unit: pt, format: a4 }); const { pageWidth 794, pageContentHeight 1043, onProgress } options; const holder createHiddenContainer(pageWidth); for (let i 0; i items.length; i) { holder.innerHTML renderTemplate(items[i]); await nextFrame(); const pageContainers splitToPages(holder, pageContentHeight); for (let j 0; j pageContainers.length; j) { if (i 0 || j 0) { pdf.addPage(); } const canvas await html2canvas(pageContainers[j], { scale: 2, useCORS: true, backgroundColor: #ffffff, logging: false, }); const imgData canvas.toDataURL(image/jpeg, 0.82); pdf.addImage(imgData, JPEG, 0, 0, 595.28, 841.89, undefined, FAST); } holder.innerHTML ; onProgress onProgress(i 1, items.length); } pdf.save(options.fileName || export.pdf); }这段代码结构上已经能跑但实际项目中还有几个问题要解决并发控制、内存复用、进度感知。下面分别展开。3.2 并发控制与内存释放你可能第一反应是六十份合同用Promise.all并发处理不更快吗我的实际经验是千万别。html2canvas本身是非常消耗CPU和内存的它要遍历DOM、计算布局、栅格化每个元素。如果同时开五六个截图任务主线程立刻卡死浏览器标签页直接无响应用户会以为页面崩了。而且截出来的canvas转成图片base64是一串很长的字符串。一份A4页面的JPEG压缩图大概几百KB但是canvas对象本身在内存里占用的位图空间是宽度×高度×4字节。一张1588×2246的canvas光像素数据就有14MB左右。如果六十份合同同时并发几百张canvas同时存在内存里内存会直接爆掉。正确的做法是串行处理最多并发2个。串行虽然慢一点但每一轮处理完就把holder.innerHTML清空canvas对象在循环迭代后也会因为作用域释放被垃圾回收。用户界面通过进度回调保持在“自动导出中”的提示状态体验完全可接受。如果你确实想提升一点速度可以用一个受限并发池但上限一定不要超过2。比如我写的一个通用控制函数async function runTaskPool(tasks, concurrency) { const results []; let index 0; async function worker() { while (index tasks.length) { const current index; results[current] await tasks[current](); } } const workers Array.from({ length: concurrency }, () worker()); await Promise.all(workers); return results; }这个函数可以用但在我这个场景里收益不大因为瓶颈不仅仅是任务数量还有单个html2canvas扫描DOM的开销。老老实实串行最稳。3.3 隐藏渲染容器的正确姿势用隐藏容器要注意一个常见的误区不能直接给容器设置display: none。因为html2canvas需要读取元素的真实布局尺寸display: none的元素没有任何宽高解析出来是零导出内容全丢。我一般用fixed定位把它移出可视区域再设置z-index为负值。这样容器在布局上真实存在只是用户看不见。function createHiddenContainer(width) { const holder document.createElement(div); holder.style.cssText [ position: fixed, left: -9999px, top: 0, width: ${width}px, background: #ffffff, z-index: -1, pointer-events: none, ].join(;); document.body.appendChild(holder); return holder; }还有一个细节容器要设置明确的背景色。如果不设html2canvas会把透明背景渲染成黑色导出的PDF每个页面都是黑底。我习惯在容器CSS里写background: #ffffff同时在html2canvas配置里也传backgroundColor: #ffffff双保险。另外隐藏容器里的内容宽度必须固定不要在导出过程中因为窗口resize而变化。我前几天就遇到过用户导出到一半后台弹了个通知导致页面布局微调结果PDF里一半页面是正常宽度一半页面错位。所以我加了一个保护措施导出期间临时给隐藏容器加上!important宽度并在导出结束后移除。3.4 页码与进度回调批量合并出来后用户首先关心两件事导到第几份了PDF里怎么知道当前是第几页进度回调很容易在每次处理完一项后调用onProgress即可。我在实际项目里配合一个全局进度条组件用户能直观看到处理进度心里踏实很多。页码要麻烦一点。因为jsPDF本身不提供“自动给末页添加页码”的能力常规做法是先把所有页面都生成好然后遍历PDF的每一页用pdf.setPage和pdf.text把页码画到页面右上角或右下角。这个操作在内存里做不影响导出文件。伪代码大概是const totalPages pdf.getNumberOfPages(); for (let p 1; p totalPages; p) { pdf.setPage(p); const pageText ${p} / ${totalPages}; pdf.setFontSize(9); pdf.setTextColor(120, 120, 120); pdf.text(pageText, 595.28 - 40, 841.89 - 20, { align: right }); }这个方法能正常画页码。但注意如果你用了smart分页并且页码要放在每一页的页脚区域最好在切割页面容器的时候就在底部预留出20pt的空白避免页码压到正文内容。4. Vue里的封装实现与调用示例4.1 封装一个组合式函数在实际Vue项目里我推荐把整套逻辑封装成一个组合式函数或者独立的导出工具模块不要在组件里堆大段逻辑。下面是一个Vue3组合式函数的示例核心是接收一个getItemSections函数该函数负责把数据渲染成DOM片段这样导出逻辑和页面模板就解耦了。import { nextTick } from vue; import html2canvas from html2canvas; import { jsPDF } from jspdf; export function useHtmlToPdf() { async function exportPdf(config) { const { items, getItemHtml, fileName export.pdf, onProgress, } config; const pageWidth 794; const pageHeight 1123; const pageContentHeight pageHeight - 80; const pdf new jsPDF({ orientation: portrait, unit: pt, format: a4 }); const holder document.createElement(div); holder.style.cssText position:fixed;left:-9999px;top:0;width:${pageWidth}px;background:#ffffff;z-index:-1;pointer-events:none;; document.body.appendChild(holder); try { for (let i 0; i items.length; i) { holder.innerHTML getItemHtml(items[i]); await nextTick(); await nextFrame(); const pageBreaks computePageBreaks(holder, pageContentHeight); const pageContainers splitContainerToPages(holder, pageBreaks); for (let j 0; j pageContainers.length; j) { if (i 0 || j 0) { pdf.addPage(); } const canvas await html2canvas(pageContainers[j], { scale: 2, useCORS: true, backgroundColor: #ffffff, logging: false, }); const imgData canvas.toDataURL(image/jpeg, 0.82); pdf.addImage(imgData, JPEG, 0, 0, 595.28, 841.89, undefined, FAST); } holder.innerHTML ; onProgress onProgress(i 1, items.length); } const totalPages pdf.getNumberOfPages(); for (let p 1; p totalPages; p) { pdf.setPage(p); pdf.setFontSize(9); pdf.setTextColor(130, 130, 130); pdf.text(${p} / ${totalPages}, 555, 825, { align: right }); } pdf.save(fileName); } finally { document.body.removeChild(holder); } } return { exportPdf }; }代码里有两个小细节。第一个是await nextFrame这个函数是确保浏览器完成当前帧的绘制避免DOM刚更新就被html2canvas扫描导致内容缺失。实现如下function nextFrame() { return new Promise((resolve) { requestAnimationFrame(() { requestAnimationFrame(() resolve()); }); }); }第二个是computePageBreaks和splitContainerToPages这两个函数直接复用第二章的实现。4.2 组件内调用示例在组件里调用非常直接。假设界面上有个列表每一行展示了一份合同的基本信息点击“批量导出”按钮就把所有选中的合同ID传进来。template div div v-foritem in selectedItems :keyitem.id {{ item.name }} - {{ item.status }} /div button :disabledexporting clickhandleBatchExport {{ exporting ? 导出中... : 批量导出合同PDF }} /button /div /template script setup import { ref } from vue; import { useHtmlToPdf } from /composables/useHtmlToPdf; const selectedItems ref([/* ... */]); const exporting ref(false); const { exportPdf } useHtmlToPdf(); function getContractHtml(item) { // 这里根据item拼合同模板HTML return div stylepadding: 40px; h2合同编号${item.no}/h2 table.../table /div ; } async function handleBatchExport() { exporting.value true; try { await exportPdf({ items: selectedItems.value, getItemHtml: getContractHtml, fileName: 合同批量导出_${Date.now()}.pdf, onProgress: (done, total) { console.log(已处理 ${done}/${total}); }, }); } finally { exporting.value false; } } /script这种用法有一个好处导出模板完全由getItemHtml函数控制测试起来很干净页面其他交互不会干扰导出过程。但如果你希望导出的内容和页面上预览的内容完全一致也可以通过id找到真实渲染的DOM节点作为模板来源取决于你的业务场景。4.3 核心参数配置说明html2canvas的配置项里下面几个参数是我每次必调的我把它们的作用列一下。参数建议值说明scale2截图分辨率倍率太低文字会发虚太高内存爆炸2是一个平衡点useCORStrue允许跨域图片加载到canvas没有它带图片的页面会导出失败backgroundColor#ffffff防止透明背景导出成黑色loggingfalse关闭控制台调试日志避免生产环境刷屏windowWidth页面实际宽度避免滚动条宽度影响布局计算scale不是越大越好。我试过scale: 3生成的图片确实更清晰但单张canvas内存接近30MB大批量导出时卡顿明显。如果只是屏幕查看和打印scale: 2完全够字迹锐利文件体积也适中。5. 高频问题排查与避坑速查做完这个功能我也没少踩坑下面几个问题基本覆盖了大部分人会遇到的情况整理成表格方便查。5.1 导出的PDF文字模糊原因基本是scale设置过低或者渲染容器宽度和PDF页面宽度比例失调。比如容器宽度设为1200px而PDF页面宽度固定595.28pt图片写入PDF时被缩小了一半多文字自然发虚。解决思路固定容器宽度为794pxscale设为2addImage的第三个和第四个参数直接写595.28和841.89。这样CSS像素到PDF点的比例是恒定的清晰度可控。5.2 图片白屏或canvas被污染html2canvas在截图时有一个安全限制如果页面里有跨域图片canvas会被污染导出时报错或输出空白。解决分几步图片所在服务器要返回CORS响应头。图片标签上加crossOriginanonymous。html2canvas配置中开启useCORS: true。如果图片服务器不在你掌控下最省事的办法是在导出前把图片主动转成base64。先拿原图画到一个临时canvas上再toDataURL转成dataURL替换原img的src。缺点是要等图片加载完好在图片通常不会太多影响有限。5.3 多了一项后多出一张空白页我遇到过一次导出的PDF中间出现一张完全空白的页面。排查下来是分页计算时某个块的高度刚好略小于剩余空间强行被放进了当前页但是这个块的bottom因为margin塌陷浮动了几个像素导致实际绘制时越界html2canvas生成了一个空页面。解决办法在computePageBreaks判断条件里加一个容错值比如pageHeight pageContentHeight - 2才切换新页。别看这个小小的2像素修正它能把很多莫名其妙的空白页问题拦在门外。5.4 动态内容导出时丢内容或错位Vue里数据更新后DOM不一定立刻渲染完成html2canvas扫描时可能抓到旧状态。解决就是等两次requestAnimationFrame再截图同时在true业务里先确认数据状态都稳定了再触发导出。如果是图片还要等所有img元素的decode事件完成。另外如果页面本身有CSS动画或者过渡效果导出前最好先暂停。我在项目里遇到过一个表格行高亮的动画截图时有一行恰好处于动画中间态导出的PDF里那一行颜色深浅不一。5.5 批量导出内存溢出症状是导出几十份后页面越来越卡最后浏览器标签页崩溃。根源就是canvas对象没有被及时回收。除了串行处理还要注意每一轮处理完后显式把canvas置null并清空隐藏容器里的DOM。再一个隐藏点html2canvas在每次调用时都会在其内部生成一个iframe用来克隆文档大量调用会产生内存碎片。串行方式的碎片回收压力远小于并发这也是我坚持串行的另一个原因。6. 这套方案的边界与后续演进6.1 什么时候应该换服务端方案html2canvas jsPDF这套方案做轻量级导出很顺手但它不是万能药。如果遇到下面几种情况我会建议项目组引入服务端渲染需要导出的文档带有大量真实文本数据用户要求PDF可以搜索、复制、选中。canvas截图生成的PDF本质是图片文字不可选。导出量非常大比如一次导出几百份甚至上千份合同。纯前端串行处理会很慢浏览器内存也可能撑不住。需要精细控制PDF内部结构比如加书签、设置文档属性。jsPDF对这些的支持比较弱。服务端用puppeteer调用无头浏览器渲染页面能拿到真正的带文本层的PDF输出质量高得多。代价是需要多维护一套服务。所以我的习惯是数据量小、纯前端优先数据量大、追求质量直接切服务端。6.2 还能继续优化的方向如果你现在就要把这套方案落到项目里有几个方向可以继续做。第一导出模板可配置化。把getItemHtml改为从远程拉取模板字符串或者渲染完预览后回传模板能覆盖更多业务场景。第二图片压缩策略。如果PDF文件体积过大可以在canvas生成后先用canvas.toDataURL(image/webp)测试一下webp在相同质量下体积更小。但要注意jsPDF对webp的支持在不同浏览器不一致稳妥起见还是JPEG。第三导出任务队列化。把导出过程放进一个有延时的任务队列配合进度条让用户感知到任务在推进而不是干等。最后说一个我的个人体会做前端PDF导出最怕的不是技术实现难而是需求边界没想清楚。你在开始写代码之前一定要跟需求方确认清楚分页断点的期望、批量合并的粒度、是否要求可复制文本。这三个问题定了技术方案基本就定了。我在这个项目里之所以没有一上来就选服务端方案就是因为需求量级和精度要求都在前端可控范围内。如果再来一次我可能还是会先尝试一条把纯前端方案的极限摸清楚再决定要不要上重武器。