美文网首页
DOM树是如何形成的?

DOM树是如何形成的?

作者: elle0903 | 来源:发表于2023-02-05 15:45 被阅读0次

前言

编写一段HTML相当简单,让它运行起来也不复杂,新建一个扩展名为html的文件,敲进去一段差不多的内容,它就能够在浏览器里面运行起来,把这个文件放到远程的服务器上,你和你的小伙伴们,就都能够访问到它。

那么问题来了。

从你编写的内容,到一个可展示的页面,这中间到底经历一个怎样的过程?

下面将尝试来回答这个问题。

概述

当你在浏览器的地址栏内键入一串地址,并点击回车,浏览器会在你看不见的地方,悄咪咪进行如下操作。

  1. 找到下载该HTML文件
  2. 解析展示该HTML文件(所描述的内容)

找到涉及DNS解析,下载又涉及HTTP请求,这部分内容跟文章标题无关,咱们先略过不谈(多年之前,博主曾呕心吐血写过一篇文章,详细描述该过程,感兴趣的朋友可以点击这里去查看)。

今天这里,我们就只聊聊第二点,解析并展示该html文件的具体过程。

总的来说,HTML文件的解析和展示,主要包括如下五个过程:

  1. 分析HTML元素,并构建DOM树。
  2. 分析CSS样式,并生成样式表。
  3. 将DOM树和样式表关联起来,构建一棵Render树。
  4. 基于Render树布局,也就是根据树节点的描述,确定每一个节点应该在屏幕上出现的具体位置。
  5. 调用每个节点的paint方法,绘制它们自身。
看图一目了然

接下来,我们将逐一深入,去了解这中间的每一个过程。

第一步:分析并构建DOM树

通过网络请求,我们获得了HTML文件的内容,它是一个字符串,将这个字符串比作食材的原材料,我们接下来要做的事情就是烹饪。
那么问题来了,一只拔了毛清洗干净的小公鸡,是如何变成一盘色泽浓郁、香气诱人的红烧鸡块的呢?

第 1 步:将鸡切块

现在,我们手上有只宰杀完成,并清洗干净的小公鸡,烹饪之前,我们需要先将它切成方便烹饪的鸡块,这个过程对应到渲染引擎内部,就叫做tokenize
这个过程顾名思义,就是将一整串不太容易解读的HTML字符串,分割成一个个方便解析的小小片段。

就跟剁🐔一样。嘎嘎。

下面是示例。

假设我们新建了一个HTML文件,它拥有如下内容:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
</head>
<body>
    <div>
        <h1 class="title">demo</h1>
        <input value="hello">
    </div>
</body>
</html>

这是一个相当简单的HTML文档,它的头部有一个定义了文档字符集的meta标签,页面上则会展示一个内容为demo的大号标题,和一个默认值为hello的输入框。

但这玩意儿我们能看懂,构建DOM树的处理函数看不懂,为了让处理函数能看懂,这段字符串会被分割,变成如下,可爱又可亲的模样。

[
{"tagName":"html","type":"DOCTYPE","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n"},
{"tagName":"html","type":"StartTag","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n"},
{"tagName":"head","type":"StartTag","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n"},
{"tagName":"meta","type":"StartTag","attr":"charset=utf-8","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n"},
{"tagName":"head","type":"EndTag","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n"},
{"tagName":"body","type":"StartTag","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n    "},
{"tagName":"div","type":"StartTag","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n        "},
{"tagName":"h1","type":"StartTag","attr":"class=title","text":""},
{"tagName":"","type":"Character","attr":"","text":"demo"},
{"tagName":"h1","type":"EndTag","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n        "},
{"tagName":"input","type":"StartTag","attr":"value=hello","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n    "},
{"tagName":"div","type":"EndTag","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"     \n"},
{"tagName":"body","type":"EndTag","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n"},
{"tagName":"html","type":"EndTag","attr":"","text":""},
{"tagName":"","type":"Character","attr":"","text":"\n"},
{"tagName":"","type":"EndOfFile","attr":"","text":""}
]

数据很长,所表述的内容却相当简单。

我们来解读一下每个键所代表的含义。

tagName指我们在HTML文件内编写的元素标签。
attr是我们为元素添加的属性。
text则表示元素内部的文本内容。

至于type,Chrome内部总共定义了七种标签类型,大家可以根据字面含义去理解。

enum TokenType {
    Uninitialized,
    DOCTYPE,
    StartTag,
    EndTag,
    Comment,
    Character,
    EndOfFile,
};

至此,我们有了切成合适大小,方便烹饪的鸡块,接下来就该是下锅爆炒,以及,酌情添加调味料了。

第 2 步:爆炒鸡块

这个过程说简单也简单,说复杂也复杂,简单来说,开火,撒调料,炒熟就完事儿了,在这儿用来炒鸡的锅,我们称之为Parser。

它将首先构造一个栈,之后便简单粗暴地对已有的数据,——也就是我们上一步解析HTML字符串获得的数据,进行一次遍历。在每一次遍历中,它又会根据不同的type类型,做不同的事情。
这个过程结束之后,我们就将得到一盘色泽浓郁、香气诱人的红烧鸡,啊不对,是DOM树。

让我们来了解一下每个类型的处理方法。

switch (token->GetType()) {
    case HTMLToken::kUninitialized:
    case HTMLToken::kCharacter:
      NOTREACHED();
      break;
    case HTMLToken::DOCTYPE:
      ProcessDoctypeToken(token);
      break;
    case HTMLToken::kStartTag:
      ProcessStartTag(token);
      break;
    case HTMLToken::kEndTag:
      ProcessEndTag(token);
      break;
    case HTMLToken::kComment:
      ProcessComment(token);
      break;
    case HTMLToken::kEndOfFile:
      ProcessEndOfFile(token);
      break;
}

DOCTYPE
typeDOCTYPE的数据标记了整个文档的类型,它的可取值有怪异模式、有限怪异模式,和标准模式(具体定义方式可自行搜索,在这儿不展开说),如果没有标明,将默认采用怪异模式。

这将影响接下来的渲染,第四步,根据Render树布局时,我们需要根据节点的样式、属性,来计算它在屏幕上的位置。

红烧鸡块和酱汁鸡块虽然都是鸡块,成品的口味却会或大或小地,在一些地方,表现出不同。

怪异模式下的input和textarea默认盒模型是border-box,标准模式下的文档高度是实际内容的高度。
怪异模式下的文档高度是窗口可视域的高度,而在有限怪异模式下,div里面的图片下方不会留空白。

StartTag 开标签
遇到typeStartTag的数据时,Parser将新建一个实例,根据具体tagName的不同,这个实例有可能来自HTMLDivElement、HTMLParagraphElement,也有可能来自HTMLInputElement或其他。

除了创建实例,这个过程还会标记实例的父节点、子节点、第一个子节点、最后一个子节点、上一个节点、下一个节点……等等,来构建节点之间的关联,继而为获得一棵完整的DOM树添砖加瓦。

EndTag 闭标签
之前我们提到过一个栈,遇到StartTag,Parser会向栈内压入一条数据,遇到EndTag,Parser则会开始弹出数据,它会一直弹一直弹,直到遇到和当前数据所标识一致的标签为止。

标签的入栈和出栈

这个过程包含一些容错处理,比如漏写了闭标签、比如多出来的开标签怎么处理,也不展开讲了。

除此之外
Comment指HTML文档中的一段注释,它是不需要被展示的,我们不去管它,Character则是一个单纯的文本节点。

至此,我们就有了一棵完整的,可以随时使用的DOM树了。

说了那么多,不如图片简洁明了,大家看一眼图片,我们进入下个阶段。

DOM树构建过程

第二步:分析并生成样式表

DOM是Document Object Model的缩写,由此,样式表的缩写就是CSSOM(CSS Object Model)。

那么,我们是如何从无到有生成一个样式表的呢?

首先我们得先了解一下,HTML引入CSS的几种方式。

  1. 内联,通过<style>标签引入。
  2. 链接,通过<link>标签引入。
  3. 内嵌,通过style属性直接给节点添加。
  4. 通过@import,在css文件中引入。

通过解析HTML、发送HTTP请求,我们获得了一串描述文档样式的CSS字符串,有了这串字符串,我们就可以开始构建CSSOM了。

这个过程跟构建DOM有些类似,但又不完全相同,相同之处在于,它们都将经历tokenize和遍历,不同之处在于,CSS字符串中,并不包含节点之间父子关系,所以解析出来的内容,也跟DOM有所不同。

值得注意的一点,在计算节点样式时,我们需要考虑到一些规则。

  1. 继承属性
    CSS的某些属性具有继承性,当一个子元素没有指定属性值时,则需取最近父元素该属性的计算值。
    这些属性包括:font-sizefont-familyfont-stylecolortext-alginline-heightvisiblilitycursorlist-style等。

  2. 选择器的优先级
    介绍选择器的优先级前,我们需要先简单了解一下CSS的选择器,目前来说,CSS主要支持如下七种基础选择器。

    • ID 选择器, 如 #id{}
    • 类选择器, 如 .class{}
    • 属性选择器, 如 a[href="jianshu.com"]{}
    • 伪类选择器, 如 :hover{}
    • 伪元素选择器, 如 ::before{}
    • 标签选择器, 如 span{}
    • 通配选择器, 如 *{}

    规则一,不同基础选择器命中同一个节点时:ID 选择器 > 类选择器 = 属性选择器 = 伪类选择器 > 标签选择器 = 伪元素选择器 > 通配选择器。还有一个内联样式,它的优先级最高。

    更复杂的选择器,由这七种组成。

    • 后代选择符: .father .child{}
    • 子选择符: .father > .child{}
    • 相邻选择符: .bro1 + .bro2{}

    规则二,不同复杂选择器命中同一个节点时,需先计算选择器中 ID 选择器的个数(a),类选择器、属性选择器、伪类选择器的个数之和(b),标签选择器、伪元素选择器的个数之和(c)。按 a、b、c 的顺序依次比较大小,大的则优先级高,相等则比较下一个。若最后两个的选择符中 a、b、c 都相等,则按照"就近原则"来判断。

    规则三!important优先级最高。

CSSOM 解析示意

第三步:关联并构建Render树

有了DOM和CSSOM之后,我们就可以开始构建Render树了。

从DOM树的根节点开始,遍历每一个可见节点,——注意,这里只遍历可见的节点,不可见的节点会被忽略,从CSSOM树中找到对应节点的样式描述,从两棵树,合并成一棵。

第四步:基于Render树布局

有了Render树之后,我们就有了节点的样式信息,节点之间的从属关系,接下来,我们可以为接下来的绘制做准备了:确定每个节点的大小,以及它将在屏幕上展示的坐标。

第五步:绘制

有了大小、有个坐标、有样式、也有从属关系,可以放心开始绘制了,从根节点开始,逐个调用节点实例的paint函数,将其绘制在屏幕上。

至此,我们在HTML文档中描述的页面,将完完整整地展示在了屏幕上。

跳跃跃、转圈圈、撒花花~🌹🌹🌹

写在最后面

大体流程虽然清楚了,一些微小的问题,我们还是需要认真讨论一下。

问题一:JS会阻塞 DOM 树的解析吗?

答案是YES,当然会。

详细解析这个答案之前,我们需要先来了解一下,HTML引入JS的两种常用方式:

  1. 直接在script标签内部写JS代码。
  2. 通过设置script标签的src值,引入外部的JS文件。
<!--方式一,直接在script标签内部写JS代码-->
<script>console.log('JavaScript工程师天下第一!');</script>
<!--方式二,通过设置script标签的src值,引入外部的JS文件-->
<script type="text/javascript" src='outer.js'></script>

对于第一种方式,解析器会选择暂停对DOM的解析,等待JS代码的执行,等到执行结束,它再继续往下进行。这样做是因为JS代码拥有操控DOM的能力,两者同步进行,很可能导致解析出现问题。

对于第二种方式,解析器同样对暂停对DOM的解析,先下载该JS文件,再执行。

第二种方式中有可能出现子情况:deferasync

看下面这张图。

普通脚本,defer脚本,async脚本对DOM Parser的影响

通过图片,我们不难发现,普通脚步完全是同步的,它的下载和执行,都会阻塞解析器的运行。
async异步下载,但它会在下载后立刻执行,它的执行同样会阻塞解析器的运行。
defer脚本异步下载,解析完成之后,再开始执行。

问题二:CSS会阻塞 DOM 树的解析吗?

答案是NO,下载和解析CSS文件内容,并不会影响DOM树的构建,但它会阻塞DOM的渲染。

大家可以回忆一下上面的几个过程:构建DOM树->分析样式表->构建Render树->布局->绘制。

大家再来回忆一下,Render树构建的数据源是什么?答案是两个,DOM树和CSS样式表。

那么在欠缺了CSSOM的情况下,Render树还能够正常构建吗?
答案当然是否定的。

在这儿渲染引擎其实还有另外一个选择:不等待CSSOM,等DOM构建好了,它便直接往下进行,等到CSSOM构建好了,它再重新刷新页面。

这样做的好处很明显,用户可以更快地看见页面内容,但它的缺陷也同样不隐晦。

渲染是一个相对比较重的过程,每一次渲染都会消耗相当的CPU资源,除此之外,只包含默认样式页面,对用户来说,也实在太不友好,一个丑了吧唧的页面甚至会赶跑用户,所以综合之后,渲染引擎选择了当前的行为模式。

问题三:什么是重绘(repaint)和回流(reflow)?以及,如何避免它们?

先来回答第一个问题,什么是重绘(repaint)?什么是回流(reflow)?

在回答这个问题之前,我们需要先知道,什么样的页面不会涉及到这两个概念。
答案是,完全静止的页面。

鼠标移上去,它没有任何反应;鼠标滚轮尝试滚动,它也一动不动。这样的页面不需要重绘(repaint),也不需要回流(reflow)。

这样一来,答案就很清楚了,重绘(repaint)和回流(reflow)是在页面内容发生改变时,会涉及到的概念。

重绘(repaint)也就是重新绘制,它在页面元素的颜色、背景等外观,发生改变时触发。

相对而言,回流(reflow)则要更加重一些,它在元素的大小、位置发生改变时触发,这也意味着,新增、删除节点,缩放、滚动页面,以及动画等等操作,都会触发回流(reflow),除此之外,尝试获取某些属性的值时,回流(reflow)也会被触发。

考虑到文档流内的节点定位,向来是牵一发而动全身,所以单一节点的回流一旦触发,它所带来的影响就绝不是单一的。它会要求渲染引擎去重新计算所有节点的位置,继而根据这些新的位置、新的大小,去重新布局页面,去重新绘制页面。

概括地说,重绘(repaint)未必导致回流(reflow),但回流(reflow)一定会导致重绘(repaint)。

有了这两个概念,我们轻易就能看出,这两者对浏览器性能来说,都存在不同程度的负面影响,想要提高浏览器性能、优化页面体验,就得尽量避免这两者的发生。

简单来说,遵循三个原则:

  1. 用批量操作替代单一操作;
  2. 用小范围操作替代大范围操作;
  3. 降低计算量;

根本这三个基本原则,我们可以梳理出如下,相对具体的优化建议。

  1. 需要批量改变样式时,使用预先定义的className,替代逐条添加的内联样式。
  2. 在脱离文档的情况下修改节点,比如使用documentFragment对象在内存里操作节点;比如将节点的display属性设为none,然后再进行复杂修改;再比如将想要修改的节点clone下来,修改完成之后,再进行替换。
  3. 避免使用复杂多嵌套布局,比如table。
  4. 尽可能地修改层级比较低的节点。
  5. 具有复杂动画的元素,给它绝对定位,让它独立出文档流。
  6. 不要在循环体内部,获取节点属性值。
  7. 避免在CSS中使用运算式。
  8. 等等。
问题四:渲染引擎和渲染进程

这一点跟文章主题其实关系不大,但因为忽然想到了,就顺带提一下。

首先,我们需要知道的一点,浏览器是多进程架构的,除了网络进程、GPU进程、插件进程,它还包含一个渲染进程。页面的渲染、JS的执行、事件的调度都在这里面进行。

渲染进程包含很多个线程,其中GUI渲染线程就负责渲染页面,之前我们提到的渲染引擎,就运行在这个线程内,之前提到的DOM树构建、样式表分析、Render树构建、布局和绘制,也都发生在这里面。

除此之外,运行JS引擎的JS引擎线程,也包含在渲染进程内,解析JS脚本、运行JS脚本,也都在这里面发生。我们说JS是单线程的,指代的,也就是这里的JS引擎线程

需要注意的一点,GUI渲染线程JS引擎线程是互斥的,当JS引擎线程在运行代码,页面的构建和渲染就会被暂停,反之亦然。

除了以上两位重量级嘉宾,渲染进程内还包含的线程有:事件触发线程、定时触发器线程、异步http请求线程、合成线程、IO线程。

浏览器线程
其它问题:待定。

好嘞。

以上就是本文的全部内容啦,将近一万字,差点写死我,下面是编写文章时参考的内容。

感谢大神们的梳理和总结,也感谢看文看到这里的大小可爱们。

比一个心:(′▽`ʃ♡ƪ)

咱们下篇文,有缘再见~

参考文章

相关文章

  • DOM树是如何形成的?

    前言 编写一段HTML相当简单,让它运行起来也不复杂,新建一个扩展名为html的文件,敲进去一段差不多的内容,它就...

  • 浏览器中的页面

    DOM树:JavaScript是如何影响DOM树构建的? 什么是 DOM从网络传给渲染引擎的 HTML 文件字节流...

  • 浅谈前端组合模式

    说说前端最简单的组合模式 HTML文档的DOM结构就是天生的树形结构,最基本的元素组成DOM树,最终形成DOM文档...

  • 2021-09-30

    DOM DOM的作用就是操作网页的内容,这个DOM对象放在一起就形成了DOM树,体现了标签与标签之间的逻辑关系。 ...

  • 重绘和重排

    DOM树:表示页面结构。渲染树:表示DOM节点如何显示。 定义 当DOM元素影响了元素的几何属性(例如宽和高),浏...

  • A27-DOM API

    DOM MDN 阮一峰什么是DOM,DOM最重要的就是要明白它的概念,否则遇到问题也不知道如何下手大家都说DOM树...

  • 第十九节: JavsScript对象类型检测,克隆与JS异步加载

    克隆对象 检测类型 一. DOM树解析 什么是DOM树 ==>DOM节点按照树型结构排列 1. DOM树生成的原则...

  • 简单几句话,知道什么是回流重绘、vue虚拟dom、diff算法和

    1.什么是vue虚拟dom。先知道什么是dom树。众所周知,一个页面形成的流程。(顺便聊一下回流和重绘)(1) 解...

  • DOM&SAX解析XML

    DOM&SAX DOM解析 特点 一次性 将整个XML文档加载到内存中,在内存中形成一个DOM树,那么操作(CR...

  • 你不知道的 DOMContentLoaded

    我的理解:1)正常同步的嵌套在html中的script会阻塞html的解析和加载,也就是形成dom树的过程,Dom...

网友评论

      本文标题:DOM树是如何形成的?

      本文链接:https://www.haomeiwen.com/subject/eexzhdtx.html