1. 先看现场:column-count 布局里卡片是如何被“腰斩”的
前段时间给某项目X做一个信息流页面,为了省事,我直接用了 column-count 多列布局来排卡片列表。结果一跑起来就翻车了:左边一列底部露出一张卡片的二分之一,剩余部分跑到右边列表的顶部,卡片里的图片从正中间断开,圆角边框断成两截,鼠标悬停上去阴影都错位了。我盯着页面看了半天,才确认这就是 column-count 多列布局最常见的问题——内容被切割在下列。
如果你也是第一次遇到这个现象,先别急着怀疑代码写错。这其实是 CSS 多列布局的“默认行为”,不是 bug,也不是某个浏览器抽风。这篇文章我就把这个问题的根因、修补方案、以及最终怎么演化出一个真正的瀑布流实现,完整说一遍。
1.1 断成两半的卡片长什么样
先还原一下现场。我最初的代码大概是这样:
<div class="multi-col"> <article class="card"> <img src="photo.jpg" alt=""> <h3>标题一</h3> <p>描述文本……</p> </article> <article class="card"> <img src="photo.jpg" alt=""> <h3>标题二</h3> <p>描述文本……</p> </article> </div>.multi-col { column-count: 3; column-gap: 16px; }column-count 设置了 3 列,浏览器会把内容分配到三列里。卡片本身没写固定高度,内容也是参差不齐的。问题出在第二张卡片高度比较高,第一列的剩余空间放不下整张卡片,于是浏览器直接把这张卡片“拦腰截断”,上半部分留在第一列底部,下半部分被推到第二列顶部。
从页面上看,就是一张完整的卡片被劈成两半,图片的上下两截分离,标题在左边一列,按钮却出现在右边一列。这类视觉事故在卡片列表、相册墙、产品列表里都特别常见,而且一旦出现,用户第一反应就是页面坏了。
1.2 根因:column-count 是“排版”的分栏,不是“卡片”的分组
要理解这个切割问题,关键得看 CSS 多列布局的本质。column-count 这套模型本来是给报纸、杂志那种文本排版准备的,它的核心机制叫 fragmentation,中文可以理解成“片段化”。
当内容在列之间流动时,浏览器会把一个大的内容流切分成多个片段,分别放进不同的列盒子里。这和打印分页是同一个道理:一份文档打印到页面底部,内容不够放下一整行,就从那一行的中间切断,剩余内容续到下一页的顶部。
column-count 就是把这个“按页分断”的逻辑移植到了网页列布局上。默认情况下,任何块级元素在遇到列边界时都有可能被切成两半,因为浏览器并不知道你页面里的 article 是一个“整体”。它只看到了一堆内容,怎么填满列就怎么切。
这里有一个容易混淆的点:column-fill: balance 和 column-fill: auto 这两个属性,控制的是“内容如何填充列”,跟“内容是否被切割”没有直接关系。balance 模式会尽量让各列高度接近,auto 模式按顺序填充。无论哪一种,遇到放不下的块级元素,该切还是切。
1.3 为什么文本没事、卡片遭殃
很多人第一次遇到这个问题时会想:column-count 不是挺常见的吗?为什么别人的博客用起来没问题,一到我这里就碎成这样?
原因在于内容类型。段落文本由一行一行的 line box 组成,line box 在列边界处被切断是完全可以接受的。一篇文章从中间某一行断开,下一行继续到另一列,阅读顺序依然顺畅,视觉上几乎看不出异常。这就是为什么你看到的大部分“多列文本”示例都表现正常。
但卡片不是文本。卡片里通常包含图片、标题、描述、按钮,外面有边框、圆角、背景色、阴影。它本身是一个不可分割的视觉单元,一旦被 fragmentation 切成两半,上半张卡片和下半张卡片就会各自独立成块,看起来极其割裂。
除了卡片,表格行、代码块、包含背景色的 div,都容易成为受害者。我后来还遇到过表格被切断的情况,某一行的单元格被劈开,上半部分在第一列,下半部分在第二列,边框完全对不上。这些对象的共同特点是:内部结构有强关联,切断后无法自愈。
所以,看待这个问题的正确姿势是:column-count 只是“按列排版文本”的工具,不是“按列呈现卡片”的布局方案。搞清楚这一点,接下来的修复手段就很好理解了。
2. break-inside 修补术:让浏览器把卡片当“不可分割”的块
既然问题出在 fragmentation,那最直接的修复思路就是告诉浏览器:这个盒子内部不要给我断掉。CSS 里对应的属性是 break-inside,用来控制元素内部是否允许出现分页、分栏或分区的断点。
这个方案在多数场景下确实有效,我也是从这里开始动手的。但用起来有几个容易踩的细节,下面拆开讲。
2.1 最小修复代码与写法
给卡片加上这样一条规则:
.multi-col .card { break-inside: avoid; -webkit-column-break-inside: avoid; page-break-inside: avoid; }这里三条规则的作用域略有不同:
- break-inside: avoid 是现代 CSS 的标准写法,同时影响分页、分栏、分区三类断点;
- -webkit-column-break-inside: avoid 是老版本 WebKit/Safari 需要的前缀写法;
- page-break-inside: avoid 是更老的分页属性,可以在兼容性兜底时保留。
加上之后,浏览器在遇到“当前列放不下整张卡片”的情况时,会把整张卡片整体移动到下一列,而不是从中间切开。对于绝大多数普通卡片,这个修复是一步见效的,卡片完整性立刻恢复。
不过要注意一个边界:如果某张卡片本身的高度超过了一列的完整高度,break-inside: avoid 是救不回来的。浏览器会发现这张卡无论如何都无法放进任何一列,最终还是会“被迫”在内部寻找断点。这种超长卡片的处理,后面讲 JS 方案时会给你一个更稳妥的思路。
2.2 图片、表格、代码块的漏网之鱼
break-inside 写上去之后,你可能会发现大多数卡片正常了,但个别卡片还是歪的。我实际排查下来的情况,通常出在图片、表格和代码块这些“内层元素”上。
先看图片。img 默认是 inline 元素,它自身不是块级盒子,而且图片底部会因为基线对齐产生一个几像素的空隙。在分栏场景里,这个空隙会被保留,卡片底部就容易多出一条高度来。更稳妥的做法是让图片填满整个卡片宽度,并转成块级:
.multi-col .card img { display: block; width: 100%; }再看表格和代码块。table 的行、pre 里的每一行文本,在 fragmentation 模型里都是可断点。即使你在外层卡片上写了 break-inside: avoid,如果卡片内部还有一层独立结构,浏览器仍然可能把内部细节切断。解决方法是给这些元素额外包一层块级容器,把 break-inside: avoid 加在容器上:
<div class="card"> <div class="card-media"> <img src="photo.jpg" alt=""> </div> <div class="card-code"> <pre>...</pre> </div> </div>.multi-col .card .card-media, .multi-col .card .card-code { break-inside: avoid; }翻译一下就是:外层卡片设 break-inside: avoid 保证卡片整体不被切,内部每个独立模块也设 break-inside: avoid 保证内部不出现意外断点。两层都堵上,切割问题才算彻底解决。
2.3 补丁的代价:列底空白、高度不均与 Safari 兼容
break-inside: avoid 能保住卡片的完整性,但它不是免费的午餐。它引入了两个新问题。
第一个是列底空白。因为浏览器想尽量保持三列高度平衡,又不允许切断卡片,当某一列剩余空间放不下下一张卡片时,它只能把卡片挪到下一列,当前列底部就空出一截。卡片数量少、高度差异大的时候,这个空白尤其明显,页面看起来像是没排完。
第二个是列高度不均匀。break-inside: avoid 只解决“切不切”的问题,不解决“怎么分最均衡”的问题。如果所有高卡片都集中在前面,浏览器会在每列底部留白来尽量平衡,但结果往往还是左列很高、右列很矮,或者第一列塞满了高卡片,后面几列零零散散。视觉上只能算“勉强能用”,谈不上精致的瀑布流。
还有 Safari 的兼容性。新版 Safari 已经支持 break-inside: avoid,但老版本 iOS Safari 的表现不太一致,最好把 -webkit-column-break-inside: avoid 一并写上。如果还是不行,就要考虑其他方案。
到这里,你应该明白了一个事实:break-inside 修补术可以让“分栏布局不切割卡片”,但它本质上只是一个“伪瀑布流”,顺序是先填满第一列再填第二列,列高也做不到精准均衡。如果你的需求只是静态展示几十张卡片,它可以满足;如果想做真正的动态瀑布流,还需要继续往下看。
3. 从“分栏”到“瀑布流”:看清几种布局路线的能力边界
“瀑布流”这个词现在已经被用滥了,凡是多列错落排卡片,大家都叫瀑布流。但在工程实现上,不同方案的视觉效果和交互能力差别很大。这一章我把自己试过的几条路线拉出来做个对比,讲清楚为什么我最后没有停在 column-count 修补方案上。
3.1 flex/grid 为什么不直接等于瀑布流
有人可能想,column-count 不行,我用 flex 或者 grid 不就行了吗?
flex 的问题在于它只有一条主轴。如果主轴是水平方向,所有卡片一行排完,本质是横向滚动列表;如果主轴是垂直方向,高度不够就换列,但每列内部的高度完全一致,所有卡片顶部对齐,形成的是规整的棋盘格,而不是错落有致的瀑布流。无论怎么调整 align-items,都没办法让每一列独立地向下堆叠。
grid 比 flex 强一些,它本身是二维布局,可以设置三列四行这样的栅格。但 grid 的每个格子位置由行列线决定,卡片默认占一个格子,无法自动实现“第二列的卡片比第一列更长”的效果。你需要给卡片手动设置 grid-row: span 2 之类的属性,而 span 的行数又依赖卡片高度,纯 CSS 根本算不出来。
结论很直接:常规 flex/grid 不管怎么调,都无法自然形成“列高独立增长、卡片任意错位”的瀑布流效果。它们适合做等高的列表或网格,不适合做内容高度未知的瀑布流。
3.2 原生 CSS grid masonry 的现状与生产风险
CSS 官方其实已经在推进原生的 masonry 布局,方案是以 grid-template-rows: masonry 的形式出现的。简单说,它允许 grid 里的行不再受固定格子约束,而是像瀑布一样流动排列。我在本地试了一下,效果顺滑,写起来也很简洁。
但这个特性目前只在一个主流浏览器里默认可用,另一个主流内核要打开实验室开关才行,还有一个是直接不认。你要是现在把它用到生产环境,同一套代码在不同浏览器里的表现完全不一样,老用户直接看到普通 grid 布局,体验就乱了。
所以在项目里我是不推荐依赖原生 masonry 的。它像是“未来值得期待的方案”,但现在尤其不适合拿来解决问题。工程上应该选择兼容性确定、行为可控的方案。
3.3 什么时候用“伪瀑布流”就够了
倒不是说 column-count + break-inside 就一定不行。我自己的经验是,当你满足这些条件时,用伪瀑布流是最省力的:
- 内容是静态的,不需要频繁追加;
- 卡片总数不多,比如几十张以内;
- 阅读顺序要求不严格,用户从上往下扫,从左到右看不是核心诉求;
- 目标场景以移动端为主,移动端通常本来就是一列或两列;
- 不想引入任何 JavaScript 逻辑。
这种场景下,CSS 方案零依赖、渲染快、代码量极少,是合理的选择。比如一个静态的作品集页面、一个几十条图文的小型展示墙,用伪瀑布流完全够用。
但一旦出现这些情况,就该换 JS 方案了:
- 需要动态加载、无限滚动,随时往列表里追加新卡片;
- 桌面端多列展示,用户需要按照从左到右、从上到下的顺序浏览内容;
- 卡片高度差异大,希望各列高度尽量接近;
- 页面中要频繁增删卡片,或者做搜索筛选;
- 需要精确控制滚动位置和虚拟列表。
这时候,就要用到真正的“瀑布流插入算法”。
4. 面向短列插入算法:实现一个不切割的“真瀑布流”
接下来这部分是全文的核心实操。我最后采用的方案是纯 JavaScript + 绝对定位,自己写一个短列插入算法。它不依赖任何第三方库,兼容性完全由自己控制,而且天然不切割卡片。
4.1 核心思路与数据结构
真瀑布流的思路其实很简单:把容器设为 position: relative,每张卡片 position: absolute,然后维护一个数组,记录每一列当前已经堆了多高。每插入一张卡片,就找到当前高度最小的那一列,把卡片放到那列底部,更新该列高度。
我把这个算法拆成两个关键点:
一是列数怎么算。根据容器宽度和设定的卡片最小宽度,用 parseInt 计算列数,保证窄屏下自动减少列数、宽屏自动增加列数。列数改变时整个布局重新排一次。
二是最短列怎么找。用一个数组 colHeights,索引代表列号,值代表该列当前累计高度。每次插入卡片前遍历数组,找出最小值对应的索引,如果多条列并列最低,就选最左边的。
这里有一个小细节:不要用 Math.min(...colHeights) 后再 indexOf,那样会有性能损耗。用 reduce 在单次遍历里同时拿到最小值和索引,效率更高,写法也简洁。
4.2 布局函数与图片加载的处理
先看完整的 HTML 结构:
<div id="masonry-container"> <div class="masonry-item"> <div class="item-content"> <img src="photo-1.jpg" alt=""> <p>这是一张卡片</p> </div> </div> <div class="masonry-item"> <div class="item-content"> <img src="photo-2.jpg" alt=""> <p>这是另一张卡片</p> </div> </div> </div>CSS:
#masonry-container { position: relative; } .masonry-item { position: absolute; box-sizing: border-box; padding: 10px; }这里给卡片加上 box-sizing: border-box,是为了让宽度计算不会因为 padding 出偏差。接下来是核心的 JS:
function initMasonry(container, { columnWidth = 260, gap = 16 } = {}) { const items = Array.from(container.children); let colHeights = []; function layout() { const containerWidth = container.clientWidth; const colCount = Math.max(1, Math.floor((containerWidth + gap) / (columnWidth + gap))); const colWidth = (containerWidth - (colCount - 1) * gap) / colCount; colHeights = new Array(colCount).fill(0); items.forEach((item) => { item.style.width = colWidth + 'px'; const minIndex = colHeights.reduce((min, current, index, arr) => { return current < arr[min] ? index : min; }, 0); const minHeight = colHeights[minIndex]; item.style.left = minIndex * (colWidth + gap) + 'px'; item.style.top = minHeight + 'px'; colHeights[minIndex] += item.offsetHeight + gap; }); container.style.height = Math.max(...colHeights) - gap + 'px'; } layout(); return { layout, }; }layout 函数的工作流程:
- 读取容器宽度,计算列数和每一列的实际宽度;
- 重置列高度数组;
- 遍历所有卡片,设置卡片宽度;
- 找当前最短列,把卡片定位到该列的 left 和 top 位置;
- 更新该列高度,供下一张卡片计算使用;
- 最后把容器自身高度设置为最长列的高度,避免容器塌陷、滚动条不出现。
这里有一个很多新手容易忽略的坑:图片还没加载完就执行 layout。img 没加载时 offsetHeight 是 0 或很小,布局出来的卡片全部重叠在一起,等图片加载完成后又被顶开,页面会闪一下。解决方案是在 layout 之前等所有图片加载完毕:
function waitForImages(container) { const images = Array.from(container.querySelectorAll('img')); return Promise.all( images.map((img) => { if (img.complete) { return Promise.resolve(); } return new Promise((resolve) => { img.addEventListener('load', resolve, { once: true }); img.addEventListener('error', resolve, { once: true }); }); }) ); } waitForImages(container).then(() => layout());如果项目里卡片数量多、图片多,还可以在图片外层包一个固定宽高比的占位容器,这样布局就不依赖图片加载状态,性能更好。
4.3 视口变化与无限滚动下的重排策略
真瀑布流方案接下去要面对的两个高频场景,是窗口尺寸变化和无限滚动。
窗口 resize 的时候,列数可能变化,原来自适应排列的卡片必须重新定位。但 resize 事件触发非常频繁,不能每次触发都立刻重新布局,否则会卡顿。我用了一个防抖函数,200 毫秒内如果用户还在持续改变窗口大小,就不执行布局,等窗口稳定了再做一次:
function debounce(fn, delay) { let timer = null; return function (...args) { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; } const masonry = initMasonry(container, { columnWidth: 260, gap: 16 }); window.addEventListener('resize', debounce(() => masonry.layout(), 200));防抖时间不用太长,200 毫秒手感刚好。时间太短,resize 过程中会有多次布局;时间太长,用户已经停下来了页面还没排整齐。
无限滚动这块,我一般会用一个哨兵元素放在页面底部,用 IntersectionObserver 监听它进入视口,然后加载下一页数据。拿到新卡片后,不需要重新排全部卡片,只需要分配合适位置。增量插入的逻辑可以独立成函数:
function appendItem(item) { const minIndex = colHeights.reduce((min, current, index, arr) => { return current < arr[min] ? index : min; }, 0); const colWidth = colHeights.size ? ... : 0; // 按当前列数重新计算 item.style.width = colWidth + 'px'; item.style.left = minIndex * (colWidth + gap) + 'px'; item.style.top = colHeights[minIndex] + 'px'; colHeights[minIndex] += item.offsetHeight + gap; container.style.height = Math.max(...colHeights) - gap + 'px'; }这里要记住更新容器总高度,否则无限滚动往下滚的时候,因为容器高度没变,滚动条不会增长,下一页永远加载不出来。
5. 工程里的最终取舍:选型表、封装方式和性能红线
最后这部分,把我在实际项目里的选型逻辑、封装方式和遇到的性能红线整理出来,给你一个可以直接落地的参考。
5.1 三种方案对比:到底该怎么选
我把 column-count 伪瀑布流、原生 grid masonry、JS 真瀑布流放在一张表里对比:
| 维度 | columns + break-inside | 原生 grid masonry | JS 绝对定位 |
|---|---|---|---|
| 阅读顺序 | 列优先,先填满第一列再排第二列 | 行优先,网格流向控制 | 行优先,按顺序落到最短列 |
| 兼容性 | 全浏览器,老浏览器需加前缀 | 仅个别浏览器默认支持 | 全浏览器,完全可控 |
| 动态插入 | 需要整组重排,且重排后顺序依然不符合预期 | 受限较多,浏览器支持不统一 | 增量插入容易,不影响已排卡片 |
| 实现成本 | 极低,CSS 几行搞定 | 低,但不稳定 | 中等,需要处理图片加载、resize 等细节 |
| 视觉效果 | 列底可能留白,高度不均衡 | 最理想,但不可用 | 均衡,列高接近 |
| 适用场景 | 静态小规模展示、移动端简单列表 | 未来可用,现阶段生产别用 | 动态加载、无限滚动、响应式瀑布流 |
如果只是做一个静态卡片墙,选第一种省事省心。如果是内容平台型页面、需要无限滚动和动态筛选,我会直接选第三种。
5.2 组件化封装要点
把布局逻辑裸写在页面里不优雅,尤其是要面向某个前端框架做封装时,很容易出现内存泄漏或事件重复绑定的问题。我后来把逻辑封装成一个工厂函数,对外暴露三个方法:
- layout():重新执行全量布局;
- appendItem(el, callback):追加一张卡片并按增量算法放置;
- destroy():销毁实例,移除事件监听。
destroy 尤其重要。如果你在一个页面里多次创建瀑布流实例,没有 destroy 的话,resize 监听会不断叠加,用户改一次窗口大小,布局会被执行好几遍,性能直接劣化。
封装后的函数大概长这样:
function createMasonry(container, options) { const { columnWidth = 260, gap = 16, debounceDelay = 200 } = options; const items = Array.from(container.children); let colHeights = []; let resizeHandler; function computeColCount(width) { const count = Math.floor((width + gap) / (columnWidth + gap)); return Math.max(1, count); } function layout() { /* 全量布局,同前 */ } function appendItem(item, callback) { item.style.position = 'absolute'; items.push(item); container.appendChild(item); // 增量放置逻辑 callback && callback(); } function destroy() { if (resizeHandler) { window.removeEventListener('resize', resizeHandler); resizeHandler = null; } } resizeHandler = debounce(() => layout(), debounceDelay); window.addEventListener('resize', resizeHandler); layout(); return { layout, appendItem, destroy }; }在组件卸载时调用 destroy,这算是我自己踩过的最值得交代的一个坑。第一次封装时忘了清理监听,结果页面上残留了好几个布局实例,一个 resize 事件触发多次全量 layout,页面掉帧明显。
5.3 性能与视觉踩坑记录
踩过的坑多了,有些东西就成了默认的纪律。我列几个自己认为最关键的红线。
第一个是不要逐个设置样式。有些代码写出来读起来舒服,比如在循环里 item.style.left = ... 之后 item.style.top = ... 再 item.style.width = ...,但这样每条 item 都要触发一次样式计算。更好的做法是先把属性拼好成字符串,一次性写入。好在现代浏览器对这类布局操作做了优化,但如果卡片数量超过一两百,还是会感觉到差别。
第二个是千万不要在图片未加载时读取 offsetHeight。这个是布局错乱的第一大来源。不用等到 window.onload,那太慢了,只需要等容器内部的图片 Promise 全部 resolve 再排。或者更彻底一点:给图片外层设定一个宽高比,宽度一变,高度也随之确定,布局完全不需要依赖图片加载。
第三个是容器高度必须主动设置。绝对定位的卡片不占正常文档流,容器如果有未设置 height,那么容器高度会塌成 0,滚动条和后续元素的位置全部错乱。每次布局完成后,把容器样式高度设置为最长列高度,这一步不能省。
第四个是列数突变时,卡片切换过程会闪。尤其是从 5 列切到 3 列,所有卡片位置全部变化,用户会看到一排图片剧烈跳动。如果要尽量避免视觉闪烁,可以在切换列数时加一个轻量的过渡动画,或者把重新布局的时机放到页面空闲时执行。
还有一个小细节:如果你给卡片加了 margin,那 gap 计算就会出问题。建议不要在卡片上用 margin,而是统一用 gap 变量,布局函数里的所有间距都从 gap 取,避免重复计算和误差。
最后分享一个我实际处理图片加载跳位的心得。最开始我想把每张图片的固定高度算出来,在 JS 里预置到占位元素上,逻辑很繁琐。后来把所有图片都统一成固定宽高比,配合 object-fit: cover,问题基本就消失了。图片虽然会有一部分被裁切,但瀑布流的视觉稳定性远比“每张图都完整露出”更重要。瀑布流的核心从来都不在“多列”这两个字,而在于内容高度不确定时,布局还能保持稳定。想清楚这一点,再回头看 column-count 的切割问题,就会明白它为什么发生,以及为什么最终要走到更主动的布局方案。