原生JS手写瀑布流布局

我为什么要手写瀑布流布局,是因为即将要做一个h5商城,首页商品是瀑布流布局的,还从来没写过呢,毕竟用的实在是太少了。虽然有很多现成的,但作为一个有追求的程序员,还是要在能力范围内尽量搞懂其实现原理。

什么是瀑布流布局

❝瀑布流,又称瀑布流式布局。是比较流行的一种网站页面布局,视觉表现为参差不齐的多栏布局,随着页面滚动条向下滚动,这种布局还会不断加载数据块并附加至当前尾部。 —— 来自百度百科

我做了个动画来说明这一点:图片

这里为了看的清楚,我们假设每个元素是依次渲染的,可以看到后续的每个元素都在找高度最小的那一列去排列。这种布局非常适用于各个子元素高度不均匀的情况。

整理思路

参考了国内两大知名网站抖音pc版小红书pc版,发现一个共同点,他们都是不管在多大的屏幕尺寸下,「所有元素的宽度是一样的」,即使拖动改变了屏幕大小,元素宽度动态调整了,每个元素跟其他兄弟元素的宽度也是一致的,「只有高度不一致」,而这就是标准的瀑布流布局。

图片
抖音
图片
小红书

那么,我就可以暂时先把每个元素的宽度定死,先不考虑改变屏幕宽度的情况。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>瀑布流布局demo</title>
    <link rel="stylesheet" href="styles.css">
</head>
<body>
  <div id="container">
    <!-- 内容块 -->
    <div class="item">1</div>
    <div class="item">2</div>
    <div class="item">3</div>
    <div class="item">4</div>
    <div class="item">5</div>
    <div class="item">6</div>
    <div class="item">7</div>
    <div class="item">8</div>
    <div class="item">9</div>
    <div class="item">10</div>
    <div class="item">11</div>
    <div class="item">12</div>
    <div class="item">13</div>
    <div class="item">14</div>
    <div class="item">15</div>
    <div class="item">16</div>
    <div class="item">17</div>
    <div class="item">18</div>
    <div class="item">19</div>
    <div class="item">20</div>
  </div>
</body>
</html>

总共20个div,现在还没有内容,无法撑开高度,我们就假设所有奇数的高100px,偶数的高150px,每一个的宽度都是200px,然后先用flex布局(或者不用flex布局,把item设为inline-block),看看会是什么效果。

/* styles.css */
#container {
  display: flex;
  flex-wrap: wrap;
}

.item {
  flex-shrink: 0;
  width: 200px;
  margin-bottom: 10px;
  background: #ccc;
  border: 1px solid #999;
  box-sizing: border-box;
  text-align: center;
}

/* 随机高度,用于演示 */
.item:nth-child(odd) {
  height: 100px;
}

.item:nth-child(even) {
  height: 150px;
}
图片

flex和inline-block效果差不多,这可不是我们想要的。那怎么才能让每个元素去找到它该去的地方呢?这时就要用到「css定位」了。

光有定位还不够,还得用「js计算」现有元素的高度,才知道每个元素到底该去哪。

步骤:

  1. 父元素相对定位,每个子元素绝对定位。
  2. js根据屏幕宽度和每个元素的宽度,计算最多显示多少列。
  3. 拿到所有dom元素,遍历并实时计算每一列中现有元素的高度,找到高度最小的那一列,把当前遍历到的这个元素绝对定位到那一列,因为每个元素宽度是一样的,绝对定位的left值就是列数*元素宽度,top值就是这个最小高度。

到这里思路就理清了。

代码实现

先改一下css,把flex去掉,改用定位,其他代码不变。

/* styles.css */
#container {
  /* display: flex;
  flex-wrap: wrap; */
  position: relative;
}
.item {
  /* flex-shrink: 0; */
  position: absolute;
}

现在是这样的了,毕竟所有元素都绝对定位了,没设置lefttop值,就会都在页面左上角。图片别急,下面来用js设置每个元素的lefttop

我们观察抖音小红书的布局,就可以看出,它们是一列列的排列的,垂直方向上是上下对齐的(毕竟每个子元素宽度是一样的),水平方向是参差不齐的(因为不同子元素高度不一样),所以我们可以把这些子元素分布成几列。

Q: 那到底是几列呢?

A: 获取父容器宽度,除以元素宽度和间隙的和,向下取整,就是最多可以显示的列数。

const container = document.getElementById('container')
const containerWidth = container.offsetWidth
// 列数
const columnCount = Math.floor(containerWidth / (200 + 10));

有列数还不够,瀑布流的核心是「参差不齐错落有致,每个元素找最矮的那一列」去显示。所以还要知道每一列的总高度。初始化每列高度的数组,用来保存每列的总高度,初始值为0。

let columnHeights = Array(columnCount).fill(0);

然后遍历子元素,每次找到高度最矮的那一列,这里就要用到高度数组了,当前元素绝对定位的left值就是下标乘以宽度加间隔的和,top值就是最矮的那一列的总高度。然后更新那一列的高度,就是加上元素自身高度和间隔的高度。

结合上面的代码,整理完善一下:

// script.js
window.onload = function () {
  // 获取父元素dom
  const container = document.getElementById('container');
  // 列宽,也就是每个子元素
  const columnWidth = 200;
  // 间隔宽度
  const columnGap = 10;
  // 父元素自身宽度
  const containerWidth = container.offsetWidth;
  // 计算最多显示多少列
  const columnCount = Math.floor(containerWidth / (columnWidth + columnGap));
  // 初始化每列高度的数组
  let columnHeights = Array(columnCount).fill(0);

  const items = Array.from(container.getElementsByClassName('item'))
  items.forEach(item => {
    // 找到高度最矮的那一列的下标
    const minColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
    // 当前元素的top值就是最矮的那一列的高度
    const top = columnHeights[minColumnIndex];
    // 当前元素的left值就是列宽乘以最矮的那一列的下标
    const left = minColumnIndex * (columnWidth + columnGap);

    item.style.top = `${top}px`;
    item.style.left = `${left}px`;

    // 实时更新高度(自身高度+间隔)
    columnHeights[minColumnIndex] += item.offsetHeight + columnGap;
  });
};

此时效果如下:图片基本上已经实现了。

加载更多

一般这种网站数据都很多,都是分页加载的,我们可以监听页面滚动事件,当页面触底时,动态加载更多数据。

window.addEventListener('scroll', () => {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
    // 模拟加载更多数据,动态创建出10个item元素
    const newItems = [];
    for (let i = 0; i < 10; i++) {
      const newItem = document.createElement('div');
      newItem.className = 'item';
      newItem.style.height = `${Math.floor(Math.random() * 100) + 100}px`;
      container.appendChild(newItem);
      newItems.push(newItem);
    }
    // 依然跟上面一样遍历
    newItems.forEach(item => {
      // 找到高度最矮的那一列的下标
      const minColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
      // 当前元素的top值就是最矮的那一列的高度
      const top = columnHeights[minColumnIndex];
      // 当前元素的left值就是列宽乘以最矮的那一列的下标
      const left = minColumnIndex * (columnWidth + columnGap);

      item.style.top = `${top}px`;
      item.style.left = `${left}px`;

      // 实时更新高度(自身高度+间隔)
      columnHeights[minColumnIndex] += item.offsetHeight + columnGap;
    });
  }
});

运行了一遍才发现,只要一滑动,还没触底呢就不停的加载更多,尝试加防抖也不行,那一定是判断触底的代码有问题,调试了一下才发现,原来是document.body.offsetHeight一直是0,难怪呢会一直触发加载更多呢,导致这里又耽误了时间,不知道是什么原因。

图片

后来检查元素才想起来,原来是我们的子元素全部都是绝对定位的,导致父元素高度塌陷了,所以body的高度也一直是0。我还一直在看js的问题,哎!

知道了原因,解决方法就简单了,在每次遍历完成item数组,更新高度数组的值之后,给父元素container设置一个高度,这个高度就是最高的那一列的高度:

container.style.height = `${Math.max(...columnHeights)}px`;

很明显目前这个代码不够优雅,重复代码过多。

我们把核心的那段共同代码封装成一个函数,接收要遍历的那些dom元素的数组作为参数。

// 布局
function layoutItem(items) {
  items.forEach(item => {
    // 找到高度最矮的那一列的下标
    const minColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
    // 当前元素的top值就是最矮的那一列的高度
    const top = columnHeights[minColumnIndex];
    // 当前元素的left值就是列宽乘以最矮的那一列的下标
    const left = minColumnIndex * (columnWidth + columnGap);

    item.style.top = `${top}px`;
    item.style.left = `${left}px`;

    // 实时更新高度(自身高度+间隔)
    columnHeights[minColumnIndex] += item.offsetHeight + columnGap;
  });
}

然后在最开始的时候,和页面触底的时候分别调用这个layoutItems函数。

const initItems = Array.from(container.getElementsByClassName('item'));
layoutItems(initItems);

window.addEventListener('scroll', () => {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
    const newItems = [];
    for (let i = 0; i < 10; i++) {
      const newItem = document.createElement('div');
      newItem.className = 'item';
      newItem.textContent = `New ${Math.floor(Math.random() * 100)}`;
      newItem.style.height = `${Math.floor(Math.random() * 100) + 100}px`;
      container.appendChild(newItem);
      newItems.push(newItem);
    }
    layoutItems(newItems);
  }
  container.style.height = `${Math.max(...columnHeights)}px`;
});

其实设置container高度的这句代码应该放到layoutItems函数里面去。

看看效果

总结

好了,原生js手写瀑布流布局到这里就完成了。我们这里加载更多用的是监听scroll事件,其实还可以结合之前的一篇文章,用IntersectionObserver这个api,在页面内容底部放一个loading元素,观察这个loading元素与页面的交叉情况。

如果用vue来实现,也是同样的思路,也是根据容器宽度和子元素宽度计算列数,不过可以不用操作dom了,只用处理数据,把所有数据分成一个二维数组,有几列就有几项,然后处理每一条新数据往哪个子数组里塞。

最后贴一下整理之后的全部js代码就结束了:

// script.js
window.onload = function () {
  // 获取父元素dom
  const container = document.getElementById('container');
  // 列宽
  const columnWidth = 200;
  // 间隔宽度
  const columnGap = 10;
  // 父元素自身宽度
  const containerWidth = container.offsetWidth;
  // 计算最多显示多少列
  const columnCount = Math.floor(containerWidth / (columnWidth + columnGap));
  // 初始化每列高度的数组
  let columnHeights = Array(columnCount).fill(0);

  function layoutItems(items) {
    items.forEach(item => {
      // 找到高度最矮的那一列的下标
      const minColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
      // 当前元素的top值就是最矮的那一列的高度
      const top = columnHeights[minColumnIndex];
      // 当前元素的left值就是列宽乘以最矮的那一列的下标
      const left = minColumnIndex * (columnWidth + columnGap);

      item.style.top = `${top}px`;
      item.style.left = `${left}px`;

      // 实时更新高度(自身高度+间隔)
      columnHeights[minColumnIndex] += item.offsetHeight + columnGap;
    });

    // 这里不给高度的话,会一直触发loadMore
    container.style.height = `${Math.max(...columnHeights)}px`;
  }

  function loadMoreItems() {
    const newItems = [];
    for (let i = 0; i < 10; i++) {
      const newItem = document.createElement('div');
      newItem.className = 'item';
      newItem.style.height = `${Math.floor(Math.random() * 100) + 100}px`;
      container.appendChild(newItem);
      newItems.push(newItem);
    }
    layoutItems(newItems);
  }

  function handleScroll() {
    if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
      loadMoreItems();
    }
  }

  window.addEventListener('scroll', handleScroll);

  // 初始布局
  const initItems = Array.from(container.getElementsByClassName('item'));
  layoutItems(initItems);
};

原创文章,作者:guozi,如若转载,请注明出处:https://www.sudun.com/ask/88401.html

(0)
guozi的头像guozi
上一篇 2024年6月3日 下午5:21
下一篇 2024年6月3日 下午5:23

相关推荐

  • 如何参加sema展会?

    想要了解如何参加SEMA展会?那么首先让我们来了解一下什么是SEMA展会。这个展会的历史与发展可谓是令人赞叹,而参加这样的展会也能带来诸多好处。但是,要参加SEMA展会并不是一件轻…

    行业资讯 2024年4月18日
    0
  • 企业网站托管的优势及选择技巧

    今天,我们将带您一起探讨一个备受关注的话题——企业网站托管。随着互联网的不断发展,越来越多的企业开始意识到拥有一个优质的网站对于业务发展的重要性。然而,搭建和维护一个高效稳定的企业…

    行业资讯 2024年4月9日
    0
  • 服务专业的热水器维修

    随着互联网的飞速发展,网络安全问题也日益突出。而为了保障网络安全,网络安全加速行业应运而生。那么什么是网络安全加速行业?它又有着怎样的重要性和发展现状?同时,我们也不得不思考,为什…

    行业资讯 2024年3月20日
    0
  • 如何测试dns,怎么测试dns污染严重程度

    随着互联网的发展,DNS污染问题越来越受到人们的关注。但什么是DNS污染呢?它是如何影响和危害我们的网络生活的呢?如何检测我们的DNS是否被污染了?这些问题一直困扰着我们。在本文中…

    行业资讯 2024年5月9日
    0

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注