作为一名事情多年的前端,我对付渲染事理自然也是理解的,但是对付它的理解只勾留在理论知识层面。以是我决定自己动手实现一个玩具版的渲染引擎。
渲染引擎是浏览器的一部分,它卖力将网页内容(HTML、CSS、JavaScript 等)转化为用户可阅读、不雅观看、听到的形式。但是要独自实现一个完全的渲染引擎事情量实在太大了,而且也很困难。于是我决定退一步,打算实现一个玩具版的渲染引擎。刚好 Github 上有一个开源的用 Rust 写的玩具版渲染引擎 robinson[2],于是决定模拟其源码自己用 JavaScript 实现一遍,并且也在 Github 上开源了 从零开始实现一个玩具版浏览器渲染引擎[3]。
这个玩具版的渲染引擎一共分为五个阶段:
分别是:
1.解析 HTML,天生 DOM 树2.解析 CSS,天生 CSS 规则凑集3.天生 Style 树4.天生布局树5.绘制
每个阶段的代码我在仓库上都用一个分支来表示。由于直接看全体渲染引擎的代码可能会比较困难,以是我建议大家从第一个分支开始进行学习,从易到难,这样学习效果更好。
现在我们先看一下如何编写一个 HTML 解析器。
HTML 解析器HTML 解析器的浸染便是将持续串的 HTML 文本解析为 DOM 树。比如将这样的 HTML 文本:
<div class="lightblue test" id=" div " data-index="1">test!</div>
解析为一个 DOM 树:
{ "tagName": "div", "attributes": { "class": "lightblue test", "id": "div", "data-index": "1" }, "children": [ { "nodeValue": "test!", "nodeType": 3 } ], "nodeType": 1}
写解析器须要懂一些编译事理的知识,比如词法剖析、语法剖析什么的。但是我们的玩具版解析器非常大略,纵然不懂也没有关系,大家看源码就能明白了。
再回到上面的那段 HTML 文本,它的全体解析过程可以用下面的图来表示,每一段 HTML 文本都有对应的方法去解析。
为了让解析器实现起来大略一点,我们须要对 HTML 的功能进行约束:
1.标签必须要成对涌现:<div>...</div>2.HTML 属性值必须要有引号包起来 <div class="test">...</div>3.不支持注释4.只管即便不做缺点处理5.只支持两种类型节点 Element 和 Text
对解析器的功能进行约束后,代码实现就变得大略多了,现在让我们连续吧。
节点类型首先,为这两种节点 Element 和 Text 定一个适当的数据构造:
export enum NodeType { Element = 1, Text = 3,}export interface Element { tagName: string attributes: Record<string, string> children: Node[] nodeType: NodeType.Element}interface Text { nodeValue: string nodeType: NodeType.Text}export type Node = Element | Text
然后为这两种节点各写一个天生函数:
export function element(tagName: string) { return { tagName, attributes: {}, children: [], nodeType: NodeType.Element, } as Element}export function text(data: string) { return { nodeValue: data, nodeType: NodeType.Text, } as Text}
这两个函数在解析到元素节点或者文本节点时调用,调用后会返回对应的 DOM 节点。
HTML 解析器的实行过程下面这张图便是全体 HTML 解析器的实行过程:
HTML 解析器的入口方法为 parse(),从这开始实行直到遍历完所有 HTML 文本为止:
1.判断当前字符是否为 <,如果是,则当作元素节点来解析,调用 parseElement(),否则调用 parseText()2.parseText() 比较大略,一贯往前遍历字符串,直至碰着 < 字符为止。然后将之前遍历过的所有字符当作 Text 节点的值。3.parseElement() 则相对繁芜一点,它首先要解析出当前的元素标署名称,这段文本用 parseTag() 来解析。4.然后再进入 parseAttrs() 方法,判断是否有属性节点,如果该节点有 class 或者其他 HTML 属性,则会调用 parseAttr() 把 HTML 属性或者 class 解析出来。5.至此,全体元素节点的前半段已经解析完了。接下来须要解析它的子节点。这时就会进入无限递归循环回到第一步,连续解析元素节点或文本节点。6.当所有子节点解析完后,须要调用 parseTag(),看看结束标署名和元素节点的开始标署名是否相同,如果相同,则 parseElement() 或者 parse() 结束,否则报错。
HTML 解析器各个方法详解入口方法 parse()HTML 的入口方法是 parse(rawText)
parse(rawText: string) { this.rawText = rawText.trim() this.len = this.rawText.length this.index = 0 this.stack = [] const root = element('root') while (this.index < this.len) { this.removeSpaces() if (this.rawText[this.index].startsWith('<')) { this.index++ this.parseElement(root) } else { this.parseText(root) } }}
入口方法须要遍历所有文本,在一开始它须要判断当前字符是否是 <,如果是,则将它当作元素节点来解析,调用 parseElement(),否则将当前字符作为文本来解析,调用 parseText()。
解析元素节点 parseElement()private parseElement(parent: Element) { // 解析标签 const tag = this.parseTag() // 天生元素节点 const ele = element(tag) this.stack.push(tag) parent.children.push(ele) // 解析属性 this.parseAttrs(ele) while (this.index < this.len) { this.removeSpaces() if (this.rawText[this.index].startsWith('<')) { this.index++ this.removeSpaces() // 判断是否是结束标签 if (this.rawText[this.index].startsWith('/')) { this.index++ const startTag = this.stack[this.stack.length - 1] // 结束标签 const endTag = this.parseTag() if (startTag !== endTag) { throw Error(`The end tagName ${endTag} does not match start tagName ${startTag}`) } this.stack.pop() while (this.index < this.len && this.rawText[this.index] !== '>') { this.index++ } break } else { this.parseElement(ele) } } else { this.parseText(ele) } } this.index++}
parseElement() 会依次调用 parseTag() parseAttrs() 解析标签和属性,然后再递归解析子节点,终止条件是遍历完所有的 HTML 文本。
解析文本节点 parseText()private parseText(parent: Element) { let str = '' while ( this.index < this.len && !(this.rawText[this.index] === '<' && /\w|\//.test(this.rawText[this.index + 1])) ) { str += this.rawText[this.index] this.index++ } this.sliceText() parent.children.push(text(removeExtraSpaces(str)))}
解析文本相对大略一点,它会一贯往前遍历,直至碰着 < 为止。比如这段文本 <div>test!</div>,经由 parseText() 解析后拿到的文本是 test!。
解析标签 parseTag()在进入 parseElement() 后,首先调用便是 parseTag(),它的浸染是解析标署名:
private parseTag() { let tag = '' this.removeSpaces() // get tag name while (this.index < this.len && this.rawText[this.index] !== ' ' && this.rawText[this.index] !== '>') { tag += this.rawText[this.index] this.index++ } this.sliceText() return tag}
比如这段文本 <div>test!</div>,经由 parseTag() 解析后拿到的标署名是 div。
解析属性节点 parseAttrs()解析完标署名后,接着再解析属性节点:
// 解析元素节点的所有属性private parseAttrs(ele: Element) { // 一贯遍历文本,直至碰着 '>' 字符为止,代表 <div ....> 这一段文本已经解析完了 while (this.index < this.len && this.rawText[this.index] !== '>') { this.removeSpaces() this.parseAttr(ele) this.removeSpaces() } this.index++}// 解析单个属性,例如 class="foo bar"private parseAttr(ele: Element) { let attr = '' let value = '' while (this.index < this.len && this.rawText[this.index] !== '=' && this.rawText[this.index] !== '>') { attr += this.rawText[this.index++] } this.sliceText() attr = attr.trim() if (!attr.trim()) return this.index++ let startSymbol = '' if (this.rawText[this.index] === "'" || this.rawText[this.index] === '"') { startSymbol = this.rawText[this.index++] } while (this.index < this.len && this.rawText[this.index] !== startSymbol) { value += this.rawText[this.index++] } this.index++ ele.attributes[attr] = value.trim() this.sliceText()}
parseAttr() 可以将这样的文本 class="test" 解析为一个工具 { class: "test" }。
其他赞助方法有时不同的节点、属性之间有很多多余的空格,以是须要写一个方法将多余的空格打消掉。
protected removeSpaces() { while (this.index < this.len && (this.rawText[this.index] === ' ' || this.rawText[this.index] === '\n')) { this.index++ } this.sliceText()}
同时为了方便调试,开拓者常常须要打断点看当前正在遍历的字符是什么。如果以前遍历过的字符串还在,那么是比较难调试的,由于开拓者须要根据 index 的值自己去找当前遍历的字符是什么。以是所有解析完的 HTML 文本,都须要截取掉,确保当前的 HTML 文本都是没有被遍历:
protected sliceText() { this.rawText = this.rawText.slice(this.index) this.len = this.rawText.length this.index = 0}
sliceText() 方法的浸染便是截取已经遍历过的 HTML 文本。用下图来做例子,假设当前要解析 div 这个标署名:
那么解析后须要对 HTML 文本进行截取,就像下图这样:
小结
至此,全体 HTML 解析器的逻辑已经讲完了,所有代码加起来 200 行旁边,如果不算 TS 各种类型声明,代码只有 100 多行。
CSS 解析器CSS 样式表是一系列的 CSS 规则凑集,而 CSS 解析器的浸染便是将 CSS 文本解析为 CSS 规则凑集。
div, p { font-size: 88px; color: #000;}
例如上面的 CSS 文本,经由解析器解析后,会天生下面的 CSS 规则凑集:
[ { "selectors": [ { "id": "", "class": "", "tagName": "div" }, { "id": "", "class": "", "tagName": "p" } ], "declarations": [ { "name": "font-size", "value": "88px" }, { "name": "color", "value": "#000" } ] }]
每个规则都有一个 selector 和 declarations 属性,个中 selectors 表示 CSS 选择器,declarations 表示 CSS 的属性描述凑集。
export interface Rule { selectors: Selector[] declarations: Declaration[]}export interface Selector { tagName: string id: string class: string}export interface Declaration { name: string value: string | number}
每一条 CSS 规则都可以包含多个选择器和多个 CSS 属性。
解析 CSS 规则 parseRule()private parseRule() { const rule: Rule = { selectors: [], declarations: [], } rule.selectors = this.parseSelectors() rule.declarations = this.parseDeclarations() return rule}
在 parseRule() 里,它分别调用了 parseSelectors() 去解析 CSS 选择器,然后再对剩余的 CSS 文本实行 parseDeclarations() 去解析 CSS 属性。
解析选择器 parseSelector()private parseSelector() { const selector: Selector = { id: '', class: '', tagName: '', } switch (this.rawText[this.index]) { case '.': this.index++ selector.class = this.parseIdentifier() break case '#': this.index++ selector.id = this.parseIdentifier() break case '': this.index++ selector.tagName = '' break default: selector.tagName = this.parseIdentifier() } return selector}private parseIdentifier() { let result = '' while (this.index < this.len && this.identifierRE.test(this.rawText[this.index])) { result += this.rawText[this.index++] } this.sliceText() return result}
选择器我们只支持标署名称、前缀为 # 的 ID 、前缀为任意数量的类名 . 或上述的某种组合。如果标署名称为 ,则表示它是一个通用选择器,可以匹配任何标签。
标准的 CSS 解析器在碰着无法识别的部分时,会将它丧失落,然后连续解析别的部分。紧张是为了兼容旧浏览器和防止发生缺点导致程序中断。我们的 CSS 解析器为了实现大略,没有做这方面的做缺点处理。
解析 CSS 属性 parseDeclaration()private parseDeclaration() { const declaration: Declaration = { name: '', value: '' } this.removeSpaces() declaration.name = this.parseIdentifier() this.removeSpaces() while (this.index < this.len && this.rawText[this.index] !== ':') { this.index++ } this.index++ // clear : this.removeSpaces() declaration.value = this.parseValue() this.removeSpaces() return declaration}
parseDeclaration() 会将 color: red; 解析为一个工具 { name: "color", value: "red" }。
小结CSS 解析器相对来说大略多了,由于很多知识点在 HTML 解析器中已经讲到。全体 CSS 解析器的代码大概 100 多行,如果你阅读过 HTML 解析器的源码,相信看 CSS 解析器的源码会更轻松。
构建样式树本阶段的目标是写一个样式构建器,输入 DOM 树和 CSS 规则凑集,天生一棵样式树 Style tree。
样式树的每一个节点都包含了 CSS 属性值以及它对应的 DOM 节点引用:
interface AnyObject { [key: string]: any}export interface StyleNode { node: Node // DOM 节点 values: AnyObject // style 属性值 children: StyleNode[] // style 子节点}
先来看一个大略的示例:
<div>test</div>
div { font-size: 88px; color: #000;}
上述的 HTML、CSS 文本在经由样式树构建器处理后天生的样式树如下:
{ "node": { // DOM 节点 "tagName": "div", "attributes": {}, "children": [ { "nodeValue": "test", "nodeType": 3 } ], "nodeType": 1 }, "values": { // CSS 属性值 "font-size": "88px", "color": "#000" }, "children": [ // style tree 子节点 { "node": { "nodeValue": "test", "nodeType": 3 }, "values": { // text 节点继续了父节点样式 "font-size": "88px", "color": "#000" }, "children": [] } ]}
遍历 DOM 树
现在我们须要遍历 DOM 树。对付 DOM 树中的每个节点,我们都要在样式树中查找是否有匹配的 CSS 规则。
export function getStyleTree(eles: Node | Node[], cssRules: Rule[], parent?: StyleNode) { if (Array.isArray(eles)) { return eles.map((ele) => getStyleNode(ele, cssRules, parent)) } return getStyleNode(eles, cssRules, parent)}
匹配选择器
匹配选择器实现起来非常随意马虎,由于我们的CSS 解析器仅支持大略的选择器。 只须要查看元素本身即可判断选择器是否与元素匹配。
/ css 选择器是否匹配元素 /function isMatch(ele: Element, selectors: Selector[]) { return selectors.some((selector) => { // 通配符 if (selector.tagName === '') return true if (selector.tagName === ele.tagName) return true if (ele.attributes.id === selector.id) return true if (ele.attributes.class) { const classes = ele.attributes.class.split(' ').filter(Boolean) const classes2 = selector.class.split(' ').filter(Boolean) for (const name of classes) { if (classes2.includes(name)) return true } } return false })}
当查找到匹配的 DOM 节点后,再将 DOM 节点和它匹配的 CSS 属性组合在一起,天生样式树节点 styleNode:
function getStyleNode(ele: Node, cssRules: Rule[], parent?: StyleNode) { const styleNode: StyleNode = { node: ele, values: getStyleValues(ele, cssRules, parent), children: [], } if (ele.nodeType === NodeType.Element) { // 合并内联样式 if (ele.attributes.style) { styleNode.values = { ...styleNode.values, ...getInlineStyle(ele.attributes.style) } } styleNode.children = ele.children.map((e) => getStyleNode(e, cssRules, styleNode)) as unknown as StyleNode[] } return styleNode}function getStyleValues(ele: Node, cssRules: Rule[], parent?: StyleNode) { const inheritableAttrValue = getInheritableAttrValues(parent) // 文本节点继续父元素的可继续属性 if (ele.nodeType === NodeType.Text) return inheritableAttrValue return cssRules.reduce((result: AnyObject, rule) => { if (isMatch(ele as Element, rule.selectors)) { result = { ...result, ...cssValueArrToObject(rule.declarations) } } return result }, inheritableAttrValue)}
在 CSS 选择器中,不同的选择器优先级是不同的,比如 id 选择器就比类选择器的优先级要高。但是我们这里没有实现选择器优先级,为了实现大略,所有的选择器优先级是一样的。
继续属性文本节点无法匹配选择器,那它的样式从哪来?答案便是继续,它可以继续父节点的样式。
在 CSS 中存在很多继续属性,纵然子元素没有声明这些属性,也可以从父节点里继续。比如字体颜色、字体家族等属性,都是可以被继续的。为了实现大略,这里只支持继续父节点的 color、font-size 属性。
// 子元素可继续的属性,这里只写了两个,实际上还有很多const inheritableAttrs = ['color', 'font-size']/ 获取父元素可继续的属性值 /function getInheritableAttrValues(parent?: StyleNode) { if (!parent) return {} const keys = Object.keys(parent.values) return keys.reduce((result: AnyObject, key) => { if (inheritableAttrs.includes(key)) { result[key] = parent.values[key] } return result }, {})}
内联样式
在 CSS 中,内联样式的优先级是除了 !important 之外最高的。
<span style="color: red; background: yellow;">
我们可以在调用 getStyleValues() 函数获得当前 DOM 节点的 CSS 属性值后,再去取当前节点的内联样式值。并对当前 DOM 节点的 CSS 样式值进行覆盖。
styleNode.values = { ...styleNode.values, ...getInlineStyle(ele.attributes.style) }function getInlineStyle(str: string) { str = str.trim() if (!str) return {} const arr = str.split(';') if (!arr.length) return {} return arr.reduce((result: AnyObject, item: string) => { const data = item.split(':') if (data.length === 2) { result[data[0].trim()] = data[1].trim() } return result }, {})}
布局树
第四阶段讲的是如何将样式树转化为布局树,也是全体渲染引擎相比拟较繁芜的部分。
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'// 当前块的宽度如果超过了父元素宽度,则将它的可扩展外边距设为 0if (!isWidthAuto && totalWidth > parentWidth) { if (isMarginLeftAuto) { marginLeft = 0 } if (isMarginRightAuto) { marginRight = 0 }}// 根据父子元素宽度的差值,去调度当前元素的宽度const underflow = parentWidth - totalWidth// 如果三者都有值,则将差值添补到 marginRightif (!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) }}
为了大略起见,我们不须要实现外边距折叠[4]。
小结布局树是渲染引擎最繁芜的部分,这一阶段结束后,我们就理解告终构树中每个盒子节点在页面中的详细位置和尺寸信息。下一步,便是如何把布局树渲染到页面上了。
绘制绘制阶段紧张是根据布局树中各个节点的位置、尺寸信息将它们绘制到页面。目前大多数打算机利用光栅(raster,也称为位图)显示技能。将布局树各个节点绘制到页面的这个过程也被称为“光栅化”。
浏览器常日在图形API和库(如Skia、Cairo、Direct2D等)的帮助下实现光栅化。这些API供应绘制多边形、直线、曲线、渐变和文本的功能。
实际上绘制才是最难的部分,但是这一步我们有现成的 canvas[5] 库可以用,不用自己实现一个光栅器,以是相对来说就变得大略了。在真正开始绘制阶段之前,我们先来学习一些关于打算机如何绘制图像、文本的根本知识,有助于我们理解光栅化的详细实现过程。
打算机如何绘制图像、文本在打算机底层进行像素绘制属于硬件操作,它依赖于屏幕和显卡接口的详细细节。为了大略出发点,我们可以用一段内存区域来表示屏幕,内存的一个 bit 就代表了屏幕中的一个像素。比如在屏幕中的 (x,y) 坐标绘制一个像素,可以用 memory[x + y rowSize] = 1 来表示。从屏幕左上角开始,列是从左至右开始计数,行是从上至下开始计数。因此屏幕最左上角的坐标是 (0,0)。
为了大略起见,我们用 1 bit 来表示屏幕的一个像素,0 代表白色,1 代表玄色。屏幕每一行的长度用变量 rowSzie 表示,每一列的高度用 colSize 表示。
绘制线条
如果我们要在打算机上绘制一条直线,那么只要知道打算机的出发点坐标 (x1,y1) 和终点坐标 (x2,y2) 就可以了。
然后根据 memory[x + y rowSize] = 1 公式,将 (x1,y1) 至 (x2,y2) 之间对应的内存区域置为 1,这样就画出来了一条直线。
绘制文本为了在屏幕上显示文本,首先必须将物理上基于像素点的屏幕,在逻辑上以字符为单位划分成多少区域,每个区域能输出单个完全的字符。假设有一个 256 行 512 列的屏幕,如果为每个字符分配一个 118 像素的网格,那么屏幕上统共能显示 23 行,每行 64 个字符(还有 3 行像素没利用)。
有了这些条件条件后,我们现在打算在屏幕上画一个 A:
上图的 A 在内存区域中用 118 像素的网格表示。为了在内存区域中绘制它,我们可以用一个二维数组来表示它:
const charA = [ [0, 0, 1, 1, 0, 0, 0, 0], // 按从左至右的顺序来读取 bit,转换成十进制数字便是 12 [0, 1, 1, 1, 1, 0, 0, 0], // 30 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 1, 1, 1, 1, 0, 0], // 63 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [1, 1, 0, 0, 1, 1, 0, 0], // 51 [0, 0, 0, 0, 0, 0, 0, 0], // 0 [0, 0, 0, 0, 0, 0, 0, 0], // 0]
上面二维数组的第一项,代表了第一行内存区域每个 bit 的取值。一共 11 行,画出了一个字母 A。
如果我们为 26 个字母都建一个映射表,按 ascii 的编码来排序,那么 charsMap[65] 就代表字符 A,当用户在键盘上按下 A 键时,就把 charsMap[65] 对应的数据输出到内存区域上,这样屏幕上就显示了一个字符 A。
绘制布局树科普完关于绘制屏幕的根本知识后,我们现在正式开始绘制布局树(为了方便,我们利用 node-canvas[6] 库)。
首先要遍历全体布局树,然后逐个节点进行绘制:
function renderLayoutBox(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox) { renderBackground(layoutBox, ctx) renderBorder(layoutBox, ctx) renderText(layoutBox, ctx, parent) for (const child of layoutBox.children) { renderLayoutBox(child, ctx, layoutBox) }}
这个函数对每个节点依次绘制背景色、边框、文本,然后再递归绘制所有子节点。
默认情形下,HTML 元素按照它们涌现的顺序进行绘制。如果两个元素重叠,则后一个元素将绘制在前一个元素之上。这种排序反响在我们的布局树中,它将按照元素在 DOM 树中涌现的顺序绘制元素。
绘制背景色function renderBackground(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D) { const { width, height, x, y } = layoutBox.dimensions.borderBox() ctx.fillStyle = getStyleValue(layoutBox, 'background') ctx.fillRect(x, y, width, height)}
首先拿到布局节点的位置、尺寸信息,以 x,y 作为出发点,绘制矩形区域。并且以 CSS 属性 background 的值作为背景色进行添补。
绘制边框function renderBorder(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D) { const { width, height, x, y } = layoutBox.dimensions.borderBox() const { left, top, right, bottom } = layoutBox.dimensions.border const borderColor = getStyleValue(layoutBox, 'border-color') if (!borderColor) return ctx.fillStyle = borderColor // left ctx.fillRect(x, y, left, height) // top ctx.fillRect(x, y, width, top) // right ctx.fillRect(x + width - right, y, right, height) // bottom ctx.fillRect(x, y + height - bottom, width, bottom)}
绘制边框,实在我们绘制的是四个矩形,每一个矩形便是一条边框。
绘制文本function renderText(layoutBox: LayoutBox, ctx: CanvasRenderingContext2D, parent?: LayoutBox) { if (layoutBox.styleNode?.node.nodeType === NodeType.Text) { // get AnonymousBlock x y const { x = 0, y = 0, width } = parent?.dimensions.content || {} const styles = layoutBox.styleNode?.values || {} const fontSize = styles['font-size'] || '14px' const fontFamily = styles['font-family'] || 'serif' const fontWeight = styles['font-weight'] || 'normal' const fontStyle = styles['font-style'] || 'normal' ctx.fillStyle = styles.color ctx.font = `${fontStyle} ${fontWeight} ${fontSize} ${fontFamily}` ctx.fillText(layoutBox.styleNode?.node.nodeValue, x, y + parseInt(fontSize), width) }}
通过 canvas 的 fillText() 方法,我们可以很方便的绘制带有字体风格、大小、颜色的文本。
输出图片绘制完成后,我们可以借助 canvas 的 API 输出图片。下面用一个大略的示例来演示一下:
<html> <body id=" body " data-index="1" style="color: red; background: yellow;"> <div> <div class="lightblue test">test1!</div> <div class="lightblue test"> <div class="foo">foo</div> </div> </div> </body></html>
{ display: block;}div { font-size: 14px; width: 400px; background: #fff; margin-bottom: 20px; display: block; background: lightblue;}.lightblue { font-size: 16px; display: block; width: 200px; height: 200px; background: blue; border-color: green; border: 10px;}.foo { width: 100px; height: 100px; background: red; color: yellow; margin-left: 50px;}body { display: block; font-size: 88px; color: #000;}
上面这段 HTML、CSS 代码经由渲染引擎程序解析后天生的图片如下:
总结
至此,这个玩具版的渲染引擎就完成了。虽然这个玩具并没有什么用,但如果能通过实现它来理解真实的渲染引擎是如何运作的,从这个角度来看,它还是“有用”的。
参考资料•Let's build a browser engine![7]
•robinson[8]
•渲染页面:浏览器的事情事理[9]
•关键渲染路径[10]
•打算机系统要素[11]
References[1] 渲染事理: https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work#%E6%B8%B2%E6%9F%93[2] robinson: https://github.com/mbrubeck/robinson[3] 从零开始实现一个玩具版浏览器渲染引擎: https://github.com/woai3c/tiny-rendering-engine[4] 外边距折叠: https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_box_model/Mastering_margin_collapsing[5] canvas: https://github.com/Automattic/node-canvas[6] node-canvas: https://github.com/Automattic/node-canvas[7] Let's build a browser engine!: https://limpet.net/mbrubeck/2014/08/08/toy-layout-engine-1.html[8] robinson: https://github.com/mbrubeck/robinson[9] 渲染页面:浏览器的事情事理: https://developer.mozilla.org/zh-CN/docs/Web/Performance/How_browsers_work[10] 关键渲染路径: https://developer.mozilla.org/zh-CN/docs/Web/Performance/Critical_rendering_path[11] 打算机系统要素: https://book.douban.com/subject/1998341/