自定义滚动条,手撸虚拟列表(上)
前言
在日常开发中对列表的展示都很熟悉,基本都是进行分页处理,有时会遇到需要一些不能使用分页方式来加载列表数据的情况,这就属于长列表渲染了,我们肯定是不能直接渲染这些数据,不然会导致交互卡顿,白屏
。通常我们可以使用虚拟列表
来优化。
什么是虚拟列表?就是只展示你想看见的部分,而不是展示全部的数据。实现原理就是滚动条每移动一段距离就计算当前滚动距离可视区域渲染的数据是什么
。渲染10000条数据慢,渲染20条数据还能慢吗?虚拟列表又分为两种,一种是每一项元素等高,还有一种不等高。这篇文章先讲等高的情况
,不等高在下一篇。文章后面有完整代码。
正文
先来看看效果,本文是会用transform来实现对自定义滚动条的滚动和内容的滚动
滚动距离计算
由于是使用自定义滚动条实现的,所以我们必须要知道一些值之间的关系,比如滑块高度
、滑块最大可滚动距离
、滑块滚动距离
、内容实际高度
、可视区高度
、内容滚动距离
- 滑块最大可滚动距离 = 可视区高度 - 滑块高度
- 内容滚动距离 = 滑块滚动距离 * (内容实际高度 - 可视区高度) / 滑块最大可滚动距离
逐步分析
关键值计算
首先要知道几个关键值滑块高度
、滑块最大可滚动距离
、列表实际高度
滑块滚动距离计算
自定义滚动条的话就要监听几个事件,鼠标在滑块上按下
、鼠标放开
、鼠标的移动
、页面滚动
、页面大小变化
,对于前几个事件的监听都可以理解,但是为什么要监听页面滚动
、页面大小变化
呢?为了处理页面出现滚动条的情况
- 滑块滚动距离 = 鼠标在可视区y的坐标 - 初始时滑块在页面中y轴坐标 - 鼠标在滑块中按下的y轴坐标;
有图就好理解多了
滚动条滚动计算
我们知道滑块滚动距离
和滑块高度与内容滚动距离的转化公式
,很容易就可以得出内容滚动距离
,但是实际的transform
并不是内容滚动的距离。
- 内容transform距离 = 内容实际滚动距离 - 当前列表开始渲染下标 * 列表每一项高度
看图易懂
再加上边界的判断,完整代码就出来了
鼠标滑轮滚动计算
滚动条滚动计算明白了,那滑轮滚动计算就简简单单了。我们只需要知道当前内容的transform值,剩下的值都可以通过一步步的转化公式计算出来。
得出现在的transform值
- 内容滚动距离 = 当前列表开始渲染下标 * 列表每一项高度 + 内容transform距离;
加上判断向上滚动还是向下滚动和边界的处理,完整代码就出来了
完整代码
/** 等高*/
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import img1 from '../assets/imgs/1.jpg'
import './VirtualScroll.scss'
export default function VirtualScroll() {
const [listItemDefaultHeight, setListItemDefaultHeight] = useState(50)
/** 滑块DOM*/
const scrollBar = useRef<any>(null)
/** 滑块样式*/
const [scrollBarStyle, setScrollBarStyle] = useState<React.CSSProperties>({})
/** 列表样式*/
const [listStyle, setListStyle] = useState<React.CSSProperties>({})
/** 滑块是否可以移动*/
const isMove = useRef(false);
/** 列表在视口的坐标*/
const initPointerObj = useRef({ x: 0, y: 0 })
/** 鼠标点击时在滑块中的坐标*/
const mouseInBar = useRef({ x: 0, y: 0 })
const frameDom = useRef(null)
/** 列表可渲染数量*/
const renderQuantity = useRef(40)
/** 列表DOM*/
const listDom = useRef(null)
/** 列表可见区域高度*/
const listVisualHeight = useRef(0);
/** 数据开始下标*/
const [startInd, setStartInd] = useState(0);
/** 总数据*/
const [allList, setAllList] = useState<any[]>([])
/** 渲染数据*/
const [resultList, setResultList] = useState<any[]>([]);
/** 列表数量*/
const listNum = useMemo(() => {
return allList.length
}, [allList])
/** 列表实际高度*/
const listActualHeight = useMemo(() => {
return listItemDefaultHeight * listNum
}, [listItemDefaultHeight, listNum])
/** 滑块高度*/
const barHeight = useMemo(() => {
let height = listVisualHeight.current / (listActualHeight) * listVisualHeight.current
if (height < 20) {
return height * (Math.floor(20 / height) + 1)
} else {
return height
}
}, [listActualHeight])
/** 滑块最大可滚动距离*/
const maxBarTransformY = useMemo(() => {
if (scrollBar.current === null) {
return listVisualHeight.current
}
return listVisualHeight.current - barHeight;
}, [barHeight])
/** 获取数据,初始化数据*/
useEffect(() => {
let list = []
for (let i = 0; i < 10000; i++) {
list.push({
img: img1,
content: `我是第${i}张图`
})
}
setAllList(list);
}, [])
/** 初始化渲染数据*/
useEffect(() => {
let list = []
for (let i = startInd; i < startInd + renderQuantity.current && i < allList.length; i++) {
list.push(allList[i])
}
setResultList(list)
}, [allList, startInd])
/** 设置鼠标在滑块中点击的y轴坐标*/
const onMousedown = useCallback((e: MouseEvent) => {
e.preventDefault()
document.documentElement.style.cursor = 'grabbing';
mouseInBar.current.y = e.y - (scrollBar.current as HTMLDivElement).getBoundingClientRect().y
isMove.current = true;
}, [])
const onMouseup = useCallback(() => {
isMove.current = false
document.documentElement.style.cursor = 'default';
}, [])
const onMousemove = useCallback((e: MouseEvent) => {
if (isMove.current === false) {
return
}
/** 滚动条移动距离*/
let barTransformY = e.y - initPointerObj.current.y - mouseInBar.current.y;
if (barTransformY > maxBarTransformY) {
barTransformY = maxBarTransformY
}
if (barTransformY <= 0) {
barTransformY = 0
}
/** 实际内容移动距离*/
let listTransformY = barTransformY * (listActualHeight - listVisualHeight.current) / maxBarTransformY;
let showInd = Math.floor(listTransformY / listItemDefaultHeight)
setScrollBarStyle({
transform: `translate(${0}px,${barTransformY}px)`
})
setListStyle({
transform: `translate(${0}px,-${listTransformY - showInd * listItemDefaultHeight}px)`
})
setStartInd(showInd)
}, [maxBarTransformY, startInd, listItemDefaultHeight])
const onWheel = useCallback((e: WheelEvent) => {
e.preventDefault()
let transformValue = window.getComputedStyle(listDom.current as unknown as HTMLDivElement).getPropertyValue('transform');
let translateYValue = 0;
if (transformValue !== 'none') {
const matrixValues = (transformValue as any).match(/matrix.*\((.+)\)/)[1].split(', ');
translateYValue = parseInt(matrixValues[5], 10);
}
let listTransformY = startInd * listItemDefaultHeight + Math.abs(translateYValue);
/** 判断是向上还是向下滚动*/
const deltaY = e.deltaY;
if (deltaY > 0) {
listTransformY = startInd * listItemDefaultHeight + Math.abs(translateYValue) + 70;
if (listTransformY > listActualHeight - listVisualHeight.current) {
listTransformY = listActualHeight - listVisualHeight.current;
}
} else if (deltaY < 0) {
listTransformY = startInd * listItemDefaultHeight + Math.abs(translateYValue) - 70;
if (listTransformY < 0) {
listTransformY = 0
}
}
let barTransformY = listTransformY * maxBarTransformY / (listActualHeight - listVisualHeight.current);
let showInd = Math.floor(listTransformY / listItemDefaultHeight)
setScrollBarStyle({
transform: `translate(${0}px,${barTransformY}px)`
})
setListStyle({
transform: `translate(${0}px,-${listTransformY - showInd * listItemDefaultHeight}px)`
})
setStartInd(showInd)
}, [startInd, listActualHeight, listItemDefaultHeight])
/** 设置初始化滑块在页面中的y轴坐标*/
const setInitPointer = useCallback(() => {
if (frameDom.current === null) {
return
}
let frameDomInfo = (frameDom.current as HTMLDivElement).getBoundingClientRect();
initPointerObj.current.y = frameDomInfo.y;
}, [])
/** 初始化数据*/
useEffect(() => {
if (frameDom.current === null) {
return
}
setInitPointer()
let frameDomInfo = (frameDom.current as HTMLDivElement).getBoundingClientRect();
listVisualHeight.current = frameDomInfo.height;
(scrollBar.current as HTMLDivElement).addEventListener('mousedown', onMousedown);
window.addEventListener('resize', setInitPointer)
window.addEventListener('scroll', setInitPointer)
document.addEventListener('mouseup', onMouseup)
return () => {
(scrollBar.current as HTMLDivElement).removeEventListener('mousedown', onMousedown);
document.removeEventListener('mouseup', onMouseup)
window.removeEventListener('resize', setInitPointer)
window.removeEventListener('scroll', setInitPointer)
}
}, [])
useEffect(() => {
if (frameDom.current === null) {
return
}
document.addEventListener('mousemove', onMousemove);
(frameDom.current as HTMLDivElement).addEventListener('wheel', onWheel);
return () => {
(frameDom.current as unknown as HTMLDivElement).removeEventListener('wheel', onWheel);
document.removeEventListener('mousemove', onMousemove)
}
}, [onWheel, onMousemove])
return (
<>
<div ref={frameDom} className='virtualScroll'>
<div ref={listDom} className='virtualScroll__list'
style={
{
...listStyle,
height: listActualHeight + 'px'
}}
>
{
resultList.map((item, ind) => {
return (
<div className='list__item' key={ind}>
<img src={item.img} alt="" />
<div>{item.content}</div>
</div>
)
})
}
</div>
<div ref={scrollBar} draggable={false} style={
{
...scrollBarStyle,
height: `${barHeight}px`
}
} className='virtualScroll__scrollBar'></div>
</div>
</>
)
}
结语
感兴趣的可以去试试
转载自:https://juejin.cn/post/7256599745425981499