(学习笔记)瀑布流实现

147 阅读7分钟

瀑布流核心功能实现

核心功能需求描述

我们希望瀑布流组件的使用方式如下:

  1. 瀑布流每张图片区域的内容是可以定制的,所以要有一个slot
  2. 可以指定列数,所有要有传参column
  3. 可以指定图片是否需要预渲染,所以要有传参picturePreReading
  4. 数据源从外部传入
<m-waterfall
        :data="" // 数据源
        :nodeKey="" // 唯一标识的 key
        :column="" // 渲染的列数
        :picturePreReading="" // 是否需要图片预渲染在不知道图片高度的情况下)
      >
        <template v-slot="{ item, width }">
          // 对应的item
        </template>
      </m-waterfall>

核心功能实现思路

瀑布流必须是绝对定位来实现,因为别的布局方式无法实现此功能,所以每张图片都有lefttop,我们的核心任务就是计算每张图片的lefttop

在计算每张图片的位置前,我们必须得到所有图片的宽和高,而图片的宽和高,如果后端没有返回,则必须等待所有的图片都加载完成才能拿到,图片的位置必须等待全部图片加载完以后再开始计算。如果后端有返回,则可以直接开始计算。

核心思路如下:

  1. 一开始要算出每张图片的宽度,每张图片的宽度 = (容器总宽度 - 所有列间距) / 列数
  2. 通过promise.all方式预加载所有的图片,目的是为了拿到所有的图片的宽和高。
  3. 对于每张图片的left的计算,假设一共有n列,则每一列的left值都是固定的,第n列的left = n * (图片 的宽度 + 列编剧)
  4. 对于每张图片的top计算,假设一共有n列,我们需要定义n个变量记录当前列的高度h,每一列的高度初始值都是0。每当新增一张图片,当前列的列高都是添加当前图片的高度 + 行间距。所以,第n列的top值 = 第n列的累计高度
  5. 下一张图片应该放在哪一列,则是要计算所有列中高度最短的是哪一列,当有高度一样的列时候,优先选择靠前的列。

核心实现代码

  1. 所有props
const props = defineProps({
  // 数据源
  data: {
    type: Array,
    required: true
  },
  // 唯一标识的 key
  nodeKey: {
    type: String
  },
  // 列数
  column: {
    default: 2,
    type: Number
  },
  // 列间距
  columnSpacing: {
    default: 20,
    type: Number
  },
  // 行间距
  rowSpacing: {
    default: 20,
    type: Number
  },
  // 是否需要进行图片预读取
  picturePreReading: {
    type: Boolean,
    default: true
  }
})
  1. 通过promis.all预加载所有的图片,以便后续获得每张图片的宽高
// item 高度集合
let itemHeights = []
/**
 * 监听图片加载完成
 */
const waitImgComplate = () => {
  itemHeights = []
  // 拿到所有元素
  let itemElements = [...document.getElementsByClassName('m-waterfall-item')]
  // 获取所有元素的 img 标签
  const imgElements = getImgElements(itemElements)
  // 获取所有 img 标签的图片
  const allImgs = getAllImg(imgElements)
  onComplateImgs(allImgs).then(() => {
    // 图片加载完成,获取高度
    itemElements.forEach((el) => {
      itemHeights.push(el.offsetHeight)
    })
    // 渲染位置
    useItemLocation()
  })
}

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

/**
 * 监听图片数组加载完成(通过 promise 完成)
 */
export const onComplateImgs = (imgs) => {
  // promise 集合
  const promiseAll = []
  // 循环构建 promiseAll
  imgs.forEach((img, index) => {
    promiseAll[index] = new Promise((resolve, reject) => {
      const imageObj = new Image()
      imageObj.src = img
      imageObj.onload = () => {
        resolve({
          img,
          index
        })
      }
    })
  })
  return Promise.all(promiseAll)
}
  1. 计算每一列列宽
// 容器实例
const containerTarget = ref(null)
// 容器总宽度(不包含 padding、margin、border)
const containerWidth = ref(0)
// 容器左边距,计算 item left 时,需要使用定位
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(() => {
  // 如果是5列,则存在 4 个列间距
  return (props.column - 1) * props.columnSpacing
})
/**
 * 开始计算
 */
const useColumnWidth = () => {
  // 获取容器宽度
  useContainerWidth()
  // 计算列宽
  columnWidth.value =
    (containerWidth.value - columnSpacingTotal.value) / props.column
}

onMounted(() => {
  // 计算列宽
  useColumnWidth()
  console.log(columnWidth.value)
})
  1. 初始化每一列的高度
// 容器的总高度
const containerHeight = ref(0)
// 记录每列高度的容器。key:所在列  val:列高
const columnHeightObj = ref({})
/**
 * 构建记录各列的高度的对象。
 */
const useColumnHeightObj = () => {
  columnHeightObj.value = {}
  for (let i = 0; i < props.column; i++) {
    columnHeightObj.value[i] = 0
  }
}
  1. 计算每一张图片的left和top
/**
 * 为每个 item 生成位置属性
 */
const useItemLocation = () => {
  // 遍历数据源
  props.data.forEach((item, index) => {
    // 避免重复计算
    if (item._style) {
      return
    }
    // 生成 _style 属性
    item._style = {}
    // left
    item._style.left = getItemLeft()
    // top
    item._style.top = getItemTop()
    // 指定列高度自增
    increasingHeight(index)
  })

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

/**
 * 返回下一个 item 的 left
 */
const getItemLeft = () => {
  // 最小高度所在的列 * (列宽 + 间距)
  const column = getMinHeightColumn(columnHeightObj.value)
  return (
    column * (columnWidth.value + props.columnSpacing) + containerLeft.value
  )
}

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

/**
 * 返回下一个 item 的 top
 */
const getItemTop = () => {
  // 列高对象中的最小的高度
  return getMinHeight(columnHeightObj.value)
}

/**
 * 指定列高度自增
 */
const increasingHeight = (index) => {
	// 最小高度所在的列  
  const minHeightColumn = getMinHeightColumn(columnHeightObj.value)
  // 该列高度自增
  columnHeightObj.value[minHeightColumn] +=
    itemHeights[index] + props.rowSpacing
}

/**
 * 返回列高对象中的最大的高度
 */
export const getMaxHeight = (columnHeightObj) => {
  const columnHeightArr = Object.values(columnHeightObj)
  return Math.max(...columnHeightArr)
}

// 触发计算
watch(
  () => props.data,
  (newVal) => {
    // 重置数据源
    const resetColumnHeight = newVal.every((item) => !item._style)
    if (resetColumnHeight) {
      // 构建高度记录容器
      useColumnHeightObj()
    }
   ...
  },
  {
    immediate: true,
    deep: true
  }
)

到此瀑布流的核心功能已经实现完成。

瀑布流的非核心功能实现

长列表实现瀑布流自动加载

我们希望瀑布流图片滚动到底部的时候,自动加载下一页,所以在这要用到长列表。

我们希望长列表的使用方式如下

<m-infinite-list
  v-model="" // 当前是否处于加载状态
  :isFinished="" // 数据是否全部加载完成
  @onLoad="" // 加载下一页数据的触发事件
>
	列表
</m-infinite-list>

实现长列表最主要用到的函数是 IntersectionObserver,该接口可以判断:目标元素与其祖先元素或顶级文档视窗( viewport )的交叉状态(是否可见)

核心原理就是,在列表的最底部,固定写入一个元素,然后利用IntersectionObserver函数监听该元素是否出现在可视区内,如果是,则emit触发加载事件。

实现代码

<template>
    <div>
        <!-- 内容 -->
        <slot />
        <div ref="laodingTarget" 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>

<script setup>
import { ref } from 'vue'
import { useVModel } from '@vueuse/core'
import { useIntersectionObserver } from '@vueuse/core'
const props = defineProps({
    // 是否处于加载状态
    modelValue: {
        type: Boolean,
        required: true
    },
    // 数据是否全部加载完成
    isFinished: {
        type: Boolean,
        default: false
    }
})

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

// 处理 loading 状态
const loading = useVModel(props)

// 滚动的元素
const laodingTarget = ref(null)
useIntersectionObserver(
    laodingTarget,
    ([{ isIntersecting }], observerElement) => {
        // 当加载更多的视图可见时,加载更多数据
        if (isIntersecting && !loading.value && !props.isFinished) {
            // 修改加载数据标记
            loading.value = true
            // 触发加载更多行为
            emits('onLoad')
        }
    }
)
</script>

<style></style>

配合瀑布流组件实现自动加载

<template>
  <div>
    <!-- 列表处理 -->
    <m-infinite-list
      v-model="isLoading"
      :isFinished="isFinished"
      @onLoad="getPexelsData"
    >
      <m-waterfall
        ...
      </m-waterfall>
    </m-infinite-list>
  </div>
</template>

<script setup>
...

/**
 * 构建数据请求
 */
let query = {
  page: 1,
  size: 20
}
// 数据是否在加载中
const isLoading = ref(false)
// 数据是否全部加载完成
const isFinished = ref(false)
// 数据源
const pexelsList = ref([])
/**
 * 加载数据的方法
 */
const getPexelsData = async () => {
  // 数据全部加载完成则 return
  if (isFinished.value) {
    return
  }

  // 完成第一次请求之后,后续请求让 page 自增
  if (pexelsList.value.length) {
    query.page += 1
  }

  // 触发接口请求
  const res = await getPexelsList(query)
  // 初始请求清空数据源
  if (query.page === 1) {
    pexelsList.value = res.list
  } else {
    pexelsList.value.push(...res.list)
  }
  // 判断数据是否全部加载完成
  if (pexelsList.value.length === res.total) {
    isFinished.value = true
  }
  // 修改 loading 标记
  isLoading.value = false
}
</script>

图片懒加载功能

我们希望瀑布流的普通,等出现在可视区后,再加载图片。所以需要懒加载指令。

懒加载指定实现思路:
一开始不把图片地址写在src当中,而是写在自定义属性data-src里面,通过IntersectionObserver函数,监听每一张图片是否出现在可视区内,当图片出现在可视区当中,把data-src中的地址,赋值到图片的src内。

代码实现

import { useIntersectionObserver } from '@vueuse/core'

export default {
  // 图片懒加载:在用户无法看到图片时,不加载图片,在用户可以看到图片后加载图片
  // 如何判断用户是否看到了图片:useIntersectionObserver
  // 如何做到不加载图片(网络):img 标签渲染图片,指的是 img 的 src 属性,src 属性是网络地址时,则会从网络中获取该图片资源。那么如果 img 标签不是网络地址呢?把该网络地址默认替换为非网络地址,然后当用户可见时,在替换成网络地址。
  mounted(el) {
    // 1. 拿到当前 img 标签的 src
    const imgSrc = el.src
    // 2. 把 img 标签的 src 替换为本地地址,也可以替换为空或者一个透明的图片
    el.src = ''

    const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
      if (isIntersecting) {
        el.src = imgSrc
        stop()
      }
    })
  }
}