useVirtualArea
是一个 React Hook,用于创建虚拟列表。虚拟列表是一种优化技术,用于在不影响性能的情况下显示大量数据。
useVirtualArea
接受一个对象和一个数组作为参数,该对象包含以下属性:
loadMoreItems
: 一个函数,当需要加载更多数据时会被调用。items
: 当前的列表项。hasMore
: 一个布尔值,表示是否还有更多的数据可以加载。height
: 容器的高度。style
: 容器的样式。containerComponent
: 用于包裹列表的容器(默认div)。containerComponentProps
: 传递给 containerComponent
的 props。renderTop
: 用于渲染列表顶部的元素。renderItem
: 用于渲染列表项的函数。itemComponent
: 用于包裹列表项的容器(默认div)。itemComponentProps
: 传递给 itemComponent
的 props。renderNoData
: 没有列表数据时渲染的元素renderLoader
: 用于渲染加载器的容器(默认div)。renderUnLoaded
: 用于渲染没有更多数据时的元素。loaderComponent
: 用于包裹加载器的组件。loaderComponentProps
: 传递给 loaderComponent
的 props。renderBottom
: 用于渲染列表底部的元素。observerOptions
: 传递给 IntersectionObserver
的选项。数组:依赖项
useVirtualArea
返回一个数组,包含以下元素:
loaderRef
: 一个 ref,指向加载器的 DOM 元素。loading
: 一个布尔值,表示是否正在加载数据。items
: 当前的列表项。render
: 一个函数,用于渲染列表。首先,我们需要定义我们的 Hook 和它的参数。我们的 Hook 将接受一个对象作为参数,该对象包含我们需要的所有配置选项。
import { useState, useRef } from 'react';
interface VirtualAreaOptions {
loadMoreItems: () => Promise<void>;
items: any[];
hasMore: boolean;
// ...其他参数
}
export function useVirtualArea({ loadMoreItems, items, hasMore, ...rest }: VirtualAreaOptions, depths?: any[]) {
// ...
}
然后,我们需要定义我们的状态和 refs。我们需要一个状态来跟踪是否正在加载数据,以及一个 ref 来引用加载器的 DOM 元素。
const [loading, setLoading] = useState(false);
const loaderRef = useRef<any>(null);
接下来,我们需要创建一个 IntersectionObserver
来检测当加载器进入视口时。当这发生时,我们将调用 loadMoreItems
函数加载更多数据。
IntersectionObserver 是一个浏览器 API,用于异步观察目标元素与其祖先元素或顶级文档视口的交叉状态。这个 API 非常有用,因为它可以让你知道一个元素何时进入或离开视口,而无需进行复杂的计算或监听滚动事件,当被监听的元素进入视口,触发回调事件。
详见 MDN文档 - IntersectionObserver
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
setLoading(true);
loadMoreItems().then(() => {
setLoading(false);
});
}
},
{ ...observerOptions }
);
if (loaderRef.current) {
observer.observe(loaderRef.current);
}
return () => {
observer.disconnect();
};
}, [loadMoreItems, hasMore, loading, observerOptions]);
最后,我们的 Hook 需要返回一些值。我们将返回一个数组,包含加载器的 ref、加载状态、列表项以及一个渲染函数。
return [loaderRef, loading, items, render];
在这个 render
函数中,我们将渲染所有的列表项和加载器。当 loading
为 true
时,我们将显示加载器,当 loading
为 false
并且 hasMore
为 false
时,我们将显示一个表示没有更多数据的元素;当列表没有时,将展示对应的 noData 元素。
render :
const render = useCallback(() => {
return (
<Container {..._containerComponentProps}>
{typeof renderTop === "function" ? renderTop() : renderTop}
{
/** @ts-ignore */
(items || []).length === 0 &&
(typeof renderNoData === "function"
? renderNoData()
: renderNoData === void 0
? "No data"
: renderNoData)
}
{items.map((item, index) => (
<Item key={index} {...itemComponentProps}>
{typeof renderItem === "function" ? renderItem(item) : renderItem}
</Item>
))}
{/** @ts-ignore */}
<Loader ref={loaderRef} {...loaderComponentProps}>
{loading &&
(typeof renderLoader === "function"
? renderLoader()
: renderLoader === void 0
? "Loading..."
: renderLoader)}
{!loading &&
!hasMore &&
(typeof renderUnLoaded === "function"
? renderUnLoaded()
: renderUnLoaded === void 0
? "No more data"
: renderUnLoaded)}
</Loader>
{typeof renderBottom === "function" ? renderBottom() : renderBottom}
</Container>
);
}, [
_containerComponentProps,
renderTop,
items,
Item,
itemComponentProps,
renderItem,
loaderRef,
loaderComponentProps,
loading,
renderLoader,
hasMore,
renderUnLoaded,
renderBottom,
...(depths || []),
]);
尽可能的使用 useMemo 和 useCallback 来提升虚拟列表的性能。
示例代码(css代码是全局注册了@emotion, Loading是自己封装的组件):
import { useState } from "react";
import { useVirtualArea } from "@hooks/useVirtualArea";
import Loading from "@/components/Loading";
import BorderClearOutlinedIcon from "@mui/icons-material/BorderClearOutlined";
function View() {
const [items, setItems] = useState<any[]>([]);
const [hasMore, setHasMore] = useState(true);
const loadMoreItems = async () => {
// Mock network request
await new Promise((resolve) =>
setTimeout(resolve, 1000 + Math.random() * 1000)
);
// push new items
setItems((prevItems) => [
...prevItems,
...Array.from({ length: 10 }, (_, i) => i + prevItems.length),
]);
// do not load more if there has been 50 items at least
if (items.length + 10 >= 50) {
setHasMore(false);
}
};
const renderItem = (item: any) => (
<div css={$css`margin-left: 20px`}>{item}</div>
);
const [loaderRef, loading, _items, render] = useVirtualArea({
loadMoreItems,
items,
hasMore,
renderItem,
renderNoData: (
<div css={$css`display: flex; align-items: center; padding-block: 20px;`}>
<span>No Data</span>
<BorderClearOutlinedIcon style={{ marginLeft: "12px" }} />
</div>
),
height: "300px",
style: {
position: "relative",
},
loaderComponentProps: {
style: {
marginBlock: "20px",
},
},
renderTop: () => {
return (
<div
css={$css`display: flex; align-items: center; position: sticky; top: 0; z-index: 1; background-color: #fff; padding: 10px; box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);`}
>
<strong>total : </strong>
<span css={$css`margin-left: 20px;`}>{items.length}</span>
<strong css={$css`margin-left: 20px;`}>hasMore : </strong>
<span css={$css`margin-left: 20px;`}>{hasMore.toString()}</span>
<strong css={$css`margin-left: 20px;`}>loading : </strong>
<span css={$css`margin-left: 20px;`}>{loading.toString()}</span>
</div>
);
},
renderLoader: () => {
return (
<div css={$css`display: flex; align-items: center; margin-left: 12px;`}>
<Loading on />
<span css={$css`margin-left: 20px; color: #44A2FC;`}>
Loading Items...
</span>
</div>
);
},
renderUnLoaded: () => {
return (
<div css={$css`display: flex; align-items: center;`}>
<span css={$css`color: #333;`}>No more Items</span>
<span
css={$css`
margin-left: 20px;
color: #44A2FC;
cursor: pointer;
`}
onClick={() => {
setItems([]);
setHasMore(true);
}}
>
Restart
</span>
</div>
);
},
});
return <div>{render()}</div>;
}
useVirtualArea 完整实现:
import React, {
useState,
useEffect,
useRef,
useMemo,
useCallback,
} from "react";
export interface VirtualAreaOptions<
C extends keyof React.JSX.IntrinsicElements = "div",
I extends keyof React.JSX.IntrinsicElements = "div",
L extends keyof React.JSX.IntrinsicElements = "div"
> {
loadMoreItems: () => Promise<void>;
items: any[];
hasMore: boolean;
height: React.CSSProperties["height"];
style?: React.CSSProperties;
containerComponent?: C;
containerComponentProps?: React.JSX.IntrinsicElements[C];
renderTop?: React.ReactNode | (() => React.ReactNode);
renderItem: React.ReactNode | ((item: any) => React.ReactNode);
itemComponent?: I;
itemComponentProps?: React.JSX.IntrinsicElements[I];
renderNoData?: React.ReactNode | (() => React.ReactNode);
renderLoader?: React.ReactNode | (() => React.ReactNode);
renderUnLoaded?: React.ReactNode | (() => React.ReactNode);
loaderComponent?: L;
loaderComponentProps?: React.JSX.IntrinsicElements[L];
renderBottom?: React.ReactNode | (() => React.ReactNode);
observerOptions?: IntersectionObserverInit;
}
export function useVirtualArea(
{
loadMoreItems,
items,
hasMore,
height,
style: containerStyle,
renderTop,
renderItem,
itemComponent,
itemComponentProps,
renderNoData,
renderLoader,
renderUnLoaded,
loaderComponent,
loaderComponentProps,
containerComponent,
containerComponentProps,
renderBottom,
observerOptions,
}: VirtualAreaOptions,
depths?: any[]
) {
const [loading, setLoading] = useState(false);
const loaderRef = useRef<any>(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
await loadMoreItems();
setLoading(false);
}, [loading, hasMore, loadMoreItems]);
useEffect(() => {
const options = {
root: null,
rootMargin: "20px",
threshold: 1.0,
};
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{
...options,
...observerOptions,
}
);
if (loaderRef.current) {
observer.observe(loaderRef.current);
}
return () => observer.disconnect();
}, [observerOptions, loadMore]);
const Container = useMemo(
() => containerComponent || "div",
[containerComponent]
);
const Item = useMemo(() => itemComponent || "div", [itemComponent]);
const Loader = useMemo(() => loaderComponent || "div", [loaderComponent]);
const _containerComponentProps = useMemo(() => {
const { style, ...rest } = containerComponentProps ?? {};
return {
...rest,
style: {
overflow: "auto",
height,
...containerStyle,
...style,
} as React.CSSProperties,
};
}, [containerComponentProps, height, containerStyle]);
const render = useCallback(() => {
return (
<Container {..._containerComponentProps}>
{typeof renderTop === "function" ? renderTop() : renderTop}
{
/** @ts-ignore */
(items || []).length === 0 &&
(typeof renderNoData === "function"
? renderNoData()
: renderNoData === void 0
? "No data"
: renderNoData)
}
{items.map((item, index) => (
<Item key={index} {...itemComponentProps}>
{typeof renderItem === "function" ? renderItem(item) : renderItem}
</Item>
))}
{/** @ts-ignore */}
<Loader ref={loaderRef} {...loaderComponentProps}>
{loading &&
(typeof renderLoader === "function"
? renderLoader()
: renderLoader === void 0
? "Loading..."
: renderLoader)}
{!loading &&
!hasMore &&
(typeof renderUnLoaded === "function"
? renderUnLoaded()
: renderUnLoaded === void 0
? "No more data"
: renderUnLoaded)}
</Loader>
{typeof renderBottom === "function" ? renderBottom() : renderBottom}
</Container>
);
}, [
_containerComponentProps,
renderTop,
items,
Item,
itemComponentProps,
renderItem,
loaderRef,
loaderComponentProps,
loading,
renderLoader,
hasMore,
renderUnLoaded,
renderBottom,
...(depths || []),
]);
return [loaderRef, loading, items, render] as const;
}
Bingo ! 一个用于实现虚拟列表的 useVirtualArea 就这样实现了!