块碎片化是指当 CSS 块级框(例如分节或段落)无法整体放入一个 fragment 容器(称为 fragmentainer)中时,将其拆分为多个 fragment。fragmentainer 不是元素,而是表示多列布局中的列或分页媒体中的页面。
如需发生碎片化,内容需要位于碎片化情境内。碎片化情境最常由多列容器(内容拆分为列)或打印(内容拆分为页面)建立。包含多行且较长的段落可能需要拆分为多个 fragment,以便将前几行放置在第一个 fragment 中,其余行放置在后续 fragment 中。

块碎片与另一种众所周知的碎片类似:行碎片,也称为“换行”。任何由多个字词组成且允许换行(任何文本节点、任何 <a>
元素等)的内嵌元素都可能会拆分为多个 fragment。每个 fragment 都会放入不同的行框中。行框是内嵌碎片,相当于列和页面的容器。
LayoutNG 块碎片化
LayoutNGBlockFragmentation 是对 LayoutNG 碎片化引擎的重写,最初在 Chrome 102 中发布。在数据结构方面,它将多个 NG 之前的数据结构替换成了直接在fragment 树中表示的 NG fragment。
例如,我们现在支持为“break-before”和“break-after”CSS 属性使用 “avoid”值,这让作者可以避免在标题后面立即换行。如果网页上的最后一项是标题,而该部分的内容从下一页开始,通常会看起来很奇怪。最好在标题前换行。

Chrome 还支持分块溢出,因此不会将单体(应不可分割)内容切片成多个列,并且会正确应用阴影和转换等绘制效果。
LayoutNG 中的块碎片化现已完成
核心碎片化(块容器,包括行布局、浮动和超出流布局)已在 Chrome 102 中发布。Flex 和网格分片已在 Chrome 103 中发布,表格分片已在 Chrome 106 中发布。最后,Chrome 108 中提供了打印功能。块碎片化是依赖于旧版引擎来执行布局的最后一个功能。
从 Chrome 108 开始,旧版引擎不再用于执行布局。
此外,LayoutNG 数据结构支持绘制和点击测试,但我们确实依赖于一些旧版数据结构来处理读取布局信息的 JavaScript API,例如 offsetLeft
和 offsetTop
。
使用 NG 进行所有布局后,您就可以实现和发布仅有 LayoutNG 实现(而没有旧版引擎对应项)的新功能,例如 CSS 容器查询、锚点定位、MathML 和自定义布局 (Houdini)。对于容器查询,我们提前发布了该功能,并向开发者发出警告,告知他们该功能尚不支持打印。
我们于 2019 年发布了 LayoutNG 的第一部分,其中包括常规的块容器布局、内嵌布局、浮动和超出边界定位,但不支持 flex、网格或表格,也完全不支持块碎片化。我们将回退到使用旧版布局引擎来处理 flex、网格、表格以及任何涉及块碎片化的内容。即使对于分块内容中的块状、内嵌、浮动和超出流式元素也是如此。正如您所见,原地升级如此复杂的布局引擎是一项非常精细的工作。
此外,到 2019 年年中,LayoutNG 分块布局的大部分核心功能已实现(通过标志控制)。那么,为什么发货时间这么长?简而言之:碎片化必须与系统的各种旧版部分正确共存,在所有依赖项都升级之前,这些旧版部分无法移除或升级。
旧版引擎互动
旧版数据结构仍负责读取布局信息的 JavaScript API,因此我们需要以旧版引擎能够理解的方式将数据回写到旧版引擎。这包括正确更新旧版多列数据结构(例如 LayoutMultiColumnFlowThread)。
旧版引擎回退检测和处理
如果内部有内容尚未由 LayoutNG 分块处理,我们就必须回退到旧版布局引擎。在发布核心 LayoutNG 块分块时,其中包括 flex、网格、表格以及任何要输出的内容。这尤其棘手,因为我们需要先检测是否需要旧版回退,然后再创建布局树中的对象。例如,我们需要先进行检测,然后才能知道是否存在多列容器祖先,以及哪些 DOM 节点会成为格式设置上下文。这是一个鸡生蛋还是蛋生鸡的问题,没有完美的解决方案,但只要其唯一的错误行为是误报(在实际上没有需要时回退到旧版),就没问题,因为该布局行为中的任何 bug 都是 Chromium 已有的,而不是新 bug。
预绘制树木漫步
预绘制是在布局之后、绘制之前执行的操作。主要挑战在于,我们仍然需要遍历布局对象树,但现在我们有 NG fragment,那么我们该如何处理?我们同时遍历布局对象和 NG fragment 树!这很复杂,因为两个树之间的映射并非易事。
虽然布局对象树结构与 DOM 树结构非常相似,但 fragment 树是布局的输出,而不是布局的输入。除了实际反映任何碎片化(包括内嵌碎片化 [行 fragment] 和块碎片化 [列或页面 fragment])的影响之外,fragment 树在包含块与将该 fragment 用作包含块的 DOM 后代之间还存在直接的父子关系。例如,在 fragment 树中,由绝对定位元素生成的 fragment 是其包含的块 fragment 的直接子级,即使在非流式定位的后代与其包含的块之间存在祖先链中的其他节点也是如此。
如果分块中存在采用外边距定位的元素,情况可能会更加复杂,因为这时外边距 fragment 会成为 fragmentainer 的直接子元素(而不是 CSS 认为的包含块的子元素)。为了与旧版引擎共存,必须解决此问题。将来,我们应该能够简化此代码,因为 LayoutNG 旨在灵活支持所有现代布局模式。
旧版碎片化引擎存在的问题
旧版引擎是在 Web 早期设计的,实际上并不具备碎片化这一概念,即使在技术上当时也存在碎片化(为了支持打印)。碎片化支持只是附加在顶部(打印)或后期改进(多列)的功能。
在排列可分屏内容时,旧版引擎会将所有内容排列到一个较高的条状中,其宽度为列或页面的内嵌大小,高度则为容纳其内容所需的高度。此高条状标签不会呈现在网页上,您可以将其视为呈现在虚拟网页上,然后重新排列以进行最终显示。从概念上讲,这类似于将一篇纸质报纸文章全部打印到一列中,然后再用剪刀将其剪成多列。(以前,一些报纸实际上就使用了类似的技术!)
旧版引擎会跟踪条状标签中的虚构页面或列边界。这样一来,系统便可将无法超出边界的内容推送到下一页或下一列。例如,如果只有某行的一半可以放置在引擎认为是当前页面的位置,则引擎会插入“分页支撑”,将其向下推到引擎假定为下一页顶部的那个位置。然后,大部分实际的碎片化工作(“用剪刀剪裁和放置”)会在布局之后的预绘制和绘制期间进行,具体方法是将较高的条状内容切割成页面或列(通过剪裁和平移部分)。这使得一些操作基本上无法实现,例如在分块之后应用转换和相对定位(这是规范的要求)。此外,虽然旧版引擎对表碎片化提供了一些支持,但完全不支持 Flex 或网格碎片化。
下图展示了在使用剪刀、放置和粘合之前,三列布局在旧版引擎中的内部表示方式(我们指定了高度,因此只能放置四行,但底部会有一些多余的空间):

由于旧版布局引擎在布局期间实际上不会对内容进行分块,因此会出现许多奇怪的伪影,例如相对定位和转换应用不正确,以及边框阴影在列边缘被剪裁。
下面是一个使用 text-shadow 的示例:
旧版引擎无法很好地处理此问题:

您是否注意到,第一列中线条的 text-shadow 是如何被剪裁的,并改为放置在第二列的顶部?这是因为旧版布局引擎不支持 fragment。
它看起来应该如下所示:
接下来,我们使用 transform 和 box-shadow 来让它变得更复杂一些。请注意,在旧版引擎中,剪裁和列溢出有误。这是因为根据规范,转换应作为布局后、分屏后效果应用。使用 LayoutNG 碎片化时,这两种方法都能正常运行。这提高了与 Firefox 的互操作性,Firefox 在很长一段时间内都提供了良好的分片支持,并且此领域的大多数测试在 Firefox 中也能通过。
旧版引擎在处理较高的单体内容时也存在问题。如果内容不符合拆分为多个 fragment 的条件,则属于单体内容。具有溢出滚动的元素是单一元素,因为在非矩形区域中滚动对用户来说毫无意义。线条框和图片也是单一内容的其他示例。示例如下:
如果单体式内容太高而无法放入列中,旧版引擎会粗暴地对其进行切片(这会导致在尝试滚动可滚动容器时出现非常“有趣”的行为):
而不是让其溢出第一列(如 LayoutNG 块碎片化所示):
旧版引擎支持强制休息。例如,<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 树中的位置向上冒泡到其容器块,然后才能进行布局。为简单起见,我将忽略此高级细节。
除了 CSS 盒子本身之外,LayoutNG 还会向布局算法提供约束空间。这会为算法提供布局可用空间、是否建立了新的格式上下文以及上一个内容的中间边距收缩结果等信息。约束空间还知道 fragmentainer 的布局块大小,以及其中当前的块偏移量。这表示换行的位置。
涉及分块时,子项的布局必须在断点处停止。换行的原因包括网页或列中没有足够的空间,或强制换行。然后,我们为所访问的节点生成 fragment,并一直返回到 fragment 上下文根(多列容器,或者在打印时为文档根)。然后,在分块上下文根目录中,我们准备好新的 fragmentainer,并再次向下进入树,从中断前停下的地方继续。
用于提供在分屏后恢复布局的方法的关键数据结构称为 NGBlockBreakToken。它包含在下一个 fragmentainer 中正确恢复布局所需的所有信息。NGBlockBreakToken 与节点相关联,并形成 NGBlockBreakToken 树,以便表示需要恢复的每个节点。NGBlockBreakToken 会附加到为内部断开的节点生成的 NGPhysicalBoxFragment。这些 break 令牌会传播到父级,形成一个 break 令牌树。如果我们需要在节点前面(而不是在节点内)进行换行,则不会生成任何 fragment,但父节点仍需要为该节点创建“break-before”换行令牌,以便我们在下一个 fragmentainer 的节点树中到达相同位置时开始进行布局。
当 fragmentainer 空间用尽(非强制性分屏)或请求强制性分屏时,系统会插入分屏。
规范中有一些规则,用于确定最佳的非强制性换行位置,而仅仅在空间不足时插入换行并不总是正确的做法。例如,有各种 CSS 属性(如 break-before
)会影响换行位置的选择。
在布局期间,为了正确实现非强制性换行规范部分,我们需要跟踪可能适合的断点。如果在违反了避免断点请求(例如 break-before:avoid
或 orphans: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 行”之前进行中断。(我们在“第 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 率和维护负担将随着时间的推移而大幅降低。
致谢
- Una Kravets,感谢您提供精美的“手绘屏幕截图”。
- Chris Harrelson 提供校对、反馈和建议。
- Philip Jägenstedt 欢迎您提供反馈和建议。
- Rachel Andrew 负责编辑和第一个多列示例图。