立即如何使用容器查询

最近,Chris Coyier 在一篇博文中提出了这样一个问题:

既然所有浏览器引擎都支持容器查询,那为什么没有更多的开发者使用它们呢?

Chris 的帖子列出了许多可能的原因(例如,缺乏意识、老习惯非常难养),但有一个特别的原因非常突出。

有些开发者说他们现在想使用容器查询,但认为自己做不到,因为他们仍然需要支持旧版浏览器

您可能已经从标题中猜到,我们认为大多数开发者现在都可以在生产环境中使用容器查询,即使您必须支持旧版浏览器也是如此。这篇博文详细介绍了我们建议您采用的方法。

务实方法

如果您现在想在代码中使用容器查询,但又希望在所有浏览器中获得相同的体验,您可以为不支持容器查询的浏览器实现基于 JavaScript 的回退。

那么,问题就变成了:后备广告应有多全面?

与所有后备广告一样,我们面临的挑战是如何在实用性和效果之间达到很好的平衡。对于 CSS 功能,系统通常不支持完整的 API(请参阅为何不使用 polyfill)。不过,您可以通过确定大多数开发者想要使用的核心功能集,然后仅针对这些功能优化后备功能,这样您可以大有裨益。

但是,大多数开发者想要实现容器查询的“核心功能集”是什么?要回答这个问题,请考虑大多数开发者目前是如何使用媒体查询构建自适应网站的。

几乎所有现代设计系统和组件库都按照移动优先原则进行了标准化,使用一组预定义的断点(例如 SMMDLGXL)来实现。组件经过优化,默认在小屏幕上良好显示,然后有条件地对样式进行分层,以支持一组固定的较大屏幕宽度。(如需查看相关示例,请参阅引导加载程序Tailwind 文档。)

这种方法与基于容器的设计系统一样,因为在大多数情况下,与设计人员相关的不是屏幕或视口有多大,而是组件在放置上下文中可用的空间。也就是说,断点会应用于特定内容区域,例如边栏、模态对话框或博文正文,而不是相对于整个视口(并且适用于整个页面)。

如果您能够遵循移动优先、基于断点的方法(目前大多数开发者都在这样做)的局限性,那么为该方法实现基于容器的回退要比实现对每个容器查询功能的全面支持要容易得多。

下一部分具体介绍了上述功能的运作方式,同时还提供了分步指南,向您展示如何在现有网站上实现该功能。

运作方式

第 1 步:更新组件样式以使用 @container 规则而非 @media 规则

在第一步中,确定网站上您认为基于容器的尺寸调整(而非基于视口的调整)会带来益处的任何组件。

最好从只使用一两个组件开始,以便了解此策略的效果,但如果您希望将全部组件转换为基于容器的样式,也没关系!这种策略的好处在于,您可以根据需要逐步采用。

确定要更新的组件后,您需要将这些组件的 CSS 中的每个 @media 规则都更改为 @container 规则。您可以保持大小条件不变。

如果您的 CSS 已使用一组预定义的断点,那么您可以继续按照定义的方式使用这些断点。如果您尚未使用预定义的断点,则需要为它们定义名称(稍后在 JavaScript 中引用该名称,请参阅第 2 步)。

下面是一个 .photo-gallery 组件的样式示例,该组件默认为单列,然后在 MDXL 断点中将其样式分别更新为两列和三列:

.photo-gallery {
  display: grid;
  grid-template-columns: 1fr;
}

/* Styles for the `MD` breakpoint */
@media (min-width: 768px) {
  .photo-gallery {
    grid-template-columns: 1fr 1fr;
  }
}

/* Styles for the `XL` breakpoint */
@media (min-width: 1280px) {
  .photo-gallery {
    grid-template-columns: 1fr 1fr 1fr;
  }
}

如需将这些组件样式从使用 @media 规则更改为使用 @container 规则,请在代码中执行查找和替换操作:

/* Before: */
@media (min-width: 768px) { /* ... */ }
@media (min-width: 1280px) { /* ... */ }

/* After: */
@container (min-width: 768px) { /* ... */ }
@container (min-width: 1280px) { /* ... */ }

将组件样式从 @media 规则更新为基于断点的 @container 规则后,下一步是配置容器元素。

第 2 步:向 HTML 中添加容器元素

上一步定义了基于容器元素尺寸的组件样式。下一步是指定页面上的哪些元素应该是大小与 @container 规则相对的容器元素。

您可以在 CSS 中将任何元素声明为容器元素,只需将其 container-type 属性设置为 sizeinline-size 即可。如果您的容器规则基于宽度,那么通常您想要使用 inline-size

假设某个网站具有以下基本 HTML 结构:

<body>
  <div class="sidebar">...</div>
  <div class="content">...</div>
</body>

若要使此网站中的 .sidebar.content 元素成为containers,请将此规则添加到您的 CSS 中:

.content, .sidebar {
  container-type: inline-size;
}

对于支持容器查询的浏览器,您只需使用此 CSS 即可使上一步中定义的组件样式相对于主内容区域或边栏取决于它们所在的元素。

但是,对于不支持容器查询的浏览器,还需要执行一些额外的操作。

您需要添加一些代码,用于检测容器元素大小何时发生变化,然后以 CSS 可连接的方式根据这些更改更新 DOM。

幸运的是,执行此操作所需的代码非常少,并且可以完全抽象化为可在任何网站和任何内容区域使用的共享组件中。

以下代码定义了一个可重复使用的 <responsive-container> 元素,它会自动监听大小变化并添加断点类,以便 CSS 根据这些类来设置样式:

// A mapping of default breakpoint class names and min-width sizes.
// Redefine these as needed based on your site's design.
const defaultBreakpoints = {SM: 512, MD: 768, LG: 1024, XL: 1280};

// A resize observer that monitors size changes to all <responsive-container>
// elements and calls their `updateBreakpoints()` method with the updated size.
const ro = new ResizeObserver((entries) => {
  entries.forEach((e) => e.target.updateBreakpoints(e.contentRect));
});

class ResponsiveContainer extends HTMLElement {
  connectedCallback() {
    const bps = this.getAttribute('breakpoints');
    this.breakpoints = bps ? JSON.parse(bps) : defaultBreakpoints;
    this.name = this.getAttribute('name') || '';
    ro.observe(this);
  }
  disconnectedCallback() {
    ro.unobserve(this);
  }
  updateBreakpoints(contentRect) {
    for (const bp of Object.keys(this.breakpoints)) {
      const minWidth = this.breakpoints[bp];
      const className = this.name ? `${this.name}-${bp}` : bp;
      this.classList.toggle(className, contentRect.width >= minWidth);
    }
  }
}

self.customElements.define('responsive-container', ResponsiveContainer);

此代码的工作原理是创建一个 ResizeObserver,它会自动监听 DOM 中任何 <responsive-container> 元素的大小变化。如果大小更改与定义的某个断点大小匹配,则系统会将具有该断点名称的类添加到该元素中(如果条件不再匹配,则会将其移除)。

例如,如果 <responsive-container> 元素的 width 介于 768 到 1024 像素之间(基于代码中设置的默认断点值),则系统会添加 SMMD 类,如下所示:

<responsive-container class="SM MD">...</responsive-container>

通过这些类,您可以为不支持容器查询的浏览器定义后备样式(请参阅第 3 步:向 CSS 添加后备样式)。

如需更新之前的 HTML 代码以使用此容器元素,请将边栏和主要内容 <div> 元素更改为 <responsive-container> 元素:

<body>
  <responsive-container class="sidebar">...</responsive-container>
  <responsive-container class="content">...</responsive-container>
</body>

在大多数情况下,您可以仅使用 <responsive-container> 元素,而不进行任何自定义,但如果您确实需要对其进行自定义,可以使用以下选项:

  • 自定义断点大小:此代码使用一组默认断点类名称和最小宽度大小,但您可以根据需要更改这些默认值。您还可以使用 breakpoints 属性按元素替换这些值。
  • 命名容器:此代码也通过传递 name 属性来支持命名容器。如果您需要嵌套容器元素,这一点尤为重要。如需了解详情,请参阅“限制”部分

以下示例设置了这两个配置选项:

<responsive-container
  name='sidebar'
  breakpoints='{"bp1":500,"bp2":1000,"bp3":1500}'>
</responsive-container>

最后,在捆绑此代码时,请确保使用特征检测和动态 import(),以便仅在浏览器不支持容器查询时加载代码。

if (!CSS.supports('container-type: inline-size')) {
  import('./path/to/responsive-container.js');
}

第 3 步:向 CSS 添加后备样式

此策略的最后一步是,为无法识别 @container 规则中定义的样式的浏览器添加后备样式。为此,请使用在 <responsive-container> 元素上设置的断点类来复制这些规则。

继续前面的 .photo-gallery 示例,两条 @container 规则的后备样式可能如下所示:

/* Container query styles for the `MD` breakpoint. */
@container (min-width: 768px) {
  .photo-gallery {
    grid-template-columns: 1fr 1fr;
  }
}

/* Fallback styles for the `MD` breakpoint. */
@supports not (container-type: inline-size) {
  :where(responsive-container.MD) .photo-gallery {
    grid-template-columns: 1fr 1fr;
  }
}

/* Container query styles for the `XL` breakpoint. */
@container (min-width: 1280px) {
  .photo-gallery {
    grid-template-columns: 1fr 1fr 1fr;
  }
}

/* Fallback styles for the `XL` breakpoint. */
@supports not (container-type: inline-size) {
  :where(responsive-container.XL) .photo-gallery {
    grid-template-columns: 1fr 1fr 1fr;
  }
}

在此代码中,对于每条 @container 规则,如果存在相应的断点类,都有一个等效规则,该规则有条件地匹配 <responsive-container> 元素。

<responsive-container> 元素匹配的选择器部分封装在 :where() 函数式伪类选择器中,以确保回退选择器的特异性与 @container 规则中原始选择器的特异性相同。

每个后备规则也会封装在 @supports 声明中。虽然这并不是回退运行的必要条件,但这意味着如果浏览器支持容器查询,就会完全忽略这些规则,这可以从整体上提高样式匹配性能。如果构建工具或 CDN 知道浏览器支持容器查询并且不需要这些后备样式,它们还可能允许它们去除这些声明。

这种后备策略的主要缺点是需要重复样式声明两次,既繁琐又容易出错。但是,如果您使用的是 CSS 预处理器,可以将其抽象为一个 mixin,为您生成 @container 规则和后备代码。下面是一个使用 Sass 的示例:

@use 'sass:map';

$breakpoints: (
  'SM': 512px,
  'MD': 576px,
  'LG': 1024px,
  'XL': 1280px,
);

@mixin breakpoint($breakpoint) {
  @container (min-width: #{map.get($breakpoints, $breakpoint)}) {
    @content();
  }
  @supports not (container-type: inline-size) {
    :where(responsive-container.#{$breakpoint}) & {
      @content();
    }
  }
}

完成此 mixin 后,您就可以将原始 .photo-gallery 组件样式更新为如下样式,从而完全消除重复项:

.photo-gallery {
  display: grid;
  grid-template-columns: 1fr;

  @include breakpoint('MD') {
    grid-template-columns: 1fr 1fr;
  }

  @include breakpoint('XL') {
    grid-template-columns: 1fr 1fr 1fr;
  }
}

就是这么简单!

回顾

总而言之,下面介绍如何更新代码,立即将容器查询与跨浏览器回退结合使用。

  1. 标识您要相对于其容器设置样式的组件,并在其 CSS 中更新 @media 规则以使用 @container 规则。此外(如果您尚未这样做的话),请对一组断点名称进行标准化,以匹配容器规则中的大小条件。
  2. 添加为自定义 <responsive-container> 元素提供支持的 JavaScript,然后将 <responsive-container> 元素添加到网页中您希望组件与其相对的任何内容区域。
  3. 为了支持旧版浏览器,请向 CSS 添加后备样式,这些样式与自动添加到 HTML 中的 <responsive-container> 元素的断点类相匹配。理想情况下,使用 CSS 预处理器 mixin 可以避免必须编写相同的样式两次。

此策略的一大优势是会产生一次性设置成本,但之后无需执行任何额外操作即可添加新组件并为其定义容器相关样式。

查看实际用例

要了解所有这些步骤如何结合在一起,最好的办法可能是观看操作演示演示

一个展示用户与容器查询演示网站互动的视频。用户正在调整内容区域的大小,以显示组件样式如何根据其包含内容区域的大小更新。

此演示是 2019 年创建的网站(在容器查询出现之前)的更新版本,旨在说明容器查询对于构建真正自适应的组件库为何至关重要。

由于此网站已经为许多“自适应组件”定义了样式,因此它非常适合通过一个大型网站来测试此处介绍的策略。事实证明,更新实际上非常简单,而且几乎不要求对原始网站样式进行任何更改。

您可以在 GitHub 上查看完整的演示源代码,同时务必专门查看演示组件 CSS,了解如何定义后备样式。如果您只想测试后备行为,可以使用仅包含该变体的 fallback-only 演示,即使在支持容器查询的浏览器中也是如此。

局限性和潜在改进

正如本文开头所述,此处概述的策略适用于开发者在进行容器查询时实际关注的大多数用例。

尽管如此,此策略也有意不会尝试支持一些更高级的用例,接下来会解决:

容器查询单元

容器查询规范定义了一些新单元,这些单元均与容器的大小相关。虽然在某些情况下可能有用,但大多数自适应设计仍可能通过现有方式实现,例如设置百分比或者使用网格或弹性布局。

也就是说,如果您确实需要使用容器查询单元,则可以使用自定义属性轻松添加对其的支持。具体而言,为容器元素中使用的每个单位定义一个自定义属性,如下所示:

responsive-container {
  --cqw: 1cqw;
  --cqh: 1cqh;
}

然后,每当您需要访问容器查询单元时,请使用这些属性,而不是使用单元本身:

.photo-gallery {
  font-size: calc(10 * var(--cqw));
}

然后,为了支持旧版浏览器,请在 ResizeObserver 回调内的容器元素上设置这些自定义属性的值。

class ResponsiveContainer extends HTMLElement {
  // ...
  updateBreakpoints(contentRect) {
    this.style.setProperty('--cqw', `${contentRect.width / 100}px`);
    this.style.setProperty('--cqh', `${contentRect.height / 100}px`);

    // ...
  }
}

这样,您就可以有效地将这些值从 JavaScript “传递”到 CSS,然后您就可以使用 CSS 的全部功能(例如 calc()min()max()clamp())根据需要操纵它们。

逻辑属性和写入模式支持

您可能已经注意到,在某些 CSS 示例中,@container 声明使用了 inline-size,而不是 width。您可能也注意到了新的 cqicqb 单元(分别针对内嵌大小和块大小)。这些新功能体现了 CSS 向逻辑属性和值(而非物理或方向属性)的转变。

遗憾的是,Resize Observer 等 API 仍会在 widthheight 中报告值,因此,如果您的设计需要逻辑属性的灵活性,则需要自行解决。

虽然可以使用 getComputedStyle() 等方法通过传入容器元素来获取写入模式,但这样做会产生费用,而且检测写入模式是否发生变化并非真正的好方法。

因此,最好的方法是让 <responsive-container> 元素本身接受一种书写模式属性,网站所有者可以根据需要设置(和更新)该属性。如需实现这一点,您需要遵循上一部分中所述的方法,并根据需要交换 widthheight

嵌套容器

借助 container-name 属性,您可以为容器命名,然后您可以在 @container 规则中引用该名称。如果您将容器嵌套在容器中,并且您需要某些规则仅匹配某些容器(而不仅仅匹配最近的祖先容器),那么已命名的容器会非常有用。

此处概述的后备策略使用后代组合器来设置与特定断点类匹配的元素的样式。如果您有嵌套容器,则可能会中断,因为来自多个容器元素祖先实体的任意数量的断点类可能会同时与给定组件匹配。

例如,下面有两个 <responsive-container> 元素封装了 .photo-gallery 组件,但由于外部容器大于内部容器,因此它们添加了不同的断点类。

<responsive-container class="SM MD LG">
  ...
  <responsive-container class="SM">
    ...
    <div class="photo-gallery">...</div class="photo-gallery">
  </responsive-container>
</responsive-container>

在此示例中,外部容器上的 MDLG 类会影响与 .photo-gallery 组件匹配的样式规则,这与容器查询的行为不匹配(因为它们仅与最近的祖先容器匹配)。

要解决此问题,您可以:

  1. 请务必始终为要嵌套的容器命名,然后确保断点类以该容器名称作为前缀以避免冲突。
  2. 在后备选择器中使用子组合器,而不是后代组合器(具有更多限制)。

演示网站的嵌套容器部分提供了一个使用命名容器的示例,并在代码中使用了它使用的 Sass mixin 来为已命名和未命名的 @container 规则生成后备样式。

如果浏览器不支持 :where()、自定义元素或 Resize Observer,会怎么样?

虽然这些 API 看似相对较新,但它们都已在所有浏览器中获得支持超过 3 年,并且已广泛应用于基准

因此,除非您有数据表明网站访问者中有很大一部分浏览器不支持这些功能,否则没有理由不通过后备选项自由使用这些功能。

即便如此,对于此特定用例,最坏的情况是回退功能对一小部分用户无效,这意味着他们会看到默认视图,而不是针对容器大小优化的视图。

网站的功能仍应正常发挥作用,这才是真正的关键。

为什么不直接使用容器查询 polyfill?

CSS 功能众所周知很难进行 polyfill 操作,并且通常需要在 JavaScript 中重新实现浏览器的整个 CSS 解析器和级联逻辑。因此,CSS polyfill 作者必须做出许多权衡取舍,而这几乎总是会带来许多功能限制和巨大的性能开销。

出于上述原因,我们通常不建议在生产环境中使用 CSS polyfill,包括 Google Chrome 实验室提供的 container-query-polyfill,因此,该实验室已不再进行维护(主要用于演示目的)。

此处讨论的回退策略的限制较少,需要的代码更少,并且性能将明显优于任何容器查询 polyfill。

您是否甚至需要针对旧版浏览器实现回退机制?

如果您对此处提到的任何限制存有疑虑,不妨先问问自己是否真的需要实现回退。毕竟,要避免这些限制,最简单的方法就是直接使用相应功能而不进行任何后备操作。老实说,在许多情况下,这可能是非常合理的选择。

根据 caniuse.com 的资料,全球 90% 的互联网用户都支持容器查询,而对于阅读这篇博文的很多人来说,其用户群的这一数字可能要高得多。因此请务必注意,大多数用户会看到容器查询版本的界面。而且,对于 10% 不会这样做的用户,他们也不会遇到不好的体验。遵循这一策略时,在最糟糕的情况下,这些用户会看到某些组件的默认或“移动设备”布局,这并不是世界末日。

进行权衡时,最好针对大多数用户进行优化,而不是默认采用最低标准方法,即为所有用户提供一致但较差的体验。

因此,在假设您会因缺少浏览器支持而无法使用容器查询之前,请先花点时间考虑一下,如果您选择采用这些查询会带来怎样的体验。即使没有任何后备选项,这种取舍或许也是值得的。

展望未来

希望这篇博文已经让您相信,现在在生产环境中使用容器查询是可行的,不必等到所有不支持的浏览器完全消失了,也不用等待数年之久。

虽然此处概述的策略确实需要一些额外的工作,但策略应当简单明了,以便大多数用户在其网站上采用。尽管如此,它肯定还有改进的空间,使其更易于采用。一种想法是将许多不同的部分整合为单个组件(针对特定框架或堆栈进行了优化),从而为您处理所有粘合工作。如果您构建了类似内容,请告诉我们,我们可以帮助您进行推广!

最后,除了容器查询,现在还有许多出色的 CSS 和界面功能可以跨所有主流浏览器引擎互操作。作为一个社区,现在让我们一起思考如何真正地使用这些功能,从而让我们的用户受益。