HTML的文档流与布局

1 概述

HTML 是一种用于显示的标记语言,最初用于显示文字和图片,而后随着互联网的发展,HTML 加入更多的显示功能,并引入了 CSS 和 JavaScript 作为辅助,从而可以达到不弱于出版物的视觉效果。

就流程而言,现代浏览器普遍采用了解析、布局和渲染的三步方案:首先将HTML源文档解析为 DOM 树(同时 CSS 源文档也会被解析为样式表,JavaScript 会被执行);而后将 DOM 树和样式表揉合成为渲染树,通过先序遍历的方式,根据节点属性和样式表规则指定的配置,决定各个渲染节点的位置和大小;最后在由位置和大小确定的矩形框内,根据节点内容和属性配置,画出视觉效果。

本文将分析现代浏览器中共通的布局策略,从对早期HTML文档的布局方式开始,逐步增加功能,直至一个现代网页。在有关节点的类型问题上,本文使用CSS观点,即将某个具有特殊意义的节点(如 LI)当作具有某个默认 CSS 属性(如 display: list-item )的普通节点。

2 最初的流模型

2.1 盒子模型

HTML 出身于 CERN,作为一个物理研究所的成果,HTML 当时被用于在互联网上显示带有元信息的规整的链接、文字和图片。从结构上看,HTML 分为两部分:HEAD 部分用于提供如标题和摘要之类的元信息;BODY 部分则包含了格式化的链接、文本和图片,他们以标签的形式存在。

在这里,我们先不去讨论日后出现的分栏等机制,那么显然的就没有了各种容器标签,因此我们将此时的BODY中节点就简化为平铺,即不嵌套,每一个节点被看作一个盒子,他们将被按照某种规则放置于一个模子中。这个模子,也就是所谓的浏览器了。

与出版物不同,网页所用的这个模子是没有长宽限制的,在物理上虽然受限于浏览器窗口和显示器大小,但通过滚动条,可以获得一个无限大的显示空间。但是,无限大的显示空间于分析布局策略无益,而且人们制作网页的经验仍然源自于传统出版业,所以我们假定网页所用的模子是一个有限宽无限高的形式,且在完成一次布局之后,可以得到一个有限的高。

同样,为了方便起见,我们假定各个节点都有确定且有限的宽高,从而可以将其看作矩形刚体盒,进一步我们还假设其宽度小于模子宽度(随后会打破这一设定)。

在规定了上述限制之后,网页布局的策略便有如活字印刷般出现在眼前了,可以把节点在网页中的布局,想象为把活字放入底板,这也就是最基本的网页布局了。不难发现,这种简单的布局将为我们带来一个两难的局面,后一个盒子是被安排在前一个盒子的右侧还是下面?

2.2 元素分类

让我们选择“放在下面”,即每个盒子(也即节点)独占一行。这一选择的优势在于,对当时的内容——标题(分6级)、正文(其中的某些字是链接)和图片——来说,分行显示是可以接受的。当然,这个选择带来的后果就是链接也会被放置于独立的行,而不是位于前后文字之中。

这一方案显然是不好的,如果链接和文字看起来支离破碎,那么网页就失去了可读性。为了解决这个问题,我们将节点分为两类(display 属性):块元素(block)和内联元素(inline)。 块元素是一个不可被打破的刚体,在布局上独占一行,其左右都不能出现其它元素。这类元素包括标题节点(H1~H6)、段落节点(P)。

内联元素是不能独立占据一行的节点,即两个相邻(这里的相邻乃就 DOM 树而言)的内联元素是左右相连而不是上下相依。普通文字和链接是典型的内联元素,这样就使得链接融于前后文字了。

由于块元素的特点,相邻的块元素和内联元素不会在同一行出现,那么为了让相邻两个内联元素(如两行文字)分行显示,就需要在其之间插入一个高度为0的块元素,这就是换行节点(BR)。进一步,如果想让分行看起来更明显,可以插入一个宽度为100%的横线,这就是分行节点(HR)。

2.3 超宽节点

现在让我们来打破“节点的宽小于模子的宽”的设定,这就带来了超宽节点,其实即便不打破这一设定,多个内联节点并排起来,宽度也可能会超出模子。

对于超宽的块元素而言,我们不能对其本身做什么,而是让模子(也就是父节点,此时不说更待何时?)做出妥协。父节点有4种选择(overflow 属性):

  1. 鸵鸟策略,有多宽就渲染多少(auto)
  2. 隐藏宽出的部分,只显示父节点宽度内的内容(hidden)
  3. 先隐藏,然后提供滚动条(scroll)
  4. 按照父节点的父节点的策略(inherit)

默认情况下,选择 inherit 策略,根节点 HTML 选择 scroll 策略。 对于内联节点而言,当一行所剩的宽度不足以将其全部容纳下时,可以进行折行,折行即将内联节点的前部分内容在当前行显示,其余内容在下一行顶头开始,这里两行之间的距离是可以配置的,称为行高(line-height)。

综合上述策略,父节点下的各个子节点通过布局,分别得到了显示宽度(内联节点不会比父节点看起来更宽)、显示高度(内联节点会折成几行)、横坐标(内联节点可能从一行中段开始)和纵坐标,综合这些数据,父节点的高度和显示宽度(针对有超宽块节点的情况)也可以计算而来。

那么对于带有嵌套节点的实际网页而言,布局算法可以看作一个递归过程,先将当前节点看作有限宽无限高的模子,然后递归的布局其所有子节点,而后得到高度以供其父节点使用。当然这里还蕴含着一个问题,内联节点在完成布局之前,宽度是不确定的,那其子元素(必然是内联元素)又以什么宽度为参考决定折行呢?方法是向上寻找,直到找到一个块元素祖先(最远是BODY),然后以其宽度为参考决定折行。

以上解决了所有基本布局问题,网页也可以进行渲染了,渲染的流程好似将网页节点变成一个个字块,依次流入模子,所以这种流程也就可被称作文档流模型。但是,仅仅是靠块元素和内联元素还无法完成很多必须的显示效果,如分栏显示(即两个或多个块元素位于同一行)和表格显示,这就需要新的分类和新的策略了。

3 分栏、列表和表格

3.1 分栏

分栏是一个必不可少的需求,即使不考虑现行的正文+侧边栏模型,科学论文也有很多是两栏显示的。通过观察两栏显示,我们可以得出这样的结论:对于两栏对应的节点,从其父节点看来,他们满足内联元素的特征,即左右相连而不是上下相依;但从节点内部看来,他们更像是块元素,他们本身有确定的宽度,他们可以容纳其他块元素。为了实现这种节点,我们引入了第三种节点类型(display 属性):内联块(inline-block)。

内联块综合了两种特征,但就布局策略而言,并没有引入新的方式。在一个递归内,如果当前节点是内联块,那么就将其当作块元素进行递归布局,递归返回后,将其按照内联元素进行摆放,但是不折行(即如果剩余宽度不足,就整体移入下一行)。

3.2 列表

列表也是一个必须的模型,在布局上没有特殊的要求,遵从块元素即可,但却拥有特殊的显示特征,即其左侧包含一个特殊标志,这个标志可以根据设定的不同(list-style 属性)显示不同的内容:如实体方块(square)、实体圆点(circle)和数字(decimal)等。为了满足这一特殊的显示效果,新增加一种节点类型(display 属性):列表项(list-item)。 对于数字而言,列表项是依次计数的,但每个列表拥有独立的计数。列表必须是块元素,其中UL节点使其列表项使用实体方块作为标志,OL则为数字。LI节点默认就是列表项。

3.3 表格

必须承认,表格技术的出现早于分栏,并且在很长一段时间内同时担当了分栏的责任。为了其特殊的显示效果,增加了很多节点类型(display 属性),一个表格(table),由标题(table-caption)和表组成,表由行(table-row)组成,其中不同的行分别位于表头(table-header-group)、表体(table-row-group)和表尾(table-footer-group),每行由单元(table-cell)组成,同一行的单元可以合并成为更大的单元,同一个单元也可以跨越多个连续的行,单元内可以放置其他节点。

表格布局的复杂性在于其宽度的计算。表格本身不同于块、内联和内联块三种:一方面它是块,所以独占一行,且可以设定宽度;另一方面,在未设定宽度时,它的宽度是根据其子节点得到的;此外,所有行的宽度都必须相等且必须等于表的总宽度,同一列所有单元的宽度必须相同且总和必须等于该行宽度,同一行所有单元的高度必须相同且等于该行高度。每个单元的宽度都可以单独指定,也可以不指定,宽度可以是绝对值,也可以是相对比例。

综合以上,导致对表格的布局需要往复两次甚至三次才能完成。

4 文字环绕图片

图片对于网页的作用,自适始终都如同书中的插图一样,是正文的一部分,也与正文相融合,但无论哪种显示模型都无法实现这一效果。本质上来说,图片不能被折行,于是即便作为内联元素,其的行高也只能等于图片本身高度,而内联元素左右相依是以行作为最小单位的,以至于无论图片多高,其两侧只能出现一行文字。

解决这一难题的方法是跳出现有的流模型,引入浮动(float)机制。

浮动有左浮动(left)和右浮动(right)之分,这里只分析左浮动,右浮动可以看作一个镜像的算法。 对于一个左浮动的节点,首先它会被按照标准的内联块流程计算出位置和大小,然后将父节点的空间割裂为两部分:上部分的宽度为总宽度减去该节点的宽度,高度为该节点的高度;下部分宽度为父节点的宽度,高度为无穷。其后的节点会先尝试在上部分空间进行布局,如宽度不足,块和内联块元素改为在下部分布局,内联元素则折行进入下部分(此时上下两行的宽度就不同了)。

当浮动节点出现时,父节点的高度不再由全部节点贡献,而是只有非浮动节点贡献,这就可能导致父节点的高度小于浮动节点的高度。

一个节点被浮动后,不仅影响自身,还影响之后的节点。如果其后的节点不想被影响,则可以使用清除属性(clear),该属性可选值为左清除(left)、右清除(right)、两侧清除(both)、不清除(none)和继承上级(inherit)。其中左、右和两侧指不允许对应方向上出现其他浮动元素,算法上就是直接进入下部分进行布局。不清除是默认行为,即允许两侧有浮动元素。

5 打破流模型

在互联网发展的初期,就出现了网页广告,这些广告很多看起来和网页本身并不是一体的,而是悬浮在其上且不断移动。及至后来,网页交互的手段越来越多,出现了对某一区域的拖动操作,被拖动的块看起来有如浮动广告一样,可以与网页本身区分开来自由移动。上述两种应用都对网页布局提出了新的要求,我们姑且不讨论它们的运动(这多是由 JavaScript 驱动的),但就“与网页本身区分,可被任意放置”一条,就足够挑战已有的全部布局形式了,此时浮动技术也无能为力。

针对这个目的,我们在网页的流布局上,提出了更高层次的抽象,即把流布局看作网页布局(position)的一种策略:静态定位(static),对“飘动”的节点使用另外的布局策略。

5.1 绝对定位(absolute)

绝对布局是指节点的位置与其所在的子树无关,其大小由自身决定(width 属性或者由子节点大小决定),位置也由自身决定,即指定相对于第一个非静态定位的祖先节点的位置(top left right bottom,其中x y两个方向各指定一个值就足够了),最远的祖先是HTML。

在布局算法上,子树在布局时,如遇到绝对定位的节点就跳过,待该节点所需的那个祖先节点布局完成后,再逐个计算绝度定位的节点。如果不指定位置,则其位置为其静态定位时应位于的位置。

5.2 相对定位(relative)

相对定位是指节点的位置相对于其静态定位时的位置,该节点虽然也被抽出文档流,但其静态定位所占据的空间在原子树中是保留的。所以,布局算法只需要先按照标准形式计算,而后对相对定位的节点增加一个偏移量即可。

5.3 固定定位(fixed)

固定定位与绝对定位相似,同样是被整体布局所跳过,只不过其位置是相对于当前浏览器可是区域计算的。如果没有可视区域的概念,可以将固定定位当作相对于HTML的绝对定位节点处理。

6. 杂项

6.1 空白与折行

HTML 文本为了可读性会对空白字符(包括空格 \t \r \n)做特殊处理,使其不显示这个为了源代码格式美观而存在字符。但是当文本已经被格式化过时,这一策略就会导致显示结果与期望不符,于是 HTML 提供了空白属性(white-space)用于控制该行为:

  • 折行(wrap)。多余的空白字符会被合并,文本会在必要的时候进行折行(即剩余宽度不足时),这是默认行为。
  • 不折行(nowrap)。多余的空白字符会被合并,但文本不会折行(超出则按照超宽属性进行处理)。 格式化(pre)。不对文本进行任何处理,显示所有的空白字符,回车字符会导致折行,但文本不会自动折行,这是PRE节点的默认行为。
  • 过滤格式化(pre-line)。多余的空白字符会被合并,但其中的回车会导致折行,文本会在必要的时候自动折行。
  • 折行格式化(pre-wrap)。不对文本进行任何处理,回车符会导致折行,文本在必要的时候也会自动折行。 继承上一级(inherit)。行为随父节点。

对于布局算法而言,一方面需要在需要自动折行的地方判断该属性以决定是否进行折行,另一方面需要在遇到回车符时根据该属性决定是否折行,但增加的判断对算法本身的设计没有影响。由于现行网页中,过滤格式化和折行格式化的应用非常少,可以将算法简化为处理是否自动折行和是否是PRE标签两种情况。

6.2 内联元素的垂直对齐

内联元素最初的目的是为了让格式化的文字(如加粗斜体链接等)与普通文字融入一起显示而设计的,因此各个内联元素即便行高不同,也会在同一行上向文字的基线对齐。

而后引入的内联块元素目的就不是为了文字显示了,这就要求我们将垂直对齐(vertical-align)提升作为一个属性,默认为基线对齐(baseline),也可设置为上对齐(top)、下对齐(bottom)和中对齐(middle)。

不同的垂直对齐不影响布局算法中对宽度和高度的计算,但会影响y坐标。考虑到通常情况下,相邻的内联元素高度相同,相邻的内联块元素都是上对齐,如果并不要求绝对精确,则可使用上对齐作为近似,这在计算上也是最方便的。

6.3 文字方向

文字的顺序可分为阅读序和书写序两种,阅读序指文字在被读出时的时间先后,以时间的早晚作为顺序的前后,书写序则指按照阅读序前后相邻的两个字符在书写介质上的相对位置,这些相对位置包括:

  • 字由左至右,行由上至下。这是目前常见的方式,所有印欧语系皆属此类,现代中日韩文受拉丁化影响,也使用这种方式。
  • 字由右至左,行由上至下。该方式仅见于阿拉伯文和希伯来文等闪族语种以及受伊斯兰教影响的维吾尔族。
  • 字由上至下,行由右至左。该方式是古代中文的书写形式,亦影响了日本和高丽地区,至今在汉语古文以及日本和台湾地区仍有使用。
  • 字由上至下,行由左至右。该方式见于古蒙古语,日语亦有部分使用。
  • 牛耕式和由下至上。双向方式仅在古希腊书写的变革期出现,文字双向交替出现,而某些地域则以远离书写者的方向书写,这些书写方式今天已经消失了。

文字在HTML源代码中总是以阅读序出现的,在布局时,文字的方向基本不影响节点的布局,而对布局有影响的首先是文字对齐顺序(text-align)。

对齐顺序包括三种:左对齐(left),右对齐(right),居中对齐(center)和两端对其(justify)。两端对齐是使得文本不同的行具有同等的宽度。对齐顺序只影响内联元素,布局方法左右对齐互为镜像,居中和两端对齐则需要更多的计算(参考6.5. 边框、补白和居中)。

文字顺序(direction)不同于对齐顺序(text-align),不过当顺序为右至左(rtl)时,对齐顺序默认会变更为右对齐(right)。假设 A 和 B 分别为两个内联右至左元素,则其出现的顺序为 BA,但如果把 BA 看作整体的话,它的位置与 AB 一样都是只右对齐顺序决定的,如果左对齐,则其左边沿紧贴父节点左侧,右对齐则为右边沿紧贴父节点右侧。可以说,文字的顺序属性只影响相邻内联元素的相对顺序,对其位置的影响是通过改变其默认对齐属性达到的。

字符顺序是指同一个内联文本中各个字符的相对顺序。UNICODE 本身定义了各个字符的标准顺序,如中文即从左至右,阿拉伯文即从右至左,浏览器会按照 UNICODE 指定的顺序渲染同一个内联文本中的各个字符。也就是说,对于本身是对于由左至右的文字(如中文、英文)而言,即便文字顺序(direction)为右至左(rtl),浏览器依然会将同一个内联文本中的字符按照左至右的顺序渲染,而只有那些本来就是右至左的文字才会在这种情况下按照右至左的顺序渲染。

可以使用 UNICODE 双向算法属性(unicode-bidi)强制浏览器按照文字顺序渲染字符(bidi-override)。 由于字符顺序是渲染阶段的工作,在这里我们只讨论文字顺序(direction)对布局算法的影响,其右至左(rtl)的默认行为接近于右浮动(float: right ),但当与左对齐(text-align :left)同时出现时,则需要反向遍历各子节点,然后按照标准的左对齐算法进行布局。

6.4 不可见标签

标签的可见性有两重含义:一是标签是否占据空间,不占据空间的标签自然看不到;二是标签是否被渲染,不画出来也看不到。前者导致了一个新的标签类型(display):空(none)。对于这类标签,布局时直接跳过,不予处理(包括子节点)。后者导致了可见性属性(visibility),这是一个渲染阶段处理的属性,不可见(hidden)时,该节点(包括子节点)不被画出来。该属性对布局无任何作用。

6.5 边框、补白和居中

任何节点都可以有外补白(margin)、内补白(padding)和边框(border),标准情况下,这些只是增大了节点的大小,对布局而言没有影响。

外补白(margin)有两种特例:自动补白(auto)和负补白。

自动补白是根据节点宽度和父节点宽度的差自动计算的,如果左右方向均为自动,则各取差值一半,从而达到居中效果。当多个自动同时出现时,就无法计算了,因此对内联元素无甚意义。为简单起见,可只处理为块居中设计的自动补白,算法是较为直白的。

负补白代表了侵占另一个节点的空间。该技术多用于加快主要内容的显示,设主要内容节点为A,边栏为B,它们是内联块元素,而且设置了正确的宽度,为了更快的显示主要内容,A 会位于 B 的前面(就 DOM 树而言),但当视觉设计上 A 要位于 B 的右侧时,就可以给 A 一个较大的左补白(这样它就位于右侧),然后给 B 一个更大的负左补白,从而使其侵占到左侧。对于布局算法而言,这并不影响流程,只要在计算x坐标和总宽度时加入相应的补白值即可。

via HTML布局