
1. 先回答一个现实问题为什么偏偏是Puppeteer前端生成PDF这事我前后折腾过三套方案最后停在Puppeteer上再没换过。起因很老套某客户要求“一键导出合同PDF”。后端同事先试了模板引擎拼HTML再转PDF表格一跨页就乱样式怎么调都对不齐我这边试着用截图方案客户又抱怨放大糊、文字不能搜索、复制不出来。最后绕到Puppeteer才找到正解——它不是在“截图”而是在让无头Chromium执行一次真正的打印渲染。这篇文章我会把从安装到部署、从样式适配到并发控制的全链路经验一次讲透。适合正在做报表导出、合同下载、证书打印或者被“页面要变成PDF”这个需求折磨过的前端同学。看完你至少能独立搭出一套能上生产的导出服务也知道哪些坑必须绕开。1.1 前端生成PDF的三条路线截图、后端模板与浏览器直出先说结论选型没选对后面所有的努力都是在填坑。路线A截图曲线救国html2canvas jsPDF。原理是把页面渲染到canvas再塞进PDF。优点是前端纯js就能跑不用碰服务端。缺点是canvas画出来的是像素图PDF里的文字不是文本无法复制、无法搜索放大就虚。更麻烦的是多页文档要自己计算分页位置表格跨页会直接从中间劈开复杂的CSS阴影、渐变、滤镜大概率还原失败。图片跨域、字体加载也会间歇性翻车。它适合做“内容不多、要求不高”的图片型PDF比如简单的二维码标签不适合做合同、报告。路线B后端模板渲染wkhtmltopdf、OpenPDF等。后端同事用模板语法写一份HTML或XML再让引擎转换。优点是输出稳定、服务端生成方便做权限控制。缺点是前端和后端要维护两套模板业务页面长得再好看也没用后端模板的CSS支持非常有限flex、grid、print媒体这些想都别想。我的体会是改一次版式前端改完后端还要跟着改一遍两边总是不同步。路线CPuppeteer调用无头Chromium直出。这等于让真正的Chrome渲染引擎打开页面然后调用浏览器自带的打印功能直接把渲染结果输出成PDF文件。它就是“浏览器打印到文件”和你在电脑上按CtrlP另存为PDF是同一个机制。这也是我最终选择的路CSS支持最全、页面始终和线上一致、PDF里保留文本图层。1.2 Puppeteer的核心优势所见即所得且文本可检索Puppeteer第一个核心优势是渲染引擎一致。你页面里用到的最新CSS特性、自定义字体、SVG图表、Canvas图表只要线上Chrome能正常显示Puppeteer就能原样打进PDF。不需要额外写一套给导出专用的模板业务页面写完顺手把print样式一调导出就能用。第二个优势是输出的是真正“可读”的PDF。因为是浏览器打印引擎生成PDF里的文字是文本对象而不是图片用户可以直接选中、复制、搜索。正式合同、报告类文档这点非常关键——客户要拿去做关键词检索截图方案根本做不到。第三个隐藏优势是可以控制整个渲染过程。Puppeteer不是简单的页面转文件它能等网络请求结束、等图表动画完成、能注入登录态cookie、能动态修改页面尺寸甚至可以执行脚本定制渲染环境。这种可控性让它比所有“URL转PDF在线工具”都靠谱。1.3 它的短板也先说清楚没有万能方案Puppeteer有它的代价。依赖重生产环境需要Node.js和完整Chromium部署体积比纯前端方案大很多。内存敏感每个浏览器实例占内存不低并发多了要控制得比较严。不适合超大文档几千页的PDF用Puppeteer硬生成内存和耗时都会很难看这种场景应该走后端分步拼装。所以我的判断标准是只要你的PDF是从真实Web页面来的、要求样式还原度和文本可检索Puppeteer就是首选如果只是拼个简单表格pdfmake这类方案反而更轻量。2. 从零跑通第一版安装、启动与三行核心代码2.1 安装环节最容易翻车的两个点先给新手打个预防针npm install puppeteer不像装普通包那么顺利。第一个坑是Chromium下载。装puppeteer时npm会默认去下载一个对应的Chromium浏览器这个下载走的是官方地址网络不好的时候经常卡住或失败。我踩过太多次解决方案是配置下载地址指向公司内网或可用仓库。如果你不需要自动下载也可以设置环境变量跳过后面在使用时手动指定浏览器路径。团队协作时记得在README里写清楚下发的版本号不然同事之间环境不一致排查起来很头疼。第二个坑是puppeteer和puppeteer-core的区别。puppeteer自带浏览器下载开箱即用puppeteer-core不带浏览器需要你指定executablePath指向系统已有的Chrome。如果服务器上已经装了Chrome用puppeteer-core能省掉下载环节但你要自己保证Chrome版本兼容。我的建议开发时直接用puppeteer省心部署到有现成Chrome的Docker镜像时再切换puppeteer-core。2.2 最小可用的PDF生成脚本装好之后第一版脚本其实短得惊人。下面这段就能把指定URL变成一份A4 PDFimport puppeteer from puppeteer; const browser await puppeteer.launch({ headless: true, args: [--no-sandbox, --disable-setuid-sandbox] }); const page await browser.newPage(); await page.goto(https://example.com/report, { waitUntil: networkidle0, timeout: 60000 }); await page.pdf({ path: report.pdf, format: A4, printBackground: true, margin: { top: 20mm, right: 15mm, bottom: 20mm, left: 15mm } }); await browser.close();几行代码核心就三件事启动浏览器、打开页面、调用page.pdf()。waitUntil: networkidle0表示等页面所有网络请求都结束后再生成这一步很关键。如果页面里有图表、异步数据光靠这个还不够后面实战章节我会讲怎么显式等待渲染完成。2.3 page.pdf() 参数逐个说清楚page.pdf()是Puppeteer里参数最值得记牢的方法之一我列的这几个是生产环境必调的。path输出文件路径。如果不传page.pdf()会返回PDF的Buffer方便接口直接返回文件流。format纸张规格A4、Letter等。也可以不写format而用preferCSSPageSize: true这样页面CSS里用page定义的尺寸会优先生效。printBackground默认false不设置的话所有背景色、背景图都会消失。几乎所有真实项目都要改成true。margin页面边距。这里单位可以用mm或in比px符合打印习惯。注意边距和页眉页脚模板的位置是联动的边距太小页眉会挡住正文。landscapetrue时横向输出。pageRanges例如1-3或2只导出指定页调试时很有用。scale0.1到2之间的缩放系数配合页面宽度微调但会让字号也变慎用。displayHeaderFooter开启页眉页脚配合headerTemplate和footerTemplate使用第四节详细讲。timeout生成PDF的超时时间默认30秒复杂的可视化页面建议调大。3. 决定PDF颜值的不是截图而是CSS打印样式适配方案3.1 浏览器“打印”页面时到底发生了什么很多人第一次用Puppeteer生成PDF发现“页面在浏览器里很好看PDF里却一团糟”原因在于没理解浏览器打印的底层机制。Chrome打印页面时不是简单把屏幕内容拍下来而是把HTML重新排版到“分页媒体”上每一页有固定的纸张宽高内容超出长度会自动换页还会应用media print里定义的样式。这意味着PDF的“长相”主要由CSS控制而不是由屏幕上的样子控制。你页面上有导航栏、侧边栏、弹窗提示如果不写打印样式让它们隐藏它们真的会被打进去你的正文宽度如果是按屏幕开发的打印出来也会因为纸张宽度不同而乱掉。所以通用思路是给业务页面单独设计一套打印样式让它在纸张上重新排版而不是寄希望于“屏幕上长什么样打印就长什么样”。3.2 一套能直接用的media print样式下面是我项目里常用的基础打印样式骨架media print { .no-print { display: none !important; } body { width: auto; margin: 0; padding: 0; -webkit-print-color-adjust: exact; print-color-adjust: exact; } .print-page { width: 210mm; min-height: 297mm; padding: 20mm; margin: 0 auto; box-sizing: border-box; } }关键点有两个。第一是print-color-adjust没有这行的话Chrome为了让纸张节省墨水会默认去掉背景色你的图表、标签、强调色全变白底黑字。第二是用mm单位控制宽度纸张的物理尺寸只有mm、cm、in这种单位才靠谱。另外建议在开发时就用浏览器的打印预览开发者工具里可以模拟打印媒体去调试而不是每次都生成PDF再看效率能差好几倍。3.3 分页控制封面、章节、表格不跨页分页是打印样式里的重头戏也是最容易翻车的部分。常用的规则是这几条/* 每个章节另起一页 */ .section-break { break-before: page; } /* 标题不要单独落在页面底部 */ h1, h2, h3 { break-after: avoid; } /* 表格行不要被劈开 */ tr, .card { break-inside: avoid; }特别提醒一个我踩过的坑表格跨页时tr设置break-inside: avoid经常无效。原因是Chromium对HTML表格行的分页控制支持一直不理想tr是表格内部布局元素break-inside不一定生效。我的解决方法是给td里面的内容包一层div对div设置break-inside: avoid或者在确实需要时把tr改成display: block再设置分页规则但这样会破坏表格列对齐需要权衡。如果表格很长且需要跨页还要让表头在每页重复出现用thead默认就能实现但前提是你的表格结构符合table thead tr th的标准写法。3.4 中文字体问题服务器上最容易翻车的一环这个问题太典型了在本地开发时PDF一切正常部署到Linux服务器上所有中文变成方框、乱码或者干脆空白。原因很简单——服务器上没装中文字体。Chromium渲染页面时找不到中文字形只能用缺省字体替代于是显示成方块。解决思路有两个。一是在服务器上安装中文字体。Ubuntu/Debian系可以直接装apt-get install -y fonts-noto-cjk fonts-noto-color-emoji装完执行fc-cache -f刷新字体缓存。如果你的应用是Docker容器把这句话写进Dockerfile即可。二是在前端页面通过font-face嵌入字体。这样浏览器会主动下载字体渲染不依赖系统字体。缺点是中文字体文件动辄几MB甚至十几MB如果每份PDF都加载一遍网络和内存都有压力。我一般只在页面用到特殊设计字形时才用嵌入方案正文内容直接依赖服务器字体。4. 加页码、页眉页脚与水印生产级细节补全4.1 官方模板方案实现页码合同、报告类文档页脚页码是刚需。Puppeteer提供了displayHeaderFooter配合模板的方式await page.pdf({ format: A4, printBackground: true, displayHeaderFooter: true, headerTemplate: div stylewidth:100%; text-align:center; font-size:8px; color:#999; 内部材料 · 请勿外传 /div , footerTemplate: div stylewidth:100%; text-align:center; font-size:10px; span第/span span classpageNumber/span span页 / 共/span span classtotalPages/span span页/span /div , margin: { top: 25mm, bottom: 25mm } });这里有几个细节必须记住。第一pageNumber和totalPages是官方预留的span class模板里要用这两个类名才能拿到页码数据。第二模板里的样式必须用内联style外部class样式在打印模板的沙箱环境里不会生效。第三模板默认字重字号非常小不显式设置font-size可能直接不显示。第四开启页眉页脚后要记得把margin.top和margin.bottom调大不然页眉页脚会和正文重叠。4.2 在每一页加水印的两种思路水印的需求很常见我试过两种做法。第一种是用footerTemplate或headerTemplate居中放透明文字。代码类似上面模板把文字颜色改成极淡的灰色加position: absolute配合transform: rotate(-30deg)倾斜。它的优点是稳定每一页都会重复不遮挡正文适合“机密”“草稿”这类全局水印。第二种是在页面里放一个固定定位的div配合CSS让它在每页重复。Chromium打印时position: fixed的元素默认会在每个打印页重复出现所以你可以这样做.watermark { position: fixed; top: 45%; left: 0; width: 100%; text-align: center; font-size: 60px; color: rgba(200, 200, 200, 0.3); transform: rotate(-30deg); pointer-events: none; z-index: 9999; }然后在页面body里放一个div classwatermark内部资料/div。我自己常用第二种因为样式控制更灵活可以结合业务数据动态生成公司名、用户名、导出时间。4.3 超链接、目录跳转与PDF元信息默认情况下Puppeteer生成的PDF里a标签是保留的用户点击PDF里的链接可以直接跳转。目录页想支持点击跳转只需要在页面里用a href#section-id这类锚点链接Chromium会正确输出PDF内部跳转。这一点在给客户交付产品说明书时特别有用。PDF文档标题会直接取页面head里的title所以业务页面写清楚title很重要。另外建议在页面里加meta namedescription等元信息某些阅读器会读取展示。需要提醒的是如果页面里有登录后才能访问的数据直接用page.goto()打开URL会跳转到登录页。解决办法在第六节的实战脚本里会给出来。5. 接进业务系统并发、超时与容器化部署5.1 别在每次请求里都启动一个浏览器很多初学着写导出接口时每个请求都执行一遍puppeteer.launch()然后browser.close()。这在演示环境没问题一旦上生产就会卡成狗。原因很简单启动一个Chromium实例要几百毫秒甚至更久内存占用也是上百MB级别。正确的思路是把浏览器实例常驻多个导出任务共享。let browserPromise; async function getBrowser() { if (!browserPromise) { browserPromise puppeteer.launch({ headless: true, args: [--no-sandbox, --disable-setuid-sandbox] }); } return browserPromise; } async function exportPdf(url, outputPath) { const browser await getBrowser(); const page await browser.newPage(); try { await page.goto(url, { waitUntil: networkidle0, timeout: 60000 }); await page.pdf({ path: outputPath, format: A4, printBackground: true }); } finally { await page.close(); } }这样只有第一个请求会触发启动后面的请求复用同一个浏览器。注意单个页面用完要page.close()否则新开页面越来越多内存迟早被吃光。5.2 并发生成PDF的两种套路导出任务通常会批量出现比如用户选了100个月的报表一次性导出。这时候要控制并发。我一般用puppeteer-cluster这个库专门干这事的import { Cluster } from puppeteer-cluster; const cluster await Cluster.launch({ puppeteer, maxConcurrency: 4, concurrency: Cluster.CONCURRENCY_PAGE, monitor: true }); await cluster.task(async ({ page, data }) { await page.goto(data.url, { waitUntil: networkidle0 }); await page.pdf({ path: data.output, format: A4, printBackground: true }); }); cluster.queue({ url: https://example.com/1, output: 1.pdf }); cluster.queue({ url: https://example.com/2, output: 2.pdf });不想引第三方库的话就用项目里现成的并发工具包控制Promise并发数量本质一样。maxConcurrency取值要看服务器内存。一个Chromium实例加几个标签页内存占用随页面复杂度从300MB到1GB不等我通常按每个并发任务预留500MB内存来估算。4核8G的机器开到4个并发比较稳。5.3 Docker部署要解决的三件事容器化部署时Puppeteer最大的敌人是缺依赖。我常用的Dockerfile片段长这样FROM node:20-slim RUN apt-get update apt-get install -y \ libnss3 \ libx11-xcb1 \ libxcb1 \ libgbm1 \ libasound2 \ fonts-noto-cjk \ --no-install-recommends \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package*.json ./ RUN npm install --production COPY . . CMD [node, server.js]这里解决的就是三件事系统依赖库让Chromium能跑起来、中文字体避免方框字、沙箱问题。最后这个防不胜防——容器里默认是root用户Chromium会拒绝在root下启动常见的解法是在启动参数里加--no-sandbox。如果公司安全要求不让关沙箱可以在Docker里创建一个非root用户去跑但配置复杂我还没见过谁为了导出功能这么折腾。6. 一个完整的“周报导出”实战从页面到PDF6.1 业务页面怎么写打印模板最后用我最近做的“周报导出”功能完整走一遍流程。业务页面长这样顶部标题栏、四周部门周报表格、中间一个图表、底部签字栏。我的做法是给页面加一层打印专用结构div classprint-only h1部门周报/h1 p classmeta统计周期span idrange/span/p table idreport-table.../table div idchart/div div classsign span汇报人/span span审批人/span /div /div屏幕显示用另一套结构打印时隐藏屏幕结构、只显示.print-only。这样屏幕上可以有交互按钮、筛选功能打印PDF时干干净净只有最终要交付的内容。6.2 带登录态的导出脚本周报页面需要登录才能访问直接page.goto()会被重定向到登录页。我之前踩过现在统一用cookie注入async function exportWeeklyReport(userId, dateRange, outputPath) { const browser await getBrowser(); const page await browser.newPage(); // 先打开目标域名确保cookie能写入 const baseUrl https://app.example.com; await page.goto(baseUrl, { waitUntil: domcontentloaded, timeout: 30000 }); const token await getTokenFromSession(userId); await page.setCookie({ name: session_token, value: token, domain: app.example.com, path: / }); const reportUrl ${baseUrl}/weekly-report?from${dateRange.from}to${dateRange.to}; await page.goto(reportUrl, { waitUntil: networkidle0, timeout: 60000 }); // 等图表渲染完成 await page.waitForFunction(() { const chart document.querySelector(#chart canvas); return chart chart.getContext(2d).getImageData(0, 0, 1, 1).data[3] ! 0; }, { timeout: 15000 }); await page.pdf({ path: outputPath, format: A4, printBackground: true, displayHeaderFooter: true, footerTemplate: getFooterTemplate(), headerTemplate: getHeaderTemplate(), margin: { top: 25mm, bottom: 25mm, left: 15mm, right: 15mm } }); await page.close(); }getTokenFromSession是你业务里从用户会话取token的逻辑这里不展开。关键是先访问一次目标域名再设置cookie再访问目标页面顺序不能反否则cookie的domain不匹配Chromium会拒绝写入。6.3 实测踩过的坑与效果对照这套流程上生产后我陆续修过三个高频问题第一个是图表空白。页面图表是Canvas绘制的网络请求都结束不代表图表画完了。一开始用networkidle0经常导出空白图表后来加了page.waitForFunction去等canvas像素点非透明才稳定下来。如果是ECharts更简单的方案是等图表容器的getAttribute(_echarts_instance_)存在且getDataURL能返回非空字符串。第二个是屏幕字体默认大小影响打印效果。页面根元素font-size在屏幕上是16px但打印时我想用12px正文。单纯写media print { html { font-size: 12px } }还不够因为很多元素用了rem相对根元素缩放后可能触发换行错乱。后来统一改成打印时禁止根元素缩放正文宽度用mm写死内部间距用固定px。第三个是页面等待时间不够。有报表页里有个定时刷新数据的setTimeout每30秒轮询一次networkidle0永远等不到“完全空闲”最后超时失败。处理方式是改成waitUntil: domcontentloaded加显式等待关键数据节点而不是等所有请求都安静。最后留个我自己一直在用的习惯每次新接一个页面导出需求我都会先固定一套“调试三步曲”本地生成PDF看效果用打印预览模拟媒体检查分页确认服务器字体和依赖没问题再上生产。这套流程帮我省掉很多来回折腾的时间。再说个小技巧别把导出逻辑散落在各个业务接口里我倾向于把所有Puppeteer生成PDF的能力收敛成一个独立服务外面只暴露“URL加参数进去文件出来”的接口。业务方不关心你用了什么引擎你也能在服务层统一做限流、超时、重试。这个架构被多个项目复用下来异常少、维护成本低算是这个方案里最划算的投入。