原生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's avatarguozi
上一篇 2024年6月3日 下午5:21
下一篇 2024年6月3日 下午5:23

相关推荐

发表回复

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