「这是我参与11月更文挑战的第10天,活动详情查看:2021最后一次更文挑战」
前言
瀑布流又称瀑布流式布局,由于图片的高度是不一致的,所以在多列布局中默认布局下很难获得满意的排列。而瀑布流能够实现在页面上参差不齐的多栏布局。常用于图片社区、电商网站等
优点
1、因为瀑布流有吸引力,瀑布流会在它的页面底部给你不断地加载新的信息,,通过给出不完整的视觉图片去吸引你的好奇心,让你停不下来想要不断的向下探索。采用这种方案的产品,往往可以通过瀑布流加强用户的停留时间,提高用户的使用粘度。
2、用户一扫而过的快速阅读模式,短时间内可以看到大量的信息,瀑布流的懒加载模式,又避免点击的的翻页操作,在最小的操作成本下能够获得最多的内容体验,瀑布流的视觉方式,让人容易沉浸其中,不容易被打断。
3、另外瀑布流的主要特质就是:定宽而不定高,这样的页面设计区别于传统的矩阵式图片布局模式,巧妙的利用视觉层级,视线的任意流动来缓解视觉的疲劳。
瀑布流更适合那些随意浏览,不带目的性的使用场景,就像是在逛街一样,边走边看,同时被街边琳琅满目的商品吸引着,所以比较适合的图片、小说、资讯类的场景,以现有的成功案例来说,以 UGC 为主的相关网站很多在使用瀑布流进行承载。
原理
瀑布流布局的特点是等宽不等高。为了让最后一行的差距最小,从第二行开始,需要将图片放在第一行最矮的图片下面,以此类推。父元素设置为相对定位,图片所在元素设置为绝对定位。然后通过设置 top 值和 left 值定位每个元素。
第一版:静态实现
我们先开发第一版简易版本,首先准备工作html和css
<style>
.waterfall { position: relative; }
.waterfall-item {
position: absolute;
width: 300px; height: 100px; background: rgb(236, 146, 10);
margin-bottom: 10px; display: flex; justify-content: center; align-items: center;
}
.num {
font-size: 18px;
color: #fff;
border-radius: 100%;
width: 25px;
height: 25px;
line-height:25px;
text-align: center;
border: 1px solid #fff;
}
</style>
<div id="waterfall" class="waterfall">
<div class="waterfall-item"><div class="num">1</div></div>
<div class="waterfall-item"><div class="num">2</div></div>
<div class="waterfall-item"><div class="num">3</div></div>
<div class="waterfall-item"><div class="num">4</div></div>
<div class="waterfall-item"><div class="num">5</div></div>
<div class="waterfall-item"><div class="num">6</div></div>
<div class="waterfall-item"><div class="num">7</div></div>
</div>
为了方便测试,我们通过JS随机为 waterfall-item 设置 100 到 400 的高度
window.onload = function() {
const waterfall = document.getElementById('waterfall');
for (let i = 0; i < waterfall.children.length; i++) {
waterfall.children[i].style.height = getRandomHeight(1,4)+ 'px'
}
}
function getRandomHeight(min = 1, max = 4) {
return ((Math.floor(Math.random() * (max-min+1))) + min) * 100
}
先处理第一行的元素,第一行的top值设置为0,left 为 (itemWidth + gap) * i ,同时需要保存每列的高度。
class WaterFall {
constructor(container, options) {
this.gap = options.gap || 0; // 间距
this.container = container; // 容器
this.heightArr = []; // 保存每列的高度信息
this.items = container.children; // 子节点
}
layout() {
if(this.items.length === 0) return;
const gap = this.gap;
const pageWidth = this.container.offsetWidth;
const itemWidth = this.items[0].offsetWidth;
const columns = parseInt(pageWidth / (itemWidth + gap)); // 总共有多少列
for (let i = 0; i < this.items.length; i++) {
if(i < columns) { // 第一列
this.items[i].style.top = 0;
this.items[i].style.left = (itemWidth + gap) * i + 'px';
this.heightArr.push(this.items[i].offsetHeight)
}
}
}
}
到第二行后需要选择插入的位置,因为我们已经把前面节点的高度保存在了heightArr,我们选择高度最小的那一列优先被插入。然后计算top值,top = heightArr[index] + gap。
class WaterFall {
constructor(container, options) {
......
}
layout() {
if(this.items.length === 0) return;
const gap = this.gap;
const pageWidth = this.container.offsetWidth;
const itemWidth = this.items[0].offsetWidth;
const columns = parseInt(pageWidth / (itemWidth + gap)); // 总共有多少列
for (let i = 0; i < this.items.length; i++) {
let top, left;
if(i < columns) { // 第一列
top = 0; left = (itemWidth + gap) * i;
this.heightArr.push(this.items[i].offsetHeight)
} else {
const minIndex = this.getMinIndex(this.heightArr);
top = this.heightArr[minIndex] + gap;
left = this.items[minIndex].offsetLeft
// 重新计算每列的高度
this.heightArr[minIndex] += (this.items[i].offsetHeight + gap);
}
this.items[i].style.top = top + 'px';
this.items[i].style.left = left + 'px';
}
}
// 计算高度最小的列
getMinIndex(heightArr) {
let minIndex = 0;
let min = heightArr[minIndex]
for (let i = 1; i < heightArr.length; i++) {
if(heightArr[i] < min) {
min = heightArr[i]
minIndex = i;
}
}
return minIndex;
}
}
new出实例,调用运行
window.onload = function() {
const waterfall = document.getElementById('waterfall');
for (let i = 0; i < waterfall.children.length; i++) {
const element = waterfall.children[i];
const height = getRandomHeight(4,1)
element.style.height = height+ 'px'
}
const water = new WaterFall(waterfall, {gap: 10})
water.layout()
}
这样就实现了我们的第一版
加点细节
跟随浏览器宽度改变而改变
只需要增加窗口改变的事件监听就行
class WaterFall {
constructor(container, options) {
this.gap = options.gap || 0;
this.container = container;
this.items = container.children;
this.heightArr = [];
window.addEventListener('resize', () => {
this.heightArr = [];
this.layout()
});
}
...
}
我们可以增加一个过渡效果,让体验更好一点
.waterfall-item {
...
transition: all 0.1s;
}
第二版:动态加载的元素
真正的需求肯定不是像我们这样元素静态存在的,我们现在通过代码模拟请求接口,然后把节点动态插入到容器中。
<div id="waterfall" class="waterfall">
</div>
<script>
var index = 0; // 用于展示第几个节点
const waterfall = document.getElementById('waterfall')
function getData(num = 5) {
return new Promise((resolve, reject) => {
setTimeout(() => { // 延迟 1s
const fragment = document.createDocumentFragment();
for (let i = 0; i < num; i++) {
const div = document.createElement('div');
const numDiv = document.createElement('div');
div.className = 'waterfall-item'
numDiv.className = 'num'
numDiv.textContent = index + 1;
index++
div.appendChild(numDiv);
div.style.height = getRandomHeight(4,1)+ 'px'
fragment.appendChild(div);
}
waterfall.appendChild(fragment);
resolve()
}, 1000)
})
}
getData(20)
// 随机获取高度 100 ~ 400
function getRandomHeight(max = 4, min = 1) {
return ((Math.floor(Math.random() * (max-min+1))) + min) * 100
}
</script>
我们动态创建了节点,但这时候因为节点是动态生成的,所以我们得监听子节点生成或移除的事件
class WaterFall {
constructor(container, options) {
this.gap = options.gap || 0;
this.container = container;
this.items = container.children || [];
this.heightArr = [];
window.addEventListener('resize', () => {
this.heightArr = [];
this.layout()
});
this.container.addEventListener('DOMSubtreeModified', () => {
// 子节点生成或移除时重新计算
this.heightArr = [];
this.layout()
})
}
...
}
这样初始的时候我们创建20个节点
滚动加载更多
我们可以增加个滚动加载更多的功能
var loading = false;
window.onscroll = async function() {
const scrollTop = document.documentElement.scrollTop; // 滚动条位置
const clientHeight = document.documentElement.clientHeight;
const scrollHeight = document.body.scrollHeight; // 完整高度
if((scrollTop + clientHeight >= scrollHeight) && !loading) {
loading = true;
await getData(5); // 一次增加5个
loading = false;
}
}
这样就完成啦,如果是在vue或者react里面的话,就把动态生成节点的方法换成请求接口就行了
优化
细节
因为我们用的时绝对定位,所以子节点就算添加再多,父节点的高度可能都不会改变,这样就会影响到,瀑布流后面的布局了,所以我们可以在每次计算后动态设置高度,容器的高度也就是与高度最高的那列相等。
class WaterFall {
...
layout() {
for (let i = 0; i < this.items.length; i++) {
...
this.container.style.height = this.getMaxHeight(this.heightArr) + 'px';
}
}
getMaxHeight(heightArr) {
let maxHeight = heightArr[0]
for (let i = 1; i < heightArr.length; i++) {
if(heightArr[i] > maxHeight) {
maxHeight = heightArr[i]
}
}
return maxHeight;
}
...
}
性能
我们现在的实现是,每次动态加载都会从头开始重新计算,这样就增大了性能的损耗,其实我们每次获取新数据后,前面已经排好的节点就可以不需要再重新计算了,只需要计算新加进来的节点的位置就行,所以我们增加一个属性renderIndex来计算每次渲染完的位置,上完整插件部分的代码:
class WaterFall {
constructor(container, options) {
this.gap = options.gap || 0;
this.container = container;
this.items = container.children || [];
this.heightArr = [];
this.renderIndex = 0; // 保存位置
window.addEventListener('resize', () => {
// 每次浏览器宽度改变都需要重新计算,所以重置 renderIndex、heightArr
this.renderIndex = 0;
this.heightArr = [];
this.layout()
});
this.container.addEventListener('DOMSubtreeModified', () => {
this.layout();
})
}
layout() {
if(this.items.length === 0) return;
const gap = this.gap;
const pageWidth = this.container.offsetWidth;
const itemWidth = this.items[0].offsetWidth;
const columns = parseInt(pageWidth / (itemWidth + gap)); // 总共有多少列
// for循环改成while循环,使用 renderIndex 为开始渲染的位置
while (this.renderIndex < this.items.length) {
let top, left;
if(this.renderIndex < columns) { // 第一列
top = 0; left = (itemWidth + gap) * this.renderIndex;
this.heightArr.push(this.items[this.renderIndex].offsetHeight)
} else {
const minIndex = this.getMinIndex(this.heightArr);
top = this.heightArr[minIndex] + gap;
left = this.items[minIndex].offsetLeft
this.heightArr[minIndex] += (this.items[this.renderIndex].offsetHeight + gap);
}
this.container.style.height = this.getMaxHeight(this.heightArr) + 'px';
this.items[this.renderIndex].style.top = top + 'px';
this.items[this.renderIndex].style.left = left + 'px';
this.renderIndex++;
}
}
getMinIndex(heightArr) {
let minIndex = 0;
let min = heightArr[minIndex]
for (let i = 1; i < heightArr.length; i++) {
if(heightArr[i] < min) {
min = heightArr[i]
minIndex = i;
}
}
return minIndex;
}
getMaxHeight(heightArr) {
let maxHeight = heightArr[0]
for (let i = 1; i < heightArr.length; i++) {
if(heightArr[i] > maxHeight) {
maxHeight = heightArr[i]
}
}
return maxHeight;
}
}
OK,大功告成。
总结
总体来说并不难,但是细节和性能方面还是能继续优化下去,比如事件监听加入防抖之类的,这些就留给大家自己去优化了。最后感谢大家的阅读,希望大家看完后能有收获,有不对或者可以改进的地方,可以在评论区告诉我呀。
代码在这里:github