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

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

引言

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

组件整体架构

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

1. 组件结构与布局

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

  • 头部区域:包含标题和关闭按钮,用于展示文档标题和控制组件显示/隐藏
  • 内容区域:分为 PDF 页面容器和加载指示器
  • PDF 渲染区域:负责 PDF 页面的渲染和滚动显示
vue 复制代码
<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 管理关键状态:

状态变量 类型 作用
loading boolean 控制加载指示器显示
pdfDoc object PDF 文档实例
totalPages number 文档总页数
scale number 页面缩放比例
currentPageNumber number 当前页码
visiblePages number 可见区域前后渲染页数
renderedPages array 已渲染页面索引
pageHeight number 单页高度
renderingPages Set 正在渲染的页面集合
renderQueue array 页面渲染队列

核心功能实现

1. PDF 文档加载

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

javascript 复制代码
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. 页面清理:自动清理不可见区域的页面,释放资源
javascript 复制代码
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. 错误处理:完善的错误捕获和日志记录机制
javascript 复制代码
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 方法渲染可见区域页面
javascript 复制代码
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. 组件生命周期管理

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

javascript 复制代码
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 预览体验。

全部代码

vue 复制代码
<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>
相关推荐
编程小Y2 小时前
Vue 3 + Vite
前端·javascript·vue.js
GDAL2 小时前
前端保存用户登录信息 深入全面讲解
前端·状态模式
大菜菜2 小时前
Molecule Framework -EditorService API 详细文档
前端
Anita_Sun2 小时前
😋 核心原理篇:线程池的 5 大核心组件
前端·node.js
灼华_2 小时前
Web前端移动端开发常见问题及解决方案(完整版)
前端
_请输入用户名2 小时前
Vue3 Patch 全过程
前端·vue.js
孟祥_成都2 小时前
nest.js / hono.js 一起学!字节团队如何配置多环境攻略!
前端·node.js
用户4099322502122 小时前
Vue3数组语法如何高效处理动态类名的复杂组合与条件判断?
前端·ai编程·trae
山里看瓜2 小时前
解决 iOS 上 Swiper 滑动图片闪烁问题:原因分析与最有效的修复方式
前端·css·ios