无限分页的不定高度虚拟列表实现

背景

项目中涉及到一个聊天页面(微信小程序),聊天页上需要呈现多种类型的消息,不同消息类型的样式不一样(高度不同);同时用户可以从上往下加载更多消息。

当前项目会将用户所有已加载的消息都渲染到页面上,这种做法在消息量少的时候没啥问题,但消息条数超过一定数量后(比如超过1000条),小程序直接崩溃了。原因是微信对每个小程序的内存使用有限制,渲染过多的内容到页面上会导致内存溢出(可以通过小程序API onMemoryWarning监控到相关的内存告警)。为此第一个想到了解决方案是用虚拟列表来优化这个问题。

这里,我们简单抽象并实现如下功能:

  1. 不定高度虚拟列表:因为不同消息类型的样式和高度不同,因此必须为不定高度的虚拟列表;
  2. 支持分页加载:项目中是从上往下滑动触发分页加载,为了简化,本文我们以从下往上触底触发分页加载;

代码

一些工具方法

在开始写虚拟列表相关代码之前,我们首先定义一些模拟的工具方法

js 复制代码
js/utils.js

const MAX_LENGTH = Infinity; // 最大消息个数
const MSG_TYPE_COUNT = 4; // 消息类型个数


/**
 * 生成一条消息对象
 * @param {*} value 
 * @returns 类型:{id: string, value: any, type: number}
 */
function createMsg(value) {
  return {
    id: `p2p_${Date.now()}_${Math.floor(Math.random() * 1000)}_${Math.floor(Math.random() * 100000000000)}`,
    value,
    type: Math.floor(Math.random() * MSG_TYPE_COUNT),
  }
}

/**
 * 自定义消息类型高度
 * @param {*} type 
 * @returns 高度值(px)
 */
function getMsgHeight(type) {
  let ret = 0;
  switch (type) {
    case 0: // 文字
      ret = 50;
      break;
    case 1: // 图片
      ret = 150;
      break;
    case 2: // 视频
      ret = 48;
      break;
    case 3: // 音频
      ret = 80;
      break;
    default: // 其他
      ret = 43;
      break;
  }
  return ret;
}

createMsg用来模拟生成一条消息对象,它有三个属性:id为消息id; value为消息体内容;type为消息类型。 getMsgHeight用来模拟每种消息类型的样式高度值。

js 复制代码
/**
 * 模拟获取分页消息
 * @param {*} page 
 * @param {*} size 
 * @returns 
 */
export function fetchData(page, size) {
  return new Promise((resolve, reject) => {
    if (page * size >= MAX_LENGTH) {
      console.log("无法加载更多数据了");
      reject();
    } else {
      console.log("加载数据中...");
      setTimeout(() => {
        const ret = [];
        for (let i = 0; i < size; i++) {
          ret.push(createMsg(page * size + i))
        }
        resolve(ret);
        console.log("数据加载完成", page, size, ret);
      }, Math.random() * 500); // 随机0~500ms
    }
  });
}

fetchData用来模拟分页加载数据API,同时模拟请求延迟为0~500ms的效果。

js 复制代码
/**
 * 判断是否滑动到底部了
 * @param {*} outerElement: 外层容器节点
 * @param {*} bottom: 滑动到离底部距离
 * @returns 
 */
export function isScrollToBottom(outerElement, bottom = 10) {
  if (outerElement.scrollTop + outerElement.clientHeight + bottom >= outerElement.scrollHeight) {
    return true;
  } else {
    return false;
  }
}

isScrollToBottom用来判断是否滑动到距离底部为bottom的距离了。

js 复制代码
/**
 * 渲染数据到页面element节点内
 * @param {*} element: 列表容器节点
 * @param {*} list: 消息列表数据
 */
export function render(element, list) {
  const fragment = document.createDocumentFragment();
  for (let i = 0; i < list.length; i++) {
    const node = document.createElement("div");
    const height = getMsgHeight(list[i].type);
    node.style.height = height + "px";
    node.style.border = `1px solid green`;
    node.id = list[i].id
    node.innerText = list[i].value;
    fragment.appendChild(node);
  }
  element.appendChild(fragment);
}

render用来实现将list中的消息列表数据,渲染到element节点内。这里我们用fragment来优化相关渲染。

模版

html 复制代码
  <head>
    <style>
      #container {
        border: 1px solid red;
        height: 300px;
        overflow: scroll;
      }
    </style>
  </head>
  <body>
    <div id="container">
      <div id="list">
      </div>
    </div>
  </body>

模版用两层div来实现虚拟列表的框架。外层div的高度我们设置为300px,且设置overflow:scroll,内层div渲染需要显示的消息流。

基本结构

js 复制代码
    import { isScrollToBottom } from './js/utils.js'
    let isLoading = false // 是否在加载中
    const outerElement = document.getElementById('container') // 外层父容器元素

    // 初始加载数据
    loadData()

    // 监听外层父容器滚动事件
    outerElement.addEventListener('scroll', (e) => {
      if(isLoading) return
      if(isScrollToBottom(outerElement, 100)) {
        loadData()
      }
      scrollHandler(e.target.scrollTop)
    })
    
    // 封装加载数据
    function loadData() {
        ...
    }
    
    // 滑动事件处理
    function scrollHandler(scrollTop) {
        ...
    }

上面的代码为虚拟列表主要的代码流程:

  1. 首先初始化获取消息loadData。
  2. 获取外层父容器节点对象,然后监听它的scroll事件。在监听scroll事件的回调中,如果当前正在加载数据loadData,则直接返回;如果滑动的距离触发了加载数据,则调用loadData; 其他情况执行具体的滑动监听响应逻辑。

封装加载数据过程

js 复制代码
    import { fetchData, render } from './js/utils.js'

    let page = 0; // 初始页数
    let size = 10; // 每页消息数量
    let loadedList = []; // 已加载的数据列表
    let loadedListMap = new Map(); // 缓存每个消息的高度信息
    let isLoading = false // 是否在加载中
    let totalLoadedHeight = 0 // loadedList所有消息如果渲染到屏幕上的总高度
    
    // 封装加载数据
    function loadData() {
      isLoading = true    
      fetchData(page, size).then(res => {
        page++;
        loadedList = loadedList.concat(res)
        render(innerElement, res);
        cacheNodeHeight(res);
      }).finally(() => {
        isLoading = false
      })
    }
    
    // 缓存每个消息节点的高度到Map中
    function cacheNodeHeight(list) {
      list.forEach(item => {
        if(!loadedListMap.has(item.id)) {
          // 从页面上获取对应ID的DOM节点的高度
          const height = document.getElementById(item.id).clientHeight
          loadedListMap.set(item.id, height)
          totalLoadedHeight += height
        }
      })
    }

loadData用于封装分页加载数据过程。数据加载成功之后,一方面合并到loadedList中,另一方面直接渲染到页面上。针对新渲染的消息,获取从页面上每条消息的高度值,缓存到Map类型中,方便后续直接获取。

scroll事件处理

js 复制代码
    import { fetchData, render, isScrollToBottom } from './js/utils.js'

    let page = 0; // 初始页数
    let size = 10; // 每页消息数量
    let loadedList = []; // 已加载的数据列表
    let loadedListMap = new Map(); // 缓存每个消息的高度信息
    let isLoading = false // 是否在加载中
    let totalLoadedHeight = 0 // loadedList所有消息如果渲染到屏幕上的总高度

    const outerElement = document.getElementById('container') // 外层父容器元素
    const innerElement = document.getElementById('list') // 列表容器元素
    const containerHeight = outerElement.clientHeight; // 外层父容器高度

    // 滑动事件处理
    function scrollHandler(scrollTop) {
      scrollTop = scrollTop < 0 ? 0 : scrollTop

      let accumulateHeight = 0; // 累计垂直偏移高度
      let startIndex = null; // 显示在container中的起始下表
      let endIndex = null; // 显示在container中的结束下标
      let paddingTop = 0; // 顶部留白高度
      let paddingBottom = 0; // 底部留白高度

      // 计算startIndex和paddingTop
      for (let i = 0; i < loadedList.length; i++) {
        const height = loadedListMap.get(loadedList[i].id)
        accumulateHeight += height;
        if (accumulateHeight > scrollTop) {
          startIndex = i;
          paddingTop = accumulateHeight - height;
          break;
        }
      }

      // 计算endIndex和paddingBottom
      for (let i = startIndex + 1; i < loadedList.length; i++) {
        const height = loadedListMap.get(loadedList[i].id)
        accumulateHeight += height;
        if (accumulateHeight >= scrollTop + containerHeight) {
          endIndex = i;
          break;
        }
      }

      // 边界情况处理
      if (endIndex === null) {
        endIndex = loadedList.length - 1;
        accumulateHeight = totalLoadedHeight;
      }

      paddingBottom = totalLoadedHeight - accumulateHeight;
      console.log(startIndex, endIndex, paddingTop, paddingBottom, scrollTop, containerHeight, totalLoadedHeight, loadedList)

      // 设置paddingTop和paddingBottom
      innerElement.setAttribute("style", "padding-top: " + paddingTop + "px; padding-bottom: " + paddingBottom + "px;");
      innerElement.innerHTML = "";
      render(innerElement, loadedList.slice(startIndex, endIndex + 1))
    }
    
  1. startIndex和endIndex分别表示真正渲染的消息下标,即loadedList中下标在[startIndex, endIndex]范围内的消息元素才会真正渲染。
  2. 因为要实现滚动条效果,因此需要在渲染消息流前后增加一些空白的padding,以此来撑大整个消息流。paddingTop和paddingBottom就是渲染的消息流上下的空白高度值。
  3. 每次滑动监听的时候,都需要重置innerElement.innerHTML = ''。

效果

可以看到在上滑过程中,消息列表的总消息数保持在一个相对稳定的水平,而不是无限增加。同时滑动触发的时候也能正常分页加载。

代码

github.com/wdskuki/js-...

相关推荐
Amd7942 分钟前
Nuxt.js 应用中的 webpack:compiled 事件钩子
前端·webpack·开发·编译·nuxt.js·事件·钩子
生椰拿铁You11 分钟前
09 —— Webpack搭建开发环境
前端·webpack·node.js
狸克先生22 分钟前
如何用AI写小说(二):Gradio 超简单的网页前端交互
前端·人工智能·chatgpt·交互
baiduopenmap37 分钟前
百度世界2024精选公开课:基于地图智能体的导航出行AI应用创新实践
前端·人工智能·百度地图
loooseFish1 小时前
小程序webview我爱死你了 小程序webview和H5通讯
前端
菜牙买菜1 小时前
让安卓也能玩出Element-Plus的表格效果
前端
请叫我欧皇i1 小时前
html本地离线引入vant和vue2(详细步骤)
开发语言·前端·javascript
533_1 小时前
[vue] 深拷贝 lodash cloneDeep
前端·javascript·vue.js
guokanglun1 小时前
空间数据存储格式GeoJSON
前端
zhang-zan2 小时前
nodejs操作selenium-webdriver
前端·javascript·selenium