如何实现高性能的在线 PDF 预览

🤖 作者简介:水煮白菜王(juejin/csdn同名) ,一位前端劝退师 👻

👀 文章专栏: 高德AMap专栏 ,记录一下平时学习在博客写作中记录,总结出的一些开发技巧✍。

目录

在线预览 PDF 文件通常可通过调用开源库(如 vue-pdf-embed、vue-office)快速实现。但传统方案存在局限性:当文件体积从常见的几百 KB 增至数十 MB 时,若仍采用 "下载完整文件→渲染" 的模式,在低网速环境下,用户需等待较长时间才能看到内容。因此,必须对加载策略进行优化以提升用户体验。


所以我们需要解决的关键问题在于如何让用户快速打开内容,减少等待时间。由于现有方案都是将 pdf 文件内容全部下载完成之后才开始进行渲染,如果文件比较大的时候,用户第一次打开时就可能需要等待很长时间。那么思路有了:我们可不可以不下载全部的文件内容就开始渲染?

思路

graph TD A1[Pdf文件太大] --> B1[需要切片下载] B1 --> C1[开始处理] C1 --> D1[从后端获取切片数据(每五页一个切片)] D1 --> E1[获取总页数及每页PDF的宽高] E1 --> F1[获取当前屏幕宽度] F1 --> G1[和pdf的宽度做对比得到缩小的比例] G1 --> H1[根据比例得出每张pdf页面渲染的宽高和整个所有页面的总高度] H1 --> I1[创建Canvas元素并渲染PDF页面(pdf.js中的方法)] I1 --> J1[创建div元素并且包裹canvas元素,并把div放在页面对应的位置上,使用position:absolute,绝对定位的方式] J1 --> K1[监听滚动事件,使用防抖函数,每隔200ms发一次请求] K1 --> L1[根据滚动的高度计算当前的pdf页面处于哪个切片] L1 --> M1[然后根据当前页面所处的切片,去请求下一个切片,然后把请求回来的数据按照前面的方式进行渲染加载] M1 --> N1[实现做到客户无感知的加载页面] N1 --> O1[达到了切片下载和用户体验的效果] O1 --> P1[更进一步的优化是只保留当前视线和前后的元素,上滑或者下拉的时候页面pdf脱离一定的范围后把这个pdf元素删除] P1 --> Q1[这样就保证了页面中只有10个pdf页面的DOM元素,减少了页面中的dom元素,极大地提高了页面性能]

实现方案

用户不可能一眼看到所有的 PDF 内容,每次只能看到屏幕显示范围内的几页。

将可视范围内的PDF 页面内容优先下载并展示,可视范围外的我们根据用户浏览的实际位置按需下载和渲染。这样就可以减少第一次打开时用户的等待时间了。(类似与数据分页、图片懒加载的思路,目的是提高首屏性能)

以将一个大的 PDF 文件分成多个小文件,即分片。比如某个 PDF 有 250 页,我们按照 5 页一片,将它切分成 50 片,每次只下载用户看到的那一个分片。然后在用户进行滚动翻页的时候,异步的去下载对应包含对应页的分片。

1.服务器对 PDF 文件进行分片

这个实现是由服务器来做,所以交给后端处理。本文不细讲,大家有兴趣的可以去了解 itextpdf 库,它提供了相关 API 对 PDF 进行切片。

要跟后端约定好 PDF 文件分片之后每一片的数据格式。假如分片的大小为5(即每次请求 5 页内容),那么可以定义数据格式如下:

javascript 复制代码
{
  "startPage": 1, // 分片的开始页码
  "endPage": 5, // 分片结束页码
  "totalPage": 100, // pdf 总页数
  "url": "https://test.com/pdf/js.pdf" // 分片内容下载地址
}

2.浏览器(客户端)根据用户交互行为获取并渲染指定的分片

获取并渲染是两个操作。为了保证用户操作(滚动)的流畅性,这两个操作我们都异步进行。至此,我们需要解决的关键问题变成两个:

● 如何下载 PDF 分片

● 如何渲染 PDF 分片

使用了 pdf.js

列举了需要用到的几个 API

获取远程的 pdf 文档
javascript 复制代码
 /**
  * This is the main entry point for loading a PDF and interacting with it.
  * NOTE: If a URL is used to fetch the PDF data a standard XMLHttpRequest(XHR)
  * is used, which means it must follow the same origin rules that any XHR does
  * e.g. No cross domain requests without CORS.
  *
  * @param {string|TypedArray|DocumentInitParameters|PDFDataRangeTransport} src
  * Can be a url to where a PDF is located, a typed array (Uint8Array)
  * already populated with data or parameter object.
  * @returns {PDFDocumentLoadingTask}
  */
 function getDocument(src) {
  // 省略实现
 }

简单的说就是,getDocument 接口可以获取 src 指定的远程 PDF 文件,并返回一个 PDFDocumentLoadingTask 对象。后续所有对 PDF 内容的操作都可以通过改对象实现

PDFDocumentLoadingTask
javascript 复制代码
 /**
  * The loading task controls the operations required to load a PDF document
  * (such as network requests) and provides a way to listen for completion,
  * after which individual pages can be rendered.
  */
 // eslint-disable-next-line no-shadow
 class PDFDocumentLoadingTask {
   // 省略 n 行实现

    /**
      * Promise for document loading task completion.
      * @type {Promise}
      */
     get promise() {
       return this._capability.promise;
     }
 }

PDFDocumentLoadingTask 是一个下载远程 PDF 文件的任务。它提供了一些监听方法,可以监听 PDF 文件的下载状态。通过 promise 可以获取到下载完成的 PDF 对象,它会生成并最终返回一个 PDFDocumentProxy 对象。

PDFDocumentProxy
javascript 复制代码
/**
* Proxy to a PDFDocument in the worker thread. Also, contains commonly used
* properties that can be read synchronously.
*/
class PDFDocumentProxy {
 // 省略 n 行实现

 /**
  * @type {number} Total number of pages the PDF contains.
  */
 get numPages() {
   return this._pdfInfo.numPages;
 }

  /**
  * @param {number} pageNumber - The page number to get. The first page is 1.
  * @returns {Promise} A promise that is resolved with a {@link PDFPageProxy}
  *   object.
  */
 getPage(pageNumber) {
   return this._transport.getPage(pageNumber);
 }
}

PDFDocumentProxy 是 PDF 文档代理类,我们可以通过它的 numPages 获取到文档的页面数量,通过 getPage 方法获取到指定页码的页面 PDFPageProxy 实例。

PDFPageProxy
javascript 复制代码
 /**
  * Proxy to a PDFPage in the worker thread.
  * @alias PDFPageProxy
  */
 class PDFPageProxy {
  // 省略 n 行实现

   /**
    * @param {GetViewportParameters} params - Viewport parameters.
    * @returns {PageViewport} Contains 'width' and 'height' properties
    *   along with transforms required for rendering.
    */
   getViewport({
     scale,
     rotation = this.rotate,
     offsetX = 0,
     offsetY = 0,
     dontFlip = false,
   } = {}) {
     return new PageViewport({
       viewBox: this.view,
       scale,
       rotation,
       offsetX,
       offsetY,
       dontFlip,
     });
   }

   /**
    * Begins the process of rendering a page to the desired context.
    * @param {RenderParameters} params Page render parameters.
    * @returns {RenderTask} An object that contains the promise, which
    *                       is resolved when the page finishes rendering.
    */
   render({
     canvasContext,
     viewport,
     intent = "display",
     enableWebGL = false,
     renderInteractiveForms = false,
     transform = null,
     imageLayer = null,
     canvasFactory = null,
     background = null,
   }) {
    // 省略方法实现
   }
 }

PDFPageProxy 我们主要用到它的两个方法。通过 getViewport 可以根据指定的缩放比例(scale)、旋转角度(rotation)获取当前 PDF 页面的实际大小。通过 render 方法可以将 PDF 的内容渲染到指定的 canvas 上下文中。

实现细节

下载 PDF 分片

首先我们使用 PDF.js 提供的接口获取第一个分片的 url,然后再下载该分片的 PDF 文件。

javascript 复制代码
/*
  代码中使用 loadStatus 来记录特定页的内容是否一件下载
*/
const pageLoadStatus = {
  WAIT: 0, // 等待下下载
  LOADED: 1, // 已经下载
}
// 拿到第一个分片
const { startPage, totalPage, url } = await fetchPdfFragment(1);
if (!pages) {
  const pages = initPages(totalPage);
}
const loadingTask = PDFJS.getDocument(url);
loadingTask.promise.then((pdfDoc) => {
  // 将已经下载的分片保存到 pages 数组中
  for (let i = 0; i < pdfDoc.numPages; i += 1) {
    const pageIndex = startPage + i;
    const page = pages[pageIndex - 1];
    if (page.loadStatus !== pageLoadStatus.LOADED) {
        pdfDoc.getPage(i + 1).then((pdfPage) => {
        page.pdfPage = pdfPage;
        page.loadStatus = pageLoadStatus.LOADED;
        // 通知可以进行渲染了
        startRenderPages();
      });
    }
  }
});
// 从服务器获取分片
asycn function fetchPdfFragment(pageIndex) {
  /* 
    省略具体实现
    该方法从服务器获取包含指定页码(pageIndex)的 pdf 分片内容,
    返回的格式参考上文约定:
    {
      "startPage": 1, // 分片的开始页码
      "endPage": 5, // 分片结束页码
      "totalPage": 100, // pdf 总页数
      "url": "http://test.com/asset/fhdf82372837283.pdf" // 分片内容下载地址
    }
  */ 
}
// 创建一个 pages 数组来保存已经下载的 pdf 
function initPages (totalPage) {
  const pages = [];
  for (let i = 0; i < totalPage; i += 1) {
    pages.push({
      pageNo: i + 1,
      loadStatus: pageLoadStatus.WAIT,
      pdfPage: null,
      dom: null
    });
  }
}

渲染 PDF 分片

PDF 分片内容下载完成之后,我们就可以将其渲染到页面上。渲染之前,我们需要知道 PDF 页面的大小。调用 PDF.js 提供的方法,我们能够根据当前 PDF 的缩放比例、选择角度来获取页面的实际大小。

javascript 复制代码
// 获取单页高度
const viewport = pdfPage.getViewport({
  scale: 1, // 缩放的比例
  rotation: 0, // 旋转的角度
});
// 记录pdf页面高度
const pageSize = {
  width: viewport.width,
  height: viewport.height,
}

然后我们需要创建一个内容渲染的区域,需要计算出内容的总高度(总高度 = 单页高度 * 总页数)。

javascript 复制代码
// 为了不让内容太拥挤,我们可以加一些页面间距 PAGE_INTVERVAL
const PAGE_INTVERVAL = 10;
// 创建内容绘制区,并设置大小
const contentView = document.createElement('div');
contentView.style.width = `${this.pageSize.width}px`;
contentView.style.height = `${(totalPage * (pageSize.height + PAGE_INTVERVAL)) + PAGE_INTVERVAL}px`;
pdfContainer.appendChild(contentView);

之后我们就可以根据 pdf 的页码来将其内容渲染到指定区域

javascript 复制代码
// 我们可以通过 scale 和 rotaion 的值来控制 pdf 文档缩放、旋转
let scale = 1;
let rotation = 0;
function renderPageContent (page) {
  const { pdfPage, pageNo, dom } = page;
  // dom 元素已存在,无须重新渲染,直接返回
  if (dom) {
    return;
  }
  const viewport = pdfPage.getViewport({
    scale: scale,
    rotation: rotation,
  });
  // 创建新的canvas
  const canvas = document.createElement('canvas');
  const context = canvas.getContext('2d');
  canvas.height = pageSize.height;
  canvas.width = pageSize.width;
  // 创建渲染的dom
  const pageDom = document.createElement('div');
  pageDom.style.position = 'absolute';
  pageDom.style.top = `${((pageNo - 1) * (pageSize.height + PAGE_INTVERVAL)) + PAGE_INTVERVAL}px`;
  pageDom.style.width = `${pageSize.width}px`;
  pageDom.style.height = `${pageSize.height}px`;
  pageDom.appendChild(canvas);
  // 渲染内容
  pdfPage.render({
    canvasContext: context,
    viewport,
  });
  page.dom = pageDom;
  contentView.appendChild(pageDom);
}

滚动加载内容

上面我们已经将第一个分片进行了展示,但是当用户进行滚动时,我们需要更新内容的显示。首先根据滚动的位置,计算出当前需要展示的页面,然后下载包含该页面的分片。

javascript 复制代码
// 监听容器的滚动事件,触发 scrollPdf 方法
// 这里加了防抖保证不会一次产生过多请求
scrollPdf = _.debounce(() => {
  const scrollTop = pdfContainer.scrollTop;
  const height = pdfContainer.height;
  // 根据内容可视区域中心点计算页码, 没有滚动时,指向第一页
  const pageIndex = scrollTop > 0 ?
        Math.ceil((scrollTop + (height / 2)) / (pageSize.height + PAGE_INTVERVAL)) :
        1;
  loadBefore(pageIndex);
  loadAfter(pageIndex);
}, 200)
// 假定每个分片的大小是 5 页
const SLICE_COUNT = 5;
// 获取当前页之前页面的分片
function loadBefore (pageIndex) {
  const start = (Math.floor(pageIndex / SLICE_COUNT) * SLICE_COUNT) - (SLICE_COUNT - 1);
  if (start > 0) {
    const prevPage = pages[start - 1] || {};
    prevPage.loadStatus === pageLoadStatus.WAIT && loadPdfData(start);
  }
}
// 获取当前页之后页面的分片
function loadAfter (pageIndex) {
  const start = (Math.floor(pageIndex / SLICE_COUNT) * SLICE_COUNT) + 1;
  if (start <= pages.length) {
    const nextPage = pages[start - 1] || {};
    nextPage.loadStatus === pageLoadStatus.WAIT && loadPdfData(start);
  }
}

优化

PDF 文件可能会很大,比如一个 1000 页的 PDF 文件。随着用户的滚动浏览,它会一直渲染,如果最终同时将 1000 个页面的 dom 全部放到页面上。那么内存占用将会非常多,导致页面卡顿。因此,为了减少内存占用,我们可以将当前可视范围之外的页面元素清除。

javascript 复制代码
// 首先我们获取到需要渲染的范围
// 根据当前的可视范围内的页码,我们前后只保留 10 页
function getRenderScope (pageIndex) {
  const pagesToRender = [];
  let i = pageIndex - 1;
  let j = pageIndex + 1;
  pagesToRender.push(pages[pageIndex - 1]);
  while (pagesToRender.length < 10 && pagesToRender.length < pages.length) {
    if (i > 0) {
      pagesToRender.push(pages[i - 1]);
      i -= 1;
    }
    if (pagesToRender.length >= 10) {
      break;
    }
    if (j <= pages.length) {
      pagesToRender.push(this.pages[j - 1]);
      j += 1;
    }
  }
  return pagesToRender;
}
// 渲染需要展示的页面,不需展示的页码将其清除
function renderPages (pageIndex) {
  const pagesToRender = getRenderScope(pageIndex);
  for (const i of pages) {
    if (pagesToRender.includes(i)) {
      i.loadStatus === pageLoadStatus.LOADED ?
        renderPageContent(i) :
        renderPageLoading(i);
    } else {
      clearPage(i);
    }
  }
}
// 清除页面 dom
function clearPage (page) {
  if (page.dom) {
    contentView.removeChild(page.dom);
    page.dom = undefined;
  }
}
// 页面正在下载时渲染loading视图
function renderPageLoading (page) {
  const { pageNo, dom } = page;
  if (dom) {
    return;
  }
  const pageDom = document.createElement('div');
  pageDom.style.width = `${pageSize.width}px`;
  pageDom.style.height = `${pageSize.height}px`;
  pageDom.style.position = 'absolute';
  pageDom.style.top = `${
    ((pageNo - 1) * (pageSize.height + PAGE_INTVERVAL)) + PAGE_INTVERVAL
  }px`;
  /*
  	此处在dom 上添加 loading 组件,省略实现
  */
  page.dom = pageDom;
  contentView.appendChild(pageDom);
}

至此,实现了 PDF 文件的分片展示。保证了第一次用户就可以很快看到文件内容,同时在用户在滚动浏览时不会感觉到有卡顿

总结&反思

优化点 简要说明 相关技术/工具
分片下载 将大PDF文件按页分割,按需加载分片,减少首次加载时间。 服务器端使用itextpdf切片,客户端通过PDF.js的getDocument获取分片。
按需渲染 仅渲染用户可视区域的PDF页面,非可视区域延迟加载。 PDF.js的getPagerender方法,结合IntersectionObserver实现懒加载。
滚动加载 监听滚动事件,预加载当前页前后分片,保证用户无感知翻页。 防抖函数(如Lodash的debounce)、异步请求分片数据。
内存优化 清理超出可视范围的DOM元素,减少内存占用。 动态创建/销毁Canvas元素,保持仅10页左右的DOM节点。
预计算页面大小 服务器返回每页尺寸,避免前端计算误差。 后端通过PDF解析库(如itextpdf)获取每页宽高,前端缓存并渲染。
渐进式加载 先展示低分辨率预览,再逐步加载高清内容。 分阶段请求不同质量的PDF分片数据。
加载状态管理 展示加载中动画,提升用户体验。 在未加载的PDF位置插入占位符或Loading组件。
响应式布局 根据屏幕宽度动态调整PDF缩放比例。 使用CSS媒体查询或JavaScript计算缩放因子。
缓存策略 缓存已下载的分片,减少重复请求。 浏览器本地存储(如localStorage)或内存缓存。
错误处理 处理分片下载失败、网络中断等异常情况。 重试机制、友好的错误提示界面。

在程序设计中,遇到请求数据较大、任务执行时间过长等场景时很容易想到通过数据切分、任务分片等方式来提升程序在系统中的执行&响应效果。本文介绍的问题便是将大的 PDF 文件拆分,然后根据用户的交互行为按需加载,从而达到提升用户在线阅读体验的目的。

我们也可以通过 IntersectionObserver API 结合容器 margin 的调整来实现 PDF 内容的滚动及页面元素的复用

上述方案在进行页面渲染时,会预先初始化整个容器( contentView)的大小。并且我们是根据第一次获取的 PDF 页面的大小进行计算容器高度的(页面高度 * 总页数)。这里有一个前提,就是我们假定所有的 PDF 页面大小是一样的,但在实际场景中,很可能出现同一个 PDF 文档中,页面大小不一样的情况。这时就会出现加载页面位置不准确或者内容展示被遮挡的情况。

针对上述问题,目前我们思考了两种方案:

  • 将大小不一样的页面进行缩放。当我们发现页面大小和保存的 pageSize 不一致时,可以将当前页进行缩放,这样就将所有页面的大小转化成了一样。但是这样做用户体验会有所影响,因为用户看到的页面内容大小可能和他实际上传的不一样,导致内容失真。
  • 可以在服务器上提前计算好每一页的页面大小,返回给前端。前端在渲染指定页时,根据服务器返回的数据进行来计算页面位置。但是这样需要在前端做大量的计算,前端需承担额外的计算成本。渲染性能上会受到一些影响。

如果你觉得这篇文章对你有帮助,请点赞 👍、收藏 👏 并关注我!👀

相关推荐
恋猫de小郭8 小时前
Flutter Zero 是什么?它的出现有什么意义?为什么你需要了解下?
android·前端·flutter
崔庆才丨静觅15 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606115 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了15 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅15 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅16 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅16 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment16 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅17 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊17 小时前
jwt介绍
前端