likes
comments
collection
share

瀑布流组件

作者站长头像
站长
· 阅读数 7

在后台项目中,需要陈列大量数据时,我们大多采用的是分页展示,当用户获取额外数据时,需要在页脚点击翻页操作。而在前台或移动端项目里,则采用的是瀑布流布局,用户只需一滑到底,交互方式更加便捷,同时降低了界面的复杂度,节省页面空间。目前,在电商应用、短视频平台、图片网站中瀑布流布局,更是随处可见。今天学习如何建立瀑布流组件?

瀑布流组件

1. 通用组件:瀑布流

如上图所示,我们把每个照片看是一个item组件,当获取数据后,item需要先横向排列铺满,铺满后下一行item顺序连接到当前最短的列中。 所以,每个item就需要使用absolute绝对布局,通过 topleft来手动控制位置。同时,当在移动端下,item的渲染列数也要发生变化,以及在不知道图片高度的情况下,是否需要图片的预渲染。

综上分析后,瀑布流组件的构建需要分成以下几个部分:

  1. 通过props传递关键数据:

    • data:数据源
    • nodeKey:唯一标识
    • column:渲染的列数
    • picturePreReading:是否需要图片预渲染
  2. 瀑布流渲染机制:通过absolute配合relative完成布局,布局逻辑为:每个item应该横向排列,第二行的item顺序连接到当前最短的列中

  3. 通过作用域插槽,将每个item中涉及到的关键数据,传递到item视图中。

<waterfall
  :data="" // 数据源
  :nodeKey="" // 唯一标识的 key
  :column="" // 渲染的列数
  :picturePreReading="" // 是否需要图片预渲染(在不知道图片高度的情况下)
  >
  <template v-slot="{ item, width }">
    // 对应的item
  </template>
</waterfall>

1.1. 获取容器宽度与列宽

  1. 定义组件接收参数:
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. 构建对应的基础视图:
    1. 因为当前为 relative 布局,所以需要主动指定高度
    2. 因为列数不确定,所以需要根据列数计算每列的宽度,所以等待列宽计算完成,并且有了数据源之后进行渲染
    3. 通过动态的 style 来去计算对应的列宽、left、top
<template>
  <div class="relative" ref="containerTarget" :style="{ height: containerHeight + 'px' }">
    <!-- 数据渲染 -->
    <template v-if="columnWidth && data.length">
      <div
        class="m-waterfall-item absolute duration-300"
        :style="{
          width: columnWidth + 'px',
          left: item._style?.left + 'px',
          top: item._style?.top + 'px'
        }"
        v-for="(item, index) in data"
        :key="nodeKey ? item[nodeKey] : index"
      >
        <slot :item="item" :width="columnWidth" :index="index" />
      </div>
    </template>
    <!-- 加载中 -->
    <div v-else>加载中...</div>
  </div>
</template>
  1. 根据以上基础视图,我们需要生成对应的:

    • containerHeight:总高度
    • columnWidth:列宽
    • item._style.left:每个 item 对应的 left
    • item._style.right:每个 item 对应的 right
  2. 想要计算 总高度,那么需要计算出 每一列的高度 ,最高的一列为 总高度

// 容器的总高度
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. 想要计算列宽,那么首先需要有容器的总宽度:

    1. getComputedStyle()这个方法来获取当前元素的样式

      1. 方法是window的方法,可以直接使用
      2. 需要两个参数:
        • 第一个:要获取样式的元素
        • 第二个:可以传递一个伪元素,一般传 null
    2. parseFloat()函数可解析一个字符串,并返回一个浮点数

      • 该函数指定字符串中的首个字符是否是数字。如果是,则对字符串进行解析,直到达到数字的末端为止,然后以数字返回,而不是字符串。
// 容器实例
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)
}
  1. 计算列宽
    1. 列宽 = (容器宽度 - 列间距) / 列数
// 列宽
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()
})

1.2. 区分图片预加载,获取元素关键属性

接下来需要获取每一个item的高度,因为只有有了每个item高,才可以判断下一列的第一个item位置。

同时我们根据picturePreReading,可分为两种情况:

  1. 需要图片预加载时:图片高度未知
  2. 不需要图片预加载时:图片高度已知

1.2.1. 需要图片预加载时(图片高度不知)

获取图片高度信息时,需要使用一些通用工具方法,创建utils.js进行封装

/**
 * 从 itemElement 中抽离出所有的 imgElements
 */
export const getImgElements = (itemElements) => {
  const imgElements = []
  itemElements.forEach((el) => {
    imgElements.push(...el.getElementsByTagName('img'))
  })
  return imgElements
}

/**
 * 生成所有的图片链接数组
 */
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)
}

等图片加载完成,获取item高度集合

import { getImgElements, getAllImg, onComplateImgs } from './utils.js'

// item 高度集合
let itemHeights = []
/**
 * 监听图片加载完成
 */
const waitImgComplate = () => {
  itemHeights = []
  // 拿到所有元素
  let itemElements = [...document.getElementsByClassName('waterfall-item')]
  // 获取所有元素的 img 标签
  const imgElements = getImgElements(itemElements)
  // 获取所有 img 标签的图片
  const allImgs = getAllImg(imgElements)
  // 图片加载完成,获取高度
  onComplateImgs(allImgs).then(() => {
    itemElements.forEach((el) => {
      itemHeights.push(el.offsetHeight)
    })
    // 渲染位置
    useItemLocation()
  })
}

1.2.2. 不需要图片预加载时(图片高度已知)

/**
 * 图片不需要预加载时,计算 item 高度
 */
const useItemHeight = () => {
  itemHeights = []
  // 拿到所有元素
  let itemElements = [...document.getElementsByClassName('waterfall-item')]
  // 计算 item 高度
  itemElements.forEach((el) => {
    // 依据传入数据计算出的 img 高度
    itemHeights.push(el.offsetHeight)
  })
  // 渲染位置
  useItemLocation()
}

1.2.3. 触发计算,定位item位置

  1. 监听数据获取时,触发对应的计算:等页面渲染完成后,根据图片是否需要预加载,获取item高度集合
// 触发计算
watch(
  () => props.data,
  (newVal) => {
    // 页面渲染完成后
    nextTick(() => {
       props.picturePreReading ? waitImgComplate() : useItemHeight()
    })
  },
  {
    immediate: true,
    deep: true
  }
)
  1. 为每个 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)
}
  1. 创建 getItemLeft 方法:
    1. 要获取当前item的左边距,要先找到最小高度列
    2. 先找到最小高度,在根据最小高度从列高对象中确定所在列
/**
 * 返回列高对象中的最小的高度
 */
export const getMinHeight = (columnHeightObj) => {
  const columnHeightArr = Object.values(columnHeightObj)
  return Math.min(...columnHeightArr)
}

/**
 * 返回列高对象中的最小高度所在的列
 */
export const getMinHeightColumn = (columnHeightObj) => {
  const minHeight = getMinHeight(columnHeightObj)
  return Object.keys(columnHeightObj).find((key) => {
    return columnHeightObj[key] === minHeight
  })
}
  1. 左边距 = (列宽 + 列间距)* 列数 + 容器左边距
import { getMinHeightColumn } from './utils.js'
/**
 * 返回下一个 item 的 left
 */
const getItemLeft = () => {
  // 最小高度所在的列 * (列宽 + 间距)
  const column = getMinHeightColumn(columnHeightObj.value)
  return (
    column * (columnWidth.value + props.columnSpacing) + containerLeft.value
  )
}
  1. 创建 getItemTop 方法
    1. getMinHeight方法,列高对象中的最小的高度
import { getMinHeight } from './utils.js'
/**
 * 返回下一个 item 的 top
 */
const getItemTop = () => {
  // 列高对象中的最小的高度
  return getMinHeight(columnHeightObj.value)
}
  1. 创建 increasingHeight 方法:
    1. 当前item插入后,列高对象数据需要更新
    2. 最小高度列的数据 = item 高度 + 行间距
/**
 * 指定列高度自增
 */
const increasingHeight = (index) => {
	// 最小高度所在的列  
  const minHeightColumn = getMinHeightColumn(columnHeightObj.value)
  // 该列高度自增
  columnHeightObj.value[minHeightColumn] += itemHeights[index] + props.rowSpacing
}
  1. 创建 getMaxHeight方法
    1. 容器高度 = 列高对象中的最大高度
/**
 * 返回列高对象中的最大的高度
 */
export const getMaxHeight = (columnHeightObj) => {
  const columnHeightArr = Object.values(columnHeightObj)
  return Math.max(...columnHeightArr)
}
  1. 在组件销毁时,清除所有的 _style
/**
 * 在组件销毁时,清除所有的 _style
 */
onUnmounted(() => {
  props.data.forEach((item) => {
    delete item._style
  })
})
  1. 最后,在第一次获取数据时,构建高度记录容器
// 触发计算
watch(
  () => props.data,
  (newVal) => {
    // 重置数据源
    const resetColumnHeight = newVal.every((item) => !item._style)
    if (resetColumnHeight) {
      // 构建高度记录容器
      useColumnHeightObj()
    }
  	...
  },
  {
    immediate: true,
    deep: true
  }
)

1.2.4. 适配移动端,动态列

  1. 判断当前是否为移动设备:
/**
 * 判断当前是否为移动设备
 */
export const isMobileTerminal = computed(() => {
  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
})
  1. 列数的变化
:column="isMobileTerminal ? 2 : 5"
  1. 列宽和定位
    1. 当列数变化后,重新构建瀑布流
/**
 * 监听列数变化,重新构建瀑布流
 */
const reset = () => {
  // 延迟 100 毫秒,否则会导致宽度计算不正确
  setTimeout(() => {
    // 重新计算列宽
    useColumnWidth()
    // 重置所有的定位数据,因为 data 中进行了深度监听,所以该操作会触发 data 的 watch
    props.data.forEach((item) => {
      item._style = null
    })
  }, 300)
}

/**
 * 监听列数变化
 */
watch(
  () => props.column,
  () => {
    if (props.picturePreReading) {
      // 在 picturePreReading 为 true 的前提下,需要首先为列宽滞空,列宽滞空之后,会取消瀑布流渲染
      columnWidth.value = 0
      // 等待页面渲染之后,重新执行计算。否则在 item 没有指定过高度的前提下,计算出的 item 高度会不正确
      nextTick(reset)
    } else {
      reset()
    }
  }
)

1.2.5. 无需图片预加载时,优化功能处理

此时当我们把 picturePreReading 修改为 false 时,我们发现瀑布流展示会出现错误。

出现这个错误的原因是因为:当我们不去进行图片预加载时,会直接 获取 waterfall-item ,得到 waterfall-item 的高度。但是因为 图片还没有获取完成,所以得到的高度 不包含 图片高度,从而导致计算的高度错误。

需要利用服务端给我们返回的图片高度这个数据,来构建图片高度,从而跳过图片预加载的过程。

  1. 作用域插槽中,拿到列宽
  2. 将列宽传给item
<template v-slot="{ item, width }">
  <itemVue :data="item" :width="width" />
</template>
  1. 利用列宽, 按照宽度比例, 算出缩放比, 在根据图片高度,算出item高度
<img
  class="w-full rounded bg-transparent"
  :src="data.photo"
  :style="{
    height: (width / data.photoWidth) * data.photoHeight + 'px'
  }"
/>

1.3. 总结

整个瀑布流的构建过程:

  1. 瀑布流的核心就是:通过 relative absolute 定位的方式,来控制每个 item 的位置
  2. 给每个item添加位置属性left top,再通过动态绑定style实现
  3. left top的值需要获取每个item高度item 高度主要由 img 决定
    • 当服务端 不返回img的高度时,我们需要等待 img 加载完成之后,计算高度
    • 当服务端 返回 img高度时:利用此高度为 item 进行高度设定。
  4. 拿到 item 的高度后遍历 item,通过 高度记录容器,获取当前 itemtop left, 同时给最小列高度自增
  5. 当进行响应式切换时,需要重新计算 列宽定位
转载自:https://juejin.cn/post/7322268449409318947
评论
请登录