在HTML/dom环境,纯JS“无限”滚动列表与众不同,和自绘制界面实现的ListView是不一样的,更加需要dom操作的技巧,讲究的要么是“左脚蹬右脚”循环覆盖,要么是滚动一点、增长一点。不过后者严格来说不是虚表,仅仅是懒加载而已。
其实“摸石过河”更加形象一点:

往下滚动,快到底了,就拿走上面的区块补充为下面的区块。往上滚动同理(相反),需要保持列表滚动的一致性。
分页层级:区块 -> 页面 -> 列表行。
1页面 == 30行
1区块 == 10页面 == 300行
区块是列表重组的单元,页面是获取数据、绑定数据的单元。
本例使用三个区块(红色、绿色、蓝色)。
性能还可以,疯狂滚动也就 8% CPU + 25% GPU。

共256行,不知道有没有BUG:
<body>
<div id="listView" style="height:100%; background:#00ff335f; overflow:scroll;">
</div>
<script>
var pageSz = 30; var minRowHeight=1.5;
var blockSz = pageSz*10; // 每 pageSz * 10 = 一页, 是列表重组的单位x
var doc=document,w=window;
var debug = console.log;
function craft(t, p, c) {
t = doc.createElement(t);
if(c)t.className=c;
if(p)p.appendChild(t);
return t;
}
function setVisible(e,v) {
e.style.display = v?'block':'none';
}
var listView = document.getElementById('listView');
var topRow, minBlockHeight, minRowPageHeight;
function initBlock(p, pagePosition) {
if(!p.init) {
p.init = 1;
for(var i=0;i<10;i++) {
var rp = craft('DIV', p, 'page');
rp.style.height=(minRowHeight*pageSz)+'em';
}
}
}
function initRowPage(p) {
if(!p.init) {
p.init = 1;
p.hide = 0;
p.style.height='auto';
for(var i=0;i<pageSz;i++)
{
var row = makeItem(p);
}
}
}
function makeItem(rowPage){
var rowItem = craft('DIV', rowPage, 'item');
rowItem.style.minHeight=minRowHeight+'em';
//rowItem.innerText = ''+position;
return rowItem;
}
var tmp = makeItem(listView);
var minRowHeightPx = tmp.offsetHeight;
tmp.remove();
var itemCount = 1000;
var maxRowDet = 0;
var rpA, rpB, 刑天=0;
function reset(total, percent) {
itemCount = total;
var blocks = [];
listView.onscroll = 0;
listView.innerHTML='';
listView.scrollTop = 0;
topRow = maxRowDet = rpA = rpB = 0;
for(var i=0;i<3;i++) {
var block = craft('DIV', listView, 'block');
initBlock(block);
blocks.push(block);
if(blockSz*i >= total) {
setVisible(block,0);
}
else if(blockSz*(i+1) >= total) {
block.size = total-blockSz*i;
for(var j=0;j<10;j++) {
setVisible(block.children[j], j*pageSz<block.size);
}
}
else block.size = blockSz;
maxRowDet += block.size;
}
minBlockHeight = blocks[0].offsetHeight;
minRowPageHeight = minBlockHeight/10;
blocks[0].style.background='#ff00d4f8'
blocks[1].style.background='#00ff335f'
blocks[2].style.background='#00f7fff8'
if(percent > 0) {
if(percent>1) percent=0.99;
var pos = total*percent;
percent = 0;
var d = pos;
if(pos>3*blockSz) {
d = blockSz;
topRow = parseInt(pos/d)*d;
d = pos - topRow;
maxRowDet = topRow+blockSz*3;
}
percent = d * minRowHeightPx;
debug('percent', topRow, d, percent)
}
lstScroll();
listView.onscroll = lstScroll;
if(topRow || percent) {
listView.scrollTop += percent;
rpA = rpB = 0;
lstScroll();
}
}
reset(1000);
function lstScroll(e) {
//debug(e);
// if(刑天!=0) {
// if(e.timeStamp-刑天<1200) return;
// 刑天=0;
// }
var y=listView.scrollTop;
if(itemCount>maxRowDet && y>= listView.scrollHeight*0.9) {
debug('底部!')
var p0=listView.children[0];
//p0.remove();
listView.append(p0);
topRow += p0.size;
var size = Math.min(blockSz, itemCount-maxRowDet);
maxRowDet += size;
if(p0.size!==size) {
p0.size=size;
for(var i=0;i<10;i++) {
setVisible(p0.children[i],i*pageSz<size);
}
}
repage();
}
else if(topRow>0 && y <= listView.clientHeight*0.2) {
debug('顶部111!')
var p0=listView.children[2];
//p0.remove();
maxRowDet -= p0.size||0;
listView.insertBefore(p0, listView.children[0]);
var size = Math.min(blockSz, topRow);
topRow -= size;
if(p0.size!==size) {
p0.size=size;
for(var i=0;i<10;i++) {
var page = p0.children[i],v=i*pageSz<size;
setVisible(page,v);
if(v) {
if(page.hide) {
for(var j=0;j<pageSz;j++)
setVisible(page.children[j],1);
page.hide=0;
}
}
}
}
var h=p0.offsetHeight;
//listView.onscroll = 0;
listView.scrollTop = h+y;
//listView.onscroll = lstScroll;
//刑天 = e.timeStamp;
//repage();
}
else if(!rpA || y<rpA.offsetTop
|| rpB && y+listView.offsetHeight>rpB.offsetTop+rpB.offsetHeight) {
repage();
}
}
listView.onscroll = lstScroll;
function getNextNode(n, e) {
var a = n.nextElementSibling;
if (a) {
if(a.style.display=='none')
return getNextNode(a, e);
return a;
}
var bk = n.parentNode.nextElementSibling;
n = bk && bk.firstElementChild;
if(n && n.style.display=='none')
n = 0;
return n;
}
function repage() {
var bk,pos = 0;
var y=listView.scrollTop, y1=y+listView.offsetHeight;
var findSt=1,findEd=1;
for(var i=0;i<3;i++) {
bk = listView.children[i];
if(bk.offsetTop+bk.offsetHeight>y) {
pos = i;
break;
}
}
debug('repage', pos)
var minSt = pageSz - (bk.offsetTop+bk.offsetHeight-y)/minRowHeight;
if(minSt<0) minSt=0;
minSt=0;
pos = topRow + pos*blockSz + minSt;
var page = bk.children[minSt];
rpA=rpB=0;
var height=0,pageCnt=1;
while(page) {
if(!rpA) {
//debug('finding...', page, off);
if(page.offsetTop+page.offsetHeight > y) {
height=page.offsetTop;
rpA = page;
debug('rpA', minSt)
}
}
if(rpA) {
bindRowPage(page, pos);
height += Math.min(minRowPageHeight, page.offsetHeight);
if(height>y1) {
rpB = page;
break;
}
pageCnt++;
}
pos+=pageSz;
page = getNextNode(page);
minSt++;
}
}
function bindRowPage(page, pos) {
initRowPage(page);
debug('bindRowPage', pos);
page.hide=0;
for(var i=0;i<pageSz;i++)
{
var row = page.children[i];
if(pos>=itemCount) {
setVisible(row,0);
page.hide = 1;
} else {
setVisible(row,1);
row.innerText=pos+'';
}
pos++
}
}
</script>
</body>
测试代码:
reset(总行数, 恢复百分比位置)
如reset(10000, .5)、reset(1000, .5)、reset(100, .5) 均能恢复到列表中间位置。
reset(9002, 0.999) 恢复到列表末尾位置。

可考虑css模拟滚动条,显示完整的滚动条而不是现在这样一跳一跳的。
或者参考其他成熟列表库,以前搜索过,名字忘记了…
或者这个也不错 : js 虚拟列表,思路不同,显示的是完整滚动条。