RenderingNG 深入探究:LayoutNG 块碎片化

Morten Stenshorne
Morten Stenshorne

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

要实现碎片化,内容需要位于碎片化上下文内。分片上下文最常通过多列容器(内容拆分为列)或打印(内容拆分为页面)建立。包含许多行的长段落可能需要拆分为多个 fragment,以便将首行放置在第一个 fragment 中,将其余行放置在后续 fragment 中。

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

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

LayoutNG 块碎片

LayoutNGBlockFragmentation 是 LayoutNG 的分片引擎的重写版本,最初在 Chrome 102 中提供。在数据结构方面,它将多个 NG 之前的数据结构替换为直接显示在 fragment 树中的 NG fragment

例如,我们现在支持 'break-before' 和 'break-after' CSS 属性的“avoid”值,这可让作者避免在标题后立即出现中断。当页面的最后内容是标题,而版块的内容从下一页开始时,这往往看起来很不正常。最好在标题之前换行。

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

Chrome 还支持碎片溢出,这样单体式内容(假定为坚不可破的)内容不会被分割成多个列,并且能够正确应用阴影和转换等绘制效果。

LayoutNG 中的块碎片化现已完成

Chrome 102 中提供的核心碎片(块容器,包括行布局、浮动和流出定位)。Chrome 103 中提供 Flex 和网格碎片,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 片段树!这相当复杂,因为两个树之间的映射并非易事。

虽然布局对象树结构与 DOM 树非常相似,但 fragment 树是布局的输出,而不是布局的输入。fragment 树除了会实际反映任何分片(包括内嵌分片(行片段)和块分片(列或页面片段))的效果之外,在包含块和以该 fragment 作为其包含块的 DOM 后代之间也有直接的父子关系。例如,在 fragment 树中,由绝对定位的元素生成的 fragment 是其包含的块 fragment 的直接子项,即使沿用的已定位后代及其包含块之间的祖先链中还有其他节点也是如此。

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

旧版分片引擎存在的问题

在早期的网络时代中设计的旧引擎实际上并没有碎片化的概念,即使当时从技术上也存在碎片化(为了支持打印)。分片支持只是固定在顶部(打印)或改造(多列)的功能。

在对可分割的内容进行布局时,旧版引擎会将所有内容布局为一个高长的条形,其宽度为列或页面的内嵌大小,高度为容纳其内容所需的高度。这个高长的条形没有呈现在网页上,可将其看作是呈现到一个虚拟网页,而该虚拟网页经过重新排列,以便最终显示。它在概念上类似于将整篇报纸上的文章打印成一列,然后在第二步中使用剪刀将其剪切成多行。(以前,有些报纸实际上采用了类似的技术!)

旧版引擎会跟踪该栏中虚构的页面或列边界。这样一来,它就可以将超出边界的内容微移到下一页或一列中。例如,如果只有行的上半部分适合引擎认为的当前页面大小,则它会插入“分页支线”,将其向下推到引擎假定下一页顶部的位置。然后,大部分实际拆分工作(“使用剪刀切割和放置”)在布局期间(通过将页面剪切并剪切到高度部分)在布局之后进行。这使得一些基本无法实现,例如在碎片化后应用转换和相对定位(这是规范要求的)。此外,虽然旧版引擎在一定程度上支持表分片,但根本不支持 flex 或网格分片。

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

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

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

下面是一个使用文本阴影的示例:

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

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

您看到第一列中线条的文本阴影是如何被裁剪的,而不是放置在第二列的顶部吗?这是因为旧版布局引擎不理解碎片化。

它看起来应该如下所示:

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

接下来,我们使用转换和 box-shadow 让它变得更复杂。请注意旧版引擎中的裁剪和列出血错误。这是因为,按照规范,转换应该作为布局后、fragment 化处理后的效果来应用。使用 LayoutNG 时,碎片化问题可以正常运行。这会增加与 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 的当前块偏移量。这表示要在哪里中断。

当涉及块碎片化时,后代的布局必须在中断处停止。中断的原因包括页面或列中的空间不足,或强制中断。然后,我们会为访问的节点生成片段,并一直返回到片段上下文根(multicol 容器,如果是打印,则是文档根)。然后,在碎片化上下文根部,为新的 fragmentainer 做准备,并再次下降到树中,从断点之前停下的地方继续。

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

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

规范中有一些规则规定了最佳的非强制广告插播时间点,只在空间不足的地方插入广告插播时间点并不总是正确的做法。例如,break-before 等各种 CSS 属性会影响广告插播位置的选择。

在布局期间,为了正确实现非强制中断规范部分,我们需要跟踪可能较为合适的断点。此记录意味着,如果我们在违反中断避免请求的时间点(例如,break-before:avoidorphans:7)耗尽了空间,我们可以返回并使用找到的最后一个最佳断点。每个可能的断点都会获得一个得分,范围从“只作为最后的手段执行”到“理想的中断位置”,中间有一些值。如果某个休息位置的得分为“完美”,则意味着如果我们违反该位置,则不会违反任何违反规则(如果我们恰好在空间用尽时获得该分数,就没有必要回头查看更合适的内容)。如果得分是“最后的补救措施”,那么断点就算是无效断点,但如果我们没有找到更好的结果,我们仍可能会在该处中断,以避免碎片化问题溢出。

有效断点通常仅出现在同级(行框或块)之间,而不会出现在父项与第一个子项之间(C 类断点除外,但此处无需讨论这些情况)。例如,在同级块的 break-before:avoid 之前有一个有效的断点,但它介于“perfect”和“last-resort”之间。

在布局过程中,我们会跟踪目前在名为 NGEarlyBreak 的结构中找到的最佳断点。提前中断可能是块节点之前或内部,或行(块容器行或 Flex 行)之前或之后的断点。我们可能会形成 NGEarlyBreak 对象链或路径,以防最佳断点位于我们早些时候用尽空间时经过的深层内部。示例如下:

在本例中,我们会在 #second 之前用尽空间,但它具有“break-before:avoid”,这会得到“violating break exit”的广告插播位置得分。此时,我们的 NGEarlyBreak 链为“inside #outer >内部 #middle >内部 #inner > “line 3”',并且包含“perfect”,所以我们不愿意换到这个链。因此,我们需要从 #outer 的开头返回并重新运行布局(这次要传递我们发现的 NGEarlyBreak),以便可以在 #inner 的“line 3”之前中断。(我们在“line 3”之前中断,以便剩余 4 行最终在下一个 fragmentainer 中结束,并遵循 widows:4。)

该算法设计为始终在最佳断点(如规范中所定义)中断,即按照正确的顺序丢弃规则,如果无法满足所有规则。请注意,每个片段流程最多只能重新布局一次。在我们进行第二次布局遍历时,最佳广告插播位置已经传递给布局算法,这是在第一次布局遍历中发现的广告插播位置,并作为该轮布局输出的一部分提供。在第二次布局遍历中,我们是在空间用尽之前进行布局的。事实上,我们不应该耗尽空间(这实际上会出错),因为我们提供了一个非常好用(尽管可用,也很甜)的位置用于插入早间休息时间,以避免不必要地违反任何违反规则。所以我们简单布局到这个位置,然后停下来。

就这一点而言,有时我们确实需要违反一些避免中断的请求,前提是这样做有助于避免 fragmentainer 溢出。例如:

在本例中,#second 前面空间不足,但它包含“break-before:avoid”。正如最后一个示例一样,这可以解释为“避免违反规定的广告插播时间点”。我们还有一个 NGEarlyBreak 显示“violating orphans and widows”(在 #first 内 > “line 2”之前),它仍然不是完美的,但比“violating break exit”要好。因此,我们会在“第 2 行”之前中断,从而违反孤儿 / 丧偶请求。该规范在 4.4. 非强制断点:定义在没有足够的断点来避免碎片化溢出时,系统会先忽略哪些违反规则。

总结

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

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

致谢