项目背景
笔者所在团队开发了一个智能报告系统,其提供客户服务概况、智能问答(借助ChatGPT
)等能力。其中存在导出报告的场景,需要重新实现。通过前期调研,最终确定了以无头浏览器模拟访问报告页面,并调用打印能力,将HTML输出为PDF。
技术方案
HTML To PDF,社区还是有很多成熟方案的。
纯客户端方案
Google or 百度一下html2pdf,有大把方案。但是这些纯客户端方案存在一个致命不足:无法打印iframe里的内容。尤其当iframe里的页面是跨域页面时,更是无能为力。
无头浏览器方案
作为纯客户端方案的替换,使用无头浏览器在服务层(node、php、python、go)模拟页面访问并打印页面,可以有效解决iframe空白问题。
这时候会有同学有疑问,用无头浏览器打印页面也会有iframe空白问题。如何解决呢?
如果有iframe,那就需要先将HTML转换为image,然后把image转换为PDF。(image怎么来呢?截图大法了解一下,不要用render方式进行转换,只要涉及render的,都无法处理iframe问题,采用物理截图才是正解)。
方案实现
作为一名刚入门的前端工程师,自然而然就会想到用nodejs+puppeteer实现无头浏览器服务。考虑到后续的其他页面控制场景(比如页面性能检测、报告质检、报告内容提前爬取等),决定搭一个express服务提供http API。
编码实现
首先,需要明确能力边界,期望的导出PDF文档应该具备以下特征:
- 基本还原页面样式
- 能够完整导出页面
- 体积足够小,提高用户下载速度
对于第一点,最终的实现其实是有取舍的。tcbi服务报告支持自适应终端,也就是说可以实现同一份报告在PC端和移动端都能正常展示,并且保证样式正常。
考虑到PDF文档页面一般为A4,更加匹配PC端的样式,因此跟产品同学讨论后决定所有报告都有PC端样式导出,而不用去做移动端的样式兼容(其实在开发阶段做了兼容,结果就是导出的PDF页面两边留白,反而不美观)。
关于第三点,因为采用的是puppeteer原生的Page.pdf API,其体积还是很小的,包含大量echarts图表的5页报告只有不到700kb。
所以,主要的技术难点在于如何完整地导出页面。
前置准备
考虑到页面的一次性(访问一次就会close),不需要进行优化,而express服务中的Browser实例完全可以多次复用,以减少开启、关闭Browser
的开销。决定采用单例模式提供一个Browser
工具函数,用来创建唯一的browser实例,并且基于该函数再提供一个创建Page实例的方法:
javascript
const puppeteer = require('puppeteer');
let instance = null;
const getBrowserInstance = async function (options) {
if (!instance) {
instance = await puppeteer.launch(options);
}
return instance;
};
// 创建一个puppeteer的Page对象
const newPuppeteerPage = async () => {
const launchOptions = {
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],// 在Docker环境下需关闭沙箱模式
};
const browser = await getBrowserInstance(launchOptions);
if (!browser) {
return {
error: 'browser初始化失败',
};
}
const page = await browser.newPage();
return { page };
};
完整导出技术实现
上面的工具函数提供了创建Page
实例的工具函数,可以方便地创建单个Page
。并且所有Page
都是在同一个Browser
实例上的,提高了性能。
Q:直接调用
Page.pdf()
不能完整导出页面吗?A:是的,在现在的页面结构下,
document.body
的高度往往小于真实内容区高度,而Page.pdf()
打印范围默认是视窗的高度。这就会出现打印区小于可视区的情况。导致导出的PDF不全。
所以完整导出的关键点有三点:
- 确保
document.body
的高度等于内容区的高度。这里的逻辑就跟需要导出的页面强相关。 - 确保
Page
视窗高度等于内容区的高度。 - 确保内容完全加载(对于拥有丰富图表的页面尤其重要)。
配置页面高度
为了实现1,2点笔者采用的算法是:
- 找到实际内容区的容器
$container
,获取其scrollHeight
、scrollWidth
; - 自底向上遍历
$container
的parentNode
,分别设置其width
、height
为上面获取的scrollWidth
、scrollHeight
; - 步骤2遍历到html节点为止(设置到
body
标签即可); - 同时,设置puppeteer的
Page
视窗宽度和高度为scrollWidth
、scrollHeight
,确保一次导出。 编码如下:
javascript
<!---->
/**
* 1.获取页面内容的实际高度(内容高度有可能比视窗高度大,也有可能比视窗高度小)
* 2.如果有滚动则滚动到页面底部
*/
const { pageScrollHeight, pageScrollWidth, errorMsg } = await page.evaluate(
async () => {
const $container = document.querySelector("[图表容器selector]");
if (!$container) {
return {
errorMsg:
"未获取到 [图表容器selector] ,无法抓取内容",
};
}
const pageScrollHeight = $container?.scrollHeight + 61;
const pageScrollWidth = $container?.scrollWidth;
const viewportHeight = document.body.clientHeight;
// 设置父容器高度为内容高度,确保导出完整的PDF页面
if (pageScrollHeight > viewportHeight) {
let $scrollParent = $container.parentNode;
while ($scrollParent.nodeName !== "HTML") {
$scrollParent.style.height = `${pageScrollHeight}px`;
$scrollParent.style["overflow-y"] = "hidden";
$scrollParent = $scrollParent.parentNode;
}
}
return {
pageScrollHeight,
pageScrollWidth,
};
}
);
if (errorMsg) {
return res.error(errorMsg);
}
// 将页面的高度设置为浏览器窗口的高度
await page.setViewport({
width: pageScrollWidth,
height: pageScrollHeight,
});
上面的代码主要借助了Page.evaluate方法,用在找到页面的真实内容区高度,并依次设置容器的祖先容器的高度。最后返回内容区的宽、高,以该宽高设置页面的可视区高度。
页面加载完成检测
为了实现第三点,需要确保页面已经完全加载 。这里的加载不仅仅资源加载,而是页面完全渲染呈现到用户面前。这里puppeteer其实提供了等待页面加载的配置:Page.goto
javascript
class Page {
goto(
url: string,
options?: WaitForOptions & {
referer?: string;
referrerPolicy?: string;
}
): Promise<HTTPResponse | null>;
}
// WaitForOptions可选值
export type PuppeteerLifeCycleEvent =
| 'load'
| 'domcontentloaded'
| 'networkidle0'
| 'networkidle2';
网上大部分资料都是设置WaitForOptions的值为networkidle2
以确保页面加载完成:
networkidle2
是 Puppeteer 中一个用于等待网络空闲的选项。它的作用是等待网络活动停止,并且在一段时间内没有新的网络连接被触发时,认为页面已经加载完成。这个选项通常用于等待动态网页的加载完成,以便于进行截图或其他操作。 具体来说,networkidle2
选项会等待以下两个条件之一满足后,认为页面已经加载完成:
- 在一段时间内没有新的网络连接被触发。
- 在一段时间内网络连接的数量保持在一个非常低的水平。
这两个条件中的任意一个满足后,Puppeteer 将认为页面已经加载完成,可以进行下一步操作。 在使用
page.goto()
方法加载页面时,你可以将networkidle2
选项传递给waitUntil
参数,以等待页面加载完成,示例:
await page.goto('https://www.example.com', { waitUntil: 'networkidle2' });
------From ChatGPT
然而实际情况是,设置networkidle2
并不能确保页面完全渲染(笔者实践中就发现导出的PDF一些图卡为空)。因此需要自定义一种算法来弥补puppeteer
自检测的不足。考虑到图卡加载缓慢的根源就是数据接口请求缓慢,那可不可以监听页面的所有请求,并且直到所有请求都响应,才认为页面加载完毕呢?编码如下:
javascript
// 【start】监听页面启动
let request = 0;
let requestfinished = 0;
page.on("request", () => {
request += 1;
});
page.on("requestfinished", () => {
requestfinished += 1;
});
await page.goto(url, {
waitUntil: "networkidle2",
});
// 【end】等待页面请求全部完成,最多等待10秒
await new Promise((resolve) => {
let maxIntervalCount = 10;
const waitTimer = setInterval(() => {
maxIntervalCount -= 1;
// 判断是否存在未完成的请求
if (request !== requestfinished) {
noop();
} else {
clearInterval(waitTimer);
resolve();
}
if (maxIntervalCount <= 0) {
clearInterval(waitTimer);
resolve();
}
}, 1000);
});
上面的代码通过Page.on
配置了两个监听器,用来监听request
和requestfinished
事件,当两者的值相等时确定为图卡数据获取完毕。为了保证对比的准确性,这里利用了request
和requestfinished
事件的时间差,因为是在await page.goto(url, {waitUntil:"networkidle2",})
之后才进行两者对比,所以request
计数一定跑在requestfinished
的计数前面。
边界内容截断问题
看一下效果图:
发现边界的文字存在被截断问题,这里的处理有两种方式:
- 一种是在开发报告时对报告边界足够留白,边界区域没有内容,自然也就不怕被截断了
- 在导出页面是设置
scale
,缩小一点页面,确保内容区远离边界,防止被截断 笔者用了第二种方式,毕竟第一种不太可控:
php
// FIXME 设置scale为0.94,确保内容边界不被截断
await page.pdf({
format: "A4",
scale: 0.94,
landscape: false,
displayHeaderFooter: false,
path: 'export/xxx.pdf',
width: pageScrollWidth,
height: pageScrollHeight,
printBackground: true,// 打印背景
margin: { left: 24, top: 24, right: 24, bottom: 24 },
});
其中printBackground:true
是产品同学特别要求的,目的是保证图卡间的分隔正常。
当然,也可以通过page.emulateMediaType('screen')
将页面的媒体类型设置为 screen
,使得 puppeteer 生成的截图或 PDF 文件更接近屏幕显示时的效果。
至此,基本完成了报告导出为PDF的本地开发,接下来将是部署阶段,这又会遇到什么问题呢?
敬请期待下篇文章^_^
。
总结
- 基于puppeteer无头浏览器的页面导出方案可以有效解决iframe空白问题;
- 获取页面实际内容高度可以通过
Page.evaluate()
方法,其可以执行任意脚本,比如导出的时候给页面添加水印; - 使用单例模式创建
Browser
实例可以有效提高性能; - goto方法的
waitUntil
参数不一定可靠,可以搭配自定义算法实现页面完全加载检测; - 对于页面边界内容,导出时存在被截断风险,可以配置
scale < 1
,将页面内容完全打印到PDF里。