在容器查询 polyfill 内

Gerald Monaco
Gerald Monaco

容器查询是一项新的 CSS 功能,可让您编写样式逻辑以定位父元素的地图项(例如,其宽度或高度),以设置其子元素的样式。近期,我们发布了对 polyfill重大更新,正好是在浏览器中支持该技术。

在本博文中,您将能够一窥 polyfill 的工作原理、克服的挑战,以及使用 polyfill 为访问者提供出色用户体验的最佳实践。

深入了解

转译

当浏览器中的 CSS 解析器遇到未知的 @ 规则(如全新的 @container 规则)时,会直接将其舍弃,就像该规则从未存在一样。因此,polyfill 必须首先将 @container 查询转译为不会被舍弃的内容。

转译的第一步是将顶级 @container 规则转换为 @media 查询。这主要是为了确保内容始终归为一组。例如,在使用 CSSOM API 和查看 CSS 源代码时。

之前
@container (width > 300px) {
  /* content */
}
之后
@media all {
  /* content */
}

在进行容器查询之前,CSS 无法让作者随意启用或停用规则组。为了对此行为执行 polyfill 操作,还需要转换容器查询内的规则。每个 @container 都有自己的唯一 ID(例如 123),该 ID 用于转换每个选择器,以使它仅在元素具有包含此 ID 的 cq-XYZ 属性时才适用。此属性将在运行时由 polyfill 设置。

之前
@container (width > 300px) {
  .card {
    /* ... */
  }
}
之后
@media all {
  .card:where([cq-XYZ~="123"]) {
    /* ... */
  }
}

请注意这里使用了 :where(...) 伪类。通常,添加额外的属性选择器会提高选择器的特异性。使用伪类,可以在保持原始特异性的同时应用额外的条件。要了解这一点为何至关重要,请查看以下示例:

@container (width > 300px) {
  .card {
    color: blue;
  }
}

.card {
  color: red;
}

鉴于此 CSS,具有 .card 类的元素应始终具有 color: red,因为后面的规则将始终覆盖上一个具有相同选择器和特异性的规则。因此,如果转译第一条规则并在添加不带 :where(...) 的额外属性选择器,则会提高特异性,并导致错误应用 color: blue

不过,:where(...) 伪类是相当新的。对于不支持该 polyfill 的浏览器,polyfill 提供了一种安全简单的解决方法:您可以手动向 @container 规则添加一个虚拟的 :not(.container-query-polyfill) 选择器,以提高规则的特异性:

之前
@container (width > 300px) {
  .card {
    color: blue;
  }
}

.card {
  color: red;
}
之后
@container (width > 300px) {
  .card:not(.container-query-polyfill) {
    color: blue;
  }
}

.card {
  color: red;
}

这样做有很多好处:

  • 源 CSS 中的选择器已更改,因此特异性差异非常明显。这也可用作文档,让您知道在不再需要相应解决方案或 polyfill 时哪些项会受到影响。
  • 规则的特异性始终相同,因为 polyfill 不会对其进行更改。

在转译期间,polyfill 会将此虚拟内容替换为具有相同特异性的属性选择器。为了避免出现任何意外,polyfill 同时使用这两个选择器:原始来源选择器用于确定相应元素是否应接收 polyfill 属性,而转译的选择器用于设置样式。

伪元素

您可能会问自己一个问题:如果 polyfill 在某个元素上设置一些 cq-XYZ 属性以包含唯一容器 ID 123,如何支持无法设置属性的伪元素?

伪元素始终绑定到 DOM 中的实际元素(称为“原始元素”)。在转译期间,条件选择器会改为应用于此实际元素:

之前
@container (width > 300px) {
  #foo::before {
    /* ... */
  }
}
之后
@media all {
  #foo:where([cq-XYZ~="123"])::before {
    /* ... */
  }
}

条件选择器不会转换为 #foo::before:where([cq-XYZ~="123"])(无效),而是移动到源元素的末尾 #foo

然而,这还远远不够。容器不允许修改未包含在其中的任何内容(容器本身不能修改其中的内容),不过请注意,如果 #foo 本身就是要查询的容器元素,会发生什么情况。系统会错误地更改 #foo[cq-XYZ] 属性,并会错误地应用所有 #foo 规则。

为了纠正这个问题,polyfill 实际上使用了两个属性:一个只能由父项应用于某个元素,另一个是元素可以应用于其自身。后一个属性用于定位伪元素的选择器。

之前
@container (width > 300px) {
  #foo,
  #foo::before {
    /* ... */
  }
}
之后
@media all {
  #foo:where([cq-XYZ-A~="123"]),
  #foo:where([cq-XYZ-B~="123"])::before {
    /* ... */
  }
}

由于容器绝不会将第一个属性 (cq-XYZ-A) 应用于其自身,因此,只有当其他父级容器满足容器条件并应用该容器时,才会匹配第一个选择器。

容器相对单位

容器查询还附带了一些可在 CSS 中使用的新单位,例如 cqwcqh 分别代表最接近的相应父级容器的 1% 的宽度和高度。为了支持这些属性,可使用 CSS 自定义属性将单元转换为 calc(...) 表达式。polyfill 将通过容器元素上的内嵌样式来设置这些属性的值。

之前
.card {
  width: 10cqw;
  height: 10cqh;
}
之后
.card {
  width: calc(10 * --cq-XYZ-cqw);
  height: calc(10 * --cq-XYZ-cqh);
}

还有一些逻辑单位,例如 cqicqb 分别表示内嵌大小和块大小。这些语句稍微复杂一些,因为内嵌轴和块轴由使用单位的元素writing-mode 决定,而不是由要查询的元素确定。为了支持这一点,polyfill 会对 writing-mode 与其父项不同的任何元素应用内嵌样式。

/* Element with a horizontal writing mode */
--cq-XYZ-cqi: var(--cq-XYZ-cqw);
--cq-XYZ-cqb: var(--cq-XYZ-cqh);

/* Element with a vertical writing mode */
--cq-XYZ-cqi: var(--cq-XYZ-cqh);
--cq-XYZ-cqb: var(--cq-XYZ-cqw);

现在,可以像之前一样将单元转换为相应的 CSS 自定义属性。

属性

容器查询还添加了一些新的 CSS 属性,例如 container-typecontainer-name。由于 getComputedStyle(...) 等 API 无法用于未知或无效属性,因此它们也会在解析后转换为 CSS 自定义属性。如果属性无法解析(例如,由于其中包含无效值或未知值),则只会让浏览器自行处理。

之前
.card {
  container-name: card-container;
  container-type: inline-size;
}
之后
.card {
  --cq-XYZ-container-name: card-container;
  --cq-XYZ-container-type: inline-size;
}

每当发现这些属性时,系统都会对其进行转换,使 polyfill 能够与其他 CSS 功能(例如 @supports)完美地协同工作。此功能是使用 polyfill 的最佳实践的基础,如下所述。

之前
@supports (container-type: inline-size) {
  /* ... */
}
之后
@supports (--cq-XYZ-container-type: inline-size) {
  /* ... */
}

默认情况下,CSS 自定义属性是继承的,这意味着,例如,.card 的所有子级都将采用 --cq-XYZ-container-name--cq-XYZ-container-type 的值。这绝对不是原生属性的行为方式。为解决此问题,polyfill 会在任何用户样式之前插入以下规则,以确保每个元素都会收到初始值,除非被其他规则有意替换。

* {
  --cq-XYZ-container-name: none;
  --cq-XYZ-container-type: normal;
}

最佳实践

虽然预计大多数访问者很快就会运行支持内置容器查询的浏览器,但为其余访问者提供良好的体验仍然非常重要。

在初始加载期间,需要执行很多操作,然后 polyfill 才能设置页面布局:

  • 此 polyfill 需要加载并初始化。
  • 样式表需要解析和转译。由于没有任何 API 可用于访问外部样式表的原始来源,因此可能需要以异步方式重新提取该表格,但最好是从浏览器缓存中重新获取。

如果 polyfill 没有仔细解决这些问题,可能会导致您的核心网页指标出现回归问题。

为了让您更轻松地为访问者提供愉悦的体验,polyfill 旨在优先考虑 First Input Delay (FID)Cumulative Layout Shift (CLS),而这可能会牺牲 Largest Contentful Paint (LCP)。具体而言,polyfill 不能保证您的容器查询会在首次绘制之前进行评估。这意味着,为了提供最佳用户体验,您必须确保在 polyfill 加载并转译 CSS 之前,隐藏任何因使用容器查询而影响大小或位置的内容。实现此目的的方法之一是使用 @supports 规则:

@supports not (container-type: inline-size) {
  #content {
    visibility: hidden;
  }
}

建议您将此动画与纯 CSS 加载动画结合起来,使其绝对定位在(隐藏)内容之上,从而告知访问者正在发生的情况。您可以点击此处查看此方法的完整演示。

建议使用此方法,原因如下:

  • 纯 CSS 加载器可最大限度减少使用新版浏览器的用户的开销,同时为使用旧版浏览器和网速较慢的用户提供精简反馈。
  • 通过将加载器的绝对定位与 visibility: hidden 相结合,可以避免布局偏移。
  • polyfill 加载后,此 @supports 条件将停止传递,并显示您的内容。
  • 在内置支持容器查询的浏览器上,该条件永远不会通过,因此在首次渲染时,网页将按预期显示。

总结

如果您希望在旧版浏览器上使用容器查询,不妨试试 polyfill。如果您遇到任何问题,请随时提交问题

我们迫不及待地想看到和体验您将用它构建的令人惊叹的成果。

致谢

主打图片由 Dan Cristian Pădure 上传,Unsplash 用户。