RenderingNG 深入探究:LayoutNG 块碎片化

Morten Stenshorne
Morten Stenshorne

块碎片化是指当 CSS 块级框(例如分节或段落)无法整体放入一个 fragment 容器(称为 fragmentainer)时,将其拆分为多个 fragment。fragmentainer 不是元素,而是表示多列布局中的列或分页媒体中的页面。

如需发生碎片化,内容需要位于碎片化情境内。碎片化情境最常由多列容器(内容拆分为列)或打印时(内容拆分为页面)建立。包含多行内容的长段落可能需要拆分为多个 fragment,以便将前几行放置在第一个 fragment 中,将其余行放置在后续 fragment 中。

一段文本分成两列。
在此示例中,段落已使用多列布局拆分为两列。每列都是一个 fragmentainer,代表碎片化的流的一个 fragment。

块碎片与另一种众所周知的碎片类似:行碎片,也称为“换行”。任何由多个字词组成的内嵌元素(任何文本节点、任何 <a> 元素等)以及允许换行的内嵌元素都可能会拆分为多个 fragment。每个 fragment 都会放入不同的行框中。行框是内嵌碎片,相当于列和页面的容器

LayoutNG 块碎片化

LayoutNGBlockFragmentation 是对 LayoutNG 碎片化引擎的重写,最初在 Chrome 102 中发布。在数据结构方面,它将多个 NG 之前的数据结构替换为直接显示在 fragment 树中的 NG fragment

例如,我们现在支持为“break-before”和“break-after”CSS 属性使用 “avoid”值,这让作者可以避免在标题后面立即换行。如果网页上的最后一项是标题,而该部分的内容则从下一页开始,这通常会看起来很奇怪。最好在标题换行。

标题对齐示例。
图 1. 第一个示例在页面底部显示标题,第二个示例在下一页顶部显示标题及其关联内容。

Chrome 还支持分块溢出,因此不会将单体(应不可分割)内容切片成多个列,并且会正确应用阴影和转换等绘制效果。

LayoutNG 中的块碎片化现已完成

核心碎片化(块容器,包括行布局、浮动和超出流布局)已在 Chrome 102 中发布。Flex 和网格分片已在 Chrome 103 中发布,表格分片已在 Chrome 106 中发布。最后,Chrome 108 中提供了打印功能。块碎片化是最后一个依赖于旧版引擎来执行布局的功能。

从 Chrome 108 开始,旧版引擎不再用于执行布局。

此外,LayoutNG 数据结构支持绘制和点击测试,但我们确实依赖于一些旧版数据结构来处理读取布局信息的 JavaScript API,例如 offsetLeftoffsetTop

通过使用 NG 进行布局布局,可以实现和发布只有 LayoutNG 实现(而没有旧版引擎对应项)的新功能,例如 CSS 容器查询、锚点定位、MathML自定义布局 (Houdini)。对于容器查询,我们提前发布了该功能,并向开发者发出警告,告知他们该功能尚不支持打印。

我们在 2019 年推出了 LayoutNG 的第一部分,其中包括常规块容器布局、内嵌布局、浮动和流外定位,但不支持 flex、grid 或 Tables,并且完全不支持块分片。我们将回退到使用旧版布局引擎来处理 flex、网格、表格以及任何涉及块碎片化的内容。即使对于分块内容中的块状、内嵌、浮动和超出流式传输范围的元素也是如此。正如您所见,原地升级如此复杂的布局引擎是一项非常精细的工作。

此外,到 2019 年年中,LayoutNG 分块布局的大部分核心功能已实现(通过标志控制)。那么,为什么发货需要这么长时间?简而言之:碎片化必须与系统的各种旧版部分正确共存,在所有依赖项都升级之前,这些旧版部分无法移除或升级。

旧版引擎互动

旧版数据结构仍负责读取布局信息的 JavaScript API,因此我们需要以旧版引擎能够理解的方式将数据回写到旧版引擎。这包括正确更新旧版多列数据结构(例如 LayoutMultiColumnFlowThread)。

旧版引擎回退检测和处理

如果内部有尚未由 LayoutNG 分块处理的内容,我们就必须回退到旧版布局引擎。在发布核心 LayoutNG 块分块时,其中包括 flex、网格、表格以及任何要输出的内容。这尤其棘手,因为我们需要先检测是否需要使用旧版回退,然后才能在布局树中创建对象。例如,我们需要先进行检测,然后才能知道是否存在多列容器祖先,以及哪些 DOM 节点会成为格式设置上下文。这是一个先有鸡蛋的问题,没有完美的解决方案,但只要其唯一的不良行为是误报(在实际上不需要时回退到旧版),就可以,因为这种布局行为中的任何 bug 都是 Chromium 已经存在的 bug,而不是新 bug。

预绘制树木漫步

预绘制是在布局之后、绘制之前执行的操作。主要挑战在于,我们仍然需要遍历布局对象树,但现在我们有 NG fragment,那么我们该如何处理?我们同时遍历布局对象和 NG fragment 树!这很复杂,因为两个树之间的映射并非易事。

虽然布局对象树的结构与 DOM 树的结构非常相似,但 fragment 树是布局的输出,而不是布局的输入。除了实际反映任何碎片化(包括内嵌碎片化 [行碎片] 和块碎片化 [列或页面碎片])的影响之外,fragment 树还在包含块与将该 fragment 用作包含块的 DOM 后代之间建立了直接的父子关系。例如,在 fragment 树中,由绝对定位元素生成的 fragment 是其包含的块 fragment 的直接子项,即使在非流式定位的子孙和其包含的块之间存在祖先链中的其他节点也是如此。

如果分块中存在采用外边距定位的元素,情况可能会更加复杂,因为这时外边距 fragment 会成为 fragmentainer 的直接子元素(而不是 CSS 认为的包含块的子元素)。这是一个必须解决的问题,才能与旧版引擎共存。未来,我们应该能够简化此代码,因为 LayoutNG 旨在灵活支持所有现代布局模式。

旧版碎片化引擎存在的问题

旧版引擎是在 Web 早期设计的,实际上并不具备碎片化这一概念,即使当时在技术上也存在碎片化(为了支持打印)。碎片化支持只是附加在顶部(打印)或后期改进(多列)的功能。

在排列可分屏内容时,旧版引擎会将所有内容排列到一个较高的条状区域中,该区域的宽度为列或页面的内嵌大小,高度则为容纳其内容所需的高度。此高条状标签不会呈现在页面上,您可以将其视为呈现在虚拟页面上,然后重新排列以进行最终显示。从概念上讲,这类似于将一篇纸质报纸文章全部打印到一列中,然后再用剪刀将其剪成多列。(以前,一些报纸实际上就使用了类似的技术!)

旧版引擎会跟踪条状标签中的虚构页面或列边界。这样一来,系统便可将超出边界的内容推送到下一页或下一列。例如,如果只有某行的一半内容可放置在引擎认为是当前页面的位置,则引擎会插入“分页支撑”,将其向下推到引擎假定为下一页顶部的那个位置。然后,大部分实际的碎片化工作(“用剪刀剪裁和放置”)会在布局之后的预绘制和绘制期间进行,具体方法是将较高的条状内容切割成页面或列(通过剪裁和平移部分)。这使得一些操作基本上无法执行,例如在分块之后应用转换和相对定位(这是规范的要求)。此外,虽然旧版引擎对表碎片化提供了一些支持,但完全不支持 Flex 或网格碎片化。

下图展示了使用剪刀、放置位置和粘合剂之前在旧版引擎内部表示三列布局的方式(我们指定了高度,因此只有四条线适合显示,但底部有一些多余的空间):

内部表示形式为一列,采用分页结构显示内容中断的地方,屏幕表示为三列

由于旧版布局引擎在布局期间实际上不会对内容进行分块,因此会出现许多奇怪的伪影,例如相对定位和转换应用不正确,以及阴影在列边缘被剪裁。

下面是一个使用 text-shadow 的示例:

旧版引擎无法很好地处理这种情况:

第二列中放置了经过裁剪的文本阴影。

您是否注意到,第一列中线条的 text-shadow 是如何被剪裁的,并改为放置在第二列的顶部?这是因为旧版布局引擎不支持 fragment。

它看起来应该如下所示:

两列文本,阴影正确显示。

接下来,我们使用转换和 box-shadow 让它稍微复杂一点。请注意,在旧版引擎中,剪裁和列溢出有误。这是因为根据规范,转换应作为布局后、分屏后效果应用。使用 LayoutNG 碎片化时,这两种方法都能正常运行。这提高了与 Firefox 的互操作性,Firefox 在很长一段时间内都提供了良好的分片支持,并且此领域的大多数测试在 Firefox 中也能通过。

框会错误地拆分到两列中。

旧版引擎在处理较高的单体内容时也存在问题。如果内容不符合拆分为多个 fragment 的条件,则属于单体内容。具有溢出滚动的元素是单一的,因为在非矩形区域中滚动对用户来说毫无意义。线条框和图片也是单一内容的其他示例。示例如下:

如果单体式内容太高而无法放入列中,旧版引擎会粗暴地对其进行切片(这会导致在尝试滚动可滚动容器时出现非常“有趣”的行为):

而不是让其溢出第一列(如 LayoutNG 块碎片化所示):

ALT_TEXT_HERE

旧版引擎支持强制休息。例如,<div style="break-before:page;"> 会在 DIV 前插入分页符。不过,它仅支持有限地查找最佳非强制分页符。它确实支持 break-inside:avoid 以及孤儿和丧偶,但不支持避免块之间的中断(例如当通过 break-before:avoid 提出请求时)。请参考下面的示例:

文本分为两列。

在这里,#multicol 元素的每列有 5 行空间(因为其高度为 100 像素,行高为 20 像素),因此所有 #firstchild 都可以放在第一列中。不过,其同级兄弟 #secondchild 具有 break-before:avoid,这意味着内容希望在它们之间不出现换行。由于 widows 的值为 2,因此我们需要将 2 行 #firstchild 推送到第二列,以满足所有避免换行请求。Chromium 是第一个完全支持这种功能组合的浏览器引擎。

NG 碎片化的工作原理

NG 布局引擎通常通过深度优先遍历 CSS 盒子树来布局文档。当某个节点的所有后代都布局完毕后,可以通过生成 NGPhysicalFragment 并返回到父布局算法来完成该节点的布局。该算法会将 fragment 添加到其子 fragment 列表中,并在所有子 fragment 完成后,为自身生成一个包含所有子 fragment 的 fragment。通过此方法,它会为整个文档创建一个 fragment 树。不过,这过于简单化了:例如,布局外定位的元素必须从 DOM 树中的位置向上冒泡到其容器块,然后才能进行布局。为简单起见,我将忽略此高级细节。

LayoutNG 与 CSS 框本身一起为布局算法提供约束空间。这会为算法提供布局可用空间、是否建立了新的格式上下文以及上一个内容的中间边距收缩结果等信息。约束空间还知道 fragmentainer 的布局块大小,以及其中当前的块偏移量。这表示换行的位置。

当涉及块碎片化时,后代的布局必须在中断处停止。中断的原因包括页面或列中的空间不足,或强制中断。然后,我们为所访问的节点生成 fragment,并一直返回到 fragment 上下文根(多列容器,或者在打印时为文档根)。然后,在分块上下文根目录中,我们为新的 fragmentainer 做好准备,然后再次向下进入树,从中断前停下的地方继续。

用于提供在休息后恢复布局的方法的关键数据结构称为 NGBlockBreakToken。其中包含在下一个 fragmentainer 中正确恢复布局所需的所有信息。NGBlockBreakToken 与节点相关联,并形成 NGBlockBreakToken 树,以便表示需要恢复的每个节点。NGBlockBreakToken 会附加到为内部断开的节点生成的 NGPhysicalBoxFragment。这些 break 令牌会传播到父级,形成一个 break 令牌树。如果我们需要在节点前面(而不是在节点内)进行换行,则不会生成任何 fragment,但父节点仍需要为该节点创建“break-before”换行令牌,以便我们在下一个 fragmentainer 的节点树中到达相同位置时开始进行布局。

当我们用完碎片化空间(非强制中断)或请求强制中断时,系统会插入中断。

规范中有一些规则,用于确定最佳的非强制性换行位置,而仅仅在空间不足时插入换行并不总是正确的做法。例如,有各种 CSS 属性(如 break-before)会影响换行位置的选择。

在布局期间,为了正确实现非强制性换行规范部分,我们需要跟踪可能适合的断点。如果在违反了避免断点请求(例如 break-before:avoidorphans:7)的位置耗尽了空间,此记录意味着我们可以返回并使用上次找到的最佳断点。每个可能的断点都会获得一个分数,分数范围从“仅在万不得已时才这样做”到“理想的断点位置”之间,中间还有一些值。如果某个换行位置的得分为“完美”,则表示在该位置换行不会违反任何换行规则(如果我们在空间不足时获得了此得分,则无需回头寻找更好的位置)。如果得分为“last-resort”,则断点甚至不是有效断点,但如果我们找不到更好的断点,则可能仍会在该位置断点,以避免 fragmentainer 溢出。

有效断点通常只出现在同级兄弟元素(线条盒或块)之间,而不是在父元素与其第一个子元素之间(C 类断点除外,但我们无需在此处讨论这些断点)。例如,在具有 break-before:avoid 的块同级兄弟之前有效的断点,但它介于“理想”和“最后手段”之间。

在布局期间,我们会在一个名为 NGEarlyBreak 的结构中跟踪到目前为止找到的最佳断点。提前中断是指在块节点之前或内部,或者在线条(块容器线条或 flex 线条)之前可能存在的断点。我们可能会形成 NGEarlyBreak 对象的链或路径,以防最佳断点位于我们在空间不足时之前经过的某个深层位置。示例如下:

在本例中,我们在 #second 前面就耗尽了空间,但它具有“break-before:avoid”,因此其换行位置得分为“违反了 break avoid”。此时,我们有一个 NGEarlyBreak 链,其中包含“#outer 内 > #middle 内 > #inner 内 > 在第 3 行之前”和“perfect”,因此我们更愿意在该位置中断。因此,我们需要返回并从 #outer 开头重新运行布局(这次传递我们找到的 NGEarlyBreak),以便在 #inner 中的“第 3 行”之前中断。(我们在“line 3”之前中断,以便剩余 4 行最终在下一个 fragmentainer 中结束,并遵循 widows:4。)

该算法设计为始终在可能的最佳断点处(如规范中所定义)中断,方法是按照正确的顺序丢弃规则,如果无法满足所有规则。请注意,每个分块流程最多只需重新布局一次。在我们进行第二次布局遍历时,最佳广告插播位置已经传递给布局算法,这是在第一次布局遍历中发现的广告插播位置,并作为该轮布局输出的一部分提供。在第二次布局传递中,我们会一直布局,直到空间耗尽为止。事实上,我们预计不会耗尽空间(这实际上会导致错误),因为我们提供了一个非常合适(至少是可用的最合适)的位置来插入提前换行,以避免不必要地违反任何换行规则。因此,我们只需布局到该点,然后中断即可。

因此,如果违反某些避免中断请求有助于避免 fragmentainer 溢出,我们有时确实需要这样做。例如:

在本例中,#second 前面空间不足,但它包含“break-before:avoid”。这会被翻译为“违反 break 避免”,就像上一个示例一样。我们还有一个 NGEarlyBreak,其中存在“违反孤行和寡行”问题(#first 内 > 在“第 2 行”之前),虽然仍不完美,但比“违反 break 避免”要好。因此,我们将在“第 2 行”前换行,违反了孤行 / 寡行要求。规范在 4.4 中对此进行了处理。非强制性断点,其中定义了如果没有足够的断点来避免 fragmentainer 溢出,系统会首先忽略哪些断点规则。

总结

LayoutNG 块碎片化项目的功能目标是,提供对旧版引擎支持的所有内容的 LayoutNG 架构支持实现,除了 bug 修复之外,尽可能少实现其他内容。主要例外情况是更好的避免中断支持(例如 break-before:avoid),因为这是碎片化引擎的核心部分,因此必须从一开始就包含该支持,因为日后再添加该支持意味着需要进行另一次重写。

现在,LayoutNG 分块碎片化已完成,我们可以开始添加新功能,例如支持打印时混合页面大小、打印时 @page 边距框、box-decoration-break:clone 等。与 LayoutNG 一样,我们预计新系统的 bug 率和维护负担将随着时间的推移而大幅降低。

致谢