Skip to content

Waterfall 瀑布流 ​

高性能虚拟瀑布流 / 网格容器:内置 ShLazyImage 懒加载、触底分页加载、双滚动模式,专为图片流、商品流、内容 Feed 设计。

为什么不抖动、为什么快

零抖动 —— 卡片高度在布局阶段一次性确定(真实宽高比 → 兜底比例池按下标取模),图片以 cover 填充,加载前后 0 回流;scrollbar-gutter: stable 防止滚动条出现引起列宽跳变。 高性能 —— 布局数据绕开深层响应式;虚拟列表每列二分查找可视区间,滚动 rAF 节流一帧一算;卡片绝对定位 + translate3d GPU 合成;分页追加走增量布局,已有卡片不重排。 可靠触底 —— 加载更多基于 IntersectionObserver 哨兵,与滚动来源解耦,容器滚动 / 页面滚动都能触发;内容不足一屏自动续载首屏。

基础用法 ​

传入 items,监听 load-more 加载下一页(追加进数组即可,组件做增量布局);用 loading / finished 控制底部状态。默认插槽缺省时,组件直接用 ShLazyImage 渲染图片。

布局支持 layout="waterfall"(瀑布流,最矮列优先)与 layout="grid"(等高网格,grid-ratio 控制单元格比例),可动态切换列数与间距。

已加载 0 条
暂无数据
vue
<script setup lang="ts">
import { ref } from 'vue'
import { ShWaterfall } from 'sh-design'

const items = ref<any[]>([])
const loading = ref(false)
const finished = ref(false)
let page = 0

async function onLoadMore() {
  loading.value = true
  const list = await api.fetchList(page++) // [{ id, src, width, height }]
  items.value.push(...list)
  loading.value = false
  if (list.length === 0) finished.value = true
}
</script>

<template>
  <!-- 默认 scroller="self":由父容器给定高度,组件内部滚动 -->
  <div style="height: 600px">
    <ShWaterfall
      :items="items"
      :cols="3"
      :gap="12"
      :loading="loading"
      :finished="finished"
      @load-more="onLoadMore"
    />
  </div>
</template>

自定义卡片(#item 插槽) ​

#item 插槽提供 { item, index, width, height }。卡片有图片下方的固定区域(标题 / 操作栏)时,用 extra-height 告知组件,布局时会为每张卡片预留该高度,避免渲染后撑高导致抖动。

下方示例用 #item 渲染「图片 + 标题栏」,并通过 extra-height="34" 预留标题高度:

已加载 0 条
暂无数据
vue
<ShWaterfall :items="items" :cols="3" :extra-height="34" @load-more="onLoadMore">
  <template #item="{ item, height }">
    <div class="card">
      <ShLazyImage :src="item.src" lazy="observer" fit="cover" :radius="8" :height="height - 34" />
      <div class="card-title">{{ item.title }}</div>
    </div>
  </template>
</ShWaterfall>

双滚动模式 ​

  • scroller="self"(默认):组件自身是滚动容器,需要父级给定高度——适合后台工作区、弹窗、固定面板。
  • scroller="window":组件自然撑开、不产生内部滚动条,跟随页面(或任意祖先滚动容器)滚动——适合 C 端长页 Feed 流。

两种模式的触底加载都由 IntersectionObserver 哨兵驱动,虚拟列表照常工作。下方案例可切换两种模式(页面滚动模式下组件会撑开跟随整页滚动):

已加载 0 条
暂无数据
vue
<!-- 容器滚动:父级给定高度,组件内部滚动(默认) -->
<div style="height: 600px">
  <ShWaterfall scroller="self" :items="items" :cols="3" @load-more="onLoadMore" />
</div>

<!-- 页面滚动模式:无需给高度,组件跟随页面滚动 -->
<ShWaterfall scroller="window" :items="items" :cols="2" @load-more="onLoadMore" />

keep-alive / 隐藏容器下的触底

当组件被 <keep-alive> 缓存的页面切走、或祖先被 display:none 隐藏时,容器宽度会归 0。此时若按几何判定照常派发 load-more,业务方往往会忽略(例如带「失活期间禁止翻页」的守卫),导致内部触底锁被空耗、返回页面后「明明还有数据却滚不动」。

组件已内置三层防护,业务方无需额外处理:

  1. 不可见即不消耗——容器宽度归 0 期间跳过触底判定,不派发也不锁定;
  2. 恢复可见即复位——onActivated 回到缓存页时立即重测尺寸、解锁并复查触底;
  3. 滚动即触发通道——滚动过程持续复查,覆盖哨兵长期停在视口内、IntersectionObserver 不再产生新交叉边的情况。

因此「进详情 → 返回列表 → 继续下滚」可正常续载,无需在业务侧再做激活补偿。

关闭虚拟列表 ​

虚拟列表默认开启(virtual="true"),只渲染视口内的卡片,因此视口外的卡片不会出现在 DOM 中。这在绝大多数场景下没问题,但以下情况需要关闭:

  • 数据量本身很小(几十条以内),虚拟化的收益可忽略;
  • 需要完整 DOM 节点:浏览器打印 / 截图、SEO 抓取、端到端测试快照比对;
  • 调试时想直接看到全部卡片。

关闭后组件会一次性渲染全部卡片,但布局算法、绝对定位与高度预留机制完全不变,仅不再做视口裁剪;触底加载、入场动画照常工作。

当前 DOM 卡片数:视口内 | 已加载 0 条
暂无数据

打开 DevTools 元素面板,对比「虚拟开 / 关」下 .sh-waterfall__item 节点数量即可验证。

vue
<!-- 数据量小或需要完整 DOM 时关闭虚拟列表 -->
<ShWaterfall :virtual="false" :items="items" :cols="3" @load-more="onLoadMore" />

入场动画 ​

默认开启。与常见实现不同的两点,也是动画真能被看见的关键:

  • 触发时机是「真正进入视口」,而非节点创建。虚拟列表会提前在视口外 buffer 处创建节点,如果挂载就播,卡片滑进视口时动画早已结束——看起来就像没有动画。
  • 方向跟随滚动:下滚时卡片从下方滑入,上滚时从上方滑入,不会出现「倒着飞」的迷向感。

同一批进入视口的卡片会错峰入场;卡片移出渲染范围后再回看会重放(可用 once 改为只播一次)。

下方示例可切换开关,并对比「默认」与「只播一次(once)」两种动画行为:

已加载 0 条,滚动查看入场效果
暂无数据

animate 支持布尔值和对象两种写法,微调参数只写需要的字段,其余取默认:

vue
<!-- 关闭 -->
<ShWaterfall :animate="false" :items="items" />

<!-- 微调:位移更大、稍快、取消错峰、只播一次 -->
<ShWaterfall
  :animate="{ distance: 120, duration: 380, stagger: 0, once: true }"
  :items="items"
/>
字段说明类型默认值
distance入场位移距离(px),越大动感越强number80
duration动画时长(ms)number460
stagger同批卡片的错峰延迟(ms),0 为同时入场number60
once只在首次进入视口时播放,来回滚动不重放booleanfalse

列数 / 布局切换时已有卡片会平滑归位;系统开启「减少动态效果」(prefers-reduced-motion)时自动降级为直接显示。

底部与空状态自定义 ​

空状态文案用 empty-text 或 #empty 插槽;底部状态由 #footer 插槽整体接管——提供即常驻渲染(不再按 loading/finished 二选一),组件透传作用域参数 { loading, finished },三态文案完全由业务决定。右侧示例带真实分页数据:向下滚动到底,footer 随内容滚动依次呈现「已显示统计 → 加载中 → 加载完成」:

暂无数据
暂无数据

左:空状态(空态分支)| 右:常驻 footer,三态随滚动轮换

vue
<ShWaterfall :items="items" :loading="loading" :finished="finished" @load-more="onLoadMore">
  <template #footer="{ loading, finished }">
    <span v-if="loading">加载中…</span>
    <span v-else-if="finished">—— 我也是有底线的 ——</span>
    <span v-else>共 {{ total }} 条,已显示 {{ items.length }} 条,向下滚动加载更多</span>
  </template>
  <template #empty><MyEmpty description="什么都没有" /></template>
</ShWaterfall>

零抖动的高度策略 ​

按优先级取卡片高度,布局时一次定死,加载前后不变:

  1. 数据带真实尺寸(width / height 字段,字段名可用 width-key / height-key 改)→ 按真实宽高比精确计算,与图片完全一致;
  2. 没有尺寸 → 从 ratios 比例池按下标取模取值(确定性:重排/回看结果一致),图片 cover 填充。

建议接口尽量返回图片原始宽高(大多图床/OSS 都有),可获得像素级还原的瀑布流。

API ​

Props ​

属性说明类型默认值
items数据列表(对象或图片 URL 字符串),分页时往数组追加即可(object | string)[][]
layout布局模式'waterfall' | 'grid''waterfall'
cols列数number2
gap卡片间距(px)number12
scroller滚动容器:self 组件内滚动(父级给高度)/ window 跟随页面滚动'self' | 'window''self'
loading是否加载中(触底后不重复触发)booleanfalse
finished是否已全部加载完booleanfalse
animate卡片入场动画:false 关闭,传对象微调(见入场动画)boolean | objecttrue
src-key图片地址字段名(默认渲染时用)string'src'
item-key唯一 key 字段名(虚拟列表节点复用)string'id'
width-key / height-key原始宽/高字段名,存在时按真实比例零抖动布局string'width' / 'height'
ratios无真实宽高时的兜底比例池(height/width),按下标取模number[][0.75, 0.9, 1, 1.2, 1.35]
grid-ratiogrid 布局单元格比例(height/width)number1
extra-height每张卡片的额外固定高度(如标题区,px)number0
virtual是否启用虚拟列表:true 仅渲染视口内卡片(万级流畅)/ false 全量渲染(数据量小或需完整 DOM:打印/截图/爬虫/测试)booleantrue
buffer视口外额外渲染缓冲(px),virtual=false 时不生效number300
threshold哨兵提前量(px),距底部多远触发加载number200
radius默认渲染 ShLazyImage 的圆角string | number8
empty-text空状态文案string'暂无数据'

Events ​

事件说明参数
load-more触底(或内容不足一屏)时触发,在此加载下一页—
item-click点击卡片{ item, index }
scroll滚动(rAF 节流){ scrollTop }

Slots ​

插槽说明作用域参数
item自定义卡片内容,缺省渲染 ShLazyImage{ item, index, width, height }
footer底部状态(提供即常驻渲染,替代原 loading / finished 插槽){ loading, finished }
empty空数据内容—

Expose ​

方法说明
relayout()手动全量重排
scrollTo(top, smooth?)滚动到指定位置(self 模式)
check()主动检查是否需要触发 load-more

Released under the MIT License.