「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,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();
});