基于vue-pdf实现PDF多页预览

593 阅读2分钟

在Web应用中,PDF预览是一个常见需求。今天我将分享如何使用vue-pdf实现一个支持多页同时预览的PDF查看器组件。

功能需求

我们需要实现一个PDF查看器,具有以下功能:

  • 支持同时显示多页(本文以3页为例)
  • 支持前后翻页浏览
  • 显示加载状态

技术选型

我们使用以下技术:

  • Vue.js:前端框架
  • vue-pdf:pdf.js的Vue封装
  • lodash,throttle:用于函数节流

核心代码实现

组件结构

  <div class="multi-pdf-viewer">
    <div v-if="loading" class="pdf-loading-mask">
      <div class="loading-spinner"></div>
      PDF加载中...
    </div>
    <button
      class="left-btn"
      @click="prevSet"
      :disabled="currentSet === 1 || loading"
    >
      <i class="iconfont icon-zuojiantou"></i>
    </button>
    <div class="pdf-pages">
      <pdf
        :src="pdfSrc"
        :page="startPage"
        class="pdf-page"
        @num-pages="numPages = $event"
        @page-loaded="handlePageLoaded(startPage)"
  ></pdf>
      <pdf
        :src="pdfSrc"
        :page="startPage + 1"
        class="pdf-page"
        @page-loaded="handlePageLoaded(startPage + 1)"
      ></pdf>
      <pdf
        :src="pdfSrc"
        :page="startPage + 2"
        class="pdf-page"
        @page-loaded="handlePageLoaded(startPage + 2)"
      ></pdf>
    </div>
    <button
      class="right-btn"
      @click="nextSet"
      :disabled="currentSet === totalSets || loading"
    >
      <i class="iconfont icon-youjiantou"></i>
    </button>
  </div>
</template> 

JavaScript逻辑

  import { throttle } from 'lodash'
  import pdf from 'vue-pdf'
  import { GlobalWorkerOptions } from 'pdfjs-dist'
  export default {
    name: 'VuePdfViewer',
    components: { pdf },
    props: {
      fileUrl: {
        type: String,
        default: '',
      },
    },
    data() {
      return {
        loading: false,
        currentComp: 'pdf',
        pdfSrc: null,
        numPages: 0,
        currentSet: 1, //当前组
        pagesPerSet: 3, //每组页数
        forceUpdateKey: 0 loadedPages: new Set(), // 记录已加载页面
        loadTimeout: null,
      }
    },
    //切换PDF文件
    watch: {
      fileUrl() {
        this.loadPdf()
      },
    },
    created() {
      GlobalWorkerOptions.workerSrc =
        'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.10.377/build/pdf.worker.min.js'
      this.loadPdf()
    },
    computed: {
      // 计算总组数
      totalSets() {
        return Math.ceil(this.numPages / this.pagesPerSet)
      },
      // 当前组的起始页
      startPage() {
              return (this.currentSet - 1) * this.pagesPerSet + 1
      },
      // 当前组的结束页
      endPage() {
        return Math.min(this.currentSet * this.pagesPerSet, this.numPages)
      },
    },
    methods: {
      // 加载PDF文件
      loadPdf() {
        this.currentSet = 1
        this.loadedPages = new Set()
        this.pdfSrc = pdf.createLoadingTask({
          url: this.fileUrl,
          cMapPacked: true,
        })
        this.pdfSrc.promise
          .then((pdf) => {
           this.numPages = pdf.numPages
            console.log(' this.numPages:', this.numPages)
          })
          .catch((error) => {
            console.error('PDF加载失败:', error)
            this.$baseMessage('PDF加载失败', 'error', 'vab-hey-message2-error')
          })
      },     // 使用 throttle 控制 1s 内只能触发一次翻页
      // 上一组
      prevSet: throttle(function () {
        if (this.currentSet > 1 && !this.loading) {
          this.loading = true
          this.loadedPages = new Set() // 重置加载状态
          this.currentSet--
        }
      }, 1000), // 节流间隔-1s
      // 下一组
      nextSet: throttle(function () {
        if (this.currentSet < this.totalSets && !this.loading) {
          this.loading = true
          this.loadedPages = new Set()
          this.currentSet++
   }
      }, 1000),    //加载页面的回调
      handlePageLoaded(page) {
        clearTimeout(this.loadTimeout)

        if (page >= this.startPage && page <= this.endPage) {
          this.loadedPages.add(page)

          const expectedPageCount = Math.min(
            this.pagesPerSet,
            this.numPages - (this.currentSet - 1) * this.pagesPerSet
          )

          if (this.loadedPages.size >= expectedPageCount) {
            this.$nextTick(() => {
              this.loading = false
            })
         } else {
            // 设置超时,防止某些页面永远不触发loaded事件
            this.loadTimeout = setTimeout(() => {
              if (this.loading) {
                console.warn(
                  `加载超时,已加载 ${this.loadedPages.size}/${expectedPageCount} 页`
                )
                this.loading = false
              }
            }, 5000) // 5秒超时
          }
        }
      }, },
  }
</script> 

样式部分

  .multi-pdf-viewer {
    position: relative;
    width: 100%;
    margin: 0 auto;
    background: #f5f5f5;
    // padding: 20px;
    border-radius: 4px;
    box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
    .left-btn {
      position: absolute;
      top: 50%;
      left: 0;
    }
    .right-btn {
      position: absolute;
      top: 50%;
      right: 0;
    }
    button {
      width: 45px;
      height: 45px;
      border-radius: 50%;
      background: #409eff;
      border: none;
      color: #fff;
      cursor: pointer;
      z-index: 999;
      i {
        font-size: 35px;
        font-weight: 600;
      }
    }

    button:disabled {
      background: #c0c4cc;
      cursor: not-allowed;
    }
  }

  .pdf-pages {
    position: relative;
    display: flex;
    justify-content: center;
    gap: 20px;
    padding: 10px;
    background: white;
    border-radius: 4px;
    box-shadow: 0 0 5px rgba(0, 0, 0, 0.05);
  }

  .pdf-page {
    flex: 1;
    min-width: 0;
    transform-origin: top center;
    box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
  }
  .pdf-controls {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 20px;
    margin-bottom: 20px;
  }

  .page-indicator {
    font-size: 16px;
    color: #333;
  }

  .pdf-loading-mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(255, 255, 255, 0.8);
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    z-index: 10;
    font-size: 16px;
    color: #333;
    border-radius: 4px;
  }

  .loading-spinner {
    border: 4px solid #f3f3f3;
    border-top: 4px solid #3498db;
    border-radius: 50%;
    width: 30px;
    height: 30px;
    animation: spin 1s linear infinite;
    margin-bottom: 10px;
  }

  @keyframes spin {
    0% {
      transform: rotate(0deg);
    }
    100% {
      transform: rotate(360deg);
    }
  }
</style>

实现要点解析

多页渲染

  • 通过currentPages计算属性动态生成需要渲染的页码数组

翻页控制

  • 使用currentSet记录当前组数
  • 通过totalSets计算总组数
  • 翻页按钮使用节流函数(throttle)控制点击频率

加载状态管理

  • 使用loading状态控制加载蒙层显示
  • 通过loadedPages集合记录已加载完成的页面
  • 设置超时机制防止页面加载卡死

性能优化

  • 使用forceUpdateKey强制重新渲染PDF组件
  • 每次翻页时清空已加载页面记录

错误处理

  • 捕获PDF加载错误并显示友好提示
  • 加载超时处理

使用示例:

  <div>
    <vue-pdf-viewer :file-url="pdfUrl" />
  </div>
</template>

<script>
import VuePdfViewer from './components/VuePdfViewer.vue'

export default {
  components: {
    VuePdfViewer
  },
  data() {
    return {
      pdfUrl: '/example.pdf'
    }
  }
}
</script>

当前实现存在的痛点与思考

在我的多页PDF预览组件实现中,还存在一些亟待优化的技术问题:

  1. 硬编码问题
    目前采用固定3页为一组的展示方式,直接在模板中编写了三个<pdf>组件。这种实现方式虽然简单,但缺乏灵活性。如果需要调整为10页一组预览,这种硬编码的方式显然无法满足需求。
  2. 动态渲染挑战
    理论上这部分应该使用v-for循环来实现动态渲染,但在实际尝试过程中遇到了组件销毁相关的报错("... destroyed"错误)。经过多次调试仍未能解决,最终不得不暂时采用编写三个独立组件的方案。
  3. 寻求更好的解决方案
    非常希望能与各位开发者交流,如果您有处理类似场景的经验或更好的实现思路,欢迎在评论区分享您的见解。特别是关于如何优雅地实现动态多页PDF渲染的方案,期待您的宝贵建议!
      v-for="n in visiblePageCount"
      :key="`${currentPage + n - 1}-${refreshKey}`"
      :src="pdfSrc"
      :page="currentPage + n - 1"
      class="pdf-page"
      @page-loaded="onPageLoaded"
    ></pdf>
     ...
computed: {
    visiblePageCount() {
      return Math.min(this.pageSize, this.numPages - this.currentPage + 1)
    }
  },

关于节流功能的使用说明

在实现翻页功能时,我采用了节流(throttle)机制,主要是为了解决以下核心问题:

  1. 频繁点击导致的渲染异常
    当用户快速连续点击翻页按钮时,会出现PDF容器节点已经渲染完成,但实际内容却未能正确加载显示的情况。这种异步渲染的竞态条件会导致用户体验的不连贯。
  2. 性能优化考虑
    不加控制的频繁翻页请求会对浏览器造成不必要的渲染压力,特别是对于大型PDF文档,可能引起界面卡顿或内存问题。
  3. 当前解决方案
    通过lodash的throttle函数将翻页操作限制为每秒最多触发一次(1000ms间隔),这样既保证了操作的流畅性,又避免了上述问题的发生。

当然,这只是一个折中的解决方案。如果有更优雅的处理方式,非常期待各位技术同行的分享和建议。毕竟在用户体验和性能优化方面,总有值得改进的空间。