性能文章>前端页面 PDF 导出与推送在大数据分析产品中的探索与实践>

前端页面 PDF 导出与推送在大数据分析产品中的探索与实践原创

2年前
330715

简述

在大数据分析产品中,我们可以通过不同的分析模型和查询语句,生成各种可视化的图表和报表,并且通过看板的形式把相关的报表进行组合和排布,形成完整的分析结论和价值输出。为了让看板分析的价值最大化,我们通常会把它们分享给同样关注和需要的人,分享一般有两种形式:

  • 系统内分享:把原始看板数据授权给具有系统账户和相关权限的其他同事。
  • 系统外分享:把看板展示信息通过其他载体分享出去,比如使用 PDF 或者图片在邮件、IM 等渠道分享。

系统内分享支持更多的操作能力,但是需要对系统有一定的认知和熟练度,因此支持系统外分享也是非常便捷和有用,一方面可以作为日报/周报,周期性的反映阶段性状况,另一方面也可以让其他同事、老板非常快速的了解到关键信息。

对于推送方式,一般也有两种:

  1. 把自己的看板手动导出为 PDF 或者图片,通过邮件或者 IM 工具进行针对性分享。
  2. 创建定时规则(一般是推送时间,推送渠道,推送内容),将看板数据定期地推送给相关人员。

工具

对于如何将页面导出为 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 就分三步:

  1. 页面加载中
  2. 文件生成中
  3. 文件可下载

在使用 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 的导出。这样,用户可以进行其他操作,等文件生成以后通过异步回调来提示用户进行文件下载。

服务侧的优化方式实现起来会比较复杂,如果服务端本身具备自动推送的功能,那么是可以复用自动推送的能力的。如果没有自动推送,前端侧优化性价比更高。

巨量数据请求&渲染

巨量数据下,数据的请求和渲染都比较耗时。那么在看板导出的场景下,我们需要进行「导出时机」的判断。我们在导出的时候需要确保:

  1. 数据请求都已返回。
  2. DOM 渲染已完成。
  3. 图表(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

点赞收藏
thinkingdata

数数科技前端团队,专注于大数据、可视化和工程化。

请先登录,查看1条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
5
1