总结一下竖向瀑布流的多多多多多种实现方式

1,341 阅读6分钟

竖向瀑布流的多多多多多种实现方式

前言

最近又做到了瀑布流的需求,一直用的 js 方案,想看看目前有没有更好的解决方法,于是就收集了一些流传较广的方法,并总结一下优缺点。

参考文档:

「中高级前端」干货!深度解析瀑布流布局 - 掘金 (juejin.cn)

瀑布流的三种实现方案及优缺点 - 掘金 (juejin.cn)

女友看了小红书的pc端后,问我这个瀑布流的效果是怎么实现的?🤔 - 掘金 (juejin.cn)

喜大普奔!纯 CSS 实现瀑布流布局的方法终于来啦【渡一教育】哔哩哔哩bilibili

数据结构:

[    {        "id": 0,        "value": "Consequatur in ipsa ab sapiente enim. Accusantium aut est voluptas sequi. Quibusdam neque aperiam dolor. Excepturi sunt a minus fuga autem excepturi cupiditate. Fuga aspernatur incidunt aliquid.",        "img": "https://i.pinimg.com/236x/7f/24/8c/7f248c9e18abe79de0d6c79617e03361.jpg"    }  ...]

页面结构:

<template>
    <div class="wrapper">
        <div class="item" v-for="item in data" :key="item.id">
            <img :src="item.img" alt="" />
            <span>{{ item.value }}</span>
            <span class="number">{{ item.id }}</span>
        </div>
    </div>
</template>

基础样式:

.item {
    position: relative;
    display: flex;
    flex-direction: column;
    width: 200px;
    border: 1px solid #000;
    margin-bottom: 10px;
​
    img {
        width: 100%;
    }
​
    .number {
        position: absolute;
        top: 0;
        left: 0;
        background: #fff;
        font-size: 24px;
        font-weight: bold;
        color: red;
    }
}

纯CSS方案

column-count

使用CSS属性column-count: 5;,可以让内容按从上到下,从左到右的顺序排列。

.wrapper {
    column-count: 5;      // 分为几列
    column-gap: 10px;     // 列间距
}
​
.item {
    break-inside: avoid;  // 防止.item元素被切断
}

image-20240612下午21729313

元素可能会被切断,可以通过break-inside: avoid;避免。

效果展示:

image-20240612下午21920168

局限性:

由于排列顺序是从上到下,从左到右,因此视觉上的顺序和数据顺序并不一致,如图所示,第一列第一项是数据的第一项,id为0,而第二列的第一项是数据的第八项,id为7,并且在数据变化时,元素位置也可能发生较大的变化。

修改代码,每秒钟添加一条数据:

Untitled

因此这种方案适合数据量固定的,对元素位置要求不高的情况使用。

grid

使用 grid 布局,这次使用 grid-template-rows: masonry 即可实现瀑布流布局:

<script setup lang="ts">
import data from "../data.json";
</script><template>
    <div class="wrapper">
        <div class="item" v-for="item in data" :key="item.id">
            <img :src="item.img" alt="" />
            <span>{{ item.value }}</span>
            <span class="number">{{ item.id }}</span>
        </div>
    </div>
</template><style>
.item {
    ...
}
  
.wrapper {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    grid-template-rows: masonry;
    gap: 10px;
}
</style>

效果如图:

image-20240617下午111810810

非常完美的实现了,当然缺点就是兼容性非常差,目前只有 Firefox 浏览器并且打开实验性功能后才能实现。

image-20240617下午111937688

打开方法:

打开 Firefox 浏览器,在地址栏输入about:config,并搜索layout.css.grid-template-masonry-value.enabled ,将其改为 true 即可:

image-20240617下午112205681

CSS + JS 方案

这里假设瀑布流显示5列。JS 实现瀑布流的原理大同小异,都是通过维护一个列的高度的数组,每次添加新元素时,找到高度最小的一列,添加在其最后。

绝对定位 + JS

这种方式使用绝对定位来控制元素的位置,缺点也很明显,父元素的宽高都为0,如果页面其他元素较多的话,不利于布局。同时这种方式是在图片加载完成后进行的布局,而不同的图片的加载速度不同,先加载完成的图片所在的元素会先占位,导致页面元素的顺序是混乱的。

<script setup lang="ts">
import { onMounted } from "vue";
import data from "../data.json";
​
const count = 5;      // 5 列
const heightArr = Array.from({ length: count }, () => 0);
​
const findLowest = () => {
    return heightArr.indexOf(Math.min(...heightArr));
};
​
const createItem = (itemInfo: (typeof data)[number]) => {
    const div = document.createElement("div");
    div.className = "item";
    const img = document.createElement("img");
    img.src = itemInfo.img;
    img.onload = () => {
        const index = findLowest();
        div.style.left = `${210 * index}px`;
        div.style.top = `${heightArr[index]}px`;
        heightArr[index] += div.clientHeight + 10;
    };
    const span = document.createElement("span");
    span.innerText = itemInfo.value;
    const span2 = document.createElement("span");
    span2.className = "number";
    span2.innerText = itemInfo.id.toString();
    div.appendChild(img);
    div.appendChild(span);
    div.appendChild(span2);
    return div;
};
​
const render = () => {
    const fragment = document.createDocumentFragment();
​
    data.map((i) => {
        const div = createItem(i);
        fragment.appendChild(div);
    });
​
    document.querySelector(".wrapper")!.appendChild(fragment);
};
​
onMounted(render);
</script>
​
<template>
    <div class="wrapper"></div>
</template>
​
<style>
.wrapper {
    display: flex;
    position: relative;
}
.item {
    ...
}
</style>
​

效果如图:

js-absolute

flex + JS

为了解决绝对定位导致的高度塌陷问题,可以使用flex布局。实现思路是先创建容器,例如需要三列,则创建 3 个容器,再将元素依次放入高度最低的那个容器中,其他的部分与上文的绝对定位布局类似。

<script setup lang="ts">
import { onMounted } from "vue";
import data from "../data.json";
​
const count = 3;
let columnArr: HTMLDivElement[] = [];
​
const generateColumns = () => {
    for (let i = 0; i < count; i++) {
        const div = document.createElement("div");
        div.className = "column";
        document.querySelector(".wrapper")?.appendChild(div);
    }
    columnArr = Array.from(document.querySelectorAll(".column"));
};
​
const findLowest = () => {
    const heightArr = columnArr.map((column) => {
        if (!column) return 0;
        return column.clientHeight;
    });
    return heightArr.indexOf(Math.min(...heightArr));
};
​
const addItems = (items: typeof data) => {
    for (let i = 0; i < items.length; i++) {
        const item = items[i];
        const div = document.createElement("div");
        div.className = "item";
        const img = document.createElement("img");
        img.src = item.img;
        const span = document.createElement("span");
        span.innerText = item.value;
        const span2 = document.createElement("span");
        span2.className = "number";
        span2.innerText = item.id.toString();
        div.appendChild(img);
        div.appendChild(span);
        div.appendChild(span2);
        const index = findLowest();
        columnArr[index]!.appendChild(div);
    }
};
​
const render = () => {
    generateColumns();
    addItems(data);
};
​
onMounted(render);
</script>
​
<template>
    <div class="wrapper"></div>
</template>
​
<style>
.item {
    ...
}
.wrapper {
    display: flex;
    align-items: start;
    gap: 10px;
​
    .column {
        width: 200px;
        border: 1px solid #333;
    }
}
</style>
​

基本实现效果,但是最底部的元素会出现一些问题:

image-20240617下午85116967

明显 元素49 应该在红框的位置,此时最外层元素的高度等于最长列的高度,方便布局。

解决图片高度不确定的问题

以上两种方式都会受到图片高度的影响,以第二种 flex 布局为例,在排序的过程中,由于图片并未来得及加载,元素的高度实际上仅是文字内容部分的高度,因此问题实际上变成了,如何在开始布局/定位时就获取图片的高度

此处一般有两种方式:

  1. 后端直接返回图片的宽高信息,前端直接设置样式
  2. 前端通过预加载图片,拿到图片的宽高信息

(此处方法参考了参考文档第三篇

本文演示第二种方法,图片预加载:

工具函数代码如下:

// /src/utils/index.ts
export const getImgSize = (url: string) => {
    return new Promise<{ width: number; height: number }>((resolve, reject) => {
        const img = new Image();
        img.onload = () => {
            resolve({ width: img.width, height: img.height });
        };
        img.onerror = (err) => {
            reject(err);
        };
        img.src = url;
    });
};

将其添加至原代码中:

<script setup lang="ts">
import { onMounted } from "vue";
import data from "../data.json";
import { getImgSize } from "./util";
​
const count = 3;
let columnArr: HTMLDivElement[] = [];
​
...
​
const addItems = async (items: typeof data) => {
    for (let i = 0; i < items.length; i++) {
        const item = items[i];
        const div = document.createElement("div");
        div.className = "item";
        const img = document.createElement("img");
        img.src = item.img;
        const { height: imgHeight, width: imgWidth } = await getImgSize(
            item.img
        );
      
        ...
        
        columnArr[index]!.appendChild(div);
        div.style.height = `${
            (imgHeight / imgWidth) * 200 + span.clientHeight
        }px`;
    }
};
​
const render = () => {
    generateColumns();
    addItems(data);
};
​
onMounted(render);
</script>
​

此时元素会一项一项的添加,并且添加时图片已经是加载好的状态:

js-flex

缺点就是有延时,元素是一项一项的加载的。

如果是后端返回的话就容易些了,可以创建一个与图片相同宽高的遮罩loading层,同时也可以一次性渲染元素,不需要一项一项的添加。

grid + JS

grid布局就是将容器分为若干更小的“小行”和“小列”,容器中的元素的大小用占了几小行、占了几小列来表示。

.wrapper {
    display: grid;
    grid-template-columns: 1fr 1fr 1fr;     // 分为三列,1fr 1fr 1fr表示1:1:11fr 2fr表示1:2
    grid-auto-rows: 1px;                    // 一行的高度
    column-gap: 10px;                       // 列间距
}
img.onload = () => {
    div.style.gridRowEnd = `span ${Math.ceil(div.scrollHeight / 1)}`;
};

这里的 grid-template-colums: 1fr 1fr 1fr 将容器三等分,frgrid 专有的单位,表示比例。

grid-auto-rows: 1px 规定了“一小行”的高度,即ts代码中的 span 的单位,这里 span 1 就是 1px

column-gap: 10px 则顾名思义就是列间距。注意这里如果设置了行间距 row-gap: 10px 则会将 grid-auto-rows 属性覆盖,即 span 1 相当于 10px

注意 js 代码中的 div.scrollHeight / 1 这里的 1 就是 grid-auto-rows 的值,意味计算元素占据了多少“小行”。

完整代码如下:

<script setup lang="ts">
import { onMounted } from "vue";
import data from "../data.json";
​
const addItems = async (items: typeof data) => {
    const fragment = document.createDocumentFragment();
    for (let i = 0; i < items.length; i++) {
      
        ...
        
        img.onload = () => {
           div.style.gridRowEnd = `span ${Math.ceil(div.scrollHeight / 1)}`;
        };
        
        div.appendChild(img);
        div.appendChild(span);
        div.appendChild(span2);
        fragment.appendChild(div);
    }
    document.querySelector(".wrapper")!.appendChild(fragment);
};
​
const render = () => {
    addItems(data);
};
​
onMounted(render);
</script><template>
    <div class="wrapper"></div>
</template><style>
.item {
    ...
}
.wrapper {
    display: grid;
    grid-template-columns: 1fr 1fr 1fr;     // 分为三列,1fr 1fr 1fr表示1:1:11fr 2fr表示1:2
    grid-auto-rows: 1px;                    // 一行的高度
    column-gap: 10px;                       // 列间距
}
</style>

效果如下:

js-grid

总结

优势劣势
column-count代码量少,实现简单数据顺序混乱,添加项后页面布局会变化
grid非常简单,性能优秀兼容性极差
JS + 绝对定位JS 控制,非常灵活代码量比较多,容器高度可能会塌陷
JS + flexflex 布局简单易懂没有明显优势
JS + grid代码量较少grid 布局有一定学习和理解成本,并需要考虑兼容性

如果你有其他实现方式,欢迎交流学习~