[译] Making a Masonry Layout That Works Today

35 阅读5分钟

原文信息


声明

翻译开工时,原作更新时间为 2025/11/25。

封面用了原文的。

代码 100% 还原,原文代码示例用 codepen。

翻译了部分注释、<img> 的 alt。译文与原文略有出入。


去年,许多 CSS 专家对新的砌砖特性,深入探讨过可能采用的语法形式。主要分成两派,还有一派介于两者之间:

  1. Use display: masonry
  2. Use grid-template-rows: masonry
  3. Use item-pack: collapse

Firefox 已经支持用第二种语法去实现了Chrome 还在测试第一种语法。有原生支持是好事,可如果浏览器不齐心,就是支持不彻底,等于彻底不支持……

看他们商量不出长短,就自己研究。在众多浏览器都能用的法子让我研究出来了!只要 66 行 JavaScript 就实现了!

体验一下这个示例,证明我所言非虚。如果你的首屏要用这种砌砖网格,建议去掉图片,改善用户体验。因为设计成了先等待媒体加载,所以会有轻微延迟。

做了什么?

别看 JavaScript 只有 66 行,简约而不简单

  • 列数可自定义。
  • 格子可跨多列。
  • 等媒体加载完,再计算格子的尺寸。
  • ResizeObserver 监听变化,实现响应式。

这些特性足够可靠,生产环境放心用,而且比别人用 Flexbox 仿制的砌砖更灵活。

技巧分享。 这一套搭配 Tailwind 的响应式变体和任意值,砌砖网格能更灵活,不用再多余写 CSS。

回归正题,看它是怎么运作的。

先从 polyfill 开始

在 Firefox 创建砌砖的写法。

.masonry {
  display: grid;
  grid-template-columns: repeat(
    auto-fit,
    minmax(min(var(--item-width, 200px), 100%), 1fr)
  );
  grid-template-rows: masonry;
  grid-auto-flow: dense; /* 写不写都行, 建议加上 */
}

检查是否支持砌砖,最佳方案是看能不能让 grid-template-rows 值为 masonry

function isMasonrySupported(container) {
  return getComputedStyle(container).gridTemplateRows === 'masonry'
}

如果已经原生支持,就甭忙活了。

const containers = document.querySelectorAll('.masonry')

containers.forEach(async container => {
  if (isMasonrySupported(container)) return
})

再让砌砖变简单

我声明一下,我不是首创。

当时我在网上搜,如何实现砌砖网格布局,偶然发现了这个技巧。我得给那位开创者颁奖——算了,颁给我也一样,我这还学以致用,做成了一套呢。

原理如下:

  1. grid-auto-rows 设为 0px
  2. 再把 row-gap 设为 1px
  3. 然后用 getBoundingClientRect 获取网格高度。
  4. 最后,height 加上 column-gap 算出每个格子该占几行。

如果按以往用 CSS Grid 的思路,确实会反直觉。咱们一步一步来,到后面就全明白了!

一步一步来

首先,每个格子的 grid-auto-rows 设为 0px,CSS Grid 仍然保持排列顺序!

containers.forEach(async container => {
  // ...
  container.style.gridAutoRows = '0px'
})

三列重叠堆叠的卡片,白底,黑色圆角边框,还有随机生成的占位文字。

其次,row-gap 设为 1px。这时候出现逐行堆叠的现象,每一行都比前一行低 1px。

  containers.forEach(async container => {
    // ...
    container.style.gridAutoRows = '0px'
+   container.style.setProperty('row-gap', '1px', 'important')
  })

卡片变成垂直堆叠。

再次,假设网格项里没有媒体元素,用 getBoundingClientRect 直接就获取到每个网格项的高度。

接着,用 height 数值去设置 grow-row-end,使网格项的「高度」恢复。之所以成功,是因为现在 row-gap 为 1px。

现在,网格项基本归位:

containers.forEach(async container => {
  // ...
  let items = container.children
  layout({ items })
})

function layout({ items }) {
  items.forEach(item => {
    const ib = item.getBoundingClientRect()
    item.style.gridRowEnd = `span ${Math.round(ib.height)}`
  })
}

卡片组成的砌砖布局,卡片有的占一列或两列。

现在恢复网格项之间的行间距。砌砖网格的 column-gaprow-gap 通常都一样,可以读 column-gap 获取行间距。

读到值后,加到 grid-row-end,就能撑开这个格子在网格占的行数(即「高度」)。

一旦获取到该值,我们将其加到 grid-row-end 上,以扩展项目在网格中所占据的行数。

containers.forEach(async container => {
  // ...
  const items = container.children
  const colGap = parseFloat(getComputedStyle(container).columnGap)
  layout({ items, colGap })
})

function layout({ items, colGap }) {
  items.forEach(item => {
    const ib = item.getBoundingClientRect()
    item.style.gridRowEnd = `span ${Math.round(ib.height + colGap)}`
  })
}

一个三列砌砖布局。格子大多占一列,其中有两个占两列。布局的排列顺序从左到右。

搞定!之后,就是为生产环境做准备了。

等待媒体加载

如果随便给一个格子加图片,布局就被打乱了。「问题」在于格子高度。

第一个项包含一张图片和文字,并且它位于前两列中的其他项目下面。

之所以出错,是因为我们在图片加载前就获取高度。这时候 DOM 不知图片尺寸。要修复,就得在 layout 调用之前,等待媒体加载完成。

用下面这段代码就能搞定(具体我就不解释了哈,这跟 CSS 技巧关系不大 😅):

containers.forEach(async container => {
  // ...
  try {
    await Promise.all([areImagesLoaded(container), areVideosLoaded(container)])
  } catch(e) {}
  // Run the layout function after images are loaded
    layout({ items, colGap })
  })

  // 检查图片是否已加载
  async function areImagesLoaded(container) {
    const images = Array.from(container.querySelectorAll('img'))
    const promises = images.map(img => {
      return new Promise((resolve, reject) => {
        if (img.complete) return resolve()
        img.onload = resolve
        img.onerror = reject
      })
    })
    return Promise.all(promises)
  }

  // 检查视频是否已加载
  function areVideosLoaded(container) {
    const videos = Array.from(container.querySelectorAll('video'))
    const promises = videos.map(video => {
      return new Promise((resolve, reject) => {
        if (video.readyState === 4) return resolve()
        video.onloadedmetadata = resolve
        video.onerror = reject
      })
    })
    return Promise.all(promises)
  }

好了!现在,CSS 砌砖网格能处理媒体了。

一个完整的砌砖布局,含六个项。第一和第三项占了前两列,第二、第四、第五和第六项则排到第三列。

具备响应式

这个简单。只需要 ResizeObserver API 监听。

一旦网格容器尺寸变化,就调用 layout 函数:

containers.forEach(async container => {
  // ...
  const observer = new ResizeObserver(observerFn)
  observer.observe(container)

  function observerFn(entries) {
    for (const entry of entries) {
      layout({colGap, items})
    }
  }
})

演示采用标准 Resize Observer API。我推荐我前几天封装好的 resizeObserver 函数,更简洁。

containers.forEach(async container => {
  // ...
  const observer = resizeObserver(container, {
    callback () {
      layout({colGap, items})
    }
  })
})

完工!祝贺你得到一个功能强大的砌砖网格!

怎么样,是不是很简单!

Splendid Labz 砌砖网格

可以试试我做的 Splendid Labz 版本砌砖,如果你能接受外来的代码。

只需要安装辅助库,再添加必要代码:

# 安装库
npm install @splendidlabz/styles
/* 导入所有 layout 代码 */
@import '@splendidlabz/styles/layouts'
// 使用砌砖脚本
import { masonry } from '@splendidlabz/styles/scripts'
masonry()

为了网页开发更轻松,我一直在开发大量工具,包括这个,都归到了 Splendid Labz 这个品牌。

如果你喜欢,那你可能会对其他一些简化布局构建过程的工具感兴趣。

好了,希望你喜欢这篇文章,也祝你尝试砌砖网格一切顺利。