CSS瀑布流这事儿,最近算是在前端圈里彻底“转正”了。以前我们拿到瀑布流需求,第一反应基本都是“上Masonry.js”或者“用columns硬凑”,总觉得自己在给JavaScript打工。现在新版CSS Grid布局模块(Grid Level 3)里加入了一个看起来平平无奇、实际很能打的值——grid-template-rows: masonry,核心代码真的只有三行,配合一个属性开关就能跑起来,传统的JavaScript布局方案可以正式退居二线了。
这篇文章主要聊清楚几件事:老方案到底卡在哪、新方案的原理是什么、那三行代码怎么拆开用、换到生产环境要处理哪些兼容和性能问题。适合所有写过瀑布流或者正在被瀑布流折磨的前端开发,读完你不仅能直接动手改造现有项目,还能在团队评审时说清楚为什么可以不用JavaScript库了。
1. 先聊聊以前的瀑布流为什么让人头疼
1.1 三种老方案:JS库、CSS多列、Grid模拟,各有各的坑
我最早做瀑布流是在电商活动页,需求很简单:图片列表错落排列,每张图宽度固定,高度不固定,从上往下看要像瀑布一样填满空隙。当时第一反应就是引Masonry.js,这几乎是行业标配。它做的事情总结起来就三步:拿到所有子元素的高度,维护一个列高度数组,然后把新元素插到当前最矮的那一列下面。听起来不复杂,但实现起来全是细节——图片是否加载完成、容器宽度变化之后要不要重新计算、绝对定位之后元素脱离文档流、动态插入数据时要手动触发layout()。项目小还好,项目大一点,布局代码和业务逻辑纠缠在一起,维护成本立刻上来了。
第二条路是用CSS多列布局columns: 3 220px。这个方案代码最少,视觉效果也最接近瀑布流,但它有一个致命问题:内容按列从上到下填充,第一列排满才排第二列。这种顺序对于看图场景影响不大,一旦涉及文字卡片、商品列表、阅读顺序敏感的内容,用户往下滚动时会发现元素的先后顺序完全被打乱了,读屏软件的阅读顺序也跟着乱。更麻烦的是,多列布局的“列”是不受Grid控制的,想要某个卡片跨两列、调整某列内元素的排序逻辑,基本无从下手。
第三条路是Grid配合行高模拟,典型写法是grid-auto-rows: 8px,然后给每个子元素设置grid-row-end: span 30或者类似值。这个方案确实能做出瀑布流效果,但问题在于:那个span 30是拍脑袋估出来的,要根据图片高度、间距、字体大小不停调,内容一变就得重新测一遍。而且Grid的网格单元是矩形,元素高度一旦比预估的span值多几个像素,布局就会错位,出现大段空白。我见过不少项目用这个方案,最后无一例外靠JavaScript动态计算span值来补锅——绕了一圈,又回到了JS手里。
这三条路我列个表方便对比:
| 方案 | 原理 | DOM顺序 | 性能 | 维护成本 |
|---|---|---|---|---|
| JavaScript库(Masonry等) | JS测量高度、绝对定位 | 容易被打乱 | 低(反复触发reflow) | 高(需要手动layout) |
| CSS columns | 内容按列填充 | 乱序 | 中 | 低但不可控 |
| Grid + span模拟 | 网格行高取模 | 保持 | 中 | 高(span估不准) |
| CSS Grid masonry | 浏览器原生布局引擎 | 保持 | 高 | 低 |
1.2 浏览器把它变成了“布局引擎的活”
CSS masonry值之所以被拿出来单独说,是因为它把这套“找最矮列、塞进去”的算法直接下沉到了浏览器原生布局引擎里。你可以把布局引擎想象成一个经验丰富的仓库管理员:他手里有4根柱子,每放一个箱子他会看一眼哪根柱子目前堆得最矮,然后把箱子放上去,全程不用你指挥。这就是grid-template-rows: masonry最核心的语义——行方向不再按照固定的Grid行轨道来排布,而是按“列”把元素堆成参差不齐的瀑布。
这个设计思路比以前的方案高明在哪?最直观的一点,它不需要JavaScript参与测量和定位。你只是告诉浏览器“这个容器是一个masonry网格”,接下来所有子元素的高度、位置、间距、响应式变化,全部由浏览器的布局算法自动处理。窗口resize时,浏览器会重新计算列数、重新排列,无需监听事件,也无需手动调用任何方法。其次,DOM顺序是完整的、稳定的,这直接保留了阅读顺序和可访问性。爬虫拿到的是原始HTML顺序,读屏软件也能按文档顺序正常朗读,这些都是JS库方案很难同时做到的。
还有一个容易忽视的点:CSS masonry是CSS Grid家族的一员,所以它天然继承Grid的强项——轨道尺寸、间距、媒体查询、跨列跨行这些能力都能无障碍叠加使用。也就是说,它不是“又一个JavaScript库的替代品”,而是把瀑布流从一个工具链问题变成了一个纯样式问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三行核心代码逐行拆解
2.1 第一行和第二行:Grid容器与masonry值
先看最基本的三行代码:
css复制.waterfall {
display: grid;
grid-template-rows: masonry;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
第一行display: grid好理解,把容器切换成Grid布局上下文。真正的主角是第二行grid-template-rows: masonry,它的意思是:行方向不启用传统的行轨道,而是启用瀑布流模式。在这个模式下,元素沿着列方向从上往下堆叠,哪一列当前最矮,下一个元素就自动排进哪一列。
这里要注意方向问题。grid-template-rows: masonry对应的是纵向瀑布流,也就是页面竖着滚动、元素按列排列,这是最常见的场景。如果你想要的是横向瀑布流——横向滚动、元素按行排列——那么就写成grid-template-columns: masonry。两种方向在语法上是完全对称的,实际开发里后者用得少,但做横向滚动图集或者时间轴场景时很好使。
第三行grid-template-columns: repeat(4, 1fr)定义了4列等宽轨道。这里我用了最直观的写法,生产环境里更推荐的其实是自适应列宽:repeat(auto-fill, minmax(220px, 1fr)),这样不管容器是窄屏手机还是宽屏显示器,列数都会自动调整。比如容器宽度800px时,每列至少220px,浏览器会算出能塞下3列还是4列,多余空间平均分配。
2.2 为什么说“三行代码”不是标题党
网上天天有人说“三行代码”,很多其实是拿个demo骗人点进去。但CSS瀑布流这句确实没夸张,上面那段代码扔到支持masonry的浏览器里,就是一个能跑的瀑布流。子元素不需要额外加类名、不需要指定位置,写几个div进去就能看到高度不一的卡片自动排列成错落有致的效果。
不过为了更贴近实战,通常我还会多写一个“保险”属性,把那三行扩成五行:
css复制.waterfall {
display: grid;
grid-template-rows: masonry;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
align-tracks: start;
}
第四行align-tracks很关键,后面第三节我会专门讲。为什么说它是保险?因为masonry模式下,默认情况下子元素会在交叉轴方向上被拉伸,导致每个卡片宽度被强行拉到和列宽完全一致。对于图片为主的瀑布流,这种拉伸往往是符合预期的;但如果卡片内部还有文字、按钮,拉伸可能让卡片里的内容间距变得很怪。加上align-tracks: start之后,每个卡片保持自己的自然尺寸,只做列内起始对齐,视觉更干净。
2.3 gap与间距:终于不用再手动算gutter
老项目用Masonry.js的时候,每列之间的间距靠gutter参数控制,做响应式还要跟着媒体查询调整。CSS masonry直接继承了Grid的gap属性,一行代码同时控制行间距和列间距:
css复制.waterfall {
gap: 20px 16px;
}
第一个值是行间距,第二个值是列间距,只写一个值时两者相同。这个细节看起来不起眼,但真的能省不少事。以前用JS库做瀑布流时,item底部通常还要额外加margin,不然最后一行的贴边效果会很奇怪;现在gap是Grid布局的内建行为,不会出现margin合并、也不会因为容器padding导致尾行间距多出一截。
还有一个实用小技巧:如果你希望不同断点下间距不同,直接加媒体查询即可,比如手机端gap: 8px、桌面端gap: 24px,浏览器会完整地应用到每条轨道之间,不需要写任何JavaScript逻辑。
3. 关键属性与细节控制
3.1 masonry-auto-flow:顺序优先还是填洞优先
masonry布局引擎虽然自动帮你找最矮列,但“找到最矮列之后怎么放”其实还有两种策略,由masonry-auto-flow控制:
css复制.waterfall {
masonry-auto-flow: next;
}
取值是next时,浏览器严格按照DOM顺序往下排:第一个元素放第1列,第二个元素放第2列……第5个元素回到第1列继续向下排。这种模式的好处是顺序绝对可预期,视觉上从前到后、从上到下都遵循文档顺序。坏处是,如果某些元素比较矮,可能导致某列比其他列短一截,空出一些不规则的缺口。
默认值其实是definite-first,意思是:先放置那些显式指定了grid-column或grid-row的元素,其余元素再按“填洞优先”的方式插入到当前最矮的列里。这个策略更接近过去Masonry.js的体验——尽量让每列高度接近,减少白边。两种策略怎么选?我个人的经验是:如果内容是图片墙,追求视觉紧凑,用默认的definite-first;如果内容是文字卡片列表,对阅读顺序敏感,就显式设成next。
另外masonry-auto-flow还支持dense关键字,比如masonry-auto-flow: next dense。加了dense之后,浏览器会在保证顺序前提下尽可能把矮元素塞进前面的空隙里,适合“结果列表”这类不想留太多空洞的场景。
3.2 align-tracks与默认的stretch陷阱
这是我在改造项目时踩过最深的坑。纯看masonry示例,很多Demo只用三行代码就能跑出效果,但如果你把示例里的卡片换成内容高度固定的按钮组,就会发现问题:每个卡片被拉成了整列宽度,卡片里的元素也跟着被拉大,视觉上一块一块很蠢。
原因在于masonry模式下,交叉轴方向(纵向瀑布流对应的就是水平方向)的默认对齐方式是align-tracks: normal,行为等价于stretch,也就是把每个item拉伸到和所在轨道一样的宽度。想让卡片保持“内容宽度”而不是“列宽度”,需要这样写:
css复制.waterfall {
grid-template-rows: masonry;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
align-tracks: start;
}
align-tracks控制的是每个轨道内项目在交叉轴上的对齐方式,start表示从列起点对齐且不做拉伸。如果你想要每列内的卡片居中、或者靠右,也可以分别设center、end。这里我建议在没有特殊视觉要求的情况下,一律加上align-tracks: start,因为stretch的拉伸效果绝大多数时候是干扰而非帮助。
3.3 跨列、跨行与混排玩法
CSS masonry并不限制你只能放等宽的小卡片。它毕竟是Grid家族成员,grid-column和grid-row仍然有效。想让某个卡片跨两列,写grid-column: span 2即可:
css复制.card--wide {
grid-column: span 2;
}
这个跨列卡片会占据两根轨道的位置,masonry引擎在计算最矮列时会把这根“大柱子”当作占用两列来处理,后续元素会自动绕着它排列。这种玩法很适合做图片墙里“偶尔一张大图”的效果,比JS库时代要简单得多——以前用Masonry.js做跨列卡片需要额外写colSpan逻辑,还容易和其他元素重叠。
grid-row在masonry模式下的作用和普通Grid不太一样。正常Grid里grid-row: span 2表示跨越两个行轨道,但在grid-template-rows: masonry的容器里,行轨道本身没有固定高度,所以显式设置grid-row通常意义不大,除非配合masonry-auto-flow: definite-first给某些元素一个明确的“先排位置”。实际开发中,跨列就够用了,跨行基本用不到。
4. 完整实操:从静态卡片到动态图库
4.1 第一个能跑的卡片墙
先来一个最朴素的demo,把代码复制到本地就能看到效果。我建议直接用Firefox或者Safari打开测试,这俩浏览器对masonry的支持相对成熟。如果你用的是Chrome,需要先到chrome://flags/#enable-experimental-web-platform-features里打开实验性功能开关,然后再看效果。
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSS Masonry Demo</title>
<style>
.waterfall {
display: grid;
grid-template-rows: masonry;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
align-tracks: start;
}
.card {
background: #f1f3f5;
border-radius: 12px;
padding: 20px;
}
.card--tall { padding-bottom: 80px; }
.card--short { padding-bottom: 20px; }
.card--wide { grid-column: span 2; }
</style>
</head>
<body>
<div class="waterfall">
<div class="card card--tall">卡片1</div>
<div class="card card--short">卡片2</div>
<div class="card card--tall">卡片3</div>
<div class="card card--wide">卡片4</div>
<div class="card card--short">卡片5</div>
<div class="card card--tall">卡片6</div>
<div class="card card--short">卡片7</div>
</div>
</body>
</html>
打开这个页面,你会看到卡片1、2、3排在第一行,卡片4由于跨了两列,排到第二行时会占据两根轨道的宽度,卡片5、6、7则会自动绕开它继续排布。整个过程中没有任何JavaScript参与,窗口宽度变化时列数和排列会自动刷新,这种“不用管”的体验,过去用Masonry.js时不手动调一遍根本享受不到。
4.2 图片瀑布流:等宽不等高的正确姿势
实际项目里最常见的是图片瀑布流。做法也很简单,卡片里放img,图片宽度撑满卡片,高度自适应。但这里有个很大的坑:如果img不附带宽高信息,浏览器在图片加载完成前不知道图片该占多高,masonry引擎会先按一个很小的默认高度把图片放进去,等图片加载完再把高度撑开,导致卡片跳动、位置错乱。
我的建议是,在HTML里给每张图加上width和height属性,再用CSS把图片设置成自适应:
html复制<img src="xxx.jpg" width="600" height="800" loading="lazy" alt="">
css复制.card img {
width: 100%;
height: auto;
display: block;
object-fit: cover;
}
width和height属性不是摆设,浏览器拿到这两个值后,即使图片还没加载完,也能提前知道宽高比,预留出正确大小的占位空间。配合loading="lazy",可视区域外的图片不会立即加载,滚动到附近时才加载,进一步降低首屏开销。这样做了之后,masonry布局几乎不会跳动,滚动体验会顺滑很多。
还有一个细节:图片的alt和object-fit: cover需要注意。如果产品图不是按固定宽高比裁剪的,cover会让图片被裁切掉一部分,这里最好和设计确认清楚;如果图片本身就是要完整显示,那就保持height: auto,让图片按原比例撑出卡片高度。
4.3 动态加载更多与滚动性能优化
瀑布流项目免不了“滚动到底部加载更多”这个交互。用JavaScript库时,每次动态插入元素后都要手动调用一次布局方法;CSS masonry的好处是:直接把新元素appendChild进去,浏览器会自动重新布局,不需要任何额外代码。
下面是一个简单的最小实现:
javascript复制const waterfall = document.querySelector('.waterfall');
const loadMore = document.querySelector('#load-more');
loadMore.addEventListener('click', async () => {
const data = await fetch('/api/items').then(res => res.json());
const fragment = document.createDocumentFragment();
data.forEach(item => {
const card = document.createElement('div');
card.className = 'card';
const img = document.createElement('img');
img.src = item.url;
img.width = item.width;
img.height = item.height;
img.loading = 'lazy';
card.appendChild(img);
card.innerHTML += `<p>${item.title}</p>`;
fragment.appendChild(card);
});
waterfall.appendChild(fragment);
});
这里用了DocumentFragment,把一批新节点先攒起来再一次性插入,减少DOM操作次数。代码本身不复杂,亮点在于没有任何一行布局逻辑——你不用计算“当前哪列最矮”,不用在插入后调用masonry.layout(),甚至不用关心容器高度变化。
滚动性能方面,我建议给卡片加上content-visibility: auto:
css复制.card {
content-visibility: auto;
contain-intrinsic-size: 400px;
}
content-visibility: auto的意思是:当卡片不在视口内时,浏览器可以跳过它的渲染工作,只保留一个占位高度;这个占位高度由contain-intrinsic-size提供。对于长列表很有效,能显著降低DOM数量大时的渲染开销。需要注意,contain-intrinsic-size给的是一个估算值,如果卡片实际高度和估算值差太多,滚动到附近时可能会有轻微跳动,所以这个值要按卡片平均高度来设置,别拍脑袋写个200px糊弄。
5. 兼容性现状与降级方案
5.1 各浏览器支持情况速查
CSS masonry目前还在往W3C标准推进过程中,没有走到“全部现代浏览器默认开启”的阶段。先把我知道的实际支持情况列出来:
| 浏览器 | 支持版本 | 是否需要开关 | 备注 |
|---|---|---|---|
| Firefox | 101+ | 早期版本需开启layout.css.grid-template-masonry-value.enabled,后续版本默认支持 |
最早的吃螃蟹者,稳定性不错 |
| Safari | 16.0+ | 无需开关 | 支持较完整,iOS Safari可用 |
| Chrome | 117+ | 需要开启#enable-experimental-web-platform-features |
还在实验阶段,Edge同理 |
这个表格信息可能随版本更新而变,但我希望你记住一个结论:目前(写这篇文章的时间点)Safari和Firefox的体验最省心,Chrome/Edge用户想直接访问你的masonry页面,可能还需要手动打开浏览器开关。这就是为什么生产环境不能只写三行代码,必须要配降级方案。
5.2 @supports优雅降级,别让老浏览器白屏
好消息是,CSS本身提供了特性检测,也就是@supports规则。我们可以把masonry写法放在@supports里,浏览器不支持时自动走备用方案:
css复制.waterfall {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
align-items: start;
}
@supports (grid-template-rows: masonry) {
.waterfall {
grid-template-rows: masonry;
align-tracks: start;
}
}
不支持masonry的浏览器里,这个容器会退化成普通Grid布局:每行高度由该行最高的卡片决定,视觉上不是错落的瀑布,但至少内容不会重叠、不会乱序,页面整体还能正常阅读。如果你希望视觉上更接近瀑布流,可以把@supports not分支改成CSS columns方案:
css复制@supports not (grid-template-rows: masonry) {
.waterfall {
columns: 3 220px;
column-gap: 16px;
}
.waterfall .card {
break-inside: avoid;
margin-bottom: 16px;
}
}
columns方案能模拟出瀑布流的样子,但会牺牲DOM顺序,所以适用于图片、卡片这种对顺序不敏感的内容。两个降级思路怎么选,核心看你的内容对顺序的敏感程度。
5.3 场景取舍:什么时候还得继续用JavaScript库
虽然CSS masonry很强,但我不建议你立刻把所有Masonry.js项目推倒重来。如果你的项目有以下几个特征,老方案仍然有它的价值:
第一,项目需要非常复杂的排序、筛选、过滤动画。Masonry.js这类库不只负责布局,还内置了排序、筛选过滤、重排动画、增删元素动画,这些能力CSS masonry短期内不可能覆盖。第二,目标用户以Chrome桌面浏览器为主,而且你无法接受让用户开flag。这种情况下直接上masonry会让大部分用户看不到效果,降级方案又等于白做,等于双重成本。第三,团队里没有人愿意捯饬@supports、content-visibility这些性能和兼容性细节,而JS库的方案团队人人都会,那就别强行炫技,工程选型还是要看团队能维护什么。
我倾向的建议是:新项目、或者准备重构的模块,可以把CSS masonry纳入技术方案,配上columns降级;老项目如果运行稳定,没必要为了“三行代码”专门重构,除非它已经因为JS库性能问题被反复投诉。
6. 我踩过的坑和社区常见问题
6.1 明明写了masonry,为什么卡片全被拉伸
这是新手最容易遇到的问题:代码看起来和官方demo一模一样,但卡片被拉成了“豆腐块”。原因就是前面说的align-tracks默认行为是stretch。解决办法很简单,在容器上加上align-tracks: start。如果你发现加了之后卡片之间出现了肉眼可见的空白,那是正常的——卡片不再填满列宽,只是按内容宽度排列,视觉上会显得没那么整齐。此时你可能需要检查一下卡片内部是否设置了width: 100%的图片,这种图片会让卡片宽度重新等于列宽,效果上等同于stretch。
6.2 图片加载之后高度一直跳,看得人眼晕
核心原因是图片没有预留宽高比占位。布局引擎在图片加载前拿到的高度是0或者很小,图片加载完突然撑开,整个masonry重排一次。解决方案就是我在图片瀑布流部分说的:给img加上width和height属性,或者用CSS的aspect-ratio预先声明宽高比:
css复制.card img {
aspect-ratio: 4 / 3;
width: 100%;
height: auto;
object-fit: cover;
}
这样浏览器在图片加载前就知道该给它留多大的空间,masonry从一开始就能算对位置,几乎不会跳动。
6.3 动态插入元素之后,顺序和预期不一样
如果你用了默认的definite-first策略,动态追加的元素不一定按DOM顺序老老实实排在各列末尾,它有可能被插到某个更矮的列里,导致你在第10个元素的位置看到了刚插入的第20个元素,看着像是“乱序”。解决方法是明确设置masonry-auto-flow: next,强制按顺序排列。代价是列高度可能没那么均衡,会出现一些空洞。实际项目中我一般优先保证可预期性,所以会显式设成next。
6.4 卡片内容高度不一,滚动时明显卡顿
长列表场景下,几百个卡片全部参与布局渲染会拖慢滚动帧率。除了前面提到的content-visibility: auto,还可以配合contain: layout style进一步隔离卡片内部的样式影响:
css复制.card {
content-visibility: auto;
contain-intrinsic-size: 400px;
contain: layout style;
}
contain: layout style能让每个卡片的内部布局和样式不影响外部,浏览器优化起来更容易。不过测试中发现,contain: layout style如果设置在某些特殊组件上可能引起显示异常,所以加之前最好实际滚动测一下。
最后分享一点个人体会。我最早是从Masonry.js切过来的,那时候每次看到控制台里一堆layout()调用和reflow警告就头疼。现在用CSS masonry,最明显的感受不只是代码量少了,而是“不用手动同步状态”这件事本身带来的心理负担减轻了许多。窗口尺寸、图片加载、数据插入这些以前需要手动协调的时机,现在都由浏览器统一处理了。如果你的项目刚好在Safari和Firefox上跑得多,试着把那三行代码写进去,体验一下“布局引擎自己干活”的感觉。如果你还在用Chrome调试,记得先去chrome://flags里把实验性功能打开,不然看到的只会是降级后的普通Grid。
