布局树
第四阶段讲的是如何将样式树转化为布局树,也是整个渲染引擎相对比较复杂的部分。
CSS 盒子模型
在 CSS 中,所有的 DOM 节点都可以当作一个盒子。这个盒子模型包含了内容、内边距、边框、外边距以及在页面中的位置信息。
我们可以用以下的数据结构来表示盒子模型:
export default class Dimensions { content: Rect padding: EdgeSizes border: EdgeSizes margin: EdgeSizes } export default class Rect { x: number y: number width: number height: number } export interface EdgeSizes { top: number right: number bottom: number left: number }
块布局和内联布局
CSS 的 display
属性决定了盒子在页面中的布局方式。display
的类型有很多种,例如 block
、inline
、flex
等等,但这里只支持 block
和 inline
两种布局方式,并且所有盒子的默认布局方式为 display: inline
。
我会用伪 HTML 代码来描述它们之间的区别:
<container> <a></a> <b></b> <c></c> <d></d> </container>
块布局会将盒子从上至下的垂直排列。
内联布局则会将盒子从左至右的水平排列。
如果容器内同时存在块布局和内联布局,则会用一个匿名布局将内联布局包裹起来。
这样就能将内联布局的盒子和其他块布局的盒子区别开来。
通常情况下内容是垂直增长的。也就是说,在容器中添加子节点通常会使容器更高,而不是更宽。另一种说法是,默认情况下,子节点的宽度取决于其容器的宽度,而容器的高度取决于其子节点的高度。
布局树
布局树是所有盒子节点的集合。
export default class LayoutBox { dimensions: Dimensions boxType: BoxType children: LayoutBox[] styleNode: StyleNode }
盒子节点的类型可以是 block
、inilne
和 anonymous
。
export enum BoxType { BlockNode = 'BlockNode', InlineNode = 'InlineNode', AnonymousBlock = 'AnonymousBlock', }
我们构建样式树时,需要根据每一个 DOM 节点的 display
属性来生成对应的盒子节点。
export function getDisplayValue(styleNode: StyleNode) { return styleNode.values?.display ?? Display.Inline }
如果 DOM 节点 display
属性的值为 none
,则在构建布局树的过程中,无需将这个 DOM 节点添加到布局树上,直接忽略它就可以了。
如果一个块节点包含一个内联子节点,则需要创建一个匿名块(实际上就是块节点)来包含它。如果一行中有多个子节点,则将它们全部放在同一个匿名容器中。
function buildLayoutTree(styleNode: StyleNode) { if (getDisplayValue(styleNode) === Display.None) { throw new Error('Root node has display: none.') } const layoutBox = new LayoutBox(styleNode) let anonymousBlock: LayoutBox | undefined for (const child of styleNode.children) { const childDisplay = getDisplayValue(child) // 如果 DOM 节点 display 属性值为 none,直接跳过 if (childDisplay === Display.None) continue if (childDisplay === Display.Block) { anonymousBlock = undefined layoutBox.children.push(buildLayoutTree(child)) } else { // 创建一个匿名容器,用于容纳内联节点 if (!anonymousBlock) { anonymousBlock = new LayoutBox() layoutBox.children.push(anonymousBlock) } anonymousBlock.children.push(buildLayoutTree(child)) } } return layoutBox }
遍历布局树
现在开始构建布局树,入口函数是 getLayoutTree()
:
export function getLayoutTree(styleNode: StyleNode, parentBlock: Dimensions) { parentBlock.content.height = 0 const root = buildLayoutTree(styleNode) root.layout(parentBlock) return root }
它将遍历样式树,利用样式树节点提供的相关信息,生成一个 LayoutBox
对象,然后调用 layout()
方法。计算每个盒子节点的位置、尺寸信息。
在本节内容的开头有提到过,盒子的宽度取决于其父节点,而高度取决于子节点。这意味着,我们的代码在计算宽度时需要自上而下遍历树,这样它就可以在知道父节点的宽度后设置子节点的宽度。然后自下而上遍历以计算高度,这样父节点的高度就可以在计算子节点的相关信息后进行计算。
layout(parentBlock: Dimensions) { // 子节点的宽度依赖于父节点的宽度,所以要先计算当前节点的宽度,再遍历子节点 this.calculateBlockWidth(parentBlock) // 计算盒子节点的位置 this.calculateBlockPosition(parentBlock) // 遍历子节点并计算对位置、尺寸信息 this.layoutBlockChildren() // 父节点的高度依赖于其子节点的高度,所以计算子节点的高度后,再计算自己的高度 this.calculateBlockHeight() }
这个方法执行布局树的单次遍历,向下执行宽度计算,向上执行高度计算。一个真正的布局引擎可能会执行几次树遍历,有些是自上而下的,有些是自下而上的。
计算宽度
现在,我们先来计算盒子节点的宽度,这部分比较复杂,需要详细的讲解。
首先,我们要拿到当前节点的 width
padding
border
margin
等信息:
calculateBlockWidth(parentBlock: Dimensions) { // 初始值 const styleValues = this.styleNode?.values || {} // 初始值为 auto let width = styleValues.width ?? 'auto' let marginLeft = styleValues['margin-left'] || styleValues.margin || 0 let marginRight = styleValues['margin-right'] || styleValues.margin || 0 let borderLeft = styleValues['border-left'] || styleValues.border || 0 let borderRight = styleValues['border-right'] || styleValues.border || 0 let paddingLeft = styleValues['padding-left'] || styleValues.padding || 0 let paddingRight = styleValues['padding-right'] || styleValues.padding || 0 // 拿到父节点的宽度,如果某个属性为 'auto',则将它设为 0 let totalWidth = sum(width, marginLeft, marginRight, borderLeft, borderRight, paddingLeft, paddingRight) // ...
如果这些属性没有设置,就使用 0 作为默认值。拿到当前节点的总宽度后,还需要和父节点对比一下是否相等。如果宽度或边距设置为 auto
,则可以对这两个属性进行适当展开或收缩以适应可用空间。所以现在需要对当前节点的宽度进行检查。
const isWidthAuto = width === 'auto' const isMarginLeftAuto = marginLeft === 'auto' const isMarginRightAuto = marginRight === 'auto' // 当前块的宽度如果超过了父元素宽度,则将它的可扩展外边距设为 0 if (!isWidthAuto && totalWidth > parentWidth) { if (isMarginLeftAuto) { marginLeft = 0 } if (isMarginRightAuto) { marginRight = 0 } } // 根据父子元素宽度的差值,去调整当前元素的宽度 const underflow = parentWidth - totalWidth // 如果三者都有值,则将差值填充到 marginRight if (!isWidthAuto && !isMarginLeftAuto && !isMarginRightAuto) { marginRight += underflow } else if (!isWidthAuto && !isMarginLeftAuto && isMarginRightAuto) { // 如果右边距是 auto,则将 marginRight 设为差值 marginRight = underflow } else if (!isWidthAuto && isMarginLeftAuto && !isMarginRightAuto) { // 如果左边距是 auto,则将 marginLeft 设为差值 marginLeft = underflow } else if (isWidthAuto) { // 如果只有 width 是 auto,则将另外两个值设为 0 if (isMarginLeftAuto) { marginLeft = 0 } if (isMarginRightAuto) { marginRight = 0 } if (underflow >= 0) { // 展开宽度,填充剩余空间,原来的宽度是 auto,作为 0 来计算的 width = underflow } else { // 宽度不能为负数,所以需要调整 marginRight 来代替 width = 0 // underflow 为负数,相加实际上就是缩小当前节点的宽度 marginRight += underflow } } else if (!isWidthAuto && isMarginLeftAuto && isMarginRightAuto) { // 如果只有 marginLeft 和 marginRight 是 auto,则将两者设为 underflow 的一半 marginLeft = underflow / 2 marginRight = underflow / 2 }
详细的计算过程请看上述代码,重要的地方都已经标上注释了。
通过对比当前节点和父节点的宽度,我们可以拿到一个差值:
// 根据父子元素宽度的差值,去调整当前元素的宽度 const underflow = parentWidth - totalWidth
如果这个差值为正数,说明子节点宽度小于父节点;如果差值为负数,说明子节点大于父节。上面这段代码逻辑其实就是根据 underflow
width
padding
margin
等值对子节点的宽度、边距进行调整,以适应父节点的宽度。
定位
计算当前节点的位置相对来说简单一点。这个方法会根据当前节点的 margin
border
padding
样式以及父节点的位置信息对当前节点进行定位:
calculateBlockPosition(parentBlock: Dimensions) { const styleValues = this.styleNode?.values || {} const { x, y, height } = parentBlock.content const dimensions = this.dimensions dimensions.margin.top = transformValueSafe(styleValues['margin-top'] || styleValues.margin || 0) dimensions.margin.bottom = transformValueSafe(styleValues['margin-bottom'] || styleValues.margin || 0) dimensions.border.top = transformValueSafe(styleValues['border-top'] || styleValues.border || 0) dimensions.border.bottom = transformValueSafe(styleValues['border-bottom'] || styleValues.border || 0) dimensions.padding.top = transformValueSafe(styleValues['padding-top'] || styleValues.padding || 0) dimensions.padding.bottom = transformValueSafe(styleValues['padding-bottom'] || styleValues.padding || 0) dimensions.content.x = x + dimensions.margin.left + dimensions.border.left + dimensions.padding.left dimensions.content.y = y + height + dimensions.margin.top + dimensions.border.top + dimensions.padding.top } function transformValueSafe(val: number | string) { if (val === 'auto') return 0 return parseInt(String(val)) }
比如获取当前节点内容区域的 x 坐标,计算方式如下:
dimensions.content.x = x + dimensions.margin.left + dimensions.border.left + dimensions.padding.left
遍历子节点
在计算高度之前,需要先遍历子节点,因为父节点的高度需要根据它下面子节点的高度进行适配。
layoutBlockChildren() { const { dimensions } = this for (const child of this.children) { child.layout(dimensions) // 遍历子节点后,再计算父节点的高度 dimensions.content.height += child.dimensions.marginBox().height } }
每个节点的高度就是它上下两个外边距之间的差值,所以可以通过 marginBox()
获得高度:
export default class Dimensions { content: Rect padding: EdgeSizes border: EdgeSizes margin: EdgeSizes constructor() { const initValue = { top: 0, right: 0, bottom: 0, left: 0, } this.content = new Rect() this.padding = { ...initValue } this.border = { ...initValue } this.margin = { ...initValue } } paddingBox() { return this.content.expandedBy(this.padding) } borderBox() { return this.paddingBox().expandedBy(this.border) } marginBox() { return this.borderBox().expandedBy(this.margin) } }
export default class Rect { x: number y: number width: number height: number constructor() { this.x = 0 this.y = 0 this.width = 0 this.height = 0 } expandedBy(edge: EdgeSizes) { const rect = new Rect() rect.x = this.x - edge.left rect.y = this.y - edge.top rect.width = this.width + edge.left + edge.right rect.height = this.height + edge.top + edge.bottom return rect } }
遍历子节点并执行完相关计算方法后,再将各个子节点的高度进行相加,得到父节点的高度。
height 属性
默认情况下,节点的高度等于其内容的高度。但如果手动设置了 height
属性,则需要将节点的高度设为指定的高度:
calculateBlockHeight() { // 如果元素设置了 height,则使用 height,否则使用 layoutBlockChildren() 计算出来的高度 const height = this.styleNode?.values.height if (height) { this.dimensions.content.height = parseInt(height) } }
为了简单起见,我们不需要实现外边距折叠。
小结
布局树是渲染引擎最复杂的部分,这一阶段结束后,我们就了解了布局树中每个盒子节点在页面中的具体位置和尺寸信息。下一步,就是如何把布局树渲染到页面上了。