# 小程序瀑布流实现思路

# 动态判断高度+图片懒加载

这个方式首先需要把列表分为两个空列表进行分别渲染,动态判断两个列表的高度,通过图片的 onload 的方法监听图片加载完成后,把元素一个一个的添加到高度低的列表中进行渲染,加载一个渲染一个,不是一次全部放到列表中渲染,可以给图片加上懒加载,随着列表滚动,加载好一个元素添加一个元素,这样效果会好点。此方式必须有图片才行。

如何实现加载好一个添加一个呢,我们通过分页滚动加载维护一份源数据列表,每次等图片加载好判断高度,取源数据列表的第一个元素,先把这个元素在源数据中删除,然后再把它添加到判断好的渲染列表中进行渲染,这样随着加载源数据一直再减少,渲染列表一直再增加。

因为渲染列表和源数据列表是独立的,所以在分页加载时不必担心渲染和分页加载数据冲突,分页加载只关心源数据列表,不会影响渲染列表。只不过极小概率会出现在 onload 时触发了分页,我们可用在分页加载时判断一下源数据列表是否为空。

瀑布流实现(uniapp 方式):

<template>
  <view class="pages-help-cate-index">
    <view class="index-wrap">
      <!-- 列表 -->
      <view class="wrap-list">
        <view class="list-left waterfall_left">
          <view class="list-item" v-for="(item, index) in leftProductList" :key="index">
            <view class="item-img">
              <image :src="item.cover" mode="scaleToFill" />
            </view>
            <view class="item-title">{{ item.title }}</view>
          </view>
        </view>
        <view class="list-right waterfall_right">
          <view class="list-item" v-for="(item, index) in rightProductList" :key="index">
            <view class="item-img">
              <image :src="item.cover" mode="scaleToFill" />
            </view>
            <view class="item-title">{{ item.title }}</view>
          </view>
        </view>
      </view>


      <!-- 空状态 -->
      <view class="wrap-empty" v-if="!productList.length">
        <u-empty mode="list"></u-empty>
      </view>


      <!-- 底部文字 -->
      <view class="wrap-base" v-if="productList.length && productList.length >= pagination.count">
        <ts-base-text title="没有更多了"></ts-base-text>
      </view>
    </view>


    <!-- 底部上拉加载 -->
    <u-loadmore v-if="productList.length < pagination.count" height="80rpx" fontSize="14" :status="pagination.status"
      icon-type="flower" color="#ccc" />
  </view>
</template>


<script>
import { mapState, mapActions } from 'vuex'
import TsBaseText from '@/components/ts-base-text'
export default {
  components: {
    'ts-base-text': TsBaseText,
  },
  data() {
    return {
      loading: false,

      // 查询参数
      queryParams: {
        page: 1,
        limit: 20,
        reply_status: 1
      },

      productList: [], // 列表数据
      originList: [], // 原始数据
      leftProductList: [],
      rightProductList: [],

      // 是否下拉刷新
      isFefresher: false,
      // 是否开启下拉刷新
      enabledRefresh: true,
      // 分页
      pagination: {
        count: 0,
        status: 'loadmore'
      }
    }
  },
  onLoad() {
    this.getProductList(true)
  },
  // 滚动到底部
  onReachBottom() {
    // 触发加载
    this.pullUpLoadMore()
  },
  methods: {
    ...mapActions('home', ['difficultyHelpListApi']),
    // 获取数据列表
    getProductList(flag) {
      // 重置数据,重新请求
      this.queryParams.page = 1
      this.pagination.count = 0
      this.pagination.status = 'loadmore'
      this._getList(flag)
    },

    /**
     * 获取列表
     * @param {Boolean} flag 是否重置列表,默认false不重置
     * */
    _getList(flag) {
      this.loading = true
      const params = {
        ...this.queryParams
      }
      // 请求接口
      this.difficultyHelpListApi(params).then(data => {
        const { list, count } = data
        // 如果是刷新直接赋值,否则添加到列表中
        this.productList = this.isFefresher || flag ? list : [...this.productList, ...list]
        this.originList = this.isFefresher || flag ? list : [...this.originList, ...list]
        this.pagination.count = count
        // 判断是否底部上拉加载更多
        this.pagination.status = this.productList.length < count ? 'loadmore' : 'nomore'
      }).finally(() => {
        this.loading = false
        // 结束下拉加载
        this.isFefresher = false
        // uni.stopPullDownRefresh()
      })
    },
    // 上拉加载更多
    pullUpLoadMore() {
      // 如果列表长度小于total继续上拉加载更多
      if (this.productList.length < this.pagination.count) {
        this.queryParams.page += 1
        this._getList()
      }
    },

    // 下拉刷新
    pullRefresh() {
      if (this.originList.length) return
      // 开启下拉加载
      this.isFefresher = true
      this.getProductList(true)
    },


    // 图片加载完成后添加数据
    handleImgLoad() {
      let leftH = 0
      let rightH = 0 // 左右高度
      const query = uni.createSelectorQuery().in(this)
      query.selectAll('.waterfall_left').boundingClientRect()
      query.selectAll('.waterfall_right').boundingClientRect()
      query.exec((res) => {
        leftH = res[0].length !== 0 ? res[0][0].height : 0 // 防止查询不到做个处理
        rightH = res[1].length !== 0 ? res[1][0].height : 0
        if (this.originList.length === 0) return
        if (leftH === rightH || leftH < rightH) {
          // 相等 || 左边小
          // 删除第一个源数据,并添加到渲染列表,一直取的是源数据的第一个
          this.leftProductList.push(this.originList.shift())
        } else {
          // 右边小
          // 删除第一个源数据,并添加到渲染列表,一直取的是源数据的第一个
          this.rightProductList.push(this.originList.shift())
        }
      })
    }
  }
}
</script>
<style lang="scss" scoped>
.pages-help-cate-index {
  width: 100%;
  min-height: 100vh;
  background-color: #fff;
  //......
}
</style>

# 不判断高度+无限滚动

此方式适用于无限滚动方式,或者没有办法通过图片 onload 方法加载动态判断的列表,此方式也是分为两个列表进行渲染,只不过是在直接获取数据后,把数据按属性分别添加到两个列表,不用其余的计算,这样渲染出来的列表可能最后会出现左右差别大的情况,所以使用与无限滚动的列表。

但是这种方式只适合数据量小进行简单快速实现瀑布流,如果实现真正的瀑布流此方式还是有所欠缺的,只能拿来应急。

# 瀑布流+虚拟列表实现思路

不论是动态判断高度的瀑布流还是无限滚动到最后都会因为数据量过大而导致页面卡顿和白屏,这里我们可以在动态判断高度实现瀑布流的思路上结合虚拟列表进行实现无限滚动瀑布流效果。

我们在根据高度进行动态填充两个列表后,根据虚拟滚动思路,在列表滚动超出多少后,把已渲染的元素隐藏,我们已经知道了每个元素的具体高度和列表高度,所以可以在左右列表分别填充一个空的元素把高度撑起来,真实只渲染用户视图内的元素,大致可以根据这个思路完善动态计算瀑布流+虚拟列表实现无限滚动效果。

# 微信小程序插件

waterfall-layout (opens new window) 支持下拉加载动画和自动计算内容高度,不需要手动添加图片的高度,只关心列表内容渲染,使用简单直接引入组件,在里面渲染内容就可以。

mp-waterfall (opens new window) 相比较 waterfall-layout 使用麻烦一点,每次都要为组件单独创建一个列表项渲染组件,把要渲染的项放到 mp-waterfall 上才行。

# Vue 插件

vue-waterfall-easy (opens new window) 使用简单,包含瀑布流布局和无限滚动加,无需在返回的数据中指定图片的宽度和高度,采用图片预加载。响应式,兼容移动端,并支持无图模式(@2.4.0)

# 总结

瀑布流渲染最好是渲染有限的数据,如果数据量过大的频繁计算 DOM 元素尺寸会导致页面出现卡顿,当然在数据量大的情况下可以结合虚拟列表实现,如果使用虚拟列表进行处理的话还要计算每个列表的高度,在滑动时进行还原,但是实现难度会加大,所以综合考虑要减少使用瀑布流+无限滚动进行渲染列表,从产品侧进行沟通解决。