引言
最近接到産品需求,使用者需要在我們的站點上線上檢視 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);
}
這裡可能會遇到跨域的問題,不過不是本文重點,不展開講,相信這種小事難不倒聰明的你。
于是乎,啪啪啪幾行代碼迅速搞定給産品示範。然後産品拿了個線上檔案來嘗試效果。。。
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)。
- 擷取遠端的 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 内容的操作都可以通過改對象實作。
- 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 對象。
- 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 執行個體。
- 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 不一緻時,可以将目前頁進行縮放,這樣就将所有頁面的大小轉化成了一樣。但是這樣做使用者體驗會有所影響,因為使用者看到的頁面内容大小可能和他實際上傳的不一樣。
- 可以在伺服器上提前計算好每一頁的頁面大小,傳回給前端。前端在渲染指定頁時,根據伺服器傳回的資料進行來計算頁面位置。但是這樣需要在前端做大量的計算。渲染性能上會受到一些影響。
如果大家還有更好的辦法,歡迎讨論。