
Security News
Packagist Urges Immediate Composer Update After GitHub Actions Token Leak
Packagist urges PHP projects to update Composer after a GitHub token format change exposed some GitHub Actions tokens in CI logs.
@pixui-dev/pixui-react-virtualwaterfall
Advanced tools
PixUI 里基于 react 实现的高性能虚拟化瀑布流列表组件。支持 虚拟滚动、元素复用、无限加载、下拉刷新、曝光回调 等能力。
以最小化节点变更为实现目的,可通过元素复用结合 Ref 更新,达到近乎无更新消耗的效果。
适用于列表项高度固定或不固定的瀑布流场景,具有优秀的性能表现。

yarn add @pixui-dev/pixui-react-virtualwaterfall
可以结合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',
},
不同配置下的更新瀑布流模式
useCache 为 false:此时,就是普通的增删瀑布流useCache 为 true:此时,瀑布流中的组件会回收,但会重新执行 renderItem,并创建 Vnode 重新比对差异,按需生成实际的 Dom。useCache 为 true,且 renderItem 返回 refs:此时,瀑布流中的组件会回收使用时,不触发 renderItem,直接调用 updateItem,让开发者通过 refs 动态修改变化部分。高度计算: getItemHeight 函数应该尽可能准确,避免频繁的布局重计算
类型分类: 使用 getItemType 可以让相同类型的元素更好地复用,提升性能
数据更新: 当 data 数组发生变化时,组件会智能地只重新计算变化部分的布局
滚动加载: 使用 hasMore 和 loadMore 实现无限滚动时,新的 data 需包含老 data 的数据
下拉刷新: 下拉刷新基于滚动回弹,仅在 PixUI 环境下生效
曝光监控: 利用 onItemExpose 和 onItemHide 可以实现精确的项目曝光统计
元素动态高度设置 利用 updateItemByIndex 更新数据并同步到 getItemHeight 进行高度运算变化, 然后调用 layoutItemsFromIndex 从对应索引位置开始,重新进行排版
renderItem 中返回 refs 对象,配合 updateItem 使用可以获得最佳性能内存占用,可以在 onItemHide 中通过 refs 手动置空图片内存,避免离屏缓存部分元素的图片持有。getItemType 进行分类缓存CPreact 进一步减少 Preact 进行 Diff 的时间overscan 值,太小会影响滚动流畅度,太大会影响性能onItemExpose 和 onItemHide 回调不规则瀑布流滚动时,会循环使用场上元素组件,并可通过 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 | 滚动相关配置对象 | - |
| 属性名 | 类型 | 说明 | 默认值 |
|---|---|---|---|
| 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 | 手动完成下拉刷新,结束刷新状态 |
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}
/>
);
}
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}
/>
);
}
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 };
具体请直接咨询 PixUI,获取官方案例。
FAQs
pixui 高性能React虚拟瀑布流组件
We found that @pixui-dev/pixui-react-virtualwaterfall demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 5 open source maintainers collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Packagist urges PHP projects to update Composer after a GitHub token format change exposed some GitHub Actions tokens in CI logs.

Research
GemStuffer abuses RubyGems as an exfiltration channel, packaging scraped UK council portal data into junk gems published from new accounts.

Company News
Socket was named to the Rising in Cyber 2026 list, recognizing 30 private cybersecurity startups selected by CISOs and security executives.