如果不考虑DOM节点回收,也就是一直不停向后加入节点,这样的长列表是比较容易的。那我们为什么要考虑DOM节点回收呢?
DOM节点本身并非耗能大户,但是也不是一点都不消耗性能,每一个节点都会增加一些额外的内存、布局、样式和绘制。如果一个站点的DOM节点过多,在低端设备上会发现明显的变慢,如果没有彻底卡死的话。同样需要注意的一点是,在一个较大的DOM中每一次重新布局或重新应用样式(在节点上增加或删除样式所触发的过程)的系统开销都会比较昂贵。所以进行DOM回收意味着我们会保持DOM节点在一个比较低的数量上,进而加快上面提到的这些处理过程。
那我们针对DOM节点回收来实现一个无尽的长列表。(最终实例代码在底部)
实现思路
我们先把这个存在数据,但不在视图显示的列表叫做虚拟列表。
具体效果就像这样判断临界点
那么如何判断到达DOM回收的时机呢,也就是实际ViewList到达顶部或者顶部,要回收或者释放DOM。有两种主流的判断方式:
一、Scroll事件
window.addEventListener("scroll",function(e){
if(window.scrollY===顶部||window.scrollY===底部){
...
}
})
这个方法麻烦的地方在于,你需要去实时计算viewport顶部和底部的Y值。像我的实现代码里最大放三个ul在viewport中,而且HTML结构简单,我只要取得顶部ul.offsetTop(元素到offsetParent顶部的距离),底部ul.offsetTop+offsetHeight就是对于的Y值了。需要注意的是,我的ul父级简单,大小位置几乎和body一样,如果你的parentDiv嵌套太复杂,还有各自的offsetTop,就另当别论。只有元素显示了(渲染完成)才会计算入offsetTop。
二、使用IntersectionObserver
IntersectionObserver接口(从属于IntersectionObserverAPI)为开发者提供了一种可以异步监听目标元素与其祖先或视窗(viewport)交叉状态的手段。祖先元素与视窗(viewport)被称为根(root)。
应用在这个实例中就是,在顶部和底部各自加一个小元素,监听他们,当他出现的时候,就会及其祖先交叉,就会发出事件。
function addIntersectionObserver(){
var intersectionObserver = new IntersectionObserver(function(entries) {
if (entries[0].intersectionRatio <= 0) return;
if(entries[0].target === topDiv){
//到达顶部
}else if(entries[0].target === bottomDiv){
//到达底部
}
});
intersectionObserver.observe(topDiv);
intersectionObserver.observe(bottomDiv);
}
DOM回收
时机确定,接下来就是关键的DOM回收了。
//存放被回收顶部List
const beforeFragment = document.createDocumentFragment();
//存放被回收底部List
const afterFragment = document.createDocumentFragment();
//存放当前显示的List
const fragment = document.createDocumentFragment();
简单介绍下DocumentFragment接口,它他表示没有父级的最小文档对象,也就不会加入真实的DOMTree,进行渲染,只是一个虚拟的dom节点,存在于内存中,所以对片段所做的更改不会影响文档,导致回流,或者在进行更改时可能会发生任何性能影响。但操作方法属性还是像标准节点一样,故被用作轻量级版本,像标准文档一样存储由节点组成的文档结构的片段。
switch(临界情况):
case 到达顶部:
if(beforeFragment.lastChild)
beforeFragment弹出最后一个节点lastChild1
加入fragment头部
fragment弹出最后一个节点lastChild2
加入afterFragment头部
case 到达底部:
if(afterFragment.firstChild)
afterFragment弹出第一个节点firstChild1
加入fragment头部
fragment弹出最后一个节点firstChild2
加入beforeFragment头部
else
向后端请求数据加入fragment
重新加入DomTree
box.appendChild(fragment);
滚动条的问题
如果元素直接从视图删除一些加入一些,还要保持当前视图在当前DOM位置,会造成滚动条的跳动。总结了两种方法解决这种问题:
一、直接隐藏滚动条,再重新绘制一个自己控制的滚动条
body::-webkit-scrollbar {
display: none;
}
首先隐藏这个CSS只有Chrome支持,而且绘制也比较麻烦,如果适合业务场景可以考虑。
二、使用padding或者其他什么占位符替换被回收的DOM位置
这样滚动条只可能被缩小,而不会跳动。我的实例使用的是padding,也可以用其他。 根据被回收的DOM节点大小,分别更新顶部和底部的padding。
topDiv.style.paddingTop = `${paddingTop}px`;
bottomDiv.style.paddingBottom = `${paddingBottom}px`;
现存问题
当然我的实例还存在一些问题,比如说极其快速向上或者向下滚动时,来不及释放被回收的元素并绘制出来,会出现白屏。希望大佬们看到,能提出问题,交流解决方案,谢谢观看!
最终代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Document</title>
<style type="text/css">
*{
padding: 0;
margin: 0;
}
body{
height: 100vh;
}
#box{
width: 90%;
margin: 20px auto;
}
#top,#bottom{
height: 40px;
}
ul{
list-style: none;
margin: 5px;
text-align: center;
}
#box ul div{
width: 100%;
margin: 5px;
height: 100px;
border: 2px solid red;
font-size: 48px;
}
</style>
</head>
<body>
<ul id="top">到顶了</ul>
<div id="box">
</div>
<ul id="bottom">加载中</ul>
</body>
<script type="text/javascript">
let index= 1;
var box = document.getElementById("box");
var body = document.querySelector("body");
const beforeFragment = document.createDocumentFragment();
const afterFragment = document.createDocumentFragment();
const fragment = document.createDocumentFragment();
const [topDiv,bottomDiv] = [document.getElementById("top"),document.getElementById("bottom")];
//函数节流,就是指连续触发事件但是在 n 秒中只执行一次函数
function throttle(fn, wait,self=null) {
let _fn = fn,
timer;
return function(...args) {
if (timer) return false;
_fn.apply(self, [...args]);
timer = setTimeout(()=> {
clearTimeout(timer);
timer = null;
}, wait);
return true;
}
}
//获取图片(这是非真实场景,用了定时器假装异步请求)
function createImg (count) {
const ul = document.createElement("ul");
for(var i = 0; i < count; i++) {
var div = document.createElement("div");
div.innerText = index;
index++;
var li = document.createElement("li");
li.appendChild(div);
ul.appendChild(li);
}
return new Promise(function(resolve, reject) {
let timer = setTimeout(function() {
clearTimeout(timer);
resolve(ul);
timer = null;
}, 500);
});
}
/**
* 维护长列表(只在视图内显示60条)
* type:0为向上,1为向下
*/
let paddingTop = 0;
let paddingBottom = 0;
async function removeOverDom(type){
let y =0;
const scrollY = window.scrollY;
if(box.children.length>2){
if(type===0&&!beforeFragment.lastChild) return true;
//为了防止白屏,先异步请求完成后再操作
if(type===1&&!afterFragment.firstChild) ul = await createImg(20);
// appendChil加入后box就会移除这个元素,随后获取不到其高度
y = type?-box.children[0].scrollHeight:box.children[2].scrollHeight;
fragment.appendChild(box.children[0]);
fragment.appendChild(box.children[0]);
fragment.appendChild(box.children[0]);
switch (type) {
case 0:
afterFragment.prepend(fragment.lastChild);
fragment.prepend(beforeFragment.lastChild);
break;
case 1:
beforeFragment.appendChild(fragment.firstChild);
if(afterFragment.firstChild){
fragment.appendChild(afterFragment.firstChild);
}else{
fragment.appendChild(ul);
}
break;
default:
break;
}
box.appendChild(fragment);
if(y<0||(y>0&&paddingTop>0)) paddingTop -=y;
if(y>0||(y<0&&paddingBottom>0)) paddingBottom +=y;
topDiv.style.paddingTop = `${paddingTop}px`;
bottomDiv.style.paddingBottom = `${paddingBottom}px`;
window.scrollTo(0,scrollY,"smooth");
body.style.overflow="scroll";
}
if(box.children.length<3){
ul = await createImg(20);
fragment.appendChild(ul);
box.appendChild(fragment);
}
return true;
}
//判断是否滚到了顶部或者底部
const removeOverDomT = throttle(removeOverDom,10);
function addIntersectionObserver(){
var intersectionObserver = new IntersectionObserver(function(entries) {
if (entries[0].intersectionRatio <= 0) return;
if(entries[0].target === topDiv){
removeOverDomT(0);
}else if(entries[0].target === bottomDiv){
removeOverDomT(1);
}
});
intersectionObserver.observe(topDiv);
intersectionObserver.observe(bottomDiv);
}
addIntersectionObserver();
</script>
</html>
参考文章: