📚 瀑布流的虚拟列表

·  阅读 1028
📚 瀑布流的虚拟列表

抓紧时间学习,然后睡觉 😪

前言

在工作中有时候会遇到类似这样的业务需求:无限加载列表数据、不能使用分页来加载列表数据,我们把这种列表统称为长列表

为什么要使用虚拟列表

假设页面上有 10000 条数据,不论是一次性渲染还是进行滚动浏览,都会存在白屏或者卡顿的情况,特别是在一些配置比较低的机型会更加明显。下面是完子说首页瀑布流列表的滚动录屏。

未命名.gif

可以明显看出在快速滚动的时候会出现白屏,甚至是卡顿的情况。这是因为当列表数据很大时,页面渲染的节点过多,而且这些节点里面又包含子节点,这样将会消耗巨大的性能,在小程序上还有可能因为内存不足而造成闪退。

虚拟列表就是这类问题的一个解决方案。

什么是虚拟列表

简单来说虚拟列表其实就是一种按需显示的实现。具体的做法是:只对可视区域进行渲染,对于非可视区域部分渲染或者不渲染,从而达到极高的渲染性能。

假设页面需要展示 1000 条数据,页面的可视区域的高度为500px,列表项的高度为50px,因此在页面的可视区域范围内最多只能显示 10 条数据,所以在首次渲染的时候,我们只需要渲染 10 条数据即可。

备注 2022.PNG

接下来我们分析下滚动发生时,可以通过计算当前的滚动值,获得此时在页面的可视区域应该渲染的列表项。

假如滚动发生,并且滚动值是100px,那么可以计算出在可视区域内的列表项为第3项第12项

备注 2021年8月29日.PNG

瀑布流列表的变形

回归到我的项目,完子说首页是以瀑布流的形式来展示列表的,所以结构会跟上面的列表有所不同。下面是瀑布流列表的基本结构。

备注 2021年8月29日 (2).jpg

由于瀑布流列表结构的特殊性,所以没办法沿用上面那种方式,来换一种实现思路。

一页请求 10 条数据,一条数据认为是一个文章卡片,把文章卡片按左右两列哪边的高度较小就优先插哪边(所以这种方案只适合已知数据高度的情况下),并且认为一页的数据就是一屏,那么就可以得到下面的这种结构。

备注 2021年8月29日 (2).PNG

由于每个文章卡片的高度都是不一致的,这样就呈现出错落有致的瀑布流效果。但是这里每一屏是分隔开的,所以会存在下面这种情况:

备注 2021年8月29日 (2) 2.PNG

要解决这种情况,只需要计算出前一屏的左右列的高度差,便可以得出下一屏偏移量

备注 2021年8月29日 (2) 3.PNG

实现原理

瀑布流虚拟列表的实现,实际上就是在首屏加载时,只渲染可视区域内需要的列表项,当页面滚动时,判断目标节点的内容是否进入可视区域内,如果进入了就把内容渲染出来。

这里可以使用IntersectionObserver对象,来判断目标节点是否已进入可视区域

// index.wxml
<wxs module="filter">
  var isInVisiblePages = function (visibleIndexs, current) {
    return visibleIndexs.indexOf(current) > -1
  }
  var offsetTop = function (offset) {
    return offset > 0
      ? 'top: -' + offset + 'px'
      : ''
  }
  
  module.exports = {
    isInVisiblePages: isInVisiblePages,
    offsetTop: offsetTop
  }
</wxs>

<view class="waterfull">
  <view
    class="waterfull__item"
    wx:for="{{ records }}"
    wx:key="index"
    style="height: {{ item.height }}px"
    data-index="{{ index }}">
    <block wx:if="{{ filter.isInVisiblePages(visibleIndexs, index) }}">
      <view
      	class="waterfull__item__left"
        style="{{ filter.offsetTop(item.leftOffset) }}">
        <template is="article" data="{{ data: item.leftData }}"></template>
      </view>
      <view
      	class="waterfull__item__right"
        style="{{ filter.offsetTop(item.rightOffset) }}">
        <template is="article" data="{{ data: item.rightData }}"></template>
     	</view>
   	</block>
  </view>
</view>

<template name="article">
  <view
    class="article-box"
    wx:for="{{ data }}"
    wx:for-item="article"
    wx:for-index="articleIndex"
    wx:key="articleIndex">
    <view class="article">
      <view class="article__poster">
      	<image
          class="article__poster__img"
          src="{{ article.coverImage }}"
          style="height: {{ article.realHeight }}rpx"
          mode="aspectFill" />
      </view>
      <view class="article__content">
        <view class="article__content__title">{{ article.title }}</view>
        <view class="article__content__creator">
            <image
              class="article__content__creator__avatar"
              src="{{ article.creator.avatar }}"
              mode="aspectFill" />
          <view class="article__content__creator__nickname">{{ article.creator.nickname }}</view>
        </view>
        <view
          class="article__content__topic"
          wx:if="{{ article.topic }}">
          <image
            class="article__content__topic__icon"
            src="https://pub-img.perfectdiary.com/material/image/2021/05/5d604f0b034d4c7f9b857fb0919f3ee3.png" />
          <view class="article__content__topic__title">{{ article.topic.title }}</view>
        </view>
      </view>
    </view>
  </view>
</template>
复制代码
// index.wxss
.waterfull {
  padding: 0 12rpx;
  background: linear-gradient(180deg, #FFFFFF 0%, #F5F5F5 100%);
}
.waterfull__item {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
}
.waterfull__item__left, .waterfull__item__right {
  position: absolute;
  top: 0;
  width: calc(50% - 5rpx);
}
.waterfull__item__left {
  left: 0;
}
.waterfull__item__right {
  right: 0;
}
.article-box {
  padding: 6rpx 0;
}
.article {
  font-size: 0;
  box-shadow: 0px 4rpx 16rpx 0px rgba(34, 34, 34, 0.05);
}

...........
复制代码
// index.js
const App = getApp()

let rtp = 0.5
let maxHeight = 238.7
let maxWidth = 179
const coverImgProportion = 0.75 // 封面图宽高比例
const proportion = 0.477 // 瀑布流 封面图高与屏幕的比例
if (App.systemInfo && App.systemInfo.windowWidth) {
  rtp = App.systemInfo.windowWidth / 750
  maxWidth = App.systemInfo.windowWidth * proportion || maxWidth
  maxHeight = (App.systemInfo.windowWidth * proportion) / coverImgProportion || maxHeight
  maxHeight += 1
}

Component({
  // 组件的属性列表
  properties: {
    articles: {
      type: Array,
      value: [],
      observer (list) {
        this.handleArticleData(list)
      }
    }
  },
  // 组件的初始数据
  data: {
    records: [], // 总列表
    visibleIndexs: [], // 可渲染的索引列表
  },
  lifetimes: {
    detached () {
      this.disconnect()
    }
  },
  pageLifetimes: {
    show () {
      this.reconnect()
    }
  },
  ready () {
    this.createObserve()
  },
  // 组件的方法列表
  methods: {
    // 处理列表数据
    handleArticleData (list) {
      // 拆分成分屏数组 一屏 10 个
      const _list = [...list]
      const allList = []
      while (_list.length) {
        const currentList = _list.splice(0, 10)
        allList.push({
          data: currentList
        })
      }
      this.handleWaterfullList(allList)
    },
    handleWaterfullList (list) {
      // 单位均为 rpx
      const titleHeight = 88 // 标题高度
      const avatarHeight = 34 // 头像高度
      const avatarMarginTop = 12 // 头像上边距
      const topicHeight = 48 // 话题高度
      const topicMarginTop = 12 // 话题上边距
      const contentPaddingTop = 12 // 内容上边距
      const contentPaddingBottom = 16 // 内容下边距 
      const boxPaddingTop = 6 // 盒子上边距
      const boxPaddingBottom = 6 // 盒子下边距
      // 固定的高度集合
      const fixedHeight = [
        titleHeight, avatarHeight, avatarMarginTop, contentPaddingTop,
        contentPaddingBottom, boxPaddingTop, boxPaddingBottom
      ]

      list.forEach((item, index) => {
        const isLast = index + 1 === list.length
        // 这里的高度要先减去偏移量
        let leftHeight = 0 - item.leftOffset || 0
        let rightHeight = 0 - item.rightOffset || 0
        const leftData = []
        const rightData = []

        item.data.forEach(article => {
          article.realHeight = this.calcImageHeight(article)
          const heights = [...fixedHeight, article.realHeight]

          if (article.topic) {
            heights.push(topicHeight, topicMarginTop)
          }

          // 计算卡片高度
          // 由于存在误差,所以将每个高度转换成 px 再相加
          const cardHeight = heights.reduce((total, current) => total + this.handleRtoP(current), 0)
          article.cardHeight = cardHeight

          // 计算左右两列的高度
          // 保证左右两列的高度差不会太大
          if (leftHeight <= rightHeight) {
            leftHeight += cardHeight
            leftData.push(article)
          } else {
            rightHeight += cardHeight
            rightData.push(article)
          }
        })

        // 计算偏移量
        if (!isLast) {
          const offset = Math.abs(leftHeight - rightHeight)
          const nextIndex = index + 1
          if (leftHeight >= rightHeight) {
            list[nextIndex].rightOffset = offset
            list[nextIndex].leftOffset = 0
          } else {
            list[nextIndex].leftOffset = offset
            list[nextIndex].rightOffset = 0
          }
        }
        item.height = Math.max(leftHeight, rightHeight)
        item.leftData = leftData
        item.rightData = rightData
      })

      this.setData({
        records: list
      }, () => {
        this.reconnect()
      })
    },
    calcImageHeight (article) {
      // 根据封面原始大小和比例换算成对应的尺寸
      // 超过限制则采用最大限制的高度
      let imageHeight = maxHeight

      if (article.imgHeight && article.imgWidth) {
        imageHeight = (maxWidth * article.imgHeight) / article.imgWidth
      }

      if (imageHeight > maxHeight) {
        imageHeight = maxHeight
      }

      // 先转换成 rpx
      imageHeight = imageHeight / rtp
      
      return imageHeight
    },
    handleRtoP (height) {
      return parseInt(height * rtp)
    },
    // 创建可视区域监听
    createObserve () {
      if (this.ob) return

      this.ob = this.createIntersectionObserver({
        observeAll: true,
        initialRatio: 0,
      }).relativeToViewport({
        bottom: 0
      })

      this.ob.observe('.waterfull__item', res => {
        const { index } = res.dataset
        if (res.intersectionRatio > 0) {
          this.setData({
            visibleIndexs: [index - 1, index, index + 1]
          })
        }
      })
    },
    connect () {
      this.createObserve()
    },
    // 重连可视监听
    reconnect () {
      if (!this.ob) return
      this.disconnect()
      this.connect()
    },
    // 断开可视监听
    disconnect () {
      if (!this.ob) return
      this.ob.disconnect()
      this.ob = null
    },
  }
})
复制代码

最终效果如下:

屏幕录制2021-08-30 下午2.gif

点击查看完整的小程序代码片段

线上优化后的效果如下:

111.gif

参考

分类:
前端
标签:
分类:
前端
标签: