背景與問題
在iOS移動端加載大型PDF文件時,由于設備內存限制,經常遇到以下問題:
- 內存不足導致頁面崩潰
- 大文件加載緩慢
- 頁面反復重新加載
##解決方案
采用PDF.js的分頁加載策略,實現按需加載當前可視頁面及相鄰頁面,減少內存占用。
核心實現代碼
let pdfDoc: pdf.PDFDocumentProxy;
let currentVisiblePage = 1;
let isScrolling = false;async function loadPdf(url: string) {try {// 先下載為 Blob(兼容 iOS 緩存)const blob = await fetch(url).then((res) => res.blob());const blobUrl = URL.createObjectURL(blob);const loadingTask = pdf.getDocument({url: blobUrl,disableAutoFetch: true,disableStream: true,disableRange: true,useSystemFonts: true,});pdfDoc = await loadingTask.promise;await loadVisiblePages();window.addEventListener("scroll", handleScroll, { passive: true });} catch (error) {console.error("PDF加載失敗:", error);}
}
關鍵技術點
1. 分頁加載策略
- 初始化加載:僅加載第一頁
- 滾動監聽:動態加載當前可視頁面
- 預加載:同時加載當前頁后2頁,提升瀏覽體驗
async function loadVisiblePages() {if (!pdfDoc) return;const startPage = currentVisiblePage;const endPage = Math.min(pdfDoc.numPages, currentVisiblePage + 2);for (let i = startPage; i <= endPage; i++) {if (!document.getElementById(`the-canvas${i}`)) {await renderPage(i);}}
}
2. 滾動優化處理
- 使用
requestAnimationFrame
優化滾動性能 - 防抖處理避免頻繁計算
function handleScroll() {if (isScrolling) return;isScrolling = true;requestAnimationFrame(async () => {const newPage = calculateCurrentPage();if (newPage !== currentVisiblePage) {currentVisiblePage = newPage;await loadVisiblePages();}isScrolling = false;});
}
3. 頁面位置計算
基于視口中心點計算當前最接近的頁面:
function calculateCurrentPage(): number {const scrollPosition = window.scrollY || window.pageYOffset;const viewportCenter = scrollPosition + window.innerHeight / 2;let closestPage = currentVisiblePage;let minDistance = Infinity;canvases.forEach((canvas) => {const pageNum = parseInt(canvas.id.replace("the-canvas", ""));const rect = canvas.getBoundingClientRect();const pageCenter = (rect.top + rect.bottom) / 2 + scrollPosition;const distance = Math.abs(pageCenter - viewportCenter);if (distance < minDistance) {minDistance = distance;closestPage = pageNum;}});return Math.max(1, Math.min(closestPage, pdfDoc.numPages));
}
4. 內存管理
雖然注釋掉了卸載邏輯,但保留了卸載能力:
function unloadPage(pageNum: number) {const canvas = document.getElementById(`the-canvas${pageNum}`);if (canvas) {const page = (canvas as any)._pdfPage;if (page) {page.cleanup();page._destroy();}canvas.remove();}
}
性能優化措施
-
PDF加載配置:
disableAutoFetch
: true - 禁用自動獲取disableStream
: true - 禁用流式加載disableRange
: true - 禁用范圍請求useSystemFonts
: true - 使用系統字體
-
渲染優化:
- 動態計算canvas尺寸適配屏幕
- 使用CSS控制canvas顯示樣式
canvas.style.width = `${document.body.clientWidth}px`;
canvas.style.height = `${document.body.clientWidth / (canvas.width / canvas.height)}px`;
總結
該方案通過以下方式解決了iOS移動端PDF加載問題:
- 分頁按需加載降低內存占用
- 智能預加載提升用戶體驗
- 優化的滾動計算確保流暢性
- 完善的錯誤處理增強穩定性
對于超大PDF文件,可考慮進一步優化:
- 實現頁面卸載邏輯
- 添加LRU緩存策略
- 支持更精細的縮放級別控制
完整代碼
import * as pdf from 'pdfjs-dist';pdf.GlobalWorkerOptions.workerSrc = 'path/to/pdf.worker.js';let pdfDoc: pdf.PDFDocumentProxy;
let currentVisiblePage = 1;
let isScrolling = false;async function loadPdf(url: string) {try {// 先下載為 Blob(兼容 iOS 緩存)const blob = await fetch(url).then((res) => res.blob());const blobUrl = URL.createObjectURL(blob);const loadingTask = pdf.getDocument({url: blobUrl,disableAutoFetch: true,disableStream: true,disableRange: true,useSystemFonts: true,});pdfDoc = await loadingTask.promise;// 初始化加載第一頁await loadVisiblePages();// 添加滾動監聽window.addEventListener("scroll", handleScroll, { passive: true });} catch (error) {console.error("PDF加載失敗:", error);}
}function handleScroll() {if (isScrolling) return;isScrolling = true;requestAnimationFrame(async () => {const newPage = calculateCurrentPage();if (newPage !== currentVisiblePage) {currentVisiblePage = newPage;await loadVisiblePages();}isScrolling = false;});
}function calculateCurrentPage(): number {if (!pdfDoc || !document.getElementById("pdfViewerPages")) {return currentVisiblePage;}const scrollPosition = window.scrollY || window.pageYOffset;const pdfContainer = document.getElementById("pdfViewerPages")!;const containerTop = pdfContainer.offsetTop;const relativeScroll = scrollPosition - containerTop;const viewportCenter = relativeScroll + window.innerHeight / 2;const canvases = Array.from(document.querySelectorAll('canvas[id^="the-canvas"]'));// 找出距離視口中心最近的頁面let closestPage = currentVisiblePage;let minDistance = Infinity;canvases.forEach((canvas) => {const pageNum = parseInt(canvas.id.replace("the-canvas", ""));const rect = canvas.getBoundingClientRect();const pageTop = rect.top + scrollPosition - containerTop;const pageBottom = rect.bottom + scrollPosition - containerTop;const pageCenter = (pageTop + pageBottom) / 2;const distance = Math.abs(pageCenter - viewportCenter);if (distance < minDistance) {minDistance = distance;closestPage = pageNum;}});return Math.max(1, Math.min(closestPage, pdfDoc.numPages));
}async function loadVisiblePages() {if (!pdfDoc) return;// 加載可見頁(當前頁及后兩頁)const startPage = currentVisiblePage;const endPage = Math.min(pdfDoc.numPages, currentVisiblePage + 2);for (let i = startPage; i <= endPage; i++) {if (!document.getElementById(`the-canvas${i}`)) {try {await renderPage(i);} catch (error) {console.error(`渲染第 ${i} 頁失敗:`, error);}}}// 下載完成時,loading消失loading.value = false;
}async function renderPage(pageNum: number) {const page = await pdfDoc.getPage(pageNum);const canvas = document.createElement("canvas");canvas.id = `the-canvas${pageNum}`;canvas.className = "pdf-page";const scaledViewport = page.getViewport({ scale: 1 }); // 縮放后的視口canvas.height = Math.floor(scaledViewport.height); // 設置畫布的高度canvas.width = Math.floor(scaledViewport.width); // 設置畫布的寬度canvas.style.width = `${document.body.clientWidth}px`; // 設置畫布的寬度canvas.style.height = `${document.body.clientWidth / (canvas.width / canvas.height)}px`; // 設置畫布的高度// 設置canvas樣式Object.assign(canvas.style, {display: "block",margin: "10px auto",boxShadow: "0 2px 5px rgba(0,0,0,0.1)",});await page.render({canvasContext: canvas.getContext("2d")!,viewport: scaledViewport,}).promise;document.getElementById("pdfViewerPages")?.appendChild(canvas);(canvas as any)._pdfPage = page;
}function unloadPage(pageNum: number) {const canvas = document.getElementById(`the-canvas${pageNum}`);if (canvas) {const page = (canvas as any)._pdfPage;if (page) {try {page.cleanup();page._destroy();} catch (e) {console.warn(`卸載頁面 ${pageNum} 時出錯:`, e);}}canvas.remove();}
}