天天看點

【Web技術】969- 如何實作高性能的線上 PDF 預覽

【Web技術】969- 如何實作高性能的線上 PDF 預覽

引言

最近接到産品需求,使用者需要在我們的站點上線上檢視 PDF 檔案,并且檢視時,使用者可以對 PDF 檔案的進行旋轉、縮放、跳轉到指定頁碼等操作。

這個太簡單了,随便找找就一堆輪子。

目前常見的線上 PDF 檢視方案:

  • 使用 iframe、embed、object 标簽直接加載

采用此方案,隻需要直接将 PDF 的線上位址設定為标簽的 src 屬性

  • 使用第三方庫 PDF.js 加載

這個方案麻煩一點,我們需要在項目中引入 PDF.js 這個庫,然後再使用 iframe 來加載指定的 HTML 檔案(下文代碼中的 viewer.html ),并且将需要通路的 PDF 的線上位址作為參數傳遞進去。大概就像下面一樣:

showPdf (selector, options) {
  const { width, height, fileUrl } = options;
  this.pdfFrame = document.createElement('iframe');
  this.pdfFrame.width = width;
  this.pdfFrame.height = height;
  this.pdfFrame.src = `./assets/web/viewer.html?file=${encodeURIComponent(fileUrl)}`;
  document.getElementById(selector).append(this.pdfFrame);
}      

這裡可能會遇到跨域的問題,不過不是本文重點,不展開講,相信這種小事難不倒聰明的你。

于是乎,啪啪啪幾行代碼迅速搞定給産品示範。然後産品拿了個線上檔案來嘗試效果。。。

【Web技術】969- 如何實作高性能的線上 PDF 預覽

BEDC8D6B-827A-4883-8A27-52B6372517A5.png

兩人對着白屏尴尬的沉默良久,産品終于忍不住了。

“這怎麼這麼慢?不行,使用者肯定不能接受。。。”

“公司網絡不好... 你這檔案太大了... 你重新開機一下試試?“

不存在的,作為一個優秀的前端開發者,怎麼可以說這種話,當然是想辦法解決啦。

重新整理一下産品的需求:

  • 頁面上檢視伺服器上的 pdf 檔案
  • 支援頁碼跳轉、旋轉、縮放
  • 打開要快

基本上前兩條上述方案都能滿足,是以我們需要解決的關鍵問題在于如何讓使用者快速打開内容,減少等待時間。由于現有方案都是将 pdf 檔案内容全部下載下傳完成之後才開始進行渲染,如果檔案比較大的時候,使用者第一次打開時就可能需要等待很長時間。那麼思路有了:我們可不可以不下載下傳全部的檔案内容就開始渲染?

方案思路 - PDF 内容分片加載

因為使用者不可能一眼看到所有的 PDF 内容,每次隻能看到螢幕顯示範圍内的幾頁。是以我們可以将可視範圍内的PDF 頁面内容優先下載下傳并展示,可視範圍外的我們根據使用者浏覽的實際位置按需下載下傳和渲染。這樣就可以減少第一次打開時使用者的等待時間了。(類似與資料分頁、圖檔懶加載的思想,目的是提高首屏性能。)

那麼我們可以将一個大的 PDF 檔案分成多個小檔案,即分片。比如某個 PDF 有 200 頁,我們按照 5 頁一片,将它切分成 40 片,每次隻下載下傳使用者看到的那一個分片。然後在使用者進行滾動翻頁的時候,異步的去下載下傳對應包含對應頁的分片。

基本的思路有了,接下來就是想辦法實作了。要實作分片加載我們需要做兩件事情:

1、伺服器對 PDF 檔案進行分片

由于這個是伺服器做了,是以,交給後端就好了。本文不細講,大家有興趣的可以去了解 itextpdf (https://api.itextpdf.com/iText5/java/5.5.11/) 庫,它提供了相關 API 對 PDF 進行切片。

我們需要跟後端約定好 PDF 檔案分片之後每一片的資料格式。假如分片的大小為 5(即每次請求 5 頁内容),那麼可以定義資料格式如下:

{
  "startPage": 1, // 分片的開始頁碼
  "endPage": 5, // 分片結束頁碼
  "totalPage": 100, // pdf 總頁數
  "url": "http://test.com/asset/fhdf82372837283.pdf" // 分片内容下載下傳位址
}      

2、用戶端根據使用者互動行為擷取并渲染指定的分片

顯然,擷取并渲染是兩個操作。為了保證使用者操作(滾動)的流暢性,這兩個操作我們都異步進行。至此,我們需要解決的關鍵問題變成兩個:

  • 如何下載下傳 PDF 分片
  • 如何渲染 PDF 分片

知識準備 - PDF.js 接口介紹

由于我們無法在已有标簽上做修改,是以我們考慮基于 PDF.js 庫進行深度定制。那麼我們先了解一下 PDF.js 可以為我們提供哪些能力。參考 官方文檔 (https://mozilla.github.io/pdf.js),下面列舉了我們需要用到的幾個 API ,由于官方文檔中内容比較粗,這裡貼上了源碼中的注釋。另附 源碼位址 (https://github.com/mozilla/pdf.js/blob/12aba0f91a5cd3e36fa81cb799540f8073990831/src/display/api.js#L431)。

  1. 擷取遠端的 pdf 文檔
/**
  * 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 内容的操作都可以通過改對象實作。

  1. PDFDocumentLoadingTask
/**
  * 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 對象。

  1. PDFDocumentProxy
/**
* 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 執行個體。

  1. PDFPageProxy
/**
  * 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 檔案。

/*
  代碼中使用 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 的縮放比例、選擇角度來擷取頁面的實際大小。

// 擷取單頁高度
const viewport = pdfPage.getViewport({
  scale: 1, // 縮放的比例
  rotation: 0, // 旋轉的角度
});
// 記錄pdf頁面高度
const pageSize = {
  width: viewport.width,
  height: viewport.height,
}      

然後我們需要建立一個内容渲染的區域,需要計算出内容的總高度(總高度 = 單頁高度 * 總頁數)。

// 為了不讓内容太擁擠,我們可以加一些頁面間距 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 的頁碼來将其内容渲染到指定區域。

// 我們可以通過 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);
}      

滾動加載内容

上面我們已經将第一個分片進行了展示,但是當使用者進行滾動時,我們需要更新内容的顯示。首先根據滾動的位置,計算出目前需要展示的頁面,然後下載下傳包含該頁面的分片。

// 監聽容器的滾動事件,觸發 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 全部放到頁面上。那麼記憶體占用将會非常多,導緻頁面卡頓。是以,為了減少記憶體占用,我們可以将目前可視範圍之外的頁面元素清除。

// 首先我們擷取到需要渲染的範圍
// 根據目前的可視範圍内的頁碼,我們前後隻保留 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 檔案拆分,然後根據使用者的互動行為按需加載,進而達到提升使用者線上閱讀體驗的目的。

當然上述方案還存在很多優化空間,比如我們可以通過 IntersectionObserver API 結合容器 margin 的調整來實作 PDF 内容的滾動及頁面元素的複用。具體的實作大家有興趣可以自己嘗試。

實際使用場景中,我們也遇到了一些坑。上述方案在進行頁面渲染時,會預先初始化整個容器( contentView)的大小。并且我們是根據第一次擷取的 PDF 頁面的大小進行計算容器高度的(頁面高度 * 總頁數)。這裡有一個前提,就是我們假定所有的 PDF 頁面大小是一樣的,但在實際場景中,很可能出現同一個 PDF 文檔中,頁面大小不一樣的情況。這時就會出現加載頁面位置不準确或者内容展示被遮擋的情況。

針對上述問題,目前我們思考了兩種方案:

  • 将大小不一樣的頁面進行縮放。當我們發現頁面大小和儲存的 pageSize 不一緻時,可以将目前頁進行縮放,這樣就将所有頁面的大小轉化成了一樣。但是這樣做使用者體驗會有所影響,因為使用者看到的頁面内容大小可能和他實際上傳的不一樣。
  • 可以在伺服器上提前計算好每一頁的頁面大小,傳回給前端。前端在渲染指定頁時,根據伺服器傳回的資料進行來計算頁面位置。但是這樣需要在前端做大量的計算。渲染性能上會受到一些影響。

如果大家還有更好的辦法,歡迎讨論。

繼續閱讀