🦘🦘深度解析动态计算+定位实现瀑布流布局

1. 瀑布流布局的定义

页面上给人一种参差不齐的多栏布局,其中元素大部分为图片,图片的宽度是统一固定的,但是由于高度不一样,第一行图片排满之后,新的图片会插入到第一排中高度最低的图片下面,并更新高度,如此循环,最终达到瀑布流式的效果。下面看两个瀑布流布局的常见例子:

小红书:

淘宝M端:

除了小红书和淘宝,很多的产品和网站都会用到瀑布流布局,利用瀑布流布局能够有效的吸引用户的眼球、有效的利用空间,作为前端开发者,都应该掌握瀑布流布局的实现思路。

其实实现瀑布流布局的方法非常多,使用纯 CSS 或者使用 JS 都能实现,我们看下具体的实现方案!

2. 具体的实现方案

column-count

column-count 表示分栏数目,column-gap 表示分栏的间距;这里表示分 2 栏,两栏之间的间距为 30px

js 复制代码
.container {
  padding: 10px;
  column-count: 3;
  column-gap: 30px;
}

这种方法存在一些缺点:排列顺序是先上下后左右,而用户是横排观看,因此无法将优先级较高的项排列在前。无限滚动时由于新元素的加入导致,第二列上面的元素会移动到左边一列的最下面导致出现抖动。

gird布局

这种方式也可以实现,而且不会出现 column-count 布局以及 flex 布局的缺点,由于我对 gird 布局不是很了解,因此这里不提供具体的讲解思路,有兴趣的小伙伴可以自行查阅相关的资料。

flex布局

将容器设置为 flex 布局,设置允许换行 + 平均分布所有项目之间的间距。

js 复制代码
.container { 
    display: flex; 
    flex-wrap: wrap; 
    justify-content: space-evenly; 
}

缺点:由于 flex 布局是一行一行的,无法实现卡片的等宽不等高,导致每行上下卡片会出现空隙

定位布局

计算每个 card 的高度,使用 absolute 定位动态计算 card 高度的偏移位置,这是一些商业瀑布流的常见做法。缺点:计算代价大,可能会造成大量重排。

这里着重讲解使用 JS + absolute 定位实现瀑布流布局!

3. 动态计算+定位实现

先看下最终的效果:

我这里使用 Vue3 实现该功能,主要可以分为三个步骤:

  1. 创建图片元素并加入页面中
  2. 根据容器的宽度计算有多少列,以及间隙的数量
  3. 设置每张图片的位置(关键)
  4. 监听窗口 resize 事件,重新设置每张图片的位置

首先定义好图片的宽度以及间隙的宽度,瀑布流布局一定是要固定图片的宽度的,高度不相等

js 复制代码
const imgWidth = 200; // 每张图片的宽度
const spaceWidth = 30; // 水平间隙的宽度
const spaceHeight = 30; // 垂直间隙的高度

创建图片元素加入页面

使用 glob 批量导入指定文件夹下的图片,如果是在真实项目的话,图片可能是由接口返回;不同的处理方式代码会不一样,所以下面的代码仅供参考。

js 复制代码
// 导入所有的图片
let imageObj = import.meta.glob('@/assets/img/*.*', { eager: true });
let imageList = Object.values(imageObj).map((v: any) => v.default);

给每张图片都设置为绝对定位,这里有一个关键点:在图片加载完成之后(通过 onload 事件判断),调用 setPositions 方法重置每张图片的位置,不然整个页面的布局会很混乱,达不到想要的效果。

js 复制代码
const createImgs = () => {
  for (let i = 0; i < imageList.length; i++) {
    let img = document.createElement('img');
    img.src = imageList[i];
    img.onload = setPositions;
    img.style.position = 'absolute';
    img.style.width = imgWidth + 'px';
    img.style.transition = '0.3s';
    ImgContainer.value.appendChild(img);
  }
};

计算列数和间隙

外部容器的宽度不是固定的,采用百分比布局:

js 复制代码
.container {
  position: relative;
  width: 60%;
  margin: 50px auto;
}

首先通过 clientWidth 拿到容器的宽度,同时计算出有多少列以及间隙的数量,然后再重新计算容器的宽度并修改,防止容器出现多余的间隙,影响美观。

js 复制代码
// 计算一共有多少列,间隙的数量
const cal = () => {
  let containerWidth = ImgContainer.value.clientWidth; // 容器的宽度
  let columns = Math.floor(containerWidth / imgWidth); // 计算列数
  let spaceNumber = columns - 1; // 间隙数量
  ImgContainer.value.style.width =
    columns * imgWidth + spaceNumber * spaceWidth + 'px'; // 重置容器的宽度
  return {
    sapce: spaceWidth,
    columns,
  };
};

设置每张图片的位置

这是最关键的一步,核心:每一轮找到高度最小的列,添加图片

准备好一个数组,其元素个数等于列数,初始元素的值均为0,例如 nextTops[i] = j 表示第i + 1列下一张图片的纵坐标是 j,下面通过几张图来详细分析:

初始状态:

根据每列图片的高度以及间隙的高度(这里是30),更新 nextTops 数组中的元素:

开启下一轮,找出 nextTops 中的最小值,也就是第一列,因此第四张图片放到第一列:

再次更新 nextTops 数组中的元素,然后以此类推就可以完成整个页面的图片布局啦!

js 复制代码
// 设置每张图片的位置
const setPositions = () => {
  ImgContainer.value.style.width = '60%'; // 重置容器的宽度
  let info = cal(); // 得到列数和间隙空间
  let nextTops = new Array(info.columns); // 该数组的长度为列数,每一项表示该列的下一个图片的纵坐标
  nextTops.fill(0); // 数组每一项填充为0
  for (let i = 0; i < ImgContainer.value.children.length; i++) {
    let img = ImgContainer.value.children[i];
    // 找到nextTops中的最小值作为当前图片的纵坐标
    let minTop = Math.min.apply(null, nextTops);
    img.style.top = minTop + 'px';
    // 重新设置数组这一项的下一个top值
    let index = nextTops.indexOf(minTop); // 得到使用的是第几列的top值
    nextTops[index] += img.height + spaceHeight;
    let left = index * info.sapce + index * imgWidth;
    img.style.left = left + 'px';
  }
  let max = Math.max.apply(null, nextTops); // 求最大值
  ImgContainer.value.style.height = max + 'px'; // 3. 设置容器的高度
};

监听窗口大小改变

当窗口大小改变后,容器的宽度会改变,重新设置每张图片的位置即可,同时由于 resize 事件频繁触发会浪费性能,因此采用防抖的方式优化。

js 复制代码
const headleResize = _.debounce(setPositions, 300);

onMounted(() => {
  createImgs();
  window.addEventListener('resize', headleResize);
});

onUnmounted(() => {
  window.removeEventListener('resize', headleResize);
});

到这里,基本就完成瀑布流布局了,当然这里只是提供一种思路,具体的实现细节还要根据不同的项目需求去做一些处理,还有很多小问题是需要处理的,以下是完整的代码:

js 复制代码
<template>
  <div ref="ImgContainer" class="container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import * as _ from 'lodash';

// 导入所有的图片
let imageObj = import.meta.glob('@/assets/img/*.*', { eager: true });
let imageList = Object.values(imageObj).map((v: any) => v.default);

const imgWidth = 200; // 每张图片的宽度
const spaceWidth = 30; // 水平间隙的宽度
const spaceHeight = 30; // 垂直间隙的高度
const ImgContainer = ref();

// 1. 加入图片元素
const createImgs = () => {
  for (let i = 0; i < imageList.length; i++) {
    let img = document.createElement('img');
    img.src = imageList[i];
    img.onload = setPositions;
    img.style.position = 'absolute';
    img.style.width = 200 + 'px';
    img.style.transition = '0.3s';
    ImgContainer.value.appendChild(img);
  }
};

// 设置每张图片的位置
const setPositions = () => {
  ImgContainer.value.style.width = '60%'; // 重置容器的宽度
  let info = cal(); // 得到列数和间隙空间
  let nextTops = new Array(info.columns); // 该数组的长度为列数,每一项表示该列的下一个图片的纵坐标
  nextTops.fill(0); // 数组每一项填充为0
  for (let i = 0; i < ImgContainer.value.children.length; i++) {
    let img = ImgContainer.value.children[i];
    // 找到nextTops中的最小值作为当前图片的纵坐标
    let minTop = Math.min.apply(null, nextTops);
    img.style.top = minTop + 'px';
    // 重新设置数组这一项的下一个top值
    let index = nextTops.indexOf(minTop); // 得到使用的是第几列的top值
    nextTops[index] += img.height + spaceHeight;
    let left = index * info.sapce + index * imgWidth;
    img.style.left = left + 'px';
  }
  let max = Math.max.apply(null, nextTops); // 求最大值
  ImgContainer.value.style.height = max + 'px'; // 3. 设置容器的高度
};

// 计算一共有多少列,间隙的数量
const cal = () => {
  let containerWidth = ImgContainer.value.clientWidth; // 容器的宽度
  let columns = Math.floor(containerWidth / imgWidth); // 计算列数
  let spaceNumber = columns - 1; // 间隙数量
  ImgContainer.value.style.width =
    columns * imgWidth + spaceNumber * spaceWidth + 'px'; // 重置容器的宽度
  return {
    sapce: spaceWidth,
    columns,
  };
};

const headleResize = _.debounce(setPositions, 300);

onMounted(() => {
  createImgs();
  window.addEventListener('resize', headleResize);
});

onUnmounted(() => {
  window.removeEventListener('resize', headleResize);
});
</script>

<style lang="scss" scoped>
.container {
  position: relative;
  width: 60%;
  margin: 50px auto;
}
</style>
相关推荐
大前端爱好者1 小时前
React 19 新特性详解
前端
随云6321 小时前
WebGL编程指南之着色器语言GLSL ES(入门GLSL ES这篇就够了)
前端·webgl
寻找09之夏2 小时前
【Vue3实战】:用导航守卫拦截未保存的编辑,提升用户体验
前端·vue.js
非著名架构师2 小时前
js混淆的方式方法
开发语言·javascript·ecmascript
多多米10053 小时前
初学Vue(2)
前端·javascript·vue.js
敏编程3 小时前
网页前端开发之Javascript入门篇(5/9):函数
开发语言·javascript
柏箱3 小时前
PHP基本语法总结
开发语言·前端·html·php
新缸中之脑3 小时前
Llama 3.2 安卓手机安装教程
前端·人工智能·算法
hmz8563 小时前
最新网课搜题答案查询小程序源码/题库多接口微信小程序源码+自带流量主
前端·微信小程序·小程序
看到请催我学习3 小时前
内存缓存和硬盘缓存
开发语言·前端·javascript·vue.js·缓存·ecmascript