「造轮子」虚拟滚动 + soild + Web Component
前端时间参与一个项目,一个下拉选择列表中可能出现最多 8000 条数据。上一位同事使用BetterScroll做滚动,但由于数据量庞大,要渲染的 dom 节点太多,导致加载时间很长甚至手机卡顿。
当我接手时,需要解决卡顿的问题。对于大量数据的加载和渲染,无疑虚拟滚动是最好的选择。
react-virtualized
我去 react 社区寻找已有的虚拟滚动实现react-virtualized,但其 2.27MB 的大小让我放弃了,我们的列表需求仅需要一个简单虚拟滚动即可,最多加上一个滚动加载,杀鸡何须用牛刀。于是便产生了自己造轮子的想法。
Web Component
我的们项目是基于 react 开发,但还有一个 vue 的复刻版。因此使用原生 js 可以实现两边兼容。封装为Web Component,可以更好的使用,且 react 和 vue 对此都有良好的支持。
solid
最近在社区逛了逛,发现有许多关于无 vdom 的响应式框架的讨论,比如svelte、solid。看完一部分我心中一惊,为什么大家知道,而我听都没听过。
从执行时和编译速度来看,这些框架确实非常快,对于饱受低速热重载折磨的我来说,简直是福音。
Vanilla 是不使用任何框架的纯粹的原生 JavaScript,通常作为一个性能比较的基准
话不多说,上手干。按照 solid 官网教程建立项目,构建工具竟然 vite,我喜欢。
设计思路
相比于普通的滚动,虚拟滚动仅渲染要显示的 dom。
我们可以让可视的 dom 随着滚动距离移动,根据滚动的距离计算当前滚动到哪一段数据,然后更新到可视的 dom 中。
实现过程
组件封装
按照上述设计思路,实现并封装虚拟列表组件VirtualList
import { createSignal } from'solid-js'; import { VirtualListProps } from'./interface'; importstylesfrom'./VirtualList.module.css'; constVirtualList= ({ rowRenderer }: VirtualListProps) => { // item高度constITEM_HEIGHT=50; // item数量constITEM_NUMBER=12000; // view视图高度constVIEW_HEIGHT=400; // 实际渲染的item数量constRENDER_NUMBER=10; // 最大滚动距离constMAX_SCROLL_DISTANCE=ITEM_HEIGHT*ITEM_NUMBER-VIEW_HEIGHT; // 可视区列表constviewArr=newArray(RENDER_NUMBER).fill(0).map((d, i) =>d+i); // 可视区向上偏移的距离const [getOffset, setOffset] =createSignal(0); // 可视区向上偏移的item数量const [getOffsetNum, setOffsetNum] =createSignal(0); letref: any; consthandleScroll= (ev: UIEvent) => { const { scrollTop=0 } =ref?? {}; // 滚动item数量constoffsetNum=Math.floor(scrollTop/ITEM_HEIGHT); // 滚动到底部if (MAX_SCROLL_DISTANCE<=scrollTop) { setOffsetNum(ITEM_NUMBER-RENDER_NUMBER); setOffset(MAX_SCROLL_DISTANCE); return; } setOffsetNum(offsetNum); setOffset(scrollTop); }; return ( <divclass={styles['view-area']} style={{ height: `${VIEW_HEIGHT}px` }} ref={ref} onScroll={handleScroll} ><divstyle={{ height: `${ITEM_NUMBER*ITEM_HEIGHT}px` }}><divclass={styles['virtual-inner']} style={{ transform: `translateY(${getOffset()}px)` }} > {viewArr.map((d) => { constindex=d+getOffsetNum(); returnrowRenderer({ index, domIndex: d }); })} </div></div></div> ); }; exportdefaultVirtualList;
App.tsx
importtype { Component } from'solid-js'; importListItemfrom'example/components/ListItem'; importVirtualListfrom'@/VirtualList'; constApp: Component= () => { return ( <VirtualListrowRenderer={({ index }) =><ListItemvalue={index} />} /> ); }; exportdefaultApp;
运行效果
滚动优化
可以看到数据滚动正常了,但是仅仅是数据内容在变化,而列表项看起来并没有动,这与真实的滚动差别还是挺大的。
原因在于滚动容器(virtual container)的偏移量(offset
)与滚动高度(scrollTop
)是相等的,那么滚动时,滚动容器与页面相对静止,因此看起来像没动也一样。
真实的滚动情况如下:
真实情况下offset
与scrollTop
并非时刻相等,当滚动到如上图所示的位置,边缘的元素只显示一部分时,offset
应当是scrollTop
减去最上面元素被遮挡高度(remainHeight
),如下图:
修改源代码
import { createSignal } from'solid-js'; import { VirtualListProps } from'./interface'; importstylesfrom'./VirtualList.module.css'; constVirtualList= ({ rowRenderer }: VirtualListProps) => { // item高度constITEM_HEIGHT=50; // item数量constITEM_NUMBER=12000; // view视图高度constVIEW_HEIGHT=400; // 实际渲染的item数量constRENDER_NUMBER=10; // 最大滚动距离constMAX_SCROLL_DISTANCE=ITEM_HEIGHT*ITEM_NUMBER-VIEW_HEIGHT; // 可视区列表constviewArr=newArray(RENDER_NUMBER).fill(0).map((d, i) =>d+i); // 可视区向上偏移的距离const [getOffset, setOffset] =createSignal(0); // 可视区向上偏移的item数量const [getOffsetNum, setOffsetNum] =createSignal(0); letref: any; consthandleScroll= (ev: UIEvent) => { const { scrollTop=0 } =ref?? {}; // 滚动到底部if (MAX_SCROLL_DISTANCE<=scrollTop) { setOffsetNum(ITEM_NUMBER-RENDER_NUMBER); setOffset(MAX_SCROLL_DISTANCE); return; } // 滚动item数量constoffsetNum=Math.floor(scrollTop/ITEM_HEIGHT); // 多余不足一个item高度的距离constremainHeight=scrollTop%ITEM_HEIGHT; setOffsetNum(offsetNum); setOffset(scrollTop-remainHeight); }; return ( <divclass={styles['view-area']} style={{ height: `${VIEW_HEIGHT}px` }} ref={ref} onScroll={handleScroll} ><divstyle={{ height: `${ITEM_NUMBER*ITEM_HEIGHT}px` }}><divclass={styles['virtual-inner']} style={{ transform: `translateY(${getOffset()}px)` }} > {viewArr.map((d) => { constindex=d+getOffsetNum(); returnrowRenderer({ index, domIndex: d }); })} </div></div></div> ); }; exportdefaultVirtualList;
看看修改后的效果:
这次的滚动看起来就比较真实了。
问题与优化
滚动结束的判定
从onScroll
获取的scrollTop
并非是连续的,那么滚动到最后临近底部时,可能出现scrollTop
突然超出计算出来的最大滚动距离MAX_SCROLL_DISTANCE
,导致多出一个元素。解决这个问题的处理比较粗暴:
// 滚动到底部if (MAX_SCROLL_DISTANCE<=scrollTop) { setOffsetNum(ITEM_NUMBER-RENDER_NUMBER); setOffset(MAX_SCROLL_DISTANCE); return; }
当scrollTop
超过MAX_SCROLL_DISTANCE
时,直接设置滚动偏移量offset
和滚动元素数目offsetNum
为预期最终结果,强制结束滚动。
这样处理在逻辑上没有问题,但因滚动并非自然结束,那么在最末尾的一段数据也会出现非自然的渲染,如下图,体现出这个细节,我将滚动条目数量ITEM_NUMBER
改为1200
, 然后缓慢的一次一次向下滚动,仔细看最后一次滚动,1190
明显渲染了2次。
元素高度固定
按照这个设计思路做虚拟滚动时,滚动元素的高度必须是已知且固定的,不适用于列表元素高度自适应的情况,而这在移动端的列表很常见。后续有时间,我会思考如何处理自适应元素高度的虚拟滚动。
编译打包
项目中使用vite打包,通过solid可把代码转换为纯js:
将这段js代码再封装,改为web Component组件:
classVirtualextendsHTMLElement { constructor() { super(); consttemplate=VirtualList({ rowRenderer: ({ index }) => { consttemplateItem=document.getElementById('virtual-item'); constcontent=templateItem.content.cloneNode(true); constdom=content.querySelector('.item'); dom.innerHTML=index; returndom; }, }); this.appendChild(template); } } window.customElements.define('virtual-list', Virtual);
这样就可以在html中直接使用virtual-list
: