前言
最近在做项目的时候,遇到了在后端生成PDF的需求,而且生成的页面较多,且样式管理相对复杂,还原度要求较高。通过一系列的社区调研后发现,
Puppeteer
这个Node库可以提供一个相对高级的API,以DevTools协议控制Chrome
或者Chromium
,其无头模式可以将HTML转为PDF。这是目前Node Server
应用中最为主流的解决方案,但是在使用过程中还是会存在很多坑点和注意事项。因此本文围绕无头浏览器
、Puppeteer
、HTML转PDF
等几个方面进行总结和介绍。
一、无头浏览器
1.1 无头浏览器的基本理解
在wiki百科
中,无头浏览器指的是没有图形用户界面GUI的浏览器。
无头浏览器运行在类似于常规网络浏览器的环境中,同时提供对网页的自动控制,由于其没有图形用户界面,因此无头浏览器通过命令行界面或使用网络通信来执行。
无头浏览器对于测试网页、爬虫等场景下能够发挥巨大的作用,因为它们能够像浏览器一样呈现和理解超文本标记语言,包括页面布局、颜色、字体以及JavaScript
和Ajax
的执行等样式元素,这些元素在使用其他测试方法时通常是不可用的。
综上所述,可以对无头浏览器的基本内含进行如下的总结:
- 它没有内容的真实呈现 ,也就是说,它绘制了内存中的所有内容。
- 它消耗的内存更少,工作更快 ,因为不需要绘制出可视化的图形界面,它不需要在实际屏幕上呈现任何内容,而尝试将其放入后端运行。
- 具有用于管理的编程接口 。例如:
Puppeteer
可以提供一个相对高级的API,以DevTools协议控制Chrome
或者Chromium
。 - 一个重要的特性是能够在裸露的Linux服务器上安装 。 这样,在全新安装的
Ubuntu
或CentOS
服务器上,则只需将二进制文件进行编译安装,无头浏览器即可使用。
在这里补充一点,Chrome
和Chromium
是2个玩意,是2个浏览器,他们大致的区别是:Chromium
是一个开源浏览器项目,它是ChromeWeb
浏览器的基础。具体差异参考下面这篇文章。
1.2 无头浏览器的应用场景
无头浏览器通常用来:
Web
应用程序中的测试自动化、JavaScript
库运行自动化测试- 对网页进行拍摄、截图、转PDF等操作
- 用浏览器自带的一些调试工具和性能分析工具帮助我们分析问题
- 抓取单页应用(
SPA
)执行并渲染(解决传统HTTP
爬虫抓取单页应用难以处理异步请求的问题) - 捕获站点的时间线跟踪以帮助诊断性能问题
- 收集网站数据(爬虫应用)
- 自动化网页交互、模拟用户行为(例如键盘输入、表单提交等)
- 用于发起一些恶意攻击QAQ
此处附上一篇文章,介绍的是基于Puppeteer控制的无头浏览器的反爬攻防:
当然无头浏览器不止一种,其他的无头浏览器检测相似,可以自行Google~
1.3 常见的无头浏览器
Puppeteer
操作的Headless Chrome
,基于Webkit
PhantomJS
,基于Webkit
SlimerJS
,基于Gecko
HtmlUnit
,基于Rhnio
TrifleJS
,基于Trident
Splash
,基于Webkit
这里需要注意一点,在笔者的理解中,Puppeteer
本质上不是无头浏览器,结合官方给出的定义:
Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the DevTools Protocol.(Puppeter是一个Node库,它提供了一个高级API来通过DevTools协议控制Chrome或Chromium)
因此他是用于操作、控制无头浏览器(如:Headless Chrome
)的Node
库,反过来Headless Chrome
理论上也应该可以由多种方式进行操作。
但是对PhantomJS
而言,官方给出的定义是:
a headless WebKit scriptable with JavaScript.(可使用JavaScript编写脚本的无头WebKit)
因此PhantomJS
属于无头浏览器~
接下来就主要对Puppeteer
进行介绍
二、Puppeteer的基本应用
2.1 官方上手资料
Puppeteer的整体架构如下:

其实基本上就是Chrome
的分层架构图。BrowserContext
是浏览器环境的一个会话(如果这个概念较难理解,那么如果用新开一个浏览器隐私窗口的行为做类比,一个BrowserContext
就是一个隐私窗口环境,各自不共享Cookie
、CacheData
这些),Page
就是一个新建标签页产生的浏览器页面,Frame
对应一个页面 Document
。
本章中主要对Puppeteer
的一些常见的API和操作进行简要罗列,不进行很具体地展开,因为整体调用相对容易上手,直接参考文档即可:
同时,官方提供了一个用于演示Puppeteer Demo的网站
在这个网站可以查看Puppeteer的执行效果
本地测试,直接npm i puppeteer
即可使用~
2.2 应用1:到指定Url将其页面存储为图片
本地执行:
1 | // example.js |
2.3 应用2:将网页(html字符串)存储为一个PDF
本地执行:
1 | // example.js |
2.4 应用3:在页面上下文中执行脚本
本地执行:
1 | // example.js |
2.5 应用4:代理
1 | // example.js |
2.6 应用5:自动提交表单
1 | // example.js |
除此之外还有很多应用,可以自行查找和探索~
三、Puppeteer在HTML转PDF的应用
由于最近在项目中主要使用的是PDF
生成相关的API
,遇到了不少坑点,因此下一章就对HTML转PDF
中常见问题进行总结~
项目场景是这样的:前后端分离的应用中,后端为
Koa
服务,基于Puppeteer
进行HTML转PDF
的应用,此处的HTML
并非Url
,而是通过ejs
进行模板渲染后读取出来的HTML字符串
。我们需要同时导出数十个
3.1 ejs模板引擎的使用
为什么要在本项目中使用ejs
呢?原因显而易见,我们需要动态渲染数据,但整体结构和样式固定,因此需要用到模板引擎,ejs
相对比较老牌,是中规中矩的技术选型,ejs的官方文档如下。
在ejs
与Puppeteer
的配合中,理论上有2种方案:
一种是,直接通过ejs
的renderFile
API进行ejs字符串
与数据的聚合,而后将生成的HTML字符串
传给Puppeteer
的page API
,进行PDF
的生成;
另一种是,把经过ejs
渲染后的HTML字符串
存为HTML文件
,将其挂载为Koa
的静态资源,这样就可以通过Url
访问到该HTML
,然后将Url
传给Puppeteer
的page API
,进行PDF
的生成;
两种方式Puppeteer
都是支持的,一个是接收HTML字符串
,一个是接收Url
,但相比而言,前者的效率要搞很多,因此实际项目应用中使用前者,核心代码如下:
1 | // 环境TypeScript |
3.2 ejs中外部资源文件的引入问题(css、js文件及图片文件)
如果在ejs
模板中,使用路径的格式调用资源,最终在Puppeteer生成PDF的时候是无法成功加载出静态资源的,例如:
1 | <script type="text/javascript" src="/public/js/echarts.min.js"></script> |
因为此时的路径环境已经发生了变化,如何解决呢,有两种方式:
- 将资源文件上传到CDN或者一些
对象存储服务
(如腾讯的COS
,阿里的OSS
),然后获取资源链接,进行替换,但前提是项目支持外网资源调用; - 当项目不可以调用外网资源的时候(本项目就是这样),我们只能将静态资源文件挂载到当前的Server中;
在Koa
中Koa-static
可以为我们挂载静态资源。
在本项目中,需要启用Koa
的多静态资源路径,一个分配给前端的打包文件,另一个分配给后端使用到的静态文件(如ejs
引入的外部文件)。这些需要用到另一个npm
包koa-mount
,如果在项目中开启了路径的权限验证时,记得将这些静态资源的权限释放出来,部分核心的设置代码如下:
1 | // TypeScript环境 |
本项目基于typescript
进行构建,javascript
的构建方式类似,可以参考addRouter
中的设置方式~
最后的效果就是,通过xxx.com/login
这样的路径可以映射到前端入口,通过xxx.com/public/images/xxx.png
可以映射到后端的静态资源,css文件
、js文件
、font
文件等都是同样的道理。
之所以这样做,是因为项目目录结构的问题,为了方便开发,在本项目中,前端源文件放在了后端的目录列表中,方便在前端npm run build
打包后,可以无缝更新到后端所指向的前端dist
目录中,而不需要手动更新dist
。
ejs
相关的静态资源文件是后端的模板渲染用到的,因此不能放在默认的dist
静态资源目录中,否则只要前端打包,那么后端的这些文件就会被自动删除。因此他们独立于前端,应该放在一个新的public
静态资源目录中,这样以来Koa
就需要启用2个静态资源目录。
整体的目录结构大致如下,client
中即为前端的源代码,同时包含有dist
资源包;public
为后端静态资源依赖~

上述配置好之后,在ejs
文件中,就以url
的形式进行资源调用。为了进行动态配置,这里的https://www.xxx.com
,就配置成了resourcesUrl
这个参数进行传递:
1 | <script type="text/javascript" src="<%= resourcesUrl %>/public/js/echarts.min.js"></script> |
3.3 Puppeteer中的字体失效问题
通常在html、css的样式书写中,当网页需要指定字体,
如果我们直接将font-family
设置为Microsoft YaHei
,而不进行其他处理,那么对于一些内置微软雅黑字体的浏览器,页面呈现效果,就是微软雅黑;而部分浏览器没有内置微软雅黑字体那么,呈现出来的就是浏览器的默认字体。
为了避免这种情况,我们通常会在资源文件夹中存储相关的字体文件,然后以类似下面的方式进行调用:
1 | @font-face { |
但是在Puppeteer
应用中,这样使用是会出问题的,因为Puppeteer
生成PDF
最终依赖的是操作系统层级的字体库,也就是说系统中安装了什么字体,我们在css
中就可以调用什么字体,且名字必须一致。这个听起来挺离谱的,但我在项目实践过程中确实是这样的,试了很多种方式都没有解决,最后发现与系统字体有关系。
通过查看文档,大致推测,这个现象是与Puppeteer
对于chromium
的依赖引起的,而chromium
直接依赖于底层的OS
。
也就是说,我们只要在系统层级安装好字体库,那么这个问题就可以解决。
3.4 Puppeteer在Linux中部署存在的问题及Docker的应用
那么新问题又来了,绝大多数情况下,我们部署的Server
都是Linux
操作系统,Linux
系统对于字体的安装与Windows
或macOS
截然不同,而开发环境往往又是windows
或macOS
。
对于Linux
中的字体安装问题,可以参考下面这篇文章,或者参考下面的Dockerfile
中的执行步骤:
当在公司的自研上云平台进行部署时,很容易会产生本地开发与线上部署出现不同效果甚至报错的情况,因此就引出了Docker
的应用,原本在引入Puppeteer
之前整个项目似乎不太需要Docker
,但事实证明,长线项目开发还是把Docker
用起来,会方便很多~
除了字体的问题外,Puppeteer
在Linux
的应用中也会出现chromium
报错,这里需要单独对chromium
进行安装。
把上述所有的操作进行梳理后,就形成了下面的Dockerfile
,在文件中对命令都进行了注解,在实际项目开发中可以参考,其他的配置参考3.9
。
1 | # 此处填写基础镜像的地址 |
这样以来,开发环境和生产环境就会完全一致~
3.5 页眉、页脚、页码的注意要点
Puppeteer
提供的页眉页脚方案,是通过设置headerTemplate
和footerTemplate
来实现的,将两者设置为HTML
字符串,然后传递给page.pdf()
中。- 通过
margin
参数设置页面的边距,这里留出来的边距,就是headerTemplate
和footerTemplate
的展示空间。 headerTemplate
和footerTemplate
中不支持使用路径和url的形式调用图片资源,那如果需要展示img该怎么办呢?将img尽可能压缩后,转为base64
,放在src
中,就可以正常显示。headerTemplate
和footerTemplate
不支持css
的background
,如需进行丰富样式的页眉页脚设计,就需要把背景转为img,再放进去。headerTemplate
和footerTemplate
设置的页眉和页脚,并不在html的dom流
中,他们不属于<html/>
也不属于<body/>
,html的dom
会自动跳过这片区域,与word
类似。因此无法在html
文件中的css
中控制其样式,只能将其样式写入Template
的字符串中,作为行间样式。headerTemplate
和footerTemplate
设置的页眉和页脚,默认会有一些偏移,因此需要在行间样式额外指定margin-top
、margin-bottom
来进行位置调整。- 目前经过实践,在
Puppeteer
的应用中我们没法在除了headerTemplate
和footerTemplate
的其他地方获取到page
的页码,headerTemplate
和footerTemplate
提供了默认的页码显示支持,将span
标签的class
设置为totalPages
为总页数,设置为pageNumber
为当前页数。
3.6 Echarts或图片发生跨页断裂时的解决方案
对于整个页面生成来说,长图片跨页断裂的情况很难避免,在word
这样的高级操作软件中,官方把问题抛给了用户,直接不允许长图跨页,因此用户就只能手动切分,或者是将图片缩小。
在Puppeteer
中发生断页,会是这样的效果(有页眉页脚和margin
配置):
但是对于复杂的动态PDF生成场景而言,我们没法人为干预切分或缩小,这样会影响到本身预计的效果,或者是导致过高的开发成本。word
页面本质上是静态的,也就是说,用户必须把每一页固定下来,不会存在不确定的页面。而在生成纵向的Echarts
图表时,如果数据项过多,就会占用多个不确定数目的页面。这种情况下有3种大致的解决方案:
- 与产品沟通,将页面的总长度尽可能静态化,即每一页的每个地方放置什么,长度大小都是确定的,这样肯定是最万无一失的。
- 可以接受图片或者
Echarts
表发生断裂跨页,那么就不需要做任何处理(前提是配置了页眉页脚和margin
),只是图片在展示的时候有可能会在两个页面中间断开。 - 在必须支持
Echarts
完美分割的场景下,将动态高度的地方摘出来处理,之前只创建一个Echarts
实例,那么在动态场景下就按需创建多个Echarts
实例,严格计算charts
中每一个item
的高度,再结合整个Page
的高度进行跨页处理。例如:如果一个chart
有40个item
纵向堆积,每一个Page
可以放15个,而当前Page
还残留有8个item
的空档,那么就生成4个chart
实例,一共占用4个页面,其中item
的数目分别为8、15、15、2,再给每一个chart的dom元素动态设置恰好的高度,即可实现完美呈现。这些都是可以由js
的动态style
设置和css
属性page-break-after: always;
及page-break-before: always;
控制的,不同的场景代码书写方式有比较大的差异,此处不列出具体代码,给一个大致的效果图,可以看到Echarts进行了相对完美的分割~
3.7 原生table表跨页解决方案
同上,如果不对table
跨页进行处理,那么是这样的效果(前提是设置了页眉页脚margin
):
table
的跨页设置相对简单,需要使用thead
:
1 | table thead { |
跨页的地方会自动补齐tablehead
,效果如下:
3.8 如何进行所见及所得的调试
由于是基于无头浏览器,因此浏览器绘制出的页面我们无法看到,通过将buffer
存为PDF
文件,点开PDF
文件再查看,这样还原度是最高的,但是调试效率会特别低。
我们可以在相关的ejs
模板中,加一端脚本:
1 | setTimeout(() => { |
然后在后台新开一个router
,用于加载最终生成的HTML字符串
(含数据),这样就可以在浏览器中直接查看。
之所以使用window.print
是因为PDF生成和常规的HTML的DOM流
不太一样,会产生一些效果差异,例如:页眉页脚、页面宽度,甚至HTML在正常的显示中是没有“页”的概念的,直接一长片显示。
目前在应用中,A4大小PDF
的像素尺寸为 794px * 1124px
(包含页眉页脚)
window.print
可以大致模拟PDF
的呈现效果,同时结合最终生成的PDF
,可以大大提高调试效率。
3.9 HTML转PDF的最佳实践(仅指PDF的创建及生成)
由于本项目中,涉及到了同时生成数十个PDF
,因此使用了Promise.all()
做了异步处理。
同时针对Puppeteer
中browser
的关闭时机、实例个数等,我自己简单做了下测试,结果是这样的:
处理速度 多个browser实例
> 单browser实例多进程
> 单browser单进程
;
当然这个也与业务场景和服务器环境有关系,上面的测试结果并不系统,整个功能也不需要承载特别多的并发,因此对于速度的要求并不高~
本项目中每一个PDF
体积较大,HTML
绘制过程占用时间较多,因此多个browser
会很有优势;另外服务器的配置也可能会影响不同配置下的处理速度,所以有条件的话,服务器配置拉上去,应该问题不大,因为Puppeteer
本质上就是开了很多个浏览器,还是很吃服务器资源的。
因此在项目中,在每个PDF
生成的时候都会创建一个browser实例
,以空间换时间。
阅读了一些文章,结合一些坑点,得出一个相对最佳的实践。这当然是基于本项目的应用场景,仅指使用Puppeteer
进行PDF
的创建及生成,并不包括UI开发
相关的内容,在代码中也对一些额外的注意事项进行了标注~
browser
的args
配置可以参考:
1 | async function getPDF() { |
本文大概就总结这些内容,后面有时间的话会再次细化QAQ~
由于是公司项目,就不贴特别具体的代码和最终的效果展示了,如有疑问可以联系我~
参考文献
- Chrome DevTools Protocol
- Wiki Headless_browser
- 无头浏览器测试的优点和缺点
- 无头Chrome的工作原理
- 无头浏览器Puppeteer初探
- Puppeteer Github
- Linux中文字体安装
- ejs官方文档
- Chrome启动参数
如有疑问,欢迎添加我的个人微信:
