问题引入
存在一个数据量大的列表,如select、list,可能存在1000个数据项
传统做法是直接将1000个item渲染到页面上,即渲染1000个dom
但是
从上图可以看出,可视区域范围内,仅有7个item,也就是说,不可见的993个元素的存在是浪费资源,从而导致渲染时占满内存、造成页面卡顿。
从这里我们可以入手优化这个list。
解决思路
既然可视区域只有至多7个元素可见,那么就仅仅渲染这7个dom,不渲染剩下的993个dom。
我们需要计算出如下的数据:
当前滚动容器的可视区域高度
容器内单个dom的高度
-
可视区域内至多可容纳的元素个数(没显示全的也算)
当前可视的第一个元素下标
当前滚动容器的可视区域高度
我们可以用到offsetHeight
属性,元素的可视高度
在此之前需要先定义一个滚动容器,并使用ref将其管理起来,并且设置容器高度height
,和overflow-y
, 让它滚动起来。 这里为了组件的通用性,我们将height
和width
都通过父组件传入的方式获取。
const listRef = useRef()
const { height, width } = props
return (
<div style={{ height, width, overflowY: "auto"}} ref={listRef} {...props}>
</div>
)
动态地计算高度
listRef?.current?.offsetHeight
容器内单个dom的高度
这里我们需要先将第一个子元素渲染出来才能动态地获取它的高度, 同样,我们将列表通过props传入
const listRef = useRef()
const { height, width, listDom } = props
const domHeight = listRef?.current?.children[1]?.offsetHeight || 0
return (
<div style={{ height, width, overflowY: "auto"}} ref={listRef} {...props}>
{listDom[0]}
</div>
)
可视区域内至多可容纳的元素个数(没显示全的也算)
用可视化区域的高度 除以 单个元素的高度,再向上取整,因为存在一些可见,但是不完全可见的元素,所以这里向上取整。
const domCount = Math.ceil(listRef?.current?.offsetHeight / domHeight)
当前可视的第一个元素下标
滚动容器中有个属性叫scrollTop
,表示当前已滚动的高度,也就是上方不可见的区域。
我们用scrollTop
除以单个元素的高度domHeight
,再向下取整,即可得到当前可见区域第一个元素的下标。
const currentIndex = Math.floor(listRef?.current?.scrollTop / domHeight)
至此我们可以将上边需要动态计算的几个变量存入state
const [ conf, setConf ] = useState({
domCount: 1, // 当前可见的dom数量
currentIndex: 0, // 当前可展示的第一个非空白dom
})
下面开始渲染。
这里需要截取listDom
, 使用slice()
方法。截取起始位置为currentIndex
,结束为止为currentIndex + domCount
<div style={{ height, width, overflowY: "auto"}} ref={listRef} {...props}>
{listDom.slice(conf?.currentIndex, conf?.currentIndex + conf?.domCount)}
</div>
在这里我们会发现,列表上只有7个元素,且无法滚动
我们还没有给元素绑定滚动事件。
这里我们将上述计算逻辑提到一个函数中进行,并将其绑定scroll
useEffect(() => {
if(listRef?.current){
listRef.current.addEventListener('scroll', throttle(()=>{
handleChangeConf()
}, 30))
}
return () => {
if(listRef?.current){
listRef.current.removeEventListener('scroll', ()=>{
handleChangeConf()
})
}
}
}, [])
const handleChangeConf = () => {
const domHeight = listRef?.current?.children[0]?.offsetHeight || 0
const domCount = Math.ceil(listRef?.current?.offsetHeight / domHeight)
const currentIndex = Math.floor(listRef?.current?.scrollTop / domHeight)
setConf({
domCount,
currentIndex
})
}
绑定事件后发现还是无法滚动...
原来我们还需要给列表设置上下空白区域,撑开列表,不然无法模拟出来真是列表的高度。
我们继续计算上下空白的高度。
上下空白的高度
上空白的高度,我们已经计算出了当前可视区域第一个元素的下标了,也计算出了单个元素的站位高度那么
const topHeight = currentIndex * domHeight
下空白的高度
整个列表的高度 - 可视区域的元素高度 - 上空白的高度
const bottomHeight = listDom?.length * domHeight - topHeight - domCount * domHeight
将这两个变量也存入state
const [ conf, setConf ] = useState({
domCount: 1, // 当前可见的dom数量
topHeight: 0, // 顶部空白dom
bottomHeight: 0, // 底部空白dom
currentIndex: 0, // 当前可展示的第一个非空白dom
})
const handleChangeConf = () => {
const domHeight = listRef?.current?.children[0]?.offsetHeight || 0
const domCount = Math.ceil(listRef?.current?.offsetHeight / domHeight)
const currentIndex = Math.floor(listRef?.current?.scrollTop / domHeight)
const topHeight = currentIndex * domHeight
const bottomHeight = listDom?.length * domHeight - topHeight - domCount * domHeight
setConf({
domCount,
topHeight,
bottomHeight,
currentIndex
})
}
然后再将上下两个空白元素添加到dom结构中占位,此时需要将计算domHeight
的取值下标改为[1]
const domHeight = listRef?.current?.children[1]?.offsetHeight || 0
...
<div style={{ height, width, overflowY: "auto"}} ref={listRef} {...props}>
<div style={{height: conf.topHeight}}></div>
{listDom.slice(conf?.currentIndex, conf?.currentIndex + conf?.domCount)}
<div style={{height: conf.bottomHeight}}></div>
</div>
初步完成
这里可以看到dom结构中只有几个可见的dom, 并且滑动时动态变化。
渲染效率也大大提高。
可以明显感觉到变快了
还能做什么优化?
在这里我们渲染的时候事在对listDom
在不断地增删、也就是在不断的增加、删除dom。
那么可不可以不增加删除、或者尽量少地增加删除dom实现我们的功能呢?
react diff算法
element_diff
(相同层级的虚拟dom发生变化)
如滚动前 A B C D
滚动后 B C D E
如果不加key,则无法判断 B C D是否还是原来的B C D
对比时 发现A!=B,则会删除A,添加B,以此类推 删除B C D、插入 C D E
添加key后,发现B C D没有变化 ,仅仅需要删除A 插入E即可