如何悄悄的封装一个支持图片和PDF在线预览的Vue组件

2,219 阅读8分钟

如何封装一个支持图片和PDF在线预览的Vue组件

在本文中,我将介绍如何设计并实现一个Vue组件,该组件能够在线预览图片和PDF文件。我们将基于element-ui的elImageViewer组件进行改造,并使用vue-pdf插件来实现PDF预览功能。本文将详细介绍从设计思路到落地实现的全过程,完整代码在文末。

设计思路

我们需要一个统一的组件来处理用户上传的图片和PDF文件,并能够在一个视图中展示它们。具体需求包括:

  1. 支持图片和PDF文件的在线预览。
  2. 支持图片和PDF文件的轮播切换。
  3. 支持PDF文件的翻页和下载功能。
  4. 支持图片的缩放、旋转等操作。

为了实现上述需求,我们选择使用element-ui的elImageViewer组件作为基础,扩展其功能以支持PDF文件。我们将使用vue-pdf插件来渲染PDF文件,最终达到图中的效果。

请添加图片描述

实现步骤

1. 安装依赖

首先,我们需要安装element-ui和vue-pdf插件。

npm install element-ui vue-pdf

2. 创建组件

接下来,我们创建一个新的Vue组件PreviewFileViewer,其模板结构如下: 请添加图片描述 整个组件的代码是基于 element-ui 的 elImageViewer 组件进行拓展,elImageViewer 组件位置在我们项目中node_modules/element-ui/packages/image/src/image-viewer,想看下这个组件的原代码可以去这个位置寻找,当然如果你的项目中需要实现图片预览模块可以在 main.js 文件全局引入:

//main.js
// 引入按钮触发图片预览模块
import elImageViewer from "element-ui/packages/image/src/image-viewer";
// 注册图片预览组件
Vue.component("elImageViewer", elImageViewer);

那么我们需要在这个代码中怎样进行拓展让他支持PDF的预览呢,在模板结构中我增加了 carousel 组件的引用,主要是我想偷个懒不用自己去写太多的 CSS 代码,在 el-carousel-item 中使用 v-for 遍历我们接收过来的文件数据,然后通过判断文件的类型来分别展示 img 和 vuePdf。每一页走马灯通过当前页文件的类型来分别展示 img 和 pdf 的 ACTIONS,img 不用说了还是保留原组件的缩放和旋转,pdf 这里我给的是前后翻页、页码展示、重载、下载,后期现场可能还会提一个跳页的需求。

<!-- CANVAS -->
<div class="el-image-viewer__canvas">
  <!-- IMG-->
  <img v-if="isImage(file.fileName)" class="img"
    :src="file.filePath" :style="imgStyle" @load="handleImgLoad" @error="handleImgError"
    @mousedown="handleMouseDown"
  />
  <!-- PDF -->
  <div v-if="isPDF(file.fileName)" class="pdf-container">
    <vuePdf class="pdf" ref="pdf" :src="i === 0 ? file.filePath : currentPdfSrc" :page="currentPage"
      @num-pages="numPages=$event" @loaded="loadPdfHandler"
      @page-loaded="currentPage=$event"
    >
    </vuePdf>
  </div>
</div>

<!-- ACTIONS -->
<div class="el-image-viewer__btn el-image-viewer__actions">
  <div v-if="isImage(file.fileName)" class="el-image-viewer__actions__inner">
    <i class="el-icon-zoom-out" @click="handleActions('zoomOut')"></i>
    <i class="el-icon-zoom-in" @click="handleActions('zoomIn')"></i>
    <i class="el-icon-refresh-left" @click="handleActions('anticlocelise')"></i>
    <i class="el-icon-refresh-right" @click="handleActions('clocelise')"></i>
  </div>
  <div v-if="isPDF(file.fileName)" class="el-image-viewer__actions__inner">
    <i class="el-icon-back" @click="changePdfPage(0)"></i>
    <span>{{ currentPage }} / {{ numPages }}</span>
    <i class="el-icon-right" @click="changePdfPage(1)"></i>
    <i class="el-icon-refresh-right" @click="loadPdfHandler"></i>
    <i class="el-icon-download" @click="downloadPdf(file.filePath)"></i>
  </div>
</div>

3. 脚本逻辑

在脚本部分,我们引入了vue-pdf和一些element-ui的工具函数,并定义了组件的props、data、computed、watch和methods: 请添加图片描述 我这里 carousel 组件的 arrow 属性设置的 never,因为我觉得 carousel 自带的 arrow 有点小,还是使用 elImageViewer 组件中绘制的按钮,在翻页操作时通过 this.$refs.carousel.prev() 、 this.$refs.carousel.next() 对走马灯进行切换。

细心的朋友可能发现了我在轮播图切换时执行了 this.currentPdfSrc = this.fileList[index].filePath 这个方法,在上面模板结构中我对 vuePdf 组件进行渲染时 src 属性使用的是我在 data 中声明的一个变量 currentPdfSrc 而不是直接 :src="file.filePath"。最开始我确实是这么做的,但是发现一个 问题:轮播图翻页时,PDF 文件没有进行更新,导致显示的是旧数据或者上一次加载的页码。

为了解决这个问题,我需要确保在轮播图切换时,vuePdf 组件能够重新加载对应的 PDF 文件。这通常涉及到重新设置 vuePdf 组件的 src 和 page 属性,以反映当前选中的文件路径和页码。

解决方案:

  1. 在你的组件数据中,维护一个与当前显示的 PDF 文件相关的状态,比如 currentPdfSrc 和 currentPage。

  2. 当轮播图的索引改变时,更新这些状态变量以反映新文件的信息。

  3. 将 vuePdf 组件的 src 和 page 属性绑定到这些状态变量上。

通过这样的修改,每当轮播图切换到新的文件时,vuePdf 组件会接收到新的 src 和 page 属性值,从而触发重新加载和显示正确的 PDF 文件及页码。

4. 样式设计

最后,我们为组件添加样式,以确保图片和PDF文件以合适的方式展示: 请添加图片描述

5. 使用组件

现在,我们可以在父组件中使用 PreviewFileViewer 组件。假设我们有一个文件列表,包含图片和PDF文件的信息,我们可以这样使用:

<template>
  <div>
    <button @click="showPreview">查看文件</button>
    <preview-file-viewer
      v-if="isPreviewVisible"
      :urlList="fileList"
      :initialIndex="0"
      @onClose="isPreviewVisible = false"
    />
  </div>
</template>

<script>
import PreviewFileViewer from '@/components/PreviewFileViewer.vue';

export default {
  components: { PreviewFileViewer },
  data() {
    return {
      isPreviewVisible: false,
      fileList: [
        { sImageName: 'image1.jpg', sImageId: '1' },
        { sImageName: 'document.pdf', sImageId: '2' }
      ]
    };
  },
  methods: {
    showPreview() {
      this.isPreviewVisible = true;
    }
  }
};
</script>

组件功能解析

1. 数据和属性

  • urlList:文件列表,包含文件名和文件路径。
  • zIndex:遮罩层的z-index。
  • onSwitch 和 onClose:回调函数,用于处理文件切换和关闭事件。
  • initialIndex:初始索引,默认为0。
  • appendToBody:是否将组件添加到body元素中。
  • maskClosable:是否允许点击遮罩层关闭组件。

2. 计算属性

  • isSingle:判断文件列表是否只有一个文件。
  • isFirst:判断当前文件是否是第一个文件。
  • isLast:判断当前文件是否是最后一个文件。
  • imgStyle:当前图片的样式,根据缩放和旋转等变换设置。
  • viewerZIndex:获取当前组件的z-index。
  • fileList:处理后的文件列表,包含文件名和文件路径。

3. 方法

  • handleChange:处理轮播图切换事件。
  • downloadPdf:下载PDF文件。
  • changePdfPage:PDF文件翻页。
  • loadPdfHandler:PDF文件加载完成事件处理。
  • isImage 和 isPDF:判断文件是否是图片或PDF文件。
  • hide:关闭组件。
  • deviceSupportInstall 和 deviceSupportUninstall:安装和卸载键盘和鼠标事件监听。
  • handleImgLoad 和 handleImgError:图片加载完成和加载失败事件处理。
  • handleMouseDown:鼠标按下事件处理,用于拖动图片。
  • handleMaskClick:处理遮罩层点击事件。
  • reset:重置图片的变换参数。
  • toggleMode:切换图片显示模式。
  • prev 和 next:切换到上一张和下一张文件。
  • handleActions:处理图片缩放和旋转等操作。

总结

通过以上步骤,我们成功地实现了一个支持图片和PDF在线预览的Vue组件。这个组件不仅能够处理图片的缩放、旋转和拖动,还支持PDF文件的翻页和下载功能。

在实际应用中,这个组件可以进一步扩展,例如支持更多文件类型、优化性能和提升用户体验等。希望这篇博客能够为你在开发中提供一些启发和帮助!

如果你在使用这个组件时遇到任何问题,或者有改进建议,欢迎在评论区留言讨论!

示例代码

完整的示例代码如下:

<template>
  <transition name="viewer-fade">
    <div tabindex="-1" ref="el-image-viewer__wrapper" class="el-image-viewer__wrapper" :style="{ 'z-index': viewerZIndex }">
      <!-- CAROUSEL -->>
      <el-carousel v-model="index" arrow="never" height="100vh" :autoplay="false"
        @change="handleChange" ref="carousel" indicator-position="none"
      >
        <el-carousel-item v-for="(file, i) in fileList" :key="i">
          <div class="el-image-viewer__mask" @click.self="handleMaskClick"></div>
          <!-- CLOSE -->
          <span class="el-image-viewer__btn el-image-viewer__close" @click="hide">
            <i class="el-icon-close"></i>
          </span>
          <!-- CANVAS -->
          <div class="el-image-viewer__canvas">
            <!-- IMG-->
            <img v-if="isImage(file.fileName)" class="img"
              :src="file.filePath" :style="imgStyle" @load="handleImgLoad" @error="handleImgError"
              @mousedown="handleMouseDown"
            />
            <!-- PDF -->
            <div v-if="isPDF(file.fileName)" class="pdf-container">
              <vuePdf class="pdf" ref="pdf" :src="i === 0 ? file.filePath : currentPdfSrc" :page="currentPage"
                @num-pages="numPages=$event" @loaded="loadPdfHandler"
                @page-loaded="currentPage=$event"
              >
              </vuePdf>
            </div>
          </div>
          <!-- ARROW -->
          <template v-if="!isSingle">
            <span
              class="el-image-viewer__btn el-image-viewer__prev"
              :class="{ 'is-disabled': !infinite && isFirst }"
              @click="prev">
              <i class="el-icon-arrow-left"/>
            </span>
            <span
              class="el-image-viewer__btn el-image-viewer__next"
              :class="{ 'is-disabled': !infinite && isLast }"
              @click="next">
              <i class="el-icon-arrow-right"/>
            </span>
          </template>
          <!-- ACTIONS -->
          <div class="el-image-viewer__btn el-image-viewer__actions">
            <div v-if="isImage(file.fileName)" class="el-image-viewer__actions__inner">
              <i class="el-icon-zoom-out" @click="handleActions('zoomOut')"></i>
              <i class="el-icon-zoom-in" @click="handleActions('zoomIn')"></i>
              <i class="el-icon-refresh-left" @click="handleActions('anticlocelise')"></i>
              <i class="el-icon-refresh-right" @click="handleActions('clocelise')"></i>
            </div>
            <div v-if="isPDF(file.fileName)" class="el-image-viewer__actions__inner">
              <i class="el-icon-back" @click="changePdfPage(0)"></i>
              <span>{{ currentPage }} / {{ numPages }}</span>
              <i class="el-icon-right" @click="changePdfPage(1)"></i>
              <i class="el-icon-refresh-right" @click="loadPdfHandler"></i>
              <i class="el-icon-download" @click="downloadPdf(file.filePath)"></i>
            </div>
          </div>
        </el-carousel-item>
      </el-carousel>
    </div>
  </transition>
</template>
<script>
  import { on, off } from 'element-ui/src/utils/dom';
import { rafThrottle, isFirefox } from 'element-ui/src/utils/util';
import { PopupManager } from 'element-ui/src/utils/popup';
import vuePdf from 'vue-pdf'
// 模式
const Mode = {
  CONTAIN: {
    name: 'contain',
    icon: 'el-icon-full-screen'
  },
  ORIGINAL: {
    name: 'original',
    icon: 'el-icon-c-scale-to-original'
  }
};
// 兼容火狐
const mousewheelEventName = isFirefox() ? 'DOMMouseScroll' : 'mousewheel';

export default {
  name: 'previewFileViewer',
  components: { vuePdf },
  props: {
    // 图片/pdf 列表
    urlList: {
      type: Array,
      default: () => []
    },
    // 遮罩层z-index
    zIndex: {
      type: Number,
      default: 2000
    },
    // 切换回调
    onSwitch: {
      type: Function,
      default: () => {}
    },
    // 关闭回调
    onClose: {
      type: Function,
      default: () => {}
    },
    // 初始索引
    // 默认为0
    initialIndex: {
      type: Number,
      default: 0
    },
    // 是否append到body
    appendToBody: {
      type: Boolean,
      default: true
    },
    // 是否允许点击遮罩层关闭
    maskClosable: {
      type: Boolean,
      default: true
    }
  },

  data() {
    return {
      index: this.initialIndex,
      isShow: false,
      infinite: true,
      loading: false,
      mode: Mode.ORIGINAL,
      transform: {
        scale: 1,
        deg: 0,
        offsetX: 0,
        offsetY: 0,
        enableTransition: false
      },
      // pdf
      currentPdfSrc: '',
      currentPage: 1,
      numPages: 0
    };
  },
  // 计算属性
  computed: {
    // 判断是否是单张图片或单个pdf
    isSingle() {
      return this.urlList.length <= 1;
    },
    // 判断是否是第一张图片或第一页pdf
    isFirst() {
      return this.index === 0;
    },
    // 判断是否是最后一张图片或最后一页pdf
    isLast() {
      return this.index === this.urlList.length - 1;
    },
    // 当前图片样式
    imgStyle() {
      const { scale, deg, offsetX, offsetY, enableTransition } = this.transform;
      const style = {
        transform: `scale(${scale}) rotate(${deg}deg)`,
        transition: enableTransition ? 'transform .3s' : '',
        'margin-left': `${offsetX}px`,
        'margin-top': `${offsetY}px`
      };
      if (this.mode === Mode.CONTAIN) {
        style.maxWidth = style.maxHeight = '100%';
      }
      return style;
    },
    // 获取当前图片的z-index
    viewerZIndex() {
      const nextZIndex = PopupManager.nextZIndex();
      return this.zIndex > nextZIndex ? this.zIndex : nextZIndex;
    },
    // 获取当前页面数据
    fileList() {
      // 这里可根据自己本地数据结构进行处理;
      // 或者可以在父组件中处理好数据直接传入,那这里就不需要了直接使用父组件传过来的urlList
      return this.urlList.map(el => {
        return {
          fileName: el.sImageName,
          filePath: this.$baseUrl + "/basic/image/view/" + el.sImageId
        }
      });
    },
  },
  // 监听
  watch: {
    index: {
      handler: function(val) {
        this.reset();
        this.onSwitch(val);
      }
    },
  },
  // 挂载钩子
  mounted() {
    console.log('fileList', this.fileList)
    this.deviceSupportInstall();
    if (this.appendToBody) {
      document.body.appendChild(this.$el);
    }
    // add tabindex then wrapper can be focusable via Javascript
    // focus wrapper so arrow key can't cause inner scroll behavior underneath
    this.$refs['el-image-viewer__wrapper'].focus();
  },
  // 销毁钩子
  destroyed() {
    // if appendToBody is true, remove DOM node after destroy
    if (this.appendToBody && this.$el && this.$el.parentNode) {
      this.$el.parentNode.removeChild(this.$el);
    }
  },
  methods: {
    // 轮播图切换触发
    handleChange(index) {
      this.currentPdfSrc = this.fileList[index].filePath;
      this.currentPage = 1;
    },
    // 下载pdf
    downloadPdf(url) {
      window.open(url)
    },
    // pdf翻页
    changePdfPage(val) {
      if (val === 0 && this.currentPage > 1) {
        this.currentPage--
      }
      if (val === 1 && this.currentPage < this.numPages) {
        this.currentPage++
      }
    },
    // pdf加载完成触发
    loadPdfHandler(e) {
      this.currentPage = 1;
    },
    // 是否为图片
    isImage(file) {
      return /\.(jpg|jpeg|png|gif)$/.test(file);
    },
    // 是否为pdf
    isPDF(file) {
      return /\.pdf$/.test(file);
    },
    // 关闭
    hide() {
      this.deviceSupportUninstall();
      this.onClose();
    },
    // 键盘监听事件启动
    deviceSupportInstall() {
      this._keyDownHandler = e => {
        e.stopPropagation();
        const keyCode = e.keyCode;
        switch (keyCode) {
          // ESC
          case 27:
            this.hide();
            break;
          // SPACE
          case 32:
            this.toggleMode();
            break;
          // LEFT_ARROW
          case 37:
            this.prev();
            break;
          // UP_ARROW
          case 38:
            this.handleActions('zoomIn');
            break;
          // RIGHT_ARROW
          case 39:
            this.next();
            break;
          // DOWN_ARROW
          case 40:
            this.handleActions('zoomOut');
            break;
        }
      };
      this._mouseWheelHandler = rafThrottle(e => {
        const delta = e.wheelDelta ? e.wheelDelta : -e.detail;
        if (delta > 0) {
          this.handleActions('zoomIn', {
            zoomRate: 0.015,
            enableTransition: false
          });
        } else {
          this.handleActions('zoomOut', {
            zoomRate: 0.015,
            enableTransition: false
          });
        }
      });
      on(document, 'keydown', this._keyDownHandler);
      on(document, mousewheelEventName, this._mouseWheelHandler);
    },
    // 键盘监听事件卸载
    deviceSupportUninstall() {
      off(document, 'keydown', this._keyDownHandler);
      off(document, mousewheelEventName, this._mouseWheelHandler);
      this._keyDownHandler = null;
      this._mouseWheelHandler = null;
    },
    // 图片加载完成触发
    handleImgLoad(e) {
      this.loading = false;
    },
    // 图片加载失败触发
    handleImgError(e) {
      this.loading = false;
      e.target.alt = '加载失败';
    },
    // 鼠标按下触发
    handleMouseDown(e) {
      if (this.loading || e.button !== 0) return;

      const { offsetX, offsetY } = this.transform;
      const startX = e.pageX;
      const startY = e.pageY;
      this._dragHandler = rafThrottle(ev => {
        this.transform.offsetX = offsetX + ev.pageX - startX;
        this.transform.offsetY = offsetY + ev.pageY - startY;
      });
      on(document, 'mousemove', this._dragHandler);
      on(document, 'mouseup', ev => {
        off(document, 'mousemove', this._dragHandler);
      });

      e.preventDefault();
    },
    // 鼠标滚轮触发
    handleMaskClick() {
      if (this.maskClosable) {
        this.hide();
      }
    },
     // 重置图片基础配置
    reset() {
      this.transform = {
        scale: 1,
        deg: 0,
        offsetX: 0,
        offsetY: 0,
        enableTransition: false
      };
    },
     // 切换模式
    toggleMode() {
      if (this.loading) return;

      const modeNames = Object.keys(Mode);
      const modeValues = Object.values(Mode);
      const index = modeValues.indexOf(this.mode);
      const nextIndex = (index + 1) % modeNames.length;
      this.mode = Mode[modeNames[nextIndex]];
      this.reset();
    },
    // 上一张
    prev() {
      if (this.isFirst && !this.infinite) return;
      const len = this.fileList.length;
      this.index = (this.index - 1 + len) % len;
      this.$refs.carousel.prev();
    },
    // 下一张
    next() {
      if (this.isLast && !this.infinite) return;
      const len = this.fileList.length;
      this.index = (this.index + 1) % len;
      this.$refs.carousel.next();
    },
    // 图片操作区
    handleActions(action, options = {}) {
      if (this.loading) return;
      const { zoomRate, rotateDeg, enableTransition } = {
        zoomRate: 0.2,
        rotateDeg: 90,
        enableTransition: true,
        ...options
      };
      const { transform } = this;
      switch (action) {
        case 'zoomOut':
          if (transform.scale > 0.2) {
            transform.scale = parseFloat((transform.scale - zoomRate).toFixed(3));
          }
          break;
        case 'zoomIn':
          transform.scale = parseFloat((transform.scale + zoomRate).toFixed(3));
          break;
        case 'clocelise':
          transform.deg += rotateDeg;
          break;
        case 'anticlocelise':
          transform.deg -= rotateDeg;
          break;
      }
      transform.enableTransition = enableTransition;
    }
  },
};
</script>

感谢阅读!希望这篇文章对你有所帮助。