uni-app 长列表虚拟滚动实战:干掉小程序 setData 的卡顿

2026-10-02 0 1,006

通讯录页面,两万条联系人。用 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」这种操作——虽然读写本身很快,但它触发的是跨线程通信。

虚拟列表这套东西写熟了之后,很多场景都能复用:商品列表、聊天记录、日志展示、图库。核心就是「别渲染看不见的东西」,剩下的都是围绕这个原则的工程化处理。

uni-app 长列表虚拟滚动实战:干掉小程序 setData 的卡顿
收藏 (0) 打赏

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

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

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

淘吗网 uniapp uni-app 长列表虚拟滚动实战:干掉小程序 setData 的卡顿 https://www.taomawang.com/web/uniapp/2852.html

常见问题

相关文章

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

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