背景与问题
在iOS移动端加载大型PDF文件时,由于设备内存限制,经常遇到以下问题:
- 内存不足导致页面崩溃
- 大文件加载缓慢
- 页面反复重新加载
##解决方案
采用PDF.js的分页加载策略,实现按需加载当前可视页面及相邻页面,减少内存占用。
核心实现代码
typescript
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页,提升浏览体验
typescript
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
优化滚动性能 - 防抖处理避免频繁计算
typescript
function handleScroll() {
if (isScrolling) return;
isScrolling = true;
requestAnimationFrame(async () => {
const newPage = calculateCurrentPage();
if (newPage !== currentVisiblePage) {
currentVisiblePage = newPage;
await loadVisiblePages();
}
isScrolling = false;
});
}
3. 页面位置计算
基于视口中心点计算当前最接近的页面:
typescript
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. 内存管理
虽然注释掉了卸载逻辑,但保留了卸载能力:
typescript
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显示样式
typescript
canvas.style.width = `${document.body.clientWidth}px`;
canvas.style.height = `${document.body.clientWidth / (canvas.width / canvas.height)}px`;
总结
该方案通过以下方式解决了iOS移动端PDF加载问题:
- 分页按需加载降低内存占用
- 智能预加载提升用户体验
- 优化的滚动计算确保流畅性
- 完善的错误处理增强稳定性
对于超大PDF文件,可考虑进一步优化:
- 实现页面卸载逻辑
- 添加LRU缓存策略
- 支持更精细的缩放级别控制
完整代码
typescript
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();
}
}