容器查询是一种提议,它允许Web开发人员根据包含元素的大小而不是浏览器视口的大小来设置DOM元素的样式。
如果您是Web开发人员,您可能以前听说过容器查询。只要我们有响应式网页设计,我们就有开发人员要求他们(最初元素查询,然后更改为容器查询)。事实上,容器查询可能是迄今为止我们浏览器中没有的CSS 请求中最需要的。
现在已经有很多,很多,很多帖子正好解释了为什么容器查询是很难在CSS做,为什么浏览器制造商一直在犹豫要实现它们。我不想在这里重新讨论这个讨论。
我没有把注意力集中在我们称之为“容器查询”的特定CSS特性提案上,而是专注于构建响应其环境的组件的更广泛概念。如果你接受这个更大的框架,实际上有新的web API已经让你实现这一点。
没错,我们不需要等待容器查询开始构建响应式组件。我们现在可以开始构建它们了!
我将在本文中提出的策略可以在今天使用,并且它被设计为增强功能,因此不支持更新的API或不运行JavaScript的浏览器将按照他们目前的工作方式运行。它也很容易实现(复制/粘贴),高性能,并且不需要任何特殊的构建工具,库或框架。
为了看到这个策略的一些实例,我构建了一个响应组件演示站点。每个演示链接到它的CSS源代码,所以你可以看到它的工作原理。
但在深入演示之前,您应该阅读本文的其余部分,了解策略如何工作。
策略
根据这两个核心原则,最具响应性的设计策略或方法(这一点将不会有所不同):
- 对于每个组件,首先定义一组通用的基本样式,无论组件位于何种环境中,该样式都适用。
- 然后为那些将在特定环境条件下应用的基础样式定义增加或覆盖。
即使浏览器不支持满足或启用特定环境条件所需的功能,这些原则的力量也是可行的。这包括功能要求JavaScript的情况 – 禁用JavaScript的用户将获得基本样式,而且这些都可以正常工作。
在大多数情况下,上面#1中定义的基本样式是在最小可能的屏幕尺寸上工作的样式(因为小屏幕往往比大屏幕更具限制性),并且它们不包含在任何排序媒体查询中(所以它们应用到处)。
下面是一个例子,它定义.MyComponent
了两个任意断点的基本样式,然后覆盖样式,36em
并且48em
:
.MyComponent {
/* Base styles that work for any screen size */
}
@media (min-width: 36em) {
.MyComponent {
/* Overrides the above styles on screens larger than 36em */
}
}
@media (min-width: 48em) {
.MyComponent {
/* Overrides the above styles on screens larger than 48em */
}
}
当然,这些断点使用媒体查询,所以它们适用于浏览器视口的大小。什么容器查询倡导者想要的是能够做这样的事情(注意,这是建议的语法,而不是官方语法):
.Container:media(min-width: 36em) > .MyComponent {
/* Overrides that only apply for medium container sizes */
}
不幸的是,上述语法在今天的任何浏览器中都不起作用,并且可能不会很快。
但是,今天的工作是这样的:
.MyComponent {
/* Base styles that work on any screen size */
}
.MD > .MyComponent {
/* Overrides that apply for medium container sizes */
}
.LG > .MyComponent {
/* Overrides that apply for large container sizes */
}
当然,这个代码假定组分容器具有添加到它们正确的类(在本例中,.MD
和.LG
)。但是暂时忽略这些细节,如果你是一个想要构建响应组件的CSS开发人员,那么第二种语法对你来说可能还是有意义的。
无论您是将容器查询作为显式长度比较查询(第一种语法)还是使用命名断点类(第二种语法)编写,您的样式仍然是声明性的,功能上是相同的。只要你可以定义你想要的命名断点,我不会看到其中一个明显的好处。
为了澄清本文的其余部分,让我使用下面的映射(min-width
适用于容器而不是视口)定义我正在使用的命名断点类:
命名断点 | 容器宽度 |
---|---|
SM | min-width: 24em |
MD | min-width: 36em |
LG | min-width: 48em |
XL | min-width: 60em |
现在我们要做的就是确保我们的容器元素总是拥有正确的断点类,所以正确的组件选择器将会匹配。
观察容器大小
对于大多数Web开发历史,可以观察对窗口的更改,但是观察对单个DOM元素的大小更改是困难的或不可能的(至少以高性能的方式)。当Chrome 64发布ResizeObserver时,这一点发生了变化。
ResizeObserver
,继类似MutationObserver和IntersectionObserver之类的API之后,Web开发人员能够以高性能的方式观察DOM元素的大小变化。
以下是您在上一节中使用CSS所需的代码ResizeObserver
:
// Only run if ResizeObserver is supported.
if ('ResizeObserver' in self) {
// Create a single ResizeObserver instance to handle all
// container elements. The instance is created with a callback,
// which is invoked as soon as an element is observed as well
// as any time that element's size changes.
var ro = new ResizeObserver(function(entries) {
// Default breakpoints that should apply to all observed
// elements that don't define their own custom breakpoints.
var defaultBreakpoints = {SM: 384, MD: 576, LG: 768, XL: 960};
entries.forEach(function(entry) {
// If breakpoints are defined on the observed element,
// use them. Otherwise use the defaults.
var breakpoints = entry.target.dataset.breakpoints ?
JSON.parse(entry.target.dataset.breakpoints) :
defaultBreakpoints;
// Update the matching breakpoints on the observed element.
Object.keys(breakpoints).forEach(function(breakpoint) {
var minWidth = breakpoints[breakpoint];
if (entry.contentRect.width >= minWidth) {
entry.target.classList.add(breakpoint);
} else {
entry.target.classList.remove(breakpoint);
}
});
});
});
// Find all elements with the `data-observe-resizes` attribute
// and start observing them.
var elements = document.querySelectorAll('[data-observe-resizes]');
for (var element, i = 0; element = elements[i]; i++) {
ro.observe(element);
}
}
此代码ResizeObserver
使用回调函数创建单个实例。然后它向DOM查询具有该data-observe-resizes
属性的元素并开始观察它们。回调函数在观察时初始调用,然后在任何更改后再次调用,检查每个元素的大小并添加(或删除)相应的断点类。
换句话说,这段代码将变成一个600像素宽的容器元素:
<div data-observe-resizes>
<div class="MyComponent">...</div>
</div>
进入这个:
<div class="SM MD" data-observe-resizes>
<div class="MyComponent">...</div>
</div>
随着容器大小的改变,这些类将自动且立即得到更新。
有了这个地方,现在所有的.SM
和.MD
前一节中选择将匹配(而不是.LG
或.XL
选择),而代码将只是工作!
定制您的断点
上面ResizeObserver回调中的代码定义了一组默认的断点,但它也允许您通过传递JSON通过data-breakpoints
属性来指定基于每个组件的自定义断点。
我建议更改上面的代码以使用任何默认断点映射对您的组件最有意义,然后任何需要它自己的特定断点集的组件都可以将它们定义为内联:
<div data-observe-resizes
data-breakpoints='{"BP1":400,"BP2":800,"BP3":1200}'>
<div class="MyComponent">...</div>
</div>
我的响应式组件站点有一个组件示例,用于设置自定义断点以及使用默认断点的组件。
处理动态DOM更改
上面的代码示例仅适用于已经在DOM中的容器元素。
对于基于内容的网站来说,这通常很好,但对于DOM不断变化的更复杂的网站,您需要确保观察所有新添加的容器元素。
解决这个问题的一个万能解决方案是将上面的代码片段扩展为包含一个跟踪所有添加的DOM元素的MutationObserver。这是我在“响应式组件”演示网站中使用的方法,适用于DOM变化有限的中小型网站。
对于经常更新DOM的大型网站,您可能已经使用了自定义元素或带有组件生命周期方法的Web框架,这些方法可跟踪元素何时添加和从DOM中移除。如果是这种情况,最好勾住这个机制。你可能甚至想制作一个通用的,可重用的容器组件。
例如,一个自定义<responsive-container>
元素可能看起来像这样:
// Create a single observer for all <responsive-container> elements.
const ro = new ResizeObserver(...);
class ResponsiveContainer extends HTMLElement {
// ...
connectedCallback() {
ro.observe(this);
}
}
self.customElements.define('responsive-container', ResponsiveContainer);
嵌套组件
在我对这个策略的最初实验中,我没有用容器元素包装每个组件。相反,我为每个不同的内容区域(标题,侧边栏,页脚等)使用了一个容器元素,而在我的CSS中,我使用了后代组合器而不是子组合器。
这导致了更简单的标记和CSS,但是当我尝试在其他组件中嵌套组件时(许多复杂的站点会这样做),它很快就会崩溃。问题是,用后代组合方法,选择器可以同时匹配多个容器。
在构建了一些非平凡的演示之后,很明显,每个组件及其容器的直接子/母结构更容易管理和扩展。请注意,只要每个托管组件都是直接后代,容器仍可以托管多个组件。
高级选择器和替代方法
我在本文中概述的策略采用了一种添加式样式组件的方法。换句话说,您从基础样式开始,然后在顶部添加更多样式。但是,这不是处理样式组件的唯一方法。在某些情况下,您想要定义完全匹配的样式,并且只应用于特定的断点(即,而不是(min-width: 48em)
您希望的样子(min-width: 48em) and (max-width: 60em)
)。
如果这是您首选的方法,那么您需要稍微调整ResizeObserver回调代码以仅应用当前匹配断点的类名称。因此,如果组件的“大”大小,而不是设置类名称SM MD LG
,你只需设置LG
。
然后,在你的CSS中,你可以编写这样的选择器:
/* To match breakpoints exclusively */
.SM > .MyComponent { }
.MD > .MyComponent { }
.LG > .MyComponent { }
/* To match breakpoints additively */
:matches(.SM) > .MyComponent { }
:matches(.SM, .MD) > .MyComponent { }
:matches(.SM, .MD, .LG) > .MyComponent { }
请注意,当使用我的推荐策略进行叠加匹配时,您仍然可以通过类似选择器完全匹配断点.MD:not(.LG)
,尽管这可以说不太清楚。
在一天结束时,您可以选择最适合您的惯例,并且最适合您的情况。
基于高度的断点
到目前为止,我的所有例子都集中在基于宽度的断点上。这是因为,根据我的经验,绝大多数响应式设计实现使用宽度,没有其他东西(至少在涉及视口尺寸时)。
然而,这个策略中没有任何东西可以阻止组件响应其容器的高度。ResizeObserver报告宽度和高度尺寸,所以如果你想观察高度变化,你可以定义一组单独的断点类 – 可能带有W-
基于宽度断点的H-
前缀和基于高度断点的前缀。
浏览器支持
虽然ResizeObserver
是目前仅适用于Chrome支持的,但绝对没有理由你不能(或不应该)今天使用它。如果浏览器不支持ResizeObserver或者即使JavaScript被禁用,我在这里概述的策略也被有意设计为可以很好地工作。无论是哪种情况,用户都会看到您的默认样式,这应该足以提供出色的用户体验。事实上,他们可能只是您今天已经服务的相同款式。
我推荐的方法是针对您的网站布局使用媒体查询,然后针对需要它的特定组件(很多不会)使用此响应组件策略。
如果您真的想在所有浏览器中提供一致的用户界面,您可以加载ResizeObserver填充,它具有很好的浏览器支持(IE9 +)。但是,确保只在用户实际需要时才加载polyfill。
另外,考虑到polyfills在移动设备上的运行速度往往较慢,并且考虑到响应组件主要只是在较大屏幕尺寸下才起作用的东西,所以如果用户使用屏幕尺寸较小的设备,则可能无需加载polyfill 。
响应组件演示网站采用后一种方法。它加载polyfill,但仅当用户的浏览器不支持ResizeObserver
并且用户的屏幕宽度至少为48em
。
限制和未来改进
总的来说,我认为我在这里概述的响应组件策略非常灵活,并且缺点很少。我坚信,每个包含大小可能独立于视口而变化的内容区域的网站都应该实施响应式组件策略,而不是仅依赖媒体查询(或者不使用ResizeObserver的基于JavaScript的解决方案)。
这就是说,这个策略有一些我认为值得讨论的限制。
这不是纯粹的CSS
这个解决方案的一个明显的缺点是它需要的不仅仅是CSS来实现。除了在CSS中定义样式之外,还必须在HTML中注释容器,并使用JavaScript来协调这两个容器。
虽然我认为我们都会同意纯粹的CSS解决方案是最终目标,但我希望我们作为一个社区能够阻止完美成为优秀的敌人。
在这样的事情中,我想提醒自己,W3C的HTML设计原则引用了这一点:
在冲突的情况下,考虑超过作者的用户而不是超过理论纯度的指定者。
未使用/不正确格式的内容闪烁
在大多数情况下,最好的做法是异步加载所有的JavaScript,但在这种情况下,异步加载可能会导致组件初始时在默认断点处呈现,但只会在加载JavaScript时突然切换到更大的断点。
虽然这不是最糟糕的体验,但您不必担心纯CSS解决方案。而且由于这个策略涉及JavaScript的协调,所以当你的样式和断点被应用时,为了避免这种重新布局,你还必须协调。
我发现处理这个问题的最好方法是将您的容器查询代码内联到HTML模板的末尾,以便尽快运行。然后,您应该在容器元素初始化并可见时向其添加类或属性,以便知道何时可以安全地显示它们(并且确保考虑禁用JavaScript或运行时出现错误的情况)。你可以在演示网站上看到我如何做到这一点的例子。
单位基于像素
许多(如果不是大多数)CSS开发人员更喜欢基于具有更多上下文相关性的单元(例如,em
基于字体大小或vh
基于视口高度等)来定义样式,而ResizeObserver
像大多数DOM API一样,它以像素为单位返回其所有值。
目前真的没有什么好办法解决这个问题。
未来,一旦浏览器实现了CSS Typed OM(新的CSS Houdini规范之一),我们就可以轻松便宜地在各种CSS单元之间转换任何元素。但在此之前,进行转换的成本可能会损害性能,足以降低用户体验。
结论
本文介绍了使用现代Web技术构建响应式组件的策略:DOM元素可以更新其样式和布局以响应容器大小的变化。
虽然以前尝试构建响应式组件对探索这个空间很有价值,但平台的局限性意味着这些解决方案总是太大或太慢,或者两者兼而有之。
幸运的是,我们现在拥有浏览器API,可以帮助我们构建高效且高性能的解决方案。本文中概述的策略:
- 今天,将在任何网站上工作
- 很容易实现(复制/粘贴)
- 与基于CSS的解决方案一样好
- 不需要任何特定的库,框架或构建工具。
- 利用渐进式增强功能,浏览器上缺少必需API或禁用JavaScript的用户仍可使用该网站。
虽然我在这篇文章中概述的策略是生产就绪,但我认为我们仍然处于这个领域的早期阶段。随着Web开发社区开始将其组件设计从视口或面向设备转向面向容器,我很高兴看到出现哪些可能性和最佳实践。