小程序虚拟列表与无限下拉实践

高性能 Taro 小程序虚拟列表与无限下拉实践

虚拟列表是前端开发中经典的性能优化手段,而在 微信小程序 + Taro 环境中,我们需要结合小程序的运行机制,解决以下实际问题:

  • 请求频繁触发:用时间戳控制请求间隔
  • 触底白屏:通过 提前加载 替代骨架屏
  • 闭包陷阱:用 useRef 保持状态最新
  • 动态容器:用 SelectorQuery 自适应高度
  • 滚动体验:隐藏滚动条,保持 UI 简洁

这套方案适用于 聊天记录、历史消息、商品列表 等场景,能显著提升渲染性能和用户体验。


一、虚拟列表原理

虚拟列表的核心思想:

只渲染可视区域内的列表元素,而不是整个列表

为什么需要虚拟列表?

  1. 性能瓶颈:大量 DOM 节点渲染会显著降低页面性能
  2. 滚动流畅度:减少节点数量,提高滚动响应速度

实现方法

  1. 计算可视区域:根据容器高度和单项高度,计算当前可见索引范围
  2. 绝对定位元素 :通过 position: absolute 保持列表整体布局,同时只渲染可视区元素
  3. 预渲染边界元素 :使用 overscan 防止快速滚动时出现空白
ini 复制代码
const visibleCount = Math.ceil(containerHeight / itemHeight);
const start = Math.floor(scrollTop / itemHeight) - overscan;
const end = start + visibleCount + overscan * 2;

二、主要解决的问题

在实际工程中,除了基础的虚拟列表逻辑,还要考虑小程序环境的种种限制。我在实现时,针对几个常见痛点做了额外处理:

1. 请求间隔过小的问题

ini 复制代码
if (onScrollToLower && scrollHeight - viewHeight < 250 && now - lastLoadTimeRef.current > 100) {
  onScrollToLower();
  lastLoadTimeRef.current = now;
}
  • 问题背景 :小程序中 onScroll 事件触发非常频繁,如果每次触发都去请求接口,会出现 瞬时并发请求,浪费资源甚至导致接口异常。
  • 解决思路 :用时间戳做请求间隔控制 (now - lastLoadTimeRef.current > 100),确保短时间内只触发一次加载。

👉 实际上这是一种 节流(throttle)思路,能有效避免接口雪崩。


2. 提前加载,替代骨架屏

在代码中我使用了:

复制代码
scrollHeight - viewHeight < 250
  • 问题背景:如果等用户滑到底部时才触发加载,会出现短暂的空白区,需要骨架屏填充。
  • 解决思路 :通过调大这个阈值(例如 250 → 500),提前发起请求。这样在用户到达底部之前,新数据就已经准备好,不再需要骨架屏。

👉 核心原理 :用"提前量"来平滑用户体验,本质是 空间换时间


3. 闭包陷阱问题

ini 复制代码
const loadingRef = useRef(false);
const pageRef = useRef(1);
const hasMoreRef = useRef(true);
  • 问题背景 :在 React/Taro 函数组件里,useState 的值如果直接在异步函数中使用,可能会被"锁死"在某个旧值(典型闭包陷阱)。
  • 解决思路 :使用 useRef 存储这些"需要跨渲染周期保持最新值的状态"(例如:是否正在加载、分页页码、是否还有更多数据)。

4. 动态容器高度

scss 复制代码
Taro.createSelectorQuery()
  .select(".drawer-body")
  .boundingClientRect((rect) => {
    if (rect) setContainerHeight(rect.height);
  })
  .exec();
  • 通过 SelectorQuery 动态获取容器高度,保证虚拟列表在抽屉、半屏等场景下正常工作。

5. 隐藏滚动条的用户体验

ini 复制代码
<ScrollView className="hide-scrollbar" />
  • 小程序默认滚动条在视觉上比较突兀,隐藏后体验更自然。

三、虚拟列表 + 无限下拉流程图

css 复制代码
[ScrollView] ──监听onScroll──▶ [Scroll处理模块]
                                      │
                                      ▼
                               [虚拟列表渲染模块]
                                      │
                                      ▼
                            [触发加载更多逻辑]
                                      │
                                      ▼
                             [异步接口请求模块]
                                      │
                                      ▼
                           [列表数据更新模块]
                                      │
                                      └───回到──▶ [虚拟列表渲染模块]
  • 用户滑动触发滚动事件
  • 判断是否接近底部
  • 判断距离上次请求时间间隔
  • 满足条件则发起接口请求
  • 数据返回后追加到列表并重新渲染

四、待优化方向

  1. 异步请求合并:避免多次重复请求,可以在滚动结束时批量请求。
  2. 图片懒加载:减少首屏渲染压力。
  3. 可变高度支持:适配复杂列表场景。
  4. 骨架屏/占位优化:虽然提前加载能替代,但在弱网环境下仍可能需要备用骨架屏。
  5. 性能监控:接入 FPS、内存占用等埋点,做进一步优化。

五、完整示例

ini 复制代码
import React, { useState, useRef, useCallback, useEffect } from "react";
import { ScrollView, View } from "@tarojs/components";

interface VirtualListProps<T> {
  list: T[];
  height: number; // 容器高度
  itemHeight: number; // 每项固定高度
  overscan?: number; // 预渲染数量
  renderItem: (item: T, index: number) => React.ReactNode;
  scrollTop: number;
  onScroll: (scrollTop: number) => void;
  onScrollToLower?: () => void;
}

function clamp(num: number, min: number, max: number) {
  return Math.max(min, Math.min(num, max));
}

export default function VirtualList<T>({
  list,
  height,
  itemHeight,
  overscan = 3,
  renderItem,
  scrollTop,
  onScroll,
  onScrollToLower,
}: VirtualListProps<T>) {
  const [range, setRange] = useState({ start: 0, end: 10 });
  const ticking = useRef(false);
  const lastLoadTimeRef = useRef(0);
  // 计算可视区范围
  useEffect(() => {
    const visibleCount = Math.ceil(height / itemHeight);
    const newStart = clamp(
      Math.floor(scrollTop / itemHeight) - overscan,
      0,
      list.length - 1
    );
    const newEnd = clamp(
      newStart + visibleCount + overscan * 2 - 1,
      0,
      list.length - 1
    );
    setRange({ start: newStart, end: newEnd });
  }, [scrollTop, list.length, height, itemHeight, overscan]);

  const handleScroll = useCallback(
    (e) => {
      const top = e.detail.scrollTop;
      const scrollHeight = e.detail.scrollHeight;
      const viewHeight = top + height;
      const now = Date.now();
      // 滑到底触发加载更多

      if (
        onScrollToLower &&
        scrollHeight - viewHeight < 250 &&
        now - lastLoadTimeRef.current > 100
      ) {
        // console.log(now - lastLoadTimeRef.current,'now')
        onScrollToLower();
        lastLoadTimeRef.current = now;
      }

      if (!ticking.current) {
        ticking.current = true;
        requestAnimationFrame(() => {
          onScroll(top);
          ticking.current = false;
        });
      }
    },
    [height, onScroll, onScrollToLower]
  );

  const items: React.ReactNode[] = [];
  for (let i = range.start; i <= range.end; i++) {
    const item = list[i];
    if (item !== undefined) {
      items.push(
        <View
          key={i}
          style={{
            position: "absolute",
            top: `${i * itemHeight}px`,
            height: `${itemHeight}px`,
            left: 0,
            right: 0,
          }}
        >
          {renderItem(item, i)}
        </View>
      );
    }
  }

  return (
    <ScrollView
      scrollY
      scrollTop={scrollTop}
      style={{ height: `${height}px` }}
      onScroll={handleScroll}
      className="hide-scrollbar"
    >
      <View
        style={{
          height: `${list.length * itemHeight}px`,
          position: "relative",
        }}
      >
        {items}
      </View>
    </ScrollView>
  );
}

function List() {
  const [list, setList] = useState<string[]>([]);
  const [scrollTop, setScrollTop] = useState(0);
  const [containerHeight, setContainerHeight] = useState(500);
  const loadingRef = useRef(false);
  const pageRef = useRef(1);
  const hasMoreRef = useRef(true);
  const pageSize = 50;
  const itemHeight = 86;

  // 获取父元素高度
  useEffect(() => {
    const query = Taro.createSelectorQuery();
    query
      .select(".drawer-body")
      .boundingClientRect((rect) => {
        if (rect) {
          setContainerHeight(rect.height);
        }
      })
      .exec();
  }, []);

  // 初次加载
  useEffect(() => {
    loadMore();
  }, []);

  // 加载更多
  const loadMore = useCallback(async () => {
    if (loadingRef.current || !hasMoreRef.current) return;
    loadingRef.current = true;
    const data = await getTitleList({
      size: pageSize,
      last_seq_id: pageRef.current,
    });
    if (!data.has_more) {
      hasMoreRef.current = false;
    } else {
      setList((prev) => [...prev, ...data.sessions]); // 追加数据
      pageRef.current = pageRef.current + 1;
    }
    loadingRef.current = false;
  }, []);

  // const debouncedLoadMore = useCallback(
  //   debounce(() => {
  //     loadMore();
  //   }, 50), // 等待 50ms 后才触发   感觉不是很有作用
  //   []
  // );

  const handleScroll = (top: number) => {
    setScrollTop(top);
  };

  const longTap = (session_id) => {
    Taro.showActionSheet({
      itemList: ["删除会话"],
      success() {
        delChat({ session_id }).then(() => {
          let newHis = [...list];
          newHis = newHis.filter((item, i) => item.session_id !== session_id);
          setList([...newHis]);
        });
      },
    });
  };

  const url =
    "https://img-ys011.didistatic.com/static/picplace_app_imgs/meijia_beijing.JPG";
  const RenderItem = (item, index) => (
    <View
      className="history-item"
      hover-class="btn-hover"
      hover-start-time="80"
      hover-stay-time="0"
      hover-stop-propagation="true"
      onClick={() => {
        smartNavigateTo(`/pages/chat/index?session_id=${item.session_id}`);
      }}
      onLongPress={() => {
        longTap(item.session_id);
      }}
    >
      <View
        className="history-item-img"
        style={{
          background: `url(${url}) no-repeat center/contain`,
        }}
      ></View>
      <View className="history-item-title">{item.title}</View>
      <View className="history-item-date">{formatDate(item.updated_at)}</View>
    </View>
  );

  return (
    <>
      <VirtualList
        list={list}
        height={containerHeight}
        itemHeight={itemHeight}
        scrollTop={scrollTop}
        onScroll={handleScroll}
        onScrollToLower={loadMore}
        renderItem={RenderItem}
      />
    </>
  );
}
相关推荐
Nan_Shu_6141 天前
学习: Threejs (2)
前端·javascript·学习
G_G#1 天前
纯前端js插件实现同一浏览器控制只允许打开一个标签,处理session变更问题
前端·javascript·浏览器标签页通信·只允许一个标签页
@大迁世界1 天前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路1 天前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug1 天前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu121381 天前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中1 天前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路1 天前
GDAL 实现矢量合并
前端
hxjhnct1 天前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子1 天前
从入门到实践:前端 Monorepo 工程化实战(4)
前端