useVirtualArea Hook
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
: 一个函数,用于渲染列表。
实现 useVirtualArea Hook
步骤 1:定义 Hook 和参数
首先,我们需要定义我们的 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[]) {// ...
}
步骤 2:定义状态和 refs
然后,我们需要定义我们的状态和 refs。我们需要一个状态来跟踪是否正在加载数据,以及一个 ref 来引用加载器的 DOM 元素。
const [loading, setLoading] = useState(false);
const loaderRef = useRef<any>(null);
步骤 3:使用 IntersectionObserver
接下来,我们需要创建一个 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]);
步骤 4:返回值
最后,我们的 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 || []),]);
步骤5 性能优化
尽可能的使用 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 requestawait new Promise((resolve) =>setTimeout(resolve, 1000 + Math.random() * 1000));// push new itemssetItems((prevItems) => [...prevItems,...Array.from({ length: 10 }, (_, i) => i + prevItems.length),]);// do not load more if there has been 50 items at leastif (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 (<divcss={$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><spancss={$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 就这样实现了!