使用 React + Rxjs 实现一个虚拟滚动组件

原文同样发布在知乎专栏https://zhuanlan.zhihu.com/p/…为什么使用虚拟列表在我们的业务场景中遇到这么一个问题,有一个商户下拉框选择列表,我们简单的使用 antd 的 select 组件,发现每次点击下拉框,从点击到弹出会存在很严重的卡顿,在本地测试时,数据库只存在370条左右数据,这个量级的数据都能感到很明显的卡顿了(开发环境约700+ms),更别提线上 2000+ 的数据了。Antd 的 select 性能确实不敢恭维,它会简单的将全部数据 map 出来,在点击的时候初始化并保存在 document.body 下的一个 DOM 节点中缓存起来,这又带来了另一个问题,我们的场景中,商户选择列表很多模块都用到了,每次点击之后都会新生成 2000+ 的 DOM 节点,如果把这些节点都存到 document 下,会造成 DOM 节点数量暴涨。虚拟列表就是为了解决这种问题而存在的。虚拟列表原理虚拟列表本质就是使用少量的 DOM 节点来模拟一个长列表。如下图左所示,不论多长的一个列表,实际上出现在我们视野中的不过只是其中的一部分,这时对我们来说,在视野外的那些 item 就不是必要的存在了,如图左中 item 5 这个元素)。即使去掉了 item 5 (如右图),对于用户来说看到的内容也完全一致。下面我们来一步步将步骤分解,具体代码可以查看 Online Demo。这里是我通过这种思想实现的一个库,功能会更完善些。https://github.com/musicq/vist创建适合容器高度的 DOM 元素以上图为例,想象一个拥有 1000 元素的列表,如果使用上图左的方式的话,就需要创建 1000 个 DOM 节点添加在 document 中,而其实每次出现在视野中的元素,只有4个,那么剩余的 996 个元素就是浪费。而如果就只创建 4 个 DOM 节点的话,这样就能节省 996 个DOM 节点的开销。解题思路真实 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>))这样就完成了一个虚拟列表的基本形态和功能了。效果如下但是这个版本的虚拟列表并不完美,它存在以下几个问题计算浪费DOM 节点的创建和移除计算浪费每次滚动都会使得 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 的创建和删除的,如下图所示,在滚动了之后,原先位于列表中的第一个节点被移除了。而我期望的理想的状态是,能够重用 DOM,不去删除和创建它们,这就是第二个版本的实现。复用 DOM 重刷列表为了达到节点的复用,我们需要将列表的 key 设置为数组索引,而非一个唯一的 id,如下this.state.data.map((data, i) => <div key={i}>{data}</div>)只需要这一点改动,再看看效果可以看到数据变了,但是 DOM 并没有被移除,而是被复用了,这是我想要的效果。观察一下这个版本的实现与上一版本有何区别是的,这个版本,每一次 render 都会使得整个列表样式发生变化,而且还有一个问题,就是列表滚动到最后的时候,会发生 DOM 减少的情况,虽然并不影响显示,但是还是有 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$const scrollDirection$ = scrollTop$.pipe( map(scrollTop => { const dir = scrollTop - this.lastScrollPos; this.lastScrollPos = scrollTop; return dir > 0 ? 1 : -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] = 0let 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 的虚拟列表组件。Githubhttps://github.com/musicq/vist上述代码具体请看在线 DEMOOnline Demo。 ...

January 10, 2019 · 3 min · jiezi

浅说虚拟列表的实现原理

在 列表数据的展示优化 一文中,提到了对于列表形态的数据展示的按需渲染。这种方式是指根据容器元素的高度以及列表项元素的高度来显示长列表数据中的某一个部分,而不是去完整地渲染长列表,以提高无限滚动的性能。而按需显示方案的实现就是本文标题中说的虚拟列表。虚拟列表的实现有多种方案,本文以 react-virtual-list 组件为基础进行分析。原文链接:https://github.com/dwqs/blog/…什么是虚拟列表?在正文之前,先对虚拟列表做个简单的定义。根据上文,虚拟列表是按需显示思路的一种实现,即虚拟列表是一种根据滚动容器元素的可视区域来渲染长列表数据中某一个部分数据的技术。简而言之,虚拟列表指的就是「可视区域渲染」的列表。有三个概念需要了解一下:滚动容器元素:一般情况下,滚动容器元素是 window 对象。然而,我们可以通过布局的方式,在某个页面中任意指定一个或者多个滚动容器元素。只要某个元素能在内部产生横向或者纵向的滚动,那这个元素就是滚动容器元素考虑每个列表项只是渲染一些纯文本。在本文中,只讨论元素的纵向滚动。可滚动区域:滚动容器元素的内部内容区域。假设有 100 条数据,每个列表项的高度是 50,那么可滚动的区域的高度就是 100 * 50。可滚动区域当前的具体高度值一般可以通过(滚动容器)元素的 scrollHeight 属性获取。用户可以通过滚动来改变列表在可视区域的显示部分。可视区域:滚动容器元素的视觉可见区域。如果容器元素是 window 对象,可视区域就是浏览器的视口大小(即视觉视口);如果容器元素是某个 div 元素,其高度是 300,右侧有纵向滚动条可以滚动,那么视觉可见的区域就是可视区域。实现虚拟列表就是在处理用户滚动时,要改变列表在可视区域的渲染部分,其具体步骤如下:计算当前可见区域起始数据的 startIndex计算当前可见区域结束数据的 endIndex计算当前可见区域的数据,并渲染到页面中计算 startIndex 对应的数据在整个列表中的偏移位置 startOffset,并设置到列表上计算 endIndex 对应的数据相对于可滚动区域最底部的偏移位置 endOffset,并设置到列表上建议参考下图理解一下上面的步骤:元素 L 代指当前列表中的最后一个元素从上图可以看出,startOffset 和 endOffset 会撑开容器元素的内容高度,让其可持续的滚动;此外,还能保持滚动条处于一个正确的位置。为什么需要虚拟列表?虚拟列表是对长列表的一种优化方案。在前端开发中,会碰到一些不能使用分页方式来加载列表数据的业务形态,我们称这种列表叫做长列表。比如,在一些外汇交易系统中,前端会准实时的展示用户的持仓情况(收益、亏损、手数等),此时对于用户的持仓列表一般是不能分页的。在本篇文章中,我们把长列表定义成数据长度大于 999,并且不能使用分页的形式来展示的列表。如果对长列表不作优化,完整地渲染一个长列表,到底需要多长时间呢?接下来会写一个简单的 demo 来测试以下。本文 demo 的测试环境:Macbook Pro(Core i7 2.2G, 16G), Chrome 69,React 16.4.1在 demo 中,我们先测一下浏览器渲染 10000 个简单的节点需要多长时间:import React from ‘react’const count = 10000function createMarkup (doms) { return doms.length ? { __html: doms.join(’ ‘) } : { __html: ’’ }}export default class DOM extends React.Component { constructor (props) { super(props) this.state = { simpleDOMs: [] } this.onCreateSimpleDOMs = this.onCreateSimpleDOMs.bind(this) } onCreateSimpleDOMs () { const array = [] for (var i = 0; i < count; i++) { array.push(’<div>’ + i + ‘</div>’) } this.setState({ simpleDOMs: array }) } render () { return ( <div style={{ marginLeft: ‘10px’ }}> <h3>Creat large of DOMs:</h3> <button onClick={this.onCreateSimpleDOMs}>Create Simple DOMs</button> <div dangerouslySetInnerHTML={createMarkup(this.state.simpleDOMs)} /> </div> ) }}当点击 Button 时,会调用 onCreateSimpleDOMs 创建 10000 个简单节点。从 Chrome 的 Performance 标签页看到的数据如下:从上图可以看到,从 Event Click 到 Paint,总共用了大约 693ms,渲染时的主要时间消耗情况如下:Recalculate Style:40.80msLayout:518.55msUpdate Layer Tree:11.84ms在 Recalculate Style 和 Layout 阶段,ReactDOM 调用了 setInnerHTML 方法,其内部主要通过 innerHTML 方法,将创建好的 html 片段添加到对应节点然后,我们创建 10000 个稍微复杂点的节点。修改组件如下:import React from ‘react’function createMarkup (doms) { return doms.length ? { __html: doms.join(’ ‘) } : { __html: ’’ }}export default class DOM extends React.Component { constructor (props) { super(props) this.state = { complexDOMs: [] } this.onCreateComplexDOMs = this.onCreateComplexDOMs.bind(this) } onCreateComplexDOMs () { const array = [] for (var i = 0; i < 5000; i++) { array.push( &lt;div class='list-item'&gt; &lt;p&gt;#${i} eligendi voluptatem quisquam&lt;/p&gt; &lt;p&gt;Modi autem fugiat maiores. Doloremque est sed quis qui nobis. Accusamus dolorem aspernatur sed rem.&lt;/p&gt; &lt;/div&gt; ) } this.setState({ complexDOMs: array }) } render () { return ( <div style={{ marginLeft: ‘10px’ }}> <h3>Creat large of DOMs:</h3> <button onClick={this.onCreateComplexDOMs}>Create Complex DOMs</button> <div dangerouslySetInnerHTML={createMarkup(this.state.complexDOMs)} /> </div> ) }}当点击 Button 时,会调用 onCreateComplexDOMs。从 Chrome 的 Performance 标签页看到的数据如下:从上图可以看到,从 Event Click 到 Paint,总共用了大约 964.2ms,渲染时的主要时间消耗情况如下:Recalculate Style:117.07msLayout:538.00msUpdate Layer Tree:31.15ms对于上述测试各进行 5 次,然后取各指标的平均值,统计结果如下:-Recalculate StyleLayoutUpdate Layer TreeTotal渲染简单节点199.66ms523.72ms12.572ms735.952ms渲染复杂节点114.684ms806.05ms31.328ms952.512msTotal = Recalculate Style + Layout + Update Layer Treedemo 的测试代码:test code从上面的测试结果中可以看到,渲染 10000 个节点就需要 700ms+,实际业务中的列表每个节点都需要 20 个左右的节点,布局也会复杂很多,在 Recalculate Style 和 Layout 阶段也会耗费更长的时间。那么,700ms 也仅能渲染 300 ~ 500 个左右的列表项,所以完整的长列表渲染基本上很难达到业务上的要求的。而非完整的长列表渲染一般有两种方式:按需渲染和延迟渲染(即懒渲染)。常见的无限滚动便是延迟渲染的一种实现,而虚拟列表则是按需渲染的一种实现。延迟渲染不在本文讨论范围。接下来,本文会简单介绍虚拟列表的一种实现方案。实现本章节将会创建一个 VirtualizedList 组件,并结合代码,慢慢梳理虚拟列表的实现。为了简化,我们设定 window 为滚动容器元素,给 html 和 body 元素均添加样式规则 height: 100%,设定可视区域为浏览器的窗口大小。VirtualizedList 在 DOM 元素的布局上将参考Twitter 的移动端:class VirtualizedList extends Component { constructor (props) { super(props) this.state = { startOffset: 0, endOffset: 0, visibleData: [] } this.data = new Array(1000).fill(true) this.startIndex = 0 this.endIndex = 0 this.scrollTop = 0 } render () { const {startOffset, endOffset} = this.state return ( <div className=‘wrapper’> <div style={{ paddingTop: ${startOffset}px, paddingBottom: ${endOffset}px }}> { // render list } </div> </div> ) }}在虚拟列表上的实现上,也分为两种情形:列表项是固定高度的和列表项是动态高度的。列表项是固定高度的既然列表项是固定高度的,那约定没个列表项的高度为 60,列表数据的长度为 1000。首先,我们根据可视区域的高度估算可视区域能渲染的元素个数:const height = 60const bufferSize = 5// …this.visibleCount = Math.ceil(window.clientHeight / height)然后,计算 startIndex 和 endIndex,并先初始化初次需要渲染的数据:// …updateVisibleData (scrollTop) { const visibleData = this.data.slice(this.startIndex, this.endIndex) const endOffset = (this.data.length - this.endIndex) * height this.setState({ startOffset: 0, endOffset, visibleData })}componentDidMount () { // 计算可渲染的元素个数 this.visibleCount = Math.ceil(window.innerHeight / height) + bufferSize this.endIndex = this.startIndex + this.visibleCount this.updateVisibleData()}如上文所说,endOffset 是计算 endIndex 对应的数据相对于可滚动区域底部的偏移位置。在本 demo 中,可滚动区域的高度就是 1000 60,因而 endIndex 对应的数据相距底部的偏移就是 (1000 - endIndex) 60。由于是初始化初次需要渲染的数据,因而 startOffset 的初始值是 0。根据上述代码,可以得知,要计算可见区域需要渲染的数据,只要计算出 startIndex 就行,因为 visibleCount 是一个定值,bufferSize 是一个缓冲值,用来增加一定的缓存区域,让正常滑动速度的时候不会显得那么突兀。而 endIndex 的值就等于 startIndex 加上 visibleCount;同时,当用户滚动改变可见区域的数据时,还需要计算 startOffset 的值,以保证新的数据会出现在用户浏览器的视口中:如果不计算 startOffset 的值,那本应该渲染在可视区域内的元素会渲染到可视区域之外。从上图可以看到,startOffset 的值就是元素8的上边框 (可视区域内最上面一个元素) 到元素1的上边框的偏移量。元素8称为 锚点元素,即可视区域内的第一个元素。 因而,我们需要定义一个变量来缓存锚点元素的一些位置信息,同时也要缓存已渲染的元素的位置信息:// …// 缓存已渲染元素的位置信息this.cache = []// 缓存锚点元素的位置信息this.anchorItem = { index: 0, // 锚点元素的索引值 top: 0, // 锚点元素的顶部距离第一个元素的顶部的偏移量(即 startOffset) bottom: 0 // 锚点元素的底部距离第一个元素的顶部的偏移量}// …cachePosition (node, index) { const rect = node.getBoundingClientRect() const top = rect.top + window.pageYOffset this.cache.push({ index, top, bottom: top + height })}// …方法 cachePosition 会在每个列表项组件渲染完后(componentDidMount)进行调用,node 是对应的列表项节点元素,index 是节点的索引值:// Item.jsx// …componentDidMount () { this.props.cachePosition(this.node, this.props.index)}render () { /* eslint-disable-next-line / const {index} = this.props return ( <div className=‘list-item’ ref={node => { this.node = node }}> <p>#${index} eligendi voluptatem quisquam</p> <p>Modi autem fugiat maiores. Doloremque est sed quis qui nobis. Accusamus dolorem aspernatur sed rem.</p> </div> )}// …缓存了锚点元素和已渲染元素的位置信息之后,接下来就可以处理用户的滚动行为了。以用户向下滚动(scrollTop 值增大的方向)为例:// …// 计算 startIndex 和 endIndexupdateBoundaryIndex (scrollTop) { scrollTop = scrollTop || 0 //用户正常滚动下,根据 scrollTop 找到新的锚点元素位置 const anchorItem = this.cache.find(item => item.bottom >= scrollTop) this.anchorItem = { …anchorItem } this.startIndex = this.anchorItem.index this.endIndex = this.startIndex + this.visibleCount}// 滚动事件处理函数handleScroll (e) { if (!this.doc) { // 兼容 iOS Safari/Webview this.doc = window.document.body.scrollTop ? window.document.body : window.document.documentElement } const scrollTop = this.doc.scrollTop if (scrollTop > this.scrollTop) { if (scrollTop > this.anchorItem.bottom) { this.updateBoundaryIndex(scrollTop) this.updateVisibleData() } } else if (scrollTop < this.scrollTop) { // 向上滚动(scrollTop 值减小的方向) } this.scrollTop = scrollTop}// …在滚动事件处理函数中,会去更新 startIndex、endIndex 以及新的锚点元素的位置信息(即更新 startOffset),然后就可以动态的去更新可视区域的渲染数据了:完整的代码在可以戳:固定高度的虚拟列表实现列表项是动态高度的这种情形下,实现的思路和列表项固高大同小异。而小异之处就在于缓存列表项的位置信息时,怎么拿到列表项的精确高度?首先要更改 cachePosition 的部分逻辑:// …cachePosition (node, index) { const rect = node.getBoundingClientRect() const top = rect.top + window.pageYOffset this.cache.push({ index, top, bottom: top + rect.height // 将 height 更为 rect.height })}// …由于列表项的高度不固定,那要怎么计算 visibleCount 呢?我们先考虑每个列表项只是渲染一些纯文本。在实际项目中,有的列表项可能只有一行文本,有的列表项可能有多行文本,此时,我们要基于项目的实际情况,给列表项一个预估的高度:estimatedItemHeight。比如,有一个长列表要渲染用户的文章摘要,并规定摘要显示不超过三行,那么我们取列表的前 10 个列表项的高度平均值作为预估高度。当然,为了预估高度更精确,我们是可以扩大取样样本的。既然有了预估高度,那么将原先代码中的 height 替换成 estimatedItemHeight,就可以计算出 visibleCount 了:// …const estimatedItemHeight = 80// …// 计算可渲染的元素个数this.visibleCount = Math.ceil(window.innerHeight / estimatedItemHeight) + bufferSize// …我们通过 faker.js 来创建一些随机数据,并赋值给 data:// …function fakerData () { const a = [] for (let i = 0; i < 1000; i++) { a.push({ id: i, words: faker.lorem.words(), paragraphs: faker.lorem.sentences() }) } return a}// …this.data = fakerData()// …修改一下列表项的 render 逻辑,其它不变:// Item.jsx// …render () { / eslint-disable-next-line */ const {index, item} = this.props return ( <div className=‘list-item’ style={{ height: ‘auto’ }} ref={node => { this.node = node }}> <p>#${index} {item.words}</p> <p>{item.paragraphs}</p> </div> )}// …此时,列表项的高度已经是动态的了,根据渲染的实际情况,我们给的预估高度是 80:完整的代码在可以戳:动态高度的虚拟列表实现那如果列表项渲染的不是纯文本呢?比如渲染的是图文,那在 Item 组件的 componentDidMount 去调用 cachePosition 方法时,能拿到对应节点的正确高度吗?在渲染图文的情况下,因为图片会发起网络请求,此时并不能保证在列表项组件挂载(执行 componentDidMount)的时候图片渲染好了,那此时对应节点的高度就是不准确的,因而在用户滚动改变可见区域渲染的数据时,就可能出现元素相互重叠的情况:在这种情况下,如果我们能监听 Item 组件节点的大小变化就能获取其正确的高度了。ResizeObserver 或许就可以满足我们的需求,其提供了监听 DOM 元素大小变化的能力,但在撰写本文时,仅 Chrome 67 及以上版本支持,其它主流浏览器均为提供支持。以下是我搜集的一些资料,供你参考(自备梯子):ResizeObserver: It’s Like document.onresize for ElementsResizeObservercaniuse#resizeobserver总结在本文中,首先对虚拟列表进行了简单的定义,然后从长列表的角度分析了为什么需要虚拟列表,最后就列表项固高和不固高两个场景下以一个简单的 demo 详细讲述了虚拟列表的实现思路。在列表项是动态高度的场景下,分析了渲染纯文本和图文混合的场景。前者给出了一个具体的 demo,针对后者对于怎么监听元素大小的变化提供了参考的 ResizeObserver 方案。基于 ResizeObserver 的方案呢,我也实现了一个支持渲染图文混合(当然也支持纯文本)的虚拟列表组件 react-virtual-list,供你参考。当然,这并不是唯一一种实现虚拟列表的方案。在组件 react-virtual-list 的实现过程中,也阅读了不同虚拟列表组件的源码,如: react-tiny-virtual-list、react-window、react-virtualized 等,后续的系列文章我会从源码的角度逐一分析。原文:https://github.com/dwqs/blog/…参考Complexities of an Infinite ScrollerInfinite List and React聊聊前端开发中的长列表再谈前端虚拟列表的实现 ...

October 19, 2018 · 4 min · jiezi