实战:用 Vue3 实现 Image 组件,顺便支持懒加载

8,041 阅读5分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

曾经面试一个靓仔实习生,他反问我说,图片有啥好封装的,直接 <img /> 梭哈不就行了吗?

我:“如果图片加载失败了呢?”。

“不是有 alt 属性吗,提示加载失败不就行了”,靓仔自信的回答道。

“那要是加载时间太长怎么给用户提示呢?懒加载有做过吗?” 我继续追问。

只见靓仔突然语塞,嘀咕了一句:“不会…”。

我差点口吐芬芳,不会你还这么横。

在一个完善的 web 项目中,图片是极为常见的页面展现元素之一,大部分前端开发者做的 Image 上的优化,可能仅是想办法压缩图片,减少体积、或者搞个懒加载减缓页面加载压力。

传统的做法实现起来较为繁杂且不好维护,那么如果用 Vue3 开发一个 Image 组件,实现加载中、加载失败、完成、懒加载等功能,便可以大幅提升开发效率和用户体验。

设计组件

万事开头难,设计好组件是开发一个 Vue 组件的重中之重,根据实际开发体验,我们需要一个 Image 组件提供最基本的几个属性和事件:适应类型:fit、加载中:loading、加载完成:load、加载失败:error,以及支持懒加载:lazy;稍微整理,确定需要的 props、slot 和 event:

name: "my-image",

// props 属性
src: { type: String, default: "" }, // 图片资源地址
fit: { type: String, default: "cover" }, // 图片适应类型
lazy: { type: Boolean, default: false }, // 是否开启懒加载
scrollContainer: { type: String, default: "" }, // 懒加载下指定的滚动容器

// emit 事件
emits: ["load", "error"],

// slot 插槽
placeholder // 图片加载中的占位内容
error // 图片加载失败提示的内容

实现组件

准备模板和样式

然后中间难,开始实现组件的模板和样式部分,以及一些基础的方法。

  • 整个组件外层用 Div 包裹,并添加 ref 属性,这在懒加载中会用到。
  • 创建占位区域和加载失败区域,并使用 slot 支持自定义。
  • <img /> 用于图片渲染,并通过行内样式设置一些基础的样式。
  • 占位区域、加载失败区域、图片渲染通过 v-if 判断,确保组件只有以上三个其中的一个状态。 具体模板代码如下:
<template>
  <div
    ref="container"
    class="my-image"
  >
    <!-- 占位区域 -->
    <div class="my-image-placeholder" v-if="loading">
      <slot name="placeholder">加载中</slot>
    </div>

    <!-- 加载失败 -->
    <div class="my-image-error" v-else-if="isLoadError">
      <slot name="error">加载失败</slot>
    </div>

    <!-- 图片 -->
    <img v-else class="my-image-inner" :src="src" :style="imgStyle" />
  </div>
</template>

样式部分代码:

.my-image {
 display: block;

  img {
    display: block;
    width: 100%;
    height: 100%;
    font-size: 0;
  }

  .my-image-inner,
  .my-image-error,
  .my-image-placeholder {
    height: 100%;
  }

  .my-image-error,
  .my-image-placeholder {
    display: flex;
    font-size: 14px;
    color: #bfbfbf;
    background-color: #f5f5f5;
    align-items: center;
    justify-content: center;
    vertical-align: middle;
  }
}

实现图片加载核心方法

整个逻辑在 setup 中实现,先定义两个状态用于存储是否加载失败和加载状态,注意的是,加载状态默认为 true,这样在懒加载或者网络不理想的情况下,默认展示的就是加载中的占位区域。

const state = reactive({
  isLoadError: false, // 是否加载失败
  loading: true, // 加载状态
});

加载图片首先得 new 一个 Image,它会暴露出自身的 onload 和 onerror 方法以供我们去处理加载完成和加载失败的情况,这里要考虑当 src 地址有变动时,需要重新执行加载方法,因此 loadImage 执行的时候,要先将加载状态和是否加载失败恢复为默认值。

// 加载图片
const loadImage = () => {
  state.loading = true;
  state.isLoadError = false;

  var image = new Image();
  image.onload = (e) => onComplete(e);
  image.onerror = () => onError(image);
  image.src = props.src;
};

// 图片地址改变重载图片
watch(
  () => props.src,
  () => loadImage()
);

加载完成和失败回调

加载完成和失败,仅需要设置对应的状态,并抛出对应的事件即可。

// 图片加载完成回调
function onComplete(e) {
  state.loading = false;
  state.isLoadError = false;
  emit("load", e);
}

// 图片加载失败回调
function onError(image) {
  state.loading = false;
  state.isLoadError = true;
  emit("error", image);
}

图片样式

如果需要对图片设置一些基础的样式,可以通过这种方式,如果过于复杂,建议通过 class 样式类去管理。

const imgStyle = computed(() => `object-fit:${props.fit}`);

实现懒加载

最后结尾难,懒加载原理最简单的方式无非是判断元素是否在可视区域内,并通过监听滚动实时判断。先来封装几个工具类,用于懒加载的时候调用:

  • 判断元素是否为 HTML 元素
  • 获取滚动容器
  • 判断元素是否在某个容器(或可视区域)中

判断元素是否为 HTML 元素较为简单,一行代码即可搞定:

export const isHtmlEl = e => e && e.nodeType === 1;

获取滚动容器

这里参考 Element 方式,另外抽出两个方法,一个用于获取元素样式,一个用于判断元素是否开启了滚动,最后再去获取真正的滚动容器。

/**
 * 获取元素样式
 * @param {Element} element
 * @param {string} styleName
 * @returns
 */
export const getStyle = function (element, styleName) {
  if (!element || !styleName) return null;

  if (styleName === 'float') {
    styleName = 'cssFloat';
  }

  try {
    const style = element.style[styleName];
    if (style) return style;

    const computed = document.defaultView.getComputedStyle(element, '')
    return computed ? computed[styleName] : '';
  } catch (e) {
    return element.style[styleName];
  }
}

/**
 * 判断元素是否含有滚动
 * @param {Element} el
 * @param {Boolean} isVertical
 * @returns Boolean
 */
export const isScroll = (el, isVertical) => {
  const determinedDirection = isVertical === null || isVertical === undefined;

  const overflow = determinedDirection
    ? getStyle(el, 'overflow')
    : isVertical
      ? getStyle(el, 'overflow-y')
      : getStyle(el, 'overflow-x');

  return overflow.match(/(scroll|auto)/);
}

/**
 * @param {Element} el
 * @param {Boolean} isVertical
 * @returns
 */
export const getScrollContainer = (el, isVertical) => {
  let parent = el;

  while (parent) {
    if ([window, document, document.documentElement].includes(parent)) {
      return window;
    }

    if (isScroll(parent, isVertical)) {
      return parent;
    }

    parent = parent.parentNode;
  }
  return parent;
}

判断目标元素是否在某个容器中

这里用到的核心是 getBoundingClientRect() 方法,它可以获取元素相对于视窗各个方向的位置,通过它可以判断两个元素的相对位置。

/**
 * @param {Element} target
 * @param {Element} container
 * @returns Boolean
 */
export const isInContainer = (target, container) => {
  if (!target || !container) return false;

  const isNotContainer = [window, document, document.documentElement, null, undefined].includes(container);
  const elClientReact = target.getBoundingClientRect();
  let containerClientRect = null;


  if (!isNotContainer) {
    containerClientRect = container.getBoundingClientRect();
  } else {
    containerClientRect = {
      top: 0,
      right: window.innerWidth,
      bottom: window.innerHeight,
      left: 0,
    }
  }

  return (
    elClientReact.top < containerClientRect.bottom &&
    elClientReact.bottom > containerClientRect.top &&
    elClientReact.right > containerClientRect.left &&
    elClientReact.left < containerClientRect.right
  );
}

懒加载的监听实现

在 setup 中定义以下懒加载相关数据,并封装事件监听和移除方法。

let _scrollContainer = null; // 滚动容器
let _lazyLoadHandler = null; // 懒加载方法
const container = ref(null); // 组件 Ref

封装事件操作方法,方便在组件中以更简洁的方式使用。

/**
 * 新增事件监听
 * @param {*} element
 * @param {*} event
 * @param {*} handler
 * @param {*} useCapture
 */
export const on = function (element, event, handler, useCapture = false) {
  if (element && event && handler) {
    element.addEventListener(event, handler, useCapture);
  }
}

/**
 * 移除事件监听
 * @param {*} element
 * @param {*} event
 * @param {*} handler
 * @param {*} useCapture
 */
export const off = function (element, event, handler, useCapture = false) {
  if (element && event && handler) {
    element.removeEventListener(event, handler, useCapture);
  }
}

/**
 * 函数节流
 */
export const throttle = (fn, delay) => {
  var lastTime;
  var timer;
  var delay = delay || 200;
  return function () {
    var args = arguments;
    var nowTime = Date.now();
    if (lastTime && nowTime - lastTime < delay) {
      clearTimeout(timer);
      timer = setTimeout(function () {
       lastTime = nowTime;
       fn.apply(this, args);
      }, delay);
    } else {
      lastTime = nowTime;
      fn.apply(this, args);
    }
  }
}

添加懒加载监听,如果没有指定 scrollContainer,则默认会以 window 作为滚动容器,符合条件后则装载懒加载方法并添加滚动监听。

function addLazyLoadLintener() {
   const { scrollContainer } = props;

   if (isHtmlEl(scrollContainer)) {
     _scrollContainer = scrollContainer;
   } else if (
     typeof scrollContainer === "string" &&
     scrollContainer !== ""
   ) {
     _scrollContainer = document.querySelector(scrollContainer);
   } else {
     _scrollContainer = getScrollContainer(container.value);
   }
   if (_scrollContainer) {
     _lazyLoadHandler = throttle(onLazyLoad, 200);
     on(_scrollContainer, "scroll", _lazyLoadHandler);
     setTimeout(() => onLazyLoad(), 100);
   }
}

其中懒加载方法如下,加载图片后需要立即移除事件监听。

function onLazyLoad() {
   if (isInContainer(container.value, _scrollContainer)) {
     loadImage();
     removeLazyLoadListener();
   }
}

// 移除懒加载监听
function removeLazyLoadListener() {
   if (!_scrollContainer || !_lazyLoadHandler) return;

   off(_scrollContainer, "scroll", _lazyLoadHandler);
   _scrollContainer = null;
   _lazyLoadHandler = null;
}

最后的最后,需要处理懒加载的触发条件,以及在销毁组件前,需要移除懒加载的监听,目前仅是在图片加载完成后才移除,如果没有触发加载,则监听会一直保留。

onMounted(() => {
   if (!props.lazy) {
     return loadImage();
   }
   nextTick(addLazyLoadLintener);
});

onBeforeUnmount(() => {
   props.lazy && removeLazyLoadListener();
});