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

高性能 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}
      />
    </>
  );
}
相关推荐
谢小飞4 分钟前
Echarts高级柱状图开发:渐变与3D效果实现
前端·echarts
FogLetter7 分钟前
Vite vs Webpack:前端构建工具的双雄对决
前端·面试·vite
tianchang9 分钟前
JS 排序神器 sort 的正确打开方式
前端·javascript·算法
怪可爱的地球人12 分钟前
ts的类型兼容性
前端
方圆fy19 分钟前
探秘Object.prototype.toString(): 揭开 JavaScript 深层数据类型的神秘面纱
前端
FliPPeDround22 分钟前
🚀 定义即路由:definePage宏如何让uni-app路由配置原地起飞?
前端·vue.js·uni-app
怪可爱的地球人23 分钟前
ts的类型推论
前端
林太白30 分钟前
动态角色权限和动态权限到底API是怎么做的你懂了吗
前端·后端·node.js
每一天,每一步34 分钟前
React页面使用ant design Spin加载遮罩指示符自定义成进度条的形式
前端·react.js·前端框架
moyu841 小时前
Pinia 状态管理:现代 Vue 应用的优雅解决方案
前端