轻量级砌块解决方案

Avatar of Ana Tudor
Ana Tudor on

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 $200 免费积分!

早在 5 月份,我就了解到 Firefox 将砌块 添加到 CSS 网格中。砌块布局是我一直想从头开始做的东西,但一直不知道从哪里开始。所以,自然地,我查看了 演示,然后我恍然大悟,明白了这个新的提议的 CSS 功能是如何工作的。

目前支持显然仅限于 Firefox(即使在那里,也只有在标志后面),但它仍然为我提供了一个 JavaScript 实现的起点,该实现将涵盖当前缺乏支持的浏览器。

Firefox 在 CSS 中实现砌块的方式是将 grid-template-rows(如示例中)或 grid-template-columns 设置为 masonry 的值。

我的方法是使用此方法来支持浏览器(再次,目前仅指 Firefox),并为其他浏览器创建 JavaScript 回退。让我们看看如何使用图像网格的特定情况来实现这一点。

首先,启用标志

为此,我们转到 Firefox 中的 about:config 并搜索“masonry”。这将显示 layout.css.grid-template-masonry-value.enabled 标志,我们通过将其值从 false(默认值)双击到 true 来启用它。

Screenshot showing the masonry flag being enabled according to the instructions above.
确保我们可以测试此功能。

让我们从一些标记开始

HTML 结构如下所示

<section class="grid--masonry">
  <img src="black_cat.jpg" alt="black cat" />
  <!-- more such images following -->
</section>

现在,让我们应用一些样式

我们首先将顶级元素设为 CSS 网格容器。接下来,我们为图像定义一个最大宽度,比如 10em。我们还希望这些图像缩小到网格的 content-box 可用的任何空间,如果视窗变得太窄而无法容纳单个 10em 列网格,因此我们实际设置的值是 Min(10em, 100%)。由于响应性在当今很重要,我们不使用固定数量的列,而是 auto-fit 尽可能多的此宽度的列。

$w: Min(10em, 100%);

.grid--masonry {
  display: grid;
  grid-template-columns: repeat(auto-fit, $w);
	
  > * { width: $w; }
}

请注意,我们使用了 Min() 而不是 min(),以避免 Sass 冲突

好吧,这是一个网格!

不过它并不美观,所以让我们强制其内容水平居中,然后添加一个 grid-gappadding,它们都等于间距值 ($s)。我们还设置一个 background,以便更易于识别。

$s: .5em;

/* masonry grid styles */
.grid--masonry {
  /* same styles as before */
  justify-content: center;
  grid-gap: $s;
  padding: $s
}

/* prettifying styles */
html { background: #555 }

在美化网格后,我们开始对网格项(即图像)执行相同的操作。让我们应用一个 filter,以便它们看起来更加统一,同时通过略微圆角和 box-shadow 添加一些额外的风格。

img {
  border-radius: 4px;
  box-shadow: 2px 2px 5px rgba(#000, .7);
  filter: sepia(1);
}

现在,对于支持 masonry 的浏览器,我们只需要声明它。

.grid--masonry {
  /* same styles as before */
  grid-template-rows: masonry;
}

虽然这在大多数浏览器中无法使用,但它在启用了标志的 Firefox 中产生了预期的结果,如前所述。

Screenshot showing the masonry result in Firefox alongside DevTools where we can see what's under the hood.
grid-template-rows: masonry 在启用了标志的 Firefox 中运行(演示)。

但是其他浏览器呢?我们需要…

JavaScript 回退

为了节省浏览器必须运行的 JavaScript,我们首先检查页面上是否有任何 .grid--masonry 元素,以及浏览器是否理解并应用了 grid-template-rowsmasonry 值。请注意,这是一个通用的方法,它假设我们可能在一个页面上有多个这样的网格。

let grids = [...document.querySelectorAll('.grid--masonry')];

if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {
  console.log('boo, masonry not supported 😭')
}
else console.log('yay, do nothing!')
Screenshot showing how Firefox with the flag enabled as explained above logs 'yay, do nothing!', while other browsers log 'boo, masonry not supported'.
支持测试(实时)。

如果新的砌块功能不受支持,我们获取每个砌块网格的 row-gap 和网格项,然后设置列数(最初每个网格为 0)。

let grids = [...document.querySelectorAll('.grid--masonry')];

if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {
  grids = grids.map(grid => ({
    _el: grid, 
    gap: parseFloat(getComputedStyle(grid).gridRowGap), 
    items: [...grid.childNodes].filter(c => c.nodeType === 1), 
    ncol: 0
  }));
  
  grids.forEach(grid => console.log(`grid items: ${grid.items.length}; grid gap: ${grid.gap}px`))
}

请注意,我们需要确保子节点是元素节点(这意味着它们具有 nodeType 的值为 1)。否则,我们最终会在项目数组中得到由回车符组成的文本节点。

Screenshot showing the number of items and the row-gap logged in the console.
检查我们是否获得了正确的项目数和间距(实时)。

在继续之前,我们必须确保页面已加载且元素没有在四处移动。处理完这些问题后,我们获取每个网格并读取其当前列数。如果这与我们已有的值不同,那么我们更新旧值并重新排列网格项。

if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {
  grids = grids.map(/* same as before */);
	
  function layout() {
    grids.forEach(grid => {
      /* get the post-resize/ load number of columns */
      let ncol = getComputedStyle(grid._el).gridTemplateColumns.split(' ').length;

      if(grid.ncol !== ncol) {
        grid.ncol = ncol;
        console.log('rearrange grid items')
      }
    });
  }
	
  addEventListener('load', e => {		
    layout(); /* initial load */
    addEventListener('resize', layout, false)
  }, false);
}

请注意,我们需要在初始加载和调整大小的时候调用 layout() 函数。

Screenshot showing the message we get when relayout is necessry.
当我们需要重新排列网格项时(实时)。

要重新排列网格项,第一步是删除所有网格项的顶部边距(这可能已设置为非零值以在当前调整大小之前实现砌块效果)。

如果视窗足够窄,以至于我们只有一列,那么我们就完成了!

否则,我们跳过前 ncol 个项目并循环遍历其余项目。对于每个考虑的项目,我们计算上面项目的底部边缘的位置及其顶部边缘的当前位置。这使我们能够计算出需要将它垂直移动多少距离,以便其顶部边缘位于上面项目的底部边缘下方一个网格间距。

/* if the number of columns has changed */
if(grid.ncol !== ncol) {
  /* update number of columns */
  grid.ncol = ncol;

  /* revert to initial positioning, no margin */
  grid.items.forEach(c => c.style.removeProperty('margin-top'));

  /* if we have more than one column */
  if(grid.ncol > 1) {
    grid.items.slice(ncol).forEach((c, i) => {
      let prev_fin = grid.items[i].getBoundingClientRect().bottom /* bottom edge of item above */, 
          curr_ini = c.getBoundingClientRect().top /* top edge of current item */;
						
      c.style.marginTop = `${prev_fin + grid.gap - curr_ini}px`
    })
  }
}

现在我们有了一个跨浏览器的解决方案!

一些小的改进

更现实的结构

在现实场景中,我们更有可能将每个图像包装在一个链接中,以便将大图像打开到灯箱(或者我们导航到它作为回退)。

<section class='grid--masonry'>
  <a href='black_cat_large.jpg'>
    <img src='black_cat_small.jpg' alt='black cat'/>
  </a>
  <!-- and so on, more thumbnails following the first -->
</section>

这意味着我们还需要稍微更改一下 CSS。虽然我们不再需要在网格项上显式设置 width(因为它们现在是链接),但我们需要在它们上设置 align-self: start,因为与图像不同,它们默认情况下会拉伸以覆盖整个行高度,这会破坏我们的算法。

.grid--masonry > * { align-self: start; }

img {
  display: block; /* avoid weird extra space at the bottom */
  width: 100%;
  /* same styles as before */
}

使第一个元素拉伸到整个网格

我们还可以使第一个项目水平拉伸到整个网格(这意味着我们可能还需要限制它的 height 并确保图像不会溢出或变形)

.grid--masonry > :first-child {
  grid-column: 1/ -1;
  max-height: 29vh;
}

img {
  max-height: inherit;
  object-fit: cover;
  /* same styles as before */
}

我们还需要在获取网格项列表时添加另一个过滤条件,以排除此拉伸的项目。

grids = grids.map(grid => ({
  _el: grid, 
  gap: parseFloat(getComputedStyle(grid).gridRowGap), 
  items: [...grid.childNodes].filter(c => 
    c.nodeType === 1 && 
    +getComputedStyle(c).gridColumnEnd !== -1
  ), 
  ncol: 0
}));

处理具有可变纵横比的网格项

假设我们要将此解决方案用于博客等内容。我们保留相同的 JS 和几乎相同的砌块特定 CSS - 我们只更改列的最大宽度并删除第一个项目的 max-height 限制。

从下面的演示中可以看到,我们的解决方案在此情况下也完美地工作,我们有一个博客文章网格。

您还可以调整视窗大小,以查看它在此情况下的行为。

但是,如果我们希望列的宽度具有一定的灵活性,例如,类似于以下内容

$w: minmax(Min(20em, 100%), 1fr)

那么我们在调整大小时会遇到 问题

网格项目宽度变化,再加上每个项目的文本内容都不一样,这意味着当超过某个阈值时,我们可能会得到一个网格项目(从而改变其 `height`)的文本行数不同,而其他项目则不会。如果列数没有改变,那么垂直偏移量就不会重新计算,最终会导致重叠或更大的间隙。

为了解决这个问题,我们需要在当前网格中至少有一个项目的 `height` 发生变化时重新计算偏移量。这意味着我们需要测试当前网格中是否有超过零个项目改变了它们的 `height`。然后,我们需要在 `if` 块的末尾重置此值,这样我们下次就不会无故重新排列项目。

if(grid.ncol !== ncol || grid.mod) {
  /* same as before */
  grid.mod = 0
}

好的,但是我们如何更改这个 `grid.mod` 值呢?我的第一个想法是使用 ResizeObserver

if(grids.length && getComputedStyle(grids[0]).gridTemplateRows !== 'masonry') {
  let o = new ResizeObserver(entries => {
    entries.forEach(entry => {
      grids.find(grid => grid._el === entry.target.parentElement).mod = 1
    });
  });
  
  /* same as before */
  
  addEventListener('load', e => {
    /* same as before */
    grids.forEach(grid => { grid.items.forEach(c => o.observe(c)) })
  }, false)
}

这个 可以完成任务,在必要时重新排列网格项目,即使网格列数没有改变。但它也让那个 `if` 条件变得毫无意义!

这是因为只要至少有一个项目的 `height` 或 `width` 发生改变,它就会将 `grid.mod` 改为 `1`。项目的 `height` 由于文本重排而发生变化,而文本重排是由 `width` 变化引起的。但是,`width` 的变化发生在每次调整视窗大小时,并不一定会触发 `height` 的变化。

这就是为什么我最终决定存储之前的项目高度,并在调整大小后检查它们是否发生变化,以确定 `grid.mod` 是否保持为 `0`。

function layout() {
  grids.forEach(grid => {
    grid.items.forEach(c => {
      let new_h = c.getBoundingClientRect().height;
				
      if(new_h !== +c.dataset.h) {
        c.dataset.h = new_h;
        grid.mod++
      }
    });
			
    /* same as before */
  })
}

就是这样!现在我们有一个轻量级的解决方案。压缩后的 JavaScript 代码不到 800 字节,而严格与砌体相关的样式不到 300 字节。

但是,但是,但是……

浏览器支持怎么样?

嗯,`@supports` 恰好比这里使用的任何较新的 CSS 功能都有更好的浏览器支持,所以我们可以将好的东西放在里面,为不支持的浏览器提供一个基本的非砌体网格。 这个版本 可以追溯到 IE9。

Screenshot showing the IE grid.
在 Internet Explorer 中的结果

它可能看起来不一样,但它看起来还不错,而且完全可以正常工作。支持一个浏览器并不意味着要为它复制所有的视觉效果。这意味着页面可以正常工作,并且看起来不会损坏或很糟糕。

没有 JavaScript 的情况呢?

嗯,我们只可以在根元素具有 `js` 类时应用花哨的样式,而这个类是通过 JavaScript 添加的!否则,我们会得到一个基本的网格,其中所有项目都具有相同的大小。

Screenshot showing the no JS grid.
没有 JavaScript 的结果 (演示)。