订单导出新方案:用ponytail将HTML优雅转换为PDF 前一阵做订单后台的导出功能运营同事提了个需求把订单详情导成 PDF带店招、带明细表格、还要能直接发给客户。我第一反应是前端调window.print()但细想一下跨浏览器排版差异、分页控制、文件格式统一这些问题就知道这条路走不通。后来在技术社区翻到一个叫 ponytail 的 HTML 转 PDF 插件看着不大用法也简单就顺手在项目里试了一轮。这篇就把我用 ponytail 做 PDF 导出的完整过程、踩过的坑和最终的优化思路写出来给后来人一个参照。1. 订单导出这个需求为什么最后选了 HTML 转 PDF1.1 一上来就排除了 window.print()很多人遇到导出 PDF的第一反应是让用户自己打开浏览器按 CtrlP我也一样。但实际业务场景里这条路有四个绕不开的问题。第一是排版不可控。不同操作系统、不同浏览器、不同打印机设置都会让同一份页面产生完全不同的分页结果。你这边调好的 3 页客户那边打出来可能变成 5 页表格头尾乱飞。第二是样式维护成本高。要让一个普通页面在打印的时候好看你得单独写一套media print样式把导航、遮罩、按钮全部隐藏再把边距重新调整。页面改版一次打印样式就得跟着改一次时间长了没人愿意碰。第三是文件格式不统一。用户可以选择另存为 PDF也可以直接打印成纸质文件你根本没法保证最终交付物是 PDF。第四是没办法做后续处理。订单导出往往还要拼上发票号、订单水印、二维码这类动态信息纯靠浏览器打印样式很难做到灵活变化。1.2 方案对比canvas 手绘、wkhtmltopdf、无头浏览器、ponytail排除了前端打印之后我列了一下当时能想到的几种做法挨个做了排除。方案优点缺点window.print()零成本前端就能做排版不可控格式不统一jsPDF 等 canvas 手绘可控性强可精确到像素工作量巨大改版成本极高wkhtmltopdf 命令行有成熟的排版引擎动态数据渲染麻烦集成到 Node 服务不自然ponytail 这类 Node 插件直接在服务端用 HTML 字符串出 PDF需要理解底层渲染行为初期有学习成本canvas 手绘这个方案我也认真想过用 jsPDF 画表格、画线条、插入图片理论上什么都能做。但订单详情里商品明细行数是动态的表格列数还不少写出来的绘制代码会非常冗长。客户后来要加一列优惠分摊金额前端绘制代码就得改一大片。用 HTML 模板加 CSS 的话改一列就是模板里加一个th和td差距太明显。wkhtmltopdf 其实是个很老牌的工具效果也不错但它的集成方式一般是命令行调用输出文件、处理错误、管理并发都要自己封装一层。而且那次项目的技术栈是 Node.js我更希望能有一个能在代码里直接require的插件把渲染 PDF 变成一个方法调用而不是外部进程。1.3 ponytail 解决的核心问题ponytail 这类插件的核心价值是把数据填充和页面排版两件事拆开了。数据用模板引擎渲染进去页面结构用 HTML 描述视觉效果用 CSS 控制最后交给 ponytail 在服务端完成排版和分页直接输出 PDF 文件。这带来的直接好处是模板可维护、可复用。我可以在项目里放一个invoice.ejs模板文件里面写订单号、客户信息、商品明细表格运维同事想改字段时甚至不用碰代码改模板就行。另一个好处是和数据接口天然适配后端接口返回什么 JSON模板就渲染什么内容不需要像 canvas 那样一行一行地把数据画到坐标上。但坏处也很明显ponytail 本质上是个无头浏览器封装不是简单的解析器。它加载页面、执行 JavaScript、等待网络请求这一整套行为都会影响最终 PDF 的内容。如果把它当黑盒用遇到问题就会很被动。我这次实践下来最大的感受是用这类工具前最好先弄明白它内部到底做了哪几件事。2. ponytail 到底做了什么从 HTML 到 PDF 的链路拆解2.1 一个无头浏览器的隐形打印窗口第一次看到 ponytail 的 README 时我把它的能力想简单了以为就是把 HTML 标签解析成 PDF 图形。实际上完全不是一回事。你可以把 ponytail 想象成一个隐藏的浏览器窗口这个窗口平时你看不到界面但会老老实实打开你给的页面地址或 HTML 字符串。页面加载完之后它模拟了一次打印到文件操作把当前页面的渲染结果按纸张大小切分一页一页地写进 PDF。这意味着很多能力它是天然具备的CSS 布局、JavaScript 执行、图片加载、字体渲染都跟你在普通浏览器里看到的行为一致。反过来很多你习以为常的东西也会带来坑某个 JS 报错了PDF 可能白屏某个字体服务器上没有中文就变方块某个图片加载慢了生成的 PDF 里就是一张裂图。2.2 HTML 到 PDF 的四个阶段我后来把 ponytail 的工作拆成了四个阶段这样排查问题时会清晰很多。第一阶段是解析 HTML构建 DOM 树和 CSS 样式树。这个阶段基本不会出问题只要 HTML 本身是合法的。第二阶段是加载外部资源。样式表、外部脚本、图片、字体都会在这个阶段被请求。这里开始出现变数如果某个资源加载超时页面会一直等在那里如果资源是跨域的、HTTPS 证书无效的可能直接加载失败。第三阶段是执行 JavaScript。页面里所有的脚本都会跑起来包括那些动态往 DOM 里插入内容的代码。动态数据渲染、异步请求结果、定时器里的逻辑都在这个阶段落地。PDF 缺内容的问题绝大多数都出在这个阶段——脚本还没执行完文档就被输出到打印流程了。第四阶段是分页渲染。渲染引擎按照设置好的纸张大小、边距、缩放比例把整个页面切成一页一页的有时还会对表格做跨页切断处理最终输出 PDF。前两个阶段基本稳定后两个阶段是问题高发区。后面第 5 章里我会把踩过的坑都列出来。2.3 为什么能打开不等于能转对一个很常见的现象是模板在浏览器里打开效果很好但用 ponytail 生成的 PDF 就是不对。原因说穿了也简单——你看到的好效果依赖于你本机安装的字体、你浏览器的内核版本、你当时的网络状态而服务端的渲染环境完全是另一套。比如你本地用 Chrome 打开模板Chrome 会自动用系统的微软雅黑、苹方字体渲染中文。但服务端如果是精简版 Linux没装中文字体那 PDF 里所有中文都会变成豆腐块。再比如页面上有个图片是从 CDN 加载的本地网络没问题但服务端访问那个 CDN 域名时恰好有防火墙限制图片就加载不出来。所以实操中一定要把渲染环境固定下来Node 版本固定、底层浏览器版本固定、服务器字体安装好模板里能用内联资源就尽量内联。环境越干净PDF 越可预测。3. 安装环境与第一份 PDF 的产出过程3.1 依赖环境与安装ponytail 是 Node.js 生态的插件所以机器上要先有 Node 环境我个人用的是 14 以上的版本太老版本对 Promise 语法支持不友好建议至少 12 往上。安装命令很简单npm install ponytail --save有一点要提醒这类插件通常依赖一个真正的浏览器内核所以首次安装或首次运行的时候可能会触发二进制文件的下载。这个下载过程跟网速关系很大有时候等得让人心慌。如果你在公司内网环境可能会遇到下载超时或者被安全策略拦住的报错解决办法一般是让 IT 同事把下载域名加入白名单或者在内网私有仓库里放一份离线包。如果安装过程卡在二进制下载上不要反复重启安装命令先确认网络再看安装日志里具体卡在哪个 URL。我见过不少同事在这步反复折腾最后发现只是公司网络策略问题。3.2 最小示例把 HTML 字符串变成 PDF 文件装好之后跑一个最简例子先建立心理预期。下面这段代码把一段含样式的 HTML 转成first.pdfconst ponytail require(ponytail); const html !DOCTYPE html html head meta charsetutf-8 style body { font-family: sans-serif; padding: 20px; } h1 { color: #333; } /style /head body h1这是测试文档/h1 p如果能正常打开这个 PDF说明 ponytail 已经跑通了。/p /body /html ; ponytail.convert({ html: html, output: first.pdf, format: A4, orientation: portrait }).then(() { console.log(PDF 已生成); }).catch((err) { console.error(生成失败, err); });这段代码跑通之后整个工具的信任感就建立起来了。不管后续遇到多少坑至少核心链路是通的后面的问题都是细节调优。有一点要说明不同版本的 ponytail API 可能略有差异有的版本用convert有的版本可能用create加toFile以你在用的版本 README 为准。我说的这个写法是项目里实测可用的版本。3.3 配置项逐条说明用 ponytail 一段时间后我觉得最有必要搞清楚的就是配置项。下面我把常用的几个配置项按优先级列出来。配置项作用我的建议format纸张大小A4、A3、Letter 等国内业务基本都选 A4orientation横向还是纵向订单、发票默认 portraitmargin页面边距支持20mm这种写法至少留出页眉页脚空间scale整体缩放比例默认 1.0小范围调整用waitUntil什么时候认为页面加载完成有异步内容时调到 networkidleheader/footer页眉页脚模板注意边距要匹配否则重叠zoomFactor类似浏览器缩放级别需要放大页面细节时使用waitUntil是这里面最影响结果的一项。页面加载完成后事件触发时机有几种DOM 解析完成、页面所有资源加载完成、网络连接空闲。默认情况下页面一加载完就开始输出 PDF但如果页面里有一段代码是异步从后端拉数据再渲染默认时机就会过早PDF 里会出现空白区域。后面我会重点讲这个坑。4. 把模板、数据和样式组装起来的实战写法4.1 目录结构与模板组织单页面的 HTML 转 PDF 很简单但真实业务中一个订单导出功能不会只有一张固定样式的页面所以我在项目里按下面的目录组织了模板。src/ templates/ invoice.ejs styles/ invoice.css pdf/ generateInvoice.js模板文件放templates样式文件单独放styles生成逻辑集中在pdf目录。这样做的好处是改排版不用动代码改样式不会误伤模板结构。团队里负责运营的同事要调整一下合计金额加粗直接改 CSS 就行完全不碰 Node 代码。模板引擎我选了 EJS主要原因是在 Node 生态里足够简单% %语法几乎没有学习成本。Nunjucks 也可以功能更全但对这个场景来说有点重。4.2 订单发票示例完整代码下面是一个简化但完整的订单导出例子。先看模板文件invoice.ejs!DOCTYPE html html head meta charsetutf-8 link relstylesheet href/styles/invoice.css /head body div classinvoice-header h1销售订单/h1 span classorder-no订单号% orderNo %/span /div div classcustomer-info p客户名称% customer.name %/p /div table classitems thead tr th商品名称/th th数量/th th单价/th th小计/th /tr /thead tbody % items.forEach(function(item) { % tr td% item.name %/td td% item.quantity %/td td% item.price %/td td% (item.price * item.quantity).toFixed(2) %/td /tr % }); % /tbody /table div classtotal 合计% total % /div /body /html生成 PDF 的代码里我先把模板内容读出来再用 EJS 渲染数据最后交给 ponytailconst ejs require(ejs); const fs require(fs); const path require(path); const ponytail require(ponytail); const invoiceData { orderNo: NO20250317001, customer: { name: 张三 }, items: [ { name: 办公椅, quantity: 2, price: 399 }, { name: 台灯, quantity: 1, price: 129 } ], total: 927.00 元 }; const templatePath path.join(__dirname, ../templates/invoice.ejs); const template fs.readFileSync(templatePath, utf-8); const html ejs.render(template, invoiceData); ponytail.convert({ html: html, output: invoice-${invoiceData.orderNo}.pdf, format: A4, margin: { top: 20mm, bottom: 20mm, left: 15mm, right: 15mm } }).then(() { console.log(发票 PDF 已生成); }).catch((err) { console.error(生成失败, err); });这里有个容易被忽略的细节模板里我引用了外部 CSS 文件/styles/invoice.css。如果 ponytail 加载 HTML 时没有设置 base 路径或者对应的资源映射这个 CSS 很可能加载不到最后生成的 PDF 就是无样式裸排版。解决方法是把 CSS 直接内联到 HTML 里或者在调用时配置好静态资源根目录。我的习惯是生产环境直接内联省得踩资源路径的坑。4.3 页眉页脚与页码一个隐藏的坑订单 PDF 通常要带页眉页脚比如左上角放公司 logo底部放页码。很多人一上来就踩坑我就是其中之一。问题是页眉并不是往页面上插入一段 HTML 那么简单它渲染在页面边距区域里。如果你只设置了header模板但margin.top设得很小页眉会和正文内容重叠看起来就像两行字叠在一起。正确的做法是给顶部留足空间ponytail.convert({ html: html, output: invoice.pdf, format: A4, margin: { top: 25mm, bottom: 25mm, left: 15mm, right: 15mm }, header: { height: 15mm, contents: div stylefont-size:12px;公司名称 | 订单导出/div }, footer: { height: 15mm, contents: span第 {{page}} 页 / 共 {{pages}} 页/span } });页码占位符{{page}}和{{pages}}是底层引擎约定的变量不同版本可能有差异有的用{page}有的用{{page}}。如果你按文档写了页码却一直原样显示基本就是占位符版本对不上。我当时调这个小问题花了大半个小时最后翻了底层模板源码才确认是占位符写法的问题。5. 我在实测中踩过的坑和对应解法5.1 中文乱码与字体环境中文乱码是这类插件最高频的问题没有之一。我第一版模板生成出来的 PDF所有中文全部是方框英文和数字正常。排查思路是有顺序的先看 HTML 有没有声明编码再看系统有没有中文字体最后看 CSS 字体栈。解决后我的模板固定为以下写法meta charsetutf-8CSS 里这样写body { font-family: PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif; }还有一个操作层面的关键点如果你用的是 Linux 服务器务必检查有没有安装中文字体。有的精简系统连一个中文都没有。我当时的做法是在 CI 脚本里加了一步字体安装确保部署环境一定能渲染中文。还有一种极端情况模板里用了特殊字体比如某个字体文件只有 WOFF 格式底层内核不支持 WOFF2或者字体文件跨域加载失败。这种就只能把字体文件转成 base64以font-face的方式内联进 CSS。效果很好但文件体积会膨胀我一般只在 logo 或标题字体上这么干。5.2 分页把标题和表格切断第二高频的坑是分页切得没脑子。一个标题孤零零留在页面底部或者一个商品表格从第三行中间断开客户拿到这种文件体验很差。CSS 分页属性专门解决这个问题。我在项目里加了这么一段h1, h2, h3 { page-break-after: avoid; } table { page-break-inside: auto; } tr { page-break-inside: avoid; } .product-list { page-break-inside: avoid; }解释一下page-break-after: avoid是说标题后面不要紧跟分页符避免标题出现在页面最底部page-break-inside: avoid是尽量保持某个元素完整不要让表格行被劈成两半。新版内核也支持break-inside建议两个一起写兼容性更稳。有了这些规则后分页位置虽然不可能完全精准但至少不会出现标题孤悬页尾表格行拦腰截断这类低级问题。5.3 图片不显示订单模板里通常有商品图片。我第一次测试时本地开发环境跑得好好的到了服务器上图片全没了。后来排查出三个原因按出现概率排序第一图片走的是 HTTP而页面整体被当成 HTTPS 环境加载浏览器默认拦截混合内容图片直接不显示。第二图片是相对路径比如./images/product.jpg但 ponytail 加载 HTML 字符串时并不知道这个相对路径的根目录在哪里。第三图片太大加载太慢页面在图片没加载完时就生成了 PDF。最稳妥的解法是把图片转成 base64 内联到 HTML 里。简单场景下代码也好写function imageToBase64(filePath) { return data:image/jpeg;base64, fs.readFileSync(filePath).toString(base64); }如果图片是远程 URL那要确保服务器能访问到那个地址并且协议一致。如果只是加载慢的问题就把waitUntil调整为网络空闲再输出 PDF。5.4 动态内容没加载就生成 PDF我踩过最隐蔽的坑是金额数据。订单模板里有一部分金额是前端异步算出来的页面加载后要等一两秒才显示。第一次生成的 PDF 里那个区域是空白用户反馈过来我还一头雾水。这个问题的根源在于输出时机。页面 DOM 一就绪ponytail 就开始打印了但异步接口可能还没返回渲染函数还没把数据写进页面。解决办法有三层第一层最理想的是把数据在模板渲染阶段就全部注入避免运行时的异步渲染。第二层如果必须依赖异步把等待条件设置好用waitUntil: networkidle。第三层针对特别慢的接口可以在页面里加一个自定义事件脚本加载完数据之后触发事件再用额外配置等这个事件发生。我在实际项目里把数据填充尽量前移到了 EJS 模板阶段服务端拿到所有数据后直接渲染 HTML字符串页面里不跑任何异步逻辑。这样生成的 PDF 又快又稳几乎没有空白内容问题。5.5 页面白屏与 CSS 兼容性还有一个让人摸不着头脑的故障生成的 PDF 完全是白纸打开一看啥都没有。白屏的第一嫌疑是 JavaScript 报错。页面加载时某个脚本抛了异常后续渲染全部停摆。排查方法是把同一段 HTML 在本地浏览器里打开打开控制台看报错信息。很多问题在浏览器里一目了然在无头环境里却完全隐形。第二嫌疑是 CSS 兼容性。一些新特性语法在老内核上不支持导致布局直接塌掉。比如某些grid布局写法老内核解析不了整个页面就变成一坨。出问题时先用最朴素的 CSS 跑通再逐步加效果能快速定位到是哪条样式导致的。我在第 5 章前面列的这些坑几乎每一类都能对应到一个具体故障现场。如果你在集成 ponytail 时也遇到类似诡异现象按这个顺序排查比对着文档一个个试要快得多。6. 并发、超时与长期维护建议6.1 进程模型与并发限制ponytail 用起来方便但并发场景下需要有一个清醒的认识它的每一个实例都相当于拉起一个完整浏览器内核内存占用不是一个轻量级库该有的样子。我在本地电脑测试时没感觉部署到 2G 内存的小服务器上同时来了几个导出请求服务直接卡死。后来监控发现内存瞬间飙高才知道不能无限制地新建实例。所以实际项目里要做两件事第一尽量复用同一个实例避免每次请求都重新初始化第二生成任务要有队列限制同时执行的并发数量。我的经验是单机同时跑两三个任务就差不多了再多就容易触发内存问题。6.2 队列、超时与重试我后来用最简单的方式实现了一个任务队列核心逻辑无非是同一时间只跑一个任务其余排队const queue []; let running false; function enqueue(task) { queue.push(task); runNext(); } async function runNext() { if (running) return; if (queue.length 0) return; running true; const task queue.shift(); try { await task(); } catch (err) { console.error(PDF 生成任务失败, err); } finally { running false; runNext(); } }实际业务中每个任务内部还要加超时控制。我习惯设为 60 秒超时超过就终止并抛出错误。因为页面一旦卡死在某个资源上不设超时就会一直占着任务名额后面所有导出都会堵住。重试策略也要考虑。某些失败是暂时性的比如某个 CDN 图片瞬时超时整体任务重试一次可能就成功了。我一般只重试两次超过就放弃把错误记录到日志里避免无限重试拖垮系统。6.3 什么时候应该换 Puppeteer / Playwright最后说点不中听但实在的话ponytail 底层使用的内核已经很久没有大更新了维护状态不算活跃。如果你只是在小项目里快速解决HTML 出 PDF的问题它够用但如果这是一个会长久演进的产品我建议在封装层做好更换引擎的准备。Puppeteer 和 Playwright 是更现代的方案基于 ChromiumAPI 更丰富社区活跃度高各种新特性支持也更快。我对比过它们的差异对比项ponytailPuppeteer / Playwright内核维护状态较老更新慢Chromium 持续更新API 丰富度够用偏基础支持复杂交互、多页面、截图等内存占用启动实例较重常态支持浏览器实例复用社区生态资料较少文档、示例、踩坑帖都很多迁移成本低适合现有系统新项目首选老系统迁移需测试我给团队的建议是新项目直接上 Puppeteer 或 Playwright别再从 ponytail 起步了。已经用 ponytail 跑起来的系统只要稳定就别急着推翻但一定要把调用逻辑封装在一个文件里后面要换引擎时只改这一个文件就够了。我后来其实把项目的 PDF 生成逻辑重构成一个独立服务模板、字体、渲染引擎都收在容器里。换引擎的时候发现只要模板层写得足够规范底层引擎换成什么影响并没有想象中那么大。说到底不管是 ponytail、Puppeteer 还是别的方案决定 PDF 质量的从来不是工具本身的 API而是你把 HTML 模板组织得是否清晰、CSS 分页属性用得是否到位。这次的经历让我彻底认同了这一点。