From b3ec4654c3bfb3ee42d3efc48fcab9e8487f6d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BC=80=E6=9C=97?= Date: Tue, 27 Aug 2019 09:50:49 +0800 Subject: [PATCH] feat: VirtualList (#59) * feat: VirtualList * add docs * add test * update types * add scroll test * fix docs examples problem * defaultProps define types && remove UNSAFE_componentWillReceiveProps --- .storybook/style.scss | 9 + docs/content/components/virtual-list.mdx | 222 +++++++++++ docs/src/components/Playground/index.js | 3 +- docs/src/components/Playground/style.scss | 16 + docs/src/utils/getMockDatas.js | 27 ++ .../__snapshots__/index.test.tsx.snap | 223 +++++++++++ src/components/VirtualList/index.test.tsx | 112 ++++++ src/components/VirtualList/index.tsx | 366 ++++++++++++++++++ src/components/VirtualList/style.scss | 10 + src/components/index.tsx | 2 + src/interface.tsx | 43 ++ stories/VirtualList.stories.tsx | 21 + stories/demos/AsyncVirtualList.tsx | 63 +++ stories/utils/getMockDatas.ts | 28 ++ 14 files changed, 1144 insertions(+), 1 deletion(-) create mode 100644 docs/content/components/virtual-list.mdx create mode 100644 docs/src/utils/getMockDatas.js create mode 100644 src/components/VirtualList/__snapshots__/index.test.tsx.snap create mode 100644 src/components/VirtualList/index.test.tsx create mode 100644 src/components/VirtualList/index.tsx create mode 100644 src/components/VirtualList/style.scss create mode 100644 stories/VirtualList.stories.tsx create mode 100644 stories/demos/AsyncVirtualList.tsx create mode 100644 stories/utils/getMockDatas.ts diff --git a/.storybook/style.scss b/.storybook/style.scss index d4344e4e..7c9fe885 100644 --- a/.storybook/style.scss +++ b/.storybook/style.scss @@ -1,3 +1,12 @@ .sb-show-main { padding: 25px; +} +.box { + border: 1px solid #ccc; + overflow: scroll; +} +.list-item { + line-height: 34px; + border-bottom: 1px dotted #ccc; + padding-left: 10px; } \ No newline at end of file diff --git a/docs/content/components/virtual-list.mdx b/docs/content/components/virtual-list.mdx new file mode 100644 index 00000000..6131b3d4 --- /dev/null +++ b/docs/content/components/virtual-list.mdx @@ -0,0 +1,222 @@ +--- +title: VirtualList 滚动加载 +date: 2019-08-20 +author: wangkailang +--- + +当一个资源数据量很大时,一次加载数据会导致浏览器页面卡顿甚至崩溃,VirtualList 支持根据鼠标滚动加载数据,同时销毁超出数据的 `DOM` 结构,保证页面的流畅性。 + +## 基本用法 +- `rowRenderer` 对列表中每行元素进行渲染,可做到动态布局(每列不等高时重新计算并重新渲染); +- `data` 指要渲染资源数据,可结合 `onQueryChange` 做异步加载。 +```js isShow +const rowRenderer = ({ + index, + item, // item: 为数组中对应项 + prevItem, // 前一项 + nextItem, // 后一项 + style, // row对应的style,需要应用在row对应的dom上,可以与自定义style合并 +}) =>

{item}

; + + +``` +## 代码演示 + +### 空数组 +```jsx +
+
{i.index}
} + data={[]} + height={35} + isFetching={false} + isReloading={false} + totalCount={0} + /> +
+``` +### 加载新数据 +真实场景中鼠标滚动,页面下滑过程中会加载新的数据。 +```jsx +
+
item-1
} + data={[1]} + height={70} + isFetching={true} + isReloading={false} + totalCount={1} + /> +
+``` +### 重新加载 +```jsx +
+
item-1
} + data={[]} + height={35} + isFetching={true} + isReloading={true} + totalCount={0} + /> +
+``` + +### 加载完成 +```jsx +
+
item-1
} + data={[1]} + height={70} + isFetching={false} + isReloading={false} + totalCount={1} + /> +
+``` +### 异步等高 +```jsx +() => { + const resName = "list"; + const rowRenderer = i =>
{resName}-{i.index + 1}
; + const listRef = React.useRef(null); + const [fetching, setFetch] = React.useState(false); + const [datas, setDatas] = React.useState([]); + const [totalCount, setTotalCount] = React.useState(0); + const [count, setCount] = React.useState(0); + async function handleQueryChange(query) { + setFetch(true); + const actionResult = await getMockDatas(query, 180, resName); + setFetch(false); + const totalCount = actionResult.response.paging.totalCount; + const lists = actionResult.response.lists; + setTotalCount(totalCount); + setDatas(datas.concat(lists)); + } + React.useEffect(() => { + handleQueryChange({ + limit: 30, + offset: 0, + }) + }, []); + React.useEffect(() => { + const existLists = listRef.current && listRef.current.querySelectorAll('.VirtualList > *'); + setCount(existLists ? existLists.length : 0); + }, [datas]); + return ( +
+

总共 {totalCount} 条数据, 获取了 {datas.length} 条。

+

渲染到 DOM 中的条数是: {count}

+
+ +
+
+ ) +} +``` + +### 异步不等高 +`row` 不等高时需要计算调整,设置 `isEstimate` 为 `true`。 +```jsx +() => { + const resName = "list"; + const rowRenderer = i => { + const randomHeight = `${35 + (i.index%5 * 4)}px`; + return ( +
+
{resName}-{i.index + 1}
+ height: {randomHeight}, transform: {i.style.transform} +
+ ) + }; + const listRef = React.useRef(null); + const [fetching, setFetch] = React.useState(false); + const [datas, setDatas] = React.useState([]); + const [totalCount, setTotalCount] = React.useState(0); + const [count, setCount] = React.useState(0); + async function handleQueryChange(query) { + setFetch(true); + const actionResult = await getMockDatas(query, 180, resName); + setFetch(false); + const totalCount = actionResult.response.paging.totalCount; + const lists = actionResult.response.lists; + setTotalCount(totalCount); + setDatas(datas.concat(lists)); + } + React.useEffect(() => { + handleQueryChange({ + limit: 30, + offset: 0, + }) + }, []); + React.useEffect(() => { + const existLists = listRef.current && listRef.current.querySelectorAll('.VirtualList > *'); + setCount(existLists ? existLists.length : 0); + }, [datas]); + return ( +
+

总共 {totalCount} 条数据, 获取了 {datas.length} 条。

+

渲染到 DOM 中的条数是: {count}

+
+ +
+
+ ) +} +``` + +## 注意事项 +`rowHeight`函数动态改变返回高度**并不会 rerender**,需要主动触发 `recomputeRowHeight` 方法。 + +## 估计模式与 Debug +对于高度无法完全确定的 row(如内容高度不固定等),依然需要给出一个较准确的 rowHeight。同时设置 `isEstimate={true}`,VirtualList会在render结束后对可视dom进行计算,并与对应的 rowHeight 比较。如果实际高度与 rowHeight 指定高度不同,则会用实际高度 rerender,直至完全一致。 + +由于 VirtualList 已将 dom 数量控制在可控范围之内,因此遍历计算再重新渲染的开销在可承受范围内,但对于可以确定高度的列表仍然要避免使用估计模式。 + +同时估计模式提供 debug 参数方便开发时确定 dom 的真实高度。同时设置 `isEstimate={true}` 和 `debug={true}`,VirtualList 会打印真实高度与 rowHeight 不同的 row 对应的信息。 +```js isShow +warning: Index 1 estimate height is 16, real height is 18. +``` +## API +```jsx previewOnly + +``` \ No newline at end of file diff --git a/docs/src/components/Playground/index.js b/docs/src/components/Playground/index.js index 3e3efe13..64a235ae 100644 --- a/docs/src/components/Playground/index.js +++ b/docs/src/components/Playground/index.js @@ -9,6 +9,7 @@ import { LiveProvider, LiveEditor, LiveError, LivePreview } from 'react-live'; import { Button } from 'react-bootstrap'; import PropTable from '../PropTable'; import moment from 'moment'; +import getMockDatas from '../../utils/getMockDatas'; import './style.scss'; @@ -18,7 +19,7 @@ export default ({ isShow, children, previewOnly, noInline }) => { setShow(!show); } return ( - + { previewOnly ? : (
{isShow ? ( diff --git a/docs/src/components/Playground/style.scss b/docs/src/components/Playground/style.scss index 722aaf68..08948fc6 100644 --- a/docs/src/components/Playground/style.scss +++ b/docs/src/components/Playground/style.scss @@ -51,6 +51,22 @@ display: flex; justify-content: space-between; position: relative; + .box { + border: 1px solid #ccc; + overflow: scroll; + } + .list-item { + line-height: 34px; + border-bottom: 1px dotted #ccc; + padding: 0 15px; + display: flex; + align-items: center; + span { + text-align: right; + flex: 1; + color: #bdbdbd; + } + } .sub-menu-basic { li { width: 160px; diff --git a/docs/src/utils/getMockDatas.js b/docs/src/utils/getMockDatas.js new file mode 100644 index 00000000..918fa241 --- /dev/null +++ b/docs/src/utils/getMockDatas.js @@ -0,0 +1,27 @@ +function createDatas(query, totalCount, resName) { + const { limit, offset } = query; + let rlt = []; + if (offset <= totalCount) { + const len = Math.min(limit, totalCount - offset); + for (let i = 0; len - i > 0; i++) { + rlt.push({ id: offset + i, name: `${resName}-${offset + i}` }); + } + } + return { + response: { + [`${resName}s`]: rlt, + paging: { + totalCount, + }, + }, + }; +} + +export default function getMockDatas(query, totalCount, resName) { + return new Promise(resolve => { + setTimeout(() => { + const datas = createDatas(query, totalCount, resName); + resolve(datas); + }, 100); + }); +} diff --git a/src/components/VirtualList/__snapshots__/index.test.tsx.snap b/src/components/VirtualList/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000..370be18a --- /dev/null +++ b/src/components/VirtualList/__snapshots__/index.test.tsx.snap @@ -0,0 +1,223 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`VirtualList only show loader when reloading 1`] = ` +
+
+

+ 数据加载中 +

+
+
+`; + +exports[`VirtualList scrolling dynamic row height 1`] = ` +
+

+ 总共 + 0 + , 获取了 + 0 + 条数据。 +

+

+ VirtualList 会在增加 list 条数的同时,销毁超出的 UI,目前渲染出来的 list 条数是: + 0 +

+
+
+
+

+ 数据加载中 +

+
+
+
+
+`; + +exports[`VirtualList scrolling equal row height 1`] = ` +
+

+ 总共 + 0 + , 获取了 + 0 + 条数据。 +

+

+ VirtualList 会在增加 list 条数的同时,销毁超出的 UI,目前渲染出来的 list 条数是: + 0 +

+
+
+
+

+ 数据加载中 +

+
+
+
+
+`; + +exports[`VirtualList show list and loader when fetching more 1`] = ` +
+
+
+
+ item +
+
+

+ 数据加载中 +

+
+
+`; + +exports[`VirtualList show no data placeholder when data array is empty 1`] = ` +
+
+

+ 暂无数据 +

+
+
+`; + +exports[`VirtualList show no more data hint when all data fetched 1`] = ` +
+
+
+
+ item +
+
+

+ 没有更多信息 +

+
+
+`; diff --git a/src/components/VirtualList/index.test.tsx b/src/components/VirtualList/index.test.tsx new file mode 100644 index 00000000..b1ca9700 --- /dev/null +++ b/src/components/VirtualList/index.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import renderer from 'react-test-renderer'; +import VirtualList from './index'; +import { VirtualRowArgs } from '../../interface'; +import AsyncVirtualList from '../../../stories/demos/AsyncVirtualList'; + +function createNodeMock(element: React.ReactElement) { + if (element.type === 'div') { + return { + scrollTop: 0, + offsetHeight: 100, + }; + } + return null; +} +const snapOptions = { createNodeMock }; + +const rowRenderer = (i: VirtualRowArgs) =>
item
; + +describe('VirtualList', () => { + it('render without crush', () => { + const list = shallow(); + expect(list.hasClass('VirtualList__holder')).toBeTruthy(); + }); + it('show no data placeholder when data array is empty', () => { + const list = renderer + .create( + , + snapOptions, + ) + .toJSON(); + expect(list).toMatchSnapshot(); + }); + + it('show list and loader when fetching more', () => { + const list = renderer + .create( + , + snapOptions, + ) + .toJSON(); + expect(list).toMatchSnapshot(); + }); + + it('only show loader when reloading', () => { + const list = renderer + .create( + , + snapOptions, + ) + .toJSON(); + expect(list).toMatchSnapshot(); + }); + + it('show no more data hint when all data fetched', () => { + const list = renderer + .create( + , + snapOptions, + ) + .toJSON(); + expect(list).toMatchSnapshot(); + }); + + it('scrolling equal row height', () => { + const list = renderer + .create( + , + snapOptions, + ) + .toJSON(); + expect(list).toMatchSnapshot(); + }) + it('scrolling dynamic row height', () => { + const list = renderer + .create( + , + snapOptions, + ) + .toJSON(); + expect(list).toMatchSnapshot(); + }) +}); diff --git a/src/components/VirtualList/index.tsx b/src/components/VirtualList/index.tsx new file mode 100644 index 00000000..18bb5bc5 --- /dev/null +++ b/src/components/VirtualList/index.tsx @@ -0,0 +1,366 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { sum, isFunction } from 'lodash'; +import { VirtualListState, VirtualAnchorItem, VirtualListDefaultProps, VirtualListProps } from '../../interface'; +import CSS from 'csstype'; +import './style.scss'; + +const BASIC_STYLES: CSS.Properties = { + position: 'absolute', + width: '100%', +}; +const RUNWAY_ITEMS = 50; +const RUNWAY_ITEMS_OPPSITE = 20; + +function getHeight(el: HTMLDivElement) { + const styles = window.getComputedStyle(el); + const height = el.offsetHeight; + const marginTop = parseFloat(styles.marginTop || '0'); + const marginBottom = parseFloat(styles.marginBottom || '0'); + return height + marginTop + marginBottom; +} + +const defaultProps: VirtualListDefaultProps = { + height: '100%', + data: [], + runwayItems: RUNWAY_ITEMS, + runwayItemsOppsite: RUNWAY_ITEMS_OPPSITE, + loader:

数据加载中

, + placeholder:

暂无数据

, + noMoreHint:

没有更多信息

, + debug: true, +}; + +export default class VirtualList extends React.Component { + static propTypes = { + /** 行高 */ + rowHeight: PropTypes.oneOfType([PropTypes.func, PropTypes.number]).isRequired, + /** 渲染 row UI */ + rowRenderer: PropTypes.func.isRequired, + /** 可见高度 */ + height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + /** 数据 */ + data: PropTypes.array, + /** 是否开启计算 */ + isEstimate: PropTypes.bool, + /** 监听 query 改变 */ + onQueryChange: PropTypes.func, + /** 确定异步数据的 offset 和 limit */ + query: PropTypes.object, + /** 总数 */ + totalCount: PropTypes.number, + /** 是否在获取数据 */ + isFetching: PropTypes.bool, + /** 行高 */ + runwayItems: PropTypes.number, + /** 行高 */ + runwayItemsOppsite: PropTypes.number, + /** 数据加载中时展示 */ + loader: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + /** 默认展示文本 */ + placeholder: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + /** 是否展示没有多余的数据 */ + noMore: PropTypes.bool, + /** 没有需要加载的数据时展示 */ + noMoreHint: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + /** Debug */ + debug: PropTypes.bool, + }; + static defaultProps = defaultProps; + holder: React.RefObject; + list: React.RefObject; + heightCache: number[]; + totalHeight: number; + anchorItem: VirtualAnchorItem; + anchorScrollTop: number; + constructor(props: VirtualListProps) { + super(props); + this.holder = React.createRef(); + this.list = React.createRef(); + this.heightCache = []; + this.totalHeight = 0; + this.anchorItem = { + index: 0, + offset: 0, + }; + this.anchorScrollTop = 0; + this.state = { + startIndex: 0, + endIndex: 0, + }; + } + + get wrapperHeight() { + const { height } = this.props; + if (typeof height === 'number') { + return height; + } + if (this.holder.current && this.holder.current.offsetHeight) { + return this.holder.current.offsetHeight; + } + return 450; + } + get noMore() { + const { noMore, totalCount, data } = this.props; + if (noMore) { + return true; + } + if (totalCount === data.length) { + return true; + } + return false; + } + + componentDidMount() { + const { data } = this.props; + this.handleResize(data); + window.addEventListener('resize', this.resizeHandler); + } + componentDidUpdate(prevProps: VirtualListProps) { + const { isEstimate, debug, data, isReloading } = this.props; + const { startIndex } = this.state; + + if (prevProps.data !== data) { + this.handleResize(data, false); + } + if (!prevProps.isReloading && isReloading) { + this.reset(); + if (this.list.current) { + this.list.current.scrollTop = 0; + } + this.setState({ + startIndex: 0, + endIndex: 0, + }); + } + + let needRender = false; + if (isEstimate && this.list.current && this.list.current.querySelectorAll) { + const rows: NodeListOf = this.list.current.querySelectorAll( + '.VirtualList > *', + ); + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const realHeight = getHeight(row); + const index = startIndex + i; + if (realHeight !== this.heightCache[index]) { + if (debug && process && process.env.NODE_ENV === 'development') { + console.warn( + `Index ${index} estimate height is ${this.heightCache[index]}, real height is ${realHeight}.`, + ); + } + this.heightCache[index] = realHeight; + needRender = true; + } + } + if (needRender) { + this.totalHeight = sum(this.heightCache); + this.forceUpdate(); + } + } + } + componentWillUnmount() { + window.removeEventListener('resize', this.resizeHandler); + } + + reset() { + this.heightCache = []; + this.anchorItem = { + index: 0, + offset: 0, + }; + this.anchorScrollTop = 0; + } + + // resizeHandler is a wrapper of this.handleResize + // only used for addEventListener and removeEventListener + resizeHandler = () => { + this.handleResize(this.props.data); + }; + handleResize = (data: object[], flushCache?: boolean) => { + this.recomputeRowHeight(data, flushCache); + this.handleScroll(); + }; + correctAnchor = () => { + const { index, offset } = this.anchorItem; + this.anchorScrollTop = sum(this.heightCache.slice(0, index)) + offset; + }; + recomputeRowHeight = (nextData: object[], flushCache: boolean = true) => { + if (flushCache) { + this.heightCache = []; + } + const { rowHeight } = this.props; + const data = nextData || this.props.data; + const count = data.length; + + for (let i = this.heightCache.length; i < count; i++) { + const item = data[i]; + const prevItem = i > 0 ? data[i - 1] : null; + const nextItem = i < data.length - 1 ? data[i + 1] : null; + const height = isFunction(rowHeight) + ? rowHeight({ + index: i, + item, + prevItem, + nextItem, + }) + : rowHeight; + if (height) { + this.heightCache.push(height); + } + } + this.totalHeight = sum(this.heightCache); + this.forceUpdate(); + }; + + handleScroll = () => { + const { runwayItems = RUNWAY_ITEMS, runwayItemsOppsite = RUNWAY_ITEMS_OPPSITE } = this.props; + const { scrollTop = 0, offsetHeight = 0 } = this.list.current || {}; + const delta = scrollTop - this.anchorScrollTop; + + this.anchorItem = scrollTop === 0 ? { index: 0, offset: 0 } : this.calculateAnchoredItem(delta); + this.anchorScrollTop = scrollTop; + + const lastVisibleItem = this.calculateAnchoredItem(offsetHeight); + let startIndex; + let endIndex; + if (delta < 0) { + startIndex = Math.max(0, this.anchorItem.index - runwayItems); + endIndex = Math.min(lastVisibleItem.index + runwayItemsOppsite, this.heightCache.length); + } else { + startIndex = Math.max(0, this.anchorItem.index - runwayItemsOppsite); + endIndex = Math.min(lastVisibleItem.index + runwayItems, this.heightCache.length); + } + this.setState( + { + startIndex, + endIndex, + }, + this.tryToFetchData, + ); + }; + + calculateAnchoredItem = (delta: number) => { + const { data } = this.props; + + if (delta === 0) return this.anchorItem; + let { index, offset: initialOffset } = this.anchorItem; + delta += initialOffset; + + if (delta < 0) { + while (delta < 0 && index > 0 && this.heightCache[index] !== undefined) { + delta += this.heightCache[index - 1]; + index--; + } + } else { + while ( + delta > 0 && + index < data.length && + this.heightCache[index] !== undefined && + this.heightCache[index] < delta + ) { + delta -= this.heightCache[index]; + index++; + } + } + + return { + index, + offset: delta, + }; + }; + + attachItems = () => { + this.correctAnchor(); + const { data } = this.props; + const { startIndex, endIndex } = this.state; + if (this.heightCache.length === 0) return []; + + let items = []; + let { index, offset } = this.anchorItem; + let currentPosition = this.anchorScrollTop - offset; + while (index > startIndex) { + currentPosition -= this.heightCache[index - 1]; + index--; + } + while (index < startIndex) { + currentPosition += this.heightCache[index]; + index++; + } + + for (let i = startIndex; i < endIndex; i++) { + const item = data[i]; + const prevItem = i > 0 ? data[i - 1] : null; + const nextItem = i < data.length - 1 ? data[i + 1] : null; + items.push({ + index: i, + item, + prevItem, + nextItem, + style: { + ...BASIC_STYLES, + transform: `translateY(${currentPosition}px)`, + }, + }); + currentPosition += this.heightCache[i]; + } + return items; + }; + + tryToFetchData = () => { + const { data, query, onQueryChange, isFetching } = this.props; + const { endIndex } = this.state; + if (isFetching) return; + if (endIndex < data.length) return; + if (this.noMore) return; + if (onQueryChange && query) { + const { offset, limit } = query; + onQueryChange({ + ...query, + offset: offset + limit, + limit, + }); + } + }; + + render() { + const { + rowRenderer, + data, + isFetching, + isReloading, + height, + loader, + placeholder, + noMoreHint, + className, + } = this.props; + const noData = data.length === 0; + return ( +
+
+ {!isFetching && noData && placeholder} + {!isReloading && !noData && ( +
+ {this.attachItems().map(rowRenderer)} +
+ )} + {isFetching && loader} + {!isFetching && !noData && this.noMore && noMoreHint} +
+
+ ); + } +} diff --git a/src/components/VirtualList/style.scss b/src/components/VirtualList/style.scss new file mode 100644 index 00000000..43c12e67 --- /dev/null +++ b/src/components/VirtualList/style.scss @@ -0,0 +1,10 @@ +.VirtualList__wrapper { + position: relative; + width: 100%; + overflow-x: hidden; + overflow-y: auto; +} +.VirtualList__no-more-hint, +.VirtualList__loader { + padding-left: 10px +} \ No newline at end of file diff --git a/src/components/index.tsx b/src/components/index.tsx index 14d3659c..92a7fb9e 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -17,6 +17,7 @@ import Tree from './Tree'; import DatePicker from './DatePicker'; import Panel from './Panel'; import UsageBar from './UsageBar'; +import VirtualList from './VirtualList'; import { Col, Clearfix, MenuItem, Navbar, NavDropdown, NavItem, Row, Well, PanelGroup } from 'react-bootstrap'; export { @@ -48,4 +49,5 @@ export { Well, Panel, PanelGroup, + VirtualList, }; diff --git a/src/interface.tsx b/src/interface.tsx index 607d55a3..d0a8dd2d 100644 --- a/src/interface.tsx +++ b/src/interface.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { SelectCallback, Sizes } from 'react-bootstrap'; import { Moment } from 'moment'; +import CSS from 'csstype'; export interface BadgeProps { count?: number | string; @@ -221,3 +222,45 @@ export interface DropdownProps extends DropdownDefaultProps { title?: string; children?: React.ReactNode; } + +export interface Query { + offset: number; + limit: number; +} +export interface VirtualRowArgs { + index: number; + item: object; + prevItem: object | null; + nextItem: object | null; + style: CSS.Properties +} +export interface VirtualAnchorItem { + index: number; + offset: number; +} +export interface VirtualListState { + startIndex: number; + endIndex: number; +} +export interface VirtualListDefaultProps { + height?: number | string; + data: any[], + runwayItems?: number; + runwayItemsOppsite?: number; + loader?: React.ReactNode; + placeholder?: React.ReactNode | string; + noMoreHint?: React.ReactNode | boolean; + debug?: boolean; +} +export interface VirtualListProps extends VirtualListDefaultProps { + query?: Query; + onQueryChange?: (query: Query) => Promise; + rowHeight?: number | ((item: object) => number); + rowRenderer: (item: VirtualRowArgs) => React.ReactNode | Element; + isFetching?: boolean; + isReloading?: boolean; + noMore?: boolean; + totalCount?: number; + className?: string; + isEstimate?: boolean; +} diff --git a/stories/VirtualList.stories.tsx b/stories/VirtualList.stories.tsx new file mode 100644 index 00000000..b35370ba --- /dev/null +++ b/stories/VirtualList.stories.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { VirtualList } from '../src'; +import AsyncVirtualList, { rowRenderer } from './demos/AsyncVirtualList'; + + +storiesOf('DATA SHOW | VirtualList', module) + .add('empty data', () => { + return ( + + ) + }) + .add('async equal height', () => ) + .add('async dynamic height', () => ) \ No newline at end of file diff --git a/stories/demos/AsyncVirtualList.tsx b/stories/demos/AsyncVirtualList.tsx new file mode 100644 index 00000000..05d75966 --- /dev/null +++ b/stories/demos/AsyncVirtualList.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { VirtualList } from '../../src'; +import getMockDatas from '../utils/getMockDatas'; +import { get } from 'lodash'; +import { Query, VirtualRowArgs } from '../../src/interface' + +const resName = "list"; +function getDatas(query: Query) { + return getMockDatas(query, 180, resName); +} + +export const rowRenderer = (i: VirtualRowArgs) =>
{resName}-{i.index + 1}
; + +const rowRendererRandomHeight = (i: VirtualRowArgs) =>
{resName}-{i.index + 1}
; + +export default (props: { random?: boolean }) => { + const [fetching, setFetch] = React.useState(false); + const [datas, setDatas] = React.useState([]); + const [totalCount, setTotalCount] = React.useState(0); + const [count, setCount] = React.useState(0); + React.useEffect(() => { + handleQueryChange({ + limit: 30, + offset: 0, + }) + }, []); + React.useEffect(() => { + const existLists = document.querySelectorAll('.VirtualList > *'); + setCount(existLists ? existLists.length : 0); + }, [datas]); + const handleQueryChange = async (query: { limit: number, offset: number }) => { + setFetch(true); + const actionResult: any = await getDatas(query); + setFetch(false); + const totalCount = get(actionResult, 'response.paging.totalCount'); + const lists = actionResult.response.lists; + setTotalCount(totalCount); + setDatas(datas.concat(lists)) + } + return ( +
+

总共 {totalCount}, 获取了 {datas.length} 条数据。

+

VirtualList 会在增加 list 条数的同时,销毁超出的 UI,目前渲染出来的 list 条数是: {count}

+
+ +
+
+ ) +} \ No newline at end of file diff --git a/stories/utils/getMockDatas.ts b/stories/utils/getMockDatas.ts new file mode 100644 index 00000000..26cf5cf1 --- /dev/null +++ b/stories/utils/getMockDatas.ts @@ -0,0 +1,28 @@ +function createDatas(query: { limit: number, offset: number }, totalCount: number, resName: string) { + const { limit, offset } = query; + console.log('limit:', limit, 'offset:', offset, 'total:', totalCount); + let rlt = []; + if (offset <= totalCount) { + const len = Math.min(limit, totalCount - offset); + for (let i = 0; len - i > 0; i++) { + rlt.push({ id: offset + i, name: `${resName}-${offset + i}` }); + } + } + return { + response: { + [`${resName}s`]: rlt, + paging: { + totalCount + } + }, + } +} + +export default function getMockDatas(query: { limit: number, offset: number }, totalCount: number, resName: string) { + return new Promise((resolve) => { + setTimeout(() => { + const datas= createDatas(query, totalCount, resName); + resolve(datas); + }, 500); + }); +} \ No newline at end of file