uni-app 长列表虚拟滚动实战:从三千条聊天记录的卡顿讲起

2026-10-13 0 630

上个月接了个客服系统的活,功能不复杂:把用户和历史对话记录读出来展示,按时间顺序排列。数据从后端 API 拉,多的时候一次要拉三千多条。

第一版直接用了 v-for 全量渲染。开发机上跑起来没问题——毕竟现在的电脑性能都不错。真机上一跑就傻眼了:iPhone 上打开要白屏三秒多,安卓机更惨,有时候滑动还直接掉帧到十几帧。

换了虚拟滚动之后,首屏打开时间从 3 秒降到 200 毫秒以内,整个页面滑动也顺了。这篇文章把这条改造路径完整写出来。不用第三方库,从零攒一个,代码可以直接拿走用。

先搞清楚为什么会卡

三千条消息,每条消息在 DOM 里大概会生成五到八个节点(头像、名字、气泡、时间)。算下来两万个左右的 DOM 节点。这不是致命的,现代浏览器渲染两万个节点也就几十毫秒。

关键是 uni-app 在小程序端的架构。Vue 层的每一次数据更新,都要通过 setData 传到底层的渲染层。这个跨线程通信是异步的、带序列化成本的。三千条数据的初始渲染,就是一个大的 setData 调用。这个调用一次要传过去几百 KB 甚至几 MB 的 JSON,序列化和反序列化的成本就把首屏时间吃掉了。

App 端稍微好一点,因为 uni-app 在 App 上是原生渲染,不走跨线程通信。但三千条数据一次性创建原生视图同样是笔开销。至于 H5,其实反而简单,就是普通 DOM 渲染,问题最小。

所以不管哪一端,问题的本质都是一样的——我们渲染了太多用户根本看不见的东西。

虚拟滚动的核心思路

用户能看到的那部分,屏幕高度也就 700 到 900 像素,就算每条消息 80 像素高,可见的也就十来条。再加点上下文,撑死三十条。

那就只渲染这三十条。

做法是在一个固定高度的滚动容器里,用一个”占位元素”撑起全部数据的完整高度,让滚动条看起来跟真的有三千条一样。滚动的时候根据当前的 scrollTop 反推出”现在该显示第几条到第几条”,用那部分数据去替换渲染内容,同时调整渲染内容相对占位元素的位置。

用户看到的效果是”内容一直在动”,实际上数据库里只渲染了三十条,剩下的两千九百七十条都是空气。

HTML 结构长什么样

<view class="chat-scroll">
  <scroll-view
    scroll-y
    :style="{ height: scrollHeight + 'px' }"
    @scroll="onScroll"
    :scroll-top="scrollTop"
    :scroll-with-animation="false"
  >
    <!-- 撑起总高度的占位 -->
    <view class="phantom" :style="{ height: totalHeight + 'px' }">

      <!-- 实际渲染的可视区内容 -->
      <view
        class="viewport"
        :style="{ transform: 'translateY(' + startOffset + 'px)' }"
      >
        <view
          v-for="(item, idx) in visibleItems"
          :key="item.id"
          class="message"
        >
          <!-- 消息内容 -->
        </view>
      </view>

    </view>
  </scroll-view>
</view>

三层结构,得说一下每一层在干什么。

最外层是 scroll-view,负责滚动。它的高度必须是固定值,否则在小程序里根本不滚动——这个坑我会在后面展开。

第二层是 .phantom,它本身不渲染任何内容,只是通过设置 height 让滚动条达到”三千条数据应该有的总高度”。

第三层是 .viewport,用 translateY 把它放到当前滚动位置对应的偏移上。里面用 v-for 渲染 visibleItems,也就是实际可见的那部分数据。

关键在于这个 transform 而不是 top。transform 走的是 GPU 加速路径,只影响合成层,不触发重排。用 top 的话每次滚动都在改一个会触发重排的属性,性能会掉一个档次。

固定行高版本实现

假设每一行的高度是固定的(比如每条消息 80 像素),计算逻辑非常简单。

<script setup>
import { computed, ref } from 'vue'

const props = defineProps({
  items: { type: Array, required: true },
  itemHeight: { type: Number, default: 80 },
  buffer: { type: Number, default: 5 }   // 上下各多渲染几条
})

const scrollTop = ref(0)
const scrollHeight = ref(600)   // 实际使用时从父级拿

const totalHeight = computed(() => props.items.length * props.itemHeight)

const startIndex = computed(() => {
  const raw = Math.floor(scrollTop.value / props.itemHeight)
  return Math.max(0, raw - props.buffer)
})

const endIndex = computed(() => {
  const visibleCount = Math.ceil(scrollHeight.value / props.itemHeight)
  const raw = startIndex.value + visibleCount + props.buffer * 2
  return Math.min(props.items.length, raw)
})

const visibleItems = computed(() =>
  props.items.slice(startIndex.value, endIndex.value)
)

const startOffset = computed(() => startIndex.value * props.itemHeight)

function onScroll(e) {
  scrollTop.value = e.detail.scrollTop
}
</script>

逻辑一句话能说清:当前 scrollTop 除以每条高度,得到最顶上那条的索引,再往上多减 buffer 条;往下多加上可见条数再加一倍 buffer。渲染的每一块都套在翻译过的容器里,视觉位置就对齐了。

这个 buffer 是必须的。不加的话,用户滑动时新的数据还没渲染出来,那块就是白的。加了 5 条缓冲区,用户滑过去的时候那条已经在屏幕上方准备着了,看不到任何闪烁。

但是聊天记录的行高不固定

真实场景下每条消息高度差别很大——”好的”两个字一行,一大段话能占七八行。固定行高不成立。

动态行高有几种处理思路。最直接也最靠谱的是:先渲染一批,测量它们的真实高度,缓存起来,再用缓存的高度作为后续计算的依据。

难点在于第一次到底该渲染多少条。这没法提前知道——不渲染就没法测量。常用的办法是首屏先渲染一个保守估计的数量(比如 15 条),等它们渲染完成、测量出真实高度之后再更新总高度。

<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'

const props = defineProps({
  items: { type: Array, required: true },
  estimatedHeight: { type: Number, default: 80 },
  buffer: { type: Number, default: 8 }
})

const scrollTop = ref(0)
const scrollHeight = ref(600)

// 已经测量过的高度,key 是 item.id
const measuredHeights = ref({})

// 需要渲染时的起点和终点
const startIndex = ref(0)
const endIndex = ref(20)

// 用缓存的高度算总高度,没测过的用估算值
const totalHeight = computed(() => {
  let sum = 0
  for (let i = 0; i < props.items.length; i++) {
    const id = props.items[i].id
    sum += measuredHeights.value[id] || props.estimatedHeight
  }
  return sum
})

// 每条数据距离顶部的高度
const offsets = computed(() => {
  const arr = new Array(props.items.length)
  let sum = 0
  for (let i = 0; i < props.items.length; i++) {
    arr[i] = sum
    const id = props.items[i].id
    sum += measuredHeights.value[id] || props.estimatedHeight
  }
  return arr
})

// 二分查找:找到 scrollTop 对应的数据索引
function findStartIndex(y) {
  const list = offsets.value
  let lo = 0
  let hi = list.length - 1

  while (lo < hi) {
    const mid = Math.floor((lo + hi + 1) / 2)
    if (list[mid] <= y) lo = mid
    else hi = mid - 1
  }

  return lo
}

function recalculate() {
  const start = Math.max(0, findStartIndex(scrollTop.value) - props.buffer)
  // 用估算的最大高度算一个安全的结束位置
  const visibleCount = Math.ceil(scrollHeight.value / props.estimatedHeight)
  const end = Math.min(props.items.length, start + visibleCount + props.buffer * 2)

  startIndex.value = start
  endIndex.value = end
}

const visibleItems = computed(() =>
  props.items.slice(startIndex.value, endIndex.value)
)

const startOffset = computed(() =>
  startIndex.value === 0 ? 0 : offsets.value[startIndex.value]
)

// 测量已渲染的项
function measure(ids) {
  let changed = false

  // 这里是 uni-app 的写法,用 uni.createSelectorQuery 拿高度
  const query = uni.createSelectorQuery().in(instance)
  ids.forEach((id) => {
    query.select('#msg-' + id).boundingClientRect()
  })
  query.exec((res) => {
    res.forEach((rect, i) => {
      if (!rect) return
      const id = ids[i]
      if (measuredHeights.value[id] !== rect.height) {
        measuredHeights.value[id] = rect.height
        changed = true
      }
    })
    if (changed) {
      // 高度变化会改变整体布局,重新计算索引
      recalculate()
    }
  })
}

let rafId = null
function onScroll(e) {
  scrollTop.value = e.detail.scrollTop

  if (rafId) return
  rafId = requestAnimationFrame(() => {
    rafId = null
    recalculate()
  })
}
</script>

这套代码比固定行高版本复杂不少,核心处理的是”如何用测量出来的高度替换掉估算的高度,同时保证滚动位置不跳变”。

二分查找是关键。因为缓存的高度随时在变,如果每个 item 都拿平均高度算,累计误差会越来越大,滚到后半段时显示的内容可能完全对不上。用前缀和数组 offsets,配合二分查找 O(log n) 定位到当前 scrollTop 应该对应哪一条数据,这条路径是稳定的。

但每次任何一条数据的高度变化,offsets 都要重算一遍。三千条数据的话,一次重算大约几毫秒,配合 requestAnimationFrame 节流,肉眼几乎看不出卡顿。

如果想再优化,可以只在滚动停止的时候测量,而不是每次滚动都测——因为滚动中测量本来就不准。

踩过的几个坑

scroll-view 必须有固定高度

这个坑耗了我半天。scroll-view 在小程序和 App 端的滚动能力依赖父容器或自身有明确的高度。如果只写 scroll-y 不写高度,它会撑到跟内容一样高——也就是三千条数据那么高,然后整个页面滚动,滚动条也不受 scroll-view 控制,@scroll 事件压根不会触发。

正确姿势是拿到屏幕可视高度(用 uni.getSystemInfoSync().windowHeight),减掉顶部导航和底部导航占据的高度,再把这个数值传给 scroll-view:

const sysInfo = uni.getSystemInfoSync()
// 假设页面顶部有一个 44px 的标题栏
scrollHeight.value = sysInfo.windowHeight - 44 - safeBottom

那个 safeBottom 是给 iPhone 全面屏底部预留的安全区。别忘了减,不然 iOS 上最后一条消息会被底部小黑条盖住。

不要双向绑定 scroll-top

我第一版写成了这样:

<scroll-view :scroll-top="scrollTop" @scroll="scrollTop = $event.detail.scrollTop">

看起来是双向绑定,实际上会进入一个危险的循环——用户滚动触发 scroll,scroll 改变 scrollTop,scrollTop 作为 prop 传回 scroll-view,scroll-view 又被”命令”滚到那个位置,触发新的 scroll……在 App 端会看到明显的抖动,小程序端偶尔会死循环。

scroll-top 这个 prop 是用来”命令”滚动的,不是用来同步当前滚动位置的。正确的用法是只在需要主动跳转(比如点了”回到最新一条”)的时候更新它,日常滚动时不要去改。

const scrollTop = ref(0)
const currentScrollTop = ref(0)

function onScroll(e) {
  currentScrollTop.value = e.detail.scrollTop   // 内部状态
}

function goToTop() {
  scrollTop.value = 0   // 只在命令式跳转时改
  // 有个小细节:值没变的时候不会触发,所以一般写
  // scrollTop.value = scrollTop.value === 0 ? -1 : 0,下帧再设回来
}

那个小细节也得注意。scroll-top 的值如果跟上一次一样,小程序不会触发滚动。所以常见的做法是先把它设成一个不同的值(比如 -1),下一帧再设成目标值。写起来丑,但能跑。

快速滑动时的白屏

安卓机上快速滑动的时候,会看到明显的一整片白。这是两个因素叠在一起的结果。

一个是 @scroll 事件本身有延迟。安卓上快速滑动时事件大概是 16ms 触发一次,但手指位移已经很大了。当 scrollTop 跳到新位置、还没渲染出对应内容的时候,白屏就出现了。

另一个是 setData 的异步性。视图层和逻辑层通信本来就是异步的,再快也会有一个轮询周期的延迟。

缓解办法是把 buffer 调大一些。我给的是 8,实测在安卓上还想再刷 5 条缓冲。代价是渲染的条数从 20 条涨到 30 条,这个量级的增量对性能几乎没有影响。

另一个办法是用 scroll-view 的 enhanced 属性(小程序端可用),开启之后滚动事件触发更频繁。但注意这个属性不是所有平台都支持,加之前先查一下兼容表。

setData 太频繁的问题

在 onScroll 里直接改 startIndex 和 endIndex,如果每秒滚动事件触发 60 次,那 setData 也触发 60 次。虽然每次传的数据量很小(只有索引变了),但通信本身的开销累积起来也很可观。

我的处理是用 requestAnimationFrame 节流——不管 @scroll 触发多频繁,每帧最多执行一次重算。这个改动把 setData 的频率稳定在每秒 60 次以内,同时因为只在帧边界处理,视图更新和屏幕刷新能对上,滑动看上去更连贯。

再进一步的做法是做一个阈值:只有 startIndex 变化超过 3 条的时候才真正更新。因为一两条的差异用户其实看不出来,而减少 setData 的频率对低端安卓的提升是立竿见影的。

列表锚定问题

还有一个非常难受的现象:你在列表中间看消息,然后往上滑看历史记录,同时后台推来了一条新消息,插入到了列表末尾。这时候如果直接 items.push(),滚动位置会跳一下——因为后面的内容变了,用户当前看到的那条消息位置也跟着变了。

解决办法是记录用户当前看到的”锚点”消息,在数据更新之后,重新把滚动位置调整到这条消息上。听起来复杂,但实现起来就几行:

function insertNewMessage(msg) {
  const anchorId = visibleItems.value[0]?.id
  props.items.push(msg)

  nextTick(() => {
    if (!anchorId) return
    const anchorIndex = props.items.findIndex(i => i.id === anchorId)
    if (anchorIndex < 0) return
    scrollTop.value = offsets.value[anchorIndex]
  })
}

聊天这种场景不一定需要这个,因为用户一般在底部看最新消息,往上翻历史的时候很少会被新消息打断。但它在”信息流”这种下拉刷新的场景里是刚需。

完整的组件

把固定行高和动态行高合成一个组件,通过 itemHeight 是否传值来决定用哪种模式。这样既能覆盖聊天这种动态高度,也能覆盖商品列表这种固定高度。

<template>
  <scroll-view
    scroll-y
    :style="{ height: scrollHeight + 'px' }"
    :scroll-top="scrollTop"
    :scroll-with-animation="false"
    @scroll="onScroll"
  >
    <view class="phantom" :style="{ height: totalHeight + 'px' }">
      <view
        class="viewport"
        :style="{ transform: 'translateY(' + startOffset + 'px)' }"
      >
        <view
          v-for="item in visibleItems"
          :key="item.id"
          :id="'msg-' + item.id"
          class="item"
        >
          <slot :item="item" />
        </view>
      </view>
    </view>
  </scroll-view>
</template>

<script setup>
import {
  computed, getCurrentInstance, nextTick, onMounted, ref, watch
} from 'vue'

const props = defineProps({
  items: { type: Array, required: true },
  scrollHeight: { type: Number, default: 600 },
  itemHeight: { type: Number, default: 0 },   // 0 表示动态高度
  estimatedHeight: { type: Number, default: 80 },
  buffer: { type: Number, default: 8 }
})

const instance = getCurrentInstance()
const scrollTop = ref(0)
const currentScrollTop = ref(0)

const measuredHeights = ref({})
const startIndex = ref(0)
const endIndex = ref(20)

const isFixedHeight = computed(() => props.itemHeight > 0)

const totalHeight = computed(() => {
  if (isFixedHeight.value) {
    return props.items.length * props.itemHeight
  }
  let sum = 0
  for (const item of props.items) {
    sum += measuredHeights.value[item.id] || props.estimatedHeight
  }
  return sum
})

const offsets = computed(() => {
  const arr = new Array(props.items.length)
  let sum = 0
  for (let i = 0; i < props.items.length; i++) {
    arr[i] = sum
    const item = props.items[i]
    const h = isFixedHeight.value
      ? props.itemHeight
      : (measuredHeights.value[item.id] || props.estimatedHeight)
    sum += h
  }
  return arr
})

function findStartIndex(y) {
  if (isFixedHeight.value) {
    return Math.floor(y / props.itemHeight)
  }

  const list = offsets.value
  let lo = 0
  let hi = list.length - 1

  while (lo < hi) {
    const mid = Math.floor((lo + hi + 1) / 2)
    if (list[mid] <= y) lo = mid
    else hi = mid - 1
  }
  return lo
}

function recalculate() {
  if (!props.items.length) {
    startIndex.value = 0
    endIndex.value = 0
    return
  }

  const start = Math.max(0, findStartIndex(currentScrollTop.value) - props.buffer)
  const unitHeight = isFixedHeight.value ? props.itemHeight : props.estimatedHeight
  const visibleCount = Math.ceil(props.scrollHeight / unitHeight)
  const end = Math.min(props.items.length, start + visibleCount + props.buffer * 2)

  startIndex.value = start
  endIndex.value = end
}

const visibleItems = computed(() =>
  props.items.slice(startIndex.value, endIndex.value)
)

const startOffset = computed(() => {
  if (startIndex.value === 0) return 0
  return offsets.value[startIndex.value] || 0
})

let rafId = null
function onScroll(e) {
  currentScrollTop.value = e.detail.scrollTop
  if (rafId) return
  rafId = requestAnimationFrame(() => {
    rafId = null
    recalculate()
  })
}

function measureVisible() {
  if (isFixedHeight.value) return

  const ids = visibleItems.value.map(it => it.id)
  if (!ids.length) return

  const query = uni.createSelectorQuery().in(instance.proxy)
  ids.forEach((id) => {
    query.select('#msg-' + id).boundingClientRect()
  })
  query.exec((res) => {
    let changed = false
    res.forEach((rect, i) => {
      if (!rect) return
      const id = ids[i]
      if (measuredHeights.value[id] !== rect.height) {
        measuredHeights.value[id] = rect.height
        changed = true
      }
    })
    if (changed) recalculate()
  })
}

watch(
  () => visibleItems.value.length,
  async () => {
    await nextTick()
    measureVisible()
  }
)

onMounted(() => {
  recalculate()
})

function scrollToTop() {
  scrollTop.value = scrollTop.value === 0 ? -1 : 0
  nextTick(() => { scrollTop.value = 0 })
}

defineExpose({ scrollToTop })
</script>

用法:

<virtual-list
  :items="messages"
  :scroll-height="scrollHeight"
  :estimated-height="80"
>
  <template #default="{ item }">
    <chat-message :data="item" />
  </template>
</virtual-list>

动态高度的时候,注意消息列表里如果有图片、长文本,会让测量变得不准——图片加载完成之后高度才会真正确定。这种情况下需要监听图片的 load 事件,重新触发一次测量。

给图片设置固定的宽高比(或者用 aspect-ratio)能绕过这个问题——图片在加载之前就占好了位置,测量结果一直是稳定的。这是电商列表和聊天界面里一个通用的做法。

最后说一点

虚拟滚动不是银弹。如果列表本来就只有一两百条数据,全量渲染其实没有明显的问题,硬上虚拟滚动反而增加了复杂度。判断标准很简单——列表是不是真的”永远用不到全部数据”,只有屏幕上的那几条会被用户实际看到。

另外,这个实现没有做”分组渲染”和”粘性标题”(比如按日期分组的聊天记录)。要做的话,思路是把 items 结构从一维数组改成”分组 + 组内条目”的两级结构,虚拟滚动的时候把分组头当作普通条目处理。代码会长一些,但核心逻辑一样。

三千条数据从三秒白屏到两百毫秒,从掉帧到顺滑——这个优化值得,但也不是每个列表都值得。写之前先想想你的真实数据量,几百条的时候还是省点事吧。

uni-app 长列表虚拟滚动实战:从三千条聊天记录的卡顿讲起
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 长列表虚拟滚动实战:从三千条聊天记录的卡顿讲起 https://www.taomawang.com/web/uniapp/2935.html

常见问题

相关文章

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

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