uni-app 瀑布流实战:分列算法、触底加载与请求去重的一次讲透

2026-10-14 0 738

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 数量会翻倍,滚动掉帧是必然的。这时候要么上虚拟列表,要么把渲染量控制在页面卸载时清掉。虚拟列表跟瀑布流的组合难度不小,非必要不建议一开始就上。

最后

瀑布流这个东西,写起来不难,难的是把它写得不容易出错。贪心分列、按比例估高、版本号作废过期请求、刷新时重置到底标记——这几件事做到位,基本就能覆盖日常用了。

真要说有什么经验值得记,就是别把请求逻辑塞进组件。它看着挺方便,一旦接口字段变了或者要换个加载策略,改起来能把人逼疯。

uni-app 瀑布流实战:分列算法、触底加载与请求去重的一次讲透
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 瀑布流实战:分列算法、触底加载与请求去重的一次讲透 https://www.taomawang.com/web/uniapp/2937.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务