PDF.js 与 WebComponent:打造轻量级 PDF 预览器

3,864 阅读10分钟

引言

随着技术的发展,越来越多的web场景都需要能够支持定制化的pdf预览,比如金融场景的协议、购物场景的发票等。目前可以处理 pdf 文件资源的库或插件有很多,但除了 PDF.js 之外,其他的插件目前都只能够展示,无法对 pdf 的展示进行定制。而市场上现有的PDF 阅读器,或者强依赖于浏览器插件,或者提供了完整的pdf 阅读器,但只能在浏览器中进行阅读。

PDF.js 介绍

PDF.js 是一款由 Mozilla 团队开发的,基于 JavaScript 的开源 PDF 阅读器库,它提供了在浏览器中展示和解码处理 PDF 文档的方式,适用于需要在Web应用程序中嵌入 PDF 阅读器或需要解码 pdf 文档的场景。

这个库中提供了完整的 pdf 阅读器,使用时可直接将其嵌入到 web 应用程序中使用,如:

image.png

由于该库的阅读器提供了丰富的功能,包括渲染和显示PDF文档、文本搜索、页面缩放、页面导航、打印、注释和表单等,整个阅读器的体积也非常大,对工程的体积影响过大。且功能复杂,定制的成本也很高。

pdfjs-dist 是 PDF.js 库的一个分发版本,它是通过npm包管理器提供的预构建版本,方便在项目中使用PDF.js 功能而无需自行构建。他包含了 PDF.js 的核心部分,如 pdf 文档解析、文本提取、页面渲染等,也包含了 pdf 阅读器的构建版本,减小了体积,也提高了 pdf 阅读器的加载效率。

webComponent 组件

Web 组件(WebComponent)是一组Web平台的标准技术,它主要由 Custom Elements(自定义元素)、Shadow DOM(影子DOM)、HTML Templates(HTML模板)组成。它用于创建可复用和独立的自定义HTML元素,与特定的框架(如vue、react 等)无关,可以在不同的项目和团队之间无缝切换。Web Component 可以创建独立的组件,与其他技术栈的组件进行互操作,促进了跨团队和跨技术栈的协作。并且它可以在各种平台上运行,包括桌面浏览器、移动设备和嵌入式系统。

LitElement 是一款由 Google 团队开发的基于 Web Component 标准的轻量级、可复用的 Web 组件库,还拥有响应式数据、生命周期管理等功能。它使用了原生的 Web 平台功能,不需要依赖其他框架或运行时。适用于单个组件和组件库开发,并解决了 webComponent 的 Custom Elements 的兼容性问题。

pdfjs-dist(PDF.js) 背景

使用 pdfjs-dist 进行定制化渲染,首先需要知道其对外使用的相关接口,在我们的预览场景中,虽然功能没那么复杂,但 pdfjs-dist 核心内的接口基本上也都用上了。如 PDFDocumentLoadingTaskPDFDocumentProxyPDFPageProxyRenderTask 等。为了更好的性能,官方也提供了对应的解析 pdf 文件的 worker 脚本,可通过 pdfjsLib.workerSrc 直接进行使用。

在历史的长河中,Mozilla 团队逐渐也抛弃了 PDF.js 对低版本的 javascript 语法的兼容,目前最新的版本只能支持以下的平台:

image.png

若需要支持上面限制以下的系统环境的浏览器,可以结合 npm 平台上各个版本的使用情况,在 gulpfile.js 文件中查看版本对应支持的系统环境。找到需要的版本直接使用该版本下的 legacy 下的核心代码即可。

image.png

另外,因为深度定制的 pdf 预览组件只使用到 PDF.js 的核心功能,如 pdf 文件解码, 页面渲染等,也可以通过 es-check 检测最新版本的 js 核心部分,查看核心代码中不兼容的语法或函数,创建 PDF.js 的副本,将其中核心代码中不兼容的部分根据情况做 polyfill 处理,再结合 gulp 和babel 编译后使用。

Pdf.js基于webComponent 标准技术创建的 pdf 预览组件,具有webComponent的跨框架,跨平台、独立、可复用的特性,与其他技术栈(如vue、react)的组件也能进行互操作。

pdf 预览组件实现

那么,如何使用 litElement 以及 pdfjs-dist 来创建自定义的 pdf 的预览组件呢?我们可以通过使用 litElement 的 customElement 装饰器进行创建一个跨浏览器兼容的 组件。

image.png

根据 litElement 的生命周期钩子函数,可以在特定的时机可以做特定的事情。

image.png

image.png

比如在 firstUpdated 时可以读取pdf文件,解码并渲染,attributeChangedCallback 用来监听属性变更render 中进行渲染等。

canvas 内存击穿

PDF.js 可以通过在浏览器上绘制 canvas 实现 pdf 文件页面的渲染,然而,在浏览器中,canvas 绘制区域是有限制的,当绘制的内容超过浏览器的上限时,后续不论怎么创建 canvas 的上下文都不会生效,而 pdf 文件的总页数也无法控制。针对各个浏览器的canvas 上限,官方提供的上限如下图。

image.png

因此,若我们需要通过 canvas 来渲染 pdf 页面时,需要对绘制部分做优化,不仅要进行分片渲染,还要处理非视口内的已渲染的 canvas,否则将无法渲染新的 canvas。

为此,我们创建3个类来维护 pdf 组件的资源,分别是渲染队列:用于按队列渲染及按队列清理;页面信息类:维护页面的绘制状态、绘制事件、失败重试、重置等功能;PDF信息类:如每一张页面信息PageViewer,总页码,配置分片数据等。

绘制主逻辑

在队列中获取到当前队列中未渲染的页面信息类,然后执行其绘制逻辑,同时监听绘制进度,当绘制完成后再次触发队列获取未渲染的页面信息类,进行渲染下一张页面,直到队列中无未渲染的页面。绘制逻辑如下:

1、在渲染队列中获取当前第一个状态不为finish 的页面类,执行页面类中的绘制事件,若绘制失败则再次初始化页面类状态,重新进入队列。

image.png

2、通过页面类中的page 类的 render 来将页面信息绘制到canvas中,若队列中正在执行绘制,则将页面类放置在等待状态中,等待正在绘制的页面完成后被绘制。

image.png

分片渲染

分片渲染是为了能更好、更快的渲染所处当前视口页码内的 pdf 页面,降低同一时间的 cpu 占比的同时,更快的看到页面的呈现。

通过监听滚动事件,并针对 iOS 的惯性滚动,做防抖处理,渲染滚动到的视口页面,且仅渲染当前视口的3张页面,保证 canvas 的绘制都作用在有效的用户体验上。

image.png

默认渲染当前页上下共3张

image.png

canvas 清理

为了避免 canvas 绘制区域的累积达到上限,我们可以在 js 内存里设置只存储队列里的 10 张绘制好的 canvas,当超过10张时,会按照渲染顺序清理队列里最开始创建的 canvas。众所周知,通过 canvas.remove() 能够去除 dom 树上的节点,但无法清除 canvas 已创建的绘制区域。所以在清理 canvas 时,除了要移除 dom 树上的 canvas,还需要通过以下方式清理掉绘制的 canvas 区域。同时还要清理 PDFPageProxy 中分配的资源。

image.png

保留历史页面10张

image.png

pdf 文件解析

上述基本已能完成 pdf 文件的预览了,但是在读取远程的 pdf 文件时,根据服务的不同,会存在跨域的情况,导致无法正确读取远程 pdf 文件。有3种解决方案,主要都是通过下载文件后再访问来解决。

1、将 pdf 下载到本地,放到工程中进行本地引用,此方式不仅增加工程体积,还不灵活。

2、将 pdf 下载到静态资源服务器上,在工程中访问静态资源服务器上的资源进行实现,此方式需要手动操作,比较低效。

3、通过调用服务器接口由服务器进行资源下载,待下载完成后由服务器提供对应的资源的 ArrayBuffer 进行访问,此方式比较灵活。

可根据情况选择,不过该过程已不在 pdf 预览组件中了,为了保持 pdf 预览组件的功能纯净,pdf 跨域问题可在使用 pdf 预览组件前处理,如第 3 种情况, 便可通过将经服务器提供的 pdf 文件对应的 ArrayBuffer 或者提供的 pdf 文件流,在前端进行转换成ArrayBuffer,传递到组件内进行解读和预览。

流式加载

pdf 文件是一个不可控的资源,可能会存在几十上百兆的场景,这个时候如果等待其下载完成后再进行渲染,体验将非常不好,弱网环境下更糟糕。PDF.js 提供了一种流式加载的功能,主要通过HTTP 的206来实现,在请求过程中接收一部分信息来进行解析并用于渲染。在主文件一直没有下载完成的情况下,默认最多会接收8块。下图为弱网环境下20m的文件下载

image.png

在弱网环境下,由于pdf 主文件过大而一直无法获取完成,但用户已经看完了接收到的文件,则内部会继续提前接收并解析部分信息来用于渲染。

编译与使用

为了能够兼容跨浏览器环境,需要将开发出的 webComponent 组件通过 rollup 编译成 umd 格式的 js 文件,在各个环境框架中都能通过直接引用的方式使用该组件。

为了浏览器的兼容性,我们使用了 litElement 的 customElement 装饰器,然而默认情况下 rollup 无法编译装饰器语法。根据 rollup 和 babel 的版本,需要安装对应版本的 @babel/plugin-proposal-decorators,因为在 Babel 7.10.0 版本之后,对装饰器语法的处理方式发生了变化,在该版本之前,通过以下方式配置:

babel({
  babelHelpers: 'runtime',
  plugins: [
    ['@babel/plugin-proposal-decorators', { legacy: true }]
  ]
})

在该版本之后,使用以下方式:

babel({
  babelHelpers: 'runtime',
  plugins: [
    ['@babel/plugin-proposal-decorators', { decoratorsBeforeExport: true }]
  ]
})

所以编译时除了正常的 commonjs 编译之外,还需要添加以上对装饰器的编译。最终产出一个 umd 模式的 js 文件。

针对编译时,PDF.js 里的一些 node 模块处理,如下图,则可以通过在编译时接入 rollup-plugin-polyfill-node 插件来处理。

const nodePolyfills = require('rollup-plugin-polyfill-node');

plugins:[
  nodePolyfills()
]

接下来便可在各种框架中使用了,以下以基于 Vue 的框架或 Vue 框架中为例,可以通过下面方式忽略自定义组件的编译。

Vue.config.ignoredElements.push('pdf-preview')

在使用的组件中通过 import 将自定义元素注册到 window,此时便可直接使用该组件进行预览 pdf 了。

<template>
  <div>
    <pdf-preview src="http://xxxxx.pdf"></pfe-preview>
  </div>
</template>
<script>
  import 'web-pdf-view/index.js'
</script>

如果想要动态使用该组件,比如在多端环境下,也可以使用 requireimport() 来动态引入组件。

总结

PDF.js通过其卓越的渲染能力和广泛的兼容性,为在线阅读和展示 PDF 文件打开了全新的可能性。结合以 WebComponent 为标准的LitElement,可以创建一个可扩展的、高性能、功能丰富、灵活可定制的PDF 阅读器组件,为用户提供高质量的PDF 展示及灵活的交互体验。