本文涉及知识点包括:
- 使用
IntersectionObserver快速实现懒加载- 使用
getBoundingClientRect判断元素是否出现在页面中- 使用
scrollIntoView滚动页面至指定元素- 同步化异步任务解决懒加载首次加载过多问题
- 定位懒加载界面中未加载元素
背景
最近有个需求是在页面中展示多个信息集,由于数量多,直接一股脑的发一大堆请求速度较慢,要求优化成懒加载的形式,且能通过点击目录跳转。页面左边为信息集目录,右边为垂直排列的信息集,信息集默认有标题,数据异步加载
懒加载通常使用监听页面scroll事件,再计算元素的位置实现。但scroll事件为同步执行,会阻塞页面,性能较低,且计算复杂,不是一个良好的方案,这里推荐使用 Intersection Observer API实现
Intersection Observer
Intersection Observer使用非常简单,上面链接的阮一峰教程中对该API介绍的很清楚。
简单说就是新建一个实例,传入监听的回调函数与选项参数,再调用实例监听DOM元素即可
// 创建实例
let observer = new IntersectionObserver(
entries => {
// callback
},
{ threshold: 0.5 }
);
// 监听DOM元素,可以监听多个
observer.observe(element)
需要注意的是,回调参数为所有触发回调的元素组成的 IntersectionObserverEntry数组,其中的属性包括:
time:可见性发生变化的时间,是一个高精度时间戳,单位为毫秒target:被观察的目标元素,是一个 DOM 节点对象rootBounds:根元素的矩形区域的信息,getBoundingClientRect()方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回nullboundingClientRect:目标元素的矩形区域的信息intersectionRect:目标元素与视口(或根元素)的交叉区域的信息intersectionRatio:目标元素的可见比例,即intersectionRect占boundingClientRect的比例,完全可见时为1,完全不可见时小于等于0
选项参数中的 threshold 为监听的门槛值,表示监听的元素在页面中出现百分之多少时触发回调,可以传入一个数值或数值数组
实现懒加载
这里以Angular为例,实现步骤非常简单:
- 在生命周期函数中获取需要监听的DOM
- 在回调函数中遍历entries,根据需求调用方法。我这里为元素可见时加载数据
- 遍历需要监听的DOM元素,添加监听
// 标记已经加载数据的DOM
cache = {};
// 将observer声明在方法外,用于组件销毁时取消监听
observer;
// 生命周期函数
ngAfterViewInit() {
// 监听ngFor变化,得到DOM(Angular中For循环渲染的DOM没法及时在生命周期中获取)
this.tableItemEL.changes.pipe(first()).subscribe(list => {
if (list.length > 0) {
// 元素在窗口中出现大于0.5时(标题部分,在窗口中显示一半时),触发回调
this.observer = new IntersectionObserver(
entries => {
for (let item of entries) {
// 出现在屏幕中且未加载
if (item.intersectionRatio > 0 && !this.cache[item.target.id]) {
// 执行加载方法
this.build(item);
}
}
},
{ threshold: 0.5 }
);
// 遍历节点,添加监听
list._results.forEach(item => {
this.observer.observe(item.nativeElement);
});
}
});
}
// 组件销毁的生命周期中取消监听,避免内存泄漏
ngOnDestroy() {
this.observer.disconnect();
}
// 加载数据方法
build(item){
// 加载完成后标记该DOM
xxx.then(() => {
this.cache[item.TABLE_CODE] = true;
})
}
这就实现了最基础的懒加载,但还有问题。在数据未加载时,由于元素仅有标题的高度,出现在屏幕中的元素会超过实际一屏能显示的数据.我这里一屏本来只能显示3个信息集,但加载了7个
解决首次加载过多问题
因为JS的单线程特性,所以可以在加载数据前再次判断元素是否在可视范围内,可以使用 Element.getBoundingClientRect() 方法判断。该方法均已窗口左上角为起点,所以当bottom>0(底在屏幕内或屏幕下方)、top<=window.innerHeight(顶在屏幕内或屏幕上方)时即为可视
这里还要考虑到因为加载数据是异步的,所以单纯加上判断位置的话,实际上是先判断了DOM位置(均在可视范围内),再加载了数据,与之前没有变化。
可以使用async await的形式,将加载数据变成同步运行,这时候再判断位置应该就没问题了
但这里还需要注意一个点,理解await的运行方式。运行到await时,JS是在当前函数内卡在await处,等异步完成再回到await继续运行;但函数外的同步代码不会等待该异步完成。所以如果仅修改了加载数据的方法,实际运行效果没有变化,需要在回调函数中为加载数据的方法也添加await就解决了该问题
// 标记已经加载数据的DOM
cache = {};
// 将observer声明在方法外,用于组件销毁时取消监听
observer;
// 生命周期函数
ngAfterViewInit() {
// 监听ngFor变化,得到DOM(Angular中For循环渲染的DOM没法及时在生命周期中获取)
this.tableItemEL.changes.pipe(first()).subscribe(list => {
if (list.length > 0) {
// 元素在窗口中出现大于0.5时(标题部分,在窗口中显示一半时),触发回调
this.observer = new IntersectionObserver(
// 以同步的方式执行加载数据
async entries => {
for (let item of entries) {
// 出现在屏幕中且未加载
if (item.intersectionRatio > 0 && !this.cache[item.target.id]) {
// 执行加载方法
await this.build(item);
}
}
},
{ threshold: 0.5 }
);
// 遍历节点,添加监听
list._results.forEach(item => {
this.observer.observe(item.nativeElement);
});
}
});
}
// 组件销毁的生命周期中取消监听,避免内存泄漏
ngOnDestroy() {
this.observer.disconnect();
}
// 加载数据方法---使用同步方式,添加DOM参数,判断位置
async build(item, target){
// 判断是否在窗口范围内,是才加载
let rect = target.getBoundingClientRect();
let isShow = rect.bottom > 0 && rect.top <= window.innerHeight;
if(isShow) {
// 加载完成后标记该DOM
await xxx.then(() => {
this.cache[item.TABLE_CODE] = true;
})
}
}
实现点击目录定位
上面已经实现了懒加载的功能,最后需要实现点击左侧的目录定位到对应DOM
这里我有两种想法
- 一是点击后把目标之前的DOM数据全部加载,再跳转到目标元素。首次跳转会比较慢,往上滚动或跳转不需要再加载数据
- 二是先加载目标数据,然后跳转到目标元素,再根据可见性懒加载其他DOM数据,上下滚动都还是懒加载的形式
看起来第二种方案比较好,但尝试写了一下发现定位目标元素都会触发上面元素的callback,导致目标元素被挤出屏幕外,无法准确定位(如有解决方案,望告知)
最后采用了第一种方案,定位用到的API为Element.scrollIntoView(),默认为定位到窗口顶部,传入false为定位到窗口底部。因为是加载完数据后才定位,所以这里不需要同步执行,可以在加载数据方法中返回Promise,然后通过Promise.all实现该功能
最终代码
// 标记已经加载数据的DOM
cache = {};
// 将observer声明在方法外,用于组件销毁时取消监听
observer;
// 生命周期函数
ngAfterViewInit() {
// 监听ngFor变化,得到DOM(Angular中For循环渲染的DOM没法及时在生命周期中获取)
this.tableItemEL.changes.pipe(first()).subscribe(list => {
if (list.length > 0) {
// 元素在窗口中出现大于0.5时(标题部分,在窗口中显示一半时),触发回调
this.observer = new IntersectionObserver(
// 以同步的方式执行加载数据
async entries => {
for (let item of entries) {
// 出现在屏幕中且未加载
if (item.intersectionRatio > 0 && !this.cache[item.target.id]) {
// 执行加载方法
await this.build(item);
}
}
},
{ threshold: 0.5 }
);
// 遍历节点,添加监听
list._results.forEach(item => {
this.observer.observe(item.nativeElement);
});
}
});
}
// 组件销毁的生命周期中取消监听,避免内存泄漏
ngOnDestroy() {
this.observer.disconnect();
}
// 目录单击事件
onTabCLick(tab){
if(!this.cache[tab.TABLE_CODE]){
// 未加载,同步方式按顺序渲染至目标元素
// tableList为所有信息集的数组
let reqArr = [];
let i=0;
do {
if(!this.cache[this.tableList[i].TABLE_CODE]){
// 执行加载,并添加Promise进数组
reqArr.push(this.build(this.tableList[i]));
}
} while (this.tableList[i].TABLE_CODE !== tab.TABLE_CODE && ++i);
// 全部完成后跳转
Promise.all(reqArr).then(() => {
document.querySelector(`#${item.TABLE_CODE}`).scrollIntoView();
}
} else {
// 已加载,直接跳转
document.querySelector(`#${item.TABLE_CODE}`).scrollIntoView();
}
}
// 加载数据方法---使用同步方式,增加DOM参数用于判断位置
async build(item, target){
// 判断是否在窗口范围内,是才加载
let rect = target.getBoundingClientRect();
let isShow = rect.bottom > 0 && rect.top <= window.innerHeight;
if(isShow) {
// 返回请求promise,加载完成后标记该DOM
return await xxx.then(() => {
this.cache[item.TABLE_CODE] = true;
})
}
}