从零开始实现一个玩具版浏览器渲染引擎(三)

简介: 从零开始实现一个玩具版浏览器渲染引擎(三)

布局树

第四阶段讲的是如何将样式树转化为布局树,也是整个渲染引擎相对比较复杂的部分。

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 的类型有很多种,例如 blockinlineflex 等等,但这里只支持 blockinline 两种布局方式,并且所有盒子的默认布局方式为 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
}

盒子节点的类型可以是 blockinilneanonymous

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()
}

这个方法执行布局树的单次遍历,向下执行宽度计算,向上执行高度计算。一个真正的布局引擎可能会执行几次树遍历,有些是自上而下的,有些是自下而上的。

计算宽度

现在,我们先来计算盒子节点的宽度,这部分比较复杂,需要详细的讲解。

首先,我们要拿到当前节点的 widthpaddingbordermargin 等信息:

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

如果这个差值为正数,说明子节点宽度小于父节点;如果差值为负数,说明子节点大于父节。上面这段代码逻辑其实就是根据 underflowwidthpaddingmargin 等值对子节点的宽度、边距进行调整,以适应父节点的宽度。

定位

计算当前节点的位置相对来说简单一点。这个方法会根据当前节点的 marginborderpadding 样式以及父节点的位置信息对当前节点进行定位:

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)
    }
}

为了简单起见,我们不需要实现外边距折叠

小结

布局树是渲染引擎最复杂的部分,这一阶段结束后,我们就了解了布局树中每个盒子节点在页面中的具体位置和尺寸信息。下一步,就是如何把布局树渲染到页面上了。

目录
相关文章
|
前端开发 JavaScript
从零开始实现一个玩具版浏览器渲染引擎(二)
从零开始实现一个玩具版浏览器渲染引擎(二)
73 0
|
前端开发 JavaScript API
从零开始实现一个玩具版浏览器渲染引擎(四)
从零开始实现一个玩具版浏览器渲染引擎(四)
87 0
|
Rust 自然语言处理 前端开发
从零开始实现一个玩具版浏览器渲染引擎(一)
从零开始实现一个玩具版浏览器渲染引擎
73 0
|
Web App开发 JavaScript 前端开发
浏览器渲染引擎工作原理|学习笔记
快速学习浏览器渲染引擎工作原理
246 0
浏览器渲染引擎工作原理|学习笔记
|
Web App开发 存储 移动开发
浏览器内核(渲染引擎)介绍|学习笔记
快速学习浏览器内核(渲染引擎)介绍
308 0
|
Web App开发 存储 JavaScript
浏览器渲染引擎与阻塞
浏览器渲染引擎与阻塞
|
Web App开发 JavaScript 前端开发
画了20张图,详解浏览器渲染引擎工作原理(下)
今天我们来学习一下浏览器渲染引擎的工作原理,文章内容较多,建议先收藏再学习! 先来看看Chrome浏览器的架构图:
306 0
|
5天前
|
JavaScript API
深入解析JS中的visibilitychange事件:监听浏览器标签间切换的利器
深入解析JS中的visibilitychange事件:监听浏览器标签间切换的利器
12 0
|
1月前
|
JavaScript 前端开发
js之浏览器对象|28
js之浏览器对象|28
|
16天前
|
机器学习/深度学习 自然语言处理 前端开发
前端大模型入门:Transformer.js 和 Xenova-引领浏览器端的机器学习变革
除了调用API接口使用Transformer技术,你是否想过在浏览器中运行大模型?Xenova团队推出的Transformer.js,基于JavaScript,让开发者能在浏览器中本地加载和执行预训练模型,无需依赖服务器。该库利用WebAssembly和WebGPU技术,大幅提升性能,尤其适合隐私保护、离线应用和低延迟交互场景。无论是NLP任务还是实时文本生成,Transformer.js都提供了强大支持,成为构建浏览器AI应用的核心工具。