简述
在大数据分析平台,分析师可以通过多维交叉分析模型,生成许多能够直观反应现状的报表。这些报表可以动态更新,实时计算。我们可以通过不同的看板,把相关的报表进行聚合,聚焦。这些看板往往是对公司是非常有价值的智慧沉淀。越有价值的东西,我们就应该让它的价值最大化。我们可以把它们分享给内部同样关注和需要的人。分享一般有两种形式:
- 系统内分享:把看板分享给同样具有系统账户和权限的其他同事。
- 系统外分享:把看板信息通过其他载体分享出去。比如作为
PDF
或者图片在邮件,企业微信等渠道分享。
系统外分享也是非常有用和普遍的。一方面可以作为日报/周报,周期性的反应阶段性现状。另一方面,可以让其他同事,老板非常便捷的了解到这些信息。
对于推送方式,一般有两种:
- 分析师可以把自己的看板导出为
PDF
或者图片,通过邮件或者内部沟通工具进行针对性分享。 - 分析师可以创建规则(一般是推送时间,推送渠道,推送内容),将自己的看板定时的推送给相关人员。
工具
对于看板导出为 PDF
,这在前端就可以实现。把 Html
页面生成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}>
<>
)
}
你可以简单粗暴的的把需要导出为 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={100}>
<Button onClick={ () => { setOffset(height) } }>
导出为PDF
</Button>
<>
)
}
这种方案的缺点就是用户在点击导出以后,要等待页面的全量加载和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],
});