吃惊!原来前端瀑布流还能这么实现?!

641 阅读13分钟

瀑布流布局,相信大家都不陌生,对于不定高的布局场景来说,瀑布流可以最大限度地利用页面的空间,避免元素之间存在空隙。 传统html中实现瀑布流布局需要直接操作真实的dom,计算每个dom的高度数据,同时根据宽高信息来决策每个元素的位置。

Vue的思想是「数据驱动视图」,开发者无需手动访问dom元素,通过操作响应式数据,进而影响页面的渲染。由此可见,在Vue中,瀑布流布局也无需直接操作dom,但是如果要将该核心逻辑封装成Hook,也应该提前将dom的宽高属性计算好,再传入对应的逻辑处理方法中。

这里我们可以写一个瀑布流布局公共组件,下面我带着大家一起实现这个功能吧。

定义数据

既然Vue是数据驱动,那么我们肯定要先从数据作为切入点,考虑到是公共组件,所以在组件设计的时候要考虑到复用性,因此我们需要将目标列表数据作为Props传入组件,该Props是一个Node列表,每个列表项需要包括节点的宽和高。在本例子中,我们先把数据都定义到一个组件中,方便代码展示。

列表定义完成后,我们还需要定义一些常量,比如固定宽度、每次渲染的数量和分段触发阈值(分段渲染要用);对应要定义的变量:起始索引、滚动记录索引、原始数据列表、缓存列表、累计高度列表、渲染结果列表、视口宽度、布局累计宽度和滚动记录距离。

const WIDTH = 400;
const SLICE = 100;
const SCROLL_TOP = 5000;
const waterfallContainer = ref<HTMLElement & { children: HTMLCollection[] }>();
const startIndex = ref(0);
const index = ref(0);
// 原始数据源,需要手动将原始dom数据源处理成布局所需的列表数据,这里只是模拟所需
const nodes = ref(
  Array.from({ length: 10000 }, (_, i) => ({
    width: WIDTH,
    height: Math.floor(Math.random() * 200) + 400,
  })),
);
const cacheList = ref<Any[]>([]);
const heights = ref<number[]>([]);
const renderNodeList = ref<Any[]>([]);
const width = ref(window.innerWidth - 40);
const innerWidth = ref(0);
const recordScrollTop = ref<number>(0);

以上是我们所需要的所有变量,大家先不用关注具体每一个变量的作用,我们继续往下实现。

瀑布流核心逻辑

瀑布流布局的核心思想就是———不断遍历元素,累计高度并寻找最小高,然后将最小高作为y轴的偏移量,以此作为元素的定位信息。

想象一下,有N多个元素,如何让元素尽可能小的利用页面空间,插空渲染。

我们需要定义一个高度记录列表,这个列表的长度L表示页面被分成了L列。

同时对于原始数据的遍历,我们要区分第一行和第二行及后面行,因为第一行不需要找最小高(无参考对象)。

第一行

首先我们先来看第一行的实现:

function layoutNodes() {
    let line = 1;
    let totalX = 0;
    const _nodes = nodes.value;
    for (let i = 0; i < _nodes.length; i++) {
    if (line === 1) {
      cacheList.value.push({
        ..._nodes[i],
        x: totalX,
        y: 20, // 第一行的y偏移就是上边距
      });
      innerWidth.value += _nodes[i].width + 10; // 10代表元素的间隔
      heights.value.push(_nodes[i].height + 20); // 20代表上边距(for 页面顶部)
    }
  }
}

可以看到,line为1的时候,我们直接将遍历的原始数据对象push到cacheList中作为缓存记录,同时我们新增了x和y两个属性,这两个属性分别对应目标渲染元素的横向偏移量和纵向偏移量,也就是元素最终所在的位置。

然后我们对innerWidth进行自增,这里是要对第一行已经占用的横向空间做记录,作为换行的依据。

同时,我们将第一行已经占用的纵向空间进行记录,存放到heights数组中。

第二行及后续行

对于第二行及后续行,我们需要寻找累计高度的最小索引,该索引是后续元素的纵向定位依据,优先找最小高。

const minHIndex = findMinHeight(heights.value);
cacheList.value.push({
    ..._nodes[i],
    x: minHIndex * WIDTH + minHIndex * 10,
    y: heights.value[minHIndex] + 10,
});
heights.value[minHIndex] += _nodes[i].height + 10;

findMinHeight即寻找最小索引方法

function findMinHeight(heights: number[]) {
  return heights.indexOf(Math.min(...heights));
}

同样是接着给cacheList赋值,但是这里涉及到一个数学运算minHIndex * WIDTH + minHIndex * 10。这里可能不太好理解,想象一下,假如这次找到的最小高的索引是1,那就是对应一行中的第二个元素,那么你的偏移肯定是要跨过第一个元素的空间的,同时我们也要把每个元素的间隔也带进去,于是就出现了上述的那个公式了。

对于y偏移就好理解了,直接把最小高取出来,加上纵向间隔,就是元素最终要定位的位置了。

处理完了元素的定位信息后,我们还需要把这个元素的自身高+y间隔,自增给刚刚查找到的最小高元素。此时heights数组也完成了一次更新。

换行条件

我们处理了第一行和后续行对应的逻辑,但我怎么才能知道,什么时候应该换行呢?这里很简单,我们只需要拿已经摆放的x偏移和视口宽度做比较就行。

totalX += _nodes[i].width + 10;
if (totalX >= width.value - 420) {
  line++;
  totalX = 0;
}

totalX是第一行的累计x偏移量,每当遍历到一个新的元素都将该值进行累加(只在第一行用到,作为参照变量),当该值超过视口宽减去页面总margin(非内容区域,这里预设一个值,实际需要props传入)。

做完这一切后,我们的瀑布流布局就基本上实现了,但是其实还有很多细节需要实现:

如果要渲染的列表过长,我们如何进行分段渲染,防止浏览器卡顿?

如果视口尺寸发生变化,如何重新触发布局以适应新的视口尺寸?

默认的布局会导致右侧出现留白,如何将默认的布局区域居中展示?

等等......

任何优秀的产品,都一定会考虑到很多边边角角的细节,替用户考虑到所有可能发生的情况,才能最大限度减小产品Bug的产生,产品也就更稳定。

下面针对边角细节,我们逐一实现。

分段渲染

还是上述的瀑布流核心方法,我们稍作修改。

首先分段渲染触发依据是用户滚动页面,当滚动条快要触底的时候,渲染下一个阶段的列表,那么我们就要区分两种情况———默认布局和滚动布局。

给该方法加一个scroll参数,用做区分。同时当时scroll为false,清空cacheList,以免存在上一次处理过的数据。

if (!scroll) {
    cacheList.value = [];
  }
  const _nodes = scroll
    ? nodes.value.slice(
        startIndex.value * SLICE,
        Math.min((startIndex.value + 1) * SLICE, nodes.value.length),
      )
    : nodes.value.slice(0, SLICE);

我们还需要对_nodes做处理,因为存在分段渲染情况,每次布局都需要对当前目标处理列表进行分割,起始索引就是逻辑起始索引*每次分割的列表长度,结束索引就是下一个起始索引的位置和列表总长度的最小值。

对于默认布局情况,直接默认渲染第一个分段的列表即可。

还有个细节,对于第一行一定只存在于默认布局情况,所以对于滚动布局,不会进入第一行的判断分支。

演示.gif

下面是完整的方法实现⬇️

function layoutNodes(scroll = false) {
  let line = 1;
  let totalX = 0;
  if (!scroll) {
    cacheList.value = [];
  }
  const _nodes = scroll
    ? nodes.value.slice(
        startIndex.value * SLICE,
        Math.min((startIndex.value + 1) * SLICE, nodes.value.length),
      )
    : nodes.value.slice(0, SLICE);
  for (let i = 0; i < _nodes.length; i++) {
    if (line === 1 && !scroll) {
      cacheList.value.push({
        ..._nodes[i],
        x: totalX,
        y: 20,
      });
      innerWidth.value += _nodes[i].width + 10;
      heights.value.push(_nodes[i].height + 20);
    } else {
      const minHIndex = findMinHeight(heights.value);
      cacheList.value.push({
        ..._nodes[i],
        x: minHIndex * WIDTH + minHIndex * 10,
        y: heights.value[minHIndex] + 10,
      });
      heights.value[minHIndex] += _nodes[i].height + 10;
    }
    totalX += _nodes[i].width + 10;
    if (totalX >= width.value - 420) {
      line++;
      totalX = 0;
    }
  }
  return cacheList.value;
}

说完了布局逻辑,下面我们讲一下事件触发逻辑。换句话说,我们如何知晓当前用户访问的列表已经进入到下一个渲染阶段。

监控元素和视口的交叉状态

这里需要用一个浏览器的API———InsectionObserver,不了解的小伙伴可以自行查一下百度,网上资料很多这里不再赘述。简单点理解,就是它可以监控你目标监控的元素和视口的交叉,当交叉一旦发生,就会触发回调。

为什么要用这个方法呢?其实这就是实现分段渲染的一个方法,我们通过触发的回调可以知道当前元素已经进入可视状态,就认为已经加载过了,作为事件触发路径的记录。

不过对于我们的场景,需要考虑对于已经加载过的元素,我们不要让它重复加载,也就是说,当该元素触发过一次回调,下一次不会重复关注它,这里需要记录用户的滚动距离。因为只有不断向下滚动,才能一直加载新的元素,而向上滚动属于返回上面的滚动记录,我们不关注。

function insectionObserver() {
  const observer = new IntersectionObserver(
    (entires) => {
      entires.forEach((entry) => {
        if (entry.isIntersecting) {
          if (document.documentElement.scrollTop > recordScrollTop.value) {
            recordScrollTop.value = document.documentElement.scrollTop;
          }
          const minH = Math.min(...heights.value);
          if (Math.abs(minH - recordScrollTop.value) < SCROLL_TOP) {
            startIndex.value++;
          }
        }
      });
    },
    {
      threshold: 1, // 交叉比例
      root: document, // 相对视口 不写的话也是默认相对视口
    },
    // 不加nextTick获取不到最新的渲染节点
    nextTick(() => {
      const nodes = Array.from(waterfallContainer.value!.children);
      nodes.forEach((node) => {
        observer.observe(node as Any);
      });
    });
  );

当进入交叉判断分支后,首先判断当前滚动记录是否小于页面滚动距离(向下滚动),如是进行赋值。

寻找当前最小高,最小宽和已经记录的滚动距离差值的绝对值如果小于预设滚动阈值,将新的开始索引自增1,表示当前已经进入下一个分段渲染。

封装一个重渲染方法,方便调用,每次调用该方法,重新布局and重新监听。

const rerender = (type = false) => {
  renderNodeList.value = layoutNodes(type);
  insectionObserver();
};
watch([startIndex], () => {
  rerender(true);
});

监听startIndex,更新后重新渲染

视口大小自适应

当用户浏览器窗口尺寸发生变化的时候,之前的样式布局无法适应视口变化后的状态,因此需要监听窗口尺寸变换事件resize,当事件回调触发时,重新调用瀑布布局方法。

为了防止频繁触发该事件,我们对这个事件进行防抖处理。

// 防抖
const debounce = (fn: () => void, delay: number) => {
  let timer: any = 0;
  return function () {
    let that = this,
      context = arguments;
    if (timer) {
      clearTimeout(timer);
    }
    timer = setTimeout(function () {
      fn.apply(that, [...context]);
    }, delay);
  };
};
// 监听窗口尺寸变化
const resizeFn = debounce(() => {
  // 一系列初始化
  window.scrollTo(0, 0);
  recordScrollTop.value = 0;
  startIndex.value = 0;
  heights.value = [];
  innerWidth.value = 0;
  width.value = window.innerWidth - 40;
  rerender();
}, 500);

这里定义一个具有防抖功能的尺寸变化回调,每次触发该回调需要进行一系列初始化操作,最后执行重新渲染方法。

最后,我们将事件绑定在onMounted中,就可以实现全局监听了。

onMounted(() => {
  rerender();
  window.addEventListener('resize', () => {
    resizeFn();
  });
});

演示3.gif

自动居中

这部分主要是css实现,不过多赘述,直接看完整代码即可。

Hook

该方法也可以封装成Hook,把核心逻辑提取出来即可,外层组件只负责事件绑定和数据的接收。

完整代码如下:

import { nextTick, ref, watch } from 'vue';
export default function ({ nodes, w, slice, container, resize, scrollTop }) {
  const WIDTH = w;
  const SLICE = slice;
  const SCROLL_TOP = scrollTop;
  const startIndex = ref(0);
  const index = ref(0);
  // 原始数据源,需要手动将原始dom数据源处理成布局所需的列表数据,这里只是模拟所需
  const cacheList = ref<Any[]>([]);
  const heights = ref<number[]>([]);
  const renderNodeList = ref<Any[]>([]);
  const width = ref(window.innerWidth - 40);
  const innerWidth = ref(0);
  const recordScrollTop = ref<number>(0);
  /**
   * 瀑布流布局 核心方法
   */
  function layoutNodes(scroll = false) {
    let line = 1;
    let totalX = 0;
    if (!scroll) {
      cacheList.value = [];
    }
    const _nodes = scroll
      ? nodes.value.slice(
          startIndex.value * SLICE,
          Math.min((startIndex.value + 1) * SLICE, nodes.value.length),
        )
      : nodes.value.slice(0, SLICE);
    for (let i = 0; i < _nodes.length; i++) {
      if (line === 1 && !scroll) {
        cacheList.value.push({
          ..._nodes[i],
          x: totalX,
          y: 20,
        });
        innerWidth.value += _nodes[i].width + 10;
        heights.value.push(_nodes[i].height + 20);
      } else {
        const minHIndex = findMinHeight(heights.value);
        cacheList.value.push({
          ..._nodes[i],
          x: minHIndex * WIDTH + minHIndex * 10,
          y: heights.value[minHIndex] + 10,
        });
        heights.value[minHIndex] += _nodes[i].height + 10;
      }
      totalX += _nodes[i].width + 10;
      if (totalX >= width.value - 420) {
        line++;
        totalX = 0;
      }
    }
    return cacheList.value;
  }
  /**
   * 寻找最小高度的索引
   */
  function findMinHeight(heights: number[]) {
    return heights.indexOf(Math.min(...heights));
  }
  /**
   * 监听节点是否进入视口
   */
  function insectionObserver() {
    const observer = new IntersectionObserver(
      (entires) => {
        entires.forEach((entry) => {
          if (entry.isIntersecting) {
            if (document.documentElement.scrollTop > recordScrollTop.value) {
              index.value++;
              recordScrollTop.value = document.documentElement.scrollTop;
            }
            const minH = Math.min(...heights.value);
            if (Math.abs(minH - recordScrollTop.value) < SCROLL_TOP) {
              startIndex.value++;
            }
            observer.unobserve(entry.target);
          }
        });
      },
      {
        threshold: 1,
        root: document, // 相对视口 不写的话也是默认相对视口
      },
    );
    // 不加nextTick获取不到最新的渲染节点
    nextTick(() => {
      const nodes = Array.from(container.value!.children);
      nodes.forEach((node) => {
        observer.observe(node as Any);
      });
    });
  }
  /**
   * 重新渲染
   */
  const rerender = (type = false) => {
    renderNodeList.value = layoutNodes(type);
    insectionObserver();
  };
  /**
   * 监听窗口变化
   */
  const resizeFn = () => {
    // 一系列初始化
    window.scrollTo(0, 0);
    recordScrollTop.value = 0;
    startIndex.value = 0;
    heights.value = [];
    innerWidth.value = 0;
    width.value = window.innerWidth - 40;
    rerender();
  };
  resize && resizeFn();
  !resize && rerender();
  watch([startIndex], () => {
    rerender(true);
  });
  return renderNodeList.value;
}

完整代码(组件)

<template>
  <div class="waterfall" :style="{ '--width': `${innerWidth - 10}px` }">
    <div class="waterfall-container" ref="waterfallContainer">
      <div
        v-for="(node, index) in renderNodeList"
        :key="index"
        :data-value="index"
        :style="{
          width: `${node.width}px`,
          height: `${node.height}px`,
          left: `${node.x}px`,
          top: `${node.y}px`,
        }"
        class="waterfall-item"
      >
        <img src="@/assets/bg2.jpeg" alt="" />
        <div class="content">
          <h3>Title</h3>
          <section>
            Lorem ipsum dolor sit amet, consectetur adipisicing elit. Odio a
            inventore impedit reprehenderit ipsam amet sint fugit eos doloribus!
            Temporibus possimus iusto cum blanditiis sunt nemo ex provident non
            quo.
          </section>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
/**
 * 瀑布流
 * 思想:本组件利用 数据驱动视图 无需考虑边写dom边处理布局,而是先将布局所需的列表数据内容全都处理好,然后直接在template中通过for循环将元素直接渲染到页面上即可
 */
import { nextTick, onMounted, ref, watch } from 'vue';
const WIDTH = 400;
const SLICE = 100;
const SCROLL_TOP = 5000;
const waterfallContainer = ref<HTMLElement & { children: HTMLCollection[] }>();
const startIndex = ref(0);
// 原始数据源,需要手动将原始dom数据源处理成布局所需的列表数据,这里只是模拟所需
const nodes = ref(
  Array.from({ length: 10000 }, (_, i) => ({
    width: WIDTH,
    height: Math.floor(Math.random() * 200) + 400,
  })),
);
const cacheList = ref<Any[]>([]);
const heights = ref<number[]>([]);
const renderNodeList = ref<Any[]>([]);
const width = ref(window.innerWidth - 40);
const innerWidth = ref(0);
const recordScrollTop = ref<number>(0);
/**
 * 瀑布流布局 核心方法
 */
function layoutNodes(scroll = false) {
  let line = 1;
  let totalX = 0;
  if (!scroll) {
    cacheList.value = [];
  }
  const _nodes = scroll
    ? nodes.value.slice(
        startIndex.value * SLICE,
        Math.min((startIndex.value + 1) * SLICE, nodes.value.length),
      )
    : nodes.value.slice(0, SLICE);
  for (let i = 0; i < _nodes.length; i++) {
    if (line === 1 && !scroll) {
      cacheList.value.push({
        ..._nodes[i],
        x: totalX,
        y: 20,
      });
      innerWidth.value += _nodes[i].width + 10;
      heights.value.push(_nodes[i].height + 20);
    } else {
      const minHIndex = findMinHeight(heights.value);
      cacheList.value.push({
        ..._nodes[i],
        x: minHIndex * WIDTH + minHIndex * 10,
        y: heights.value[minHIndex] + 10,
      });
      heights.value[minHIndex] += _nodes[i].height + 10;
    }
    totalX += _nodes[i].width + 10;
    if (totalX >= width.value - 420) {
      line++;
      totalX = 0;
    }
  }
  return cacheList.value;
}
/**
 * 寻找最小高度的索引
 */
function findMinHeight(heights: number[]) {
  return heights.indexOf(Math.min(...heights));
}
/**
 * 监听节点是否进入视口
 */
function insectionObserver() {
  const observer = new IntersectionObserver(
    (entires) => {
      entires.forEach((entry) => {
        if (entry.isIntersecting) {
          if (document.documentElement.scrollTop > recordScrollTop.value) {
            recordScrollTop.value = document.documentElement.scrollTop;
          }
          const minH = Math.min(...heights.value);
          if (Math.abs(minH - recordScrollTop.value) < SCROLL_TOP) {
            startIndex.value++;
          }
        }
      });
    },
    {
      threshold: 1,
      root: document, // 相对视口 不写的话也是默认相对视口
    },
  );
  // 不加nextTick获取不到最新的渲染节点
  nextTick(() => {
    const nodes = Array.from(waterfallContainer.value!.children);
    nodes.forEach((node) => {
      observer.observe(node as Any);
    });
  });
}
/**
 * 防抖
 */
const debounce = (fn: () => void, delay: number) => {
  let timer: any = 0;
  return function () {
    let that = this,
      context = arguments;
    if (timer) {
      clearTimeout(timer);
    }
    timer = setTimeout(function () {
      fn.apply(that, [...context]);
    }, delay);
  };
};
/**
 * 重新渲染
 */
const rerender = (type = false) => {
  renderNodeList.value = layoutNodes(type);
  insectionObserver();
};
/**
 * 监听窗口变化
 */
const resizeFn = debounce(() => {
  // 一系列初始化
  window.scrollTo(0, 0);
  recordScrollTop.value = 0;
  startIndex.value = 0;
  heights.value = [];
  innerWidth.value = 0;
  width.value = window.innerWidth - 40;
  rerender();
}, 500);
onMounted(() => {
  rerender();
  window.addEventListener('resize', () => {
    resizeFn();
  });
});
watch([startIndex], () => {
  rerender(true);
});
</script>

<style lang="scss" scoped>
.waterfall {
  margin: 0 20px;
  .waterfall-container {
    min-height: 100vh;
    position: relative;
    box-sizing: border-box;
    width: var(--width);
    margin: 0 auto;
    .waterfall-item {
      position: absolute;
      background: #fff;
      border-radius: 16px;
      border: 1px solid #eee;
      cursor: pointer;
      transition: all 0.3s ease-in-out;
      &:hover {
        border: 1px solid #a6a6a6;
      }
      > img {
        width: 100%;
        border-radius: 16px 16px 0 0;
      }
      .content {
        text-align: left;
        margin: 0 12px;
      }
    }
  }
}
</style>

END