RenderingNG 深入探究:LayoutNG 块碎片化

LayoutNG 中的块碎片化现已完成。请阅读本文,了解其运作方式及其重要性。

Morten Stenshorne
Morten Stenshorne

我叫 Morten Santshorne,是 Google Blink 渲染团队的布局工程师。我从 21 世纪初开始从事浏览器引擎开发工作,工作时得到了很多乐趣,例如帮助在 Presto 引擎(Opera 12 及更低版本)中通过 acid2 测试,以及对其他浏览器进行逆向工程,以修复 Presto 中的表格布局。在这些年里,我所花的时间也远远超出我承认的块碎片化水平,尤其是在 Presto、WebKit 和 Blink 中的 multicol。在过去的几年里,在 Google 工作,我主要负责主导为 LayoutNG 添加块碎片化支持方面的工作。与我一起深入了解区块碎片化实现,因为这可能是我最后一次实现区块碎片化。:)

什么是块碎片化?

块碎片化是指当 CSS 块级框(例如某个部分或段落)无法整体放入一个称为 fragmentainer 的 fragment 容器内时,将此类框拆分为多个 fragment。fragmentainer 不是元素,而是表示多列布局中的列或分页媒体中的页面。为了实现碎片化,内容需要位于碎片化上下文中。碎片化上下文通常通过多列容器(内容将拆分为列)或打印(内容将拆分为页面)来建立。包含许多行的长段落可能需要拆分为多个片段,以便将首行置于第一个片段中,而将其余行置于后续片段中。

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

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

什么是 LayoutNG 块碎片化?

LayoutNGBlockFragmentation 是对 LayoutNG 碎片化引擎的重写。经过多年的工作,今年早些时候,第一部分终于在 Chrome 102 中推出。这修复了长期存在的问题,这些问题在我们的“旧版”引擎中基本无法解决。在数据结构方面,它将多个预先 NG 数据结构替换为直接显示在 Fragment 树中的 NG 片段

例如,我们现在支持“break-before”和“break-after”CSS 属性中的“avoid”值,这让作者可以避免在标题之后立即中断。通常,如果网页上最后一项内容是页眉,而此部分的内容从下一页开始,效果通常不太理想。最好在标题之前换行。请参阅下图中的示例。

第一个示例在页面底部显示标题,第二个示例显示标题及其相关内容的后续页面。

Chrome 102 还支持碎片化溢出,这样系统就不会将整体(应该是不间断的)内容分割为多列,从而能够正确应用阴影和转换等绘制效果。

LayoutNG 中的块碎片化现已完成

在撰写本文时,我们已经在 LayoutNG 中实现了全面的块碎片化支持。Chrome 102 中提供的核心碎片化功能(块容器,包括行布局、浮点数和流出定位)。Chrome 103 中提供了弹性和网格碎片化功能,Chrome 106 中提供了表碎片化功能。最后,Chrome 108 推出了打印功能。块碎片化是最后一项依赖于旧版引擎来执行布局的功能。这意味着,从 Chrome 108 开始,系统将不再使用旧版引擎来执行布局。

除了实际布置内容之外,LayoutNG 数据结构支持绘制和点击测试,但我们仍然依赖一些旧版数据结构来实现读取布局信息的 JavaScript API,例如 offsetLeftoffsetTop

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

我们在 2019 年发布了 LayoutNG 的第一部分,其中包括常规块容器布局、内嵌布局、浮点数和流出定位,但不支持 flex、网格或表,也不支持块碎片化。我们会回退为使用旧版布局引擎来处理 Flex、网格、表以及任何涉及块碎片化的内容。即使是对于碎片化内容中的块状、内联、浮动和流外元素也是如此 - 如您所见,就地升级这种复杂的布局引擎是一项非常微妙的舞蹈。

此外,无论您是否相信,到 2019 年年中,LayoutNG 块碎片化布局的大部分核心功能已经实现(在标记后面)。请问,为什么需要这么长的时间才能发货?简而言之:碎片化必须与系统的各个旧版部分正确共存,这些旧版部分无法移除或升级,除非所有依赖项都升级。如需了解详细答案,请参阅以下详细信息。

旧版引擎互动

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

旧版引擎回退检测和处理

当其中包含 LayoutNG 块碎片化无法处理的内容时,我们不得不回退到旧版布局引擎。在核心 LayoutNG 块碎片化(2022 年春季)发布时,包括 Flex、网格、表格以及输出的任何内容。这尤其困难,因为我们需要在布局树中创建对象之前检测对旧版回退的需求。例如,我们需要在确定是否存在多列容器祖先实体以及哪些 DOM 节点会成为格式设置上下文之前,先进行检测。这是一个既有鸡又有蛋的问题,并没有完美的解决方案,但只要它的唯一不当行为是误报(在实际上不需要时回退到旧版),也是可以的,因为该布局行为中的所有错误都是 Chromium 已有的错误,而不是新错误。

涂漆前的木头漫步

预绘制是我们在布局之后,但在绘制之前执行的一项操作。主要挑战是,我们仍然需要浏览布局对象树,但是现在有 NG fragment,那我们如何处理它呢?我们会同时走动布局对象和 NG fragment 树!这非常复杂,因为这两个树之间的映射并不简单。虽然布局对象树的结构与 DOM 树非常相似,但 fragment 树是布局的输出,而不是布局的输入。除了实际反映任何碎片(包括内嵌片段(行片段)和块片段(列片段或页面片段))的效果之外,片段树在包含块和将该片段作为其包含块的 DOM 后代之间还存在直接的父子关系。例如,在 fragment 树中,由绝对定位的元素生成的 fragment 是其所在块 fragment 的直接子级,即使在流出的定位后代及其所在块之间的祖先链中存在其他节点也是如此。

如果碎片化中存在流出的定位元素,情况会变得更加复杂,因为这样会使流外 Fragment 成为 fragmentainer 的直接子级(而不是 CSS 认为是包含块的子级)。遗憾的是,这个问题必须得到解决,这样才能确保与旧版引擎共存,而不会遇到过多麻烦。将来,我们应该能够简化大量此类代码,因为 LayoutNG 旨在灵活支持所有现代布局模式。

旧版碎片化引擎的问题

设计于早期网络时代的旧版引擎实际上并不包含碎片化的概念,即使从技术层面上说,这种机制也存在着(为了支持打印)。碎片化支持只是固定在顶部(输出)或改造(多列)的功能。

在布置可碎片内容时,旧版引擎会将所有内容布局到一个高大的条带中,该长条的宽度为列或页面的内嵌大小,高度与包含内容所需的高度一样高。这个高大的条形不会呈现在网页上,而是视为呈现到虚拟网页,然后重新排列以最终显示。从概念上来讲,这类似于将整篇报纸上的报道打印成一列,然后在第二步中使用剪刀将其切成多行。(过去,一些报纸实际上使用了类似的技术!)

旧版引擎会跟踪栏中的虚构网页或列边界。这样,系统可以将超出边界范围的内容微移至下一页或下一列。例如,如果只有一条线的上半部分适合引擎认为为当前页面的内容,它会插入一个“分页结构体”将其下推到引擎认为下一页的顶部所在的位置。然后,大多数实际的碎片化工作(即“用剪刀和展示位置进行切割”)是在预绘制和绘制时,通过将内容分割成布局和将页面分割成布局和高列,从而完成布局的。这使得一些事情本质上不可能,例如在碎片化之后应用转换和相对定位(这是规范的要求)。此外,虽然旧版引擎在一定程度上支持表碎片化,但根本不支持弹性或网格碎片化。

下图展示了在使用剪刀、放置位置和胶水之前,旧版引擎内部是如何表示三列的布局的(我们指定了高度,因此只能容纳四行,但底部有一些多余的空间):

内部表示为一列,其中内容为分页符,显示内容为三列。

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

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

旧版引擎无法很好地处理此问题:

放置到第二列中的裁剪文本阴影。

您是否看到第一列的行的文本阴影是如何裁剪的,而不是放置在第二列的顶部?这是因为旧版布局引擎无法理解碎片!

显示的内容应如下所示(以下是使用 NG 显示的内容):

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

接下来,我们使用变形和框阴影让它变得更复杂。请注意,在旧版引擎中,剪裁错误且列出血。这是因为按照规范,转换应该作为布局后、碎片后的效果应用。使用 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 Box 树深度。布局完一个节点的所有后代后,可以通过生成 NGPhysicalFragment 并返回父布局算法来完成该节点的布局。该算法会将该 fragment 添加到其子 fragment 列表中,并在所有子 fragment 完成后,为自身生成一个 fragment,其中包含其所有子 fragment。通过此方法,它会为整个文档创建一个 fragment 树。不过,这是一种过度简化:例如,流外定位元素必须先从它们在 DOM 树中的位置冒出到其所属的代码块,然后才能进行布局。为简单起见,我忽略了这些高级细节。

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

涉及块碎片化时,后代的布局必须在休息时间停止。中断的原因包括页面或列中空间不足,或强制中断。然后,我们为所访问的节点生成片段,一直返回到碎片化上下文根(多列容器,如果是输出,则为文档根目录)。然后,在碎片化上下文根处,我们准备新的碎片化程序,并再次下降到树中,从中断前离开的位置继续。

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

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

规范中有一些关于最佳非强制中断的规则,直接在空间不足的位置插入广告插播时间点并不总是正确的做法。例如,有很多 CSS 属性(例如 break-before)会影响中断位置的选择。因此,在布局期间,为了正确实现非强制断点规范部分,我们需要跟踪可能良好的断点。此记录表示,如果我们在会违反断点避免请求的位置(例如,break-before:avoidorphans:7)空间不足,则可以返回并使用找到的最后一个最佳断点。每个可能的断点都会获得一个得分,范围从“仅作为万不得已时执行”到“最佳断点”之间,并且之间有一些值。如果广告插播位置得分为“完美”,意味着如果我们中断该位置,则不会违反任何规则(如果我们在空间不足时获得该得分,则不需要再寻找更好的方法)。如果得分是“最后的度假方式”,则该断点甚至都不是有效断点,但如果找不到更好的断点,我们可能仍会在该断点处中断,以避免 fragmentainer 溢出。

有效的断点通常只发生在同级(行框或代码块)之间,而不会出现在父项和其第一个子项之间(例如,C 类断点是一个例外情况,但我们在此无需讨论此类断点)。例如,在具有 block-before:avoid 的同级块之前,有一个有效的断点,但它介于“perfect”和“last-resort”之间。

在布局期间,我们会跟踪目前在名为 NGEarlyBreak 的结构中发现的最佳断点。提前断点是指块节点之前、内部或代码行(代码块容器行或灵活代码行)之前可能存在的断点。我们可以形成 NGEarlyBreak 对象链或路径,以防最佳断点位于我们在空间不足时我们先前走过的深处。示例如下:

在本例中,我们在 #second 前面用尽了空间,但它包含“break-before:avoid”,这会获取广告插播位置得分“violating break visible”。此时,我们在“第 3 行”前面有一个 NGEarlyBreak 链,即“#outer 内部 > 内部 #middle > #inner 内部 > 在“第 3 行”之前,因此更希望在该链上中断。因此,我们需要从 #outer 的开头返回并重新运行布局(这次传递的是我们发现的 NGEarlyBreak),以便在 #inner 中的“第 3 行”之前中断。(我们在“第 3 行”之前换行,以便其余 4 行代码最终出现在下一个 fragment 中,以便遵循 widows:4。)

该算法旨在始终在最佳断点(如spec中所定义)处中断,方法是以正确的顺序丢弃规则(如果不能满足所有规则)。请注意,每个分片流程最多只能重新布局一次。到我们进行第二次布局传递时,最佳广告插播位置已传递给布局算法,这是在第一次布局传递中发现的插播位置,并作为该轮布局输出的一部分提供。在第二次布局遍历中,在空间耗尽之前,我们不会进行布局。事实上,我们不会耗尽空间(实际上这属于错误),因为我们已经提供了一个非常合适的位置(当然也有一个特别实用的位置)来提前休息,以避免不必要地违反任何违规规则。我们只需对这一点进行布局,然后停住。

需要指出的是,有时我们确实需要违反一些中断避免请求,以便帮助避免碎片化溢出。例如:

在这里,我们在 #second 前面用尽了空间,但包含“break-before:avoid”。这个示例的意思是“violating break protection”,这个示例跟上一个示例一样。我们还有一个 NGEarlyBreak,其中包含“violating orphans and widows”(在 #first 内部 > 中的“line 2”之前),但这样的代码尚不完美,但比“violating break close”更好。因此,我们将在“第 2 行”之前换行,这样就违反了孤立人 / 丧偶请求。该规范在 4.4. Unforced Breaks,它定义了在没有足够的断点来避免 fragmentainer 溢出时首先忽略哪些违规规则。

摘要

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

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

感谢您阅读本邮件!

致谢