最近想搞点有意思的东西,偶然看到大佬的文章《新手也能看懂的虚拟滚动实现方法》实现的虚拟列表滚动,跟着学习了一下
假设有一个很大数据量的列表,肯定是不能直接渲染真实DOM到浏览器上然后滚动的,浏览器会非常卡。所以其中一个解决办法就是分页。另一个办法就是虚拟列表,通过计算滚动的距离,只渲染容器元素内能看到的DOM,同时计算出容器内元素的偏移量然后应用这个偏移量,看起来就像是滚动了一样。
我大概画了一个图,只要每次滚动计算出现在container内应该有哪些列表元素(图中为item2到item5),同时计算出container的offset应该多大,滚动就完美模拟了
scrollHeight是滚动的高度,监听滚动事件的deltaY累加可以得到,itemHeightSum是容器内元素高度累加后的超过scrollHeight的第一个元素所在的位置,二者相减可以得到offset。
另一个需要得到的是当前容器所容纳的列表的首尾元素,上一步已经计算出容器元素高度累加后超过scrollHeight的第一个元素,自然首元素就是它,尾元素可以计算容器元素高度累加后超过(scrollHeight+ContainerHeight)的第一个元素就是了
知道了要做的事情,下一步就是要怎么做:
给容器元素绑定mousewheel事件,事件触发时更新scrollHeight并执行render(),在事件处理函数中计算首尾元素,itemHeightSum和offset并渲染容器内应有的元素同时设置内部元素的整体偏移:
bindEvents(){
let y = 0;
let scrollHeight = 0
// 计算scrollHeight,也就是y
const _updateY = (e) => {
e.preventDefault();
y += e.deltaY;
// scrollHeight的最大值只能是元素高度和减去容器高度,不能再往下滚了,同理最小只能是0
y = Math.max(y, 0);
y = Math.min(y, itemHeightSum - containerHeight);
};
// 将计算scrollHeight和更新scrollHeight分离开
const updateOffset = (e) => {
e.preventDefault();
if (y !== scrollHeight) {
scrollHeight = y;
}
};
// 为了防止更新频率过快加了一个防抖
const _updateOffset = throttle(updateOffset, 50);
// 给容器绑定事件
container.addEventListener("mousewheel", _updateY);
container.addEventListener("mousewheel", _updateOffset);
}
render(scrollHeight){
// 计算首尾元素,_list是超大列表,findOffsetIndex是工具函数,用来计算headIndex和tailIndex
const headIndex = findOffsetIndex(_list, scrollHeight);
const tailIndex = findOffsetIndex(
this._list,
scrollHeight + containerHeight
);
// 应该渲染的元素列表
list = this._list.slice(headIndex, tailIndex);
// calcHeight用来计算从0到headIndex的高度,offset也就是容器内元素的偏移量
const offset = scrollHeight - calcHeight(_list, 0, headIndex);
if(!container.querySelector('.wrapper')){
// wrapper包裹容器内元素,设置偏移量就设置给它
let wrapper = document.createlement('div')
wrapper.classList.add('wrapper')
container.appendChild(wrapper)
}
wrapper = container.querySelector('wrapper')
wrapper.innerHTML = ""
list.forEach((v) => {
const $v = this.itemGenerator(v);
wrapper.appendChild($v);
});
// 设置偏移,translateY负值为向上偏移
this.wrapper.style.transform = `translateY(-${offset}px)`;
}
完整代码一会贴,上述实现还有一些缺点,虽然做了节流防止更新过快,但是每次触发滚动事件都会进行DOM操作,还是比较费性能,更好点的办法是每次渲染更多一些元素(缓存元素列表),如果container的首尾元素都在这个缓存元素列表里,只需要设置wrapper的位移就可,不需要更新DOM
如图所示,container在1-6之内滚动只设置位移就可以,不需要重新render:
render(scrollHeight){
let cacheList = []
// 计算首尾元素,_list是超大列表,findOffsetIndex是工具函数,用来计算headIndex和tailIndex
const headIndex = findOffsetIndex(_list, scrollHeight);
const tailIndex = findOffsetIndex(
this._list,
scrollHeight + containerHeight
);
// 如果在缓存列表内不用渲染DOM
if (withinCacheList(headIndex, tailIndex, cacheList)) {
const headCacheIndex = cacheList[0].index;
const offset = _offset - calcHeight(_list, 0, headCacheIndex);
this.wrapper.style.transform = `translateY(-${offset}px)`;
return;
}
console.log("重新生成DOM");
//假设上下都多渲染5个元素,计算cacheIndex
const headCacheIndex = Math.max(headIndex - 5, 0);
const tailCacheIndex = Math.min(
tailIndex + 5,
this._list.length - 1
);
cacheList = this._list.slice(headCacheIndex, tailCacheIndex + 1);
// 计算到headCache的offset
const offsetToEdge = offset - calcHeight(_list, 0, headCacheIndex);
if(!container.querySelector('.wrapper')){
// wrapper包裹容器内元素,设置偏移量就设置给它
let wrapper = document.createlement('div')
wrapper.classList.add('wrapper')
container.appendChild(wrapper)
}
wrapper = container.querySelector('wrapper')
wrapper.innerHTML = ""
cacheList.forEach((v) => {
const $v = this.itemGenerator(v);
wrapper.appendChild($v);
});
// 设置偏移,translateY负值为向上偏移
wrapper.style.transform = `translateY(-${offset}px)`;
}
参考: 新手也能看懂的虚拟滚动实现方法