把一份几十页的 HTML 报告导出成 PDF 时,最头疼的问题往往不是样式,而是目录。屏幕上看起来一切正常的锚点链接,一旦进入打印分页,页码就全乱了:某个章节实际落在第 12 页,目录里却还写着第 10 页。手动核对当然不现实,而 paged.js 恰好为这类需求提供了完整的解决方案——它在浏览器里模拟真实分页,并允许我们在分页完成后动态生成目录、回填真实页码。

一、paged.js 的分页原理:为什么它能拿到真实页码
要理解动态目录的实现,得先弄清楚 paged.js 的工作方式。浏览器原生的 @page 规则只做很有限的支持,Chrome、Firefox 都不会真正把内容“切”到一个个页面盒子里,而是在打印预览时才临时分页。paged.js 的做法不同:它在 DOM 加载后拦截整个文档内容,创建一系列 <div class="pagedjs_page"> 作为页面容器,然后把原始内容逐块搬进这些页面,遇到放不下的元素就自动断页,并处理 break-before、break-after 等分页控制属性。
这个“先分页、后渲染”的过程带来一个关键优势:每个元素最终落在哪个页面里,在 DOM 结构中是可查询的。比如想知道某个 h2 标题的真实页码,只需要向上遍历它的祖先节点,找到最近的页面容器,读取该容器上的 data-page-number 属性即可。这是任何静态目录方案都做不到的。
另一个重要机制是生命周期钩子。paged.js 暴露了 Paged.Handler 类,允许注册 afterPageLayout、afterRendered 等回调。动态目录的核心思路就是:在所有页面渲染完成之后,扫描全文标题、收集页码、生成目录 DOM、插入文档,再触发一次重新渲染让目录本身也参与分页。
二、基础接入:让 HTML 先正确分页
接入 paged.js 非常简单,引入脚本后它会自动处理 @page 样式。需要注意分页媒体样式要写在普通样式之外,避免影响屏幕预览。
<script src="https://unpkg.com/pagedjs/dist/paged.polyfill.js"></script>
<style>
@page {
size: A4;
margin: 20mm 18mm;
@bottom-center { content: counter(page); }
}
/* 只在打印分页时为标题强制起新页 */
@media print {
h1.chapter { break-before: page; }
}
</style>
<script>
window.PagedConfig = {
auto: true,
before: () => console.log('分页即将开始')
};
</script>上面这段代码定义了 A4 页面、页脚页码计数器,并让每个 h1.chapter 从新页开始。此时打开页面,就能看到内容已经被切分到一个个可视化的页面容器中,每个容器带有 data-page-number 属性。可以先在控制台执行 document.querySelectorAll('.pagedjs_page').length 验证页面数量是否符合预期。
有一个容易踩的坑:如果文档里使用了 position: fixed 的浮动元素或异步加载的图片,分页时高度计算会出错,导致页码偏移。解决办法是在 PagedConfig.before 回调里等待所有图片加载完成,或者给图片写死宽高,确保 paged.js 拿到稳定的布局信息。
三、核心实现:动态目录插件
下面是完整可用的目录处理器。它的逻辑分三步:渲染完成后提取所有标题及页码,生成带锚点的目录结构插入文档头部,然后清空已渲染结果重新分页一次,让目录占据自己的页面,并保证锚点跳转正确。
class TocHandler extends Paged.Handler {
constructor(chunker, polisher, caller) {
super(chunker, polisher, caller);
}
// 所有页面渲染完成后触发
afterRendered(pages) {
// 防止第二次渲染时重复生成目录
if (document.querySelector('.generated-toc')) return;
const items = [];
document.querySelectorAll('h1[data-toc], h2[data-toc]').forEach(el => {
const page = el.closest('.pagedjs_page');
items.push({
level: el.tagName === 'H1' ? 1 : 2,
text: el.textContent.trim(),
page: page ? page.dataset.pageNumber : '?'
});
});
// 构造目录 DOM 并插入文档最前面
const toc = document.createElement('nav');
toc.className = 'generated-toc';
const title = document.createElement('h1');
title.textContent = '目录';
toc.appendChild(title);
items.forEach(item => {
const line = document.createElement('div');
line.className = 'toc-line toc-level-' + item.level;
line.innerHTML = '<span class="toc-text">' + item.text +
'</span><span class="toc-page">' + item.page + '</span>';
toc.appendChild(line);
});
// 目录本身从新的一页开始
toc.style.breakBefore = 'page';
document.body.prepend(toc);
// 关键:清空 paged.js 渲染结果,重新分页,让目录参与排版
document.querySelectorAll('.pagedjs_pages')[0].remove();
window.PagedPolyfill.preview();
}
}
Paged.registerHandlers(TocHandler);注意 document.querySelectorAll('h1[data-toc], h2[data-toc]') 这个选择器:通过 data-toc 属性显式标记哪些标题进入目录,是过滤页眉、封面上重复标题最可靠的方式。比起按标签名全量抓取再排除,这种方式让文档作者自己控制目录粒度,代码也不用维护黑名单。
目录行的排版建议用 flex 布局,文字与页码两端对齐,中间留出引导点或空白:
.toc-line {
display: flex;
align-items: baseline;
gap: 8px;
}
.toc-text::after {
content: ' ';
flex: 1;
border-bottom: 1px dotted #999;
/* 也可以用 leaders 特性,兼容性一般时用此方案 */
}
.toc-level-2 { padding-left: 2em; font-size: 0.9em; }
.toc-page { font-variant-numeric: tabular-nums; }四、进阶问题与调优
页码在第二次渲染后偏移怎么办。插入目录后文档会多出一到两页,所有正文的真实页码都会顺移。这正是代码里“重新分页”的意义:第一次渲染只为收集页码,第二次渲染后目录中的数字才与正文一致。但如果目录条目极多(比如超过两页),第二轮插入的目录又会改变后续页码,形成循环。解决办法是先估算目录占用的页数,在第一次渲染前就预留出相同数量的空白页,这样两轮渲染的总页数一致,页码自然稳定。
封面与前言不计入页码。正式文档通常封面无页码、目录从 1 或罗马数字开始。可以在 CSS 中对特定页面重置计数器:
@page cover { @bottom-center { content: none; } }
.cover { page: cover; }
@page :first {
counter-reset: page 0; /* 封面记为 0,下一页从 1 开始 */
}点击目录跳转到对应页。打印场景下跳转意义不大,但屏幕预览时很有用。paged.js 会为带 id 的元素维护内部锚点,给每个标题加上唯一 id(可在 handler 中用 el.id = el.id || 'toc-' + i 补齐),再把目录行包一层 a 标签指向该 id,浏览器内点击即可定位。
最后是性能问题。几百页的长文档,双次渲染的耗时可能达到十几秒。如果导出是高频操作,建议把渲染放到 Web Worker 或独立的无头浏览器(如 Puppeteer)中执行,渲染完成后再触发 window.print() 输出 PDF,用户侧几乎无感知等待。这套方案上线后,目录页码与实际内容完全由程序保证一致性,人工校对环节可以直接省掉。