【react.js + hooks】useVirtualArea 渲染虚拟列表

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 函数中,我们将渲染所有的列表项和加载器。当 loadingtrue 时,我们将显示加载器,当 loadingfalse 并且 hasMorefalse 时,我们将显示一个表示没有更多数据的元素;当列表没有时,将展示对应的 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 来提升虚拟列表的性能。

最终效果图:
【react.js + hooks】useVirtualArea 渲染虚拟列表_第1张图片

示例代码(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 就这样实现了!

你可能感兴趣的:(react.js,前端,前端框架,react-hooks)