上个月接了个客服系统的活,功能不复杂:把用户和历史对话记录读出来展示,按时间顺序排列。数据从后端 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 结构从一维数组改成”分组 + 组内条目”的两级结构,虚拟滚动的时候把分组头当作普通条目处理。代码会长一些,但核心逻辑一样。
三千条数据从三秒白屏到两百毫秒,从掉帧到顺滑——这个优化值得,但也不是每个列表都值得。写之前先想想你的真实数据量,几百条的时候还是省点事吧。

