瀑布流组件

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

1. 通用组件:瀑布流

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

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

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

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

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

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

1.1. 获取容器宽度与列宽

  1. 定义组件接收参数:
js 复制代码
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
js 复制代码
<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. 想要计算 总高度 ,那么需要计算出 每一列的高度 ,最高的一列为 总高度

ini 复制代码
// 容器的总高度
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()函数可解析一个字符串,并返回一个浮点数

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

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高度集合

js 复制代码
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. 不需要图片预加载时(图片高度已知)

js 复制代码
/**
 * 图片不需要预加载时,计算 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高度集合
js 复制代码
// 触发计算
watch(
  () => props.data,
  (newVal) => {
    // 页面渲染完成后
    nextTick(() => {
       props.picturePreReading ? waitImgComplate() : useItemHeight()
    })
  },
  {
    immediate: true,
    deep: true
  }
)
  1. 为每个 item 生成位置属性
js 复制代码
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. 先找到最小高度,在根据最小高度从列高对象中确定所在列
js 复制代码
/**
 * 返回列高对象中的最小的高度
 */
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. 左边距 = (列宽 + 列间距)* 列数 + 容器左边距
js 复制代码
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方法,列高对象中的最小的高度
javascript 复制代码
import { getMinHeight } from './utils.js'
/**
 * 返回下一个 item 的 top
 */
const getItemTop = () => {
  // 列高对象中的最小的高度
  return getMinHeight(columnHeightObj.value)
}
  1. 创建 increasingHeight 方法:
    1. 当前item插入后,列高对象数据需要更新
    2. 最小高度列的数据 = item 高度 + 行间距
js 复制代码
/**
 * 指定列高度自增
 */
const increasingHeight = (index) => {
	// 最小高度所在的列  
  const minHeightColumn = getMinHeightColumn(columnHeightObj.value)
  // 该列高度自增
  columnHeightObj.value[minHeightColumn] += itemHeights[index] + props.rowSpacing
}
  1. 创建 getMaxHeight方法
    1. 容器高度 = 列高对象中的最大高度
js 复制代码
/**
 * 返回列高对象中的最大的高度
 */
export const getMaxHeight = (columnHeightObj) => {
  const columnHeightArr = Object.values(columnHeightObj)
  return Math.max(...columnHeightArr)
}
  1. 在组件销毁时,清除所有的 _style
js 复制代码
/**
 * 在组件销毁时,清除所有的 _style
 */
onUnmounted(() => {
  props.data.forEach((item) => {
    delete item._style
  })
})
  1. 最后,在第一次获取数据时,构建高度记录容器
js 复制代码
// 触发计算
watch(
  () => props.data,
  (newVal) => {
    // 重置数据源
    const resetColumnHeight = newVal.every((item) => !item._style)
    if (resetColumnHeight) {
      // 构建高度记录容器
      useColumnHeightObj()
    }
  	...
  },
  {
    immediate: true,
    deep: true
  }
)

1.2.4. 适配移动端,动态列

  1. 判断当前是否为移动设备:
js 复制代码
/**
 * 判断当前是否为移动设备
 */
export const isMobileTerminal = computed(() => {
  return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
})
  1. 列数的变化
js 复制代码
:column="isMobileTerminal ? 2 : 5"
  1. 列宽和定位
    1. 当列数变化后,重新构建瀑布流
js 复制代码
/**
 * 监听列数变化,重新构建瀑布流
 */
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
js 复制代码
<template v-slot="{ item, width }">
  <itemVue :data="item" :width="width" />
</template>
  1. 利用列宽, 按照宽度比例, 算出缩放比, 在根据图片高度 ,算出item高度
html 复制代码
<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. 当进行响应式切换 时,需要重新计算 列宽定位
相关推荐
无语听梧桐12 分钟前
vue3中使用Antv G6渲染树形结构并支持节点增删改
前端·vue.js·antv g6
web前端神器1 小时前
forever启动后端服务,自带日志如何查看与设置
前端·javascript·vue.js
是Yu欸1 小时前
【前端实现】在父组件中调用公共子组件:注意事项&逻辑示例 + 将后端数组数据格式转换为前端对象数组形式 + 增加和删除行
前端·vue.js·笔记·ui·vue
进击的阿三姐2 小时前
vue2项目迁移vue3与gogocode的使用
前端·javascript·vue.js
CiL#3 小时前
vue2+element-ui新增编辑表格+删除行
前端·vue.js·elementui
nbsaas-boot3 小时前
为什么面向对象的设计方法逐渐减少
前端·javascript·vue.js
vx_Biye_Design3 小时前
小学校园“闲书”交易平台的设计与实现-计算机毕业设计源码04282
java·css·vue.js·spring boot·mysql·ajax·myeclipse
桔筐4 小时前
Vue前端打包
前端·javascript·vue.js
与墨学长5 小时前
Rust破界:前端革新与Vite重构的深度透视(上)
开发语言·前端·vue.js·重构·rust·前端框架
卓卓没头发6 小时前
Vuex 核心揭秘:打造高效前端状态库
前端·javascript·vue.js