pdf.js在iOS移动端分页加载优化方案(ios移动端反复刷新加载问题)

背景与问题

在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();
  }
}

性能优化措施

  1. PDF加载配置

    • disableAutoFetch: true - 禁用自动获取
    • disableStream: true - 禁用流式加载
    • disableRange: true - 禁用范围请求
    • useSystemFonts: true - 使用系统字体
  2. 渲染优化

    • 动态计算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文件,可考虑进一步优化:

  1. 实现页面卸载逻辑
  2. 添加LRU缓存策略
  3. 支持更精细的缩放级别控制

完整代码

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();
  }
}