我会从我拿到需求到最后得出最佳思路整个心路历程描述一下,文章会经过AI润色。
需求阶段
想要实现一个错题本导出,把选中题导出,并且附带前端样式。
第一阶段:基于 html2canvas
+ 图片拼接 的方案
做前端或多或少用过这么一个库html2canvas
可以把DOM转换成canvas图片,所以一开始最简单的想法也是先在页面上实现分页效果,解决预览。导出的时候,先截图,后拼接,用jsPDF
把截图的图片拼接起来实现导出。这也是埋坑的开始.
过程
- 在页面上实现分页,利用'影子dom'(此影子dom非彼影子dom)。即在看不到的地方先渲染一份不分页的但是每个小块儿都是跟分页的样式差不多的整个dom,然后挨个计算块儿代码的高度,进行计算,常用的A4纸对应的px差不多是1123,累加高度超过一页就放到第二页出来一个分页数组,然后再渲染出来
- 利用
html2canvas
截取每一页为图片 - 将图片拼接生成 PDF(使用
jsPDF
) - 上传到s3提供下载链接
优点
- 纯前端实现,集成简单、开发速度快
- 适合快速验证、对精度要求不高的场景
遇到的问题
-
资源兼容性差:
- 跨域图片无法正常截取
SVG
图像显示异常- 视频/音频元素无法捕捉,直接丢失
-
导出质量问题:
- 图片模式下 PDF 体积大(尤其是二倍图)
- 导出的文字不可选中、可能出现虚边或模糊
-
无法满足业务要求,仅适用于展示级需求,不能作为正式下发材料使用,比如复制文字
填坑
为了解决一些html2canvas
的局限性,无法完全还原页面,我又找到了一个浏览器截图的方法 tabs.captureVisibleTab() - Mozilla | MDN 可以把可视范围截图上传
第二阶段:引入(无头浏览器) Puppeteer
+ page.pdf()
服务端方案
对于第一步的工作后,意识到有很大的局限性,查了资料后,开始尝试node服务实现无头浏览器自带的打印方法page.pdf()
js
import puppeteer from 'puppeteer'
import path from 'path'
import fs from 'fs'
async function exportPdf(exportPdfKey) {
console.log('开始执行导出PDF脚本...')
let isErrorDetected = false;
let errorMessage = '';
const browser = await puppeteer.launch({
headless: false,
args: ['--no-sandbox', '--disable-setuid-sandbox']
})
try {
console.log('浏览器启动成功,创建新页面...')
const page = await browser.newPage()
// 添加页面错误监听
page.on('error', err => {
console.error('页面发生错误:', err)
isErrorDetected = true;
errorMessage = `Page error: ${err.message}`;
})
page.on('console', msg => {
const text = msg.text();
console.log('页面控制台:', text);
})
console.log('设置页面视口...')
await page.setViewport({
width: 1200,
height: 800
})
const content = await page.$eval('body', el =>
window.getComputedStyle(el).fontFamily
);
console.log('Loaded Fonts:', content);
console.log('开始访问页面...')
// &show_content=${show_content}&question_blank=${question_blank}
await page.goto(`www.baidu.com`, {
waitUntil: 'networkidle0',
timeout: 30000
})
console.log('页面加载完成')
if (isErrorDetected) {
throw new Error(errorMessage);
}
console.log('等待选择器 .pdf-page-real-container 出现...')
await page.waitForSelector('.pdf-page-real-container', {
timeout: 30000
})
console.log('选择器已找到')
// 等待所有图片加载完成
await page.evaluate(() => {
return Promise.all(
Array.from(document.images)
.filter(img => !img.complete)
.map(img => new Promise(resolve => {
img.onload = img.onerror = resolve
}))
)
})
// 等待一段时间确保所有内容都渲染完成
console.log('等待中')
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('等待结束')
// 强制使用 @media screen 的 CSS 样式,而不是默认的 @media print 样式
// await page.emulateMediaType('screen');
const outputPath = path.join(process.cwd(), 'output', `${pdfName}.pdf`)
console.log('准备保存PDF到:', outputPath)
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true })
console.log('开始生成PDF...')
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
displayHeaderFooter: true,
footerTemplate: `
<div style="width: 100%; font-size: 10px; text-align: center; color: #000;">
<span>第<span class="pageNumber"></span>页/共<span class="totalPages"></span>页</span>
</div>
`,
margin: {
top: '30px',
right: '30px',
bottom: '30px',
left: '30px'
}
})
console.log(`PDF已成功保存到: ${outputPath}`)
// 验证 PDF 文件是否创建成功
const stats = await fs.promises.stat(outputPath)
console.log(`PDF文件大小: ${stats.size} 字节`)
} catch (error) {
console.error('导出PDF失败:', error)
console.error('错误堆栈:', error.stack)
throw error; // 重新抛出错误以便上层处理
} finally {
console.log('关闭浏览器...')
await browser.close()
}
}
🚀 效果优势:
- 渲染质量高:保留 DOM 结构、支持 SVG、字体、可选文字等
- 支持更丰富的样式控制与打印级导出
🐞 遇到的问题:
- 字体匹配异常,如 SVG 字体加粗问题
- 分页不准确,与前端预期页面不一致
- 部分样式渲染差异大,需要适配打印模式下的 CSS
填坑
因为之前我是在本地去计算分页的,所以在打印后发现页眉和实际打印的页对应不上,所以开始找方法,后来发现即使没有分好页也没关系,讲的是一个css属性可以帮我做好分页,强制dom分页自然也不太用依赖页面的计算
kotlin
break-after,用来控制当前元素后面内容的行为,比如 break-after: page 意思是当前元素后面的内容强制分页(即新开一页)
break-before,用来控制当前元素之前的分页行为
break-inside,用来控制当前元素内部的分页行为
第三阶段:深度理解分页问题,发现前端分页的根本缺陷
在处理上述问题过程中逐步发现:
- 前端影子 DOM 分页 ≠ 真正可控分页
- 大量渲染时,影子 DOM + 虚拟列表滚动逻辑冲突,导致性能瓶颈
- 设备性能较差时页面会严重卡顿
填坑
因为我们的项目是要应用到实际生产环境中的,所以不是所有人都是mac,iphone去导出。
如果本地去预览你的大量数据的pdf就会出现性能瓶颈。
然后这个时候我们想出来的两种方案,做虚拟列表渲染,但是跟计算逻辑冲突,因为你要先拿到dom才能计算高度,这个就是我们思考的弊端。
然后想出来一种方案是做前端的数据分页,首次加载一部分数据,滚动到底部加载更多,这样会解决首次卡的问题,但是如果加载越多也是会卡,最后想要做这个性能优化唯有做滚动加载。
其实到这里会发现我已经陷入到一个恶性循环,因为要解决一个问题而引入另一种方案,最终引发新的问题。
由此总结出第一个最佳实践
其实页面预览做分页这个需求可以砍掉,只要保证在node服务器导出的时候做就好了,增加一个判断是浏览器还是服务器在调用页面就能区分。然后服务端做页眉页脚,就很简单。
第四阶段:计划引入 Paged.js
实现结构化分页并读取源码
为了提升分页准确性与打印友好性,开始调研并尝试接入 Paged.js
:
pagedjs能做什么
- 专注 HTML → 分页逻辑,支持页眉、页脚、页码、水印等打印能力
- 配合 Puppeteer 渲染时能做到内容、结构、页码统一
- 适合复杂布局场景如讲义、题本、报告等正式输出需求
pagedjs工作原理
- 先把所有内容渲染到一个隐藏的 DOM 容器(或你指定的容器)
- 然后递归遍历、排版、分页,生成每一页的 DOM
- 最后把分页后的 .pagedjs_pages 插入到页面
结论: pagedjs 不是"按需分页"或"懒加载分页",而是一次性把所有内容分页。这意味着内容越多,初次分页耗时越长,内存占用越大。只不过pagedjs 更像浏览器的"排版引擎",它会模拟真实的分页排版,支持更多 CSS 规则和复杂结构,适合通用文档/书籍/杂志等场景
pagedjs跟我们上面的想法存在的差异
方式 | 手动高度分页 | pagedjs 分页引擎 |
---|---|---|
原理 | 先渲染全部,累加高度 | 递归渲染,边渲染边判断 |
断点 | 只能按题目整体断开 | 可按块、行、甚至字母断开 |
CSS支持 | 需自己处理 break 规则 | 支持 CSS Paged Media |
复杂结构 | 需自己处理 | 内置处理表格/图片/嵌套 |
性能 | 只测量一次 | 可能多次测量/回退 |
灵活性 | 完全可控 | 受 pagedjs 算法约束 |
填坑
pagedjs这个库其实不足以完全适用我们的业务场景,有些只能通过改源码来实现,也不是非要用这个才几百star的库,其实我们的上面的思路已经很接近,可以优化我们手撕的分页逻辑来做
由此总结出最佳实践2.0
结合pagedjs的分页思路,先加载计算分页,分好后渲染在页面上
后续功能叠加
pdf合并功能(封面,尾页,多页面)
如果是单纯合并并不难,核心点在于页脚的页数的叠加如何做
单纯叠加
javascript
import { PDFDocument } from 'pdf-lib'
/**
* 将三份 PDF 文件合并为一份
* 另外三个参数的类型都是 Buffer,是表示 PDF 文件加载到内存后二进制内容
* @param { Buffer } coverBuffer 封面 PDF
* @param { Buffer } contentBuffer 内容页 PDF
* @param { Buffer } lastPageBuffer 尾页 PDF
* @returns 合并后的 PDF 文件的二进制内容
*/
export default async function mergePDF(coverBuffer, contentBuffer, lastPageBuffer) {
// 通过 pdf-lib 加载现有的 3份 PDF 文档
const { load } = PDFDocument
const [coverPdfDoc, contentPdfDoc, lastPagePdfDoc] = await Promise.all([
load(coverBuffer),
load(contentBuffer),
load(lastPageBuffer)
])
// 分别将封面文档和尾页文档的第一页拷贝到内容文档
const [[coverPage], [lastPagePage]] = await Promise.all([
contentPdfDoc.copyPages(coverPdfDoc, [0]),
contentPdfDoc.copyPages(lastPagePdfDoc, [0])
])
// 将封面页插入到 内容文档 的第 0 页,即最开始的位置
contentPdfDoc.insertPage(0, coverPage)
// 将尾页添加到 内容文档 的最后一页
contentPdfDoc.addPage(lastPagePage)
// 将合并后的 内容文档 序列化为字节数组(Uint8Array),并以二进制的格式返回
return Buffer.from(await contentPdfDoc.save())
}
页面先合并后导出 胶水层PDF导出方案是在现有技术架构基础上增加的一个中间层,用于将多个内容页合并到一个容器页面中,然后统一导出PDF。但是这个方案存在很多问题,比如css如何避免冲突
- 获取页面HTML内容
- 通过
page.goto
方法依次打开用户提供的众多内容页 - 使用
page.evaluate
获取每个页面的HTML内容 - 清理不需要的元素(如script标签、no-print元素等)
- 创建容器页面HTML
- 通过
page.setContent
打开PDF生成服务提供的容器页面 - 将上一步拿到的所有HTML信息都填充到该容器页中
- 为每个页面添加统一的页脚、页码等信息
- 导出合并后的PDF
- 通过
page.pdf
方法打印填充后的容器页得到PDF内容页 - 使用CSS的
page-break-after
和page-break-inside
控制分页
pdf目录页的生成
如果是自己的页面
可以直接用a标签单独写个html页面在前面导出就好了。 如果不是自己的页面 就要算一下浏览器顶部到到pdf一页对应的高度1123px就算一页,然后就能把a标签对应的id的位置的页码算出来。