打印这个需求在web项目里看着不起眼真做起来能把人折腾到怀疑人生。我最近两三年反复处理“把页面变成PDF”这类任务从用户按CtrlP另存为到服务端用无头浏览器批量生成踩过一遍完整的坑。这篇文章就围绕“基于Playwright或Puppeteer实现web页面打印”这个主题把无头浏览器打印的实现思路、核心参数、实操流程和排障经验一次性写透。不管你是第一次接触headless浏览器还是已经在做但总被分页和样式折腾这篇应该都能给你省下不少时间。1. 为什么选择无头浏览器做页面打印1.1 传统打印方案的痛点早期做页面打印最直接的做法是window.print()让用户自己调出浏览器打印对话框再手动选择“另存为PDF”。这种方案有几个绕不过去的问题。第一个问题是依赖人工操作。每次打印都要用户在场点确认、选纸张、调边距批量出文档的场景直接歇菜。比如要一次性生成几百份电子合同总不能让用户坐在那里点几百次对话框。第二个问题是不同浏览器打印效果不一致。同一个页面Chrome和Firefox渲染出来的分页位置、字体大小、边距缩进都不一样。对内没有统一标准对外交付的文档又要求一致性这种不确定性是致命的。第三个问题是后端直接拼PDF的方案。用PDF库在服务端一张一张画表格、摆文字效果和前端HTML/CSS渲染出来的页面完全是两回事。页面里的渐变背景、圆角表格、复杂布局想在PDF库里复刻出来工作量接近重写整个前端。尤其是前端框架里动态渲染的DOM节点和异步加载的数据服务端在解析HTML时根本拿不到最终的渲染结果。无头浏览器方案把这几个问题一次性解决了用真正的Chromium内核加载页面、执行JavaScript、触发接口请求然后把排版引擎渲染出的最终结果直接输出为PDF。从使用者角度来看这就是“浏览器里看到什么样PDF就是什么样”所见即所得。1.2 无头浏览器带来的四点核心优势具体来说用Playwright或Puppeteer做页面打印优势可以拆成四点。第一点是渲染结果一致性高。底层是完整的Chromium排版引擎CSS的盒模型、浮动、网格布局、Flex布局全部按标准渲染。本地Chrome调试好样式部署到服务器输出的PDF几乎一摸一样不会出现“本地好好的服务器上就崩了”的翻车现场。第二点是流程可以完全自动化。浏览器启动、页面跳转、登录、填写表单、点击按钮、等待数据、触发打印每一步都能用代码驱动。我做过一个典型的场景模拟用户登录后台系统进入报表页等待图表加载然后自动生成PDF并通过消息推送给业务方全程零人工介入。第三点是输出参数完全可控。纸张大小是A4还是Letter、横向还是纵向、页边距多少、背景色要不要打印、页眉页脚怎么排、页码从哪一页开始全部是代码里的参数可以在不同任务间自由切换。第四点是部署灵活。无头模式不依赖图形界面Linux服务器上就能跑。把打印任务丢进队列一台机器一个晚上生成几千份PDF完全没问题。1.3 Playwright与Puppeteer如何选型这两套方案经常被放到一起比较。直接说结论如果是新项目优先选Playwright如果只是给老项目里已有的Puppeteer代码做维护没必要迁移。Playwright更现代化的地方在于自带浏览器管理命令安装依赖后一条命令就能下载匹配的Chromium内核API设计更符合直觉比如locator定位元素比Puppeteer的$函数好用对现代前端框架的等待策略也更完善。而且它的page.pdf()接口和Puppeteer的写法高度相似从Puppeteer迁过来基本不用改太多逻辑。Puppeteer的优势是资历老、生态久、网上能搜到的资料更多。遇到问题时Google一搜大半结果都是Puppeteer相关的排查效率高。但这里有一个必须注意的点无论选哪个真正干活的都是Chromium内核。Chromium的PDF生成能力是最完善的Playwright虽然支持Firefox和WebKit但打印功能在非Chromium内核上差异很大实际生产环境我都是固定用Chromium跑打印任务其他内核只在功能验证时偶尔用一下。2. 打印参数与渲染机制拆解2.1 PDF生成的完整链路把API用好之前先得理解无头浏览器把页面变成PDF的过程中发生了什么。页面加载流程和普通浏览器打开网页没有本质区别发起HTTP请求、获取HTML、解析DOM、加载CSS、执行JavaScript、触发异步接口请求、等待图片和字体资源完成下载。区别在于无头模式没有人去操作页面所以代码里必须人为控制“等待”的时机确保关键资源都加载完成后再调用打印否则PDF里很容易出现半成品页面。这个过程里有一个非常容易被忽略的点打印媒体和屏幕媒体的区别。浏览器渲染页面时可以区分screen和print两种媒体模式。CSS里通过media print写的打印样式、通过page设置的页面尺寸在普通访问时不会生效。无头浏览器默认按screen模式渲染所以要在调用PDF之前显式执行page.emulateMedia({ media: print })把渲染模式切到打印模式。很多新手一上来就调pdf()发现页面样式不对、字号对不上多半就是漏掉了这一步。另外还需要知道无头浏览器生成PDF并不是“截图”而是完整的PDF文档。这意味着PDF里的文字是可以选中、可以搜索的而不是一张图片。这一点对于合同、报告、发票这类需要后续检索和二次处理的文档非常重要。2.2 核心参数逐个过以Playwright的page.pdf()为例核心参数可以按用途分成几组。我列了一张表方便对照查阅参数作用备注format纸张格式A4/Letter/Legal等也可以用widthheight自定义尺寸width / height自定义纸张宽高可带单位如px、cm、mmlandscape是否横向打印默认falsemargin页边距上下左右独立设置单位可带mm/cmpreferCSSPageSize是否优先遵循CSS里的page尺寸设置了true时会忽略formatprintBackground是否打印背景色和背景图默认false做单据必须开scale内容缩放比例默认1影响分页位置displayHeaderFooter是否显示页眉页脚配合模板使用headerTemplate页眉HTML模板宽度2480px字号用ptfooterTemplate页脚HTML模板同上pageRanges页码范围如1-3, 5输出指定页omitBackground是否去掉背景适合导出纯白底文档其中printBackground是个高频坑。页面里明明上了底色、加了背景图生成的PDF里却一片白。原因就是默认不打印背景表单、票据类的模板几乎都要把printBackground设置成true才能还原设计稿。再说一下format和自定义尺寸。format: A4是最省事的写法但实际项目里合同和报告多数要求自定义边距甚至自定义纸张宽高。用width和height定义时要注意单位换算建议统一用mm或cm避免在不同环境下因为默认分辨率不同产生偏差。加上preferCSSPageSize的话页面里page规则定义的尺寸会优先于format适合页面模板本身就写死了纸张规格的场景。2.3 页眉页脚模板的自定义页眉页脚这块是打印功能里最花时间调的部分。displayHeaderFooter设为true且不提供模板时浏览器会输出一套默认样式又丑又占地方绝大多数项目里都要重写。headerTemplate和footerTemplate接收的是两段HTML片段不是完整的HTML文档。这个片段被浏览器放置在一个固定的容器内默认宽度是2480px字号单位是pt。如果你直接用px去写模板里的字号打印出来要么大得离谱要么小到看不见。模板里有三个特殊的占位类名pageNumber表示当前页码totalPages表示总页数date、title、url分别对应当前日期、文档标题和页面URL。这些占位符通过CSS定位在模板区域内。我惯用的布局是页眉放文档标题页脚左边放日期、中间放“第x页 共y页”、右边放公司名称或内部编号。这里提醒一个容易踩的坑模板里的中文内容在部分服务器环境中可能显示为乱码或方块。原因是模板渲染用的字体和页面主文档不是同一套渲染流程需要显式在模板样式里设置font-family指到一套服务器上确实安装的中文字体比如Noto Sans CJK系列。这个问题在Windows本地开发时往往发现不了部署到精简Linux服务器后就会爆发。3. 从零到一的实操过程3.1 环境准备与项目搭建下面进入完整实操环节。我以Node.js环境下的Playwright为例演示Puppeteer的接入方式高度相似差别我会在注释里标注。前置要求Node.js版本建议16以上一台能正常联网的机器。项目初始化命令npm init -y npm install playwright npx playwright install chromium第三步是关键。Playwright不会在npm install时自动下载浏览器内核必须手动执行install命令。这一步需要从网络下载几百MB的Chromium公司内网环境可能需要配置前置代理或镜像源否则会长时间卡住不报错也不结束。项目结构推荐这样组织print-service/ ├── src/ │ ├── index.js # 服务入口接收打印请求 │ ├── printer.js # 打印核心逻辑封装 │ └── templates/ # 页面模板或模板URL配置 ├── output/ # PDF输出目录 └── package.json服务框架用Express或原生HTTP都行核心逻辑集中在printer.js里。这样拆的好处是方便单元测试、方便单独部署、也方便后续扩展任务队列。3.2 基础打印方案落地先写一个最小可运行版本把链路跑通。我把最容易出问题的地方都标注在注释里const { chromium } require(playwright); async function generatePdf(url, outputPath) { const browser await chromium.launch({ headless: true, // 生产环境建议加 args: [--no-sandbox, --disable-setuid-sandbox] }); try { const page await browser.newPage(); // 关键步骤1切换到打印媒体模式让media print样式生效 await page.emulateMedia({ media: print }); // 关键步骤2加载页面networkidle确保网络请求完成 await page.goto(url, { waitUntil: networkidle, timeout: 30000, }); // 关键步骤3等待页面内的关键节点出现 // await page.locator(#main-content).waitFor({ state: visible }); // 关键步骤4输出PDF await page.pdf({ path: outputPath, format: A4, printBackground: true, margin: { top: 20mm, bottom: 20mm, left: 15mm, right: 15mm }, displayHeaderFooter: true, headerTemplate: div stylefont-size:9pt; width:100%; text-align:center; color:#666; font-family: sans-serif;项目报告/div, footerTemplate: div stylefont-size:8pt; width:100%; text-align:center; color:#666; font-family: sans-serif; span第/spanspan classpageNumber/spanspan页/span /div, }); } finally { await browser.close(); } }这段代码跑通后PDF已经能正常生成。注意这里headless: true是基础配置生产环境我还会加--no-sandbox参数原因后面排查章节会详细讲。真实项目远不止这些接下来逐步叠加需求。3.3 CSS打印样式怎么配合调用代码只是把事情“做出来”页面在打印模式下的呈现质量还是得靠CSS来控制。打印场景的CSS工作可以分成三类。第一类是隐藏不需要打印的元素。导航栏、按钮、弹窗、视频、评论区这些只在屏幕上看的元素打印模式下要全部隐藏。最常用的写法media print { .no-print { display: none !important; } }如果项目里很多页面要重复做这件事建议在公共样式表里预先定义好.no-print类页面开发时顺手给不想打印的元素都加上。第二类是分页控制这是打印样式里最核心也最麻烦的部分。内容刚好卡在两页交接处、表头被切到上一页、标题孤立在页面底部这些属于高频问题。CSS提供了几个专门控制分页的属性media print { /* 在元素前强制分页 */ .page-break-before { break-before: page; } /* 在元素后强制分页 */ .page-break-after { break-after: page; } /* 元素内部避免被切断 */ tr, .card, .section { break-inside: avoid; } /* 标题之后紧跟内容避免标题孤悬在页尾 */ h2, h3, h4 { break-after: avoid; } }我踩过最典型的坑是长表格。一个数据量很大的表格如果不管分页打印出来可能出现表头在前一页、数据在后一页的情况。解决方案是给thead设置重复显示同时用break-inside: avoid让每一行数据尽量保持完整。如果需要按组强制分页还可以在每组数据前面加一个带page-break-before类的空div作为分页锚点。第三类是颜色和背景处理。很多浏览器的打印模式默认会去掉背景色除了在代码里设置printBackground: trueCSS这边也要注意兼容。像box-shadow、渐变背景这类视觉效果在强制打印背景下可能渲染异常正式单据模板里尽量用纯色背景避免依赖特殊视觉效果。3.4 动态页面与异步数据的处理实际项目里要打印的页面几乎都是动态的数据来自后端接口DOM由前端框架渲染。这里有一个很大的坑page.goto()返回时并不代表页面所有数据都加载完成了。goto返回只表示网络层面的导航动作结束JavaScript里的异步请求、组件懒加载、图表初始化都可能在goto返回后才开始执行。如果立刻调pdf()拿到的很可能是一张只有外壳没有内容的空页。解决办法是主动“等数据就绪”。业界常用的策略有三种我按推荐程度排个序。第一种是等待可见的关键元素。确定页面渲染完成后会出现的节点用locator等待它await page.locator(#data-table tbody tr).first().waitFor({ state: visible, timeout: 10000, });如果页面里有loading指示器就等它消失await page.locator(#loading).waitFor({ state: hidden });第二种是等待接口响应。如果页面通过fetch或axios拉取数据可以直接等指定接口返回await page.waitForResponse((resp) resp.url().includes(/api/orders) resp.status() 200 );第三种是滚动触发加载。页面使用了懒加载时无头浏览器不会自动滚动图片和长列表永远不会加载。解决办法是在生成PDF前手动模拟滚动到底再回到顶部让懒加载逻辑全部触发。代码我放在后面排查章节里。我自己的生产经验是组合使用goto带networkidle然后再等待一个代表“数据已渲染完成”的DOM节点两者都满足才执行pdf()。宁可多等几百毫秒也绝不让半成品页面进入正式文档。4. 常见问题与排查技巧实录4.1 打印样式和屏幕样式不一致症状很典型本地打开页面正常生成的PDF却“裸奔”或者样式错乱。排查思路可以按三步走。第一步确认emulateMedia有没有正确调用。有些同学在代码里调用了page.emulateMedia但传参格式不对比如漏了media字段导致没有真正切到print模式。这个API的完整写法是page.emulateMedia({ media: print })。第二步确认viewport是否合理。打印模式下页面布局逻辑和屏幕模式不同如果页面有响应式断点建议在打印前把viewport固定为一个接近A4宽高比例的值减少响应式布局带来的分页抖动。第三步检查是不是JavaScript覆盖了样式。很多前端框架会在运行时设置内联样式内联样式的优先级高于CSS如果打印样式依赖JS添加的类名必须确保这些操作在调pdf()之前已经执行完成。必要时在调用PDF前加一段延时或主动等待特定class出现。4.2 内容加载不全、白页这个问题的排查优先级排在第一位。PDF生成成功但里面全是空白或者只有一半内容原因几乎可以断定是生成时机早于渲染完成时机。第一步检查图片懒加载。现在很多项目给img标签加了loadinglazy或用IntersectionObserver做懒加载页面在无头模式下没有滚动行为图片永远不会进入可视区域自然不会加载。解决办法是在生成PDF前手动触发滚动await page.evaluate(async () { await new Promise((resolve) { let totalHeight 0; const distance 200; const timer setInterval(() { const scrollHeight document.body.scrollHeight; window.scrollBy(0, distance); totalHeight distance; if (totalHeight scrollHeight) { clearInterval(timer); window.scrollTo(0, 0); resolve(); } }, 100); }); });注意滚动完成后要回到页面顶部否则输出PDF时会从滚动后的位置开始计算分页可能导致前几页空白或者内容错位。第二步检查是否有定时器或轮询请求一直在运行。networkidle等待如果遇到轮询接口可能一直等不到空闲状态导致超时。这时可以退一步改用domcontentloaded配合显式等待关键元素。第三步检查是否有跨域资源加载被拦截。无头浏览器默认会阻止部分跨域请求或混合内容如果页面上引用了外部CDN图片或字体确认服务器或代理环境允许这些请求正常完成。4.3 中文乱码和字体缺失这个问题在本地开发时基本不会出现因为开发机自带中文字体部署到干净的Linux服务器后才会爆发。症状是PDF里的中文全部变成方块或“口”字。排查手段很简单服务器上执行字体列表命令fc-list :langzh如果输出为空说明服务器没有中文字体。安装一套开源中文字体即可apt install fonts-noto-cjk安装完成后必须重启Node进程否则浏览器内部不会重新加载字体配置。我给过一个教训某次部署后生成的合同PDF里中文全部是方块排查了很久才发现是基础镜像里没有中文字体而开发环境一切正常问题完全被环境差异掩盖了。除了系统字体页眉页脚模板里的中文也需要单独处理。模板HTML要确保UTF-8编码模板内的CSS要显式设置font-family指向服务器上已安装的中文字体。模板里用到的字体和页面主文档的字体不是同一套渲染上下文这事我踩过很多次。4.4 并发任务与内存问题批量打印时最容易出问题的就是内存和并发。无头浏览器实例非常重一个Chromium进程随随便便吃几百MB内存。如果代码里每来一个请求就launch一个新的浏览器实例并发一高服务器内存立刻打满。我的实践经验是复用浏览器实例页面用完即关。核心思路const browser await chromium.launch({ headless: true }); async function processTasks(tasks) { for (const task of tasks) { const page await browser.newPage(); try { await renderAndPrint(page, task); } finally { await page.close(); } } } await browser.close();这样做的好处是浏览器内核只启动一次多次复用内存开销大幅降低。但要提醒一点同一个浏览器实例下并发跑多个page时它们共享进程资源pdf()属于CPU密集操作并发数需要结合服务器配置实际压测。我一般控制在5个以内超过的话加队列排队。还有一个很容易被忽略的内存泄漏点page对象创建后忘记关闭。长时间运行的任务每漏掉一个page就会积攒一批内存最终导致进程OOM。务必在finally块里关闭page或者在任务结束时主动调用browser.close()释放全部资源。4.5 元素被截断与分页定位分页导致元素被切断在复杂页面里几乎无法彻底避免只能通过CSS约束和反复调试来逼近理想效果。我分享一个用来快速定位分页问题的调试法。先把输出格式固定为和A4等宽的固定像素值例如width: 794px、height: 1123px同时关掉printBackground等干扰项先快速生成一版PDF再用阅读器逐页检查。每次只调整一个变量比如只改某个容器的高度或者只加一个break-inside改完重新生成对比。分页问题往往是多个因素叠加的结果一次动太多参数根本分不清是谁在起作用。如果打印的是固定模板页面强烈建议把viewport固定下来让分页位置变得可预测。对于动态内容场景能做的就是控制每张“卡片”或每个区块的高度一致给容器设置合适的min-height尽量让浏览器的自动分页落在整齐的位置上。表格数据多的场景配合thead重复表头、tr设置break-inside: avoid分页体验会好很多。最后再补充一个经常被忽略的细节在Linux服务器上用非root用户跑无头浏览器时大概率会遇到沙箱权限报错。常规解法是在launch时加上args: [--no-sandbox, --disable-setuid-sandbox]这是无头浏览器在容器和CI环境里的标配参数本地开发时不加反而可能因为权限限制打不开浏览器。另外务必给pdf()调用设置合理的超时时间别让一个异常页面把整个任务队列挂死——这个坑我在生产环境里踩过不止一次。做了这么多打印项目我的体会是无头浏览器方案是经过验证的可靠路径但它的可靠性恰恰建立在细致的参数调试上。页边距差1mm、字体少装一个、等待时间短了200ms最终呈现的PDF都可能完全不同。如果条件允许建议把打印服务从主业务里拆出来做成独立模块单独部署单独扩容这样既不影响主链路的稳定性打印任务积压时也能快速横向扩展。希望这篇能帮你在做页面打印时少走几步弯路。