我为什么要手写瀑布流布局,是因为即将要做一个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计算」现有元素的高度,才知道每个元素到底该去哪。
步骤:
-
父元素相对定位,每个子元素绝对定位。 -
js根据屏幕宽度和每个元素的宽度,计算最多显示多少列。 -
拿到所有dom元素,遍历并实时计算每一列中现有元素的高度,找到高度最小的那一列,把当前遍历到的这个元素绝对定位到那一列,因为每个元素宽度是一样的,绝对定位的 left
值就是列数*元素宽度,top
值就是这个最小高度。
到这里思路就理清了。
代码实现
先改一下css,把flex去掉,改用定位,其他代码不变。
/* styles.css */
#container {
/* display: flex;
flex-wrap: wrap; */
position: relative;
}
.item {
/* flex-shrink: 0; */
position: absolute;
}
现在是这样的了,毕竟所有元素都绝对定位了,没设置left
和top
值,就会都在页面左上角。别急,下面来用js设置每个元素的left
和top
。
我们观察抖音
和小红书
的布局,就可以看出,它们是一列列的排列的,垂直方向上是上下对齐的(毕竟每个子元素宽度是一样的),水平方向是参差不齐的(因为不同子元素高度不一样),所以我们可以把这些子元素分布成几列。
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