uni-app 做瀑布流是个老话题,但真到项目里,十有八九会栽在三个地方:分列分着分着左右两边高度差出一屏;触底加载莫名其妙连着触发两次;下拉刷新之后列表顺序全乱。
市面上的瀑布流组件不少,但多数要么只支持固定高度卡片,要么把请求逻辑一起包进去,跟自己项目的接口格式对不上,改起来比分分钟重写还累。这篇不讲怎么挑插件,直接把一个能用的瀑布流拆开给你看。
先把职责划清楚
最关键的一步不是写代码,是想清楚”谁管分列”和”谁管请求”。
我的做法是:组件只负责把给定的数组按高度分到左右两列并渲染,请求、页码、加载状态全部留在页面里。组件对外只暴露一个 list 属性,加一个 reset 方法。
这么拆有个直接好处:接口翻页逻辑改起来不用动组件,组件想复用到另一个页面也不用带着一堆请求参数搬家。
分列算法:别用取模
新手最容易写出来的是这种:
// 别这么写
const left = list.filter((_, i) => i % 2 === 0)
const right = list.filter((_, i) => i % 2 === 1)
卡片高度一样的时候这没问题,但瀑布流的前提就是高度不一样。第二张图特别高、第三张图特别矮,左右两边就会越走越偏,滚到第三屏的时候能差出大半个屏幕。
正确做法是贪心:每来一个卡片,塞进当前最矮的那一列,然后把这列的高度加上这张卡片的高度。
function distribute(list) {
list.forEach(item => {
// 找出当前最矮的一列
let target = 0
for (let i = 1; i < colHeights.length; i++) {
if (colHeights[i] < colHeights[target]) target = i
}
colList[target].push(item)
colHeights[target] += estimateHeight(item)
})
}
这段逻辑用一个循环就能表达,不需要排序也不需要额外数据结构。列数固定是 2 或 3 的时候,遍历成本可以忽略。
高度怎么估
分列需要高度,但这时候图片还没加载,拿不到真实像素。只能靠接口给的封面宽高比来估。
function estimateHeight(item) {
const w = Number(item.coverWidth) || 1
const h = Number(item.coverHeight) || 1
// 列宽当作 1 来算,只要所有卡片用同一个基准,大小关系就是对的
const imgRatio = h / w
// 标题按两行封顶估,长标题不会无限撑高
const titleLen = (item.title || '').length
const titleLines = Math.min(2, Math.ceil(titleLen / 12))
return imgRatio + titleLines * 0.18 + 0.42
}
这里的数字(0.18、0.42)是相对列宽的系数,不是像素。调的时候不用纠结绝对值,看两眼实际排布效果微调就行。
有个前提必须确认:接口得返回封面宽高。如果后端只给一个 URL,估算就无从谈起,只能给所有卡片一个默认比例。短列表还行,长列表滚下去之后左右高度差会非常明显。真拿不到宽高,退而求其次的做法是首屏先用默认比例,等 image 的 load 事件拿到实际尺寸后再把差值补回去——实现起来麻烦不少,能推动后端加字段就推动后端。
图片用 widthFix,能省掉一半麻烦
渲染图片时用 mode="widthFix",图片一旦加载完成,高度会自动按比例撑开,跟我们的估算值通常不会差太远。
<image
class="waterfall__cover"
:src="item.cover"
mode="widthFix"
lazy-load
@load="onCoverLoad(item)"
/>
如果用了固定高度的 mode(比如 aspectFill 配死高度),估算值和真实值一旦偏差较大,卡片底部就会出现一块空白或者内容被压扁。widthFix 让浏览器自己算高度,兜底效果最好。
lazy-load 这个属性在小程序端是原生支持的,H5 端部分场景下不生效,需要自己用 IntersectionObserver 补。不追求极致性能的话,长列表里带上它没坏处。
触底加载:onReachBottom 的三个坑
页面滚动用 onReachBottom,这是 uni-app 的页面生命周期。在 vue3 的 <script setup> 里要这样引入:
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
坑一:组件里用不了
onReachBottom 只能在页面里注册,写在子组件里不会报错,但也不会执行。所以事件必须留在页面,这正是前面为什么要拆职责的原因。
坑二:内容不足一屏时根本不触发
页面总高度撑不满屏幕,滚动条压根不出来,onReachBottom 永远不会调用。这时候用户看到的是一个空荡荡的首屏,加载更多也不会启动。
解决办法是首屏主动加载两页,或者在列表还没铺满的时候给容器一个最小高度。我更倾向于前者,因为接口本来就支持翻页,多请求一次成本不高。
onLoad(async () => {
await fetchPage('refresh')
// 首屏内容太少,主动再补一页
if (list.value.length < 8) {
await fetchPage('append')
}
})
坑三:scroll-view 里 onReachBottom 不生效
如果页面外层套了 <scroll-view>,滚动发生在 scroll-view 内部,页面本身没滚动,onReachBottom 自然不会触发。这时候要换成 scroll-view 的 @scrolltolower 事件。
用 scroll-view 还有个高频事故:容器没有确定高度,滚动区域塌陷成 0,@scrolltolower 永远不会来。要么给它固定的 px 高度,要么让它在 flex 布局里拿到实际的剩余空间。
<scroll-view
scroll-y
:lower-threshold="120"
@scrolltolower="onLoadMore"
>
能不用 scroll-view 就别用。页面级滚动配合 onReachBottom 的体验更顺,原生下拉刷新也能直接用,省掉一层状态管理。
请求去重:光靠一个 loading 锁不够
先看最常见的写法:
// 有问题
async function loadMore() {
if (loading) return
loading = true
const res = await request(...)
list.value = list.value.concat(res.list)
loading = false
}
这段在正常情况下能用,但有两个场景会出问题。
第一个是快速下拉到底部又弹回来,触底事件可能在请求返回前又触发一次。如果 loading 是在 await 之后才赋值的,中间那段时间就是空的。
第二个更麻烦:用户触底加载的同时下拉刷新。刷新请求先回来把 list 清空重设,追加请求后回来又把旧数据 concat 上去,列表就乱了。
我的处理是用一个递增的版本号,谁发出的请求不是最新版本,结果直接丢弃。
let seq = 0
const loading = ref(false)
async function fetchPage(mode = 'append') {
if (mode === 'append' && (loading.value || finished.value)) return
loading.value = true
const mySeq = ++seq
try {
const res = await uni.request({
url: API,
data: { page: mode === 'refresh' ? 1 : page.value, size: PAGE_SIZE }
})
// 期间发生了刷新,这一轮结果作废
if (mySeq !== seq) return
const rows = (res.data && res.data.list) || []
if (mode === 'refresh') {
list.value = rows
page.value = 2
} else {
list.value = list.value.concat(rows)
page.value += 1
}
if (rows.length < PAGE_SIZE) finished.value = true
} catch (e) {
if (mySeq === seq) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
} finally {
if (mySeq === seq) loading.value = false
}
}
注意 finally 里的判断。如果这里不判断,一个过期的追加请求返回时会把 loading 设成 false,而此时刷新请求还在途,界面上的”加载中”就提前消失了。
另外 finished 这个标记很重要。接口返回数量小于每页条数,说明到底了,之后的触底事件直接 return。不加这个标记,用户滚到底部就会疯狂发请求,服务端那边能看到一秒钟几十次调用。
下拉刷新:别忘了重置 finished 和 page
onPullDownRefresh(async () => {
finished.value = false
page.value = 1
await fetchPage('refresh')
uni.stopPullDownRefresh()
})
三件事一个都不能少。忘了重置 finished,刷新完再触底就不加载了;忘了重置 page,刷新后下一页请求会带着旧页码,拉回来的数据直接断层。
uni.stopPullDownRefresh() 要放在 await 之后,不然下拉的圈刚出现就收回去,用户会以为没刷新成功。当然也别放在 finally 之外的奇怪位置,接口报错时圈会一直转。
还有一点:onPullDownRefresh 需要在 pages.json 里给对应页面开启 "enablePullDownRefresh": true,不开的话这个生命周期不会触发,很容易排查半天以为是代码写错了。
组件的完整写法
把前面的东西拼起来,组件长这样:
<template>
<view class="waterfall">
<view class="waterfall__col" v-for="(col, ci) in columns" :key="ci">
<view
class="waterfall__card"
v-for="item in col"
:key="item.id"
@tap="emit('itemClick', item)"
>
<image
class="waterfall__cover"
:src="item.cover"
mode="widthFix"
lazy-load
/>
<view class="waterfall__title">{{ item.title }}</view>
<view class="waterfall__meta">
<text>{{ item.author }}</text>
<text>{{ item.likes }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, watch } from 'vue'
const props = defineProps({
list: { type: Array, default: () => [] },
columnCount: { type: Number, default: 2 }
})
const emit = defineEmits(['itemClick'])
const columns = ref([])
const colHeights = ref([])
let processed = 0
function reset() {
columns.value = Array.from({ length: props.columnCount }, () => [])
colHeights.value = new Array(props.columnCount).fill(0)
processed = 0
}
function estimateHeight(item) {
const w = Number(item.coverWidth) || 1
const h = Number(item.coverHeight) || 1
const imgRatio = h / w
const titleLen = (item.title || '').length
const titleLines = Math.min(2, Math.ceil(titleLen / 12))
return imgRatio + titleLines * 0.18 + 0.42
}
function distribute(rows) {
rows.forEach(item => {
let target = 0
for (let i = 1; i < colHeights.value.length; i++) {
if (colHeights.value[i] < colHeights.value[target]) target = i
}
columns.value[target].push(item)
colHeights.value[target] += estimateHeight(item)
})
}
watch(
() => props.list,
newList => {
// 列表变短了,说明是被整体替换过的,重新分
if (newList.length < processed) {
reset()
}
distribute(newList.slice(processed))
processed = newList.length
},
{ immediate: true }
)
defineExpose({ reset })
</script>
样式部分不展开,只提醒两个点:两列之间用 flex: 1 均分宽度,卡片之间的纵向间距用一个 margin-bottom 就够,别再用 gap 叠加,不然估算系数全得重调。
外面套一个 flex-direction: row 加横向间距的容器,两列就能均匀铺开。列宽不是写死的像素,估算时用的是比例基准,所以屏幕宽度变化不影响分列结果。
页面侧怎么接
<template>
<view>
<WaterfallList
ref="waterfallRef"
:list="list"
@item-click="goDetail"
/>
<view v-if="loading" class="tip">加载中…</view>
<view v-else-if="finished && list.length" class="tip">没有更多了</view>
<view v-else-if="!list.length" class="tip">还没有内容</view>
</view>
</template>
下拉刷新时手动调一次组件暴露的 reset:
const waterfallRef = ref(null)
onPullDownRefresh(async () => {
finished.value = false
page.value = 1
waterfallRef.value.reset()
await fetchPage('refresh')
uni.stopPullDownRefresh()
})
为什么要在发请求之前就 reset?因为列表替换的过程中,组件内部的 watch 会先收到一个空数组。如果组件没做那个”变短就重分”的判断,就会把新数据追加到旧列屁股后面。这里 reset 一次,配合 watch 里的判断,两条路都堵上了。
几个零碎的实战细节
页面返回时列表还在吗? 从详情页返回,页面实例没被销毁,list 和列状态都还在,不用做额外处理。但如果页面被 keep-alive 缓存过又做了复杂的重建逻辑,记得在 onShow 里确认下组件状态和 list 是否一致。
想调触底触发的时机? 在 pages.json 的对应页面里加 "onReachBottomDistance": 200,单位是 px。默认值我记得是 50,滚动到离底部还有 50px 时就触发。图片多的页面可以调大到 300 左右,提前把请求发出去,用户滚下去的时候数据已经在了,体感会好很多。
卡片里有互动按钮怎么办? 点赞、收藏这类按钮直接在卡片上加 @tap.stop,别让它冒泡到外层的跳转事件上。.stop 修饰符在小程序端是支持的,可以放心用。
列表特别长的时候? 几百条以内不用担心,直接渲染没问题。上千条的话,两列 DOM 数量会翻倍,滚动掉帧是必然的。这时候要么上虚拟列表,要么把渲染量控制在页面卸载时清掉。虚拟列表跟瀑布流的组合难度不小,非必要不建议一开始就上。
最后
瀑布流这个东西,写起来不难,难的是把它写得不容易出错。贪心分列、按比例估高、版本号作废过期请求、刷新时重置到底标记——这几件事做到位,基本就能覆盖日常用了。
真要说有什么经验值得记,就是别把请求逻辑塞进组件。它看着挺方便,一旦接口字段变了或者要换个加载策略,改起来能把人逼疯。

