npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@pixui-dev/pixui-react-virtualwaterfall

v1.0.12

Published

pixui 高性能React虚拟瀑布流组件

Readme

VirtualWaterfallList 虚拟瀑布流组件

PixUI 里基于 react 实现的高性能虚拟化瀑布流列表组件。支持 虚拟滚动、元素复用、无限加载、下拉刷新、曝光回调 等能力。 以最小化节点变更为实现目的,可通过元素复用结合 Ref 更新,达到近乎无更新消耗的效果。

适用于列表项高度固定或不固定的瀑布流场景,具有优秀的性能表现。

安装

yarn add @pixui-dev/pixui-react-virtualwaterfall

CPreact开启

可以结合CPreact提升性能。使用 pxw 模版 的情况下,路径为

.../node_modules/@pixui-dev/pxw/config/webpack.js

// wepack 配置添加
externals: {
    preact: 'window.cpreact',
    react: 'window.cpreact',
    'preact/hooks': 'window.cpreact',
    'preact/compat': 'window.cpreact',
    'pxwdom': 'window.pxwdom',
},

使用注意事项

  1. 不同配置下的更新瀑布流模式

    • useCache 为 false:此时,就是普通的增删瀑布流
    • useCache 为 true:此时,瀑布流中的组件会回收,但会重新执行 renderItem,并创建 Vnode 重新比对差异,按需生成实际的 Dom。
    • useCache 为 true,且 renderItem 返回 refs:此时,瀑布流中的组件会回收使用时,不触发 renderItem,直接调用 updateItem,让开发者通过 refs 动态修改变化部分。
  2. 高度计算: getItemHeight 函数应该尽可能准确,避免频繁的布局重计算

  3. 类型分类: 使用 getItemType 可以让相同类型的元素更好地复用,提升性能

  4. 数据更新: 当 data 数组发生变化时,组件会智能地只重新计算变化部分的布局

  5. 滚动加载: 使用 hasMoreloadMore 实现无限滚动时,新的 data 需包含老 data 的数据

  6. 下拉刷新: 下拉刷新基于滚动回弹,仅在 PixUI 环境下生效

  7. 曝光监控: 利用 onItemExposeonItemHide 可以实现精确的项目曝光统计

  8. 元素动态高度设置 利用 updateItemByIndex 更新数据并同步到 getItemHeight 进行高度运算变化, 然后调用 layoutItemsFromIndex 从对应索引位置开始,重新进行排版

最佳实践

  • renderItem 中返回 refs 对象,配合 updateItem 使用可以获得最佳性能
  • 如果需要获得最低 内存占用,可以在 onItemHide 中通过 refs 手动置空图片内存,避免离屏缓存部分元素的图片持有。
  • 对于瀑布流中完全不同类型的元素,建议使用 getItemType 进行分类缓存
  • 使用过程中,可以使用 CPreact 进一步减少 Preact 进行 Diff 的时间
  • 合理设置 overscan 值,太小会影响滚动流畅度,太大会影响性能
  • 对于需要曝光统计的场景,合理使用 onItemExposeonItemHide 回调
  • 下拉刷新和加载更多可以配合使用,提供完整的数据加载体验

案例效果与性能报告

不规则瀑布流滚动时,会循环使用场上元素组件,并可通过 Ref 直接更新变化数据,免去Dom的删除创建,达到 Diff 最小化,达到近乎无消耗的性能。(具体性能可以参考 虚拟瀑布流组件性能测试报告

通用属性

Tips: 以下通用属性适用于 pixui 大部分前端组件,不支持的组件会单独说明。

| 属性名 | 类型 | 说明 | 默认值 | | --- | --- | --- | --- | | rootId | string | 添加在组件最外层的 id | - | | rootClassName | string | 添加在组件最外层的 className | - | | rootStyle | CSSProperties | 添加在组件最外层的样式对象 | - |

必需属性

| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | data | any[] | 输入数据列表,每个数据项可包含任意字段,排版基于数据项进行索引 | - | | | itemWidth | number | 单个项目的宽度(像素) | - | | | containerHeight | number | 容器的高度(像素) | - | | | columns | number | 瀑布流的列数 | - | | | getItemHeight | (item: any, index: number, itemWidth: number) => number | 获取单个项目高度的函数,接收(item, index, itemWidth)参数,返回高度值,用于当前元素的瀑布流排版 | - | | | renderItem | (item: any, index: number) => {element: JSX.Element, refs?: object} | 渲染单个项目的函数,接收(item, index)参数,返回{element, refs?}对象。如返回 ref 且存在 updateItem,则重用组件时,会直接进行 updateItem,否则会重新进行 renderItem | - | |

可选属性

| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | contentStyle? | CSSProperties | 内容区域style,可以按需覆盖,不过可能导致预期外的结果,用于适配用户定制化的样式设定 | - | | | gap? | number | 列之间的间距(像素) | 10 | | | overscan? | number | 可视区域外预加载的尺寸大小(像素) | 0 | | | useCache? | boolean | 是否启用元素缓存复用 | true | | | getItemType? | (item: any) => string | 获取项目类型的函数,用于分类缓存,返回类型字符串,不设定则认为全局为同一类组件进行缓存 | - | | | updateItem? | (item: any, index: number, refs: object) => void | 更新已复用项目内容的函数,用于元素复用时,存在 Refs 才生效,直接通过 Ref 更新内容 | - | | | hasMore? | () => boolean | 是否还有更多数据可加载的函数 | - | | | loadMore? | () => void | 加载更多数据的回调函数 | - | | | loadMoreThreshold? | number | 触底距离阈值,到底前多少具体触发loadMore | 100 | | | renderLoadMoreArea? | () => JSX.Element | JSX.Element[] | 加载更多渲染区域,通常用于显示加载状态 | - | |

滚动配置属性

| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | scrollProps? | VirtualListScrollProps | 滚动相关配置对象 | - | |

VirtualListScrollProps 配置项

| 属性名 | 类型 | 说明 | 默认值 | | --- | --- | --- | --- | | movementType? | 'elastic' | 'clamped' | 滚动回弹类型,elastic: 触顶触底会回弹,clamped: 触顶触底不回弹 | 'elastic' | | scrollSensitivity? | number | 滑动灵敏度,值越大滑动越灵敏 | 1 | | inertia? | boolean | 是否有惯性,true: 滑动后会有惯性滚动,false: 滑动后立即停止 | true | | inertiaVersion? | number | 惯性函数版本,1: 倾向于unity的惯性效果,2: 倾向于ue的惯性效果 | 2 | | decelerationRate? | number | 惯性衰减率,仅在inertiaVersion为1时生效,取值范围:0(立即停止)到 1(无减速) | 0.135 | | staticVelocityDrag? | number | 静态速度阻力,仅在inertiaVersion为2时生效,每秒速度的固定衰减值 | 100 | | frictionCoefficient? | number | 摩擦系数,仅在inertiaVersion为2时生效,每秒速度的动态衰减值 | 2.0 |

曝光回调属性

| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | onItemExpose? | (item: any, index: number, refs?: object) => void | 项目曝光回调函数,当项目进入可视区域时触发 | - | | | onItemHide? | (item: any, index: number, refs?: object) => void | 项目隐藏回调函数,当项目离开可视区域时触发 | - | |

下拉刷新属性

| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | hasPullDownRefresh? | () => boolean | 是否启用下拉刷新的函数 | () => false | | | pullDownRefreshThreshold? | number | 下拉刷新阈值,下拉超过此距离时触发刷新 | 50 | | | onPullDown? | (scrollTop: number) => void | 下拉过程中的回调函数 | - | | | onPullDownRefresh? | () => void | 下拉刷新触发时的回调函数 | - | | | onPullDownEnd? | () => void | 下拉结束时的回调函数 | - | | | renderPullDownArea? | () => JSX.Element | JSX.Element[] | 下拉刷新渲染区域,用于显示下拉刷新状态 | - | |

公共信息

公共属性

| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | renderData | any[] | 具体的渲染数据,只读 | - | 1.0.9 | | containerRef | ref | 外层容器的 Dom Ref | - | 1.0.9 | | contentRef | ref | 内容容器的 Dom Ref | - | 1.0.9 |

公共方法

| 方法名 | 参数 | 返回值 | 说明 | | --- | --- | --- | --- | | reset | - | void | 重新初始化瀑布流组件,包括清理缓存、重新计算布局、重新渲染 | | getVisibleIndices | - | number | 获取当前滚动位置 | | getScrollTop | - | number | 获取当前滚动位置 | | getVisibleIndices | - | number[] | 用于获取可视区域项目索引的拷贝 | | getContentHeight | - | number | 用于获取总内容高度 | | scrollTo | (scrollLeft: number, scrollTop: number) | void | 设定滚动距离(1.0.8) | | updateItemByIndex | (index: number, item: any) | void | 更新指定索引的项目数据 | | removeItemByIndex | (index: number) | void | 删除指定索引的项目 | | addSubData | (data: any[]) | void | 添加子数据到列表末尾 | | addSubDataAtIndex | (data: any[], index) | void | 在指定索引添加子数据(1.0.9) | | layoutItemsFromIndex | (index: number) | void | 从指定索引开始重新计算布局(1.0.6) | | finishPullDownRefresh | - | void | 手动完成下拉刷新,结束刷新状态 |

使用示例

1. 最简单案例,默认增删

import { VirtualWaterfall } from "@pixui-dev/pixui-react-virtualwaterfall"

export function BasicDemo() {
    const data = Array.from({ length: 50 }, (_, i) => ({
        id: i,
        content: `Item ${i + 1}`
    }));

    const getItemHeight = (item: any, width: number) => {
        return 120 + Math.floor(Math.random() * 100);
    };

    const renderItem = (item: any, index: number) => ({
        element: (
            <div style={{
                width: '100%',
                height: '100%',
                padding: '16px',
                border: '1px solid #ddd',
                borderRadius: '8px',
                backgroundColor: '#fff'
            }}>
                {item.content}
            </div>
        )
    });

    return (
        <VirtualWaterfall
            data={data}
            columns={2}
            itemWidth={200}
            containerHeight={400}
            getItemHeight={getItemHeight}
            renderItem={renderItem}
        />
    );
}

2. 带Ref回收示例

import { VirtualWaterfall } from "@pixui-dev/pixui-react-virtualwaterfall"
import { createRef } from 'preact';

export function SimpleListDemo() {
  const data = Array.from({ length: 2000 }, (_, i) => ({
      id: i,
      title: `标题 ${i + 1}`,
      content: `这是第 ${i + 1} 项的内容描述`
  }));

  const getItemHeight = (item: any, width: number) => 120;

  const renderItem = (item: any, index: number) => {
      const titleRef = createRef();
      const contentRef = createRef();

      const element = (
          <div style={{ width: '100%', height: '100%', padding: '12px', border: '1px solid #ddd', backgroundColor: '#fff', flexDirection: 'column' }}>
              <text ref={titleRef} style={{ margin: '0 0 8px 0', color: '#000' }}>
                  {item.title}
              </text>
              <text ref={contentRef} style={{ margin: 0, fontSize: '14px', color: '#000' }}>
                  {item.content}
              </text>
          </div>
      );

      return {
          element,
          refs: { title: titleRef, content: contentRef }
      };
  };

  const updateItem = (item: any, index: number, refs: any) => {
      if (refs.title.current) {
          refs.title.current.textContent = item.title;
      }
      if (refs.content.current) {
          refs.content.current.textContent = item.content;
      }
  };

  return (
      <VirtualWaterfall
          data={data}
          columns={2}
          itemWidth={220}
          containerHeight={400}
          getItemHeight={getItemHeight}
          renderItem={renderItem}
          updateItem={updateItem}
          useCache={true}
      />
  );
}

3. ref更新 + 下拉刷新 + 加载更多 + 动态变高 案例

import { h, Component } from 'preact';
import { VirtualWaterfall } from "@pixui-dev/pixui-react-virtualwaterfall"

interface NormalCardProps {
    data: any;
    key: string;
    index: number;
    titleRef?: (ref: any) => void;
    keyRef?: (ref: any) => void;
    imageRef?: (ref: any) => void;
    statusWrapRef?: (ref: any) => void;
    statusRef?: (ref: any) => void;
    deleteButtonRef?: (ref: any) => void;
    bounsWrapRef?: (ref: any) => void;
    bounsIconRef?: (ref: any) => void;  
    bounsBigRef?: (ref: any) => void;
    bounsRef?: (ref: any) => void;
    linkButtonRef?: (ref: any) => void;
    linkIconRef?: (ref: any) => void;
    likeButtonRef?: (ref: any) => void;
    likeIconRef?: (ref: any) => void;
    shareButtonRef?: (ref: any) => void;
    shareIconRef?: (ref: any) => void;
    linkRef?: (ref: any) => void;
    likeRef?: (ref: any) => void;
    shareRef?: (ref: any) => void;
}

interface NormalCardState {}

export default class NormalCard extends Component<NormalCardProps, NormalCardState> {

    private index: number;
    private useData: any;

    constructor(props: NormalCardProps) {
        super(props);
        this.index = props.index;
        this.useData = props.data;
    }

    /**
     * 更新数据, 重用情况下,必须手动更新数据
     * @param data 新的数据
     */
    updateData(data: any) {
        this.useData = data;
    }
    
    /**
     * 更新索引, 重用情况下,必须手动更新索引
     * @param index 新的索引
     */
    updateIndex(index: number) {
        this.index = index;
    }

    render() {
        const { data, index } = this.props;

        // 更新 props 索引
        if (this.index !== index) {
            this.index = index;
        }
        // 更新 props 数据
        if (this.useData !== data) {
            this.useData = data;
        }

        // console.log('PPCard render', this.index, this.useData);
        
        return (
            <div style={{ display: 'flex', flexDirection: 'column', width: '353px', height: '100%', backgroundColor: 'rgb(22,38, 72)' }}>
                <div style={{ position: 'relative', width: '100%', height: '200px', backgroundColor: '#fff' }}>
                    {/* 图片 */}
                    <img src={data.imageUrl} style={{ width: '100%', height: '100%' }} ref={this.props.imageRef} />
                    {/* 状态 */}
                    <div
                        ref={this.props.statusWrapRef}
                        style={{
                            position: 'absolute',
                            left: 0,
                            top: 0,
                            padding: '5px 10px',
                            height: '25px',
                            backgroundColor: data.status === 'Finished' ? 'rgb(219, 255, 87)' : data.status === 'Not Shortlisted' ? 'rgb(182, 54, 45)' : 'rgb(48, 134, 5)',
                        }}
                    >
                        <text style={{ color: '#000', fontSize: '12px', fontWeight: 'bold' }} ref={this.props.statusRef}>{data.status}</text>
                    </div>
                    {/* 奖金 */}
                    <div
                        style={{
                            display: data.bouns !== null ? 'flex' : 'none',
                            position: 'absolute',
                            left: 0,
                            bottom: 0,
                            padding: '10px',
                            width: '100%',
                            height: data.bouns === '600' ? '60px' : '30px',
                            backgroundColor: 'rgb(219, 194, 6)',
                            flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start'
                        }}
                        ref={this.props.bounsWrapRef}
                    >
                        <div
                            style={{
                                display: data.bouns === '600' ? 'flex' : 'none',
                                width: '26%', 
                                height: '100%', 
                                flexDirection: 'row', 
                                alignItems: 'center', 
                                justifyContent: 'flex-start',
                            }}
                            ref={this.props.bounsIconRef}
                        >
                            <div style={{ width: '80%', height: '80%', backgroundColor: 'rgb(255, 89, 0)' }}></div>
                        </div>
                        <div style={{ 
                            width: '85%', 
                            height: '100%', 
                            display: 'flex', 
                            flexDirection: 'column', 
                            alignItems: 'flex-start', 
                            justifyContent: 'center',
                        }}>
                            <text style={{
                                display: data.bouns === '600' ? 'flex' : 'none',
                                color: '#000', fontSize: '14px', fontWeight: 'bold'
                            }} ref={this.props.bounsBigRef}>The GOLD AWARD</text>
                            <text style={{
                                color: '#fff', fontSize: '14px'
                            }} ref={this.props.bounsRef}>Bouns: ${data.bouns}</text>
                        </div>
                    </div>
                    {/* 删除按钮 */}
                    <div
                        ref={this.props.deleteButtonRef}
                        style={{
                            display: data.showDelete ? 'flex' : 'none',
                            position: 'absolute',
                            right: 10,
                            top: 10,
                            width: '60px',
                            height: '30px',
                            backgroundColor: 'rgb(255, 89, 0)',
                            borderRadius: '10px',
                            flexDirection: 'column',
                            alignItems: 'center',
                            justifyContent: 'center',
                            cursor: 'pointer'
                        }}
                        onClick={() => data.deleteFunc(this.index)}
                    >
                        <text style={{ color: '#000', fontSize: '10px', fontWeight: 'bold' }}>delete</text>
                    </div>
                </div>
                {/* 下方栏目 */}
                <div style={{ width: '100%', height: '70px', backgroundColor: 'rgb(22,38, 72)', 
                    display: 'flex',
                    flexDirection: 'row',
                    alignItems: 'center',
                    justifyContent: 'center'
                }}>
                    {/* 标题 */}
                    <div style={{
                        width: '50%', color: '#fff', fontSize: '20px', fontWeight: 'bold',
                        height: '100%',
                        display: 'flex',
                        flexDirection: 'row',
                        alignItems: 'center',
                        justifyContent: 'flex-start',
                        overflow: 'hidden',
                    }}>
                        <text
                            ref={this.props.titleRef}
                            style={{ width: '70%', height: '40px', fontSize: '14px', lineHeight: '20px', textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', color: '#fff' }}
                        >
                            {data.title}
                        </text>
                        <text ref={this.props.keyRef} style={{ marginLeft: '10px', color: '#fff', fontSize: '20px', fontWeight: 'bold' }}>{data.key}</text>
                    </div>
                    {/* 多个栏目 */}
                    <div
                        style={{ width: '14%', height: '100%', backgroundColor: 'rgb(219, 255, 87)' }}>
                        <div
                            ref={this.props.linkButtonRef}
                            style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
                            onClick={() => {
                                data.linkFunc(this.index, this.useData);
                            }}
                        >
                            <div
                                style={{
                                    backgroundColor: data.link ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)',
                                    width: '60%', height: '40%', borderRadius: '50%', position: 'relative'
                                }}
                                ref={this.props.linkIconRef}
                            ></div>
                            <text ref={this.props.linkRef} style={{ marginTop: '4px', height: '20%', color: '#000', fontSize: '12px', fontWeight: 'bold' }}>1200</text>
                        </div>
                    </div>
                    <div style={{ width: '14%', height: '100%', backgroundColor: 'rgb(182, 54, 45)' }}>
                        <div
                            ref={this.props.likeButtonRef}
                            style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
                            onClick={() => {
                                data.likeFunc(this.index, this.useData);
                            }}
                        >
                            <div
                                style={{
                                    backgroundColor: data.liked ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)',
                                    width: '60%', height: '40%', borderRadius: '50%', position: 'relative'
                                }}
                                ref={this.props.likeIconRef}
                            ></div>
                            <text ref={this.props.likeRef} style={{ marginTop: '4px', height: '20%', color: '#000', fontSize: '12px', fontWeight: 'bold' }}>3554</text>
                        </div>
                    </div>
                    <div style={{ width: '14%', height: '100%', backgroundColor: 'rgb(48, 134, 5)' }}>
                        <div 
                            ref={this.props.shareButtonRef}
                            style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
                            onClick={() => {
                                data.shareFunc(this.index, this.useData);
                            }}
                        >
                            <div
                                style={{ 
                                    backgroundColor: data.shared ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)',
                                    width: '60%', height: '40%', borderRadius: '50%', position: 'relative'
                                }}
                                ref={this.props.shareIconRef}
                            ></div>
                            <text ref={this.props.shareRef} style={{ marginTop: '4px', height: '20%', color: '#000', fontSize: '12px', fontWeight: 'bold' }}>3200</text>
                        </div>
                    </div>
                </div>
            </div>
        )
    }
}

const words = [
    'Jojo\'s Bizarre Adventure: Golden Wind',
    'The Legend of Zelda: Breath of the Wild',
    'The Witcher 3: Wild Hunt',
    'The Elder Scrolls V: Skyrim',
    'The Last of Us: Left Behind',
    'The Last of Us 2: Juggernaut Edition',
]

interface NormalListDemoState {}

interface NormalListDemoProps {
    columns?: number;
    wrapHeight?: number;
}

class NormalListDemo extends Component<NormalListDemoProps, NormalListDemoState> {

    private VirtualWaterfallRef: any;

    private initData: any[] = [];
    private data: any[] = [];
    

    private hadPullDownRefresh: boolean = false;
    private pullDownRefreshed: boolean = false;

    private isLoading: boolean = false;

    private mockDataIndex: number = 0;

    constructor(props: NormalListDemoProps) {
        super(props);

        // 生成示例数据
        this.initData = this.generateSampleData();
        this.data = this.initData;

        setTimeout(() => {
            this.initData = this.generateSampleData();
            this.data = this.initData;
            this.forceUpdate();
        }, 1000);
    }

    private generateSampleData = (isLoadMore: boolean = false): any[] => {
        const data: any[] = [];

        if (!isLoadMore) {
           this.mockDataIndex = 0;
        }
        
        for (let i = 0; i < 120; i++) {

            let imageUrls = [
                `https://down.pandora.qq.com/public/open/gex/anythings/ORG-100001/gooseFeathers-avatar/27988b9757cf05575f61ada27a5119ec4caf33edb3887c55537a5aa9854c236e/gooseFeathers.png`
                ,`https://down.pandora.qq.com/public/open/gex/anythings/ORG-100001/INFP-TEST-avatar/a6c332446f9b4354b263fcdb56396ee57b9defb87b41cf1fc723817e0b973ebf/skin_infp.png`
                ,`https://down.pandora.qq.com/public/open/gex/anythings/ORG-100001/farmCommunity-avatar/63f8ff8af6b4a2fbe40f167274cd8cd0498516df903a9cba5cec6e29e8dfe9ba/image.png`
            ];

            const key = this.mockDataIndex++;

            const randomImageUrl = imageUrls[Math.floor(Math.random() * imageUrls.length)];
            const randomIndex = Math.floor(Math.random() * words.length);

            data.push({
                key: key, // 确保ID唯一
                imageUrl: randomImageUrl,
                title: words[randomIndex],
                status: i % 3 === 0 ? 'Finished' : i % 3 === 1 ? 'Not Shortlisted' : 'In Progress',
                bouns: i % 3 === 0 ? '600' : i % 3 === 1 ? null : '0',
                liked: i % 3 === 1 ? true : false,
                showDelete: true,
                linkFunc: (index, data) => {
                    // console.log('linkFunc', index, data);
                    if (this.VirtualWaterfallRef) {
                        // 这里是更新本地数据
                        this.VirtualWaterfallRef.updateItemByIndex(index, {
                            ...data,
                            link: !data.link
                        });
                    }
                },
                likeFunc: (index, data) => {
                    // console.log('likeFunc', index, data);

                    if (this.VirtualWaterfallRef) {
                        // 这里是更新本地数据
                        this.VirtualWaterfallRef.updateItemByIndex(index, {
                            ...data,
                            liked: !data.liked
                        });
                    }
                },
                shareFunc: (index, data) => {
                    // console.log('shareFunc', index, data);

                    // 额外添加重新设置高度逻辑
                    this.VirtualWaterfallRef.updateItemByIndex(index, {
                        ...data,
                        shared: !data.shared,
                        height: !data.shared ? 450 : 270,
                    });
                    // 从索引开始重新排版
                    this.VirtualWaterfallRef.layoutItemsFromIndex(index);
                },
                deleteFunc: (index) => {
                    if (this.VirtualWaterfallRef) {
                        // console.log('deleteFunc', i, index);
                        this.VirtualWaterfallRef.removeItemByIndex(index);
                    }
                }
            });
        }
        
        return data;
    }

    // 渲染元素
    renderItem(item: any, index: number) {
         // 创建refs对象来存储ref
         const refs = {
            cardRef: null,
            titleRef: null,
            keyRef: null,
            imageRef: null,
            statusWrapRef: null,
            statusRef: null,
            deleteButtonRef: null,
            bounsWrapRef: null,
            bounsIconRef: null,  
            bounsBigRef: null,
            bounsRef: null,
            linkButtonRef: null,
            linkIconRef: null,
            likeButtonRef: null,
            likeIconRef: null,
            shareButtonRef: null,
            shareIconRef: null,
            linkRef: null,
            likeRef: null,
            shareRef: null,
        };

        const element = <NormalCard

            data={item}
            key={item.key}
            index={index}
            ref={(ref) => {
                refs.cardRef = ref;
            }}
            titleRef={(ref) => {
                refs.titleRef = ref;
            }}
            keyRef={(ref) => {
                refs.keyRef = ref;
            }}
            imageRef={(ref) => {
                refs.imageRef = ref;
            }}
            statusWrapRef={(ref) => {
                refs.statusWrapRef = ref;
            }}
            statusRef={(ref) => {
                refs.statusRef = ref;
            }}
            deleteButtonRef={(ref) => { 
                refs.deleteButtonRef = ref;
            }}
            bounsWrapRef={(ref) => {
                refs.bounsWrapRef = ref;
            }}
            bounsIconRef={(ref) => {
                refs.bounsIconRef = ref;
            }}
            bounsBigRef={(ref) => {
                refs.bounsBigRef = ref;
            }}
            bounsRef={(ref) => {
                refs.bounsRef = ref;
            }}
            linkButtonRef={(ref) => {
                refs.linkButtonRef = ref;
            }}
            linkIconRef={(ref) => {
                refs.linkIconRef = ref;
            }}
            likeButtonRef={(ref) => {
                refs.likeButtonRef = ref;
            }}
            likeIconRef={(ref) => {
                refs.likeIconRef = ref;
            }}
            shareButtonRef={(ref) => {
                refs.shareButtonRef = ref;
            }}
            shareIconRef={(ref) => {
                refs.shareIconRef = ref;
            }}
            linkRef={(ref) => {
                refs.linkRef = ref;
            }}
            likeRef={(ref) => {
                refs.likeRef = ref;
            }}
            shareRef={(ref) => {
                refs.shareRef = ref;
            }}
            />

        return {
            element,
            // 返回 refs, 后续会直接通过 updateItem 更新组件,
            // 不返回 refs 则不会更新组件,每次重新调用 renderItem 重新渲染组件
            refs
        };
    }

    updateItem(item: any, index: number, refs: { [key: string]: any }) {
        /* 
        *   !!!重要!!!
        *   如内部使用了索引 或者 数据,
        *   重用情况必须手动更新组件索引 和 数据
        *   否则会导致组件索引错误,导致组件无法正常工作
        */
        // 获取组件Ref
        if (refs.cardRef) {
            // 更新index
            refs.cardRef.updateIndex(index);
            // 更新数据,避免引用到旧数据
            refs.cardRef.updateData(item);
        }
        

        // 手动更新数据
        if (refs.titleRef) {
            if (item.title !== refs.titleRef.textContent) {
                refs.titleRef.textContent = item.title;
            }
        }

        if (refs.keyRef) {
            if (item.key !== refs.keyRef.textContent) {
                refs.keyRef.textContent = item.key;
            }
        }

        if (refs.imageRef) {
            if (item.imageUrl !== refs.imageRef.src) {
                refs.imageRef.src = item.imageUrl;
            }
        }
        if (refs.statusRef) {
            if (item.status !== refs.statusRef.textContent) {
                refs.statusRef.textContent = item.status;
                if (refs.statusWrapRef) {
                    if (item.status === 'Finished') {
                        refs.statusWrapRef.style.backgroundColor = 'rgb(219, 255, 87)';
                    } else if (item.status === 'Not Shortlisted') {
                        refs.statusWrapRef.style.backgroundColor = 'rgb(182, 54, 45)';
                    } else {
                        refs.statusWrapRef.style.backgroundColor = 'rgb(48, 134, 5)';
                    }
                }
            }
        }

        if (refs.deleteButtonRef) {
            if (item.showDelete !== refs.deleteButtonRef.style.display) {
                refs.deleteButtonRef.style.display = item.showDelete ? 'flex' : 'none';
            }
        }

        if (refs.bounsWrapRef) {
            if (item.bouns !== null) {
                refs.bounsWrapRef.style.display = 'flex';
                if (item.bouns === '600') {
                    refs.bounsWrapRef.style.height = '60px';
                } else {
                    refs.bounsWrapRef.style.height = '30px';
                }
                if (item.bouns === '600') {
                    refs.bounsIconRef.style.display = 'flex';
                } else {
                    refs.bounsIconRef.style.display = 'none';
                }
                if (item.bouns === '600') {
                    refs.bounsBigRef.style.display = 'flex';
                } else {
                    refs.bounsBigRef.style.display = 'none';
                }
            } else {
                refs.bounsWrapRef.style.display = 'none';
            }
        }

        if (refs.bounsRef) {
            const nextBouns =`Bouns: ${item.bouns}`;
            if (nextBouns !== refs.bounsRef.textContent) {
                refs.bounsRef.textContent = nextBouns;
            }
        }

        if (refs.likeIconRef) {
            const nextLikeIconColor = item.liked ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)';
            if (nextLikeIconColor !== refs.likeIconRef.style.backgroundColor) {
                refs.likeIconRef.style.backgroundColor = nextLikeIconColor;
            }
        }
        if (refs.linkIconRef) {
            const nextLinkIconColor = item.link ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)';
            if (nextLinkIconColor !== refs.linkIconRef.style.backgroundColor) {
                refs.linkIconRef.style.backgroundColor = nextLinkIconColor;
            }
        }
        if (refs.shareIconRef) {
            const nextShareIconColor = item.shared ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)';
            if (nextShareIconColor !== refs.shareIconRef.style.backgroundColor) {
                refs.shareIconRef.style.backgroundColor = nextShareIconColor;
            }
        }

        /* 其他的 ref 更新 没有实现 */

    }

    // 获取元素高度
    getItemHeight(item: any, index: number, itemWidth: number) {
        return item.height;
    }

    // 是否启用下拉刷新
    hasPullDownRefresh() {
        return true;
    }

    // 下拉回调
    pullDown(scrollTop: number) {
        // console.log('pullDown', scrollTop);
    }

    // 下拉刷新回调
    pullDownRefresh() {
        // console.log('pullDownRefresh');
        this.hadPullDownRefresh = true;
        this.pullDownRefreshed = true;
    }

    // 下拉结束回调
    pullDownEnd() {
        // console.log('pullDownEnd');

        if (this.hadPullDownRefresh) {
            // 成功 触发下拉刷新
            
            // 更新数据,这里相当于 props 里面 data 的更新
            this.initData = this.generateSampleData();
            // 更新本地数据
            this.data = this.initData;
            // 更新组件后,手动更新组件
            this.forceUpdate();

            // 重置下拉刷新状态
            this.hadPullDownRefresh = false;
        }

        this.pullDownRefreshed = false;
    }

    // 下拉刷新渲染区域
    renderPullDownArea() {
        // 只会在触发下拉刷新时渲染 以及 回到初始状态触发渲染, 不会在滚动过程中渲染
        // 如果 需要动画过渡,可以自己基于 ref 实现
        return <div style={{ width: '100%', height: '60px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
            <text style={{ color: '#000', fontSize: '20px', fontWeight: 'bold' }}>
                {this.pullDownRefreshed ? '松手触发更新' : '下拉刷新...'}
            </text>
        </div>;
    }

    // 是否还有更多数据
    hasMore() {
        return this.data.length < 1200;
    }

    // 加载更多数据
    loadMore() {
        if (this.isLoading || !this.hasMore()) {
            return;
        }
        // console.log('loadMore');

        this.isLoading = true;

        // 模拟网络请求
        setTimeout(() => {

            if (this.VirtualWaterfallRef) {
                const subData = this.generateSampleData(true);
                // 添加数据,这里是直接往 瀑布流组件里面添加数据
                this.VirtualWaterfallRef.addSubData(subData);

                // 更新本地数据
                this.data = [...this.data, ...subData];

                this.isLoading = false;
            }
        }, 200);
    }

    // 加载更多渲染区域
    renderLoadMoreArea() {
        return <div style={{ width: '100%', height: '60px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
            {
                this.hasMore() ?  <text style={{ color: '#000', fontSize: '20px', fontWeight: 'bold' }}>加载更多...</text> : <text style={{ color: '#000', fontSize: '20px', fontWeight: 'bold' }}>没有更多数据了</text>
            }
        </div>;
    }

    // 元素曝光
    onItemExpose(item: any, index: number, refs: any) {
        // console.log('onItemExpose', index);

		// setTimeout(() => {
			// 如果要获取新创建的refs,需要等一帧渲染
			// console.log('refs', index, refs);
		// }, 0);
    }
    // 元素隐藏
    onItemHide(item: any, index: number, refs: any) {
        // console.log('onItemHide', index, refs);

		// 隐藏的时候,手动去掉图片,避免加载时候的原图片显示,以及完全避免内存占用。
		if (refs.imageRef) {
			refs.imageRef.style.src = '';
		}
    }

    render() {
        return (
            <div style={{ width: '100%', height:'100%', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
                {/* 等高 虚拟瀑布流 通过 refs 更新 */}
                <VirtualWaterfall
                    rootId="normal-list-root"
                    rootClassName="normal-list-root"
                    ref={(ref) => this.VirtualWaterfallRef = ref}
                    data={this.initData}
                    // 列数
                    columns={this.props.columns || 3}
                    // 元素宽度
                    itemWidth={353}
                    // 间距
                    gap={20}
                    // 额外渲染距离,默认 0
                    overscan={0}
                    // 容器高度
                    containerHeight={this.props.wrapHeight || document.body.clientHeight * 0.9}
                    // 开启缓存
                    useCache={true}
                    // 瀑布流元素相关
                    renderItem={this.renderItem}
                    updateItem={this.updateItem}
                    getItemHeight={this.getItemHeight}
                    // 下拉刷新
                    hasPullDownRefresh={this.hasPullDownRefresh.bind(this)}
                    pullDownRefreshThreshold={60}
                    onPullDown={this.pullDown.bind(this)}
                    onPullDownRefresh={this.pullDownRefresh.bind(this)}
                    onPullDownEnd={this.pullDownEnd.bind(this)}
                    renderPullDownArea={this.renderPullDownArea.bind(this)}
                    // 加载更多
                    loadMoreThreshold={20}
                    hasMore={this.hasMore.bind(this)}
                    loadMore={this.loadMore.bind(this)}
                    renderLoadMoreArea={this.renderLoadMoreArea.bind(this)}
                    // 元素曝光隐藏
                    onItemExpose={this.onItemExpose.bind(this)}
                    onItemHide={this.onItemHide.bind(this)}
                    // 滚动效果
                    scrollProps={{
                        movementType: 'elastic',
                        scrollSensitivity: 1,
                        inertiaVersion: 2,
                        decelerationRate: 0.135,
                        staticVelocityDrag: 100,
                        frictionCoefficient: 2.0,
                    }}
                >
                </VirtualWaterfall>

            </div>
        );
    }

}

export { NormalListDemo };

4. 复杂案例集

具体请直接咨询 PixUI,获取官方案例。

版本更新记录

1.0.12

  1. 修复 removeItemByIndex 最后一个元素的报错,以及高度更新问题。

1.0.11

  1. 提供 getVisibleIndices, 用于获取可视区域项目索引的拷贝
  2. 提供 getContentHeight, 用于获取总内容高度

1.0.10

  1. renderLoadMoreArea 兼容 Unity/Unreal 情况下,滚动不包含当前区域的问题。
  2. 修复连续删除最后一个节点,导致的排版异常。

1.0.9

  1. 暴露 containerRef, contentRef,提供 getLayoutInfoByIndex 获取指定 index 的 排版信息。
  2. 添加 addSubDataAtIndex 用于在指定索引添加,一个或者多个渲染信息。

1.0.8

  1. 添加 scrollTo 方法

1.0.7

  1. 添加可选参数 contentStyle,用于自定义内容容器样式。可用于适配 em、rem 或 用户定制的样式方式。

1.0.6

  1. 修复1.0.5 去除 key后,下拉刷新的更新问题
  2. 添加 layoutItemsFromIndex,用于更新排版,进行动态高度设置

1.0.5

  1. 修复 preact 重用 vNode,子节点的父节点引用没有清理,导致引用图片的内存泄漏问题。
  2. onItemExpose onItemHide 添加可选参数 refs