搞网页布局的时候,那种像瀑布一样错落有致的卡片墙,也就是Masonry布局(瀑布流布局),简直yyds。这玩意儿能让每张卡片根据自己内容高度自然排列,不会像普通网格那样强行对齐,看着就舒服。但问题来了,各大浏览器对它的原生支持还在打架,火狐用一套语法,Chrome又测另一套,直接在生产环境用?心有点大。别慌,今天整一个硬核方案,用66行JS代码就能让所有主流浏览器乖乖听话,而且支持响应式、图片视频延迟加载,甚至能让卡片跨列。
很多前端老铁都在盼着CSS原生瀑布流落地,可火狐和Chrome各自站队不同语法。与其干等标准统一,不如抄起66行JS写个polyfill。这方案把网格行高设为0,再用行间隙当标尺,通过getBoundingClientRect算每个卡片的高度,动态塞回gridRowEnd。配合图片预加载和ResizeObserver监听,最后整出一个能跑在所有支持CSS Grid浏览器上的瀑布流,还能直接用现成库一键接入。
啥是Masonry
瀑布流布局的核心就是“错位”。普通网格每行高度固定,卡片多高都得被撑开同一行,丑得一批。而Masonry让每个卡片像砌墙的砖,下面有空隙就自动填上去,Pinterest那种效果就是这么来的。原生CSS里,火狐通过grid-template-rows: masonry实现,Chrome则在测display: masonry。但标准没定死之前,跨浏览器用就得自己动手。
浏览器支持现状
火狐已经支持grid-template-rows: masonry这套写法,Chrome金丝雀版能开display: masonry实验flag。其他浏览器?没动静。所以写代码时得先探测环境,支持就躺平,不支持再上polyfill。探测方法很简单:拿到容器的计算样式,看gridTemplateRows是不是等于masonry字符串。这样既不给火狐添乱,又能让老浏览器续命。
66行JS打底
这个方案的核心思路有点骚:把CSS Grid当成一个“高度标尺”来用。正常Grid里每个格子都有实际高度,但这里先把所有行高拍扁成0,再靠行间隙row-gap撑出1像素的刻度线。每个卡片的高度通过getBoundingClientRect读取后,换算成需要跨多少行(每行1像素加上间隙),最后用gridRowEnd把卡片拉长。整个过程分五步走。
第一步 探测原生支持
拿到页面上所有带.masonry类名的容器,挨个检查。如果浏览器已经原生支持瀑布流,直接跳过后面的所有骚操作。
const containers = document.querySelectorAll('.masonry')
containers.forEach(container => {
if (getComputedStyle(container).gridTemplateRows === 'masonry') return
// 否则开始打补丁
})第二步 拍扁行高设置间隙
先把容器的grid-auto-rows锤成0px,所有格子瞬间变成一条线。紧接着把row-gap强行改成1px(加!important防止被覆盖)。这时候Grid的每一“行”实际只占1像素,但列的顺序和结构还在,相当于拿到了一个空骨架。
container.style.gridAutoRows = '0px'
container.style.setProperty('row-gap', '1px', 'important')这里有个小细节:如果容器原本有row-gap的其他值,会被这行代码覆盖掉。后面会从column-gap读回期望的间隙大小,所以不用担心。
第三步 读取每个卡片身高
用getBoundingClientRect捞每个子元素的实际渲染高度。注意此时卡片内的内容(文字、图片)已经渲染出来了,但图片可能还没加载完,这个问题稍后解决。拿到的高度是像素整数,比如某卡片高342px。
const items = container.children
const colGap = parseFloat(getComputedStyle(container).columnGap)
Array.from(items).forEach(item => {
const height = item.getBoundingClientRect().height
// 高度值暂存,下一步用
})第四步 换算跨行数量并拉伸
因为每行只有1像素高,加上行间隙1像素,所以每个卡片实际占用的“行数”约等于它的高度加上列间隙(列间隙和行间隙通常保持一致)。把算出来的数值塞进gridRowEnd属性,使用span关键字表示跨越多少行。
item.style.gridRowEnd = `span ${Math.round(height + colGap)}`这时候Grid会把卡片从原来的0高度拉伸到正确尺寸,而且因为之前保留了列结构,卡片们自动挤到合适的列下面。但看起来可能还有点挤?别急,间隙还没完全复原。实际上因为每个卡片底部都多加了colGap,行与行之间的空隙就自然出现了。
第五步 等待图片视频加载
如果卡片里有图片或视频,直接执行上面的代码会翻车。因为getBoundingClientRect拿高度时,图片还没下载完,高度是0或者只有占位符的高度。必须等所有媒体资源完全加载后再跑布局函数。写两个辅助函数分别轮询img和video元素,用Promise.all等待全部完成。
async function waitForMedia(container) {
const imgs = Array.from(container.querySelectorAll('img'))
const videos = Array.from(container.querySelectorAll('video'))
const imgPromises = imgs.map(img => new Promise((resolve, reject) => {
if (img.complete) resolve()
img.onload = resolve
img.onerror = reject
}))
const videoPromises = videos.map(video => new Promise((resolve, reject) => {
if (video.readyState === 4) resolve()
video.onloadedmetadata = resolve
video.onerror = reject
}))
return Promise.all([...imgPromises, ...videoPromises])
}在调用layout函数前先await waitForMedia(container)。这样图片再大也不会把布局撑歪。不过首屏如果用瀑布流,建议先把图片列表藏一下或者给个骨架屏,毕竟等待时间肉眼可见。
响应式整上
窗口缩放或者容器尺寸变化时,卡片高度会重新计算。直接用ResizeObserver盯着容器,一旦尺寸变动,重新执行上面的布局函数。注意要防抖一下,不然频繁触发可能掉帧。
const observer = new ResizeObserver(() => {
// 重新读取列间隙和所有卡片高度
const newColGap = parseFloat(getComputedStyle(container).columnGap)
Array.from(container.children).forEach(item => {
const newHeight = item.getBoundingClientRect().height
item.style.gridRowEnd = `span ${Math.round(newHeight + newColGap)}`
})
})
observer.observe(container)移动端横竖屏切换、侧边栏折叠展开,这玩意儿都能自动适配。而且ResizeObserver现代浏览器基本都支持,不支持的可以找polyfill垫一下。
现成库一把梭
不想手撸66行代码?有人已经把上面整套逻辑打包好了,叫@splendidlabz/styles。装完之后引入样式和脚本,调用masonry()就能自动扫描.masonry容器并应用补丁。这种方式适合项目里频繁用瀑布流,懒得每次复制粘贴。
npm install @splendidlabz/styles/* 在CSS里导入布局相关样式 */
@import '@splendidlabz/styles/layouts'// JS里引入并执行
import { masonry } from '@splendidlabz/styles/scripts'
masonry()库里面还集成了图片预加载和响应式监听,跟手写版本效果一样。但要注意,这个库目前还在迭代,生产环境用之前最好锁死版本号。
几个坑点提醒
grid-auto-rows: 0px配合row-gap: 1px时,如果容器有背景色或边框,那1像素的间隙可能会透出底色,看起来像一条细线。解决方案是把容器的背景色去掉,或者给每个卡片单独设背景。另外,卡片之间的实际间隙等于colGap,但设置gridRowEnd时加上了这个值,导致最后一个卡片下方可能会多出一段空白。可以在父容器上加个负的margin-bottom抵消掉,或者接受这点留白——反正瀑布流底部本来就不太齐。
如果某些卡片内容高度会动态变化(比如展开折叠面板),需要手动触发重新布局。可以暴露一个refresh方法,重新跑一遍getBoundingClientRect和gridRowEnd赋值。但是频繁操作DOM有性能损耗,建议变化结束时再调用。
最后,这个方案依赖CSS Grid,IE11直接抬走。现在应该没人还在意IE了吧?
