背景与问题
在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();}
}