很多做网站的朋友第一次接触瀑布流,原因往往不是什么高大上的业务需求,而是"我想把一批好看的图片放一个页面里慢慢翻"。我这个项目的起因就是这个——手头攒了一堆适合当壁纸的"女神图片",浏览器里一张张点开看太累,标签页开多了又卡。干脆自己写一个瀑布流页面,把这些图都摊在一个页面里,鼠标一滚就能刷完。结果这一写,居然顺带把瀑布流的三种主流实现方案全摸了一遍:纯CSS多列、Flex多列加JS分发、JS绝对定位计算。这篇文章就按我实际的开发顺序来写,每一版都附完整代码、运行逻辑和踩过的坑,你照着其中任何一种都能把效果复现出来。
先说明一下图片来源。项目本身是一个自娱自乐的Demo,为了快速拿到"尺寸不一、比例各异"的图片,我用的是占位图服务,每张图带上不同的种子值就能得到不同尺寸的随机图片,地址格式大致形如https://cdn.example.com/img/xxx.jpg。如果你手头有自己整理的图片资源,直接把 src 换成自己的地址就行。重点在于后面这套布局算法和踩坑细节,跟图片具体内容没关系。
1. 瀑布流的核心难点:等宽不等高,怎么排才不"漏底"
1.1 网格布局和瀑布流的本质差异
先想一个问题:为什么不用普通的网格布局?
网格布局(Grid)的行高度是统一的,不管图片原始比例是竖图还是横图,都会被裁剪成固定比例,或者干脆撑不满格子、留出大片空白。瀑布流不一样,它的核心特征是"列宽相等、列高不相等"——每张图保持原始比例显示,哪一列短就往哪一列下面塞。视觉上的效果就是图片参差错落,像水流一样自然往下淌,滚动浏览的节奏比整整齐齐的网格舒服得多。
这个"哪列短就往哪塞"听起来简单,真上手就发现有三个麻烦。
第一,浏览器天生不会自动把不同高度的元素排成瀑布流。默认的文档流是从左往右、从上往下一行行排的,你塞进去多少张图,它就一个方块一个方块往外摆,高度对齐得整整齐齐。
第二,图片的高度在加载完成之前是未知的。CSS 布局只认"当前能看到的高度",一张图如果还没加载出来,它参与布局的高度就是 0。等图片加载完,页面又要重新计算一遍。处理不好就会出现先排得整整齐齐、刷新后突然跳成瀑布流的"闪变"。
第三,瀑布流通常要配合滚动加载。一张张往里追加图片的时候,你得保证新加的图片不会把已经在屏幕上的图片顶得乱跳。这一点在第三种绝对定位方案里最明显,处理不好用户体验直接崩。
1.2 三条技术路线的底层思路
针对上面三个麻烦,业界沉淀出了三条主流路线:
- 纯 CSS 多列布局:把"分列"这件事完全交给浏览器,你只管声明列数和间距,实现成本最低。
- Flex 多列 + JS 分发:用 Flex 构造出若干列容器,再用 JavaScript 决定每张图放进哪一列,排序和分发逻辑可控。
- JavaScript 绝对定位:页面只有一个容器,所有卡片用绝对定位,按照"最短列优先"的算法钉在对应的坐标上,精确到像素。
这三条路线没有绝对的优劣,只有"适合什么场景"的区别。为了把区别讲清楚,我把三版 Demo 都真实写了出来,用同一批图片、同一个视觉设计,跑完之后对比效果和代码量。下面按实现复杂度从低到高的顺序展开。
2. 第一种方案:CSS多列布局,一行代码能跑但藏着两个大坑
2.1 核心代码和"一行实现"的真相
CSS 多列布局大概是别人口中"瀑布流一行代码"说法的来源。核心就两个属性:column-count设置列数,break-inside防止内容块在跨列时被切断。Demo 代码长这样:
<div class="waterfall"> <img class="item" src="https://cdn.example.com/img/1.jpg" alt=""> <img class="item" src="https://cdn.example.com/img/2.jpg" alt=""> <!-- ... 更多图片 ... --> </div>.waterfall { column-count: 3; /* 三列 */ column-gap: 16px; /* 列间距 */ } .waterfall .item { width: 100%; display: block; margin-bottom: 16px; /* 卡片间距 */ break-inside: avoid; /* 关键:防止图片跨列截断 */ }就这两段,页面立刻能跑出瀑布流的样子。图片保持自身比例显示,高度不一样,列与列之间参差不齐,滚动起来确实有那味儿了。
不过我劝你先别激动,这里有个最容易被忽略的细节:break-inside: avoid在绝大多数现代浏览器里都能正常工作,但在某些老版本浏览器上完全无效,图片照样会被劈成两半,一列顶部半张、下一列顶部另外半张。如果你的用户群体里还有这类老版本浏览器的存量,保险做法是在图片外面再包一层容器,比如改成<div class="item"><img ...></div>,靠外层块级元素的完整性来兜底。
注意:
break-inside: avoid是 CSS 多列方案的必需品。没有它,所有图片都会按"内容流"被切成跨列的碎片,瀑布流直接变成拼图现场。
2.2 排序规则:把"从上到下再看下一列"当作默认行为
多列布局最大的隐藏特性是填充顺序。普通 HTML 元素在网格、Flex、绝对定位里的排布方向是从左往右、从上往下;多列则相反,它是从上往下填满第一列,再回头填第二列,直到全部填完。
这个差异在图片场景里非常明显。用户打开页面的瞬间,最先看到的是第一列从头到尾的图片,滚动着滚动着,第二列、第三列才慢慢接上。如果你的图片来源本身带有强时间顺序,比如最新发布在最前面,这种"纵向优先"的展示会让用户在视觉上觉得"怎么全是老图"。如果对排序有严格要求,多列布局就会非常别扭,因为它不提供任何调整内部排列顺序的手段。
2.3 这种方案的短板:高度不可控与列数跳变
用 CSS 多列还要面对一个现实问题:图片高度变化时,浏览器会自动重排,把后面的卡片"挤"到别的列去。这带来两个连锁反应。
一是底部往往参差不齐。多列默认会把内容尽量均匀地分配到各列,但最后一列经常比其他列短一截,底部留出明显的空白。想通过 CSS 让它正好齐平,基本做不到,因为内容总高度和列数决定了分配结果,纯样式控制不了。
二是列数跳变很难优雅处理。响应式布局里常见的做法是通过媒体查询切换column-count:
.waterfall { column-count: 2; } @media (min-width: 768px) { .waterfall { column-count: 3; } } @media (min-width: 1200px) { .waterfall { column-count: 4; } }这个写法能用,但用户体验上有个小瑕疵:断点切换的瞬间,所有卡片会被浏览器整体重新排版,页面内容会产生一次明显的"跳动"。如果你的页面又有懒加载、又有动画,这个跳变动画很难做得平滑,因为图片位置全变了,动画补偿无从谈起。
所以我的判断是:CSS 多列方案适合图片数量不多(比如 50 张以内)、对排序无严格要求、追求快速落地的场景。它代码量最少、没有 JS 依赖、图片加载顺序也和懒加载天然兼容。但你要是计划做一个图片很多、还要无限滚动、带排序功能的完整页面,这个方案很快会成为瓶颈。
3. 第二种方案:Flex多列 + JS分发,固定卡片场景的真香选择
3.1 为什么要换成 Flex:把"分列逻辑"抢回自己手里
被 CSS 多列的两个坑恶心到之后,我试了第二种方案:先用 Flex 建立若干个等宽的列容器,再用 JavaScript 把每一张图分发给"当前高度最短"的那一列。这样一来,排序规则、追加位置都回到了自己手里,布局顺序可以精确控制,列间高度也能做到基本齐平。
先说结构。HTML 里不再是单一容器,而是"外层 Flex 容器 + 若干列容器":
<div class="waterfall"> <div class="col" id="col-0"></div> <div class="col" id="col-1"></div> <div class="col" id="col-2"></div> </div>.waterfall { display: flex; gap: 16px; align-items: flex-start; /* 关键:防止列容器被拉伸成一样高 */ } .col { flex: 1; display: flex; flex-direction: column; gap: 16px; }注意align-items: flex-start,默认情况下 Flex 会把三个列容器拉伸到一样高,那样内部卡片之间的间距会被撑开,视觉上就不是瀑布流了。设置成flex-start之后,每列容器高度由自身内容决定。剩下两个属性(flex: 1均分宽度、flex-direction: column让卡片纵向排列)用来保证各列等宽且内部从上往下排列。
3.2 分发算法:每张图都进"当前最短"的那一列
分发逻辑的设计是这版方案的核心。最朴素的想法是轮询,第 1 张进第 1 列,第 2 张进第 2 列,依次循环。但轮询对高度不等的图片明显不公,很可能第 1 列一堆高图、第 2 列一堆矮图,最后各列底部落差巨大。正确的做法是每次找到当前总高度最小的列,把新卡片追加进去:
const cols = Array.from(document.querySelectorAll('.col')); const heights = new Array(cols.length).fill(0); function addCard(item) { const shortestIndex = heights.indexOf(Math.min(...heights)); const card = document.createElement('figure'); card.className = 'card'; card.innerHTML = `<img src="${item.src}" alt="">`; cols[shortestIndex].appendChild(card); // 估算这张卡片加入后该列的高度 // 由于页面列宽已知、图片宽高比可以预取,估算值接近真实值 const colWidth = cols[shortestIndex].clientWidth; const estimatedHeight = colWidth * item.height / item.width + 16; // + 间距 heights[shortestIndex] += estimatedHeight; } images.forEach(addCard);这里有个非常关键的前提:分发前得知道每张图片的宽高比。只有知道比例,才能估算出加入后的列高,才能找到"最短列"。获取宽高比有两个来源:一是图片接口直接返回宽高字段,二是先创建Image对象预加载一遍得到尺寸。后者有性能代价,实际项目中强烈建议走接口字段。
3.3 为什么这套方案适合固定卡片,却在懒加载面前栽跟头
Flex 分发的优点是逻辑直观、代码好懂,而且因为每列都是独立的纵向 Flex 容器,卡片跟卡片之间就是普通的文档流排列,滚到底部追加新内容时不会像绝对定位那样需要手动维护坐标。
但它有个天然缺陷:所有列容器的高度是"内容撑出来的",页面初次渲染时图片还没加载,列容器高度近似于 0,分发脚本一上来就可能把前 100 张图全塞进第 1 列。等图片陆续加载完,第 1 列高度暴涨,而后续图片其实早就发完了,最终结果就是前几列巨长、后几列几乎空着。这也是为什么我说它适合"固定卡片"场景——如果卡片内容高度是已知的,比如先有图片宽高、或者卡片本身是固定尺寸的缩略图,这套方案就非常稳定;如果图片高度要靠加载后才知晓,就需要配合预加载或者占位高度,复杂度会明显上升。
我在实际测试中还发现,Flex 分发方案做"删减"比较麻烦。用户点了删除某张图,你得重新计算整列高度,否则后面的分发索引会错乱。无限滚动的场景下,新数据追加倒是还好,但如果是"前端筛选 + 瀑布流重排",这一版方案需要写不少重置逻辑,反而比绝对定位方案更啰嗦。
4. 第三种方案:JavaScript绝对定位计算,真正的标准瀑布流
4.1 算法思路:最短列优先,坐标自己算
终于到了我最推荐、也是各大图片社区实际采用的方案:JavaScript 绝对定位。它的思路一句话就能说清:页面只有一个相对定位的容器,里面每一张卡片都用 absolute 定位,left 由列号决定,top 由当前列累计高度决定;新卡片永远放进当前高度最短的那一列。
这个方案把布局从"依赖浏览器自动排版"彻底改成"自己计算坐标",意味着排序、列数、追加位置、动画时机全部可控。代价是你要自己处理容器高度,因为子元素全 absolute 之后,容器高度不再自动撑开,你还要在图片加载前后维护好每列的高度数组。
先看数据准备。为了让布局不抖动,我建议数据格式里直接带图片宽高:
const list = [ { id: 1, src: 'https://cdn.example.com/img/1.jpg', width: 800, height: 1200 }, { id: 2, src: 'https://cdn.example.com/img/2.jpg', width: 800, height: 1000 }, // ... ];接下来是核心的布局函数:
function initWaterfall(container, list, columnCount = 3, gap = 16) { const totalWidth = container.clientWidth; const cardWidth = (totalWidth - gap * (columnCount - 1)) / columnCount; const colHeights = new Array(columnCount).fill(0); // 清空容器,后续重排时调用 container.innerHTML = ''; container.style.position = 'relative'; list.forEach((item) => { // 1. 找最短列 let shortestIndex = 0; for (let i = 1; i < columnCount; i++) { if (colHeights[i] < colHeights[shortestIndex]) { shortestIndex = i; } } // 2. 计算这张卡片的实际高度(按列宽等比缩放) const cardHeight = cardWidth * item.height / item.width; const left = shortestIndex * (cardWidth + gap); const top = colHeights[shortestIndex]; // 3. 创建卡片并定位 const card = document.createElement('div'); card.className = 'card'; card.style.position = 'absolute'; card.style.width = cardWidth + 'px'; card.style.height = cardHeight + 'px'; card.style.left = left + 'px'; card.style.top = top + 'px'; card.innerHTML = `<img src="${item.src}" alt="">`; container.appendChild(card); // 4. 更新该列高度 colHeights[shortestIndex] += cardHeight + gap; }); // 5. 设置容器总高度,否则没有内容撑开,滚动区会消失 container.style.height = Math.max(...colHeights) + 'px'; }4.2 为什么"给图片设高度"能彻底消灭闪变
这套代码里最容易被忽略、但恰恰最重要的一个细节,是第 3 步里给卡片设了显式height。图片本身还没加载出来时,浏览器不知道它的高度,但布局代码已经在卡片上"预告"了这个高度,于是等图片加载完成时,只是往一个已经占好位置的框里填充像素,页面不会产生任何跳动。这就是绝对定位方案在"高度未知图片"场景下远胜前两种的根本原因——布局高度由数据决定,不受资源加载状态影响。
你可以对比一下:CSS 多列方案里,图片没加载完高度为 0,但列数是固定的,浏览器只能把还没加载的图片暂时"忽略",整体页面高度忽高忽低;Flex 分发方案里,如果分发脚本执行时图片未加载,分发依据的高度数组就是错的;绝对定位方案从一开始就把所有位置钉死,后续只有图片渐显,没有布局变化。
配合响应式断点,你只需要监听窗口宽度变化,重新调用一次这个函数即可。注意重排的代价,图片越多重排越昂贵,所以一定要做防抖(debounce),最常见的是 200 毫秒左右:
let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const columns = window.innerWidth < 768 ? 2 : window.innerWidth < 1200 ? 3 : 4; initWaterfall(container, list, columns); }, 200); });4.3 滚动加载如何与绝对定位正确协同
绝对定位方案里,无限滚动追加图片的正确做法是"追加 + 更新列高",而不是"全量重排"。维护一个全局的colHeights数组,新图片来的时候继续走最短列逻辑,只往容器末尾 append 新卡片,不需要重新计算旧卡片的位置:
function appendItem(item) { const cardWidth = (container.clientWidth - gap * (columnCount - 1)) / columnCount; let shortestIndex = 0; for (let i = 1; i < columnCount; i++) { if (colHeights[i] < colHeights[shortestIndex]) shortestIndex = i; } const cardHeight = cardWidth * item.height / item.width; const card = document.createElement('div'); card.className = 'card'; card.style.cssText = ` position: absolute; left: ${shortestIndex * (cardWidth + gap)}px; top: ${colHeights[shortestIndex]}px; width: ${cardWidth}px; height: ${cardHeight}px; `; card.innerHTML = `<img src="${item.src}" loading="lazy" alt="">`; container.appendChild(card); colHeights[shortestIndex] += cardHeight + gap; container.style.height = Math.max(...colHeights) + 'px'; }这里有个实战经验:图片的loading="lazy"属性在绝对定位布局里配合"显式高度"是有效的,因为浏览器已经有占位高度可以计算是否进入视口。如果卡片没设高度,懒加载可能触发不准确,图片会一直不加载,这是很多人排查半天找不到原因的地方。
5. 三种方案横向对比与真实项目选型建议
5.1 同一批图片下的实测表现
我把同一批 120 张图片分别用三种方案渲染,在普通桌面浏览器上做了简单对比。性能这块我用的是开发者工具的性能面板,再配合肉眼观察滚动流畅度,不追求实验室级精度,但差异足以说明问题。
| 对比维度 | CSS多列 | Flex多列+JS | JS绝对定位 |
|---|---|---|---|
| 实现代码量 | 最少,约10行 | 中等,约40行 | 较多,约80行 |
| 依赖JS程度 | 不依赖 | 依赖分发脚本 | 完全依赖 |
| 图片顺序方向 | 纵向优先 | 可控 | 可控 |
| 初次加载闪烁 | 明显 | 高度未知时明显 | 无(高度预告) |
| 追加/删除卡片 | 自动 | 需手动维护 | 需手动维护 |
| 大批量图片(500+)流畅度 | 一般,重排频繁 | 良好 | 最好 |
| 动效/过渡配合 | 难以控制 | 中等 | 最好 |
| 响应式重排体验 | 跳变明显 | 重排可控 | 重排可控且可加过渡 |
这个表格并不是说绝对定位永远最好。拿"卡片内容高度固定"的例子来说,比如每张卡片都只有固定的标题、简介、缩略图,高度基本一致,Flex 分发方案代码量少、逻辑简单,完全不必要上绝对定位。而"图片高度千变万化、总量大、需要频繁筛选重排"的场景,绝对是绝对定位方案的天下。
5.2 我的选型决策路径
给出我自己的决策路径,你照着走就行:
- 如果项目是一次性小页面、图片少于 50 张、不要求排序、不追求完美底部齐平——选 CSS 多列,写两行 CSS 收工。
- 如果图片来自接口、带宽高字段、卡片内容较固定、需要做有限交互——选 Flex 多列 + JS 分发,逻辑好维护。
- 如果要做图片社区、商品流、壁纸站这类重图片、重交互、要无限滚动和筛选重排——选 JS 绝对定位,前期多花一点工时,后期一劳永逸。
另外提醒一句:网上很多方案会直接推荐引用第三方塞满功能的瀑布流库。能用,但我建议至少自己把第三种方案的算法写一遍。这个"最短列优先"的思想不只是瀑布流专用,很多前端布局问题都能从中获益,而且自己写一遍之后出了问题你能立刻定位,不至于对着别人几千行的源码抓瞎。
6. 进阶优化与踩坑记录:从能做出来到做得好用
6.1 图片高度未知的三种兜底策略
前面反复提到"图片宽高要预知",这里集中说一下在实际项目里怎么拿到它,以及拿不到时怎么办。
- 接口返回宽高字段:最可靠。图片上传时服务器就能解析出原始宽高,返回给前端,前端直接等比计算。
- 前端预存宽高表:图片量固定、资源可控时,写个脚本在构建期扫描一遍图片目录,生成 JSON 宽高表。适合个人项目。
- onload 后回填:前端确实拿不到宽高时,只能监听每张图
img.onload,加载完成后再回填高度、重新排布。这个方案有性能损耗,而且会有肉眼可见的布局跳动,只适合应急。
三种兜底策略里,我最推荐第一种。如果接口不返回宽高,自己写一个小工具,批量读图片文件的宽高并生成数据列表,也不难,别偷懒。
6.2 占位色块、骨架屏和加载体验
瀑布流的加载体验有一个"隐形需求":图片加载慢的瞬间,用户看到的不应该是一堆空白框。绝对定位方案里,由于卡片高度已经设定,你可以顺手给卡片加一个渐变色占位背景,图片加载前先显示一块同比例的色块,图片加载完再盖上去。代码几乎零成本,在.card的 CSS 里加背景渐变,图片本身用opacity过渡显示就行。
6.3 我踩过的三个具体坑
第一个坑是column-count 与图片懒加载的冲突。CSS 多列方案里,如果图片用了loading="lazy",页面拉到底之前,底部的图片不会加载,但多列布局会把未加载的图片高度当成 0,导致整个瀑布流的"流线"被打乱,滚动时会看到图片突然补位、把下面的卡片挤开。解决办法是在加载完成前给图片一个统一占位高度,或者干脆对少量图片关闭懒加载。
第二个坑是绝对定位方案重排时丢了滚动位置。窗口尺寸变化触发重排后,容器高度会变化,用户的滚动位置可能跳到奇怪的地方。我在项目里加了一个简单的"锚定顶部"处理:重排前记录当前视口顶部对应的第一张可见卡片,重排后把这个卡片滚动回视口顶部。代码不复杂,但体验提升非常明显。
第三个坑是容器 height 忘记更新。绝对定位方案里所有卡片都脱离文档流,容器高度必须由 JS 设置。我刚写完第一版时,页面显示一片空白,排查了很久才发现是容器高度为 0,滚动区根本不存在。后来我把"更新容器高度"写成了一个独立函数,在初始化、追加、重排三个时机统一调用,才彻底解决。
6.4 下一步拓展:把瀑布流做出花来
三条路都跑通之后,我又在这个项目上做了几件锦上添花的事,你可以参考:给卡片加筛选按钮,点一下按分类重排;鼠标悬停时显示图片信息和收藏按钮;做"回到顶部"的悬浮按钮。这些都是在这个布局基座上顺手加的功能,验证了绝对定位方案对交互的包容度。等图片量攒到几千张,再考虑分页还是虚拟滚动——那又是另一个话题了。
最后说点个人的实在话。这个项目的初衷是"用女神图片实现瀑布流,比在浏览器里一张张点开看过瘾",做成之后我确实觉得过瘾——不是图片本身多新奇,而是当你亲手把一套布局算法跑通、把几十上百张图流畅地摊在页面上时,那种"全在掌控中"的感觉特别爽。做技术的人应该都懂这种快感。如果你也想练手,我建议别用框架,先用原生 HTML + CSS + JS 把三种方案各写一遍,写完了你对"浏览器怎么排版"这件事的理解绝对会上一个台阶。