瀑布流(waterfull
)布局,也称作masonry
。是一种被广泛应用的多图布局,尤其在移动端。对于一个网页,人类是习惯于横向定宽,垂直滚动来进行阅读的。在展示大量图片时,为避免杂乱无章,采用定宽定列的布局方式,可以促使浏览更加高效。
文章目录
准备工作
开始之前,我们需要准备一些图片方便测试。本节使用canvas
API 来程序化的生成测试图片。
random
封装生成随机数和随机颜色的方法
const random = Math.random;
const floor = Math.floor;
/**
* 随机整数
* @param {number} n
* @returns [0, n)
*/
function intRand(n) {
return floor(random() * n);
}
/**
* 从范围[a, b)内随机
* @param {number} a start
* @param {number} b end
* @returns [a, b)
*/
function rand(a, b) {
if (b) {
const diff = b - a;
return a + intRand(diff);
}
return intRand(a);
}
/**
* 随机HEX颜色
*/
function hexColor() {
return (
"#" +
Array(6)
.fill(0)
.map(() => rand(16).toString(16))
.join("")
);
}
canvas
// 创建一个canvas
function createCanvas(width = 300, height = 150) {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
return canvas;
}
利用canvas
API的toDataURL()
方法,得到编码,用的时候赋值给<image>
的src
属性就好了。
/**
* 生成随机彩色图片URL
*/
function randImage(
text,
width = rand(50, 150),
height = rand(50, 150),
bgColor = hexColor(),
fontColor = "white"
) {
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = fontColor;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = Math.min(width, height) / 2 + "px bold";
ctx.fillText(text, width / 2, height / 2);
return canvas.toDataURL();
}
测试randImage
方法:
<img id="testImage"></img>
<script>
document.getElementById("testImage").src = randImage('测试')
</script>
map
/**
* 生成一个length大小的数组
* @param {Number} length
* @param {Function} func
* @returns {Array}
*/
function map(length, func = i => i) {
return Array.from({ length }, (_, i) => func(i));
};
生成50
张图片直接调用const images = map(50, randImage);
CSS 方案
CSS 的实现五花八门,主要说两种,column-count
和flex
布局。方便演示起见,html
部分用petite-vue
来写。
img
通用样式如下,宽度撑满,块级布局避免img间隔问题
<style>
img {
width: 100%;
border: 4px solid #ccc;
display: block;
}
</style>
column-count
HTML结构无需分列 div.container>div.item*n>img
<div class="container">
<div class="item"><img src="x" alt="x"></div>
<div class="item"><img src="y" alt="y"></div>
<!-- ... -->
</div>
用petite-vue
实现:
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
const images = map(16, randImage);
function Container() {
return {
images,
}
}
createApp({
Container
}).mount();
</script>
<div class="container" v-scope="Container()">
<div class="item" v-for="image in images">
<img :src="image" :alt="image">
</div>
</div>
<style>
.container {
column-count: 3;
margin-right: 8px;
break-inside: avoid;
}
</style>
这样实现的瀑布流由上至下布局,不符合从左到右的习惯,也不支持滚动加载,并不推荐。
flex
HTML结构需要认为分列 div.container>div.column*n>div.item*m>img
<div class="container">
<div class="column">
<div class="item"><img src="x" alt="x"></div>
<div class="item"><img src="y" alt="y"></div>
<!-- ... -->
</div>
<div class="column">
<div class="item"><img src="z" alt="z"></div>
</div>
<!-- ... -->
</div>
用petite-vue
实现:
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
const images = map(50, randImage);
const columns = [[], [], []];
let cur = 0;
async function loadNext() {
if (cur >= images.length) {
cur = 0;
return;
};
const minHeightIndex = cur % 3;
columns[minHeightIndex].push(images[cur]);
cur++;
await loadNext();
}
await loadNext();
function Container() {
return {
columns,
}
}
createApp({
Container
}).mount();
</script>
<div class="container" v-scope="Container()">
<div class="column" v-for="column in columns">
<div class="item" v-for="image in column">
<img :src="src" :alt="src" />
</div>
</div>
</div>
<style>
.column .item {
margin: 4px 8px;
flex: auto;
width: 100%;
}
.column {
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-content: flex-start;
flex: 1;
}
.container {
display: flex;
}
</style>
我们根据图片默认的顺序,从左到右按序填坑。基本的瀑布流就完成了。
基于flex
布局的改进
基本的瀑布流实现,在极端条件,比如某一列的高度异常大时,就会特别不合理。修改images
生成逻辑,使第一列高度变大。
const images = map(50, (i) => {
return randImage(
i,
i % 3 === 0 ? 50 : undefined,
i % 3 === 0 ? 150 : undefined,
)
})
此时第一列的[0,3,6,9,12]
中只有0,3
在视野中,6,9,12
都看不见,但是7,8,10,11,13,14
却都已经在视野里面了,如果图片顺序是比较重要的信息,那显然这样的布局方案是需要改进的。改进的依据就是每张图片的大小信息。
getImageHeight
一般来说,请求拿到的图片只有一个url
地址,没有图片大小信息,如果我们基于大小进行布局,就需要一个获取图片大小的辅助函数。
/**
* 根据 src 获取图片高度
* @param {string} src dataURL
* @returns {Promise<[width, height]>}
*/
function getImageHeight(src) {
return new Promise((res, rej) => {
const img = new Image();
img.src = src;
img.onload = function () {
res([this.width, this.height]);
}
img.onerror = function (e) {
rej(e);
}
})
}
贪心
假定我们分3
列,并记录每列现有的图片总高度,每来一张图片,我们去找3
列中高度最小的那一列来放置图片,即可。
修改分列部分的核心代码:
const heights = [0, 0, 0]; // 增加高度数组,记录每一列的高度
const minHeightIndex = heights.reduce((pi, c, ci, a) => pi = (a[pi] > c) ? ci : pi, 0); // 找到高度最小的一列
const [w, h] = await getImageHeight(images[cur]); // 记录新加入图片的高度到高度数组
heights[minHeightIndex] += h / w; // 由于我们每列同宽,所以 高宽比 足以描述高度差异
完整代码:
<script type="module">
import { createApp } from 'https://unpkg.com/petite-vue?module'
const images = map(50, randImage);
const columns = [[], [], []];
const heights = [0, 0, 0];
let cur = 0;
async function loadNext() {
if (cur >= images.length) {
cur = 0;
return;
};
const minHeightIndex = heights.reduce((pi, c, ci, a) => pi = (a[pi] > c) ? ci : pi, 0);
columns[minHeightIndex].push(images[cur]);
const [w, h] = await getImageHeight(images[cur]);
heights[minHeightIndex] += h / w;
cur++;
await loadNext();
}
await loadNext();
function Container() {
return {
columns,
}
}
createApp({
Container
}).mount();
</script>
<div class="container" v-scope="Container()">
<div class="column" v-for="column in columns">
<div class="item" v-for="image in column">
<img :src="image" :alt="image" />
</div>
</div>
</div>
<style>
.column .item {
margin: 4px 8px;
flex: auto;
}
.column {
display: flex;
flex-wrap: wrap;
flex-direction: row;
align-content: flex-start;
flex: 1;
}
.container {
display: flex;
}
</style>
现在视野之内的图片都是按照顺序的,不存在看不到8
却能看到9
的问题。
懒加载
参考图片的懒加载
瀑布流做懒加载的话,基本上就要服务端提前返回宽高信息了。因为一旦需要前端拿到图片再计算宽高,自然就是提前加载(不是懒加载)了。
我们这里用IntersectionObserver
实现不用贪心的基本瀑布流,核心代码如下,加在mounted
钩子里。同时懒加载的一个小技巧是使用background: url('loading.png')
来在图片未更新之前显示一个占位信息。
mounted() {
const images = document.querySelectorAll('img');
const loadImage = (img) => {
img.src ||= img.dataset.src;
img.onload = () => {
img.style.height = 'auto'; // 给未加载的图片一个默认初始高度,真正加载后再置为auto
}
}
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0) {
loadImage(entry.target);
}
})
});
images.forEach(img => {
observer.observe(img);
})
}
动态规划
这是一个有趣的问题,假如有10张图片,分两列,怎么布局能使的最后两列的高度差最小?
这里我们假设服务端会返回图片的高度,即我们的randImage
函数改写如下:
function randImage(
text,
width = rand(50, 150),
height = rand(50, 150),
bgColor = hexColor(),
fontColor = "white"
) {
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = fontColor;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = Math.min(width, height) / 2 + "px bold";
ctx.fillText(text, width / 2, height / 2);
return {
url: canvas.toDataURL(),
width,
height
}
}
未完待续…