最近在做项目的时候,遇到了在后端生成PDF的需求,而且生成的页面较多,且样式管理相对复杂,还原度要求较高。通过一系列的社区调研后发现,
Puppeteer
这个Node库可以提供一个相对高级的API,以DevTools协议控制Chrome
或者Chromium
,其无头模式可以将HTML转为PDF。这是目前Node Server
应用中最为主流的解决方案,但是在使用过程中还是会存在很多坑点和注意事项。因此本文围绕无头浏览器
、Puppeteer
、HTML转PDF
等几个方面进行总结和介绍。
在wiki百科
中,无头浏览器指的是没有图形用户界面GUI的浏览器。
无头浏览器运行在类似于常规网络浏览器的环境中,同时提供对网页的自动控制,由于其没有图形用户界面,因此无头浏览器通过命令行界面或使用网络通信来执行。
无头浏览器对于测试网页、爬虫等场景下能够发挥巨大的作用,因为它们能够像浏览器一样呈现和理解超文本标记语言,包括页面布局、颜色、字体以及JavaScript
和Ajax
的执行等样式元素,这些元素在使用其他测试方法时通常是不可用的。
综上所述,可以对无头浏览器的基本内含进行如下的总结:
Puppeteer
可以提供一个相对高级的API,以DevTools协议控制Chrome
或者Chromium
。Ubuntu
或CentOS
服务器上,则只需将二进制文件进行编译安装,无头浏览器即可使用。在这里补充一点,Chrome
和Chromium
是2个玩意,是2个浏览器,他们大致的区别是:Chromium
是一个开源浏览器项目,它是ChromeWeb
浏览器的基础。具体差异参考下面这篇文章。
文章:Chromium和Chrome有什么区别
无头浏览器通常用来:
Web
应用程序中的测试自动化、JavaScript
库运行自动化测试SPA
)执行并渲染(解决传统 HTTP
爬虫抓取单页应用难以处理异步请求的问题)此处附上一篇文章,介绍的是基于Puppeteer控制的无头浏览器的反爬攻防:
文章:无头浏览器Puppeteer的检测攻防
当然无头浏览器不止一种,其他的无头浏览器检测相似,可以自行Google~
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的整体架构如下:
其实基本上就是Chrome
的分层架构图。BrowserContext
是浏览器环境的一个会话(如果这个概念较难理解,那么如果用新开一个浏览器隐私窗口的行为做类比,一个BrowserContext
就是一个隐私窗口环境,各自不共享Cookie
、CacheData
这些),Page
就是一个新建标签页产生的浏览器页面,Frame
对应一个页面Document
。
本章中主要对Puppeteer
的一些常见的API和操作进行简要罗列,不进行很具体地展开,因为整体调用相对容易上手,直接参考文档即可:
Puppeteer 基本使用
Puppeteer 详细API
同时,官方提供了一个用于演示Puppeteer Demo的网站
Demo:Try Puppeteer
在这个网站可以查看Puppeteer的执行效果
本地测试,直接npm i puppeteer
即可使用~
本地执行:
// example.js
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({ path: 'example.png' });
await browser.close();
})();
// 在命令行输入node example.js即可执行
本地执行:
// example.js
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.all1024.com', {
waitUntil: 'networkidle2',
});
await page.pdf({ path: '1024.pdf', format: 'a4' });
await browser.close();
})();
// 在命令行输入node example.js即可执行
本地执行:
// example.js
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://example.com');
// Get the "viewport" of the page, as reported by the page.
const dimensions = await page.evaluate(() => {
return {
width: document.documentElement.clientWidth,
height: document.documentElement.clientHeight,
deviceScaleFactor: window.devicePixelRatio,
};
});
console.log('Dimensions:', dimensions);
await browser.close();
})();
// 在命令行输入node example.js即可执行
// example.js
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({
// Launch chromium using a proxy server on port 9876.
// More on proxying:
// https://www.chromium.org/developers/design-documents/network-settings
args: [ '--proxy-server=127.0.0.1:9876' ]
});
const page = await browser.newPage();
await page.goto('https://google.com');
await browser.close();
})();
// 在命令行输入node example.js即可执行
// example.js
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// 地址栏输入网页地址
await page.goto('https://baidu.com/', {
waitUntil: 'networkidle2',
});
// 输入搜索关键字
await page.type('#kw', '腾讯公司', {
delay: 1000, // 控制 keypress 也就是每个字母输入的间隔
});
// 回车
await page.keyboard.press('Enter');
})();
// 在命令行输入node example.js即可执行
除此之外还有很多应用,可以自行查找和探索~
由于最近在项目中主要使用的是PDF
生成相关的API
,遇到了不少坑点,因此下一章就对HTML转PDF
中常见问题进行总结~
项目场景是这样的:前后端分离的应用中,后端为
Koa
服务,基于Puppeteer
进行HTML转PDF
的应用,此处的HTML
并非Url
,而是通过ejs
进行模板渲染后读取出来的HTML字符串
。我们需要同时导出数十个
为什么要在本项目中使用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
,但相比而言,前者的效率要搞很多,因此实际项目应用中使用前者,核心代码如下:
// 环境TypeScript
// ejs传入数据的类型定义
interface PDFDataObj {
[propName: string]: any
}
async function getHTML(pdfReportData:PDFDataObj) {
// 解析html字符串
let EJS2HTML = await new Promise((resolve, reject) => {
ejs.renderFile(
path.resolve(__dirname, "../../../", "public/htmlModel/", "report.ejs"), // ejs模板文件的存储路径
pdfReportData, // 给ejs传入的渲染数据
function (err, string) {
// 回调函数
if (err) {
reject(string);
} else {
resolve(string);
}
}
);
});
return EJS2HTML as string;
}
如果在ejs
模板中,使用路径的格式调用资源,最终在Puppeteer生成PDF的时候是无法成功加载出静态资源的,例如:
<script type="text/javascript" src="/public/js/echarts.min.js">script>
<script type="text/javascript" src="./js/echarts.min.js">script>
因为此时的路径环境已经发生了变化,如何解决呢,有两种方式:
对象存储服务
(如腾讯的COS
,阿里的OSS
),然后获取资源链接,进行替换,但前提是项目支持外网资源调用;在Koa
中Koa-static
可以为我们挂载静态资源。
在本项目中,需要启用Koa
的多静态资源路径,一个分配给前端的打包文件,另一个分配给后端使用到的静态文件(如ejs
引入的外部文件)。这些需要用到另一个npm
包koa-mount
,如果在项目中开启了路径的权限验证时,记得将这些静态资源的权限释放出来,部分核心的设置代码如下:
// TypeScript环境
import Koa from "koa"
import koaJwt from "koa-jwt"
import koaMount from "koa-mount"
import koaStatic from "koa-static"
import { Config } from "./config"
export class App {
public app: Koa
private server!: Server
private config: Config
public constructor() {
this.app = new Koa()
this.config = new Config()
}
private addRouter() {
let staticPath = path.resolve(__dirname, "../client/dist")
let publicPath = path.resolve(__dirname, "../public")
this.app.use(koaJwt({secret:this.config.config.jwt.secretKey, key:"jwt", cookie: "jwt_token"}).unless({ path: [/^\/(v1|login|js|img|css|font|images|public)/] }))
this.app.use(koaStatic(staticPath, { index: "index.html", maxage: 24 * 3600 * 1000, defer: true }))
// 挂载多个静态目录
this.app.use(koaMount("/public", koaStatic(publicPath)))
}
}
本项目基于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
这个参数进行传递:
<script type="text/javascript" src="<%= resourcesUrl %>/public/js/echarts.min.js">script>
通常在html、css的样式书写中,当网页需要指定字体,
如果我们直接将font-family
设置为Microsoft YaHei
,而不进行其他处理,那么对于一些内置微软雅黑字体的浏览器,页面呈现效果,就是微软雅黑;而部分浏览器没有内置微软雅黑字体那么,呈现出来的就是浏览器的默认字体。
为了避免这种情况,我们通常会在资源文件夹中存储相关的字体文件,然后以类似下面的方式进行调用:
@font-face {
font-family: 'MyWebFont';
src: url('../font/webfont.woff') format('woff'),
url('../font/webfont.ttf') format('truetype');
}
.targetDom {
font-family: MyWebFont;
}
但是在Puppeteer
应用中,这样使用是会出问题的,因为Puppeteer
生成PDF
最终依赖的是操作系统层级的字体库,也就是说系统中安装了什么字体,我们在css
中就可以调用什么字体,且名字必须一致。这个听起来挺离谱的,但我在项目实践过程中确实是这样的,试了很多种方式都没有解决,最后发现与系统字体有关系。
通过查看文档,大致推测,这个现象是与Puppeteer
对于chromium
的依赖引起的,而chromium
直接依赖于底层的OS
。
也就是说,我们只要在系统层级安装好字体库,那么这个问题就可以解决。
那么新问题又来了,绝大多数情况下,我们部署的Server
都是Linux
操作系统,Linux
系统对于字体的安装与Windows
或macOS
截然不同,而开发环境往往又是windows
或macOS
。
对于Linux
中的字体安装问题,可以参考下面这篇文章,或者参考下面的Dockerfile
中的执行步骤:
Linux中文字体安装
当在公司的自研上云平台进行部署时,很容易会产生本地开发与线上部署出现不同效果甚至报错的情况,因此就引出了Docker
的应用,原本在引入Puppeteer
之前整个项目似乎不太需要Docker
,但事实证明,长线项目开发还是把Docker
用起来,会方便很多~
除了字体的问题外,Puppeteer
在Linux
的应用中也会出现chromium
报错,这里需要单独对chromium
进行安装。
把上述所有的操作进行梳理后,就形成了下面的Dockerfile
,在文件中对命令都进行了注解,在实际项目开发中可以参考,其他的配置参考3.9
。
# 此处填写基础镜像的地址
FROM mirrors.tencent.com/xxxxx/xxxxxx
ARG NODEJS_VERSION=v14.1.0
LABEL MAINTAINER="Alexzhli"
# Install
# 安装chromium
RUN yum -y install chromium \
# 获取并安装 nodejs
&& wget https://github.com/nvm-sh/nvm/archive/v0.35.1.zip \
&& unzip v0.35.1.zip -d /root/ \
&& rm v0.35.1.zip \
&& mv /root/nvm-0.35.1 /root/.nvm \
&& echo ". /root/.nvm/nvm.sh" >> /root/.bashrc \
&& echo ". /root/.nvm/bash_completion" >> /root/.bashrc \
&& source /root/.bashrc \
&& nvm install $NODEJS_VERSION \
# 安装ts及ts-node
&& npm install -g typescript ts-node \
# 安装并且设置linux中文字体
# 安装中文字体支持
&& yum -y groupinstall chinese-support \
# 设置linux语言环境
&& LANG=zh_CN.UTF-8 \
# 从COS中下载字体
&& wget https://xxx.com./xxx/TencentSans-W7.ttf \
# 从COS中下载字体
&& wget https://xxx.com./xxx/msyh.ttf \
# 安装字体
&& cp TencentSans-W7.ttf /usr/share/fonts \
&& cp msyh.ttf /usr/share/fonts \
&& cd /usr/share/fonts \
&& mkfontscale \
&& mkfontdir \
# 更新缓存
&& fc-cache
WORKDIR /usr/local/app
这样以来,开发环境和生产环境就会完全一致~
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的dom
会自动跳过这片区域,与word
类似。因此无法在html
文件中的css
中控制其样式,只能将其样式写入Template
的字符串中,作为行间样式。headerTemplate
和footerTemplate
设置的页眉和页脚,默认会有一些偏移,因此需要在行间样式额外指定margin-top
、margin-bottom
来进行位置调整。Puppeteer
的应用中我们没法在除了headerTemplate
和footerTemplate
的其他地方获取到page
的页码,headerTemplate
和footerTemplate
提供了默认的页码显示支持,将span
标签的class
设置为totalPages
为总页数,设置为pageNumber
为当前页数。对于整个页面生成来说,长图片跨页断裂的情况很难避免,在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进行了相对完美的分割~同上,如果不对table
跨页进行处理,那么是这样的效果(前提是设置了页眉页脚margin
):
table
的跨页设置相对简单,需要使用thead
:
table thead {
display: table-header-group;
break-inside: avoid;
}
跨页的地方会自动补齐tablehead
,效果如下:
由于是基于无头浏览器,因此浏览器绘制出的页面我们无法看到,通过将buffer
存为PDF
文件,点开PDF
文件再查看,这样还原度是最高的,但是调试效率会特别低。
我们可以在相关的ejs
模板中,加一端脚本:
setTimeout(() => {
window.print()
}, 2000)
然后在后台新开一个router
,用于加载最终生成的HTML字符串
(含数据),这样就可以在浏览器中直接查看。
之所以使用window.print
是因为PDF生成和常规的HTML的DOM流
不太一样,会产生一些效果差异,例如:页眉页脚、页面宽度,甚至HTML在正常的显示中是没有“页”的概念的,直接一长片显示。
目前在应用中,A4大小PDF
的像素尺寸为 794px * 1124px
(包含页眉页脚)
window.print
可以大致模拟PDF
的呈现效果,同时结合最终生成的PDF
,可以大大提高调试效率。
由于本项目中,涉及到了同时生成数十个PDF
,因此使用了Promise.all()
做了异步处理。
同时针对Puppeteer
中browser
的关闭时机、实例个数等,我自己简单做了下测试,结果是这样的:
处理速度 多个browser实例
> 单browser实例多进程
> 单browser单进程
;
当然这个也与业务场景和服务器环境有关系,上面的测试结果并不系统,整个功能也不需要承载特别多的并发,因此对于速度的要求并不高~
本项目中每一个PDF
体积较大,HTML
绘制过程占用时间较多,因此多个browser
会很有优势;另外服务器的配置也可能会影响不同配置下的处理速度,所以有条件的话,服务器配置拉上去,应该问题不大,因为Puppeteer
本质上就是开了很多个浏览器,还是很吃服务器资源的。
因此在项目中,在每个PDF
生成的时候都会创建一个browser实例
,以空间换时间。
阅读了一些文章,结合一些坑点,得出一个相对最佳的实践。这当然是基于本项目的应用场景,仅指使用Puppeteer
进行PDF
的创建及生成,并不包括UI开发
相关的内容,在代码中也对一些额外的注意事项进行了标注~
browser
的args
配置可以参考:
chrome启动参数
async function getPDF() {
const browser = await puppeteer.launch({
headless: true,
args: [
"--no-sandbox", // linux系统中必须开启
"--no-zygote",
// "--single-process", // 此处关掉单进程
"--disable-setuid-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--no-first-run",
"--disable-extensions",
"--disable-file-system",
"--disable-background-networking",
"--disable-default-apps",
"--disable-sync", // 禁止同步
"--disable-translate",
"--hide-scrollbars",
"--metrics-recording-only",
"--mute-audio",
"--safebrowsing-disable-auto-update",
"--ignore-certificate-errors",
"--ignore-ssl-errors",
"--ignore-certificate-errors-spki-list",
"--font-render-hinting=medium",
]
});
// try...catch...
try {
const page = await browser.newPage();
// 页眉模板(图片使用base64,此处的src的base64为占位值)
const headerTemplate = `
李钟航的报告模板
`
// 页脚模板(pageNumber处会自动注入当前页码)
const footerTemplate = `
这里是页脚文字
`;
// 对于大的PDF生成,可能会时间很久,这里规定不会进行超时处理
await page.setDefaultNavigationTimeout(0);
// 定义html内容
await page.setContent(this.HTMLStr, { waitUntil: "networkidle2" });
// 等待字体加载响应
await page.evaluateHandle("document.fonts.ready");
let pdfbuf = await page.pdf({
// 页面缩放比例
scale: 1,
// 是否展示页眉页脚
displayHeaderFooter: true,
// pdf存储单页大小
format: "a4",
// 页面的边距
// 页眉的模板
headerTemplate,
// 页脚的模板
footerTemplate,
margin: {
top: 50,
bottom: 50,
left: 0,
right: 0
},
// 输出的页码范围
pageRanges: "",
// CSS
preferCSSPageSize: true,
// 开启渲染背景色,因为 puppeteer 是基于 chrome 浏览器的,浏览器为了打印节省油墨,默认是不导出背景图及背景色的
// 坑点,必须加
printBackground: true,
});
// 关闭browser
await browser.close();
// 返回的是buffer不需要存储为pdf,直接将buffer传回前端进行下载,提高处理速度
return pdfbuf
} catch(e) {
await browser.close();
throw e
}
}
本文大概就总结这些内容,后面有时间的话会再次细化QAQ~
由于是公司项目,就不贴特别具体的代码和最终的效果展示了,如有疑问可以联系我~
如有疑问,欢迎添加我的个人微信: