背景
公司里的PHP后台项目,上一任的工程师对于很长列表的处理,都是直接全部进行渲染,导致的一个问题就是,加载页面都要加载比较久的时间,这里只能庆幸这个后台项目只是给公司里边的大佬用,用的机器还可以,除了忍受一下加载速度比较慢之外,没有很大的影响。但他们能忍,作为前端工程师,我实在是忍不了了,之前每天打开,看看数据,等那几秒至十几秒的时间,好像是过了半个世纪一样,难受!最近终于抽空不偷懒了,想要好好的优化一下,于是我就瞄准了 IntersectionObserver 这个API
,期待能够给我一个比较好的解决方案,能让我好好的摆个地摊,赚取第一桶金
最终效果(在edge的chrome内核版测试)
好紧张!!!当然是先要温柔一点试试水啦
光温柔,不能暴力使用,那就没法面对很多场景了,中看不中用,来张暴力图
舒服了,总算是让它入得厨房,出得厅堂了。
第一次尝试 (致敬网易云音乐团队的 一个简洁、有趣的无限下拉方案)
核心思想:
- 利用
intersectionObserver
监听已渲染的DOM列表首末两个 item,在首末两个item进入视口的时候渲染上一页或下一页的列表项 - 为了能够保持当前所占据的位置,通过设置
padding
,来替代已经渲染过的列表上方的item,假装上方有DOM已经渲染。
实践:
具体流程为
重新渲染之后的结果为1. 监听元素是否已进入可视区域
intersectionObserver
在监听的元素刚进入可视区域的时候,会触发一次回调,在完全进入可视区域的时候,还会触发一次回调,因此,在进行监听的时候,需要对是否是刚进入的状态进行筛选,这样可以避免多次渲染。
// 创建 intersectionObserver 对象
this.intersectionObserver = new IntersectionObserver(entries => {
console.log(entries)
for (const entry of entries) {
// 如果为true,则证明是进入的状态,如果为false,则是完全进入的状态,取刚进入的状态即可
if (!entry.isIntersecting) return
if (entry.target.classList.contains(firstItemClass)) {
console.log('向上滑动')
this.handleScrollUp(false)
} else if (entry.target.classList.contains(lastItemClass)) {
console.log('向下滑动')
this.handleScrollDown(true)
}
}
})
- 在我个人看来,只要监听到
entry.isIntersecting
为true,则可以说明是刚进去的状态。
2. 渲染更新下一页的数据
-
在渲染下一页的数据之前,需要拿到下一页数据的首item索引,也就是
item-first
对应的索引,这里我直接沿用网易云音乐的方案,缓存当前的首item索引,然后在下一次监听的元素进入视口触发回调的时候,拿到上次的首item索引,对其进行半数的页容量增加作为新的首item索引。 -
就如上图中每次需要渲染的元素个数(页容量)为10个,初始首item索引为0,然后在监听到
item-last
元素进入视口之后,取5作为最新的首item索引,这样来渲染下一页(5-14)的元素。
getContainerFirstIndex(isScrollDown) {
// 获取缓存的currentIndex
const { currentIndex } = this.dataCache
// 获取半数的页容量作为增量
const { halfListSize } = this.staticData
// 一次进行半数列表项的递增
return isScrollDown
? currentIndex + halfListSize
: (currentIndex - halfListSize < 0 ? 0 : currentIndex - halfListSize)
}
- 然后就从首item索引开始渲染下一页的数据
3. 绑定新的首末项
- 当渲染完新的数据列表之后,之前的数据列表已经被移除掉了,此时新的数据列表还没有重新设定首末项元素监听
- 因此需要在渲染完数据之后取消监听将之前监听的首末项元素,监听新的首末项元素
bindNewItems() {
const { firstItemClass, lastItemClass, container } = this.staticData
const { firstItem, lastItem } = this.dataCache
// 解绑旧的
firstItem && this.intersectionObserver.unobserve(firstItem)
lastItem && this.intersectionObserver.unobserve(lastItem)
// 先给新的数据列表添加class
container.children[0].classList.add(firstItemClass)
container.children[container.children.length - 1].classList.add(lastItemClass)
// 然后获取新的首末项元素并开启监听
const newFirstItem = container.querySelector('.' + firstItemClass)
const newLastItem = container.querySelector('.' + lastItemClass)
this.intersectionObserver.observe(newFirstItem)
this.intersectionObserver.observe(newLastItem)
// 将当前新的首末项元素缓存起来,可以用作下次绑定新的首末项元素之前取消绑定旧的首末项元素
this.updateDataCache({
firstItem: newFirstItem,
lastItem: newLastItem
})
}
4. 进行padding的设置
- 关于padding的设置,我的方案比较简单粗暴,具体就是先拿到全部item列表项的高度(比如1个item的高度为150,有100个item,总高度为 150 x 100 = 15000),减去一个页容量的高度(页容量为10个item,对应的页容量的高度 10 x 150 = 1500),就作为总padding的值(15000 - 1500 = 13500)。
- 那么对应的 paddingTop 的高度就是 这个首item索引前面所有元素的高度总和(比如在上图中首item索引为5的时候,paddingTop 为5个item的高度, 5 x 150 = 750)
- 对应的 paddingBottom 的高度就是总 padding - paddingTop(13500 - 750 = 12750)
adjustPaddings(firstIndex) {
// 半数页容量,一个item的高度,item全部列表项,页容量,容器
const { halfListSize, itemHeight, listLen, listSize, container } = this.staticData
// 获取渲染listSize一半对应的高度
const halfListSizeHeight = halfListSize * itemHeight
// 将 listLen - listSize 的高度作为总padding
const totalPadding = (listLen - listSize) / halfListSize * halfListSizeHeight
// 将超出当前firstIndex以上的部分作为paddingTop
const newCurrentPaddingTop = firstIndex <= 0 ? 0 : (firstIndex / halfListSize) * halfListSizeHeight
// paddingBottom则等于 totalPadding - paddingTop
const newCurrentPaddingBottom = totalPadding - newCurrentPaddingTop < 0 ? 0 : totalPadding - newCurrentPaddingTop
// 修改container的padding
container.style.paddingTop = `${newCurrentPaddingTop}px`
container.style.paddingBottom = `${newCurrentPaddingBottom}px`
console.log('firstIndex, paddingTop, paddingBottom', firstIndex, newCurrentPaddingTop, newCurrentPaddingBottom)
}
- 此时的效果如图(以页容量20来做的测试),是不是超棒的?
5. 缺陷
- 当我们正常的用鼠标去滚动的时候,效果都是棒棒的。我差点就以为我已经成功了
- 然而,当我们尝试去拉动滚动条的时候,尴尬的事情就发生了,页面直接白屏~,当场去世???
6. 原因
- 再来说一下
intersectionObserver
这个API,是监听元素刚进入视口以及完全进入视口的 - 当我们快速去滑动滚动条的时候,直接就略过了那些需要监听的元素,此时,就没法触发回调,那就没法进行数据渲染啦
- 所以我们就只能很悲伤的看着白屏,我还以为可以实现了我的做法了???
第二次尝试(最终版本)
- 作为当代前端选手,怎么可能那么容易就放弃呢?至少也得进行 收藏 = 学会 的操作吧?
- 上述的方案虽然有缺陷,除了白屏导致了这个问题之外其它都还好,但有困难还是需要去解决的,不然就没法用在实际项目中去了,只能这样玩玩~
- 其实解决方案也很简单,当检测到用户正在大幅度进行滚动条滚动的时候,根据当前的滚动条位置来获取当前区域内应该出现的元素,并进行首末项监听绑定,渲染
- 没错,也就是
onscroll
的方案,先在这里说一下自己的看法,虽然这个会每次都触发回调,但是在做了是否大幅度滚动的检测之后,拿到的效果就是,正常上下滑动的时候,不会触发onscroll
里边的渲染。这就相当于润滑剂polyfill
的存在了 - 虽然是传统的
onscroll
方案,但其中涉及的计算还是挺多的,不过,和在intersectionObserver
监听回调那里做的一样,都是为了拿到首项item元素,然后渲染对应的元素列表
1. 往下滚动时,需要先拿到baseHeight
// 获取往下滑动时,必要的阈值 相对于 叶容量 20 以及 itemHeight 为 150, 容器盒子为500 来说就是 850
/**
* eg: listSize: 20, itemHeight: 150, boxHeight: 500
* firstIndex bottom top scrollTop firstBaseHeight
* 0 12000 0
* 10 10500 1500 2350 1500 - 150 - 500 = 850 = 2350 - 1500
* 20 9000 3000 3850 1500 - 150 - 500 = 850 = 3850 - 3000
* 30 7500 4500 ...
*/
- 先贴出注释, 在上面注释对应的例子中, 首项item与paddingTop的值是一一对应的关系
- 我在对总item数为100, 叶容量 20 以及 itemHeight 为 150, 容器盒子为500上下进行滑动测试
- 发现在向下滑动时,当我滚动条滑动距离在 2350 的时候就触发了
intersectionObserver
对末项item第一次响应 - 在向下滑动时,当我滚动条滑动距离在 3850 的时候就触发了
intersectionObserver
对末项item第二次响应 - 然后我神奇的发现,经过我瞎 jier 的计算之后,有那么一个值(这里是 850 ),可以作为精准获取首项item的标志(有请评论区的大神给小弟解释一下😄)
2. 拿到首项item元素
- 当我拿到当前的滑动距离 - baseHeight 之后,就可以一一对应着firstIndex的padding值,也就是说我可以精确的拿到firstIndex并进行这一块的渲染
// 获取当前的firstIndex(向下滑动时)
let firstIndex = Math.floor(Math.abs(currentScrollTop - baseHeight) / (itemHeight * halfListSize)) * halfListSize
- 向上滑动时,也有这么个baseHeight,就是一个itemHeight的高度
let firstIndex = Math.floor((currentScrollTop - itemHeight) / (itemHeight * halfListSize)) * halfListSize
-
拿到了首项item, 就可获取对应视口需要渲染的元素,然后按照之前的逻辑进行渲染 => 解绑旧元素 => 绑定新元素 => 给新的padding即可
-
然后就随意的爱怎么玩就怎么玩,就是不白屏了,舒服~,是我喜欢的类型❤
思考总结
1. 方案
- 这个长列表优化渲染方案优先是基于
intersectionObserver
+padding
进行异步加载数据,以及润滑剂基于scroll
+padding
进行同步加载数据来完成的,具体步骤除了一开始获取首项item的方式不同,其它的步骤一致,均为取消监听 旧的首末项, 绑定监听新的首末项,然后对容器添加padding
。在实现正常的滑动的基础上,能够让用户的一些大幅度的举动也能满足要求
2. 缺陷
- 目前只能固定item的高度,我没有想到过能够渲染动态高度的方案,适用于表格列表、同等高度的列表等
- 如果页容量太小,会导致首末元素同时出现在视口中,这样将没法进行渲染,必须要让页容量大一点
3. 后续优化
- 可以实现异步加载数据(这是可行的,暂时没去实现)
- 可以考虑添加对动态高度的拓展(暂时没有什么灵感,评论区请大佬指点)