扯皮
那是一个风和日丽的下午,作为一名24应届生我像往常一样刷着面经,突然刷到一篇有这样的内容:手写虚拟列表,这几个加粗的大字愣住了,回过神来看了一眼标题,害,字节啊,鼠鼠连简历都过不了,再仔细一看,招的是实习生?点进去看个人信息,好家伙25届的同学,作为24届老人虽然封装过虚拟列表,相关优化也看了不少,定高、不定高、瀑布流虚拟列表能够直接吟唱,但让我现场手撕一个虚拟列表还是有点虚的,看完之后赶紧下来尝试一波,因此有了这篇文章。
正文
关于虚拟列表相关概念以及一些实现方案不再过多介绍,虚拟列表的本质就是用有限的 DOM 渲染 “无限” 的数据,文章的后续内容将默认读者已熟悉虚拟列表的基本实现原理。
现在我们把它当作一道普普通通手写题,在面试当中其关键要素有两点:
- 控制编码时间以及代码量,尽量以最少的代码实现最基本的功能
- 关于细节的体现更多靠的是嘴而不是手
因此我们只实现一个最基本的定高虚拟列表并能够让面试官看到效果,虚拟列表相关的优化以及应用场景等更多的是靠自己现场自由发散。
DOM 基本结构和样式
我们把虚拟列表的实现尽可能简化并以组件的形式搭建,最终只需要三个部分:
- 列表容器
container
- 数据列表
list
- 列表项
item
其他内容均为辅助,为了展示效果这里直接搭建最基本的 DOM 结构并设置其样式:
<div class="container">
<div class="fs-virtuallist-container">
<div class="fs-virtuallist-list">
<!-- <div class="fs-virtuallist-item"></div> -->
</div>
</div>
</div>
fs 开头的整体结构可以把它当成一个组件,这里我把 item
进行了注释是因为后续实现会直接通过设置 list
的 innerHTML,先进行占位。 container
作为父组件决定虚拟列表的宽高,因此样式就可以这样设置:
/* 容器布局并设置具体的宽高 */
.container {
width: 600px;
height: 600px;
margin: 100px auto;
border: 1px solid red;
}
/* 作为虚拟列表组件宽高由父组件决定,注意这里需要保证垂直方向有滚动条 */
.fs-virtuallist-container {
width: 100%;
height: 100%;
overflow-y: auto;
}
/* list 高度会用 JS 设置动态样式 */
.fs-virtuallist-list {
width: 100%;
}
/* item 固定高度即可,其他样式仅为了做展示 */
.fs-virtuallist-item {
width: 100%;
height: 100px;
box-sizing: border-box;
border: 1px solid #000;
text-align: center;
font-size: 20px;
line-height: 100px;
}
到此我们就能够看到虚拟列表的基本结构(先写一个单独的 item
看看整体的效果):
封装虚拟列表类
以类的方式进行封装整体性更强,也方便我们后续编码以及在手写过程中整理思路,我们将其命名为 FsVirtuallist
初始化内部状态
我们要求用户创建实例时传入 container
和 list
的选择器,因为内部需要获取 DOM 拿到相关的高度信息,在 constructor 中先初始化内部状态,这里需要的状态见注释:
class FsVirtuallist {
constructor(containerSelector, listSelector) {
this.state = {
dataSource: [], // 模拟数据源
itemHeight: 100, // 固定 item 高度
viewHeight: 0, // container 高度
maxCount: 0, // 虚拟列表视图最大容纳量
};
this.scrollStyle = {}; // list 动态样式(高度,偏移)
this.startIndex = 0; // 当前视图列表在数据源中的起始索引
this.endIndex = 0; // 当前视图列表在数据源中的末尾索引
this.renderList = []; // 渲染在视图上的列表项
// 根据用户传入的选择器获取 DOM 并保存
this.oContainer = document.querySelector(containerSelector);
this.oList = document.querySelector(listSelector);
}
}
初始化列表
我们把初始化列表的操作封装至 init 方法中,其主要功能如下:
- 获取
container
高度,计算列表最大容纳量 - 给
container
添加滚动事件 - 添加模拟数据
- 渲染视图
这里给出基本的结构代码,最大容纳量这里可以直接计算并保存,剩余的功能后续会逐步实现:
class FsVirtuallist {
// constructor(){}...
init() {
this.state.viewHeight = this.oContainer.offsetHeight;
this.state.maxCount = Math.ceil(this.state.viewHeight / this.state.itemHeight) + 1;
// this.bindEvent();
// this.addData();
// this.render();
}
}
最大容纳量
最大容纳量的计算只需用 container
的高度除以 item
的固定高度
这里可能会有疑问在于为什么需要向上取整 + 1 ,我们将其分为三种情况:整除、>= 0.5 、< 0.5,其实没有什么好解释的,直接上图看效果:
结论:最大容纳量 = 初始状态列表展示的最大 item 数量 + 1
初始状态指
scrollTop
为 0,展示的最大 item 数量指我们看到的数量(不完整的也算一个)
实际上我们只需要保证需要展示在视图上的 item 项 >= 视图最大容量即可,比如上面高度为 100 时最大容纳量为 5,设置为 6 也可以,但如果小于就会导致列表的留白,计算最大容纳量是正好等于的情况,如果无法理解就无脑设置多一两个也可以(当然也不能过多,否则虚拟列表的意义就不大了,毕竟最大容纳量决定了视图上 DOM 的数量)。
计算 endIndex,renderList
有了最大容纳量 maxCount
以及初始状态 startIndex
,我们就能够计算出 endIndex
有了 startIndex
和 endIndex
,我们就能够截取数据源来获取需要渲染在视图上的列表 renderList
这里的 endIndex
的设置可以严谨一些,由于后续进行向下滚动时会不断增加 startIndex
,但 endIndex
要保证不能超出数据源数组的范围
class FsVirtuallist {
// constructor(){}...
// init(){}...
computedEndIndex() {
const end = this.startIndex + this.state.maxCount;
this.endIndex = this.state.dataSource[end] ? end : this.state.dataSource.length;
}
computedRenderList() {
this.renderList = this.state.dataSource.slice(this.startIndex, this.endIndex);
}
}
计算滚动样式 scrollStyle
虚拟列表需要在用少量的 DOM 情况下也要给用户营造滚动的假象,当 list
的高度超出 container
自然会出现滚动条,而 list
最初的高度就是数据源的 length * item 高度
现在有了滚动效果,但是我们还要保证数据列表的正确展示,这也是虚拟列表原理的核心原理,用户向下滚动而列表整体向上移动,每滚动出一项我们立即设置列表样式将其压回初始状态,同时更新列表数据,压回操作可以用 translate 或者 padding,这里我采用的 translate3d:
注意这里
height
的计算,transform
的偏移量也会变相增加滚动的长度,因此在滚动时height
需要减去偏移量,否则在滚动时滚动条会不断变小(相当于内容不断增多),而多余的内容都是偏移量造成的留白
class FsVirtuallist {
// constructor(){}...
// init(){}...
// computedEndIndex(){}...
// computedRenderList(){}...
computedScrollStyle() {
const { dataSource, itemHeight } = this.state;
this.scrollStyle = {
height: `${dataSource.length * itemHeight - this.startIndex * itemHeight}px`,
transform: `translate3d(0, ${this.startIndex * itemHeight}px, 0)`,
};
}
}
实现 render 渲染操作
现在基本状态设置已经完成,可以补充真正渲染在视图上的逻辑了,实际上就是两个功能点:
- 将
renderList
转换为 DOM 渲染在视图 - 动态设置
list
的样式
不过由于我们是原生手写,不像框架那样只需更改状态数据带动视图渲染,因此每次渲染之前都需要重新计算数据:
class FsVirtuallist {
// constructor(){}...
// init(){}...
// computedEndIndex(){}...
// computedRenderList(){}...
// computedScrollStyle(){}...
render() {
this.computedEndIndex();
this.computedRenderList();
this.computedScrollStyle();
const template = this.renderList.map((i) => `<div class="fs-virtuallist-item">${i}</div>`).join("");
const { height, transform } = this.scrollStyle;
this.oList.innerHTML = template;
this.oList.style.height = height;
this.oList.style.transform = transform;
}
}
计算 startIndex,添加滚动事件
我们在计算 endIndex
、renderList
、scrollStyle
时都依赖了 startIndex
,很明显 startIndex
是整个虚拟列表的关键点,但因为初始状态 startIndex
默认值就是 0 ,所以之前没有着重讲解。
startIndex
计算牵扯到滚动事件,每当进行滚动时会产生滚动距离 scrollTop
,滚动出去一项我们就需要更新 startIndex
举一个例子
dataSource
为 [1, 2, 3, 4, 5, 6],renderList
为 [1, 2, 3],startIndex
为 0 指向第一项 1,当进行滚动第一项已经超出视图范围时,就要把startIndex
向后移动,也就是 + 1,牵动着renderList
也进行更新变为 [2, 3, 4]。
可以看到 startIndex
的计算与滚动出的项的个数有关,而滚出项的个数可以通过 scrollTop
和 itemHeight
计算出来。
再次强调我们现在是原生手写没有使用框架,startIndex
被很多变量依赖,当进行改变时我们同样需要更新其他变量的计算,而这些都已经封装到了 render 函数中,重新调用即可。
class FsVirtuallist {
// constructor(){}...
// init(){}...
// computedEndIndex(){}...
// computedRenderList(){}...
// computedScrollStyle(){}...
bindEvent() {
// 注意需要改变 this 指向 -> bind
this.oContainer.addEventListener("scroll", this.handleScroll.bind(this));
}
handleScroll() {
const { scrollTop } = this.oContainer;
this.startIndex = Math.floor(scrollTop / this.state.itemHeight);
this.render();
}
// render() {}...
}
模拟数据
现在虚拟列表的基本操作都已经完成了,为了有数据展示我们增加一个 addData 方法来给 dataSource
中添加数据来模拟真实场景的发送数据请求:
class FsVirtuallist {
// constructor(){}...
// init(){}...
// computedEndIndex(){}...
// computedRenderList(){}...
// computedScrollStyle(){}...
// bindEvent(){}...
// handleScroll(){}...
// render() {}...
addData() {
for (let i = 0; i < 10; i++) {
this.state.dataSource.push(this.state.dataSource.length + 1);
}
}
}
加载更多
我们希望实现滚动加载更多的效果,实际上无需通过 DOM 来获取 scrollHeight、scrollTop、clientHeight 来比较计算触底的操作。
可以把这个实现放在 computedEndIndex 中,我们知道 endIndex
当长度正好等于数据源的长度时其实已经到达底部,因此以这个条件判断来获取更多数据再好不过,注意:
如果想要提前表示触底加载数据的话可以把条件设置为 length - 1 或者 - 2
class FsVirtuallist {
// constructor(){}...
// init(){}...
computedEndIndex() {
const end = this.startIndex + this.state.maxCount;
this.endIndex = this.state.dataSource[end] ? end : this.state.dataSource.length;
// 滚动加载更多
if (this.endIndex >= this.state.dataSource.length) {
this.addData();
}
}
// computedRenderList(){}...
// computedScrollStyle(){}...
// bindEvent(){}...
// handleScroll(){}...
// render() {}...
// addData() {}...
}
finish
到此整个最基本的虚拟列表就已经实现了,我们结合之前的 DOM 结构,实例化一个虚拟列表调用 init 来看看实现的效果:
const vList = new FsVirtuallist(".fs-virtuallist-container", ".fs-virtuallist-list");
vList.init();
优化
现在虽然功能实现了但是性能还是比较差的,原生手写重复计算和执行渲染逻辑确实比不上框架,这里针对于上面实现的代码我提两个优化点。
startIndex 缓存优化
我们注意看滚动的这个细节,右边 DOM 更新情况:
实际上没有滚出第一项时 startIndex
不会进行改变,但由于我们在滚动事件中会不断调用 render 函数,造成重复的 innerHTML 更新,很显然这样是不对的。
解决这个问题的方法很简单,我们另存上一次的 startIndex
值,每次滚动时进行比对,没有变化不进行更新即可:
class FsVirtuallist {
constructor(containerSelector, listSelector) {
// ...
this.lastStart = -1; // 保存上一次的 startIndex,初始化不能与 startIndex 一样,故设置为 -1
// ...
}
// init(){}...
// computedEndIndex(){}...
// computedRenderList(){}...
// computedScrollStyle(){}...
// bindEvent(){}...
handleScroll() {
const { scrollTop } = this.oContainer;
this.startIndex = Math.floor(scrollTop / this.state.itemHeight);
// 比对更新
if (this.startIndex !== this.lastStart) this.render();
// 保存上一次的 startIndex
this.lastStart = this.startIndex;
}
// render() {}...
// addData() {}...
}
现在再来看看效果,注意看右边 DOM 的更新:
raf 滚动节流优化
关于滚动事件必然要想到进行节流优化,由于虚拟列表的每次滚动会频繁到视图更新逻辑,如果采用时间戳或者定时器方案并不是最优解,甚至在滚动过快的情况下由于时间间隔的设置会出现白屏问题。
这里需要使用 requestAnimationFrame
API,这个 API 相关内容不再过多介绍,我们直接编写一个 rafThrottle 方法,并在 bindEvent 中进行处理:
class FsVirtuallist {
// constructor() {}...
// init(){}...
// computedEndIndex(){}...
// computedRenderList(){}...
// computedScrollStyle(){}...
bindEvent() {
this.oContainer.addEventListener("scroll", this.rafThrottle(this.handleScroll.bind(this)));
}
// handleScroll() {}...
// render() {}...
// addData() {}...
rafThrottle(fn) {
let lock = false;
return function (...args) {
window.requestAnimationFrame(() => {
if (lock) return;
lock = true;
fn.apply(this, args);
lock = false;
});
};
}
}
End
原生 JS 实现的虚拟列表到此就结束了,整个实现核心代码大概 100 行左右,实际上关于虚拟列表的优化还有很多,比如设立缓冲区来缓解白屏问题等,而且实际场景中也大多会使用框架封装组件。这里用 JS 手写主要是为了理清虚拟列表的实现逻辑让面试官能够理解自己的思路,之后再转换为组件就十分容易了。
最后源码奉上:
最后的最后再补充一句,实际上面试当中让手写虚拟列表的情况少之又少,到目前为止我也只看到一篇面经让自己手写实现。
但关于虚拟列表的实现原理、优化以及应用场景被问的情况还是比较多的,基本上已经作为应届生必备的技能了,因此还没有了解这块知识的小伙伴一定要自己私底下补充这块内容,也能给自己简历上多加一条亮点。