干货!纯js封装瀑布流布局插件

2,735 阅读4分钟

「这是我参与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()
}

这样就实现了我们的第一版 20211111153845.jpg

加点细节

跟随浏览器宽度改变而改变

只需要增加窗口改变的事件监听就行

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个节点 image.png

滚动加载更多

我们可以增加个滚动加载更多的功能

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里面的话,就把动态生成节点的方法换成请求接口就行了

Kapture 2021-11-11 at 17.18.29.gif

优化

细节

因为我们用的时绝对定位,所以子节点就算添加再多,父节点的高度可能都不会改变,这样就会影响到,瀑布流后面的布局了,所以我们可以在每次计算后动态设置高度,容器的高度也就是与高度最高的那列相等。

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