竖向瀑布流的多多多多多种实现方式
前言
最近又做到了瀑布流的需求,一直用的 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元素被切断
}
元素可能会被切断,可以通过break-inside: avoid;避免。
效果展示:
局限性:
由于排列顺序是从上到下,从左到右,因此视觉上的顺序和数据顺序并不一致,如图所示,第一列第一项是数据的第一项,id为0,而第二列的第一项是数据的第八项,id为7,并且在数据变化时,元素位置也可能发生较大的变化。
修改代码,每秒钟添加一条数据:
因此这种方案适合数据量固定的,对元素位置要求不高的情况使用。
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>
效果如图:
非常完美的实现了,当然缺点就是兼容性非常差,目前只有 Firefox 浏览器并且打开实验性功能后才能实现。
打开方法:
打开 Firefox 浏览器,在地址栏输入about:config,并搜索layout.css.grid-template-masonry-value.enabled ,将其改为 true 即可:
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>
效果如图:
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>
基本实现效果,但是最底部的元素会出现一些问题:
明显 元素49 应该在红框的位置,此时最外层元素的高度等于最长列的高度,方便布局。
解决图片高度不确定的问题
以上两种方式都会受到图片高度的影响,以第二种 flex 布局为例,在排序的过程中,由于图片并未来得及加载,元素的高度实际上仅是文字内容部分的高度,因此问题实际上变成了,如何在开始布局/定位时就获取图片的高度。
此处一般有两种方式:
- 后端直接返回图片的宽高信息,前端直接设置样式
- 前端通过预加载图片,拿到图片的宽高信息
(此处方法参考了参考文档第三篇)
本文演示第二种方法,图片预加载:
工具函数代码如下:
// /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>
此时元素会一项一项的添加,并且添加时图片已经是加载好的状态:
缺点就是有延时,元素是一项一项的加载的。
如果是后端返回的话就容易些了,可以创建一个与图片相同宽高的遮罩loading层,同时也可以一次性渲染元素,不需要一项一项的添加。
grid + JS
grid布局就是将容器分为若干更小的“小行”和“小列”,容器中的元素的大小用占了几小行、占了几小列来表示。
.wrapper {
display: grid;
grid-template-columns: 1fr 1fr 1fr; // 分为三列,1fr 1fr 1fr表示1:1:1,1fr 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 将容器三等分,fr 为 grid 专有的单位,表示比例。
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:1,1fr 2fr表示1:2
grid-auto-rows: 1px; // 一行的高度
column-gap: 10px; // 列间距
}
</style>
效果如下:
总结
| 优势 | 劣势 | |
|---|---|---|
| column-count | 代码量少,实现简单 | 数据顺序混乱,添加项后页面布局会变化 |
| grid | 非常简单,性能优秀 | 兼容性极差 |
| JS + 绝对定位 | JS 控制,非常灵活 | 代码量比较多,容器高度可能会塌陷 |
| JS + flex | flex 布局简单易懂 | 没有明显优势 |
| JS + grid | 代码量较少 | grid 布局有一定学习和理解成本,并需要考虑兼容性 |
如果你有其他实现方式,欢迎交流学习~