最近在给内容管理后台做"文章导出中心",第一个需求就是标题里那句:"把富文本编辑器里的正文,导出成 html 静态页面和 pdf 格式文件"。听起来很简单对不对?一个按钮,前端拿到编辑器里的 HTML,拼个文件,下载,完事。真做起来才发现,这里面的坑比想象中深得多:编辑器输出的脏标签、图片的懒加载、样式依赖、打印分页、中文字体、带页码的页眉页脚……随便一个环节不注意,导出来的文件就不堪用。
这篇文章就把我从需求分析到最终交付的完整过程拆开来讲:为什么"导出HTML"和"导出PDF"是两个截然不同难度的工作,富文本内容在导出前要做哪些清洗,静态页面怎么做才叫"静态",PDF 走哪条技术路线最靠谱,以及最后我在真实项目里踩过的几个典型问题。适合正在做内容平台、知识库、OA 系统、文档导出功能的工程师参考,也适合产品经理看完之后理解为什么这个需求不能"排两天就上线"。
1. 需求拆解:用户要的其实是"所见即所得地搬家"
接到需求的那一刻,我第一反应也是写个按钮就行。但坐下来仔细捋了一遍真实使用场景,发现"导出"这个动作背后藏着好几个完全不同的诉求。
1.1 三类典型场景决定了完全不同的技术路线
先看用户到底拿导出文件去干什么。以我做这个项目时调研到的实际用法来看,基本是三类。
第一类是内容归档与分发。运营编辑想把平台上的文章下载下来发给外部合作方,或者放在本地留档。这时候导出的 HTML 必须是一个完整的、可独立打开的网页,不能依赖我们后台自己的 CSS 和 JS;图片也不能是临时授权的链接,否则对方打开就是一堆裂图。
第二类是线下查阅与打印。财务、法务、管理层喜欢把文章或合同导出成 PDF 打印出来,或者加批注后走审批。这种场景下,PDF 的分页逻辑、页边距、页眉页脚就特别重要,你不能让一张表格刚好被劈成两半。
第三类是跨平台阅读。有人想放到网盘里用手机看,有人想转成文档格式再编辑。HTML 和 PDF 之所以是"最通用"的交付格式,就是因为它们几乎在任何设备、任何操作系统上都能打开,不像 docx 那样依赖 Office 环境的版本一致性。
把这三类场景摆在一起,就能看出一个核心结论:用户要的不是"文件格式转换",而是"内容搬家",搬家的时候还得把样子原封不动搬过去。这俩的难度天差地别。
1.2 外行看的是格式,内行看的是"依赖"
我让产品经理用一句话描述需求时,他说的是"把富文本内容变成 html 和 pdf"。但作为工程实现者,我脑子里自动把这句话翻译成了几个必须回答的问题:
- 编辑器里的 HTML 是标准的还是带了一堆私有 class 的?
- 图片资源是外链、base64 还是对象存储私有链接?
- 导出的 HTML 是单个文件、还是需要连 CSS 资源一起拷贝?
- 样式是内联的还是一份独立样式表?
- 打印时 A4 页面能不能放得下当前编辑器的内容宽度?
- 字体是 web 字体还是系统字体库里有?中文环境下缺字怎么办?
这些问题如果不在需求阶段问清楚,开发到一半必然返工。我这里给出的建议是:接到这种需求,先拿着编辑器实际产出的 HTML 源码跑一遍完整流程,再做方案选型。拿真实数据说话,比讨论任何框架和库都实在。
2. 编辑器内容到"干净HTML"这一步,是整条链路的生死线
不管前端用的是主流的富文本编辑器,还是自己封装的,浏览器里显示的其实都是 DOM。而我们要导出的"干净 HTML",指的是去掉编辑器私有逻辑之后、可以被任意静态环境渲染的语义化 HTML。这一步做不到位,后面所有环节都是白忙。
2.1 编辑器输出到底有多"脏"
我在测试环境里随便写了一篇带标题、列表、表格、引用、代码块、图片的文章,然后把编辑器输出的 HTML 源代码拉出来看,问题集中在三处。
第一是私有 class 满天飞。编辑器为了自己定样式,会在每个段落、每个列表项上挂一堆类名。静态导出时,这些类名对应的 CSS 根本不存在,如果不处理,导出的页面打开后要么样式全丢,要么呈现一种"猜猜看"的随机排版。
第二是空标签和嵌套垃圾。连续回车会生成空段落<p></p>,复制粘贴会带进一堆<span style="...">层叠嵌套,还有莫名其妙的内联font-family、font-size。这些标签在编辑场景里藏在可视层下面看不出来,一旦导出成纯 HTML,就会把页面搞得很糟糕。
第三是结构不语义化。编辑器输出的有些标题用了<span style="font-weight: bold; font-size: 24px;">来冒充,而不是真正的<h1>。这在 HTML 里还能勉强看,但在打印和 PDF 分页时,浏览器和 PDF 渲染器对标题的感知就全乱了。
2.2 我用的清洗策略:白名单过滤,而不是黑名单
做 HTML 清洗时,新手容易用"删掉这些标签",也就是黑名单方式。但 HTML 标签千奇百怪,黑名单永远堵不完。我采用的方式是白名单归一化:只保留确定安全的标签和属性,其他一律剔除。
我整理了一份最小的白名单,核心就是这些:
- 块级:
p, h1, h2, h3, h4, blockquote, pre, ul, ol, li, table, thead, tbody, tr, th, td, hr, div - 行内:
a, strong, em, b, i, u, code, br, img, span(span 只保留无样式或可映射样式的情况) - 属性:
href, src, alt, title, colspan, rowspan, style(style 需要再处理)
清洗之后再做一步标签语义化:把那些用span + style冒充的标题、加粗、斜体,按照样式特征还原成h1-h4、strong、em。这一步非常关键,因为后面做 PDF 时,目录生成和分页控制都要依赖真实的标题标签。
2.3 图片资源内嵌:静态化的第一道坎
"静态 HTML 页面"的硬要求是:整个文件拿出去,到任何电脑上双击打开,效果必须和在线预览一致。所以图片绝对不能是后台服务的临时链接。我测试过三种方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 外链保留 | 实现快、文件小 | 脱离内网或链接失效就裂图 | 内部白名单网络环境 |
| base64 内嵌 | 单文件、绝对静态 | 体积膨胀约 33%,大文章吃力 | 归档、分发,图片少时 |
| 下载图片到本地目录 | 文件不大、真静态 | 需要同时交付文件夹,不方便单文件分发 | 对体积敏感的场景 |
我最终默认选了 base64 内嵌,用前端把图片转成 DataURL,或者在后端用工具把图片读取后转成 base64 塞进src。图片多的场景则降级为"图片外链保留 + 导出时同步打包图片目录"的双轨策略。
这一步的教训是:千万不要在需求描述模糊时替用户决定图片策略。后来我们跟业务方确认后才知道,他们最常用的场景是把 HTML 文件放进内网资料库长期保存,外链图片第二天就失效了,幸亏没有只做外链方案。
3. HTML静态导出的三种组装路线:我为什么选了模板+内嵌样式
把清洗后的正文片段拿在手里,下一步是把它组装成一个完整的 HTML 文件。这一步有三条路线,工程界很多项目都在这上面吃过亏,我逐个说清楚。
3.1 路线一:直接拼剪贴板式 HTML
最粗糙的做法是:<html><head>...拼一段,中间塞编辑器输出的 HTML 原文,再拼</body></html>。这样做出来的文件在浏览器里能打开,但样式是乱的,因为编辑器输出的 class 对应的 CSS 没跟过来,正文里的文字可能直接变成默认字体一排排排下去,表格、引用、代码块全部"裸奔"。
只适合内部临时预览,不适合交付。
3.2 路线二:连 CSS 一起打包,但依赖外部样式表
稍微进阶一点的做法是,把后台预览页用的那套 CSS 文件一起导出到同目录下,HTML 里用相对路径引用。看起来比剪贴板好,但问题在于:
- 你只搬了一部分 CSS,却不知道编辑器内部渲染还藏了哪些局部样式;
- 只要少一个文件,整个页面样式崩溃,而且这类问题在交付前很难全面暴露;
- 对"单个文件分发"这个需求完全无效。
3.3 路线三:模板渲染 + 关键样式内联(我用的是这个)
我最终走的路线是:后端(或前端)准备一个导出专用 HTML 模板,模板里将所有关键样式内联到<head>的<style>中,正文区域使用清洗后的语义化 HTML 片段替换。关键样式指的就是那些不随正文变化、但是影响整体观感的规则——正文排版、表格边框、代码块背景、引用块样式、打印分页规则、页眉页脚样式。
这样做出来的文件有三个好处:
- 单文件、离线可打开、无外部依赖;
- 模板由我们自己完全控制,不会因为编辑器内部样式泄漏而失真;
- 导出 HTML 和后续 PDF 的排版基础是同一套模板,样式一致性有保障。
组装伪代码大概是这个思路(前端负责做文本拼接,格式示意):
const cleanBody = sanitizeHtml(editor.getContents()); const fullHtml = ` <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>${escapeHtml(title)}</title> <style>${exportCss}</style> </head> <body> <main class="article"> <h1 class="article-title">${escapeHtml(title)}</h1> ${cleanBody} </main> </body> </html> `; const blob = new Blob([fullHtml], { type: 'text/html;charset=utf-8' });这里有几个容易忽略的细节:
charset必须显式声明为 UTF-8,否则中文在部分系统默认编码下会乱码;- 标题文本要经过 HTML 转义,防止标题里出现
<、>、引号时破坏页面结构; - 模板里的
<style>要包含@media print规则,因为后面导出 PDF 时也要复用; - 导出模板和在线编辑器的视觉呈现,最好用同一套设计 token(间距、字号、配色、边框),不要让同一种正文在导出后像换了套皮肤。
4. PDF导出的三条主路与我的选型判断
HTML 做完后,PDF 还远不是"另存为"那么简单。为了把文章正确变成 PDF,业内常用三类方案,我在这里直接给对比结论,然后说说我为什么在真实项目里选了其中一种。
4.1 浏览器打印到PDF:零依赖,但控制力有限
第一种是调用浏览器的打印能力:把导出的 HTML 弹到新窗口,调用window.print(),用户在弹出的打印对话框里选择"另存为 PDF"。这种方式的好处是零依赖、所见即所得,但问题也很明显:
- 分页控制能力弱,表格、代码块、图片很容易被拦腰截断;
- 页眉页脚、页码、水印这类需求实现成本高,而且不同浏览器表现不一致;
- 无法在无人值守时批量生成,每个文件都要人工操作一次。
这套方案被我定位为"临时抽查、应急交付"的手段,不当作正式功能。
4.2 无头浏览器渲染 PDF:服务端可控,格式保真
第二种是当前主流方案:在服务端用无头浏览器加载导出的 HTML,等页面完全渲染后调用其打印接口输出 PDF。代表实现是 Puppeteer 这类库。它能做到:
- 完整渲染 CSS 和图片,和浏览器里看到的基本一致;
- 通过
page.pdf()的选项控制纸张大小、页边距、页眉页脚模板、是否打印背景; - 可以在生成前等待图片加载完成、等待字体加载完成,解决"导出空白"的问题。
代价是:服务端需要安装相应依赖和系统字体,尤其是中文字体,否则导出的 PDF 里中文会变成方框;同时渲染节点会比较吃内存,并发量大的时候需要做队列控制。
4.3 前端截图生成PDF:适合预览图,不适合正式文档
第三种是纯前端方案:先用截图类库把 HTML 区域截成图片,再逐页放入 PDF。这套方案对页面复杂内容的还原度不错,但致命问题在于:PDF 里是图片而不是文字,没法复制、没法搜索、文件体积大、清晰度受截图分辨率限制。在正式公文、合同、资料归档场景下,几乎不可用。
对比表格如下:
| 方案 | 文字可选/可搜索 | 分页控制 | 批量生成 | 服务端依赖 | 适用场景 |
|---|---|---|---|---|---|
| 浏览器打印 | 是 | 弱 | 否 | 无 | 个人临时导出 |
| 无头浏览器 | 是 | 强 | 是 | 需Node/字体 | 正式批量导出 |
| 前端截图 | 否 | 中 | 是(但慢) | 无 | 页面快照、分享图 |
我最终选择的是无头浏览器方案。判断逻辑很简单:我们要做的导出功能是给运营、法务、财务日常使用的,输出物会进入合同、存档、审批流程,文字必须可选可搜索,分页必须可控。只有服务端无头渲染能满足这套严苛标准。
5. 完整落地链路:从编辑保存到双格式产物的全流程
下面按我可以直接复用的流程走一遍,这个链路就是我在正式项目里跑通的版本,你照着搭基本不会跑偏。
5.1 核心流程步骤
内容入库时保存两份:编辑器原始 HTML 和清洗后的语义化 HTML。清洗要放在保存时做,而不是导出时做——导出时再做的话,内容一旦调整,历史版本就会受影响。
导出时先拉取最新正文,套用 HTML 导出模板,生成一个完整的 HTML 字符串。
处理资源:把正文里的图片地址逐一请求后转成 base64;如果图片数量超过阈值(比如 20 张),切换为"HTML + 图片目录"双文件导出。
生成 HTML 文件。这一步如果只是在线预览,直接把 HTML 字符串塞进 Blob 让前端下载即可。
PDF 生成:把同一个 HTML 模板交给服务端无头渲染任务。渲染时传入标题、正文片段、生成时间,渲染完成后产物下载或存到对象存储。
校验与降级:检查 PDF 页数是否为 0、是否缺失图片,若有异常则自动返回错误,而不是交付坏文件。
5.2 PDF 渲染的关键配置(经验值)
无头浏览器的pdf()配置里,下面这几个参数是我反复调试后的最终选择,直接粘走能省很多事:
await page.pdf({ format: 'A4', printBackground: true, margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }, displayHeaderFooter: true, headerTemplate: ` <div style="width:100%; text-align:right; font-size:9px; padding-right:15mm;"> <span class="title"></span> </div> `, footerTemplate: ` <div style="width:100%; text-align:center; font-size:9px;"> <span class="pageNumber"></span> / <span class="totalPages"></span> </div> ` });三个关键点:
printBackground: true一定要开,否则表格表头、代码块背景色全会丢失,PDF 一片惨白;- 页眉页脚模板里的字体要显式设置,不然默认字体很小且样式不统一;
format: 'A4'和 margin 的设置直接影响分页密度,建议在开发环境用一篇文章多滚动几屏测试后再定。
5.3 渲染前必须等待的三件事
无头浏览器渲染 PDF 时最容易踩的一个坑是:页面还没加载完就开始打印,导致图片空白或懒加载未触发。我在封装渲染函数时,强制做三步等待:
// 等待页面主体内容挂载 await page.waitForSelector('.article-content'); // 等待所有图片完成加载 await page.evaluate(async () => { const imgs = Array.from(document.querySelectorAll('img')); await Promise.all(imgs.map(img => img.complete ? Promise.resolve() : new Promise(resolve => { img.onload = resolve; img.onerror = resolve; // 失败也要放行,避免卡死 }) )); }); // 让渲染引擎再空转一小段时间 await new Promise(r => setTimeout(r, 500));这三步分别解决:内容未挂载、图片未加载完、字体/样式应用未完成这三个问题。实测下来,等待时间设太长拖慢速度,设太短容易出空图,500ms 是一个对大多数场景都友好的折中值。
6. 上线后踩到的实际问题与排查思路
到这里,整个功能看起来已经"能用了",但真正让它变成生产级代码的,是后来上测试环境后陆续踩出来的一串问题。我把几个典型的写出来,附带根因和排查思路。
6.1 表格和代码块被拦腰截断
测试同事反馈:一篇文章里有个 8 行的表格,导出的 PDF 里表格正好被从第 5 行和第 6 行之间劈开,阅读体验很糟。
根因是:无头浏览器默认允许在表格行之间分页。解决办法是在导出模板的<style>里加上打印保护规则:
@media print { table, tr, img, pre, blockquote { break-inside: avoid; } thead { display: table-header-group; } td, th { word-wrap: break-word; } }break-inside: avoid让表格、代码块、图片尽量完整地待在当前页;thead { display: table-header-group; }保证表格跨页时每页都自动带表头;word-wrap主要防止超长字符串把表格撑破。
6.2 图片全部变成空白占位
第一次交付测试 PDF 时,导出的文件里每张图的位置都是一片空白。查了很久发现是编辑器在内容里对图片开启了懒加载,图片真实地址是写在>// 伪代码:清洗时对图片标签做一次规整 imgTag.replace(/src="data:image\/gif[^"]*"/, ''); imgTag.replace(/data-src="([^"]+)"/, 'src="$1"');
6.3 大文章导出超时
压测时,一篇带 50 张高清图片的长文,在无头渲染节点上导出 PDF 超时了。排查发现两个瓶颈:一是图片转为 base64 时内存暴增,二是一次性渲染 50 张大图导致渲染进程卡顿。
处理办法是把渲染任务拆成队列,控制在渲染浏览器实例的同时打开数量,并在转 base64 环节加上大小限制——单张超过 1MB 的图片用压缩后版本,避免把大图二进制塞进 HTML。
6.4 中文在某些环境变成方框
在开发机导出 PDF 一切正常,部署到生产的 Linux 容器后,导出的 PDF 里所有中文全部变成了空心方框。这个坑最隐蔽,也最典型:无头浏览器渲染时用的是系统字体,如果系统里没有中文字体,字形就无法显示。
排查时我先在容器里渲染了个只含中文的测试页,导出的 PDF 同样乱码,基本锁定了字体问题。验证方法更简单:
fc-list :lang=zh查系统已安装字体,如果没有中文字体,安装一套开源中文字体(比如思源黑体或文泉驿),重启渲染服务即可。这里要特别提醒:不要在服务端用"某个具体字体名"硬编码,应该先确认容器已安装可用字体,再在 CSS 里用字体栈(font-family: "思源黑体", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif)柔性降级。
7. 一点收尾的个人体会
这套双格式导出功能上线后,我们内部实际用得最多的反而不是"批量导出"按钮,而是那套"同一个模板同时出静态页和 PDF"的复用机制。编辑在后台点一次按钮,产出一个可归档的 HTML 和一个可打印的 PDF,两份文件的排版完全一致,省掉了之前"编辑器里改一遍、Word 里再排一遍"的重复劳动。
做这个项目最大的体会是:导出的核心难点从来不是"生成文件",而是"让生成的文件在脱离原有系统之后仍然保持可用"。任何依赖系统内部资源的做法,最后都会在交付现场变成问题。所以我现在做同类需求时,第一个问的问题不是"用什么格式存",而是"这个文件会被拿到什么环境里打开"。想清楚这个问题,技术选型基本就定了一大半。
一个小技巧收尾:如果你也要做类似功能,开发阶段可以先把导出的 HTML 文件交给不使用你们系统的同事打开,让他在没有本地账号、没有内网权限的环境里给你反馈。他碰到的问题,才是这个功能真正需要解决的问题。