Vue3 PDF 预览组件设计与实现分析

193 阅读9分钟

Vue3 PDF 预览组件设计与实现分析

引言

PDF 预览是现代 Web 应用中常见的功能需求,尤其是在文档管理、在线阅读等场景下。本文将深入分析一个基于 Vue3 和 PDF.js 实现的高性能 PDF 预览组件,探讨其设计思路、核心功能和优化策略,为开发者提供参考。

组件整体架构

该组件采用 Vue3 的 Composition API 开发,结合 PDF.js 库实现 PDF 文档的加载、渲染和交互。整体架构分为以下几个主要部分:

1. 组件结构与布局

组件采用了清晰的三层布局结构:

  • 头部区域:包含标题和关闭按钮,用于展示文档标题和控制组件显示/隐藏
  • 内容区域:分为 PDF 页面容器和加载指示器
  • PDF 渲染区域:负责 PDF 页面的渲染和滚动显示
<template>
  <div class="pdf-viewer-container" ref="container">
    <div class="pdf-header">
      <!-- 头部标题和关闭按钮 -->
    </div>
    <div class="pdf-content">
      <div class="pdf-pages-wrapper">
        <div class="pdf-pages-container" ref="pagesContainer" @scroll.passive="handleScroll">
          <div class="pdf-canvas-container" ref="canvasContainer"></div>
        </div>
        <!-- PDF加载中指示器 -->
        <div class="spin-container" v-if="loading">
          <n-spin size="large" />
        </div>
      </div>
    </div>
  </div>
</template>

2. 核心状态管理

组件通过 Vue3 的响应式 API 管理关键状态:

状态变量类型作用
loadingboolean控制加载指示器显示
pdfDocobjectPDF 文档实例
totalPagesnumber文档总页数
scalenumber页面缩放比例
currentPageNumbernumber当前页码
visiblePagesnumber可见区域前后渲染页数
renderedPagesarray已渲染页面索引
pageHeightnumber单页高度
renderingPagesSet正在渲染的页面集合
renderQueuearray页面渲染队列

核心功能实现

1. PDF 文档加载

组件在 onMounted 钩子中调用 initPDF 方法初始化 PDF 文档:

const initPDF = async () => {
  try {
    loading.value = true;
    await nextTick();
    
    const loadingTask = pdfjsLib.getDocument({
      url: props.src,
      cMapUrl: "/cmaps/",
      cMapPacked: true,
    });
    const doc = await loadingTask.promise;
    pdfDoc.value = doc;
    totalPages.value = doc.numPages;
    
    // 设置默认缩放比例和页面高度
    const defaultScale = 1.0;
    scale.value = defaultScale;
    
    if (totalPages.value > 0) {
      const firstPage = await doc.getPage(1);
      const viewport = firstPage.getViewport({ scale: defaultScale });
      pageHeight.value = viewport.height;
    }
    
    // 渲染可见区域页面
    await renderVisiblePages();
    loading.value = false;
  } catch (error) {
    loading.value = false;
    console.error("PDF加载失败:", error);
  }
};

2. 虚拟滚动机制

为了优化大型 PDF 文档的性能,组件实现了虚拟滚动机制,只渲染可见区域附近的页面:

  1. 可见区域计算:通过 getVisiblePageRange 方法计算当前可见区域需要渲染的页面范围
  2. 渲染队列管理:使用 renderQueueisRenderingQueue 控制页面渲染顺序
  3. 异步渲染:采用异步方式渲染页面,避免阻塞主线程
  4. 页面清理:自动清理不可见区域的页面,释放资源
const getVisiblePageRange = () => {
  if (!pagesContainer.value) return { start: 1, end: Math.min(visiblePages.value, totalPages.value) };
  const currentPage = currentPageNumber.value;
  let start = Math.max(1, currentPage - 5);
  let end = Math.min(totalPages.value, currentPage + 5);
  start = Math.max(1, start - 2);
  end = Math.min(totalPages.value, end + 2);
  return { start, end };
};

3. 页面渲染逻辑

页面渲染是组件的核心功能,通过 renderPage 方法实现:

  1. Canvas 创建与管理:为每个页面创建独立的 Canvas 元素,并按顺序插入到容器中
  2. 页面渲染:使用 PDF.js 的 page.render() 方法将页面内容渲染到 Canvas 上
  3. 缩放处理:根据当前缩放比例调整 Canvas 显示大小
  4. 错误处理:完善的错误捕获和日志记录机制
const renderPage = async (pageNum) => {
  if (!pdfDoc.value || !canvasContainer.value) return;
  if (pageNum < 1 || pageNum > totalPages.value) return;

  // 检查页面是否正在渲染中,如果是则跳过
  if (renderingPages.value.has(pageNum)) {
    return;
  }

  // 标记页面正在渲染
  renderingPages.value.add(pageNum);

  try {
    const page = await pdfDoc.value.getPage(pageNum);
    const viewport = page.getViewport({
      scale: 2.0,
      rotation: 0 
    });

    // 创建或获取 Canvas 元素
    let canvas = pageCanvasRefs.value[pageNum];
    if (!canvas) {
      // Canvas 创建逻辑...
    }

    // 设置 Canvas 尺寸
    canvas.width = Math.round(viewport.width);
    canvas.height = Math.round(viewport.height);

    // 获取绘图上下文并渲染页面
    const context = canvas.getContext('2d');
    if (!context) return;

    await page.render({
      canvasContext: context,
      viewport,
    }).promise;

    // 应用当前缩放比例
    canvas.style.transform = `scale(${scale.value})`;
  } catch (error) {
    console.error(`渲染页面 ${pageNum} 失败:`, error);
  } finally {
    // 标记页面渲染完成
    renderingPages.value.delete(pageNum);
  }
};

4. 滚动事件处理

组件通过监听滚动事件实现页面的动态加载和清理:

  1. 防抖处理:使用 lodash-esdebounce 函数优化滚动事件,避免频繁触发
  2. 当前页码计算:根据滚动位置计算当前浏览的页码
  3. 动态渲染:调用 renderVisiblePages 方法渲染可见区域页面
const handleScroll = debounce(() => {
  if (!pagesContainer.value) return;
  const scrollContainer = pagesContainer.value;
  const scrollTop = scrollContainer.scrollTop;
  const gap = props.pageGap || 20;
  const unit = pageHeight.value + gap;
  let page = 1;
  if (unit > 0) {
    page = Math.floor(scrollTop / unit) + 1;
  }
  if (page < 1) page = 1;
  if (page > totalPages.value) page = totalPages.value;
  currentPageNumber.value = page;
  renderVisiblePages();
}, 500);

性能优化策略

1. 虚拟滚动优化

  • 渲染范围控制:只渲染可见区域前后各 7 页(总共 15 页左右)
  • 动态清理:自动清理距离可见区域较远的页面,释放内存和 DOM 节点
  • 渲染队列:采用队列机制管理页面渲染,避免同时渲染过多页面导致性能问题

2. 渲染性能优化

  • 高分辨率渲染:使用 2.0 倍缩放渲染 Canvas,提高页面清晰度
  • 按需渲染:仅在页面进入可见区域时渲染,避免不必要的计算和绘制
  • 异步渲染:页面渲染采用异步方式,不阻塞主线程

3. 内存管理

  • Canvas 复用:对于已经渲染过的页面,保存 Canvas 引用,避免重复创建
  • 及时清理:组件卸载时释放 PDF 文档实例和 Canvas 资源
  • 渲染状态管理:使用 Set 数据结构跟踪正在渲染的页面,避免重复渲染

代码亮点与最佳实践

1. 事件处理优化

  • 使用 passive 滚动事件@scroll.passive="handleScroll" 提高滚动性能
  • 防抖处理:减少滚动事件触发频率,优化性能

2. 组件生命周期管理

在组件卸载时,释放所有资源,避免内存泄漏:

onUnmounted(() => {
  if (pdfDoc.value) {
    pdfDoc.value.destroy();
  }
  for (const key in pageCanvasRefs.value) {
    const c = pageCanvasRefs.value[Number(key)];
    if (c && c.parentNode) c.parentNode.removeChild(c);
  }
  pageCanvasRefs.value = {};
  renderedPages.value = [];
});

应用场景与扩展方向

该组件适用于以下场景:

  • 文档管理系统:用于在线预览上传的 PDF 文档
  • 在线阅读平台:提供流畅的 PDF 阅读体验
  • 报表系统:用于预览和导出报表文档
  • 教育平台:在线教材、课件预览

扩展方向

  • 添加页码导航:允许用户直接跳转到指定页码
  • 实现缩放控制:提供缩放按钮,允许用户调整页面大小
  • 添加文本搜索功能:支持在 PDF 文档中搜索文本
  • 实现页面旋转:允许用户旋转页面方向
  • 添加书签功能:支持添加和管理文档书签

总结

本文深入分析了一个基于 Vue3 和 PDF.js 实现的高性能 PDF 预览组件,探讨了其设计思路、核心功能和优化策略。该组件通过虚拟滚动、按需渲染、异步处理等技术,实现了高效的 PDF 文档预览功能,具有良好的性能表现和用户体验。

对于开发者来说,该组件提供了一个优秀的参考案例,展示了如何在 Vue3 项目中实现复杂的第三方库集成和高性能交互功能。通过学习其设计思想和实现细节,开发者可以更好地理解和应用 Vue3 的 Composition API,以及如何进行前端性能优化。

随着 Web 技术的不断发展,PDF 预览功能的需求将越来越多样化和复杂化。开发者可以在此基础上,结合实际业务需求,进一步扩展和优化该组件,提供更加丰富和高效的 PDF 预览体验。

全部代码

<template>
  <div class="pdf-viewer-container" ref="container">
    <div class="pdf-header">
      <div class="pdf-header-title">
        <h2>{{ props.title }}</h2>
      </div>
      <div class="pdf-header-actions">
        <n-button quaternary circle @click="close">
          <template #icon>
            <n-icon>
              <Close />
            </n-icon>
          </template>
        </n-button>
      </div>
    </div>
    <div class="pdf-content">
      <!-- 右侧主内容 - 多页容器 -->
      <div class="pdf-pages-wrapper">
        <div class="pdf-pages-container" ref="pagesContainer" :style="{
          position: 'absolute',
          top: '0',
          right: '0',
          bottom: '0',
          left: '0',
          '--page-gap': pageGap + 'px',
        }" @scroll.passive="handleScroll">
          <div class="pdf-canvas-container" ref="canvasContainer"></div>
        </div>
        <!-- PDF加载中指示器 -->
        <div class="spin-container" v-if="loading">
          <n-spin size="large" />
        </div>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import {
  ref,
  onMounted,
  onUnmounted,
  shallowRef,
  nextTick,
} from "vue";
import * as pdfjsLib from "pdfjs-dist/build/pdf";
import { NIcon, NSpin } from "naive-ui";
import {debounce} from 'lodash-es'
import {
  Close,
} from "@vicons/carbon";
const loading = ref(false);
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.js', import.meta.url).href;

const props = defineProps({
  src: {
    type: String,
    required: true,
  },
  title: {
    type: String,
    default: 'pdf预览',
  },
  pageGap: {
    type: Number,
    default: 20,
  },
});
const pagesContainer = ref<HTMLDivElement | null>(null);
const canvasContainer = ref<HTMLDivElement | null>(null);
const pageCanvasRefs = ref<Record<number, HTMLCanvasElement>>({});
const pdfDoc = shallowRef<any>(null);
const totalPages = ref(0);
const scale = ref(2.0);
const currentPageNumber = ref(1);
// 渲染单页PDF到canvas
const renderPage = async (pageNum) => {
  if (!pdfDoc.value || !canvasContainer.value) return;
  if (pageNum < 1 || pageNum > totalPages.value) return;

  // 检查页面是否正在渲染中,如果是则跳过
  if (renderingPages.value.has(pageNum)) {
    return;
  }

  // 标记页面正在渲染
  renderingPages.value.add(pageNum);

  try {
    const page = await pdfDoc.value.getPage(pageNum);
    const viewport = page.getViewport({
      scale: 2.0,
      rotation: 0 
    });

    let canvas = pageCanvasRefs.value[pageNum];
    if (!canvas) {
      // 创建新canvas
      canvas = document.createElement('canvas');
      canvas.className = `pdf-page-canvas page-${pageNum}`;
      canvas.style.display = 'block';
      canvas.style.margin = '0 auto var(--page-gap, 20px) auto';
      canvas.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.15)';
      canvas.style.backgroundColor = 'white';
      canvas.style.transition = 'box-shadow var(--transition-speed) ease, transform var(--transition-speed) ease';
      canvas.style.transformOrigin = 'center center';

      // 确保页面按顺序添加到DOM中
      // 查找当前页码应该插入的位置
      const existingPages = Array.from(canvasContainer.value.children);
      let insertIndex = existingPages.length;

      for (let i = 0; i < existingPages.length; i++) {
        const child = existingPages[i];
        if (child.className.includes('pdf-page-canvas')) {
          const childPageNum = parseInt(child.className.match(/page-(\d+)/)[1]);
          if (childPageNum > pageNum) {
            insertIndex = i;
            break;
          }
        }
      }

      // 按顺序插入canvas
      canvasContainer.value.insertBefore(canvas, existingPages[insertIndex] || null);
      pageCanvasRefs.value[pageNum] = canvas;
    }

    // 设置canvas尺寸
    canvas.width = Math.round(viewport.width);
    canvas.height = Math.round(viewport.height);

    // 获取绘图上下文
    const context = canvas.getContext('2d');
    if (!context) return;

    // 渲染页面
    await page.render({
      canvasContext: context,
      viewport,
    }).promise;

    // 应用当前的缩放transform
    canvas.style.transform = `scale(${scale.value})`;
  } catch (error) {
    console.error(`渲染页面 ${pageNum} 失败:`, error);
  } finally {
    // 标记页面渲染完成
    renderingPages.value.delete(pageNum);
  }
};
// 虚拟滚动相关变量
const visiblePages = ref(10); // 可见区域前后各渲染5页,总共10页
const renderedPages = ref([]); // 当前已渲染的页面索引
const pageHeight = ref(0); // 单页高度
const renderingPages = ref(new Set()); // 正在渲染的页面集合
// 页面渲染队列,确保按顺序渲染
const renderQueue = ref([]);
let isRenderingQueue = ref(false);

// 计算可见区域的页面范围
const getVisiblePageRange = () => {
  if (!pagesContainer.value) return { start: 1, end: Math.min(visiblePages.value, totalPages.value) };
  const currentPage = currentPageNumber.value;
  let start = Math.max(1, currentPage - 5);
  let end = Math.min(totalPages.value, currentPage + 5);
  start = Math.max(1, start - 2);
  end = Math.min(totalPages.value, end + 2);
  return { start, end };
};
// 处理渲染队列
const processRenderQueue = async () => {
  if (isRenderingQueue.value) return;
  isRenderingQueue.value = true;
  try {
    while (renderQueue.value.length > 0) {
      const pageNum= renderQueue.value.shift();
      // 跳过已经渲染或正在渲染的页面
      if (renderedPages.value.includes(pageNum) || renderingPages.value.has(pageNum)) {
        continue;
      }
      try {
        // 渲染页面
        await renderPage(pageNum);
        // 页面渲染完成后添加到已渲染列表
        if (!renderedPages.value.includes(pageNum)) {
          renderedPages.value.push(pageNum);
          renderedPages.value.sort((a, b) => a - b);
        }
      } catch (innerError) {
        console.error(`渲染页面 ${pageNum} 失败:`, innerError);
      }
    }
  } catch (error) {
    console.error('渲染队列处理异常:', error);
  } finally {
    isRenderingQueue.value = false;
  }
};

// 渲染可见区域页面
const renderVisiblePages = async () => {
  if (!pdfDoc.value || !canvasContainer.value) return;
  const { start, end } = getVisiblePageRange();
  // 收集需要渲染的页面
  const pagesToRender = [];
  const currentQueueSet = new Set(renderQueue.value);
  for (let i = start; i <= end; i++) {
    if (!renderedPages.value.includes(i) &&
      !renderingPages.value.has(i) &&
      !currentQueueSet.has(i)) {
      pagesToRender.push(i);
    }
  }
  if (pagesToRender.length > 0) {
    // 添加到渲染队列,按顺序渲染
    renderQueue.value.push(...pagesToRender.sort((a, b) => a - b));
    // 处理渲染队列
    processRenderQueue();
  }
  // 清理不可见的页面(确保不在渲染中)
  const pagesToRemove = renderedPages.value.filter(
    pageNum => pageNum < start - 5 || pageNum > end + 5
  );
  for (const pageNum of pagesToRemove) {
    // 检查页面是否正在渲染中,如果是则跳过清理
    if (renderingPages.value.has(pageNum)) {
      continue;
    }
    const canvas = pageCanvasRefs.value[pageNum];
    if (canvas && canvas.parentNode) {
      canvas.parentNode.removeChild(canvas);
      delete pageCanvasRefs.value[pageNum];
    }
    renderedPages.value = renderedPages.value.filter(p => p !== pageNum);
  }
};

// 初始化 PDF
const initPDF = async () => {
  try {
    loading.value = true;
    await nextTick();
    // 确保容器存在且样式满足要求
    if (!pagesContainer.value || !canvasContainer.value) {
      await nextTick();
    }
    const loadingTask = pdfjsLib.getDocument({
      url: props.src,
      cMapUrl: "/cmaps/",
      cMapPacked: true,
    });
    const doc = await loadingTask.promise;
    pdfDoc.value = doc;
    totalPages.value = doc.numPages;
    // 设置默认缩放比例
    const defaultScale = 1.0;
    scale.value = defaultScale;
    // 计算页面高度和位置
    if (totalPages.value > 0) {
      const firstPage = await doc.getPage(1);
      const viewport = firstPage.getViewport({ scale: defaultScale });
      pageHeight.value = viewport.height;
    }
    // 渲染可见区域页面
    await renderVisiblePages();
    loading.value = false;
  } catch (error) {
    loading.value = false;
    console.error("PDF加载失败:", error);
  }
};

const handleScroll = debounce(() => {
  if (!pagesContainer.value) return;
  const scrollContainer = pagesContainer.value;
  const scrollTop = scrollContainer.scrollTop;
  const gap = props.pageGap || 20;
  const unit = pageHeight.value + gap;
  let page = 1;
  if (unit > 0) {
    page = Math.floor(scrollTop / unit) + 1;
  }
  if (page < 1) page = 1;
  if (page > totalPages.value) page = totalPages.value;
  currentPageNumber.value = page;
  renderVisiblePages();
}, 500);
onMounted(() => {
  initPDF();
});

onUnmounted(() => {
  if (pdfDoc.value) {
    pdfDoc.value.destroy();
  }
  for (const key in pageCanvasRefs.value) {
    const c = pageCanvasRefs.value[Number(key)];
    if (c && c.parentNode) c.parentNode.removeChild(c);
  }
  pageCanvasRefs.value = {};
  renderedPages.value = [];
});

const emit = defineEmits(["close"]);
const close = () => {
  emit("close");
};
</script>

<style lang="less" scoped>
/* 主容器样式 */
.pdf-viewer-container {
  align-items: center;
  display: flex;
  flex-direction: column;
  height: 100%;
  position: relative;
  width: 100%;
}

/* 图标激活状态 */
.n-icon.active {
  color: #1890ff;
}

.pdf-header {
  align-items: center;
  background-color: #fff;
  border-bottom: 1px solid rgba(0, 0, 0, 0.08);
  box-sizing: border-box;
  display: flex;
  gap: 50px;
  height: 56px;
  justify-content: space-between;
  padding: 0 15px;
  width: 100%;
  &-title {
    h2 {
      color: rgb(51, 54, 57);
      font-size: 18px;
      font-weight: 400;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
  }
}

.pdf-content {
  display: flex;
  flex: 1;
  overflow: hidden;
  position: relative;
  width: 100%;
}

.spin-container {
  position: absolute;
  height: 100%;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(255, 255, 255, 0.9);
  z-index: 10;
}

.pdf-pages-wrapper {
  background-color: #f4f5f7;
  box-sizing: border-box;
  flex: 1;
  padding: 20px;
  position: relative;
}

// PDF页面容器样式
.pdf-pages-container {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  overflow-y: auto;
  overflow-x: auto;
  padding: 0 40px;
  background-color: var(--bg-color);
  // 响应式调整
  @media (max-width: 768px) {
    padding: 0 20px;
  }
  @media (max-width: 480px) {
    padding: 0 10px;
  }
}

/* PDF Canvas Container */
.pdf-canvas-container {
  display: block;
  margin: 0 auto;
  padding: 20px 0;
  width: 100%;
}
// PDF页面Canvas样式优化
.pdf-page-canvas {
  display: block;
  margin: 0 auto var(--page-gap, 20px) auto;
  border: none;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  background: white;
  transition: box-shadow var(--transition-speed) ease;
  // 页面悬停效果
  &:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
  }
  // 响应式页面间距
  @media (max-width: 768px) {
    --page-gap: 15px;
  }
  @media (max-width: 480px) {
    --page-gap: 10px;
  }
}
</style>