原文一样发布在知乎专栏react
在咱们的业务场景中遇到这么一个问题,有一个商户下拉框选择列表,咱们简单的使用 antd 的 select 组件,发现每次点击下拉框,从点击到弹出会存在很严重的卡顿,在本地测试时,数据库只存在370条左右数据,这个量级的数据都能感到很明显的卡顿了(开发环境约700+ms),更别提线上 2000+ 的数据了。Antd 的 select 性能确实不敢恭维,它会简单的将所有数据 map 出来,在点击的时候初始化并保存在 document.body 下的一个 DOM 节点中缓存起来,这又带来了另外一个问题,咱们的场景中,商户选择列表不少模块都用到了,每次点击以后都会新生成 2000+ 的 DOM 节点,若是把这些节点都存到 document 下,会形成 DOM 节点数量暴涨。github
虚拟列表就是为了解决这种问题而存在的。typescript
虚拟列表本质就是使用少许的 DOM 节点来模拟一个长列表。以下图左所示,不论多长的一个列表,实际上出如今咱们视野中的不过只是其中的一部分,这时对咱们来讲,在视野外的那些 item 就不是必要的存在了,如图左中 item 5 这个元素)。即便去掉了 item 5 (如右图),对于用户来讲看到的内容也彻底一致。数据库
下面咱们来一步步将步骤分解,具体代码能够查看 Online Demo。数组
这里是我经过这种思想实现的一个库,功能会更完善些。浏览器
以上图为例,想象一个拥有 1000 元素的列表,若是使用上图左的方式的话,就须要建立 1000 个 DOM 节点添加在 document 中,而其实每次出如今视野中的元素,只有4个,那么剩余的 996 个元素就是浪费。而若是就只建立 4 个 DOM 节点的话,这样就能节省 996 个DOM 节点的开销。antd
真实 DOM 数量 = Math.ceil(容器高度 / 条目高度)数据结构
定义组件有以下接口
interface IVirtualListOptions {
height: number
}
interface IVirtualListProps {
data$: Observable<string[]>
options$: Observable<IVirtualListOptions>
}
复制代码
首先须要有一个容器高度的流来装载容器高度
private containerHeight$ = new BehaviorSubject<number>(0)
复制代码
须要在组件 mount 以后,才能测量容器的真实高度。能够经过一个 ref 来绑定容器元素,在 componentDidMount
以后,获取容器高度,并通知 containerHeight$
。
this.containerHeight$.next(virtualListContainerElm.clientHeight)
复制代码
获取了容器高度以后,根据上面的公式来计算视窗内应该显示的 DOM 数量
const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(
map(([ch, { height }]) => Math.ceil(ch / height))
)
复制代码
经过组合 actualRows$
和 data$
两个流,来获取到应当出如今视窗内的数据切片
const dataInViewSlice$ = combineLatest(this.props.data$, actualRows$).pipe(
map(([data, actualRows]) => data.slice(0, actualRows))
)
复制代码
这样,一个当前时刻的数据源就获取到了,订阅它来将列表渲染出来
dataInViewSlice$.subscribe(data => this.setState({ data }))
复制代码
效果
给定的数据有 1000 条,只渲染了前 7 条数据出来,这符合预期。
如今存在另外一个问题,容器的滚动条明显不符合 1000 条数据该有的高度,由于咱们只有 7 条真实 DOM,没有办法将容器撑开。
在原生的列表实现中,咱们不须要处理任何事情,只须要把 DOM 添加到 document 中就能够了,浏览器会计算容器的真实高度,以及滚动到什么位置会出现什么元素。可是虚拟列表不会,这就须要咱们自行解决容器的高度问题。
为了能让容器看起来和真的拥有1000条数据同样,就须要将容器的高度撑开到 1000 条元素该有的高度。这一步很容易,参考下面公式
真实容器高度 = 数据总数 * 每条 item 的高度
将上述公式换成代码
const scrollHeight$ = combineLatest(this.props.data$, this.props.options$).pipe(
map(([data, { height }]) => data.length * height)
)
复制代码
效果
这样看起来就比较像有 1000 个元素的列表了。
可是滚动以后发现,下面全是空白的,因为列表只存在7个元素,空白是正常的。而咱们指望随着滚动,元素能正确的出如今视野中。
这里有三种实现方式,而前两种基本同样,只有细微的差异,咱们先从最初的方案提及。
这种方案是最简单的实现,咱们只须要在列表滚动到某一位置的时候,去计算出当前的视窗中列表的索引,有了索引就能获得当前时刻的数据切片,从而将数据渲染到视图中。
为了让列表效果更好,咱们将渲染的真实 DOM 数量多增长 3 个
const actualRows$ = combineLatest(this.containerHeight$, this.props.options$).pipe(
map(([ch, { height }]) => Math.ceil(ch / height) + 3)
)
复制代码
首先定义一个视窗滚动事件流
const scrollWin$ = fromEvent(virtualListElm, 'scroll').pipe(
startWith({ target: { scrollTop: 0 } })
)
复制代码
在每次滚动的时候去计算当前状态的索引
const shouldUpdate$ = combineLatest(
scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
this.props.options$,
actualRows$
).pipe(
// 计算当前列表中最顶部的索引
map(([st, { height }, actualRows]) => {
const firstIndex = Math.floor(st / height)
const lastIndex = firstIndex + actualRows - 1
return [firstIndex, lastIndex]
})
)
复制代码
这样就能在每一次滚动的时候获得视窗内数据的起止索引了,接下来只须要根据索引算出 data 切片就行了。
const dataInViewSlice$ = combineLatest(this.props.data$, shouldUpdate$).pipe(
map(([data, [firstIndex, lastIndex]]) => data.slice(firstIndex, lastIndex + 1))
);
复制代码
拿到了正确的数据,还没完,想象一下,虽然咱们随着滚动的发生计算出了正确的数据切片,可是正确的数据却没有出如今正确的位置,由于他们的位置是固定不变的。
所以还须要对元素的位置作位移(逮虾户)的操做,首先修改一下传给视图的数据结构
const dataInViewSlice$ = combineLatest(
this.props.data$,
this.props.options$,
shouldUpdate$
).pipe(
map(([data, { height }, [firstIndex, lastIndex]]) => {
return data.slice(firstIndex, lastIndex + 1).map(item => ({
origin: item,
// 用来定位元素的位置
$pos: firstIndex * height,
$index: firstIndex++
}))
})
);
复制代码
接下把 HTML 结构也作一下修改,将每个元素的位移添加进去
this.state.data.map(data => (
<div
key={data.$index}
style={{
position: 'absolute',
width: '100%',
// 定位每个 item
transform: `translateY(${data.$pos}px)`
}}
>
{(this.props.children as any)(data.origin)}
</div>
))
复制代码
这样就完成了一个虚拟列表的基本形态和功能了。
效果以下
可是这个版本的虚拟列表并不完美,它存在如下几个问题
每次滚动都会使得 data 发生计算,虽然借助 virtual DOM 会将没必要要的 DOM 修改拦截掉,可是仍是会存在计算浪费的问题。
实际上咱们确实应该触发更新的时机是在当前列表的索引起生了变化的时候,即开始个人列表索引为 [0, 1, 2]
,滚动以后,索引变为了 [1, 2, 3]
,这个时机是咱们须要更新视图的时机。借助于 rxjs 的操做符,能够很轻松的搞定这个事情,只须要把 shouldUpdate$
流作一次过滤操做便可。
const shouldUpdate$ = combineLatest(
scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
this.props.options$,
actualRows$
).pipe(
// 计算当前列表中最顶部的索引
map(([st, { height }, actualRows]) => [Math.floor(st / height), actualRows]),
// 若是索引有改变,才触发从新 render
filter(([curIndex]) => curIndex !== this.lastFirstIndex),
// update the index
tap(([curIndex]) => this.lastFirstIndex = curIndex),
map(([firstIndex, actualRows]) => {
const lastIndex = firstIndex + actualRows - 1
return [firstIndex, lastIndex]
})
)
复制代码
效果
若是仔细对比会发现,每次列表发生更新以后,是会发生 DOM 的建立和删除的,以下图所示,在滚动了以后,原先位于列表中的第一个节点被移除了。
而我指望的理想的状态是,可以重用 DOM,不去删除和建立它们,这就是第二个版本的实现。
为了达到节点的复用,咱们须要将列表的 key 设置为数组索引,而非一个惟一的 id,以下
this.state.data.map((data, i) => <div key={i}>{data}</div>)
复制代码
只须要这一点改动,再看看效果
能够看到数据变了,可是 DOM 并无被移除,而是被复用了,这是我想要的效果。
观察一下这个版本的实现与上一版本有何区别
是的,这个版本,每一次 render 都会使得整个列表样式发生变化,并且还有一个问题,就是列表滚动到最后的时候,会发生 DOM 减小的状况,虽然并不影响显示,可是仍是有 DOM 的建立和移除的问题存在。
为了能让列表只按照须要进行更新,而不是所有重刷,咱们就须要明确知道有哪些 DOM 节点被移出了视野范围,操做这些视野范围外的节点来补充列表,从而完成列表的按需更新,以下图
假设用户在向下滚动列表的时候,item 1 的 DOM 节点被移出了视野,这时咱们就能够把它移动到 item 5 的位置,从而完成一次滚动的连续,这里咱们只改变了元素的位置,并无建立和删除 DOM。
dataInViewSlice$
流依赖props.data$
、props.options$
、shouldUpdate$
三个流来计算出当前时刻的 data 切片,而视图的数据彻底是根据 dataInViewSlice$
来渲染的,因此若是想要按需更新列表,咱们就须要在这个流里下手。
在容器滚动的过程当中存在以下几种场景
可是这两种场景其实均可以概括为一种状况,都是求前一种状态与当前状态之间的索引差集。
在 dataInViewSlice$
流中须要作两步操做。第一,在初始加载,尚未数组的时候,填充一个数组出来;第二,根据滚动到当前时刻时的起止索引,计算出两者的索引差集,更新数组,这一步即是按需更新的核心所在。
先来实现第一步,只须要稍微改动一下原先的 dataInViewSlice$
流的 map 实现便可完成初始数据的填充
const dataSlice = this.stateDataSnapshot;
if (!dataSlice.length) {
return this.stateDataSnapshow = data.slice(firstIndex, lastIndex + 1).map(item => ({
origin: item,
$pos: firstIndex * height,
$index: firstIndex++
}))
}
复制代码
接下来完成按需更新数组的部分,首先须要知道滚动先后两种状态之间的索引差别,好比滚动前的索引为 [0,1,2]
,滚动后的索引为 [1,2,3]
,那么他们的差集就是 [0]
,说明老数组中的第一个元素被移出了视野,那么就须要用这第一个元素来补充到列表最后,成为最后一个元素。
首先将数组差集求出来
// 获取滚动先后索引差集
const diffSliceIndexes = this.getDifferenceIndexes(dataSlice, firstIndex, lastIndex);
复制代码
有了差集就能够计算新的数组组成了。还以此图为例,用户向下滚动,当元素被移除视野的时候,第一个元素(索引为0)就变成最后一个元素(索引为4),也就是,oldSlice [0,1,2,3]
-> newSlice [1,2,3,4]
。
在变换的过程当中,[1,2,3]
三个元素始终是不须要动的,所以咱们只须要截取不变的 [1,2,3]
再加上新的索引 4 就能变成 [1,2,3,4]
了。
// 计算视窗的起始索引
let newIndex = lastIndex - diffSliceIndexes.length + 1;
diffSliceIndexes.forEach(index => {
const item = dataSlice[index];
item.origin = data[newIndex];
item.$pos = newIndex * height;
item.$index = newIndex++;
});
return this.stateDataSnapshot = dataSlice;
复制代码
这样就完成了一个向下滚动的数组拼接,以下图所示,DOM 确实是只更新超出视野的元素,而没有重刷整个列表。
可是这只是针对向下滚动的,若是往上滚动,这段代码就会出问题。缘由也很明显,数组在向下滚动的时候,是往下补充元素,而向上滚动的时候,应该是向上补充元素。如 [1,2,3,4]
-> [0,1,2,3]
,对它的操做是 [1,2,3]
保持不变,而 4号元素变成了 0号元素,因此咱们须要根据不一样的滚动方向来补充数组。
先建立一个获取滚动方向的流 scrollDirection$
// scroll direction Down/Up
const scrollDirection$ = scrollWin$.pipe(
map(() => virtualListElm.scrollTop),
pairwise(),
map(([p, n]) => n - p > 0 ? 1 : -1),
startWith(1)
);
复制代码
将 scrollDirection$
流加入到 dataInViewSlice$
的依赖中
const dataInViewSlice$ = combineLatest(this.props.data$, this.options$, shouldUpdate$).pipe(
withLatestFrom(scrollDirection$)
)
复制代码
有了滚动方向,咱们只须要修改 newIndex 就行了
// 向下滚动时 [0,1,2,3] -> [1,2,3,4] = 3
// 向上滚动时 [1,2,3,4] -> [0,1,2,3] = 0
let newIndex = dir > 0 ? lastIndex - diffSliceIndexes.length + 1 : firstIndex;
复制代码
至此,一个功能完善的按需更新的虚拟列表就基本完成了,效果以下
是否是还差了什么?
没错,咱们尚未解决列表滚动到最后时会建立、删除 DOM 的问题了。
分析一下问题缘由,应该能想到是 shouldUpdate$
这里在最后一屏的时候,计算出来的索引与最后一个索引的差小于了 actualRows$
中计算出来的数,因此致使了列表数量的变化,知道了缘由就好解决问题了。
咱们只须要计算出数组在维持真实 DOM 数量不变的状况下,最后一屏的起始索引应为多少,再和计算出来的视窗中第一个元素的索引进行对比,取两者最小为下一时刻的起始索引。
计算最后一屏的索引时须要得知 data 的长度,因此先将 data 依赖拉进来
const shouldUpdate$ = combineLatest(
scrollWin$.pipe(map(() => virtualListElm.scrollTop)),
this.props.data$,
this.props.options$,
actualRows$
)
复制代码
而后来计算索引
// 计算当前列表中最顶部的索引
map(([st, data, { height }, actualRows]) => {
const firstIndex = Math.floor(st / height)
// 在维持 DOM 数量不变的状况下计算出的索引
const maxIndex = data.length - actualRows < 0 ? 0 : data.length - actualRows;
// 取两者最小做为起始索引
return [Math.min(maxIndex, firstIndex), actualRows];
})
复制代码
这样就真正完成了彻底复用 DOM + 按需更新 DOM 的虚拟列表组件。
Github
上述代码具体请看在线 DEMO