前端开发者学堂 - fedev.cn

初探CSS的容器模块

发布于 大漠

CSS的容器模块指的是CSS Containment Module,到目前为止主要分为 Level 1Level 2两个版本。在容器模块中主要包含了CSS的 containcontent-visibility 两个属性。这两个属性都可以帮助我们提高Web页面的性能,那么它们是如何工作的?我们在实际项目中又应该怎么使用呢?如果你感兴趣的话,请继续往下阅读。

重排和重绘

在开始介绍containcontent-visibility属性之前,我们先简单的了解两个重要概念:重排(Reflows)重绘(Repaints)

  • 重排(Reflows):当DOM的变化影响了元素的几何信息(DOM对象的位置和尺寸大小),浏览器需要重新计算元素的几何属性,将其安放在界面中的正确位置,这个过程叫做 重排(重排也叫回流)
  • 重绘(Repaints):当一个元素外观发生改变,但没有改变布局,重新把元素外观绘制出来的过程,这个过程叫做 重绘

简单地回忆一下,浏览器在渲染一个页面时会经历像下图这样的一个过程:

也就是说,浏览器的渲染过程其实就是 将页面转换成像素显示到屏幕上,它大致会经历以下几个过程:

在Web页面的生命周期中,Web页面生成的时候,至少会渲染一次。在用户访问的过程中,还会不断触发重排和重绘。需要注意的是,重绘不一定需要重排,但重排必然导致重绘!不管页面发生了重绘还是重排,都会影响性能,其中最可怕的就是重排,会使我们付出高额的性能代价,所以我们应该尽量避免。

简单地说,修改DOM修改样式用户事件会导致Web页面重新渲染,也就触发重排重绘。JavaScript的一些操作会对DOM和样式进行修改,比如说offsetLeftoffsetTopoffsetWidthscrollBy()scrollTo()getComputedStyle()等,除了这些操作会触发重排和重绘之外,还有其他的一些JavaScript操作,具体的可以查阅:

有关于这方面更详细的介绍,还可以阅读@Charis Theodoulou的《Web Performance: Minimising DOM Reflow / Layout Shift》一文。

什么是CSS容器(Containment)

CSS容器模块主要目标是提高Web页面的渲染性能,它允许将子树(Subtree)和文档(Document)的其余部分隔离开来。这个规范引入了两个新的CSS属性:containcontent-visibility两个属性。浏览器引擎可以使用这些特性来对Web性能进行优化:

  • 当容器的内容发生变化时,浏览器考虑到其他元素可能也会发生变化,于是就会去检查页面中所有的元素。一直以来浏览器都是这么做的,大家都习以为常了。但从另一方面来说,开发者很清楚当前修改的元素是否独立、是否影响其他元素。因此如果开发者能把这个信息通过CSS告诉浏览器,那么浏览器就不需要再去考虑其他元素了,这就是非常完美的事情。而CSS容器模块中的contain属性就为我们提供了这种能力
  • CSS容器模块中的content-visibility属性会显著影响第一次下载和第一次渲染的速度。此外,你可以立即与新渲染的内容交互,而无需等待内容的其余部分加载。该属性强制用户代理跳过不在屏幕上的标记和绘制元素。实际上,它的工作方式类似于延迟加载,只是不加载资源,而是渲染资源

接下来,我们分别来探讨这两个属性的具体使用。

CSS contain属性

CSS的contain属性现在得到了众多主流浏览器的支持:

W3C规范是这样描述contain

The contain property allows an author to indicate that an element and its contents are, as much as possible, independent of the rest of the document tree. This allows user agents to utilize much stronger optimizations when rendering a page using contain properly, and allows authors to be confident that their page won’t accidentally fall into a slow code path due to an innocuous change.

大致的意思是: contain属性允许我们指定特定的DOM元素和它的子元素,让它们能够独立于整个DOM树结构之外。目的是能够让浏览器有能力只对部分元素进行重绘、重排,而不必每次针对整个页面

contain可接受的值:

contain: none | strict | content | [ size || layout || style || paint ]

其中nonecontain的默认值,在实际使用的时候,我们可以通过contain的其他五个值中的某一个来规定元素以何种方式独立于文档树:

  • layout :该值表示元素的内部布局不受外部的任何影响,同时该元素以及其内容也不会影响以上级
  • paint :该值表示元素的子级不能在该元素的范围外显示,该元素不会有任何内容溢出(或者即使溢出了,也不会被显示)
  • size :该值表示元素盒子的大小是独立于其内容,也就是说在计算该元素盒子大小的时候是会忽略其子元素
  • content :该值是contain: layout paint的简写
  • strict :该值是contain: layout paint size的简写

在上述这几个值中,sizelayoutpaint可以单独使用,也可以相互组合使用;另外contentstrict是组合值,即contentlayout paint的组合,strictlayout paint size的组合。

在规范中还有style类型,但style常被认为是没有什么价值,而且很有可能从规范中移除。

我们在这里只会围绕sizelayoutpaint三种类型展开。

size类型

先来看下个contain属性取值为size的示例效果。拿我们最为熟悉的手风琴效果为例:

为了更好的看出contain的效果,我们在手风琴的容器上添加一些样式:

.accordion__item {
    border: 1px dashed #015fcc;
    border-radius: 4px;
    box-shadow: 0 0px 1px 1px #015fcc;
}

当手风琴展开时,边框和阴影也会展开:

但当我们在.accordion__item上设置:

.accordion__item {
    contain: size
}

手风琴展开,但容器高度并没有变化:

具体效果如下:

从效果上我们可以感知:

显式设置了contain:size的元素渲染不会受其子元素内容的影响

我们在Web布局中,对于容器而言,它的预期大小要么是显式的设置widthheight(或通过一些设置元素尺寸大小属性)来决定,要么是由容器子元素内容来决定。如果一个容器什么都没做(也没有子内容),那么它在页面中什么都看不见:

这里为了让大家能看到有一个容器,在.container设置了一个最为简单的样式:

.container {
    border: 1px dashed #f36;
    padding: 5px;
}

当我们点击“Add Item”按钮之后,JavaScript会创建一个新的div.box且插入到.container容器中,这个时候.container容器会因为.box的内容撑开:

一旦我们给.container容器开启了contain的设置:

.container {
    contain: size
}

即使有多个.box子元素,.container大小还是初始大小:

前面我们也提到过,如果我们通过JavaScript动态创建DOM的时候,很有可能会造成页面的重排和重绘,这对于性能的开锁是很大的。换句话说,如果你希望在操作的时候对于原有的元素不想造成重排或重绘的话,就可以考虑在该元素上显式设置contain属性,比如示例中的.container设置了contain:size之后,新创建的子元素对它不会有任何影响。

但是在使用contain:size的时候也需要特别注意,它很有可能会影响Web的布局效果。正如上面的示例,你可能已经发现了,当.container设置contain:size时,原有的Flexbox布局的计算就受到相应的影响了。

事实上,contain:size实际上并没有提供太多的优化方法。它通常与contain其他的值中的一个组合在一起。比如和layout,可能的优化可以被启用包括(但不限于):

  • 当包含框的后代的样式或内容被更改时,计算DOM树的哪些部分“脏了”并可能需要重新布局可以在包含框处停止
  • 在布局页面时,如果包含框在屏幕外或模糊,其内容的布局可能会延迟或以较低的优先级完成

不过,contain:size可以提供的一个好处是帮助JavaScript根据容器的大小改变容器的后代。在某些情况下,根据容器的大小更改子元素可能会导致容器在对子元素进行更改之后更改其大小。由于容器大小的更改可能会触发子元素(或后代元素)的另一个更改,而且这样的操作可能是一个不断循环的过程。针对于这样的场景,contain:size就可以帮助我们防止循环。

我们来看@Travis Almand提供的一个Demo

在这个示例中,我们先来看未选中“contain: size”之间几个按钮操作给mainsection元素带来的变化:

点击“Start”按钮后,红色方框(section元素)的宽度(width)会开始变大(基于父元素mainwidth基础上加上5px)。当紫色方框(main)调整大小时,ResizeObserver会告诉红色方框(section)根据父元素方框的大小再次调整大小。这就产生一个循环,红色框宽度变大了就会导致紫色框变大,而红色框的宽度又是基于紫色框计算,所以紫色框变大了,红色框又会基于紫色框变大,依此循环,只到紫色框(main)宽度超过300px,就会停止这样的过程,以防止无限循环。

点击“Reset”按钮可以把mainsection回到初始化状态。

点击“Resize Container”按钮,紫色方框(main)的width会变大。延时之后,红框(section)的width也会相应地调整。再次点击该按钮将使紫色方框回到原来的大小,然后红色方框的大小也将再次调整。

接下来看复选框选中状态下(contain: size),几个按钮操作的效果:

现在,当你点击“Start”按钮时,红色方框将根据紫色方框的宽度调整自己的大小。你会看以红色方框溢出了紫色方框,但重点是它只调整一次大小,然后停止,不再有循环。

另外,在使用contain:size的时候并不是任何元素上都是有效的。如果元素不生成主框(比如display设置了contentsnone),或者它内部显示类型是表格(display: table),或者它的主框是内部表框、内部ruby框或非原子内联级框,那么contain:size就没有影响。

layout类型

我们可以在任何元素上显式设置:

.element {
    contain: layout;
}

这样做的好处是可以告诉浏览器外部元素不会影响容器元素的内部布局,容器元素的内部布局也不会影响外部元素。因此,当浏览器进行布局计算时,它可以假设具有contain: layout的各种元素不会影响其他元素。这可以减少布局相关的计算量。

另外一个好处是,如果容器在屏幕外或模糊,相关计算的优先级可能会延迟或降低。

我们通俗一点讲,布局折范围通常是整个文档,这意味着如果你移动了一个元素,那么整个文档就需要被当作可以移动到任何地方一样处理。如果我们使用contain: layout就可以告诉浏览器它只需要检查这个元素,而元素内的所有内容都限定在那个元素上,不会影响页面的其余部分,并且包含框还会建立一个独立的格式化上下文。

此外:

  • 浮动布局将独立执行
  • margin不会在显式设置contain: layout的容器上具有折叠
  • 布局容器将是绝对、固定定位的相对计算容器
  • 包含框创建一个堆叠上下文

比如说,

  • 比如说容器的子元素显式设置了float会致使容器高度塌陷,但对于contain: layout的容器中的子元素显式设置了浮动之后,并不会造成其父容器高度塌陷(有点类似于在浮动元素父元素上显式设置了display: flow-root),这也印证了设置了contain: layout的容器的子元素改变不会对其有影响
  • 对于固定定位,一般情况之下都是相对地视窗进行定位,但当固定定位元素的容器显式设置了contain: layout,那么固定元素会相对于该容器定位,有点类似于固定元素相对于显式设置了transform的容器定位
  • 显式设置了contain: layout的元素会创建一个堆叠上下文,这个时候z-index就生效了(在我们的认知中,只有position设置了非static的元素,z-index才有效)

比如下面这个示例:

就上面示例而言,容器<main>示显式设置contain: layout时,固定定位元素、绝对定位元素的定位都是相对于视窗来定位,浮动元素容器高度塌陷,元素自身不具备z-index的特性:

一旦<main>容器显式设置了contain: layout之后,所有的一切都有所变动:

也就是说,启用了contain: layout可以潜在地将每一帧需要渲染的元素数量减少到少数,而不是重新渲染整个文档,从而为浏览器节省了大量的计算(没必要的工作),渲染的时候就减少了重排、重绘的概率。

paint类型

显式设置了contain: paint的容器(元素)则会告诉浏览器,该容器的所有子元素(后代元素)都不会绘制到该容器框边界之外,有点类似于容器设置了overflow: hidden

设置了contain: paintcontain: layout有点类似:

  • 浮动布局将独立执行
  • margin不会在显式设置contain: layout的容器上具有折叠
  • 布局容器将是绝对、固定定位的相对计算容器
  • 包含框创建一个堆叠上下文

我们基于上面的示例,将contain: layout的值更改变contain: paint

容器<main>示开启contain: paint设置时,效果如下:

开启之后,效果如下:

也就是说:

通过使用 contain: paint, 如果元素处于屏幕外,那么用户代理就会忽略渲染这些元素,从而能更快的渲染其它内容。

sizelayoutpaint组合使用

contain还可以取值为contentstrict,而这两个属性值是多个contain的简写属性:

  • contain: content相当于contain: layout paint
  • contain: strict相当于contain: layout paint size

正如上面所述,sizelayoutpaint提供了不同的方式来影响浏览器渲染计算:

  • size: 告诉浏览器,当其内容发生变化时,该容器不应导致页面上的位置移动
  • layout:告诉浏览器,容器的后代不应该导致其容器外元素的布局改变,反之亦然
  • paint:告诉浏览器,容器的内容将永远不会绘制超出容器的尺寸,如果容器是模糊的,那么就根本不会绘制内容

由于它们各自提供了不同的优化,因此组合其中一些是有意义的。而且规范上也是允许这么做的。例如,我们可以将layoutpaint混合在一起使用,比如:

.element {
    contain: content
}

相当于:

.element {
    contain: layout paint;
}

contain如何提高Web页面性能

经过上面的学习,我们对CSS容器模块中的contain属性有了一个基本的认识。为了更好的帮助大家了解该属性是如何提高Web页面性能,我们来看@Manuel Rego Casasnovas在《An introduction to CSS Containment》文章中提供的一个示例

假设一个页面有很多个元素,在这个示例中,我们有10000个这样的元素:

<div class="item">
    <div>Lorem ipsum...</div>
</div>

使用JavaScript的textContent这个API来动态更改div.item > div的内容:

const NUM_ITEMS = 10000;
const NUM_REPETITIONS = 10;

function log(text) {
    let log = document.getElementById("log");
    log.textContent += text;
}

function changeTargetContent() {
    log("Change \"targetInner\" content...");

    // Force layout.
    document.body.offsetLeft;

    let start = window.performance.now();

    let targetInner = document.getElementById("targetInner");
    targetInner.textContent = targetInner.textContent == "Hello World!" ? "BYE" : "Hello World!";

    // Force layout.
    document.body.offsetLeft;

    let end = window.performance.now();
    let time = window.performance.now() - start;
    log(" Time (ms): " + time + "\n");
    return time;
}

function setup() {
    for (let i = 0; i < NUM_ITEMS; i++) {
    let item = document.createElement("div");
    item.classList.add("item");

    let inner = document.createElement("div");
    inner.style.backgroundColor = "#" + Math.random().toString(16).slice(-6);
    inner.textContent = "Lorem ipsum...";
    item.appendChild(inner);

    wrapper.appendChild(item);
    }
}

如果不使用contain,即使更改是在单个元素上,浏览器在布局上的渲染也会花费大量的时间,因为它会遍历整个DOM树(在本例中,DOM树很大,因为它有10000个DOM元素):

在本例中,div的大小是固定的,我们在内部div中更改的内容不会溢出它。因此,我们可以将contain: strict应用到项目上,这样当项目内部发生变化时,浏览器就不需要访问其他节点,它可以停止检查该元素上的内容,并避免到外部去。

尽管这个例子中的每一项都很简单,但通过使用contain,但Web性能得到很大的改变,从~4ms降到了~0.04ms,这是一个巨大的差异。想象一下,如果DOM树具有非常复杂的结构和内容,但只修改了页面的一小部分,如果可以将其与页面的其他部分隔离开来,那么将会发生什么情况呢?

是不是想想都是美美的?

扩展阅读

上面我们看到的是CSS 容器模块中contain属性的基本使用。如果你对这方面感兴趣的话,还可以阅读:

CSS content-visibility属性

CSS的content-visibility属性 可跳过不在屏幕上的内容渲染,包括布局(Layout)和渲染(Paint),直到真正需要布局渲染的时候为止。所以利用它可以使用初始用户加载速度更快,还能与屏幕上的内容进行更快的交互。

上图来自于@Una Kravets和@Vladimir Levin的《content-visibility: the new CSS property that boosts your rendering performance》一文。从图中我们可以获知,使用content-visibility: auto属性可使分块的内容区域的初始加载性能提高7倍。

将HTML转换为用户可以看到的内容,需要浏览器在绘制第一个像素之前经历许多步骤。它对整个页面都这样做,甚至是对视窗(Viewport)中不可见的内容。

对于应用了content-visibility: auto的元素,将会告诉浏览器它可以跳过该元素的渲染工作,直到它滚动到视口,这提供了一个更快的初始渲染。

content-visibility属性接受三个值:

content-visibility: visible | auto | hidden

其中visible是其默认属性。

  • visible :没有效果。元素的内容以正常方式布局和渲染
  • hidden :元素跳过其内容。跳过的内容必须不能被用户代理特性(比如内查找,标签顺序导航等)访问,也不能是可选择或可定焦的,类似于display: none
  • auto :元素开启contain(取值可能是layoutpaint等)。如果元素与用户无关,它也会跳过其内容

到目前为止,content-visibility只得到了Chromium 85浏览器的支持。

事实上,content-visibility属性也是CSS容器模块的属性之一。而CSS容器模块(CSS Containment)规范的主要目的是:

在页面渲染的过程中通过忽略文档中的某些子树来提高页面的渲染性能

当容器的内容发生变化时,浏览器考虑到其他元素可能也会发生变化,于是就会去检查页面中所有的元素。一直以来浏览器都是这么做的,大家都习以为常了。但是从另一方面来说,开发者很清楚当前修改的元素是否独立是否影响其他元素。因此,如果开发者能把这个信息通过CSS来告诉浏览器,那么浏览器就不需要再去考虑其他元素了,这是非常完美的事情。我们前面提到的contain属性就提供了这种能力。

  • size : 表示元素盒子的大小是独立于其内容,也就是说在计算该元素盒子大小的时候是会忽略其子元素
  • layout : 该值表示元素的内部布局不受外部的任何影响,同时该元素以及其内容也不会影响到上级
  • paint : 声明这个元素的子孙节点不会在它边缘外显示。如果一个元素在视窗外或因其他原因导致不可见,则同样保证它的子孙节点不会被显示

注意,contain还可以取值style。该值指的是声明那些同时会影响这个元素和其子孙元素的属性,都在这个元素的包含范围内。只不过我们前面选择性的忽略了contain: style,那是因为style可有可能会被移出CSS容器模块。

设置 content-visibility 跳过渲染

虽然前面我们花了一定的篇幅和相应的Demo阐述了contain属性的作用和使用,但对于很多同学来说(包括偶)都很难清楚明白在实际使用的时候应该使用哪个contain属性,这主要是因为 只有在指定了适当的值后,浏览器才开始优化。我们可以使用这些值为验证最有效的方法,也可以使用content-visibility来自动应用所用的内容量。content-visibility可确保你以开发人员最小的成本来获得浏览器最大的性能提升。

从规范中我们可得知,CSS的content-visibility属性可以接受多个值,但是 auto 是可立即提高性能的属性。一个具有content-visibility:auto属性的元素可以获得布局(Layout)、**样式(Style)渲染(Paint)**的限制区域。如果该元素不在屏幕上(并且与用户无关,则相关元素将是在其子树中具有焦点或已选择的元素),它也会获得大小限制(并且停止绘制和对其内容时行命中测试)。

简单地说,如果元素不在屏幕上,则不会渲染其后代。浏览器在不考虑元素任何内容的情况下确定元素的大小,在此处则跳过大多数渲染(例如元素子树的样式和布局)。

当元素接近视窗(Viewport)时,浏览器不再增加大小限制,而是开始绘制并命中测试元素的内容。这使得渲染工作能够及时被用户看到。

上面视频对应的Demo是@Vladimir Levin在Codepen上提供的Demo:

在此示例中,我们将旅行博客的基线设置在右侧,并将content-visibility:auto应用于左侧的分块区域。 结果显示,在初始页面加载时,渲染时间从232ms变为30ms

一般旅游博客都会包含一些图片和一些描述性的文字故事。这是典型浏览器导航到旅行博客时发生的情况:

  • 步骤1:页面的部分内容以及任何所需的资源都从网络下载
  • 步骤2:浏览器的样式和布局页面的所有内容,而无需考虑该内容是否对用户可见
  • 步骤3:浏览器返回到步骤1,直到下载了所有页面和资源

在步骤2中,浏览器处理所有内容以查找可能已更改的内容。 它会更新任何新元素的样式和布局,以及由于新更新而可能发生移动的元素。 这是渲染工作。 这需要时间。

上图来自于@Una Kravets在Codepen上写的一个Demo

现在考虑一下,如果将content-visibility: auto设置在博客上每个单独的故事上会发生什么呢?一般是相同的循环:浏览器下载并呈现大块的内容。但是,不同之处则是步骤2的工作量。

借助content-visibility,他将设置样式和布局用户当前可见的所有内容(他们在屏幕可视区域内)。但是,当处理完全不在屏幕上的内容使,浏览器将跳过渲染工作,仅样式化和布局元素框本身。

加载页面的性能好像它只包含完整的屏幕上的内容以及每个非屏幕上的内容的空白框。这样的效果看起来要好的多,其可以将加载的渲染成本降低50%或更多。在我们的示例中,我们看到渲染的时间从232ms提升到了30ms,性能提升了7倍。

为了获得这些好处,您需要做什么工作? 首先,我们将内容分成几部分:

然后,我们将以下样式规则应用于这些部分:

.story {
    content-visibility: auto;
    contain-intrinsic-size: 1000px; 
}

注意:随着内容移入和移出可见性,它将根据需要开始和停止绘制。 但是,这并不意味着浏览器将不得不一次又一次地渲染和重新渲染相同的内容,因为在可能的情况下会保存渲染工作。

隐藏内容设置content-visibility: hidden

如果想要利用缓存绘制状态的优点,使内容不显示在屏幕上而又不绘制它怎么办?使用content-visibility: hidden

content-visibility: hidden属性为您提供未渲染内容和缓存的渲染状态的所有相同好处,如content-visibility: auto 在屏幕外执行()。 但是,与auto不同,它不会自动开始在屏幕上渲染。

这给了您更多的控制权,使您可以隐藏元素的内容并稍后快速取消隐藏它们。

将其与其他隐藏元素内容的常见方式进行比较:

  • display: none:隐藏元素并破坏其渲染状态。 这意味着取消隐藏元素与渲染具有相同内容的新元素一样昂贵
  • visibility: hidden:隐藏元素并保持其渲染状态。 这并不能真正从文档中删除该元素,因为它(及其子树)仍占据页面上的几何空间,并且仍然可以单击。 它也可以在需要时随时更新渲染状态,即使隐藏也是如此

另一方面,content-visibility: hidden隐藏元素,同时保留其呈现状态,因此,如果需要进行任何更改,则仅在再次显示元素时才会发生更改(即content-visibility: hidden属性已移除)。

content-visibility: hidden的一些很好用例:实现高级虚拟滚动条和测量布局。

使用contain-intrinsic-size指定元素的自然大小

为了实现content-visibility的潜在好处,浏览器需要应用大小限制,以确保内容的呈现结果不会被任何方式影响元素的大小。 这意味着该元素将布局为好像是空的。 如果元素没有在常规块布局中指定的高度,则其高度为0

这可能不是理想的,因为滚动条的大小会发生变化,这取决于每个具有非零高度的内容。

值得庆幸的是,CSS提供了另一个属性contains-intrinsic-size,如果元素受大小限制影响,它可以有效地指定元素的自然大小。 在我们的示例中,我们将其设置为1000px,作为对这些部分的高度和宽度的估计。

这意味着它好像有一个“内在大小”尺寸的子项一样进行布局,从而确保未调整大小的div仍然占据空间。 contains-intrinsic-size用作占位符大小,而不是呈现的内容。

扩展阅读

小结

在这篇文章中,和大家一起初探了CSS容器模块中的containcontent-visibility两个属性。使用这两个属性可以让CSS告诉浏览器如何来对元素进行布局和渲染。简单地说,在某种程度或场景之下,可以让Web页面在布局和渲染的过程中可以尽可能的避免重排和重绘,同时减少相应的开锁。对于Web性能的优化有着强大的优势和帮助。