vue3异步瀑布流组件实现

429 阅读6分钟

1. 背景

前端瀑布流布局是一种网页布局方式,通常用于展示大量图片或卡片等内容,其特点是各个元素的高度不一致,像瀑布一样错落排列,以充分利用页面空间,给用户带来独特的视觉体验。那么什么是异步瀑布流呢(dom的渲染时机与最终真实大小之间的异步),那么为什么会产生异步呢?

普通标签

对于普通标签如div,span等而言,这些标签基本只取决于样式在渲染之后大小可以通过getBoundingClientRect等方法直接获取大小宽高等信息,用以实现瀑布流。

比较常见的方案是通过先直接渲染一遍获取dom大小,然后进行瀑布流的排列重新渲染,这种是不会产生异步的。

多媒体标签

但对于多媒体标签如img video,这种标签的最终大小不仅仅取决于样式计算,同时也与src有关,当资源加载完毕之后,大小可能会发生改变(写死除外),所以一般情况下是无法在渲染后直接获取最后的真实大小。在这种情况下如何获取dom大小呢?

我看网上比较常见的是,获取图片的时候同时后端返回图片的大小用以计算,但这就需要后端同学配合,且这种写死的方案,在我看来也是不够灵活,如果设计稿改动或者响应式变化等等大小改变的情况可能会造成牵一发而动全身。

那么如何才能做到在纯前端不依赖后端,具有灵活性不需要关注各种大小计算,同时可以实现异步的瀑布流呢?

2. 普通瀑布流实现思路

首先我们要知道普通的瀑布流是如何实现的呢。

2.1 分栏

我们都知道瀑布流是以一列一列为单位的,所以我们首先需要分栏,假设目前有4列,我们什么都没有放置,会有这样的数组我们记为columns=[[],[],[],[]],columnsHeight=[0,0,0,0]。

image.png

2.2 获取元素高度

我们要将元素放入到这4栏中,那么如何放置呢?每次选择最短的列放,一般情况下我们只需要考虑高度,因为宽度基本都是均分的假设我有这样几个元素A,B,C,D,E,F,我们可以通过提前渲染一遍,或者计算出来他们的高度假设对应的数组是 [300,200,400,200,100,300]。 我们每次要找最短的列

2.3 放置元素位置

第一次放置:此时columnsHeight=[0,0,0,0],所以我们放到第一列中,此时columns=[[A],[],[],[]],columnsHeight=[300,0,0,0]

第二次放置:此时columnsHeight=[300,0,0,0],所以我们放到第二列中,此时columns=[[A],[B],[],[]],columnsHeight=[300,200,0,0]

第三次放置:此时columnsHeight=[300,200,0,0],所以我们放到第三列中,此时columns=[[A],[B],[C],[]],columnsHeight=[300,200,400,0]

第四次放置:此时columnsHeight=[300,200,400,0],所以我们放到第四列中,此时columns=[[A],[B],[C],[D]],columnsHeight=[300,200,400,200]

第五次放置:此时columnsHeight=[300,200,400,200],所以我们放到第二列中,此时columns=[[A],[B,E],[C],[D]],columnsHeight=[300,200+100,400,200]

第六次放置:此时columnsHeight=[300,300,400,200],所以我们放到第四列中,此时columns=[[A],[B,E],[C],[D,F]],columnsHeight=[300,200+100,400,200+300]

最后的结果

image.png

2.4 渲染

我们现在知道每一个元素所在的位置,然后就可以直接渲染了。

3.异步瀑布流实现思路

3.1 存在的问题

无法在渲染后之间获取高度

3.2 解决思路

当元素渲染完成之后主动通知组件

3.3 核心实现

   <!-- 组件 -->
   <div ref="mockRef" class="mock">
      <div v-for="(item, index) in renderList" :ref="(dom) => { mockListRefs[index] = dom as HTMLElement; }">
            <slot name="item" :item="item" :rendered="rendered" :index="index"></slot>
      </div>
   </div>
   
   rendered(){
      // 渲染完成之后的操作
   }
   <!-- 插槽 -->
    <template #item="{ item, rendered, index }">
      <div class="item">
        <!-- <div class="text"></div> -->
        <img :src="item" @load="rendered(index)" />
      </div>
    </template>

插槽内容传递了一个rendered函数,当img标签load之后,通过rendered函数可以将信息带给瀑布流组件,此时就可以异步获取信息了。

例子

代码可能存在bug,不过主要提供实现思路。 WaterFall 组件 WaterFall.vue

<template>
    <div class="waterfall-container">
        <div class="list-container" :key="props.columns">
            <div v-for="(i, index) in props.columns" class="column-container"
                :ref="(dom) => { columnRefs[index] = dom as HTMLElement }">
                <div v-for="item in columnsList[index]">
                    <slot name="item" :item="item" :rendered="() => { }"></slot>
                </div>
            </div>
        </div>
        <!-- 提前渲染不展示,目的是获取dom大小 -->
        <div ref="mockRef" class="mock" :key="props.columns">
            <div v-for="(item, index) in renderList" :ref="(dom) => { mockListRefs[index] = dom as HTMLElement; }">
                <slot name="item" :item="item" :rendered="rendered" :index="index">
                </slot>
            </div>
        </div>
        <div class="loading" v-show="props.hasMore" ref="loadingRef">
            <slot name="loading">加载中</slot>
        </div>
        <div class="finish" v-show="!props.hasMore" ref="finishRef">
            <slot name="finish">没有更多了</slot>
        </div>
    </div>
</template>

<script lang="ts" setup>
import { nextTick, onMounted, ref, watch } from 'vue';
// 渲染的信息 data原始传递的数据  listIndex 在列表中的下标
type RenderType = { data: any, columnIndex?: number }
interface Props {
    columns: number // 列数
    dataList: Array<any>  // 数据列表 
    hasMore: boolean // 是否有更多
}
const props = defineProps<Props>()
const emits = defineEmits<{
    (e: 'load'): void
}>()
// 按列渲染  每一列的dom
const columnRefs = ref<HTMLElement[]>([])
// 计算每一列高度
let columnsHeight: number[] = []
// 用来计算每一项元素大小
const mockListRefs = ref<HTMLElement[]>([])
// 每一列下的数据
const columnsList = ref<RenderType[][]>([])
// 渲染列表需要内部控制 主要是为了reset服务,让数据初始化时候可以清空数据得到控制
const renderList = ref<RenderType[]>([])
watch(() => props.dataList, () => {
    renderList.value = [...props.dataList]
}, { deep: true, immediate: true })
// 已经渲染的数量
let renderNumber = 0;
// 初始化函数
const init = async () => {
    renderNumber = 0;
    columnsList.value = new Array(props.columns).fill(0).map(v => new Array())
    columnsHeight = new Array(props.columns).fill(0)
    await nextTick()
    if (columnRefs.value[0] && mockRef.value) {
        mockRef.value.style.width = columnRefs.value[0].getBoundingClientRect().width + 'px'
    }
    if (loadingRef.value) {
        loadingObserver.unobserve(loadingRef.value)
        loadingObserver.observe(loadingRef.value)
    }
}

// rendered 异步渲染完成
const rendered = (index: number) => {
    let shortColumnIndex = 0;
    for (let i = 0; i < props.columns; i++) {
        if (columnsHeight[i] < columnsHeight[shortColumnIndex]) {
            shortColumnIndex = i;
        }
    }
    columnsList.value[shortColumnIndex].push(props.dataList[index])
    renderNumber++;
    columnsHeight[shortColumnIndex] += mockListRefs.value[index].getBoundingClientRect().height

    console.log(renderNumber, props.dataList.length, loadingIsIntersecting);
    if (renderNumber === props.dataList.length && loadingIsIntersecting) {

        emits('load')
    }
}

// loading效果
const loadingRef = ref<HTMLElement>()
// 是否处于可视区 因为一开始没数据,默认在可视区
let loadingIsIntersecting = true
const loadingObserver = new IntersectionObserver((entrys) => {
    console.log(entrys[0], 'entrys[0].isIntersecting');

    if (renderNumber === props.dataList.length && entrys[0].isIntersecting) {
        emits('load')
    }
    loadingIsIntersecting = entrys[0].isIntersecting
})
// 获取mock区域
const mockRef = ref<HTMLElement>()
onMounted(async () => {

    await init()
})
defineExpose({
    reset: async () => { renderList.value = []; await nextTick(); await init(); }
})
</script>

<style scoped>
.waterfall-container {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
}

.list-container {
    width: 100%;
    display: flex;
    justify-content: space-between;
}

.column-container {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-self: flex-start;
}

.loading,
.finish {
    text-align: center;
}

.mock {
    height: 0px;
    overflow: hidden;
}
</style>

App.vue

<template>
  <WaterFall :columns="columns" :data-list="list" :has-more="hasMore" @load="load" ref="waterFull">
    <template #item="{ item, rendered, index }">
      <div class="item">
        <!-- <div class="text"></div> -->
        <img :src="item" @load="rendered(index)" />
      </div>
    </template>
  </WaterFall>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import WaterFall from './components/WaterFall.vue';
const list = ref([
  'https://inews.gtimg.com/om_bt/Os3eJ8u3SgB3Kd-zrRRhgfR5hUvdwcVPKUTNO6O7sZfUwAA/641',
  'https://img2.baidu.com/it/u=2814429148,2262424695&fm=253&fmt=auto&app=138&f=JPEG',
  'https://img1.baidu.com/it/u=2931243091,718249849&fm=253&app=120&size=w931&n=0&f=JPEG&fmt=auto?sec=1728579600&t=a785ea043105a08ffee052762725762a',
  'https://img0.baidu.com/it/u=2567590042,3251997562&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=623'
])
const mock = [
  'https://inews.gtimg.com/om_bt/Os3eJ8u3SgB3Kd-zrRRhgfR5hUvdwcVPKUTNO6O7sZfUwAA/641',
  'https://img2.baidu.com/it/u=2814429148,2262424695&fm=253&fmt=auto&app=138&f=JPEG',
  'https://img1.baidu.com/it/u=2931243091,718249849&fm=253&app=120&size=w931&n=0&f=JPEG&fmt=auto?sec=1728579600&t=a785ea043105a08ffee052762725762a',
  'https://img0.baidu.com/it/u=2567590042,3251997562&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=623'
]

const hasMore = ref(true)
const columns = ref(4)
const waterFull = ref<InstanceType<typeof WaterFall>>()
const load = () => {
  setTimeout(() => {
    list.value.push(...mock)
    if (list.value.length > 60) {
      hasMore.value = false
    }
  }, 500)
}
// 瀑布流可能存在筛选重新传递数据
watch(() => hasMore.value, () => {
  if (hasMore.value === false) {
    setTimeout(async () => {
      // 设置columns必须在reset前
      columns.value = 3
      // 重新传递数据需要reset一下,
      await waterFull.value?.reset()
      // 设置datalist必须在reset后
      list.value = [...mock]
      // 这样可以不改变数据
      // list.value = [...list.value]

      // hasMore.value = true

    }, 5000)
  }
})
</script>

<style scoped>
* {
  box-sizing: border-box;
}

.item {
  width: 100%;
  padding: 10px;
}


.item img {
  width: 100%;
}
</style>