前言
这篇文章我们详细解析v-lazy源码
承接上篇文章Vant4图片懒加载源码解析(一)后续
ini
<img v-for="img in imageList" v-lazy="img" />
源码解析
1. 注册v-lazy指令
swift
app.directive('lazy', {
beforeMount: lazy.add.bind(lazy),
updated: lazy.update.bind(lazy),
unmounted: lazy.remove.bind(lazy),
});
2. lazy.add函数
该函数是图片懒加载指令的核心方法
- 将元素添加到IntersectionObserver观察队列
- 初始化后立即触发一次加载检查
- DOM更新后再次触发加载检查,确保正确处理初始可见元素
首先要了解一个知识点,add函数中的observer和ReactiveListener是什么关系?
observer负责何时加载(检测时机)ReactiveListener负责如何加载(执行细节)
kotlin
/*
* 添加监听器实例到队列中
* @param {DOM} el html文档中挂载的节点
* @param {object} binding vue directive binding // vue指令
* @param {vnode} vnode vue directive vnode 虚拟节点
* @return
*/
add(el, binding, vnode) {
// 如果listeners数组中存在当前el, 则直接update, 我们直接看下面的update函数
if (this.listeners.some((item) => item.el === el)) {
this.update(el, binding);
return nextTick(this.lazyLoadHandler);
}
// 格式化value值
const value = this.valueFormatter(binding.value);
// 获取src
let { src } = value;
// 在dom更新之后执行回调函数
nextTick(() => {
// 从el的srcset属性中获取当前最优的src属性
src = getBestSelectionFromSrcset(el, this.options.scale) || src;
// observer具体怎么执行的,我们具体看1.3
// 将当前元素添加到观察者队列中去
this.observer && this.observer.observe(el);
const container = Object.keys(binding.modifiers)[0];
let $parent;
if (container) {
$parent = vnode.context.$refs[container];
// 先从$refs中获取container, 如果获取不到,则document.getElementById 获取
$parent = $parent
? $parent.$el || $parent
: document.getElementById(container);
}
// 如果$parent还是为null, 则向上查找具有滚动属性的第一个父级元素
if (!$parent) {
$parent = getScrollParent(el);
}
// 创建了一个ReactiveListener实例,详情看1.4
const newListener = new ReactiveListener({
bindType: binding.arg,
$parent,
el,
src,
loading: value.loading,
error: value.error,
cors: value.cors,
elRenderer: this.elRenderer.bind(this),
options: this.options,
imageCache: this.imageCache,
});
this.listeners.push(newListener);
// 给windos和$parent都绑定监听器
if (inBrowser) {
this.addListenerTarget(window);
this.addListenerTarget($parent);
}
// 立刻执行一次lazyLoadHandler
this.lazyLoadHandler();
// 当前dom更新之后再执行一次lazyLoadHandler
nextTick(() => this.lazyLoadHandler());
});
}
3. this.observer && this.observer.observe(el);
this.observer是IntersectionObserver类
1.3.1 IntersectionObserver的作用?
IntersectionObserver 提供了一种异步观察目标元素与其祖先元素或顶级文档视口(viewport)交叉状态的方法。其祖先元素或视口被称为根(root)
kotlin
// 初始化交互换观察者对象
initIntersectionObserver() {
if (!hasIntersectionObserver) {
return;
}
// 创建IntersectionObserver类
this.observer = new IntersectionObserver(
this.observerHandler.bind(this),
this.options.observerOptions,
);
if (this.listeners.length) {
// 遍历listeners中的元素
this.listeners.forEach((listener) => {
// 使 `IntersectionObserver` 开始监听一个目标元素。
this.observer.observe(listener.el);
});
}
}
/**
* 观察者对象处理函数
* @return
*/
observerHandler(entries) {
entries.forEach((entry) => {
// 如果有被观察元素出现在视口内
if (entry.isIntersecting) {
this.listeners.forEach((listener) => {
// 查找该元素是listener中的哪个元素
if (listener.el === entry.target) {
// 如果该listener的状态已加载,则unobserve该元素
if (listener.state.loaded)
return this.observer.unobserve(listener.el);
// 否则执行load函数
listener.load();
}
});
}
});
}
4. ReactiveListener类
ReactiveListener主要实现图片加载
-
先了解一下loadImageAsync函数
loadImageAsync封装了图片的加载功能,如果图片加载成功,就调用外部resolve方法,否则reject错误。 -
crossOrigin是 Web 开发中用于处理跨域资源共享(CORS, Cross-Origin Resource Sharing)的重要属性,常见值如下:-
anonymous:发起跨域请求但不携带用户凭证(默认行为) -
use-credentials:发起跨域请求并携带用户凭证
我们看下有无crossOrigin对图片加载有什么作用!
-
| 场景 | 无 crossOrigin | 有 crossOrigin="anonymous" |
|---|---|---|
| 显示图片 | ✅ 正常显示 | ✅ 正常显示(服务器需支持 CORS) |
| 读取像素数据 | ❌ 抛出安全错误 | ✅ 允许读取(服务器需支持 CORS) |
| 发送凭据 | 不适用 | ❌ 不发送 cookies 等凭据 |
总结:
-
在仅需于浏览器中展示跨域图片而不进行额外处理(如Canvas操作、像素数据读取等)的场景下,crossOrigin属性的设置与否并不影响图片的正常加载与显示。
-
跨域图片且没加crossOrigin属性,尝试读取像素数据将触发安全错误!
arduino
// 尝试读取像素数据将触发安全错误!
try {
ctx.getImageData(0, 0, canvas.width, canvas.height);
}
catch (e) {
console.error('无法读取跨域图片的像素数据', e);
}
ini
// 加载图片资源函数
export const loadImageAsync = (item, resolve, reject) => {
const image = new Image();
if (!item || !item.src) {
return reject(new Error('image src is required'));
}
image.src = item.src;
// 根据cors设置了crossOrigin属性值
if (item.cors) {
image.crossOrigin = item.cors;
}
image.onload = () =>
// image加载成功回调函数,就resolve数据回去
resolve({
naturalHeight: image.naturalHeight,
naturalWidth: image.naturalWidth,
src: image.src,
});
// image加载失败,则reject error回去
image.onerror = (e) => reject(e);
};
- ReactiveListener类
Listener 类不仅是"加载图片"的简单封装,而是一个完整的资源生命周期管理器
判断元素位置,图片的加载实现
kotlin
import { useRect } from '@vant/use';
// 加载图片异步函数loadImageAsync
import { loadImageAsync } from './util';
export default class ReactiveListener {
constructor(){...}
/*
* 检查元素是否在预定位置
* @return {Boolean} el is in view
*/
checkInView() {
// 获取图片的位置信息,useRect原理就是getBoundingClientRect()
const rect = useRect(this.el);
//如果元素垂直方向满足,视口顶部的值 < 视口高度 * 预设值 且底部要大于预设值
// 水平方向满足:元素左边距离视口的值 < 浏览器可视宽度 * 预设值且右边>0
return (
rect.top < window.innerHeight * this.options.preLoad &&
rect.bottom > this.options.preLoadTop &&
rect.left < window.innerWidth * this.options.preLoad &&
rect.right > 0
);
}
/*
* 加载图片资源
* @params cb:Function
* @return
*/
renderLoading(cb) {
this.state.loading = true;
// 加载图片资源
loadImageAsync(
{
src: this.loading,
cors: this.cors,
},
() => {
// 成功回调函数
this.render('loading', false);
this.state.loading = false;
cb();
},
() => {
// 失败回调函数
cb();
this.state.loading = false;
if (process.env.NODE_ENV !== 'production' && !this.options.silent)
console.warn(
`[@vant/lazyload] load failed with loading image(${this.loading})`,
);
},
);
}
/*
* 加载图片资源
* @return
*/
load(onFinish = noop) {
if (this.attempt > this.options.attempt - 1 && this.state.error) {
if (process.env.NODE_ENV !== 'production' && !this.options.silent) {
console.log(
`[@vant/lazyload] ${this.src} tried too more than ${this.options.attempt} times`,
);
}
onFinish();
return;
}
// 加载过就直接return
if (this.state.rendered && this.state.loaded) return;
// 如果this.imageCache集合中存在src,说明加载过,设置加载成功状态
if (this.imageCache.has(this.src)) {
this.state.loaded = true;
this.render('loaded', true);
this.state.rendered = true;
return onFinish();
}
this.renderLoading(() => {
// 记录加载次数
this.attempt++;
this.options.adapter.beforeLoad?.(this, this.options);
this.record('loadStart');
loadImageAsync(
{
src: this.src,
cors: this.cors,
},
(data) => {
// 图片加载成功后的回调函数
this.naturalHeight = data.naturalHeight;
this.naturalWidth = data.naturalWidth;
this.state.loaded = true;
this.state.error = false;
this.record('loadEnd');
this.render('loaded', false);
this.state.rendered = true;
// imageCache 缓存已经加载过的src
this.imageCache.add(this.src);
onFinish();
},
(err) => {
!this.options.silent && console.error(err);
this.state.error = true;
this.state.loaded = false;
this.render('error', false);
},
);
});
}
elRenderer(listener, state, cache) {
// listener中不存在el元素,直接return
if (!listener.el) return;
const { el, bindType } = listener;
// 通过state状态获取src值
let src;
switch (state) {
case 'loading':
src = listener.loading;
break;
case 'error':
src = listener.error;
break;
default:
({ src } = listener);
break;
}
// `bindType`:指定如何应用图片,可能是:
1. `background-image`:作为CSS背景
2. 作为`<img>`标签的`src`属性
if (bindType) {
el.style[bindType] = 'url("' + src + '")';
} else if (el.getAttribute('src') !== src) { // 仅在需要时更新
el.setAttribute('src', src);
}
// 设置lazy属性
el.setAttribute('lazy', state);
this.$emit(state, listener, cache);
this.options.adapter[state] &&
this.options.adapter[state](listener, this.options);
if (this.options.dispatchEvent) {
// 创建一个原生自定义事件
const event = new CustomEvent(state, {
detail: listener,
});
// 触发当前创建的这个事件
el.dispatchEvent(event);
}
}
}
5. lazyLoadHandler
判断node是否在 viewport范围内,如果是,则加载图片资源。如果listener中数据不存在el,则清理掉。
scss
/**
* find nodes which in viewport and trigger load
* @return
*/
lazyLoadHandler() {
const freeList = [];
this.listeners.forEach((listener) => {
if (!listener.el || !listener.el.parentNode) {
freeList.push(listener);
}
const catIn = listener.checkInView();
if (!catIn) return;
listener.load();
});
freeList.forEach((item) => {
remove(this.listeners, item);
item.$destroy();
});
}
6. lazy.update
v-lazy指令所在dom更新el元素src属性时触发
kotlin
update(el, binding, vnode) {
const value = this.valueFormatter(binding.value);
// 获取src
let { src } = value;
src = getBestSelectionFromSrcset(el, this.options.scale) || src;
// 判断当前el是否在listeners集合中存在
const exist = this.listeners.find((item) => item.el === el);
if (!exist) {
// 不存在则add进去
this.add(el, binding, vnode);
} else {
exist.update({
src,
error: value.error,
loading: value.loading,
});
}
// 重新设置observe 元素
if (this.observer) {
this.observer.unobserve(el);
this.observer.observe(el);
}
// 加载图片资源
this.lazyLoadHandler();
// 等dom更新后再次检查更新
nextTick(() => this.lazyLoadHandler());
}
7. lazy.remove
从listener集合中移除el元素
kotlin
/**
* remove listener form list
* @param {DOM} el
* @return
*/
remove(el) {
if (!el) return;
// 不在观察el元素
this.observer && this.observer.unobserve(el);
const existItem = this.listeners.find((item) => item.el === el);
if (existItem) {
// 移除
this.removeListenerTarget(existItem.$parent);
this.removeListenerTarget(window);
remove(this.listeners, existItem);
existItem.$destroy();
}
}
总结
声明了好几个类来处理懒加载功能,还挺复杂的~