通讯录页面,两万条联系人。用 v-for 直接渲染,真机跑起来第一屏就要两三秒,滑动的时候手指跟页面基本不同步,快滑几下还能触发内存告警。开发机上看着还行,一到中低端安卓就原形毕露。
这不是 uni-app 的问题,是所有小程序平台的通病。你塞给渲染层两万个节点,它就得老老实实算两万次布局。想解决只有一个办法:别渲染那么多。
这篇文章就把这个通讯录从头改到能用。中间会碰到不定高、滚动位置、上拉加载几个硬骨头,逐个啃。
先量一量,卡在哪儿
优化之前先确认瓶颈。很多人一看到卡就以为是渲染慢,其实是 setData 的数据量太大。
// 在 app 里安一个简单的计时
const start = Date.now();
this.list = bigArray;
this.$nextTick(() => {
console.log('更新耗时', Date.now() - start, 'ms');
});
如果耗时超过 200ms,说明卡在 setData 上。如果耗时很短但滑动还是卡,那就是节点太多了,渲染层压力大。两种情况虚拟列表都能解决,但要先确认是不是这个方向。
还有个更细的检查方法,在微信开发者工具的「调试器 – Performance」里录一段滑动,看 Scripting 和 Rendering 两块的时间占比。Scripting 高就是 JS 端的问题,Rendering 高就是节点多。大多数长列表卡顿,两块都高。
最朴素的方案:分页加载
很多人的第一反应是分页。先渲染 20 条,滑到底再加载 20 条。这确实能解决首屏慢的问题,而且实现简单。
但它没解决「用户一路滑到底部」之后的场景。假设用户加载到了 500 条,往上滑看历史记录,页面还是要维护 500 个节点。到了 2000 条以上,微信小程序的滚动性能就开始肉眼可见地下降了。
分页可以作为虚拟列表的补充,但不能替代它。虚拟列表的核心是:不管数据有多少,DOM 里始终只保留可视区的那几十个。
虚拟列表的基本思路
假设每项高度固定为 60px,屏幕能显示 800px 高度。那么可视区最多能放下 14 项。再上下各预渲染 5 项作为缓冲,总共 24 个节点就够。
做法是把列表包在一个高度为 总项数 × 项高 的容器里,然后用一个 transform 把可视区对应的那一段内容「挪」到正确的位置。
<template>
<scroll-view
scroll-y
:style="{ height: viewportHeight + 'px' }"
@scroll="onScroll"
>
<view :style="{ height: totalHeight + 'px', position: 'relative' }">
<view
:style="{ transform: 'translateY(' + offsetY + 'px)' }"
>
<view v-for="item in visibleList" :key="item.id">
{{ item.name }}
</view>
</view>
</view>
</scroll-view>
</template>
三个变量:totalHeight 是撑起滚动条的总高度,offsetY 是可视区内容的起始偏移,visibleList 是当前要渲染的那一段数据。
JS 部分的逻辑:
const ITEM_HEIGHT = 60;
const BUFFER = 5;
export default {
data() {
return {
list: [],
visibleList: [],
offsetY: 0,
totalHeight: 0,
viewportHeight: 0,
startIndex: 0
};
},
methods: {
initViewport() {
const sys = uni.getSystemInfoSync();
// 减去导航栏等占位,具体数值按项目调整
this.viewportHeight = sys.windowHeight - 100;
},
recalc() {
const start = Math.max(0, this.startIndex - BUFFER);
const visibleCount = Math.ceil(this.viewportHeight / ITEM_HEIGHT);
const end = Math.min(this.list.length, start + visibleCount + BUFFER * 2);
this.visibleList = this.list.slice(start, end).map((item, idx) => ({
...item,
_index: start + idx
}));
this.offsetY = start * ITEM_HEIGHT;
this.totalHeight = this.list.length * ITEM_HEIGHT;
},
onScroll(e) {
const scrollTop = e.detail.scrollTop;
const newStart = Math.floor(scrollTop / ITEM_HEIGHT);
if (newStart === this.startIndex) return;
this.startIndex = newStart;
this.recalc();
}
}
};
这段代码已经能跑了,但有两个明显的缺陷:一是每项高度必须固定,二是每次滚动都会触发 setData,频率太高。先把这两个处理掉,再回来做不定高。
降低触发频率
滚动事件里立刻 setData 是大忌。手指滑动过程中每秒能触发几十次 scroll,每次都更新一遍 visibleList,等于把卡顿从一个地方搬到了另一个地方。
做法是「先算再攒」。滚动事件里只记录目标位置,用 $nextTick 或者 setTimeout 合并成一个批量更新。或者更彻底一点,只有「跨过一整项」的时候才真正触发重算。
onScroll(e) {
const scrollTop = e.detail.scrollTop;
const newStart = Math.floor(scrollTop / ITEM_HEIGHT);
// 只在整数项边界变化时才触发更新
if (newStart === this.startIndex) return;
this.startIndex = newStart;
if (this._pendingUpdate) return;
this._pendingUpdate = true;
// 用微任务批量,避免每秒更新几十次
Promise.resolve().then(() => {
this._pendingUpdate = false;
this.recalc();
});
}
理论上还可以用 Page 的 onPageScroll 之类的 API 来做节流,但滚动事件本身已经是高频的了,用微任务合并是最稳的。真实测试下来,每秒的 setData 次数能从 60 次降到 10 次以内。
不定高项目:真实的通讯录
前面那个固定高度是理想情况。真实场景里每个项的高度可能不一样,比如有头像的、有备注的、有多行地址的。硬凑一个固定高度会很难看。
处理不定高有几种思路,简单到复杂依次是:
方案 A:动态测量。 先渲染出来,用 createSelectorQuery 拿到每一项的真实高度,存进一个高度数组,然后算偏移。问题是第一次渲染还是要全部渲染一遍才知道高度,跟优化的初衷冲突。
方案 B:预估加校正。 先用一个平均高度把列表撑起来,某个项真正进入可视区时再测量并修正。已经滚动过的部分用测量值,还没滚到的部分用估算值。这是业界用得最多的方案。
方案 C:分层预渲染。 把列表按高度分成几个 bucket,每个 bucket 内部高度固定。这样能用固定高度的算法,同时保持视觉上不呆板。实现复杂但效果稳定。
选方案 B,因为它实现成本可控,效果也够。
方案 B 的实现
核心数据结构要加一个高度缓存和位置前缀和数组。
export default {
data() {
return {
list: [],
heights: [], // 每项实测高度,未测量时为估算值
offsets: [], // 每项的 y 位置前缀和
visibleList: [],
offsetY: 0,
totalHeight: 0
};
},
methods: {
buildLayout() {
const ESTIMATE = 72;
// 先给所有项一个估算值
this.heights = this.list.map(item => item._height || ESTIMATE);
this.rebuildOffsets(this.heights);
},
rebuildOffsets(heights) {
const offsets = new Array(heights.length + 1);
offsets[0] = 0;
for (let i = 0; i < heights.length; i++) {
offsets[i + 1] = offsets[i] + heights[i];
}
this.offsets = offsets;
this.totalHeight = offsets[heights.length];
}
}
};
有了 offsets 数组,找当前可视区起点就变成了二分查找:
// 找到第一个大于等于 scrollTop 的位置
function findStart(offsets, scrollTop) {
let lo = 0;
let hi = offsets.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (offsets[mid] <= scrollTop) {
lo = mid;
} else {
hi = mid - 1;
}
}
return lo;
}
二分查找的结果是「偏移不超过 scrollTop 的最后一个索引」。反过来我们也可以找到 end,就是「偏移小于 scrollTop + viewportHeight 的最后一个索引」。
渲染部分基本不变,只是 visibleList 的 offsetY 变成了 offsets[start],不再是简单的 start * ITEM_HEIGHT。
测量与校正
渲染完成之后,用 createSelectorQuery 拿到当前这批可见项的真实高度,跟缓存里的比较。有差异的话,更新高度、重建 offsets 数组、顺带微调滚动位置。
measureVisible() {
const ids = this.visibleList.map(item => '#item-' + item.id);
const query = uni.createSelectorQuery().in(this);
ids.forEach(sel => {
query.select(sel).boundingClientRect();
});
query.exec(rects => {
let dirty = false;
rects.forEach((rect, i) => {
if (!rect) return;
const globalIdx = this.visibleList[i]._index;
const real = rect.height;
if (Math.abs(this.heights[globalIdx] - real) > 0.5) {
this.heights[globalIdx] = real;
dirty = true;
}
});
if (dirty) {
this.rebuildOffsets(this.heights);
// 因为高度变了,当前 scrollTop 对应的实际位置也会变
// 需要按最新的 offsets 修正 seek
this.adjustScrollPosition();
}
});
}
adjustScrollPosition 是这一段里最麻烦的地方。你想一下:正在滚动的时候,某个已经划过去的项被重新测量,结果发现它比估算的高 20px。这时候用户视野内的内容整体会被顶下去 20px,出现明显跳动。
解决办法是调整 startIndex 而不是调整滚动位置。因为 startIndex 是根据 scrollTop 和 offsets 算出来的,offsets 变了,重新算一遍就能让视野内容保持稳定:
adjustScrollPosition() {
const scrollTop = this.currentScrollTop || 0;
this.startIndex = findStart(this.offsets, scrollTop);
this.recalc();
}
onScroll(e) {
const scrollTop = e.detail.scrollTop;
this.currentScrollTop = scrollTop;
const newStart = findStart(this.offsets, scrollTop);
if (newStart === this.startIndex) return;
this.startIndex = newStart;
if (this._pendingUpdate) return;
this._pendingUpdate = true;
Promise.resolve().then(() => {
this._pendingUpdate = false;
this.recalc();
this.measureVisible();
});
}
这里的关键是把「滚动位置」和「渲染位置」解耦了。滚动位置由 scroll-view 自己维护,渲染位置永远是根据 offsets 和 scrollTop 现算的。哪边变了都能纠正回来,不会因为中间状态的脏数据造成跳动。
滚动锚定的边界情况
上面那套逻辑在「向下滚动」的时候能跑通。但「向上快速滚动」时会有个更细的问题:用户快速往上滑,可视区在 offsets 里的起点快速变化,而每一项的测量都是异步的。可能出现「当前可视区里有几项还没测过,用的是估算值」的情况。
这种状态本身没问题,只是会有轻微的高度漂移。等测量完成后,adjustScrollPosition 会把位置拉回来。用户能感觉到一点点细微的反馈,但不会跳。
处理更极端的场景(比如用户拖到一半放下,然后立刻反向滑),可以给 createSelectorQuery 加一个「只测量还需要测量的项」的判断,避免每次滚动都重新测量所有可见项。
const needMeasure = this.visibleList.filter(item => {
return !this._measured.has(item._index);
});
if (!needMeasure.length) return;
用 Set 记录已经测过的 index,测过就不再测。这样测量次数跟滚动距离成正比,而不是跟滚动次数成正比。
上拉加载怎么接
虚拟列表和上拉加载合起来用的时候,要特别注意「加载过程中列表长度在变,offsets 数组也要跟着变」。
async loadMore() {
if (this.loading || this.noMore) return;
this.loading = true;
try {
const newItems = await fetchContacts({
offset: this.list.length,
limit: 50
});
if (!newItems.length) {
this.noMore = true;
return;
}
// 追加新项,同时给它们一个估算高度
const ESTIMATE = 72;
this.list.push(...newItems);
for (let i = 0; i < newItems.length; i++) {
this.heights.push(ESTIMATE);
}
this.rebuildOffsets(this.heights);
} finally {
this.loading = false;
}
}
注意这里没有调用 recalc。因为滚动位置没变,可视区的内容也不需要重新渲染,只是把总高度更新了一下。等到用户滑到下面真正需要渲染新数据的时候,onScroll 里自然会触发 recalc。
触发 loadMore 的时机有两种:一是滑到底部附近就加载,二是滑到真的触底才加载。第一种体验更好,但要小心别在用户往上滑的时候也误触发。判断方式是用 scrollTop + viewportHeight >= totalHeight - 200,同时加一个「只在下滑方向才触发」的标记。
几个额外的小优化
吸顶分组。 通讯录一般按首字母分组。分组标题如果也要虚拟化,就变成了两级虚拟列表,复杂度会上升一大截。如果分组数量不多(几十个),可以不虚拟化分组标题,只在可视区范围内渲染对应几个。分组标题用一个独立的绝对定位层来做吸顶效果。
图片懒加载。 虚拟列表只解决节点数量,图片加载是另一回事。头像用 <image lazy-load>,或者干脆进入可视区再设置真实 src。
用 nvue 吗?如果项目是 App 端为主,而且列表滚动性能要求极高,可以试试 list 和 recycle-list。它们是原生渲染的,性能上限比 webview 高。代价是不能复用现有 Vue 组件,样式也受限。
用 scroll-view 还是页面滚动?虚拟列表一般推荐用 scroll-view,因为可以精确控制它的高度和滚动事件。页面滚动在这种场景下控制不住,尤其是需要「滚动位置还原」的时候。
踩过的坑
不要在 scroll-view 里放 position: fixed 元素。小程序里 fixed 是相对页面而不是相对 scroll-view 的,吸顶效果会跑到屏幕外面。用 position: sticky,或者自己在可视区顶部画一个假的标题。
boundingClientRect 拿到的应该是项容器而不是项内容。否则 padding、margin 之类的不算进去,测量结果会比真实高度小,用着用着就乱了。
offsets 数组别用普通对象模拟。有人图省事写成 {0: 0, 1: 72, 2: 144},二分查找的时候会经常拿到 undefined。老老实实用 Array。
setData 数据要精简。虚拟列表传过去的 visibleList 通常不需要原数据的所有字段。大型对象可能包含几十个字段,真正用来渲染的可能就三四个。发之前 map 一下,只留需要的。
刷新列表之后要重置所有状态。用户下拉刷新时,list、heights、offsets、_measured 都要清掉重来。忘了清 offsets 会导致新数据渲染在旧位置,画面极其诡异。
createSelectorQuery 有调用频率限制。同一帧内连续调很多次会被限制或合并。如果测量项很多,一次性把 selector 都交给 exec,不要每项都新开一个 query。
scrollTop 精度问题。有些设备上 e.detail.scrollTop 返回的是整数,有些是浮点数。二分查找的时候不用在意,但如果你有个「滚动到指定位置」的功能,浮点数精度会影响落点。可以统一四舍五入到整数再比较。
iOS 上 scroll-view 的滚动惯性。松手之后还会继续滑一段距离,这段时间 scroll 事件也在持续触发。如果不是特别在意性能,一般不用特殊处理。如果要在松手瞬间做点事,用 @scrolltoupper 之类的事件会更准。
写完之后再测一遍
改完之后记得测这几个场景:
两万条数据首屏渲染,应该控制在 200ms 以内;快速滑动到底部和顶部,观察是否有明显跳动;滑到中间禁用网络再继续滑,看看加载失败后的表现;在低端安卓机上跑一遍完整的滑动流程。
如果还有卡顿,用微信开发者工具的 Performance 一看,大概率是某个细节漏了。常见的漏点是「在 onScroll 里读了 this.list」这种操作——虽然读写本身很快,但它触发的是跨线程通信。
虚拟列表这套东西写熟了之后,很多场景都能复用:商品列表、聊天记录、日志展示、图库。核心就是「别渲染看不见的东西」,剩下的都是围绕这个原则的工程化处理。

