简介:这是一份面向前端开发者的 paged.js 系统教程资料,围绕如何将 HTML 内容转换为带分页效果的 PDF 展开,适合希望掌握浏览器端 PDF 生成、CSS Paged Media 分页规则与打印样式适配的初中级开发者。内容涵盖库的安装引入、基础分页示例、页面尺寸与边距配置、自定义 @page 规则、Grid 与 Flexbox 复杂布局处理、toBlob/toDataURL 输出下载,以及与 React、Vue 等框架的集成和调试优化思路。资源包共 32401 个文件,以 28814 个 png 图片、2918 个 js 脚本为主,另含 otf、eot、woff 等字体文件及少量 html、svg、json、md 文档,整体约 40.16MB,多为示例站点与依赖资源。目前已有 585 人学习下载,可帮助读者快速理解分页原理并落地到实际项目。
1. 用 Paged.js 把网页变成能打印的 PDF:一份写给前端的一线笔记
如果你做过「网页导出 PDF」这个需求,大概率经历过这样的场景:浏览器打印预览里排版全乱,分页位置诡异,页眉页脚对不齐,表格跨页断得莫名其妙。用后端方案吧,又要维护一套完全独立的模板,前端改个样式得同步改两遍。Paged.js 就是冲着这个痛点来的——它把 CSS 的 Paged Media 规范搬进浏览器,让你用写网页的方式写打印稿,同一套 HTML 和 CSS,屏幕上能看,打印出来也能看。
这篇笔记面向的是需要落地「HTML 转 PDF」的开发者,尤其是做报表、合同、电子书、试卷、发票这类有严格分页要求的场景。我会从 Paged.js 的核心机制讲起,然后给出一套能直接跑通的最小工程,再拆解分页控制、页眉页脚、页码、目录生成这些高频需求的具体写法,最后把我在实际项目里踩过的坑一条条列出来。读完你应该能判断:这个方案值不值得引入你的项目,以及引入后怎么少走弯路。
2. Paged.js 到底做了什么:从 CSS 分页模型到浏览器渲染
2.1 浏览器原生打印为什么不够用
浏览器自带的打印能力,本质上是把屏幕上的连续流式布局硬塞进纸张尺寸里。它只认@media print里那点有限的样式,@page规则支持得残缺不全,页码、页眉页脚、跨页断点控制基本靠猜。你写page-break-before: always,有时候生效有时候不生效,表格跨页时表头不会自动重复,图片被拦腰截断也没法控制。
Paged.js 的思路不是去修补浏览器打印,而是绕开它:在页面加载后,用 JavaScript 读取你的 HTML 和 CSS,按照 Paged Media 规范重新计算布局,把内容切成一页一页的「纸张」DOM 结构,再把这些纸张渲染到屏幕上。你看到的预览就是最终 PDF 的样子,导出时直接对这个分页后的结构做打印或截图转 PDF。
这个机制决定了它的能力边界:凡是 CSS Paged Media 规范里定义的分页属性,它基本都支持;凡是依赖浏览器原生打印引擎的特性,它可能不认。理解这一点,后面很多「为什么这个属性没生效」的问题就都有答案了。
2.2 最小可运行工程:三行引入加一个配置块
先跑通一个最小例子,感受一下它的工作方式。新建一个 HTML 文件,内容如下:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>Paged.js 最小示例</title> <!-- 引入 Paged.js 的 polyfill 脚本 --> <script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"></script> <style> /* 定义纸张尺寸和页边距 */ @page { size: A4; margin: 20mm 18mm; } /* 屏幕预览时给纸张加个阴影,方便看分页效果 */ .pagedjs_page { box-shadow: 0 0 8px rgba(0, 0, 0, 0.15); margin-bottom: 16px; } body { font-family: "Noto Sans SC", sans-serif; font-size: 12pt; line-height: 1.7; } /* 强制某段内容从新页开始 */ .chapter { break-before: page; } </style> </head> <body> <section class="chapter"> <h1>第一章 概述</h1> <p>这是第一章的内容,Paged.js 会自动把它排到第一页。</p> </section> <section class="chapter"> <h1>第二章 细节</h1> <p>因为设置了 break-before: page,这一章会从新的一页开始。</p> </section> </body> </html>这段代码里,paged.polyfill.js是 Paged.js 的完整打包版本,引入后它会自动扫描页面内容并执行分页。@page规则里的size和margin是纸张级参数,break-before: page是分页控制属性。打开这个文件,你会看到内容被切成了两张带阴影的「纸」,每张纸的尺寸和边距都按 A4 算好了。
参数说明:size可以写A4、A3、letter,也可以写具体尺寸如210mm 297mm;margin支持四值写法,顺序是上右下左。break-before的取值还有avoid(尽量避免在此元素前分页)、left、right(强制分到左页或右页,适合双面印刷)。
2.3 分页引擎的工作流程与关键钩子
Paged.js 的分页不是一次性完成的,它分几个阶段:先解析 CSS 里的@page规则,建立页面模板;然后遍历 DOM,把内容按块级元素逐个「流」进页面;当一页装不下时,触发分页逻辑,创建新的页面容器;最后在所有页面渲染完成后,触发rendered事件。
这个流程里有两个关键钩子值得记住。一个是beforeRender,在分页开始前触发,适合做内容预处理,比如给所有表格加上重复表头的类名。另一个是afterRendered,在所有页面生成后触发,适合做后处理,比如给页码元素填值、生成目录。
// 在分页开始前,给所有表格标记需要重复表头 class TableHandler extends Paged.Handler { beforeRender(chunker, polisher, caller) { document.querySelectorAll('table').forEach(table => { const thead = table.querySelector('thead'); if (thead) { // Paged.js 识别这个属性来决定是否重复表头 thead.style.display = 'table-header-group'; } }); } } Paged.registerHandlers(TableHandler);这段代码注册了一个自定义 Handler,在渲染前把所有带thead的表格标记为表头重复组。table-header-group是 CSS 表格布局里的标准值,Paged.js 在分页时会识别它,让表头在每一页顶部重复出现。如果不加这个处理,跨页表格的第二页开始就没有表头了,阅读体验很差。
3. 页眉页脚、页码与目录:把打印稿的「标配」做出来
3.1 用 @page 边距框写页眉页脚
Paged Media 规范里有一套「边距框」机制,允许你在页面的上下左右边距区域放内容。Paged.js 实现了这套机制,写法是在@page规则里用@top-center、@bottom-right这类 at-rule。
@page { size: A4; margin: 25mm 18mm 20mm 18mm; /* 页眉居中显示文档标题 */ @top-center { content: "项目验收报告"; font-size: 9pt; color: #666; border-bottom: 0.5pt solid #ccc; width: 100%; padding-bottom: 4mm; } /* 页脚右侧显示页码 */ @bottom-right { content: "第 " counter(page) " 页 / 共 " counter(pages) " 页"; font-size: 9pt; color: #666; } }counter(page)是当前页码,counter(pages)是总页数,这两个计数器由 Paged.js 自动维护。边距框的位置关键字有@top-left、@top-center、@top-right、@bottom-left、@bottom-center、@bottom-right,以及左右两侧的@left-top、@left-middle、@left-bottom等。content属性里可以拼接字符串和计数器,写法跟 CSS 的content一致。
注意一点:边距框里的样式是独立于正文的,width: 100%是相对于边距框自身区域,不是整页宽度。如果你想让页眉横线贯穿整个版心,得在边距框里用border-bottom并配合合适的宽度设置。
3.2 首页不同、奇偶页不同的页眉页脚
正式文档经常要求封面不显示页眉页脚,或者奇偶页的页码位置不同。Paged.js 支持用命名页面来实现。
/* 默认页面:有页眉页脚 */ @page { @bottom-center { content: counter(page); } } /* 封面页:不显示页码 */ @page cover { @bottom-center { content: none; } } /* 给封面元素指定使用 cover 页面 */ .cover-page { page: cover; break-after: page; }page: cover这个属性把元素绑定到名为cover的页面模板上。@page cover里把content设为none,页码就不显示了。同样的思路可以做奇偶页:定义@page :left和@page :right,分别设置不同的页码位置。:left和:right是伪类选择器,Paged.js 会根据页面在文档中的位置自动应用。
3.3 自动生成目录:从标题采集到页码回填
目录是打印稿的刚需,但 Paged.js 本身不提供目录生成功能,需要自己写 Handler 来实现。思路分两步:先采集所有标题元素和它们所在的页码,再在目录区域渲染出带页码的列表。
class TocHandler extends Paged.Handler { constructor(chunker, polisher, caller) { super(chunker, polisher, caller); this.tocEntries = []; } // 每个页面渲染完成后,记录该页上的标题 afterPageLayout(pageElement, page, breakToken) { const headings = pageElement.querySelectorAll('h1, h2, h3'); headings.forEach(h => { this.tocEntries.push({ level: parseInt(h.tagName[1]), text: h.textContent.trim(), page: page.position, // 当前页码 id: h.id || '' }); }); } // 所有页面渲染完成后,把目录填进占位容器 afterRendered(pages) { const tocContainer = document.querySelector('#toc'); if (!tocContainer) return; const html = this.tocEntries.map(entry => { const indent = (entry.level - 1) * 16; return `<div class="toc-item" style="padding-left:${indent}px"> <span class="toc-text">${entry.text}</span> <span class="toc-page">${entry.page}</span> </div>`; }).join(''); tocContainer.innerHTML = html; } } Paged.registerHandlers(TocHandler);这段代码里,afterPageLayout在每一页布局完成后触发,此时能拿到该页的 DOM 和页码page.position。把所有标题收集起来后,afterRendered在全部页面完成后统一渲染目录。目录项的缩进按标题层级计算,h1不缩进,h2缩进 16px,以此类推。
有个细节要注意:目录本身也占页面,如果目录在正文之前,它的页码会影响后续内容的页码计算。常见做法是把目录放在一个独立的命名页面里,用罗马数字编页码,正文从阿拉伯数字 1 重新开始。这需要配合counter-reset来实现,稍微复杂一些,但原理就是把页码计数器在正文开始处重置。
4. 分页控制与内容适配:表格、图片、代码块的实战处理
4.1 表格跨页:表头重复与行断裂控制
表格是分页里最容易出问题的元素。默认情况下,一个长表格跨页时,第二页不会重复表头,而且某一行可能被从中间截断,上半行在第一页,下半行在第二页。
table { width: 100%; border-collapse: collapse; /* 允许表格跨页断开 */ break-inside: auto; } thead { /* 表头在每页重复 */ display: table-header-group; } tr { /* 禁止单行内部断裂 */ break-inside: avoid; } td, th { border: 0.5pt solid #999; padding: 6pt 8pt; font-size: 10pt; }break-inside: avoid加在tr上,保证一行不会被拆到两页。display: table-header-group加在thead上,让表头在每页顶部重复。这两个属性配合使用,基本能解决表格跨页的绝大多数问题。如果表格特别长,还可以在tbody上设置break-inside: auto,允许表格体自由断开。
4.2 图片与代码块:避免被拦腰截断
图片和代码块这类「原子性」内容,最怕被分页线切开。处理方式是在这些元素上设置break-inside: avoid。
figure, pre, blockquote { break-inside: avoid; } img { max-width: 100%; height: auto; /* 图片底部留一点间距,避免紧贴下一页顶部 */ margin-bottom: 8pt; } pre { background: #f5f5f5; padding: 10pt; font-size: 9pt; line-height: 1.5; /* 代码块过长时允许横向滚动,不撑破页面 */ overflow-x: auto; white-space: pre-wrap; word-break: break-all; }break-inside: avoid告诉分页引擎:如果这个元素在当前页放不下,就整体挪到下一页,不要从中间切开。对于特别长的代码块,如果一页都放不下,这个属性会失效,引擎只能强行断开。这时候可以考虑把长代码拆成多个pre,或者接受断开并在断开处加视觉提示。
4.3 强制分页与避免分页的取舍
break-before和break-after控制元素前后的分页行为,取值有auto、page、avoid、left、right。实际项目里,章节标题前用break-before: page强制新起一页,标题后跟内容之间用break-after: avoid防止标题和正文被分开。
h1 { break-before: page; break-after: avoid; } h2, h3 { break-after: avoid; } /* 某些短小的提示框不要单独占一页 */ .tip-box { break-inside: avoid; }这里有个容易忽略的点:break-after: avoid加在标题上,意思是「标题后面不要分页」,但引擎是否遵守取决于后续内容能否在当前页剩余空间放下。如果标题后面跟了一个大表格,表格放不下要挪到下一页,那标题也可能被一起挪过去,这是符合预期的。但如果标题后面是普通段落,段落能放下,标题就不会被孤立在页尾。
5. 避坑与排查:Paged.js 落地时最容易翻车的五个地方
5.1 样式不生效:先查选择器优先级和作用域
现象:写了@page规则,但页边距、页眉页脚完全没变化。
原因:Paged.js 在分页时会重新构建 DOM 结构,你的样式如果依赖特定的父级选择器或body > div这类结构,可能在分页后匹配不到。另外,@page规则必须写在<style>标签里,通过外部 CSS 文件引入时要注意加载顺序,Paged.js 可能在样式加载完成前就开始分页了。
解决:把@page相关样式放在<head>里靠前的位置,确保在 Paged.js 脚本执行前已经解析。如果用了外部样式表,给<link>加rel="stylesheet"并确保它在脚本之前。调试时可以在浏览器控制台里检查.pagedjs_page元素的计算样式,看@page规则有没有被应用。
5.2 页码错乱:目录页和正文页的计数器冲突
现象:目录页用了罗马数字,正文从 1 开始,但正文第一页显示的是目录页的页码加一。
原因:counter(page)是全局递增的,不会因为命名页面切换而自动重置。目录页占了 2 页,正文第一页的counter(page)就是 3。
解决:在正文开始处重置页码计数器。可以用一个 Handler 在正文第一页的afterPageLayout里执行page.element.style.counterReset = 'page 1',或者用 CSS 的counter-reset配合命名页面。更稳妥的做法是给正文容器加一个类,在afterRendered里遍历页面,找到正文第一页后手动设置页码起始值。
5.3 中文字体渲染异常:字体加载时机与回退
现象:导出的 PDF 里中文变成方框或默认宋体,跟网页上预览的字体不一致。
原因:Paged.js 分页时如果字体还没加载完,它会用回退字体渲染。等字体加载完,分页已经结束了,不会自动重排。另外,PDF 导出工具对字体的嵌入策略也会影响最终效果。
解决:在分页开始前确保字体已加载。可以用document.fonts.ready这个 Promise,等它 resolve 后再初始化 Paged.js。如果用的是自定义字体,在 CSS 里用@font-face声明并设置font-display: block,避免回退字体先渲染。
// 等字体加载完成后再启动分页 document.fonts.ready.then(() => { // 这里再动态引入 paged.polyfill.js 或调用 Paged.js 的初始化方法 });5.4 长内容分页性能差:分块渲染与虚拟滚动
现象:文档超过 100 页时,分页过程卡顿明显,浏览器甚至无响应。
原因:Paged.js 默认会一次性把所有内容分页并渲染到 DOM 里,页面数量多了以后,DOM 节点数爆炸,布局计算量急剧上升。
解决:对于超长文档,考虑分块处理。把内容按章节拆成多个独立的 Paged.js 实例,每个实例只处理一章,最后合并 PDF。或者用 Paged.js 的chunker配置项调整分块大小。另一个思路是只在导出时启用 Paged.js,预览时用普通滚动视图,减少实时分页的开销。
5.5 导出 PDF 的两种路径:浏览器打印与截图转 PDF
现象:用浏览器打印对话框导出 PDF,发现页眉页脚重复出现,或者边距不对。
原因:浏览器打印时会加上自己的页眉页脚(URL、日期、页码),这些是浏览器行为,不是 Paged.js 控制的。另外,打印时的缩放比例、纸张来源设置也会影响最终输出。
解决:在打印对话框里把「页眉和页脚」选项取消勾选,边距设为「无」,缩放设为 100%。如果对 PDF 质量要求高,可以用截图方案:把每个.pagedjs_page元素用html2canvas或类似库转成图片,再拼成 PDF。截图方案的好处是所见即所得,坏处是文字不可选中、文件体积大。两种方案按场景取舍。
6. 进阶技巧:用 Handler 扩展 Paged.js 的能力边界
Paged.js 的 Handler 机制是它最灵活的地方。除了前面用到的beforeRender、afterPageLayout、afterRendered,还有beforePageLayout、afterParsed、beforeParsed等钩子,覆盖了分页流程的各个阶段。你可以用 Handler 做很多内置功能之外的事情。
比如,给所有外部链接自动加上脚注标记,在页脚显示链接地址。这个需求在打印稿里很常见,因为纸上点不了链接。
class LinkFootnoteHandler extends Paged.Handler { constructor(chunker, polisher, caller) { super(chunker, polisher, caller); this.linkIndex = 0; } afterParsed(content) { const links = content.querySelectorAll('a[href^="http"]'); links.forEach(link => { this.linkIndex++; // 在链接后插入上标编号 const sup = document.createElement('sup'); sup.textContent = `[${this.linkIndex}]`; sup.style.fontSize = '0.7em'; sup.style.color = '#0066cc'; link.after(sup); // 把链接地址存到 data 属性,供页脚使用 link.dataset.footnoteUrl = link.href; }); } afterPageLayout(pageElement, page) { // 收集当前页上的链接脚注 const links = pageElement.querySelectorAll('a[data-footnote-url]'); if (links.length === 0) return; const footnotes = Array.from(links).map((link, i) => { const num = link.previousElementSibling.textContent; return `${num} ${link.dataset.footnoteUrl}`; }).join(' | '); // 把脚注插入页面底部的边距框区域 const footnoteArea = pageElement.querySelector('.pagedjs_margin-bottom-center'); if (footnoteArea) { footnoteArea.textContent = footnotes; footnoteArea.style.fontSize = '7pt'; footnoteArea.style.color = '#888'; } } } Paged.registerHandlers(LinkFootnoteHandler);这段代码在afterParsed阶段遍历所有外部链接,给每个链接后面插入一个上标编号,并把链接地址存到style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />