后台管理系统里,表格导出 PDF 几乎是每个项目都躲不掉的。之前聊过普通文本块在html2canvas+jsPDF导出时被拦腰截断的通用处理思路,这次专门说表格行截断。跟普通 div 相比,表格最大的问题是:一个 tr 是一个整体,跨页切断时边框、背景色、文字全都对不上,打印出来就像报表被刀砍了一刀,这种输出基本没法交付。
这篇文章我会给出一个可以直接抄走的方案:不按固定像素切割 canvas,而是先测量每一行的高度,把行分组成几个不超过单页可用高度的 chunk,再逐个 chunk 渲染成小 canvas 写入 PDF,同时保留每页表头。代码完整,我也把实测中遇到的列宽抖动、字体测量、合并单元格等坑一并写清楚。
1. 表格行被一刀切断:问题出在切图策略上
1.1 大多数人的做法为什么必然截断
很多同学第一次接入html2canvas+jsPDF时,都会写成这样:
const canvas = await html2canvas(table); const pdf = new jsPDF({ unit: 'pt', format: 'a4' }); const imgHeight = (canvas.height * 210) / canvas.width; pdf.addImage(canvas.toDataURL('image/png'), 'PNG', 0, 0, 210, imgHeight); pdf.save('table.pdf');如果表格只有一页高,这样做没有任何问题。可一旦表格超过一页,jsPDF的 canvas 不会自动分页,要么你把 canvas 按固定页高切成几段,要么用doc.addPage()把整张超长图片平铺到多页上。
无论哪种,切分边界都是由像素坐标决定的,跟 DOM 里的行边界没有任何关系。举个例子,A4 页面高度换算成 96dpi 下的 CSS 像素大约是 1122px,如果你的表格第 15 行起始位置在 1110px 处,那一刀就会切在这个行内部,表格下边框只剩一半,文字只剩半行。
1.2 表格截断比文本截断更难忍受
普通文本块截断,顶多感觉“内容断得不优雅”,但表格截断是结构性的:单元格的背景色被拦腰分成两截,上下两页都出现半个色块;行的下边框在一页底部消失,下一页顶部又莫名冒出一段横线;多行文本的单元格如果被切断,第二行文字直接变成半截字。
还有一个很容易被忽略的问题:表格的视觉连续性依赖“整行完整”。一旦行被切断,阅读者很难判断下页第一行到底是不是上页最后一行的延续,这对对账单、报关单、成绩单这类需要逐行核对的表格来说,几乎是不可接受的。
1.3 换一个目标:让断点跟行边界对齐
想明白这一点之后,思路就清晰了:我不需要去控制 canvas 的像素切分位置,而是应该在导出前就把表格按完整行拆开,保证每一页都是从某个行的开头开始,到某个行的末尾结束。
也就是说,分页的单位从“像素高度”变成“行的集合”。页面可用高度是固定的,表格由一系列离散行组成,我要做的是找出“不超过页面可用高度的最大连续行集合”,把它作为一个渲染单元生成 canvas,再写入 PDF 的单独一页。这就是后面整个方案的核心。
2. 破局思路:把“切整图”改成“算行高,按 chunk 渲染”
2.1 分页点计算的核心思路
先把表格中所有内容行(通常是tbody里的行)取出来,逐个测量行高,然后从头累加。每次累加之前判断“当前累计高度 + 下一行高度”是否超过单页可用高度,如果超过,就把当前行作为新一页的起始行。
用一句话描述就是:尽量让每一页装满行,但绝不把一个行劈成两半。
这里要注意,表头行(thead)不应该参与累加,因为我的方案里每一页都会保留表头,表头算固定开销,不占内容行的可用高度。
2.2 页面可用高度的换算逻辑
这一步是很多实现写错的地方。jsPDF默认单位是 pt,A4 页面是 595.28 x 841.89 pt,但 DOM 里我们量到的是 CSS 像素,不能直接拿 841 去减边距然后跟行高比。
需要先算出一个系数:1 个 CSS 像素在最终 PDF 里对应多少 pt。
因为我会把渲染出来的 chunk canvas 等宽拉伸到 PDF 内容区宽度,所以:
const pxToPt = contentWidthPt / tableWidthPx;contentWidthPt是 A4 宽度刨去左右边距后的 pt 数,tableWidthPx是表格在网页里的实际 CSS 宽度。有了这个系数,页面可用高度的 CSS 像素值就是:
const availableHeightPx = (pageHeightPt - marginTopPt - marginBottomPt) / pxToPt;如果表格宽度刚好接近页面可用宽度,这个系数大约就是 0.75(1 CSS px = 0.75 pt),页高 1122px。但如果表格比较窄,被放大铺满页面宽度时,pxToPt变大,可用高度就会变小;反过来表格很宽被压缩时,可用高度反而变大。这也是为什么不能拍脑袋写死一个 900 或 1000 的原因。
2.3 为什么拒绝“画完整再裁剪”的备选方案
有人可能会问:我先用html2canvas把整个表格渲染成一张超长 canvas,然后拿到每一行的像素边界,从这张大 canvas 里切片行不行?
理论可以,但工程上我自己是不推荐的,原因有三:
第一,大表格生成的 canvas 高度动辄几千甚至上万像素,配合scale: 2渲染,内存占用很容易冲到几百 MB,页面会明显卡顿甚至崩溃。
第二,html2canvas对“屏幕外”的内容默认是不渲染的,如果表格底部在视口之外,即使你设置了windowHeight,切出来的底部区域也可能是一块空白,排查起来非常反复。
第三,从大 canvas 里切子图本质上还是像素操作,虽然切片位置可以对齐行边界,但你要先精确算出每个行在 canvas 里的 y 坐标,中间只要有一个行高测量偏差,整列切片就全错了。
所以我最终采用的做法是:每个 chunk 渲染之前,先把源表格克隆一份,删掉不属于当前 chunk 的行,再把这份“迷你表格”放到页面上渲染。这样既避免了大 canvas 的内存压力,又天然保证 canvas 里只有当前页要展示的那些行。
3. 核心代码:分页点计算与 chunk 渲染的实现
3.1 分页点计算函数
下面这个函数输入表格行数组和单页可用高度,返回一个数组,每一项表示“这一页对应的行索引区间”。
function splitRowsToPages(rows, availableHeightPx) { const pages = []; let start = 0; let acc = 0; rows.forEach((row, index) => { // 用 getBoundingClientRect 而不是 offsetHeight, // 因为浏览器缩放或 transform 场景下它更接近实际渲染高度 const h = row.getBoundingClientRect().height; if (acc + h > availableHeightPx && acc > 0) { pages.push({ start, end: index }); start = index; acc = 0; } acc += h; }); // 最后剩余的行也要放进一页 pages.push({ start, end: rows.length }); return pages; }注意pages里end是排他的,也就是说{ start: 0, end: 10 }表示第 0 到第 9 行,这样遍历时直接用index < end判断即可。
有个边界情况需要单独考虑:如果某一行特别高,超过了一整页的可用高度,acc为 0 时我不会触发换页,这行会单独占一页,但渲染出来的图片高度会超出页面。这种场景说明表格本身不适合当前页面布局,要么把页面横过来,要么缩小字体后重新测量。
3.2 构建当前页表格片段
拿到分页区间后,我要基于源表格克隆一份“迷你表格”。这一步如果直接删行,很容易出现列宽抖动,所以我一般在删除行之前先固定列宽。
function buildChunkTable(sourceTable, range) { const clone = sourceTable.cloneNode(true); // 删除不在当前页范围内的行 const body = clone.querySelector('tbody'); const rows = Array.from(body.querySelectorAll('tr')); rows.forEach((row, index) => { if (index < range.start || index >= range.end) { row.remove(); } }); // 固定列宽,避免 clone 后自动布局把列宽搞乱 const sourceFirstRow = sourceTable.tHead ? sourceTable.tHead.rows[0] : sourceTable.tBodies[0].rows[0]; const widths = Array.from(sourceFirstRow.cells).map( (cell) => cell.getBoundingClientRect().width ); let colgroup = clone.querySelector('colgroup'); if (!colgroup) { colgroup = document.createElement('colgroup'); clone.insertBefore(colgroup, clone.firstChild); } else { colgroup.innerHTML = ''; } widths.forEach((w) => { const col = document.createElement('col'); col.style.width = `${w}px`; colgroup.appendChild(col); }); clone.style.tableLayout = 'fixed'; clone.style.width = `${sourceTable.getBoundingClientRect().width}px`; clone.style.margin = '0'; return clone; }table-layout: fixed配合colgroup能极大减少跨页时列宽不一致的问题。如果源表格没有colgroup,这段代码会主动创建一个;列宽数据来自表头第一行,如果没有表头就取tbody第一行。
3.3 把片段放进页面并让 html2canvas 能渲染到它
直接把这个clone加到body里还不够。如果你把它拖到屏幕外面,html2canvas很容易渲染出空白,所以我一般把它放进一个position: fixed的容器,定位到源表格当前所在位置,同时用一个全屏遮罩把页面盖住,用户不会看到这个临时节点出现。
function mountChunk(table, clone) { const rect = table.getBoundingClientRect(); const wrapper = document.createElement('div'); wrapper.style.cssText = ` position: fixed; left: ${rect.left}px; top: ${rect.top}px; width: ${rect.width}px; background: #fff; margin: 0; padding: 0; z-index: 2147483646; `; wrapper.appendChild(clone); document.body.appendChild(wrapper); return wrapper; }遮罩层我单独加一个比它高一层的元素:
function showLoadingOverlay() { const overlay = document.createElement('div'); overlay.style.cssText = ` position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 16px; z-index: 2147483647; `; overlay.textContent = '正在导出 PDF,请稍候…'; document.body.appendChild(overlay); return overlay; }两道 z-index 是刻意安排的:遮罩在最上面,临时片段在遮罩下面一层。这样用户全程只能看到半透明遮罩,看不到临时表格的闪现,同时html2canvas渲染临时片段时又不会把遮罩画进 canvas。
3.4 主流程:逐 chunk 渲染并写入 PDF
把前面的逻辑串起来,主函数大致长这样:
async function exportTableToPdf(table, options = {}) { const { fileName = 'table.pdf', scale = 2, margin = { top: 60, bottom: 60, left: 40, right: 40 }, showPageNumber = true, } = options; const doc = new jsPDF({ unit: 'pt', format: 'a4' }); const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); const bodyRows = Array.from(table.querySelectorAll('tbody tr')); if (!bodyRows.length) return; const tableWidth = table.getBoundingClientRect().width; const imgWidth = pageWidth - margin.left - margin.right; const pxToPt = imgWidth / tableWidth; const availableHeightPx = (pageHeight - margin.top - margin.bottom) / pxToPt; const pages = splitRowsToPages(bodyRows, availableHeightPx); const overlay = showLoadingOverlay(); try { for (let i = 0; i < pages.length; i++) { const clone = buildChunkTable(table, pages[i]); const wrapper = mountChunk(table, clone); const canvas = await html2canvas(wrapper, { scale, useCORS: true, backgroundColor: '#ffffff', logging: false, }); const imgHeight = (canvas.height * imgWidth) / canvas.width; if (i > 0) { doc.addPage(); } doc.addImage( canvas.toDataURL('image/png'), 'PNG', margin.left, margin.top, imgWidth, imgHeight ); if (showPageNumber) { doc.setFontSize(9); doc.setTextColor(150); doc.text( `${i + 1} / ${pages.length}`, pageWidth / 2, pageHeight - 24, { align: 'center' } ); } wrapper.remove(); // 释放 canvas 内存 canvas.width = 0; canvas.height = 0; } } finally { overlay.remove(); } doc.save(fileName); }调用方式很简单:
const table = document.getElementById('data-table'); await exportTableToPdf(table, { fileName: '订单明细.pdf', scale: 2, });这套代码跑通后,每一页 PDF 里的内容一定是从某个完整行的顶部开始,到完整行的底部结束,不会再出现那种“一行被劈成两半”的截断。
4. 行被截断之外的真实细节坑
4.1 列宽抖动:table-layout 和 colgroup
第一次实现时我踩过最隐蔽的坑就是列宽。源表格在正常布局下,每列宽度由浏览器自动计算,但我 clone 出来删掉部分行后,浏览器会重新执行自动布局。如果某个被删掉的行恰好是列宽参考行,整个表格的列就会变窄或变宽,渲染出来的 chunk 跟上一页对不齐。
解决办法就是我代码里做的两件事:
- 给 clone 设置
table-layout: fixed - 用
colgroup显式设置每一列的宽度
实测中这两步缺一不可。如果没有colgroup,只设fixed,某些浏览器会用 clone 后的第一行来决定列宽,依然可能抖动。
4.2 字体未加载导致的测高错误
行高测量是在 JS 里直接用getBoundingClientRect()拿的,如果表格里的字体还没加载完成,浏览器会用 fallback 字体先布局,量出来的行高偏小。等到真正渲染时字体加载完毕,行高变大了,分页点就会算错,最后几行会溢出页面底部。
我现在的习惯是导出前先等字体就绪:
if (document.fonts && document.fonts.ready) { await document.fonts.ready; }特别是用了第三方字体、数字字体或者中文 web font 的后台系统,这个等待非常必要。
4.3 rowspan/colspan 合并单元格
这是整套方案里最麻烦的一类问题。因为我用“clone 表格 + 删除行”来构造 chunk,一旦出现rowspan,被删除的起始行可能会连带影响后面 chunk 的合并结构。
举个例子,第 3 行某个单元格rowspan=2,它跨越了第 3、4 行。如果分页点把第 4 行分到下一页,下一页 clone 出来的表格里就没有这个合并单元格来填充左侧位置,单元格整体错位,视觉上等于漏了一格。
对这种表,我的建议是:在数据来源层就把合并单元格展开,不要等到了 DOM 再处理。导出前把二维数据平铺成规整网格,再构造一个无合并的表格 clone。这虽然会在 PDF 里失去合并效果,但至少内容完整、结构不错位。
如果必须在渲染时处理,只能遍历所有单元格,把rowspan和colspan的复制逻辑写清楚,但不同表格的合并结构差异很大,这个函数几乎无法通用,我一般建议业务侧写单元测试覆盖。
4.4 表头的处理与行高余量
表头我直接依赖cloneNode(true)天然保留thead的方式,每个 chunk 都带着同一份表头,无需额外插入。但注意分页计算时我没有把表头高度算进可用高度里,因为每一页都会重新画表头。
另外,行高测量偶尔会有 0.5px 左右的舍入误差,导致最后一行的下边框正好顶到 margin 边界,视觉上很局促。我通常会在availableHeightPx上预留 20px 左右的余量,让每页底部留一点“呼吸空间”。这个 20px 不是硬性规定,可以根据你表格的行高和边距调整。
5. 另一条路:jsPDF 自带 html 的 pagebreak 值不值得换
5.1 doc.html 的 avoid 模式其实已经在帮你避开行切断
jsPDF2.x 里提供一个更高层的doc.html()方法,它默认接的就是html2canvas。它支持pagebreak配置,其中有一个很显眼的功能:
doc.html(table, { x: margin.left, y: margin.top, html2canvas: { scale: 2 }, pagebreak: { avoid: ['tr'] }, callback: () => doc.save('table.pdf'), });avoid: ['tr']会让引擎尝试避免在<tr>内部切断,实现方式本质上是给这些元素附加break-inside: avoid的打印分页语义。对于结构简单、没有复杂样式和合并单元格的表格,这个方案确实能直接解决问题,代码量也少很多。
5.2 对比表:手动分 chunk 和 doc.html 怎么选
| 维度 | 手动分 chunk 方案 | doc.html + avoid |
|---|---|---|
| 对行截断的控制力 | 完全可控,断点精确到行 | 依赖浏览器分页语义,复杂表格可能失效 |
| 样式还原度 | 高,渲染的是裁剪后的真实 DOM | 中,部分 CSS 属性支持有限 |
| 分页点自定义 | 灵活,可加页眉页脚、每页表头 | 受限于内部实现,定制困难 |
| 实现复杂度 | 需要自己处理测高、clone、遮罩 | 低,几行代码能跑通 |
| 大表格性能 | 可控,每个 chunk 单独渲染 | 代码简单但遇到大表可能导致整页渲染 |
| 合并单元格 | 需要预先展开 | 同样容易被分页语义打断 |
我自己的感受是:如果是后台某张简单的数据表、行数不多、没有合并单元格,直接用doc.html省心很多;但如果表格将来会加多行表头、跨行合并、图片单元格、动态列宽调整,手动方案的前期投入就完全值得了。因为你在手动方案里积累的分页点、行测量、克隆渲染这套能力,换到任何报表场景都能复用。
5.3 什么场景下可以偷懒
总结一下我的选择逻辑:表格结构规整、行高稳定、不需要精确控制页间距,优先用doc.html;一旦出现以下任何一种情况,就回退到手动的 chunk 方案:
- 有
rowspan且合并跨度可能跨页 - 单元格里有图片、二维码或其他非文本内容
- 客户要求每一页底部都要有固定页脚或签字栏
- 表格列很多,需要横向缩放后按比例分页
- 行数超过几百行,需要分批次导出避免页面卡死
这套判断标准,足够应付我日常遇到的大部分导出需求了。
6. 实测数据与参数调优
6.1 一个 300 行表格的导出实测
我在一个模拟的订单明细表上跑过这套代码:300 行内容、10 列,一列是长文本备注,表格宽度 1100px,A4 纵向,边距 40/60,scale: 2。分页点计算出来共 7 页,前 6 页每页都接近满页,第 7 页只有 12 行。
浏览器是普通办公机型,整个导出过程大约 7 到 9 秒。其中后面几页因为行数较多,单页渲染时间会比前面慢一点。如果表格里没有长文本,行高更矮,每页能多塞几行,页数会减少,耗时也会相应缩短。
6.2 scale、图片格式与导出体积
scale直接决定 canvas 的清晰度和内存占用。scale: 1导出来的字会发虚,尤其在高分屏下很难看;scale: 2是性价比最高的值,字迹清晰,内存尚可;scale: 3虽然更清晰,但每页图片体积和渲染耗时都会明显上升,对长表格来说不太推荐。
图片格式方面,表格通常有背景色或边框,我用image/png比较稳妥,不会出现黑色底块。如果表格背景是纯白且没有透明需求,也可以换成image/jpeg,质量设 0.92,文件体积能小不少。不过底色如果不是纯白,JPEG 压缩后边缘会有轻微色斑,自己权衡就行。
6.3 针对超大表格的最终建议
有一个很容易被忽略的细节:html2canvas渲染出来的 canvas 在调用addImage之后,内存并不会立刻释放,因为 canvas 对象还挂在变量上。我在主流程里手动把canvas.width = 0; canvas.height = 0;,这样能明显降低连续导多页时的峰值内存,尤其是几十页的大表场景。
如果你要导出的表格有几千行,我建议放弃纯前端方案,改为把数据传给后端生成。前端方案再优化也扛不住几万行 DOM 的渲染和内存消耗。几百行的规模用本文这套手动 chunk 方案,已经完全够用了。
个人实际操作中还有个经验:每次分页点计算前最好先给需要导出的表格区域加一个统一的loading状态,避免用户在这几秒内滚动页面或缩放浏览器。因为getBoundingClientRect测量的是实时布局,页面滚动状态变化会导致测量结果不同,进而影响分页点和临时节点的定位。把页面“锁住”,这个方案才会稳定复现。