怎样实现瀑布流布局和图片懒加载?
瀑布流的核心是每张卡片放进当前最矮的一列,用 JavaScript 实现时按图片宽高比算出高度再绝对定位,所以接口最好返回图片尺寸。懒加载优先用 img 的 loading="lazy",要更早预加载或配合加载更多时用 IntersectionObserver 加 rootMargin。首屏图片不要懒加载,图片要预留尺寸,避免布局跳动。
简答
图片社区的首页,卡片宽度一样、高度各不相同,一列列往下排,每张新卡片都放进当前最矮的那一列,这就是瀑布流。它的难点在于要知道每张卡片有多高,才能算出放在哪一列、离顶部多远。
用 JavaScript 实现时,维护一个记录每列当前高度的数组,按顺序给每张卡片找最矮的一列,算出
top和left后绝对定位,再把这一列的高度加上卡片高度。图片还没加载时高度未知,所以最好让接口返回图片的宽高,前端按宽高比算出显示高度,不用等图片下载完再排。懒加载是让屏幕外的图片先不下载,快滚到时再加载。最简单的是给
img加loading="lazy",浏览器自己决定什么时候开始请求。要自己控制提前多少距离开始加载,或者要在滚到底部时请求下一页,就用IntersectionObserver,用rootMargin把触发范围往外扩一些。要注意三点。首屏能看到的图片不要懒加载,否则会拖慢最大内容绘制。每张图片都要预留尺寸,否则加载后撑开高度,页面会跳。列数随屏幕宽度变化时要重新排版,数据很多时还要考虑只渲染可视区域附近的卡片。
瀑布流怎样计算位置
下面的函数接收图片原始宽高,按列宽算出显示高度,每张图放进当前最矮的列。
// 每张图片放进当前最矮的一列,返回每张图的位置
function layout(items, columnCount, columnWidth, gap) {
const heights = new Array(columnCount).fill(0);
return items.map((item) => {
// 按图片原始宽高比算出它在这一列里的高度
const height = Math.round((item.height / item.width) * columnWidth);
const column = heights.indexOf(Math.min(...heights));
const position = { id: item.id, column, top: heights[column] };
heights[column] += height + gap;
return position;
});
}
const items = [
{ id: 'a', width: 300, height: 400 },
{ id: 'b', width: 300, height: 200 },
{ id: 'c', width: 300, height: 300 },
{ id: 'd', width: 300, height: 150 },
];
for (const { id, column, top } of layout(items, 2, 150, 10)) {
console.log(`${id}: 第 ${column} 列, top ${top}`);
}
// a: 第 0 列, top 0
// b: 第 1 列, top 0
// c: 第 1 列, top 110
// d: 第 0 列, top 210
列宽 150,图片 a 原始是 300×400,显示高度就是 200。a 放进第 0 列,第 0 列高度变成 210(加上 10 的间距);b 高 100,放进还是 0 高度的第 1 列,第 1 列变成 110。c 放进较矮的第 1 列,第 1 列变成 270。这时第 0 列是 210,比 270 矮,所以 d 回到第 0 列,top 是 210。
left 不用存,就是 column * (columnWidth + gap)。整个容器的高度取所有列里最高的那一列,要设到容器上,否则绝对定位的卡片撑不开父元素。
为什么强调要接口返回宽高?如果不知道高度,只能等图片加载完再测量,图片加载有先有后,排版就会一次次重新计算,卡片在屏幕上跳来跳去。卡片里除了图片还有标题这类文字时,文字高度也要测量,可以先渲染再用 ResizeObserver 监听卡片尺寸变化,变化后重新排它和它之后的卡片。
列数变化和加载更多
屏幕变宽时列数从 2 变成 3,所有卡片都要重新算一遍位置。可以用 ResizeObserver 监听容器宽度,算出新的列数和列宽,再整体重排。
加载下一页时不用从头算。只要保存每列当前的高度,新数据接着往下排即可。
有没有纯 CSS 的写法
多栏布局 columns: 3 能做出类似的样子,但它是先填满第一列再填第二列,第 2 条数据可能出现在第一列中间,阅读顺序是竖着的,加载更多时已有卡片还会在列之间移动。
原生的瀑布流布局写作 display: grid-lanes,还是实验特性,只能作为增强。几种方案的对比见《CSS Masonry 和传统瀑布流方案应该怎样选择?》。不管用哪种,键盘和读屏都是按 DOM 顺序走的,视觉上相邻的卡片在顺序上不一定相邻。
图片懒加载怎样做
最简单的做法是用浏览器原生的懒加载:
<img src="photo.jpg" loading="lazy" width="300" height="400" alt="雪山日出" />
MDN 说明,loading="lazy" 会把图片推迟到距离视口一段由浏览器决定的距离时再加载。要注意两点。一是宽高一定要写:没加载的图片宽高是 0,可能永远不会和可视区域相交,也就一直不加载;加载后突然撑开还会让页面跳动。二是禁用 JavaScript 时浏览器不会推迟加载,这是防止网站借此追踪用户的滚动位置。
需要自己控制时机,就用 IntersectionObserver:
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src; // 真实地址先放在 data-src 上
observer.unobserve(img); // 加载过的图片不再观察
}
},
{ rootMargin: '300px 0px' }, // 距离视口 300px 时就开始加载
);
document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));
rootMargin 把判断范围往外扩,图片还没进屏幕就开始请求,用户滚到时通常已经加载好了。加载过的图片要 unobserve,不然它在可视区域进进出出,回调会反复触发。这种写法在 JavaScript 失败时图片不会显示,所以能用原生懒加载的场景优先用原生的。
列表底部放一个哨兵元素,同样用 IntersectionObserver 观察它,进入视口时请求下一页,这就是瀑布流的无限滚动,见《无限滚动和虚拟列表怎样选型?怎样触发加载并恢复滚动位置?》。
首屏的图片为什么不能懒加载
首屏能看到的图片,懒加载反而会让它更晚开始下载。懒加载的图片要等浏览器判断出它离视口多远才开始请求,比普通图片晚,而首屏最大的那张图往往就是 LCP 元素,延迟它就拖慢了 LCP。所以首屏的图片用默认的 loading="eager",懒加载只给屏幕外的图片。更多场景见《懒加载为什么有时反而让首屏和首次交互变慢?》,手写懒加载的完整实现见《怎样手写图片预加载和懒加载?》。
常见追问
瀑布流里有上万张图片,页面越来越卡怎么办?
卡片全部留在 DOM 里,节点越来越多,布局和内存开销都会变大。因为每张卡片的位置都已经算好了,可以只渲染和可视区域重叠的卡片,滚动时根据滚动位置算出要显示哪些,其余的从 DOM 移除,这就是虚拟列表的思路。要注意移除前保存好每张卡片的位置,不要重新测量。
图片加载失败怎么处理?
监听
img的error事件,换成占位图或者显示“加载失败,点击重试”。因为位置是按接口返回的宽高算的,加载失败也不会影响整体排版,这又是提前拿到尺寸的一个好处。