Vue3 组件示例工程(四) —— 图片组件

目录

1.图片组件 image-viewer 编写

2.图片组件 image-base 编写

3.图片组件 image-base 使用


1.图片组件 image-viewer 编写

  • 效果展示:
  • 使用该组件时,当前页面会被加上黑色遮罩,屏幕中间会放大图片,屏幕四周会展示一些操作按钮,用于实现轮播、关闭、放大缩小、全屏、旋转等功能

  • 组件名称:TImageViewer

  • 定义关键字段及图标名(全屏 / 恢复原状)
  • const Mode = {
      CONTAIN: {
        name: 'contain',
        icon: 'el-icon-full-screen',
      },
      ORIGINAL: {
        name: 'original',
        icon: 'el-icon-c-scale-to-original',
      },
    };

  • 定义 图片操作选项 关键字(缩小 / 放大 / 向左旋转 / 向右旋转)
  • export type ImageViewerAction = 'zoomIn' | 'zoomOut' | 'clocelise' | 'anticlocelise' 

  • 定义接受的图片数据类型:
  • export interface ImageData {
      src: string; // 图片地址
      desc?: string; // 图片描述信息
    }

  • 可供接受的参数(props):
  props: {
    // 图片列表
    urls: {
      type: Array as PropType<ImageData[]>,
      default: () => ([]),
    },
    // 全屏展示时,该组件的默认层级
    zIndex: {
      type: Number,
      default: 9000,
    },
    // 默认展示的图片编号
    initialIndex: {
      type: Number,
      default: 0,
    },
    //
    infinite: {
      type: Boolean,
      default: true,
    },
    //
    hideOnClickModal: {
      type: Boolean,
      default: false,
    },
    // 默认图片进入的动画
    animateClass: {
      type: String,
      default: 'fadeInDown',
    },
  },

  // 监听事件
  emits: ['close', 'change'],
  • 该组件监听(发送)了两个事件:关闭大屏展示的事件 / 改变当前展示图片的事件
  • 注意:组件的监听事件需要在 setup() 上方进行声明,关键字段 emits: [ ]

  • 组件模板:
<template>
  <!-- 设置层级 -->
  <div id="t-image-viewer" class="t-image-viewer" :style="{ zIndex }">
    <div class="t-image-viewer__wrapper">
      <!-- 遮罩层 -->
      <div
        class="t-image-viewer__mask"
        @click.self="hideOnClickModal && hide()"
      />
      <!-- 关闭按钮 -->
      <span class="t-image-viewer__btn t-image-viewer__close" @click="hide">
        <i class="el-icon-close" />
      </span>
      <!-- 左右切换箭头 -->
      <template v-if="!isSingle">
        <span
          class="t-image-viewer__btn t-image-viewer__prev"
          :class="{ 'is-disabled': !infinite && isFirst }"
          @click="prev"
        >
          <i class="el-icon-arrow-left" />
        </span>
        <span
          class="t-image-viewer__btn t-image-viewer__next"
          :class="{ 'is-disabled': !infinite && isLast }"
          @click="next"
        >
          <i class="el-icon-arrow-right" />
        </span>
      </template>
      <!-- 图片描述 -->
      <div
        v-if="currentImg.desc"
        class="t-image-viewer__btn t-image-viewer__desc"
      >
        {{ currentImg.desc }}
      </div>
      <!-- 操作按钮 -->
      <div class="t-image-viewer__btn t-image-viewer__actions">
        <div class="t-image-viewer__actions__inner">
          <!-- 放大 -->
          <i class="el-icon-zoom-out" @click="handleActions('zoomOut')" />
          <!-- 缩小 -->
          <i class="el-icon-zoom-in" @click="handleActions('zoomIn')" />
          <i class="t-image-viewer__actions__divider" />
          <!-- 全屏切换 -->
          <i :class="mode.icon" @click="toggleMode" />
          <i class="t-image-viewer__actions__divider" />
          <!-- 左旋转 -->
          <i
            class="el-icon-refresh-left"
            @click="handleActions('anticlocelise')"
          />
          <!-- 右旋转 -->
          <i
            class="el-icon-refresh-right"
            @click="handleActions('clocelise')"
          />
        </div>
      </div>
      <!-- 大图 -->
      <div
        class="t-image-viewer__canvas opacity-hide"
        :class="[isAddClass === true?'opacity-show animated '+animateClass:'']"
      >
        <img
          v-for="(url, i) in urls"
          v-show="i === index"
          ref="img"
          :key="url"
          :src="currentImg.src"
          :style="imgStyle"
          class="t-image-viewer__img"
          @load="handleImgLoad"
          @error="handleImgError"
          @mousedown="handleMouseDown"
        />
      </div>
    </div>
  </div>
</template>
  • 该模板包含了以下内容:遮罩层、关闭按钮、左右切换箭头、图片描述信息、图片下方操作按钮、图片展示区域

  • 定义需要的变量:
    let dragHandler = null;

    // 是否加载中,默认为加载中
    const loading = ref(true);

    // 是否添加动画类名,默认不添加
    const isAddClass = ref(false);

    // 当前图片编号
    const index = ref(props.initialIndex);

    // 图片对象
    const img = ref(null);

    // 当前展示模式,默认全屏展示
    const mode = ref(Mode.CONTAIN);

    // 变换效果
    const transform = ref({
      scale: 1,
      deg: 0,
      offsetX: 0,
      offsetY: 0,
      enableTransition: false,
    });

    // 是否显示左右箭头 - 图片数目≤1时,不显示左右箭头 
    const isSingle = computed(() => {
      const { urls } = props;
      return urls.length <= 1;
    });

    // 是否是第一张图片
    const isFirst = computed(() => index.value === 0);

    // 是否是最后一张图片
    const isLast = computed(() => index.value === 0);

    // 当前图片
    const currentImg = computed(() => props.urls[index.value]);

  • 图片变化样式事件
    /**
     * 图片变化的样式
     */
    const imgStyle = computed(() => {
      const {
        scale, deg, offsetX, offsetY, enableTransition,
      } = transform.value;
      const style = {
        transform: `scale(${scale}) rotate(${deg}deg)`,
        transition: enableTransition ? 'transform .3s' : '',
        marginLeft: `${offsetX}px`,
        marginTop: `${offsetY}px`,
      } as CSSStyleDeclaration;
      if (mode.value.name === Mode.CONTAIN.name) {
        style.maxWidth = '100%';
        style.maxHeight = '100%';
      }
      return style;
    });

  • 关闭事件:
    /**
     * 关闭事件
     */
    function hide() {
      emit('close');
    }

  • 图片加载完成 / 加载失败:
    /**
     * 图片加载完成
     */
    function handleImgLoad() {
      loading.value = false;
      isAddClass.value = true;
    }

    /**
     * 图片加载失败
     */
    function handleImgError() {
      loading.value = false;
    }

  • 一些神奇的事件
   // eslint-disable-next-line no-undef
    function rafThrottle(fn) {
      let locked = false;
      return function (...args: any[]) {
        if (locked) return;
        locked = true;
        window.requestAnimationFrame(() => {
          fn.apply(this, args);
          locked = false;
        });
      };
    }

   /* istanbul ignore next */
    const on = function (
      element: HTMLElement | Document | Window,
      event: string,
      handler: EventListenerOrEventListenerObject,
      useCapture = false,
    ): void {
      if (element && event && handler) {
        element.addEventListener(event, handler, useCapture);
      }
    };

    /* istanbul ignore next */
    const off = function (
      element: HTMLElement | Document | Window,
      event: string,
      handler: EventListenerOrEventListenerObject,
      useCapture = false,
    ) {
      if (element && event && handler) {
        element.removeEventListener(event, handler, useCapture);
      }
    };

    function handleMouseDown(e: MouseEvent) {
      if (loading.value || e.button !== 0) return;

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

      e.preventDefault();
    }

    function reset() {
      transform.value = {
        scale: 1,
        deg: 0,
        offsetX: 0,
        offsetY: 0,
        enableTransition: false,
      };
    }

  • 图片操作按钮事件:
    /**
     * 点击底下的工具条操作
     */
    function handleActions(action: ImageViewerAction, options = {}) {
      if (loading.value) return;
      const { zoomRate, rotateDeg, enableTransition } = {
        zoomRate: 0.2,
        rotateDeg: 90,
        enableTransition: true,
        ...options,
      };
      if (action === 'zoomOut') {
        if (transform.value.scale > 0.2) {
          transform.value.scale = parseFloat((transform.value.scale - zoomRate).toFixed(3));
        }
      } else if (action === 'zoomIn') {
        transform.value.scale = parseFloat((transform.value.scale + zoomRate).toFixed(3));
      } else if (action === 'clocelise') {
        transform.value.deg += rotateDeg;
      } else if (action === 'anticlocelise') {
        transform.value.deg -= rotateDeg;
      }
      transform.value.enableTransition = enableTransition;
    }

  • 切换大小图
    /**
     * 切换大小图
     */
    function toggleMode() {
      if (loading.value) return;
      const modeNames = Object.keys(Mode);
      const modeValues = Object.values(Mode);
      const currentMode = mode.value.name;
      const index = modeValues.findIndex((i) => i.name === currentMode);
      const nextIndex = (index + 1) % modeNames.length;
      mode.value = Mode[modeNames[nextIndex]];
      reset();
    }

  • 图片切换
   /**
     * 上一张
     */
    function prev() {
      if (isFirst.value && !props.infinite) return;
      const len = props.urls.length;
      index.value = (index.value - 1 + len) % len;
      isAddClass.value = false;
    }

    /**
     * 下一张
     */
    function next() {
      if (isLast.value && !props.infinite) return;
      const len = props.urls.length;
      index.value = (index.value + 1) % len;
      isAddClass.value = false;
    }

  • watch 事件
    watch(currentImg, () => {
      nextTick(() => {
        const $img = img.value;
        if (!$img.complete) {
          loading.value = true;
        }
      });
    });

    watch(index, (val) => {
      reset();
      emit('change', val);
    });

  • 将组件挂在到 body 里
    onMounted(() => {
      // 将组件挂载到body标签里
      nextTick(() => {
        const body = document.querySelector('body');
        if (body.append) {
          body.append(document.getElementById('t-image-viewer'));
        } else {
          body.appendChild(document.getElementById('t-image-viewer'));
        }
      });
    });
  • 默认样式的设置:
// 固定定位,铺满屏幕
.t-image-viewer{
  position:fixed;
  top:0;
  right:0;
  bottom:0;
  left:0;

  &__wrapper{
    position:fixed;
    top:0;
    right:0;
    bottom:0;
    left:0;
  }

  // 按钮通用样式
  &__btn {
    position: absolute;
    z-index: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    opacity: .8;
    cursor: pointer;
    box-sizing: border-box;
    user-select: none; // 控制页面文字不被选中
  }

  // 关闭按钮定位
  &__close {
    top: 40px;
    right: 40px;
  }

  // 大图展示
  &__canvas {
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;

    img {
      margin-bottom: 0 !important;
    }
  }

  // 描述文字
  &__desc {
    left: 50%;
    bottom: 90px;
    padding: 0 23px;
    max-width: calc(100% - 50px);
    overflow: hidden;
    font-size: 16px;
    color: #fff;
    white-space: nowrap;
    background-color: rgba($color: #606266, $alpha: .6);
    border-radius: 22px;
    transform: translateX(-50%);
    cursor: default;
  }

  // 操作按钮外框
  &__actions {
    left: 50%;
    bottom: 30px;
    padding: 0 23px;
    width: 282px;
    height: 44px;
    background-color: #606266;
    border-radius: 22px;
    transform: translateX(-50%);
  }

  // 操作按钮内容
  &__actions__inner {
    width: 100%;
    height: 100%;
    font-size: 23px;
    color: #fff;
    text-align: justify;
    cursor: default;
    display: flex;
    align-items: center;
    justify-content: space-around;
    i {
      cursor: pointer;
    }
  }

  &__close,
  &__next,
  &__prev {
    width: 44px;
    height: 44px;
    background-color: #606266;
    font-size: 24px;
    color: #fff;
  }

  &__prev {
    top: 50%;
    left: 40px;
    transform: translateY(-50%);
  }

  &__next {
    top: 50%;
    right: 40px;
    text-indent: 2px;
    transform: translateY(-50%);
  }

  &__mask {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: #000;
    opacity: .5;
  }

  .opacity-hide {
    opacity: 0;
  }

  .opacity-show {
    opacity: 1;
  }
}

2.图片组件 image-base 编写

  • 效果展示:
  • 用来装图片的小容器,和 image-viewer 搭配使用

  • 组件名称:TImageBase 

  • 可供接受的参数(props):
  • /**
     * t-image-base
     * @module packages/image-base
     * @desc 基础图片
     * @param {string} [themeStyle] - 主题风格
     * @param {string} [fit] - 确定图片如何适应容器框,同原生 object-fit
     * @param {Array} [previewUrls] - 开启图片预览功能,和image-viewer属性的urls相同
     * @param {number} [initialIndex] - 预览的首张图片的位置, 小于等于数组长度
     * @param {string} [animateClass] - 图片切换的动画名称,动画采用animate.css
     * @param {Object} [data] - 数据
     * @param {Object} [cStyle] - 自定义样式
     *
     * @example
     * <t-image-base>
     * </t-image-base>
     */
    
    // 主题风格
    enum ThemeStyle {
      LIGHT = 'light',
      DARK = 'dark',
    }
    
      props: {
        // 主题风格深浅
        themeStyle: {
          type: String as PropType<ThemeStyle.LIGHT | ThemeStyle.DARK>,
          default: ThemeStyle.DARK,
        },
        // 确定图片如何适应容器框
        fit: {
          type: String,
          default: 'fill',
        },
        // 数据
        data: {
          type: Object,
          default: () => ({}),
        },
        // 图片大图浏览数据
        previewUrls: {
          type: Array,
          default: () => ([]),
        },
        // 预览的首张图片的位置, 小于等于数组长度
        initialIndex: {
          type: Number,
          default: 0,
        },
        // 图片切换的动画名称,动画采用animate.css
        animateClass: {
          type: String,
          default: 'fadeInDown',
        },
        // 自定义样式
        cStyle: {
          type: Object,
          default: () => ({
            wrapper: {},
            desc: {},
          }),
        },
      },
    
      emits: ['image-click', 'change'],
    
  • 注意:该组件通过 emits 给外部提供了两个事件
  1. image-click 
  2. change

  • 图片列表使用了图片预览动能,也就是1中的那个组件,因此需要进行引入:
import TImageViewer from '../../image-viewer/src/index.vue';
  components: {
    TImageViewer,
  },

  • 组件模板:
<template>
  <div class="t-image-base">
    <div class="image" :style="{ ...cStyle.wrapper }">
      <div class="image-interval">
        <!-- 单张图片容器 -->
        <img class="image__inner" :src="data.src" :style="{'object-fit':fit}" />
      </div>
      <!-- 图片描述信息 -->
      <div
        class="image-desc"
        :style="{ ...cStyle.desc }"
        @click="handleImage(data)"
      >
        {{ data.desc }}
      </div>
    </div>
    <!-- 如果开启了大屏预览,就展示图片放大器组件 -->
    <template v-if="preview">
      <t-image-viewer
        v-if="showViewer"
        :urls="previewUrls"
        :initial-index="initialIndex"
        :animate-class="animateClass"
        @close="closeViewer"
        @change="changeImage"
      />
    </template>
  </div>
</template>

  • 页面逻辑:
    // 是否显示大图浏览
    const showViewer = ref(false);
    // 大图浏览的数据
    const preview = computed(() => {
      const { previewUrls } = props;
      return Array.isArray(previewUrls) && previewUrls.length > 0;
    });

    /**
     * 打开大图浏览
     */
    function openViewer() {
      if (!preview.value) {
        return;
      }
      showViewer.value = true;
    }

    /**
     * 关闭大图浏览
     */
    function closeViewer() {
      showViewer.value = false;
    }

    /**
     * 图片切换时
     */
    function changeImage(index) {
      emit('change', index);
    }

    /**
     * 图片的点击事件
     * @param item 当前点击的图片数据
     */
    function handleImage(item) {
      if (preview.value) {
        openViewer();
      } else {
        emit('image-click', item);
      }
    }

  • 图片样式:
.t-image-base {

  .image {
    position: relative;
    padding: 2px;
    width: 100%;
    height: 89px;
    border: 1px solid var(--theme-color);
    border-radius: 5px;
    cursor: pointer;

    &-interval {
      width: 100%;
      height: 100%;
      overflow: hidden;
    }

    &-desc {
      position: absolute;
      top: 0;
      left: 0;
      padding: 0 10px;
      width: 100%;
      height: 100%;
      text-align: center;
      font-size: 14px;
      color: #fff;
      text-shadow: 1px 1px 2px #000;
      display: flex;
      justify-content: center;
      align-items: center;
    }

    .image__inner {
      margin: 0;
      width: 100%;
      height: 100%;
      transition: all .5s;
    }

    &:hover {

      .image__inner {
        transform: scale(1.1);
      }
    }
  }
}

3.图片组件 image-base 使用

      <el-row :gutter="15" v-if="beautyData.length >= 0">
        <el-col
          v-for="(item, index) in beautyData"
          :key="index"
          :span="12"
        >
          <t-image-base
            :data="item"
            :preview-urls="beautyData"
            :initial-index="index"
            :cStyle="{
              wrapper: { height: '87px', boxSizing: 'border-box', marginBottom: '17px'},
              desc: { boxSizing: 'border-box' },
            }"
            />
        </el-col>
      </el-row>

    // 图片列表
    const beautyData: any = ref([]);

          // 通过接口填充 - 图片列表
          list.forEach((item: any) => {
            const img = { src: item.url };
            beautyData.value.push(img);
          });

    return {
      beautyData,
    };

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lyrelion

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值