中前台解决方案1——瀑布流&无限加载

296 阅读11分钟

《中前台通用解决方案》课程里有若干细碎的知识点,其中瀑布流无限加载算是页面布局中的典型,而且这两者也一般是同时使用的。

本博客主要记录了Vue3中瀑布流和无限加载组件的搭建过程。

瀑布流

瀑布流组件常见于C端产品,且多针对图片、视频等多媒体文件,例如淘宝、小红书、B站。

和传统的布局相比,瀑布流布局有这些优点

  1. 非对称,不是常规的“自上而下,从左到右”,更适用于长度不固定的页面内容
  2. 元素布局位置灵活,会动态根据之前元素所占空间动态调整后续元素的位置,样式更好看

瀑布流展示.png

组件基础设计

组件的搭建一般应从实际使用出发

即:先考虑“希望怎么使用组件”,再考虑如何设计参数

组件参数props设计

依据上面的设计思路,瀑布流组件的参数(基础)应该包括:

  • 数据
  • 列数
  • 列间距(瀑布流元素间)
  • 行间距(瀑布流元素间)

按照如上的参数,设计了如下的props以及一些默认值

const props = defineProps({
  // 数据
  data: {
    type: Array,
    required: true,
  },
  // 列数
  columns: {
    type: Number,
    default: 2,
  },
  // 列间距
  columnSpace: {
    type: Number,
    default: 20,
  },
  // 行间距
  rowSpace: {
    type: Number,
    default: 20,
  },
});

组件模板

Waterfall组件的模板很清晰,就是外层一个容器,内部一个循环生成每一项即可,如果没有数据,展示一个“无数据”

<template>
  <div
    ref="container"
    class="relative"
    :style="{ height: containerHeight + 'px' }"
  >
    <template v-if="data.length">
      <div
        v-for="(item, index) of data"
        :key="index"
        class="m-waterfall-item absolute"
        :style="{
          width: columnWidth + 'px',
          top: item._style?.top + 'px',
          left: item._style?.left + 'px',
        }"
      >
        <slot :item="item" :index="index" :width="columnWidth.value" />
      </div>
    </template>
    <div v-else>无数据</div>
  </div>
</template>

组件渲染逻辑设计

整个组件渲染的逻辑包括瀑布流容器和内部每一项Item

组件内部Item

使用插槽渲染,每一个Item可以拿到列表的每一项数据,以及每一项的计算得出的宽度

瀑布流内每一项宽度 = (瀑布流总宽度 - 列间距 * (列数 - 1)) / 列数

插槽还用到了v-slot传参的方式

<!-- waterfall 组件中 -->
<slot :item="item" :index="index" :width="itemWidth" />

<!-- 使用时 -->
<template v-slot="{ item, width }">
  <Item :data="item" :width="width" />
</template>
每一项渲染位置

具体到每一项的渲染位置,需要根据瀑布流的列高计算

因为采用绝对定位放置元素,需要注意的包括如下:

  1. 需要记录瀑布流每一列的高度。每增加新的一项,就要看当前高度最低的瀑布流列是哪一列,把这一项放在这一列底部,并把当前列的高度增加

  2. 因为用了绝对定位,所以瀑布流的每一项都会脱离文本流,因此需要用topleft两个CSS属性定位每一项的位置

考虑到以上,先封装了三个通用的方法,即:

  • 获取最短列高度getMinHeight
  • 获取最高列高度getMaxHeight
  • 获取最短列的下标getMinHeightColumn
// 获取最短列高度
export const getMinHeight = (columnHeightObj) => {
  const columnHeightArr = Object.values(columnHeightObj);
  return Math.min(...columnHeightArr);
};

// 获取最高列高度
export const getMaxHeight = (columnHeightObj) => {
  const columnHeightArr = Object.values(columnHeightObj);
  return Math.max(...columnHeightArr);
};

// 返回列高对象中最小高度所在列
export const getMinHeightColumn = (columnHeightObj) => {
  const minHeight = getMinHeight(columnHeightObj);
  return Object.keys(columnHeightObj).find(
    (key) => columnHeightObj[key] === minHeight
  );
};

此外,每一项元素的topleft位置也需要两个方法获取,可以基于以上这三个方法再度封装

  • 下一个元素的top值:getItemTop
  • 下一个元素的left值:getItemLeft
// 下一个元素top:当前最小列高
const getItemTop = () => {
  return getMinHeight(columnHeight.value)
}

// 下一个元素left:当前最小列高所在列的index,以及列间距计算得出
const getItemLeft = () => {
  const column = getMinHeightColumn(columnHeight.value)
  return (
    column * (columnWidth.value + props.columnSpacing) + containerLeft.value
  )
}

最后,基于以上这些方法,封装一个自动渲染Item位置的useItemLocation

useItemLocation逻辑图.png

const useItemLocation = () => {
  props.data.forEach((item, index) => {
    if (item._style) {
      return
    }
    item._style = {}
    item._style.left = getItemLeft()
    item._style.top = getItemTop()
    // 指定列高度自增
    increasingHeight(index)
  })

  // 指定容器高度
  containerHeight.value = getMaxHeight(columnHeight.value)
}

// 指定列高度自增
const increasingHeight = (index) => {
  // 最小高度所在列
  const minHeightColumn = getMinHeightColumn(columnHeight.value)
  columnHeight.value[minHeightColumn] += itemHeights[index] + props.rowSpacing
}
瀑布流宽高

瀑布流的高度包括了每一列的高度,以及整个组件的高度

宽度主要是整个组件的宽度,这个是根据容器大小决定的,而每一项的宽度则可以通过容器宽度和列间距计算得到

列高度

瀑布流每一列在最开始时候都应该初始化一下,高度默认给0

// 记录每一列高度的容器
const columnHeight = ref({})

// 高度初始化
const useColumnHeightObj = () => {
  // 根据列数,初始化每一列的高度为0
  columnHeight.value = {}
  for (let i = 0; i < props.column; i++) {
    columnHeight.value[i] = 0
  }
}

因为高度是要在DOM渲染之后才知道的,而作为数据驱动视图的框架,每一列的高度就跟数据挂钩了,所以应该对数据做监听,在第一次获取数据的时候,初始化每一列高度的记录容器

watch(
  () => props.data,
  (newVal) => {
    nextTick(() => {
      // 第一次获取数据,构建高度记录容器
      // 只有当数据的每一项都没有style样式的时候,才说明是第一次渲染
      const resetColumnHeight = newVal.every((item) => !item._style)
      if (resetColumnHeight) {
        useColumnHeightObj()
      }
      
      useItemLocation()
    })
  },
  {
    deep: true,
    immediate: true
  }
)
容器高度

因为是绝对定位,所以容器高度必须自己定义,这里取的就是瀑布流所有列中最高的,而初始状态高度是0

// 容器总高度:初始为0
const containerHeight = ref(0)
容器宽度

容器的宽度可以用getComputedStyle这个window对象的方法获取,在组件渲染完成的时候执行一次(mounted钩子)

// 容器总宽度(这里说的是内容总宽度)
const containerWidth = ref(0)
// 容器左边距
const containerLeft = ref(0)
// 计算容器宽度
const useContainerWidth = () => {
  const { paddingLeft, paddingRight } = getComputedStyle(
    containerTarget.value,
    null
  )
  // 容器左边距
  containerLeft.value = parseFloat(paddingLeft)
  // 容器宽度(内容)
  containerWidth.value =
    containerTarget.value.offsetWidth -
    parseFloat(paddingLeft) -
    parseFloat(paddingRight)
}

// 列宽
const columnWidth = ref(0)
// 列间距合计
const columnSpacingTotal = computed(() => {
  return props.columnSpacing * (props.column - 1)
})
// 计算列宽
const useColumnWidth = () => {
  // 获取容器宽度
  useContainerWidth()
  // 列宽 = 容器宽度 - 列间距合计 / 列数
  columnWidth.value =
    (containerWidth.value - columnSpacingTotal.value) / props.column
}

onMounted(() => {
  // 计算列宽
  useColumnWidth()
})

组件优化

对于瀑布流Waterfall组件,有几个地方需要考虑优化

  • 用户并不能一次就能看到底,所以需要在用户即将看到的时候再去继续加载
  • 瀑布流的每一项元素都包含了图片,同样,有些图片一开始并不在用户的可视范围内,应该考虑在用户即将看到的时候再去渲染
  • 对于网络图片,可能从请求到拿到数据需要一个时间,这个过程如果让用户看到满屏的白色或者未渲染时候的裂痕图片,很影响体验
  • 针对不同宽度的屏幕(PC、移动端),瀑布流可能有不同的列数,需要做适配

针对以上优化点,要考虑的优化方案包括

  • 数据懒加载(使用无限加载Infinite组件)
  • 图片懒加载、预加载(用占位颜色替代)
  • 移动端适配

图片懒加载

懒加载的核心逻辑:

当元素还没出现在视图区域内,不加载,出现了,再加载

所以懒加载关键就是监听元素是否在可视区域内,这里用到@vueuse/core里的useIntersectionObserver

useIntersectionObserver使用方法

该方法包括两个参数

  • 第一个参数:监听是否可见的DOM元素
  • 第二个参数:回调函数,回调的参数里可以判断当前DOM元素是否可见
    • 回调函数返回一个stop方法,可以用来结束对该DOM元素是否可见的监听
自定义指令

考虑到瀑布流中的图片元素太多了,我们不可能一个个创建监听,所以考虑封装一个通用处理方案,这里可以考虑Vue的自定义指令,最后也是封装了一个v-lazy指令

指令里面需要添加生命周期钩子,这里因为图片涉及DOM渲染,所以用了mounted

import { useIntersectionObserver } from '@vueuse/core'

export default {
  mounted(el) {
    // 先拿到图片元素的src,一开始给个空值,等到能看到了再赋值渲染
    const imgSrc = el.src
    el.src = ''

    const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
      if (isIntersecting) {
        el.src = imgSrc
        // 结束监听
        stop()
      }
    })
  }
}
Vue插件

为了在全局任意地方都可以直接调用v-lazy指令,使用了Vue中的插件,从而可以通过Vue.use把指令绑定到全局

插件需要一个有install属性的对象

export default {
  // app.use的方式使用
  install: (app) => {
    app.directive('lazy', lazy)
  }
}

图片预加载

图片由接口提供,从请求发出到数据返回,再到最终渲染出来,需要一些时间。而在图片没有完全渲染出来之前,盲目直接使用图片地址,可能会导致白屏,或者展示img默认的一个裂屏小图标,给用户的体验不是很好。

所以应该做的是,先知道图片的尺寸信息(宽高),预留好这个尺寸的空间,等待图片从服务器真正返回之后,再去把图片放到页面上。

整个预加载过程如下

图片预加载流程.png

按照如上流程,封装了三个方法

  • 从每一项中抽提图片元素:getImageElement
  • 获取所有图片元素的链接:getAllImg
  • 监听所有图片链接加载完成:onCompleteImgs
// 从 item 中抽离出所有图片元素
export const getImageElements = (itemElements) => {
  const imgElements = []
  itemElements.forEach((item) => {
    imgElements.push(...item.getElementsByTagName('img'))
  })
  return imgElements
}

// 生成所有的图片链接
export const getAllImg = (imgElements) => {
  return imgElements.map((item) => item.src)
}

// 监听图片数组加载完成
export const onCompleteImgs = (imgs) => {
  const promiseAll = []
  imgs.forEach((img, index) => {
    promiseAll[index] = new Promise((resolve, reject) => {
      // 处理 img 加载情况
      const imageObj = new Image()
      imageObj.src = img
      imageObj.onload = () => {
        resolve({ img, index })
      }
    })
  })
  return Promise.all(promiseAll)
}

Waterfall组件中,只需要添加处理图片预加载的方法即可,等到图片预加载完成再去渲染每一项的位置

// 监听图片加载完成(需要图片预加载)
const waitImageComplete = () => {
  itemHeights = [];
  // 拿到所有元素
  let itemElements = [...document.getElementsByClassName("m-waterfall-item")];
  // 获取元素内的img标签
  const imgElements = getImageElements(itemElements);
  // 获取所有img标签的图片
  const allImgs = getAllImg(imgElements);
  // 等待图片加载完成
  onCompleteImgs(allImgs).then(() => {
    itemElements.forEach((item) => {
      itemHeights.push(item.offsetHeight);
    });
    // 渲染位置
    useItemLocation();
  });
};

watch(
  () => props.data,
  (newVal) => {
    nextTick(() => {
      ......

      // 根据props决定是否需要预加载
      if (props.picturePreLoading) {
        waitImageComplete();
      } else {
        useItemHeight();
      }
    });
  }
);

移动端适配

移动端的主要区别就是列数变化,因此核心就是监听column属性的变化,并重新调整瀑布流每一项的布局

watch(
  () => props.column,
  () => {
    if (props.picturePreReading) {
      columnWidth.value = 0
      reset()
    } else {
      reset()
    }
  }
)

当列宽变化的时候,重置所有项的style

const reset = () => {
  setTimeout(() => {
    // 重新计算列宽
    useColumnWidth()
    // 重置所有定位数据
    props.data.forEach((item) => {
      item._style = null
    })
  }, 200)
}

无限加载

无限加载Infinity组件一般和瀑布流这种长列表组件组合使用,当用户滚动到列表底部,而数据还有多的时候,可以继续加载后续内容

组件参数props设计

无限加载组件主要是要判断三点

  1. 是否到达长列表的底部
  2. 是否还有更多数据
  3. 如果有更多数据,新数据是否加载完成可以继续渲染

关于到达底部,可以考虑在底部放一个元素,使用之前图片懒加载中的useIntersectionObserver

而是否还有更多数据,则需要一个单独变量控制,而且加载更多数据的回调方法要传入

const props = defineProps({
  // 是否处于加载状态
  modelValue: {
    type: Boolean,
    required: true
  },
  // 数据是否加载完成
  isFinished: {
    type: Boolean,
    default: false
  }
})

const emits = defineEmits(['onLoad', 'update:modelValue'])

// loading状态放在父组件变化,这里只做联动
const loading = useVModel(props)

组件模板

组件的模板比较简单,上面就是正常渲染的长列表内容,底部添加一个区域监听是否到达底部

如果到达,在数据没加载完成前展示一个loading状态,加载完成后则继续展示长列表,没有更多数据也单独展示一个“没有更多数据”

<template>
  <div class="">
    <!-- 内容 -->
    <slot />
    <div ref="loadingTarget" class="h-6 py-4">
      <!-- 加载更多 -->
      <m-svg-icon
        v-show="loading"
        class="w-4 h-4 mx-auto animate-spin"
        name="infinite-load"
      ></m-svg-icon>
      <!-- 没有更多数据 -->
      <p v-if="isFinished" class="text-center text-base text-zinc-400">
        已经没有更多数据了!
      </p>
    </div>
  </div>
</template>

组件渲染逻辑设计

无限加载主要考虑列表是否到底部,初始状态下应该是false,而一旦无限加载组件渲染出来就应该监听这个底部元素是否可见,从而继续渲染长列表

// 滚动元素
const loadingTarget = ref(null)

// 记录当前是否在底部
const targetIsIntersecting = ref(false)

const emitLoad = () => {
  // 当加载更多的视图可见时,loading中,数据尚未全部加载完,触发加载更多的逻辑
  if (targetIsIntersecting.value && !loading.value && !props.isFinished) {
    // 修改加载数据的标记
    loading.value = true
    emits('onLoad')
  }
}

useIntersectionObserver(loadingTarget, ([{ isIntersecting }]) => {
  targetIsIntersecting.value = isIntersecting
  emitLoad()
})

此外还有一种极端情况,就是第一次渲染得到的长列表数据,还并没有超出可视区域,这种情况就需要再次渲染

// 监听loading变化,解决数据加载完成后,首屏未铺满问题
watch(loading, () => {
  // 以防数据少的时候多加载一次,延时判断是否需要加载新一次
  setTimeout(emitLoad, 100)
})