未验证 提交 a96508f0 编写于 作者: M mangov587 提交者: GitHub

feat: 新增虚拟列表virtuallist (#136)

* feat: 新增虚拟列表virtuallist
上级 afd3e182
......@@ -678,6 +678,16 @@
"show": false,
"exportEmpty": true,
"author": "hanyuxinting"
},
{
"version": "1.0.0",
"name": "VirtualList",
"type": "component",
"cName": "虚拟列表",
"desc": "长列表渲染",
"sort": 20,
"show": true,
"author": "hx"
}
]
},
......@@ -728,4 +738,4 @@
]
}
]
}
}
\ No newline at end of file
......@@ -91,7 +91,7 @@ export default class Trigger extends React.Component<TriggerProps, TriggerState>
...newChildProps,
}
cloneProps.ref = composeRef(this.props.forwardedRef, (child as any).ref)
const trigger = React.cloneElement(child, cloneProps)
const trigger = React.cloneElement(child, cloneProps) as JSX.Element
return trigger
}
}
import * as React from 'react'
// import * as renderer from 'react-test-renderer'
import { render, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { VirtualList } from '../virtuallist'
const props = {
sourceData: new Array(100).fill(0),
}
describe('props', () => {
const { getByTestId } = render(
<>
<VirtualList {...props} itemSize={40} data-testid="verticalList" />
<VirtualList horizontal {...props} itemSize={100} data-testid="horizontalList" />
</>
)
const horizontalListBox = getByTestId('horizontalList').getElementsByTagName('div')[0]
const verticalListBox = getByTestId('verticalList').getElementsByTagName('div')[0]
test('direction props', () => {
expect(horizontalListBox).toHaveClass('nut-horizontal-box')
expect(verticalListBox).toHaveClass('nut-vertical-box')
})
test('itemSize props', () => {
expect(horizontalListBox.style.width).toBe('10000px')
expect(verticalListBox.style.height).toBe('4000px')
})
})
test('renders only visible items', async () => {
const handleScroll = jest.fn(() => {})
const boxHeight = 500
const itemSize = 66
const overscan = 2
const visibleCount = Math.ceil(boxHeight / itemSize) + overscan
const { container } = render(
<VirtualList
{...props}
containerSize={boxHeight}
itemSize={itemSize}
data-testid="scrollList"
handleScroll={handleScroll}
/>
)
await waitFor(() => {
const listElement = container.querySelectorAll('.nut-virtuallist-item')
expect(listElement.length).toBe(visibleCount)
})
})
import React, { useCallback, useEffect, useLayoutEffect, useState } from 'react'
import Radio from '@/packages/radio'
const { RadioGroup } = Radio
import Cell from '@/packages/cell'
import CellGroup from '@/packages/cellgroup'
import VirtualList from './index'
// import './demo.scss'
const ListDemo = () => {
const [sourceData, setsourceData] = useState([])
const [pageNo, setPageNo] = useState(1)
const [radioVal, setRadioVal] = useState('1')
const [isLoading, setIsLoading] = useState(false)
const handleChange = (v) => {
setRadioVal(v)
setPageNo(1)
}
const getData = useCallback(() => {
const datas = []
const pageSize = 10
for (let i = (pageNo - 1) * pageSize; i < pageNo * pageSize; i++) {
const num = i > 9 ? i : `0${i}`
datas.push(` list${num}`)
}
if (pageNo === 1) {
setsourceData(() => {
return datas
})
} else {
setsourceData((sourceData) => {
return [...sourceData, ...datas]
})
}
}, [pageNo])
const ItemRender = ({ data }) => {
return <p>我是-{data}</p>
}
const ItemRenderMemo = React.memo(ItemRender)
const ItemVariable = ({ data, index }) => {
return (
<p className={index % 2 === 0 ? '' : 'nut-virtualList-demo-item'}>可变大小隔行展示-{data}</p>
)
}
const ItemVariableDemo = React.memo(ItemVariable)
const handleScroll = () => {
if (pageNo > 50 || isLoading) return
setIsLoading(true)
setTimeout(() => {
setPageNo(pageNo + 1)
setIsLoading(false)
}, 30)
}
useEffect(() => {
getData()
}, [getData])
const showNode = () => {
switch (radioVal) {
case '1':
return (
<VirtualList
itemSize={66}
className="heigh1"
sourceData={sourceData}
ItemRender={ItemRenderMemo}
handleScroll={handleScroll}
/>
)
case '2':
return (
<VirtualList
sourceData={sourceData}
ItemRender={ItemVariableDemo}
itemSize={128}
containerSize={500}
itemEqualSize={false}
handleScroll={handleScroll}
/>
)
case '3':
return (
<VirtualList
sourceData={sourceData}
ItemRender={ItemRenderMemo}
itemSize={124}
containerSize={341}
handleScroll={handleScroll}
horizontal
/>
)
case '4':
return (
<VirtualList
sourceData={sourceData}
itemSize={300}
ItemRender={ItemVariableDemo}
horizontal
itemEqualSize={false}
handleScroll={handleScroll}
/>
)
default:
return (
<VirtualList
itemSize={66}
className="heigh1"
sourceData={sourceData}
ItemRender={ItemRenderMemo}
handleScroll={handleScroll}
/>
)
}
}
return (
<>
<div className="demo">
<CellGroup>
<Cell>
<RadioGroup value={radioVal} onChange={handleChange} direction="horizontal">
<Radio value="1">垂直等高</Radio>
<Radio value="2">垂直不等高</Radio>
<Radio value="3">水平等宽</Radio>
<Radio value="4">水平不等宽</Radio>
</RadioGroup>
</Cell>
</CellGroup>
<div key={radioVal} className="nut-virtualList-demo-box hideScrollbar">
{showNode()}
</div>
</div>
</>
)
}
export default ListDemo
# VirtualList组件
### 介绍
虚拟列表
### 安装
```javascript
import { Virtuallist } from '@nutui/nutui-react';
```
## 代码演示
### 1、基础用法-垂直等高
:::demo
``` tsx
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { VirtualList } from '@nutui/nutui-react';
const App =() => {
const [sourceData, setsourceData] = useState([])
const getData = useCallback(() => {
const datas = []
const pageSize = 90
for (let i = 10; i < pageSize; i++) {
datas.push(`${i} Item`)
}
setsourceData((sourceData) => {
return [...sourceData, ...datas]
})
}, [])
useEffect(() => {
getData()
}, [getData])
const ItemRender = ({ data,index }) => {
return <p>自定义-{data}-{index}</p>
}
const ItemRenderMemo = React.memo(ItemRender)
return (
<div className='nut-virtualList-demo-box hideScrollbar heigh1'>
<VirtualList
itemSize={66}
sourceData={sourceData}
ItemRender={ItemRenderMemo}
/>
</div>
)
}
export default App;
```
:::
### 2、垂直不等高&无限下滑
:::demo
``` tsx
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { VirtualList } from '@nutui/nutui-react';
const App =() => {
const [sourceData, setsourceData] = useState([])
const getData = useCallback(() => {
const datas = []
const pageSize = 90
for (let i = 10; i < pageSize; i++) {
datas.push(`${i} Item`)
}
setsourceData((sourceData) => {
return [...sourceData, ...datas]
})
}, [])
const handleScroll = () => {
if (pageNo > 100) return
setPageNo(pageNo + 1)
}
useEffect(() => {
getData()
}, [getData])
const ItemVariable = ({ data, index }) => {
return (
<p className={index % 2 === 0 ? '' : 'nut-virtualList-demo-item'}>可变大小隔行展示-{data}</p>
)
}
/** itemSize为首屏最大元素大小*/
const ItemVariableDemo = React.memo(ItemVariable)
return (
<div className='nut-virtualList-demo-box hideScrollbar heigh1'>
<VirtualList
sourceData={sourceData}
ItemRender={ItemVariableDemo}
itemSize={128}
itemEqualSize={false}
handleScroll={handleScroll}
/>
</div>
)
}
export default App;
```
:::
### 3、水平等宽
:::demo
``` tsx
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { VirtualList } from '@nutui/nutui-react';
const App =() => {
const [sourceData, setsourceData] = useState([])
const getData = useCallback(() => {
const datas = []
const pageSize = 90
for (let i = 10; i < pageSize; i++) {
datas.push(`${i} Item`)
}
setsourceData((sourceData) => {
return [...sourceData, ...datas]
})
}, [])
useEffect(() => {
getData()
}, [getData])
const ItemRender = ({ data,index }) => {
return <p>自定义-{data}-{index}</p>
}
const ItemRenderMemo = React.memo(ItemRender)
return (
<div className='nut-virtualList-demo-box hideScrollbar'>
<VirtualList
sourceData={sourceData}
ItemRender={ItemRenderMemo}
itemSize={124}
horizontal
/>
</div>
)
}
export default App;
```
:::
### 4、水平不等宽&无限滑动
:::demo
``` tsx
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { VirtualList } from '@nutui/nutui-react';
const App =() => {
const [sourceData, setsourceData] = useState([])
const getData = useCallback(() => {
const datas = []
const pageSize = 90
for (let i = 10; i < pageSize; i++) {
datas.push(`${i} Item`)
}
setsourceData((sourceData) => {
return [...sourceData, ...datas]
})
}, [])
const handleScroll = () => {
if (pageNo > 100) return
setPageNo(pageNo + 1)
}
useEffect(() => {
getData()
}, [getData])
const ItemVariable = ({ data, index }) => {
return (
<p className={index % 2 === 0 ? '' : 'nut-virtualList-demo-item'}>可变大小隔行展示-{data}</p>
)
}
/** itemSize为首屏最大元素大小 */
const ItemVariableDemo = React.memo(ItemVariable)
return (
<div className='nut-virtualList-demo-box hideScrollbar'>
<VirtualList
sourceData={sourceData}
itemSize={300}
ItemRender={ItemVariableDemo}
horizontal
itemEqualSize={false}
handleScroll={handleScroll}
/>
</div>
)
}
export default App;
```
:::
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
|--------------|----------------------------------|--------|------------------|
| sourceData | 获取数据 | Array | - |
|containerSize | 容器高度 | Number|获取元素的offsetWidth或offsetHeight,需要css给出|
| ItemRender | virtual 列表父节点渲染的函数 | React.FC<any> | -|
| itemSize | item高度,如果不定高,则为首屏单个最大size | String | - |
| itemEqualSize | item大小是否一致 | Boolean | true |
| overscan |除了视窗里面默认的元素, 还需要额外渲染的item个数 | Number | 2 |
| key |唯一值 ,Item(sourceData)具体的某个唯一值的字段 | string | index |
| horizontal |决定列表是横向的还是纵向的 | Boolean | false |
## Events
| 方法名 | 说明 | 参数 | 返回值 |
| -------------- | ----------------------------- | --------------- | ---------- |
| handleScroll | 活动到底(右)的事件,可以实现无限滚动 | - | - |
import { VirtualList } from './virtuallist'
export default VirtualList
export type Data = any
export interface VirtualListState {
startOffset: number // 可视区域距离顶部的偏移量
startIndex: number // 可视区域开始索引
overStart: number
endIndex: number // 可视区域结束索引
}
export interface PositionType {
index: number // 缓存索引
top?: number // 每一页距离顶部的位置
height?: number // 页面高度
bottom: number // 每一页底部距离顶部的高度
width?: number
left?: number
right: number
}
export interface VirtualListProps {
sourceData: Array<Data> // 获取数据
containerSize?: number // 容器大小
ItemRender?: React.FC<any> // virtual 列表父节点渲染的函数,默认为 (items, ref) => <ul ref={ref}>{items}</ul>
itemSize?: number // 预估高度
itemEqualSize?: boolean // item 固定大小,默认是true
horizontal?: boolean // true为水平的,false为垂直的, 默认为false
overscan?: number // 除了视窗里面默认的元素, 还需要额外渲染的, 避免滚动过快, 渲染不及时,默认是2
handleScroll?: (...args: any[]) => any // 滑动到底部执行的函数
key?: string // 遍历时生成item 的唯一key,默认是index,建议传入resources的数据具体的某个唯一值的字段
locale?: { [key in string]: string }
className?: string
}
import { PositionType, Data } from './type'
// 缓存列表初始化信息
const initPositinoCache = (reaItemSize: number, length = 0): PositionType[] => {
let index = 0
const positions: PositionType[] = Array(length)
while (index < length) {
positions[index] = {
index,
height: reaItemSize,
width: reaItemSize,
top: index * reaItemSize,
bottom: (index + 1) * reaItemSize,
left: index * reaItemSize,
right: (index + 1) * reaItemSize,
}
index++
}
return positions
}
// 获取列表总高度
const getListTotalSize = (positions: Array<PositionType>, horizontal: true | false): number => {
const index = positions.length - 1
let size = 0
if (index < 0) {
size = 0
} else {
size = horizontal ? positions[index].right : positions[index].bottom
}
return size
}
// 通过二分法找到 scrollOffset 对应的值
const binarySearch = (
positionsList: Array<PositionType>,
value = 0,
horizontal: true | false
): number => {
let start = 0
let end: number = positionsList.length - 1
let tempIndex = null
const key = horizontal ? 'right' : 'bottom'
while (start <= end) {
const midIndex = Math.floor((start + end) / 2)
const midValue = positionsList[midIndex][key]
// 相等则直接返回(因为是bottom, 因此startIndex应该是下一个节点)
if (midValue === value) {
return midIndex + 1
}
// 中间值 < 传入值,则说明 value对应的节点 大于 start, start往后移动一位
if (midValue < value) {
start = midIndex + 1
}
// 中间值 > 传入值,则说明 value 在 中间值之前,end 节点移动到 mid - 1
else if (midValue > value) {
// tempIndex存放最靠近值为value的所有
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex
}
end = midIndex - 1
}
}
tempIndex = tempIndex || 0
return tempIndex
}
const getEndIndex = ({
sourceData,
startIndex,
visibleCount,
itemEqualSize = true,
positions,
offSetSize,
overscan,
sizeKey = 'width',
}: {
sourceData: Array<Data>
startIndex: number
visibleCount: number
itemEqualSize?: boolean
positions: PositionType[]
offSetSize: number
overscan: number
sizeKey?: 'width' | 'height'
}): number => {
const dataLength = sourceData.length
let tempIndex = null
if (itemEqualSize) {
const endIndex = startIndex + visibleCount
tempIndex = dataLength > 0 ? Math.min(dataLength, endIndex) : endIndex
} else {
let sizeNum = 0
for (let i = startIndex; i < dataLength; i++) {
sizeNum += positions[i][sizeKey] || 0
if (sizeNum > offSetSize) {
const endIndex = i + overscan
tempIndex = dataLength > 0 ? Math.min(dataLength, endIndex) : endIndex
break
}
}
if (sizeNum < offSetSize) {
tempIndex = dataLength
}
}
tempIndex = tempIndex || 0
return tempIndex
}
// 更新Item大小
const updateItemSize = (
positions: PositionType[],
items: HTMLCollection,
sizeKey: 'width' | 'height'
): void => {
const newPos = positions.concat()
Array.from(items).forEach((item) => {
const index = Number(item.getAttribute('data-index'))
const styleVal = item.getAttribute('style')
if (styleVal && styleVal.includes('none')) return
const nowSize = item.getBoundingClientRect()[sizeKey]
const oldSize = positions[index][sizeKey] as number
// 存在差值, 更新该节点以后所有的节点
const dValue = oldSize - nowSize
if (dValue) {
if (sizeKey === 'width') {
newPos[index].right -= dValue
newPos[index][sizeKey] = nowSize
for (let k = index + 1; k < positions.length; k++) {
newPos[k].left = positions[k - 1].right
newPos[k].right -= dValue
}
} else if (sizeKey === 'height') {
newPos[index].bottom -= dValue
newPos[index][sizeKey] = nowSize
for (let k = index + 1; k < positions.length; k++) {
newPos[k].top = positions[k - 1].bottom
newPos[k].bottom -= dValue
}
}
}
})
}
export { initPositinoCache, getListTotalSize, binarySearch, getEndIndex, updateItemSize }
.nut-horizontal-items {
float: left;
li {
display: block;
float: left;
background: #fff;
padding: 10px;
margin-right: 20px;
}
&:after {
content: '';
display: block;
visibility: hidden;
clear: both;
}
}
.nut-vertical-items {
li {
display: block;
background: rgba(255, 255, 255, 1);
border-radius: 7px;
box-shadow: 0 1px 6px 0 rgba(237, 238, 241, 1);
margin-top: 20px;
padding: 14px 15px;
font-size: 13px;
line-height: 18px;
font-family: PingFangSC;
font-weight: 500;
color: rgba(102, 102, 102, 1);
}
}
.nut-virtualList-demo-item {
height: 100px;
}
.nut-virtualList-box {
overflow: auto;
}
.nut-virtualList-demo-box {
.heigh1 {
height: 500px;
}
&.hideScrollbar {
.nut-virtualList-box {
&::-webkit-scrollbar {
width: 0px;
height: 0px;
}
}
}
}
import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { useConfig } from '@/packages/configprovider'
import { VirtualListProps, VirtualListState, PositionType } from './type'
import {
initPositinoCache,
getListTotalSize,
binarySearch,
getEndIndex,
updateItemSize,
} from './utils'
const defaultProps = {} as VirtualListProps
export const VirtualList: FunctionComponent<
VirtualListProps & React.HTMLAttributes<HTMLDivElement>
> = (props: VirtualListProps) => {
const {
sourceData = [],
ItemRender,
itemEqualSize = true,
itemSize = 200,
horizontal = false,
overscan = 2,
key,
handleScroll,
className,
containerSize,
...rest
} = props
const sizeKey = horizontal ? 'width' : 'height'
const scrollKey = horizontal ? 'scrollLeft' : 'scrollTop'
const offsetKey = horizontal ? 'left' : 'top'
const { locale } = useConfig()
// 虚拟列表容器ref
const scrollRef = useRef<HTMLDivElement>(null)
// 虚拟列表显示区域ref
const itemsRef = useRef<HTMLUListElement>(null)
const firstItemRef = useRef<HTMLLIElement>(null)
// 列表位置信息
const [positions, setPositions] = useState<PositionType[]>([
{
index: 0,
left: 0,
top: 0,
bottom: 0,
width: 0,
height: 0,
right: 0,
},
])
// 列表总大小
const [listTotalSize, setListTotalSize] = useState<number>(99999999)
// 可视区域条数
const [visibleCount, setVisibleCount] = useState<number>(0)
const [offSetSize, setOffSetSize] = useState<number>(containerSize || 0)
const [options, setOptions] = useState<VirtualListState>({
startOffset: 0, // 可视区域距离顶部的偏移量
startIndex: 0, // 可视区域开始索引
overStart: 0,
endIndex: 10, // 可视区域结束索引
})
// 列表位置信息
useEffect(() => {
const pos = initPositinoCache(itemSize, sourceData.length)
setPositions(pos)
const totalSize = getListTotalSize(pos, horizontal)
setListTotalSize(totalSize)
}, [sourceData, itemSize, horizontal])
const getElement = useCallback(() => {
return scrollRef.current?.parentElement || document.body
}, [])
useEffect(() => {
if (containerSize) return
const size = horizontal ? getElement().offsetWidth : getElement().offsetHeight
setOffSetSize(size)
}, [getElement, horizontal, containerSize])
useEffect(() => {
// 初始-计算visibleCount
if (offSetSize === 0) return
const count = Math.ceil(offSetSize / itemSize) + overscan
setVisibleCount(count)
setOptions((options) => {
return { ...options, endIndex: count }
})
}, [getElement, horizontal, itemSize, overscan, offSetSize])
const updateTotalSize = useCallback(() => {
if (!itemsRef.current) return
const items: HTMLCollection = itemsRef.current.children
if (!items.length) return
// 更新缓存
updateItemSize(positions, items, sizeKey)
const totalSize = getListTotalSize(positions, horizontal)
setListTotalSize(totalSize)
}, [positions, sizeKey, horizontal])
const onScroll = useCallback(() => {
requestAnimationFrame((e) => {
const scrollSize = getElement()[scrollKey]
const startIndex = binarySearch(positions, scrollSize, horizontal)
const overStart = startIndex - overscan > -1 ? startIndex - overscan : 0
// const offSetSize = horizontal ? getElement().offsetWidth : getElement().offsetHeight
if (!itemEqualSize) {
updateTotalSize()
}
let endIndex = getEndIndex({
sourceData,
startIndex,
visibleCount,
itemEqualSize,
positions,
offSetSize,
sizeKey,
overscan,
})
const startOffset = positions[startIndex][offsetKey] as number
setOptions({ startOffset, startIndex, overStart, endIndex })
// 无限下滑
if (endIndex > sourceData.length - 1) {
handleScroll && handleScroll()
}
})
}, [
positions,
getElement,
sourceData,
visibleCount,
itemEqualSize,
updateTotalSize,
offsetKey,
sizeKey,
scrollKey,
horizontal,
overscan,
handleScroll,
offSetSize,
])
useEffect(() => {
const element = getElement()
element.addEventListener('scroll', onScroll, false)
return () => {
element.removeEventListener('scroll', onScroll, false)
}
}, [getElement, onScroll])
return (
<div
className={className ? `${className} nut-virtualList-box` : 'nut-virtualList-box'}
{...rest}
style={{
[sizeKey]: containerSize ? `${offSetSize}px` : '',
}}
>
<div
ref={scrollRef}
className={horizontal ? 'nut-horizontal-box' : 'nut-vertical-box'}
style={{
position: 'relative',
[sizeKey]: `${listTotalSize}px`,
}}
>
<ul
className={
horizontal
? 'nut-virtuallist-items nut-horizontal-items'
: 'nut-virtuallist-items nut-vertical-items'
}
ref={itemsRef}
style={{
transform: horizontal
? `translate3d(${options.startOffset}px,0,0)`
: `translate3d(0,${options.startOffset}px,0)`,
}}
>
{sourceData.slice(options.overStart, options.endIndex).map((data, index) => {
const { startIndex, overStart } = options
const dataIndex = overStart + index
const styleVal = dataIndex < startIndex ? 'none' : 'block'
const keyVal = key && data[key] ? data[key] : dataIndex
return (
<li
ref={dataIndex === 0 ? firstItemRef : null}
data-index={`${dataIndex}`}
className={
dataIndex % 2 === 0 ? 'nut-virtuallist-item even' : 'nut-virtuallist-item odd'
}
key={`${keyVal}`}
style={{ display: styleVal }}
>
{ItemRender ? <ItemRender data={data} index={`${dataIndex}`} /> : data}
</li>
)
})}
</ul>
</div>
</div>
)
}
VirtualList.defaultProps = defaultProps
VirtualList.displayName = 'NutVirtualList'
......@@ -3,10 +3,7 @@
"baseUrl": ".",
"rootDir": ".",
"target": "ESNext",
"types": [
"vite/client",
"jest"
],
"types": ["vite/client", "jest"],
"allowJs": false,
"declaration": true,
"declarationDir": "./dist/esm/types",
......@@ -27,25 +24,12 @@
// "noUnusedParameters": true,
// "noUnusedLocals": true,
"jsx": "react",
"lib": [
"esnext",
"dom"
],
"lib": ["esnext", "dom"],
// "outDir": "./tsc/test",
"paths": {
"@/*": [
"src/*"
]
"@/*": ["src/*"]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx"
],
"exclude": [
"./node_modules/*",
"src/sites/*",
"src/**/demo.tsx"
]
}
\ No newline at end of file
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx"],
"exclude": ["./node_modules/*", "src/sites/*", "src/**/demo.tsx"]
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册