前端页面 PDF 导出与推送在大数据分析产品中的探索与实践原创
简述
在大数据分析产品中,我们可以通过不同的分析模型和查询语句,生成各种可视化的图表和报表,并且通过看板的形式把相关的报表进行组合和排布,形成完整的分析结论和价值输出。为了让看板分析的价值最大化,我们通常会把它们分享给同样关注和需要的人,分享一般有两种形式:
- 系统内分享:把原始看板数据授权给具有系统账户和相关权限的其他同事。
- 系统外分享:把看板展示信息通过其他载体分享出去,比如使用 PDF 或者图片在邮件、IM 等渠道分享。
系统内分享支持更多的操作能力,但是需要对系统有一定的认知和熟练度,因此支持系统外分享也是非常便捷和有用,一方面可以作为日报/周报,周期性的反映阶段性状况,另一方面也可以让其他同事、老板非常快速的了解到关键信息。
对于推送方式,一般也有两种:
- 把自己的看板手动导出为 PDF 或者图片,通过邮件或者 IM 工具进行针对性分享。
- 创建定时规则(一般是推送时间,推送渠道,推送内容),将看板数据定期地推送给相关人员。
工具
对于如何将页面导出为 PDF,目前社区上相关的前端技术已经比较成熟,我们这里就借用了当下非常主流的两大利器:
- html2canvas:把 html 转成 Canvas,进而生成图片。
- jspdf:把图片转成 PDF 文件。
对于定时推送,更多的需要借助服务端的能力,比如定时任务、邮件、企业微信/钉钉/飞书等渠道的推送都需要后端服务来支持。页面的生成,需要用到无头浏览器来进行登录模拟、页面加载和图片生成。
- Node 环境下可以使用 Puppeteer。
- Java 环境下可以使用 ChromeDriver 工具。
前端需要配合完成推送规则、渠道和内容的配置。此外,前端还需要针对导出模式对原有页面做一系列特殊处理,让导出的报表更加符合预期,比如隐藏一些无用信息和操作按钮,或者额外显示一些信息(比如在页面上 hover 上去才会显示的有用信息),同时还要保证不会影响常规模式下页面的使用效果。
场景
工具虽好,但在实际场景中应用的时候,很多问题是工具无法帮你解决的,在大数据分析产品中,以下场景就比较常见:
- 报表懒加载
- 巨量数据请求
- 巨量数据渲染
- 差异化渲染
你不能给用户导出一个数据不全,或者还没有渲染完成的报表。所以,我们必须解决懒加载的问题,并确保巨量数据已经请求并渲染完成。
懒加载
懒加载也叫按需加载,是一种被广泛应用的网页性能优化方式,它能极大的提升用户体验,比如页面很长,我们优先加载可视区域的内容,其他部分等用户滚动进入可视区域再加载。
在 React 技术栈项目中,我们可以使用 react-lazyload 组件实现懒加载:
import React from 'react';
import LazyLoad from 'react-lazyload';
const Demo = () => {
return (
<LazyLoad height={500} offset={100}>xx</Lazyload>
)
}
你可以简单粗暴的把需要导出为 PDF 的页面去除懒加载。这种方法唯一的优点是尽早的开始请求和加载页面,让用户能尽早的进行导出,缺点是为了实现导出功能而牺牲了正常浏览性能,当然如果懒加载带来的优化微乎其微,也可以接受。
但是在很多场景,懒加载非常重要,直接去除的代价会非常大,比如大数据系统的看板页面,通常包含了几十甚至上百个报表,一方面,服务侧的大数据计算队列是非常宝贵的资源,默认全量加载,计算队列的消耗会随着用户数几何式增长。另一方面,大数据报表的前端渲染也非常复杂耗时,如果看板的报表数量较多,一次性加载会导致页面长时间卡顿。
那么,在这些懒加载必要的场景下,我们只能选择在用户进行导出的时候,再进行全量加载。这样页面导出 PDF 就分三步:
- 页面加载中
- 文件生成中
- 文件可下载
在使用 react-lazyload 的情况下,可以通过重新设置 offset 参数,并触发容器 scroll 事件来进行全量加载:
import React, { useState, useEffect } from 'react';
import LazyLoad from 'react-lazyload';
const Demo = (height) => {
const [offset, setOffset] = useState(100);
useEffect(() => {
const myEvent = new Event('scroll');
document.body.dispatchEvent(myEvent);
}, [offset])
return (
<>
<LazyLoad height={height} offset={offset}>
<Button onClick={() => setOffset(height)}>
导出为PDF
</Button>
</Lazyload>
<>
)
}
这种方案的缺点就是用户在点击导出以后,要等待页面的全量加载和 PDF的生成,可能需要较长时间。我们可以通过以下两种方式来进行一些优化:
- 前端侧:友好的提示用户导出的进度和状态。
- 服务侧:用户在点击导出 PDF 以后,服务端生成一个任务来进行 PDF 的导出。这样,用户可以进行其他操作,等文件生成以后通过异步回调来提示用户进行文件下载。
服务侧的优化方式实现起来会比较复杂,如果服务端本身具备自动推送的功能,那么是可以复用自动推送的能力的。如果没有自动推送,前端侧优化性价比更高。
巨量数据请求&渲染
巨量数据下,数据的请求和渲染都比较耗时。那么在看板导出的场景下,我们需要进行「导出时机」的判断。我们在导出的时候需要确保:
- 数据请求都已返回。
- DOM 渲染已完成。
- 图表(Canvas)绘制已完成。
在巨量数据的情况下,DOM 渲染和 Canvas 绘制不能简单的通过在请求完成后,预留一点时间的方式来判断。
那么,有没有一种全局的,与业务逻辑解耦的方式来来进行判断,比如监听方式。
请求监听
在前端侧,我没有找到全局解耦的进行请求监听的方式,服务侧非常简单,比如 Node 环境的 Puppeteer就可以很方便的进行网络监听。
对于普通的的 Http 请求,在 Puppeteer 中监听非常便利:
await page.goto('https://www.baidu.com', {
timeout: 30 * 1000,
waitUntil: [
'load', //等待 “load” 事件触发
'domcontentloaded', //等待 “domcontentloaded” 事件触发
'networkidle0', //在 500ms 内没有任何网络连接
'networkidle2', //在 500ms 内网络连接个数不超过 2 个
],
});
但是上述方式不适用于 webSocket 请求。我们可以通过以下方式来监听 webSocket 请求的所有通信:
const client = await page.target().createCDPSession();
await client.send('Network.enable');
client.on('Network.webSocketCreated', function (params) {
// console.log(`创建 WebSocket 连接:`)
});
client.on('Network.webSocketClosed',function (params) {
// console.log(`关闭 WebSocket 连接`)
});
client.on('Network.webSocketWillSendHandshakeRequest',function (params) {
// console.log(`发送 WebSocket 握手消息`)
});
client.on('Network.webSocketHandshakeResponseReceived',function (params) {
// console.log(`收到 WebSocket 握手消息`)
});
client.on('Network.webSocketFrameSent', ( frame ) => {
// console.log(`发送 WebSocket 请求`)
});
client.on('Network.webSocketFrameReceived',function (frame) {
// console.log(`收到 WebSocket 请求`)
);
我们只需最后两种监听,就可以判断是否还存在未完成的 websocket 请求。
前面有提到,在前端侧我们并没有找到一种全局解耦的方式进行请求监听,但是对于页面导出为 PDF 这样的纯前端功能,需要前端来判断所有请求已完成,无法通过请求监听的方式,那只能通过记录所有请求的发送和回调这种请求判断的方式。
DOM 监听
DOM 监听的运用和实践已经比较普遍,我们使用 MutationObserver 就可以。MutationObserver 主要用于监听 DOM 元素的一系列变化,如果一段时间内页面无任何DOM 变化,我们可以认为页面渲染已经完成。
/**
* 监听 Dom 变化
*/
function domMutationObserver(resolve): void {
observer = new MutationObserver(() => {
// console.log('rendering...');
observerHeadler(resolve);
});
observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
});
}
Canvas 监听
我们都知道,Canvas 是通过 JS 绘制,所以图形不会反应在 DOM 结构中,没法通过 DomMutationObserver 来进行监听。但是要进行 Canvas 绘制,必然会一直调用 Canvas 的各种 API。我们可以通过 「数据劫持」 的方式来监听这些 API。如果短时间内无任何相关 API的调用,我们可以认为 Canvas 绘制已经完成。
/**
* 监听 canvas 绘制
*/
function canvasMutationObserver(resolve): void {
const canvasProto = CanvasRenderingContext2D.prototype;
const canvasProps = Object.getOwnPropertyNames(canvasProto);
canvasProps.forEach((prop) => {
const property = Object.getOwnPropertyDescriptor(canvasProto, prop);
const getter = property && property.get;
/* 监听 canvas 属性(方法就不用不监听了)*/
if (getter) {
Object.defineProperty(canvasProto, prop, {
get: function () {
// console.log('drawing...');
observerHeadler(resolve);
},
});
}
});
}
至此,我们可以通过导出时机的准确判断来导出一个懒加载的页面。
差异化导出
在报表导出的时候,用户可能希望导出的 PDF 文件中隐藏一些不必要的元素(比如操作功能区),也可能希望额外显示一些重要的信息(比如 hover 到某元素上去才显示),这是非常普遍又合理的。
首先,我觉得在页面交互设计上,应该尽量保证页面和导出的一致性,对于个别无法保证的差异点再通过编码处理,对于处理方式,需要分「前端导出」和「自动推送」两种场景来分析。
前端导出
对于前端导出的场景,用户导出的同时,还是能够看到页面,如何做到用户无感知的差异化导出比较重要。我们可以在导出时先微调,导出后再还原。但是这种用户可感知的方案非常奇怪,新开一个不可见的窗口二次渲染又非常耗时耗能。
好在,html2canvas 提供了一个非常好用的 onclone 钩子函数作为配置参数。该函数会在 html2canvas 已经解析获取到页面 dom 副本后,在生成canvas 前调用。我们只需要给隐藏的元素增加一个 pdf_hidden 标记。在页面新增默认隐藏的需要额外显示的信息,然后增加 pdf_show 标记。然后在 onclone 钩子函数中移除或隐藏带 pdf_hidden 标记的元素;显示带 pdf_show 标记的元素即可。
html2canvas(element, {
...options,
onclone: (html) => {
const needHide = html.getElementsByClassName('pdf_hidden');
const needShow = html.getElementsByClassName('pdf_show');
if (needHide) {
Array.from(needHide).forEach((item) => {
item.remove();
});
}
if (needShow) {
Array.from(needShow).forEach((item) => {
item.setAttribute('style', 'display: block');
});
}
},
}).then((canvas) => {});
onclone 钩子函数能处理很多问题。比如你页面的自定义图标( SVG )是使用 <symbol>元素来全局定义,然后在具体的 <svg/> 元素中使用 <use> 来引用的,那么你生成的 canvas 是看不到 svg 图标的。因为 html2canvas 是单独解析遇到的 <svg/> 元素的。这个时候,你就需要通过 onclone 钩子函数来做一些特殊处理,比如:
/**
* svg 处理
*/
function svgDealwith(element): void {
const svgs: Document[] = Array.from(element.getElementsByTagName('svg'));
svgs.forEach((svg) => {
const use = svg.getElementsByTagName('use');
if (use.length > 0) {
const fontId = use[0].getAttribute('xlink:href');
if (fontId) {
const path = document
.getElementById(fontId.replace('#', ''))
.cloneNode(true);
svg.insertBefore(path, svg.firstChild);
setTimeout(() => {
svg.removeChild(svg.firstChild);
}, 0);
}
}
});
}
自动推送
前面有提到,自动推送是后端通过无头浏览器进行页面的渲染和截屏的,是所见即所得的。如果存在导出差异,新做一个绝大部分内容一致的页面进行承载显然是不可取的。我们可以通过在原有页面增加参数来区分「导出模式」,然后在页面中根据是否是「导出模式」,做一些差异化处理。
这无疑增加了页面的逻辑复杂度,开发得确保满足导出模式的差异化需求的同时,不影响页面的原有逻辑。我们能做的就是对差异化处理进行更好的抽象,做到尽量隔离。虽然我们没法在「导出模式」下,使用 html2canvas 的 onclone 钩子函数,但是可以复用前端导出时增加的 pdf_hidden 和 pdf_show 标记。导出模式是无头浏览器模式,不用考虑用户感知,我们只需要在页面加载以后,如果是「导出模式」就对带有 pdf_hidden 和 pdf_show 标记的元素进行相似处理即可。
比如在 React hooks 组件中,我们可以这样处理:
import React, { useEffect } from 'react';
// pageReady 页面渲染完成标识
const Demo = (pageReady) => {
useEffect(() => {
if (pageReady) {
const needHide = html.getElementsByClassName('pdf_hidden');
const needShow = html.getElementsByClassName('pdf_show');
if (needHide) {
Array.from(needHide).forEach((item) => {
item.remove();
});
}
if (needShow) {
Array.from(needShow).forEach((item) => {
item.setAttribute('style', 'display: block');
});
}
}
}, [pageReady])
return (
<div>
<div className="pdf_hidden">hello<div>
<div className="pdf_show" style={{display: 'none'}}>world<div>
<div>
)
}
jspdf 问题
最后,给大家分享一个 jspdf 工具在使用中遇到的一个问题:页面报表非常多的情况下,导出的 pdf 文件会丢失部分内容。
原因是我们导出的 PDF 是单页的,这样显示效果较好,但是 PDF 单页有高度 「14400」 的限制,针对这种情况,我们需要对 PDF 的宽高进行等比缩放来处理。
while (h1 + h2 > 14400) {
w = Math.floor(w * 0.95);
h1 = Math.floor(h1 * 0.95);
h2 = Math.floor(h2 * 0.95);
}
const h = Math.max(w, h1 + h2);
const pdf = new jsPDF({
orientation: 'p',
unit: 'pt',
format: [w, h],
});
尾声
目前这套方案已经在我们产品中进行了应用,大部分场景下的体验都比较不错,当然部分极限场景下还有不少可以进一步优化的地方,欢迎有相关经验的朋友互相交流学习。
参考资料
https://artskydj.github.io/jsPDF/docs/jsPDF.html
https://html2canvas.hertzen.com/configuration
https://github.com/rrweb-io/rrweb
我们是数数科技前端团队,目前负责游戏行业使用最多的用户行为分析系统的前端研发,同时也在积极探索前端新技术和新领域,如果你对游戏、大数据、可视化、工程化、全栈等方面有兴趣,欢迎加入我们,共创未来!
邮箱:young@thinkingdata.cn