带你看看从输入URL到页面显示背后的故事

小狮子前端

共 24492字,需浏览 49分钟

 ·

2021-02-27 15:25

说一说从在浏览器输入URL到整个页面显示这个过程经历了什么?这个问题应该是目前大厂必问的一个面试题,是一个非常综合性的问题,可以考查我们对如HTTP协议的了解,浏览器相关知识的基础如,对html,css文件解析,浏览器如何渲染等,所以如果我们可以把这一题回答的非常清楚一定是非常加分的,并且作为一个前端,如果可以很深入的了解这些知识对自己的专业能力提升也时常非常大的,我们可以直接应用到自己的工作中,如之前文章中说的性能优化等,这是迈向高级前端非常重要的一步。

本文会介绍浏览器从发送请求开始,到接受到服务器响应,到解析文件,之后调用GPU来渲染到界面上的整个过程。过程中如遇到像HTTP协议需要掌握的知识,TCP协议等我都会提出,可能有些不会讲的那么深,但作为一个前端我认为都需要了解的知识我都会提出来,如果你有兴趣还是希望你可以查查其他的文章,尽力把他们学懂。本文我会尽可能的查找多的文章,来让确保每一个知识点的正确度

本文的特点有两点:一定要好懂,一定要深入。好懂我希望这个不是在我的角度来说一个知识是怎么样的,我希望的是站在一个读者的角度,可以用更多常见的例子来说明,但又不丧失深度,深入是我之前听winter老师说的一句话,如果我们想再前端,其实其他工作也是一样的,如果我们想走的更远,我们一定要更深入的学习,必挖原理,必学底层,所以这篇文章也是这样,有些部分我直接会涉及到chrome的源码,来看其解析过程是怎么样的,我将我知道的尽可能深的知识说出来,还是看读者能理解到什么程度,取法乎上,仅得其中

最后说一点学习的建议,希望大家在看文章的时候可以认真去理解,怎么样判断自己是否理解了呢?可以自己在心里对自己说出来,如果很熟的话可以写一篇文章来总结,然后遇到自己不熟的名词一定要查资料,一定要查,一定要查,不要觉得自己可能知道这个东西,其实其背后代表的知识也许是很深的。现在开始正题。这篇文章主要会按照顺序分为三大部分:网络部分浏览器解析部分浏览器渲染部分



网络部分:


我们想要访问腾讯的官网,我们需要知道这个网页的URL地址,首先我们会在chrome浏览器的地址输入www.tencent.com这个域名。

之后浏览器会帮我们自动补全一些内容,如http协议还是https协议。这里有两个地方需要注意:一个是这里的想访问的默认是80端口,访问的是这个域名下的根路径“/”。所以首先浏览器会根据我们请求的URL地址生成请求行。有兴趣的同学还可以看一下到底什么是URLURLURI是什么关系等。


1.生成请求行:

请求行由三部分组成分别是:请求方式请求路径协议版本号。这里如果要再细一点的话,我们可以深入的去看看还有哪些HTTP请求方式,如我们常用的GET,POST,还有没用过的HEAD,PUT,OPTIONS,CONNECT,DELETE,TRACE等他们的作用。请求路径默认是根路径"/",协议版本号有HTTP/1.0HTTP/1.1,还有最新的HTTP/2.0。

GET / HTTP/1.1

2.查找强缓存:

查找强缓存是非常重要的知识点,引申出来的问题如请你说说你对浏览器缓存的认识

这里就会涉及到强缓存与协商缓存,强缓存就是浏览器保存之前服务器发送过来的数据,保存我们访问的历史记录下次再想访问这个网页直接使用缓存就好了,但这个数据会有过期时间,如果过期了浏览器还是要再向服务器发送请求来获取新的数据。

所以当缓存的数据过期了我们就需要协商缓存了,我们需要问一下服务器看看服务器的数据更新了没,如果服务器的数据也没有更新那么就会给我们返回一个304代表,我们我们可以继续使用之前的缓存。如果想更细节的了解强缓存与协商缓存的知识,可以去我的语雀中看,我在文章的后面会写地址。

好我们接着说,如果发现强缓存过期了我们就需要进入下一步了。


3.DNS域名解析:

当发现我们的强缓存过期,或者浏览器里还没有缓存的时候,我们首先就需要将我们输入的域名转换为对应的IP地址了,因为服务器要和客户端是通过IP地址来确认彼此的身份的。那为什么不能通过域名来确认彼此的身份呢?因为对于我们用户来说我们更容易记住一段域名,而对于计算机来说他们更适合识别IP地址。

根据域名来查找IP地址的过程就是域名解析,DNS系统就可以理解为存储许多IP地址与域名的哈希表,里面存储的便是域名与IP地址的映射,具体解析过程大概是这样:

首先会去我们的系统中一个叫hosts的文件中去查看有没有对应的IP地址,这hosts文件是在我们的系统中可以找到的,我们可以自己添加和修改的,自己写一些域名和IP地址进去。一般里面默认会写localhosts对应127.0.0.1等们,一般情况下我们不会去添加。如果文件中没有找到我们就需要去本地DNS域名服务器发送请求(本地DNS域名服务器一般是由电信公司等保管),来获取相应的IP地址,如果本地没有找到而且本地服务器也没有之前的缓存,那么就要向更下层的服务器去查找了,后面还要根服务器,域服务器,这些不是我们的重点,有兴趣的同学可以看一下。

在这里我们只需要知道我们输入的URL地址会通过DNS域名系统将域名转化为对应的IP地址便好,还需要知道的是浏览器会对我们之前解析过的域名进行缓存,下次使用时如果这个域名之前解析过就可以直接使用了就不需要再进行解析了。


4.建立TCP连接

这里会涉及到TCP三次握手以及四次挥手由于时间原因,这一部分我就先不细讲了大家如果想深入了解可以看看TCP协议灵魂之问,巩固你的网路底层基础。


5.发送HTTP请求

等建立好TCP连接后我们便需要发送HTTP请求了,我们知道对GET请求我们会发送一个数据包,也就是请求报文头部(包含请求行与请求头),而对于POST请求会发送两个数据包,先发送请求首部,等待服务器返回100状态码再发送请求实体body

请求行之前已经建立好了,接下来我们来看一个请求请求头

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3Accept-Encoding: gzip, deflate, brAccept-Language: zh-CN,zh;q=0.9Cache-Control: no-cacheConnection: keep-aliveCookie: /* 省略cookie信息 */Host: www.baidu.comPragma: no-cacheUpgrade-Insecure-Requests: 1User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1
  • Accept首部字段:

大致可分为四个大类,数据类型、压缩方式、语言类型、字符集,客户端请求报文对应的首部字段为:Accept,Accept-Encoding、Accept-Language,Accept,服务器响应对应的首部字段为:Content-Type、Content-Encoding、Content-Language、Content-Charset。

  • Cache-Control:

这个首部字段主要用于强缓存,属于通用首部字段,其值主要用来告诉通信双方,什么数据可以缓存,缓存完多久过期,缓存服务器可以缓存吗?等等

  • Cookie:

这个大家应该都很熟悉了,就不多做介绍了

  • User-Agent:

这个做过移动端适配的同学应该清楚,值是客户端的一些信息,如操作系统等


请求与响应报文的首部字段有很多很多,可能会涉及强缓存,协商缓存,Cookie,HTTP如何处理定长不定长数等,大家有时间还是要好好了解了解。


5.接收HTTP响应:

服务器在接收到客户端的请求之后便会向客户端发送响应啦,响应报文与请求报文一样都是由头部与实体组成,关于HTTP更多的知识这里就不介绍了,这篇文章主要将重点放在浏览器接收到服务器发送过来的数据之后如何对报文进行解析,如何渲染到页面上的过程想了解更多的同学可以去看我语雀的文章,或者我会在文章后放几篇比较优秀的文章大家可以去看。


6.总结

第一部分重点需要掌握的知识是:

  • 浏览器的缓存,什么是强缓存,什么是协商缓存,缓存的存放地点

  • TCP连接,如何保证数据高效可靠的传输,三次握手,四次挥手

  • HTTP协议,报文结构,首部字段内容等



浏览器解析:

首先我们可以经常听到说JS是一种单线程的语言,但是我们也知道JS中还是有异步任务的,如异步宏任务,异步微任务,而这些任务是怎么执行的呢?其实本质上是浏览器在接收到服务器响应过来的文本文件后,如HTML文件,只会分配一个线程来进行解析执行,如果遇见文件里像<link href="xxxxx">,<script src="xxxxx">这样还需要获取其他文件的依赖时,我们解析执行代码的主线程并不会停止,而是浏览器会调用一个新的线程去向服务器发送请求,来获取文件,客户端接收到响应后的文件后会放在一个任务队列里,等主线程的任务执行完之后我们再处理任务队列里的东西。从始至终解析执行代码的线程只有一个,这就是单线程的本质原因。(如果有人问这里说的是执行的HTML文件啊和JS有什么关系?😅😅HTML文件中不是也有JS要执行嘛。。。)

说到这里我们来看一下我们对腾讯域名发送请求后到底给我们返回了什么东西?我们可以在我们的控制台Sources中看到。看到的是漂亮的版面以及图片吗?...不是,是一堆的字符串。。。。。。

我们可以看一个比较简单的HTML文件:

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

其是由许多的字符串组成,根据编译原理,对于任意一种编程语言在计算机编译其之前都是一堆的字符串,编译器要做的就是根据这些字符串生成对应的语句然后根据语句生成逻辑,根据逻辑 再执行对应的任务。这个过程离不开三个阶段:词法分析,语法分析,语意分析


  • 词法分析:

逐句的读取每个字符,根据构词规则生成对应的单词或符号

  • 语法分析:

在词法分析的基础上,根据生成的单词或符号,使用逻辑处理,得到对应的语句,函数,表达式等,这一步会判断我们写的代码在结构上是否正确。如我们使用if语句没有写括号呀等

  • 语义分析:

语义分析就是判断我们写的代码是否符合逻辑,如我们的变量是const声明的,结果我们要修改变量值,在这一步就会报错


说这些是为了方便理解接下来,根据HTML字符生成Tokens序列,以及生成DOM树的过程,其是就是词法分析,与语法分析的过程。


生成Takens序列(DOM):

在服务器返回数据以后便需要使用Chrome的Blink内核进行解析了,我们得到的是一个bytes字符串,将这个字符串作为一个参数传递给一个方法,这个方法便可以对字符串进行就解析了,那么如何进行解析呢?解析之后是什么样呢?

我们可以观察一下我们的HTML标签,其都是以<来开头,以>来结束,有些标签是单标签,有些是双标签,遇见双标签的话,有一个开标签就一定要有一个闭标签。所以我们的解析器就根据这些尖括号,来对我们的标签名,标签的属性,标签里面的文本这些进行标记并生成序列来记录他们,我们可以看一个简单的例子:

<p>hahaha</p>

如何解析:如我们看见一个P标签,当我们遇见第一个<括号的时候我们知道是标签名了,当我们遇见第二个>括号时我们知道标签名结束了,之后便是标签的文本了,但要注意的是目前还只是对每一个标签进行标记,我们得到的只是一些单词字符,没有任何逻辑关系。

解析结果:那么在Chrome浏览器中生成的Tokens序列究竟长啥样呢?我们可以看一下对于上面的HTML文件它的Tokens是什么样的:

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,标签的类型type,标签的属性attr,标签的文本text。但这里要注意的是标签之间的文本,空格或换行也会被当做是一个标签,有了这个序列之后我们就可以正式构建DOM树啦。


构建DOM树:

首先我先从大体上说一下什么是DOM树,树大家应该都知道是一种分层数据结构,我们最常见的应该是二叉树,其每一个节点最多有两个子节点,而DOM树是一种多叉树,而这课树的每一个节点就是DOM节点。

接下来大家可以思考一下对于每一个DOM节点来说最重要的应该是什么?我们可以想想我们是不是需要使用JS来操作DOM,使用parentNode来读取他的父节点,使用childern来获取它的子节点,甚至我们还需要获取它的兄弟节点,获取它的第一个子节点,获取其的最后一个子节点,通过innerHTML获取它的内容。我们需要读取它的属性,我们需要修改它的属性,有些时候还要将其插入到其他的节点之中。这些所有的操作都是浏览器在内部使用C++语言封装的方法来,根据我们之前传入的Tokens序列做到的。大家可以想一想如果是你,你怎么样根据之前的序列来生成这样的一个DOM节点,如果你做到了。。。。你可以去写一个全新的浏览器内核,不同浏览器之间不同的内核本质上就是使用不同的方法来实现DOM节点的结构以及操作DOM节点的功能


说完了大体我们来看一下构建一个DOM节点的具体过程,我们首先来看一下DOM节点的结构:

 

Node是最顶层的父类,它有三个指针,两个指针分别指向它的前一个结点和后一个结点,一个指针指向它的父结点;

ContainerNode继承于Node,添加了两个指针,一个指向第一个子元素,另一个指向最后一个子元素;

Element又添加了获取dom结点属性、clientWidth、scrollTop等函数

HTMLElement又继续添加了Translate等控制,最后一级的子类HTMLParagraphElement只有一个创建的函数,但是它继承了所有父类的属性。

需要提到的是每个Node都组合了一个treeScope,这个treeScope记录了它属于哪个document(一个页面可能会嵌入iframe)。


我相信作为一个正常人,应该还是很难记住上面的内容的,所以总结一句话就是构建DOM最关键的步骤便是是建立起每个结点的父子兄弟关系,即上面提到的成员指针的指向


接下来首先我们要处理的第一条便是<DOCTYPE html>这个节点代表的token

tagName: html  |type: DOCTYPE   |attr:              |text: "

我们的Blink内核中会有一个Parser任务来会调用一个TreeBuilder方法,这个方法会对不同的标签类型进行不同的处理:

void HTMLTreeBuilder::processToken(AtomicHTMLToken* token) {  if (token->type() == HTMLToken::Character) {    processCharacter(token);    return;  }   switch (token->type()) {    case HTMLToken::DOCTYPE:      processDoctypeToken(token);      break;    case HTMLToken::StartTag:      processStartTag(token);      break;    case HTMLToken::EndTag:      processEndTag(token);      break;    //othercode  }}

对于DOCTYPE这个类型比较特殊要特别处理,具体过程就不说了,我们只需要知道的是,会创建一个DOCTYPE节点,并确定文件的类型,文件的类型会影响到后面的CSS解析工作。

两种怪异模式:

<!DOCType svg><!DOCType math>



接下来构建DOM树正式开始:

首先我们遇见的是开标签<html>,在这里会做如下几件事:

  • 创建一个html节点

  • 将其放入到一个任务队列Task里,进行存放,这里是通过一个方法存放到任务队列里

  • 将其压入到一个只存放开标签

  • 执行任务队列里的代码

具体源码如下对应的第二行,第三行,第四行,第五行:

void HTMLConstructionSite::insertHTMLHtmlStartTagBeforeHTML(AtomicHTMLToken* token) {  HTMLHtmlElement* element = HTMLHtmlElement::create(*m_document);  attachLater(m_attachmentRoot, element); // 这里有两个参数,一个是父节点,一个是当前节点  m_openElements.pushHTMLHtmlElement(HTMLStackItem::create(element, token));  executeQueuedTasks();}

我们可以看见要存入任务队列需要经过attachLater方法,其接收两个参数,一个是父节点也就是这里的m_attachmentRoot,另一个是刚刚创建好的当前节点。

那么m_attachmentRoot是什么呢?

HTMLConstructionSite::HTMLConstructionSite(    Document& document)    : m_document(&document),      m_attachmentRoot(document)) {}

之前在创建DOCTYPE这个节点的时候便会初始化HTMLConstructionSite我将其称之为HTML建立函数🤣,同时也就初始化了这个变量,而这个变量的值便是document,也就是我们DOM树的根节点,第一次执行时我们将这个变量作为html标签的父节点传入了进去。这里大家先留意一下,后面还会提到。。。。。


而对于这个html节点来说,Task任务队列里面要做的是便是确定这个节点的父子兄弟关系

那么这里为什么要使用一个任务队列先将节点存放起来然后再插入(建立父子兄弟关系)呢?因为有些节点并不是一下子就插入的,比如遇见一个link语句,比如在我们的form表单节点里又有一个表单节点form呢?这就有许多的变化了,并不是插入到DOM树里,而是有不同的操作。

我们可以看一下执行任务队列的具体过程,这里执行的是任务队列里的insert方法,有些标签可能是获取资源呀等(我真的看不懂):


这个还比较好理解,在插入里面它会先去检查父元素是否支持子元素,如果不支持,则直接返回,就像video标签不支持子元素。然后再去调具体的插入:

void ContainerNode::parserAppendChild(Node* newChild) {  if (!checkParserAcceptChild(*newChild))    return;    AdoptAndAppendChild()(*this, *newChild, nullptr);  }  notifyNodeInserted(*newChild, ChildrenChangeSourceParser);}

之后是这样:

void ContainerNode::appendChildCommon(Node& child) {  child.setParentOrShadowHostNode(this);  if (m_lastChild) {    child.setPreviousSibling(m_lastChild);    m_lastChild->setNextSibling(&child);  } else {    setFirstChild(&child);  }  setLastChild(&child);}

上面代码第二行,设置子元素的父结点,也就是会把html结点的父结点指向document,然后如果没有lastChild,会将这个子元素作为firstChild,由于上面已经有一个docype的子结点了,所以已经有lastChild了,因此会把这个子元素的previousSibling指向老的lastChild,老的lastChild的nexSibling指向它。最后倒数第二行再把子元素设置为当前ContainerNode(即document)的lastChild。这样就建立起了html结点的父子兄弟关系。


能看懂的兄弟们就看吧,看不懂的就理解这里会确定一个节点的父子兄弟关系。我们之前向attachLater方法传入了当前节点的父节点,与当前节点,所以无非就是让当前节点通过指针指向他的父节点罢了。

这是对于html标签来说的那么对于后面的<body>标签又是怎么处理呢?我们将其放入到一个只存放开标签的栈里的作用是什么呢?


我们接着看:

遇到<head>标签的token时,也是先创建一个head结点,然后再创建一个task,插到队列里面:

void HTMLConstructionSite::insertHTMLHeadElement(AtomicHTMLToken* token) {  m_head = HTMLStackItem::create(createHTMLElement(token), token);  attachLater(currentNode(), m_head->element());  m_openElements.pushHTMLHeadElement(m_head);}

这里我们会发现attachLater方法传入的参数不一样了,这个currentNode()是什么呢?

ContainerNode* currentNode() const {     return m_openElements.topNode(); }

根据这段代码,我们可以看到其是来自于m_openElements,且调用了他的topNode()方法,我们可以看看之前我们创建html节点时的代码,我们会创建一个节点,并且将其与其父节点作为参数传递给attachLater方法,之后我们还要将创建出的节点放入栈里面,所以这里的m_openElements.topNode()语句应该就是调用栈顶节点为父元素,也就是之前创建的<html>节点。

通过这个栈我们便可以很容易的得到当前节点的父元素,顶的元素便是当前创建出来节点的父节点,因为我们只会把开标签放入栈中,那么如果遇见闭标签呢?


如果遇见闭标签我们就会将栈顶的节点pop出来,直到遇见和当前标签一样的开标签。

m_tree.openElements()->popUntilPopped(token->name());

我们将我们的head节点pop出来之后,栈顶就是html元素了,这下如果再遇见body标签,那么html节点就是body节点的父节点了。

<!DOCTYPE html><html><head>    <meta charset="utf-8"></meta></head><body>    <div>        <p><b>hello</b></p>        <p>demo</p>    </div></body></html>

我们可以将pop与push的过程打印出来:

 push "HTML" m_stackDepth = 1 push "HEAD" m_stackDepth = 2 pop "HEAD" m_stackDepth = 1 push "BODY" m_stackDepth = 2 push "DIV" m_stackDepth = 3 push "P" m_stackDepth = 4 push "B" m_stackDepth = 5 pop "B" m_stackDepth = 4 pop "P" m_stackDepth = 3 push "P" m_stackDepth = 4 pop "P" m_stackDepth = 3 pop "DIV" m_stackDepth = 2 "tagName: body  |type: EndTag    |attr:              |text: " "tagName: html  |type: EndTag    |attr:              |text: "

与我们所说的结果是一样的,遇见开标签就入栈,遇见闭标签就出栈。

这就是栈的一个典型的应用,让我想起之前leetCode上一个题,有效的括号,也就是关于栈的一个应用。

但是如果有些时候我们写的HTML格式不正确呢?我们的浏览器也应该是有容错机制的。这里还要补充一个有意思的地方,就是我们的栈最大的深度是512,也就是说如果深度超过了512那么当前节点便是上一个节点的兄弟节点并不会一直深入下去。


容错机制:

在某些时候我们要对栈里的元素进行特别处理,因为很多时候可能我们写的HTML代码的结构是不正确的,但是总不能因为一个一个标签的错误而整个页面的不进行解析吧,所以我们的Chrome要进行特别的处理。

1.如果我们遇见了body标签的闭标签,body的开标签也不会出栈,因为我们写在body标签后的元素还是会被自动当成body标签的子元素。

2.如果有些时候使用的是</br>标签,解析出来的还是正常的<br>

3.如果我们的<form>标签里面还有一个form标签,那么会忽略里面的那个form标签

4.如果我们在HTML页面里写了一个不认识的标签,那么浏览器本质上会将其当做一个span标签来处理,之后我们可以通过display:block将其转换为块元素



生成DOM树的过程总结就是生成网页总体结构的过程,这一部分我们结合了Chrome源码来探究了一个节点究竟是如何生成的,我们首先需要扫描每一个字符来生成Tokens序列,之后我们通过Tokens序列来生成节点。

最重要的是我们需要建立起每一个节点的父子兄弟关系,这一过程我们利用了栈的后进先出的特征来实现。如果遇见开标签我们就入栈,如果遇见闭标签我们就出栈,这样我们栈顶的元素一定是当前元素的父节点。当然很多时候我们写的HTML页面的结构并不是那么的正确,所以在插入节点的时候我们还是要考虑一些错误的情况,做一下容错机制。

在构建DOM树时,我们同样需要处理如<link>标签这样需要获取其他资源的标签,当生成<link>节点在插入DOM树之后便会自动触发资源加载机制,浏览器需要发送请求向服务器来获取相应的资源,这个过程是异步的不会影响我们DOM树的解析工作,当DOM树构建好之后我们便需要来处理CSS文件啦,所以接下来我们来看看CSS解析是什么样的。






                




生成Tokens序列(CSS):

这里还是和解析HTML一样的,需要先生成CSS的Tokens序列,但是解析字符的规则是不一样的,还是和上面一样我们先来看看生成的Tokens序列是什么样的。

CSS的token是有多种类型的,我们可以看一张图,这样的一个CSS被划分成了多个类型:

同样是设置颜色,如果我们使用的rgb的格式,token的类型将是一个函数,也就是说我们需要多调用一个函数来转换rgb格式的颜色,所以从性能优化的角度这里我们更提倡使用16进制代表颜色。


    


将Tokens转换为cssRule:

做完词法分析,生成Tokens之后我们需要进行语法分析了。这里我们不需要关注将Tokens序列转换为cssRule的规则,但我们可以了解一下cssRule,其分为两部分,对应的是CSS的选择器selectors属性值properties集合,每一个CSS的选择器与属性集构成一条rule我们以以下例子来看看:

.text .hello{    color: rgb(200, 200, 200);    width: calc(100% - 20px);} #world{    margin: 20px;}

打印出来的cssRule是这样的:

selector text = “.text .hello”value = “hello” matchType = “Class” relation = “Descendant”tag history selector text = “.text”value = “text” matchType = “Class” relation = “SubSelector”selector text = “#world”value = “world” matchType = “Id” relation = “SubSelector”

我们可以发现其是从右到左进行解析的,这样可能在匹配标签的时候比较方便一些。blink定义了一下几种matchType:

 enum MatchType {    Unknown,    Tag,               // Example: div    Id,                // Example: #id    Class,             // example: .class    PseudoClass,       // Example:  :nth-child(2)    PseudoElement,     // Example: ::first-line    PagePseudoClass,   // ??    AttributeExact,    // Example: E[foo="bar"]    AttributeSet,      // Example: E[foo]    AttributeHyphen,   // Example: E[foo|="bar"]    AttributeList,     // Example: E[foo~="bar"]    AttributeContain,  // css3: E[foo*="bar"]    AttributeBegin,    // css3: E[foo^="bar"]    AttributeEnd,      // css3: E[foo$="bar"]    FirstAttributeSelectorMatch = AttributeExact,  };

还定义了一些选择器的类型:

enum RelationType {    SubSelector,       // No combinator    Descendant,        // "Space" combinator    Child,             // > combinator    DirectAdjacent,    // + combinator    IndirectAdjacent,  // ~ combinator    // Special cases for shadow DOM related selectors.    ShadowPiercingDescendant,  // >>> combinator    ShadowDeep,                // /deep/ combinator    ShadowPseudo,              // ::shadow pseudo element    ShadowSlot                 // ::slotted() pseudo element  };

Descendant便指的是后代选择器.hello,选择器的类型帮助我们快速匹配到这个元素的样式,如.hello的类型是后代,那么从右往左,下一步判断当前元素父类是否匹配.text这个选择器。接下了便是属性值的集合了。

我们将其打印出来:

selector text = “.text .hello”perperty id = 15 value = “rgb(200, 200, 200)”perperty id = 316 value = “calc(100% – 20px)”selector text = “#world”perperty id = 147 value = “20px”perperty id = 146 value = “20px”perperty id = 144 value = “20px”perperty id = 145 value = “20px”

id对应的是不同的属性名:

enum CSSPropertyID {    CSSPropertyColor = 15,    CSSPropertyWidth = 316,    CSSPropertyMarginLeft = 145,    CSSPropertyMarginRight = 146,    CSSPropertyMarginTop = 147,    CSSPropertyMarkerEnd = 148,}

这里有一个小知识:

设置了margin: 20px,会转化成四个属性。从这里可以看出CSS提倡属性合并,但是最后还是会被拆成各个小属性。所以属性合并最大的作用应该在于减少CSS的代码量。


由cssRule生成styleSheet:

每一个CSS的选择器与属性集都会构成一个cssRule,同一个css表的所有rule会被放到styleSheet对象里,blink会把用户的样式存放到一个m_authorStyleSheets的向量里面,如下图示意:

    


这里面还有浏览器默认的样式DefaultStyleSheet,这里面就包括像a标签的默认样式,h1-h5标签的margin指等。


最后会把生成的rule放到一个哈希map之中:

 CompactRuleMap m_idRules; CompactRuleMap m_classRules; CompactRuleMap m_tagRules; CompactRuleMap m_shadowPseudoElementRules;

哈希map根据右边第一个选择器的类型进行分类存放rule,一共有四种类型ID,类名,标签,伪类,这样将rule分类的原因是我们可以先快速的找到最外层选择器相同的rule,之后我们再去寻找这个rule的下一个选择器来匹配到当前元素。


命中选择器:

在解析好CSS样式的时候我们需要根据每一个可视的Node节点来生成Layout节点,由Layout节点再生成我们需要的Layout tree。这个过程其实就是确定整个页面布局每一个可视节点样式的过程,在生成Layout 节点的时候我们需要计算一下每一个节点的CSS样式,而这个过程其实可以分为两部分:根据当前节点的选择器匹配到对应的样式设置这个节点的CSS样式


首先是根据当前节点的选择器匹配到对应的样式,我们一个DOM树上有那么多的节点,我们如何快速高效的找到每一个节点对应的CSS样式呢?首先我们需要遍历每一个可视节点,将按照id、class、伪元素、标签的顺序取出所有的selector,之前说过我们会把每一条rule放入到一个哈希map中,我们可以根据遍历到的这个节点的选择器快速的和哈希map的键进行配对,找到保存的rule。

我们还是以这个demo为例:

<style>.text{    font-size: 22em;}.text p{    color: #505050;}</style><div class="text">    <p>hello, world</p></div>


其会生成两个rule,在map表中通过classRule与tagRuled对应我们在遇到<div class="text">的时候会对应到哈希map里的classRule,首先与.text进行配对,如果成功就判断其父选择器是否匹配,这个样式没有用父选择器所以就成功返回了,如果失败就直接退出。

第二个我们遇见了.text p,还是一样的我们会由tagRuled先匹配P标签,因为选择器的类型是后代选择器,所以我们会对当前节点的所有父节点进行遍历,查看是否可以匹配到.text如果命中就再查看其左边再有没有其他的选择器了,如果没有就可以成功返回了。


这里需要注意的是我们在查找完右边第一个选择器后如果左边还有其他的选择器我们需要使用之前的方法递归判断当前节点的父节点或者其他情况,我们知道使用递归往往是比较消耗性能的,所以我们不应将复合选择器写的过长,最好不要超过三层。



设置Style:

当节点匹配到对应的rule的时候我们对将其保存在该元素的m_matchedRules向量里面从而生成Layout Tree,之后我们需要去计算选择器的优先级,得出的结果保存在m_specificity变量。这里重点就来了,我们可以在网上看到许多关于优先级的文章,那么Chrome的本质到底是怎么样去计算优先级的呢?

for (const CSSSelector* selector = this; selector;     selector = selector->tagHistory()) {   temp = total + selector->specificityForOneSelector();}return total;

首先从右往左取各个选择器的优先级之和,不同类型的选择器优先级定义如下:

 
switch (m_match) {    case Id:       return 0x010000;    case PseudoClass:      return 0x000100;    case Class:    case PseudoElement:    case AttributeExact:    case AttributeSet:    case AttributeList:    case AttributeHyphen:    case AttributeContain:    case AttributeBegin:    case AttributeEnd:      return 0x000100;    case Tag:      return 0x000001;    case Unknown:      return 0;  }  return 0;}

从中我们可以看到ID选择器的优先级最高是16进制的0x010000=65536,类、属性、伪类的优先级是0x100 = 256,标签选择器的优先级是1,其他如通配符就是0了。将我们案例中的选择器的优先级计算如下:

/*优先级为257 = 265 + 1*/.text h1{    font-size: 8em;} /*优先级为65537 = 65536 + 1*/#text h1{    font-size: 16em;}

当所有的优先级都放在m_matchedRules这个向量里面之后我们需要对所有的向量按照优先级的大小进行排序,排序规则就是如果优先级相同我们就比较其先后位置。这就是css的层叠性。

之后我们需要去处理内联式样式表,这在我们构建DOM树的时候已经保存起来了,我们将其放在按照优先级排序好的装向量容器的顶部,这样无论之前的样式的优先级有多高内联式一定是最大的。

collector.addElementStyleProperties(state.element()->inlineStyle(),                                          isInlineStyleCacheable);

最后我们便需要根据优先级来设置元素的Style了,我们先设置正常的最后再设置!important的规则,后面设置的会覆盖前面设置的。


接下来我们可以大概看一下计算出来的style是什么样的,按优先级计算出来的Style会被放在一个ComputedStyle的对象里面,这个style里面的规则分成了几类,通过检查style对象可以一窥:


把它画成一张图表:

  


主要有几类,box是长宽,surround是margin/padding,还有不可继承的nonInheritedData和可继承的styleIneritedData一些属性。Blink还把很多比较少用的属性放到rareData的结构里面,为避免实例化这些不常用的属性占了太多的空间。


具体来说,上面设置的font-size为:22em * 16px = 352px:




关于颜色的属性值会被转换为16进制整数:

static const RGBA32 lightenedBlack = 0xFF545454;static const RGBA32 darkenedWhite = 0xFFABABAB;

调整Style样式:

最后我们需要对我们计算出的CSS样式进行一些调整,如将absoluet、fixed定位,float的元素转化为block

// Absolute/fixed positioned elements, floating elements and the document// element need block-like outside display.if (style.hasOutOfFlowPosition() || style.isFloating() ||    (element && element->document().documentElement() == element))  style.setDisplay(equivalentBlockDisplay(style.display()));

最后还会对表格元素做一些调整。


最后当所有的样式都计算完之后我们会将其挂载到window.getComputedStyle上来供之后的JS访问



总结:

讲到这里CSS的解析与计算的部分就说完了,浏览器的解析部分也就完结了,可能过程有些复杂但是我们如果捋一捋还是很明确的。我们可以大致分为四部分,我们来一个个的总结一下:

  • 1.解析HTML

这一部分其实就做了一件事,扫描每个字符构建出Tokens 序列,Tokens 序列中的内容标签的类型、标签名、文本内容、属性等

  • 2.构建DOM树

之后我们需要根据生成好的Tokens 序列来生成DOM节点,从而构建DOM树,这里比较重要的部分就是我们需要建立每一个节点的父子兄弟关系,DOM树的根节点就是document。

1.最开始我们会根据<DOCType html>确定文档的类型。

2.对于<html>标签我们首先会创建节点,然后让其父指针指向document,让开标签入栈

3.对于其他的标签我们创建节点的过程也是非常相似的,首先我们会创建节点,通过任务队列来建立起其父子兄弟关系,这里其父节点就是栈顶的元素,之后会让开标签入栈

4.如果遇见闭标签就出栈

5.还有一些特殊功能需要完成,如容错机制,异步加载的处理等

  • 3.计算CSS样式

这个部分其实就是对CSS文件的解析,还是一样先生成Tokens序列,但是CSS的Token类型是非常多的,比如我们设置背景颜色时rgb格式我会将其标记成一个函数,方便后序转化。

生成CSS Tokens之后解析工作还没有完成,我们还需要将选择器与属性值分开生成CSSRule,每一个选择器与属性集共同组成了一个rule,我们将生成的rule放在stylesheet这个对象里保存。

最后我们还需要将生成的rule集放在一个哈希map里,根据右边第一个选择器的类型分类进行存放。

  • 4.生成Layout tree

这一部分我们通过遍历DOM树中的可视节点来生成布局树。

命中选择器:首先我们会先去遍历dom树中每一个可视节点来快速匹配哈希map里的键,然后找到该节点对应的rule,将该rule保存在该节点上。

计算优先级:计算出所有选择器的优先级保存在一个向量中。这里着重要注意一下选择器的优先级,对于ID选择器其优先级是最高的0x010000,之后是类,伪类,属性选择器为0x0100,最后是标签选择器为1,剩下的通配符等都是0。保存在向量中之后我们需要对所有的向量按照优先级的大小进行排序,放在一个容器里,容器的顶部优先级最高,之后我们需要去处理内联式,处理完之后放在我们的容器顶部。

生成Style样式:最后就是根据我们保存的优先级来设置每一个元素的CSS样式,这个过程需要运算与转化,先设置正常的最后再设置!important,在这里我们会将rgb格式的颜色转换为16位的数字,我们会对样子做最后的调整,为absolute/fixed元素转换为block等。

最后将所有计算完的CSS样式挂载到window.getComputerStyle上。



浏览器渲染部分



构建图层树:

在解析完HTML与CSS文件之后我们有了DOM树与Layout树,这时还不能直接渲染到页面上,因为我们还需要考虑一些特殊的情况,如我们使用CSS3D效果这时可能不止是一个图层,还有有些时候可能会有层叠上下文的情况。所以在构建完布局树之后,来确定每一个节点的叠放顺序,所以我们还需要构建一棵图层树(Layer Tree)来解决这些问题。

那么如何构建图层树呢?一般情况下该节点的图层默认是父节点的图层,有两种情况需要单独合成图层(也称为合成层),一种是显式合成,一种是隐式合成。接下来我们一一介绍:


  • 显式合成:

显式合成有两种情况,一种是具有层叠上下文的节点,用CSS的属性就可以控制,下面罗列了几种,这些情况我们需要单独创建图层,如果有些同学想要多了解层叠上下文的概念可以看看这篇文章:

  1. 首先是HTML的根元素本身就是层叠上下文的情况

  2. 我们经常使用的absolute/fixed/sticky定位,并且我们需要设置z-index的属性,这些定位是脱离普通文档流的

  3. 我们使用的透明度opacity,其值不能为1

  4. 相同的还有filter ,其值不是none

  5. 之前在性能优化的文章中提到的transform,其值不是none

  6. 元素的 isolation 值是isolate

  7. will-change指定的属性值为上面任意一个。(will-change的作用后面会详细介绍)


在这里要说一下的是像opacity,filter,transform属性虽然是要构建出一个单独的图层,但是这些CSS3的属性会调用硬件加速GPU,并不会引起后面说的回流重绘

                        

还有一种就是需要剪裁的地方,当然如我们一个块元素内容溢出需要使用滚动条,这个滚动条就是在一个单独的图层上。


  • 隐式合成

这个特点总结就是一句话,层叠等级低的节点如果被提升为一个单独的图层,那么所有层叠等级比他高的节点都会成为一个单独的图层。

大家可以想想如果一个层叠等级比较低的节点,上面有几百个节点,如果其成为了一个单独图层,一下子要生成几百个图层,这样大大增加了内存的压力,可能会让页面崩溃,这就是层爆炸


生成绘制列表:

有了图层树之后我们渲染的准备工作基本上就做完了,接下来我们需要做的就是将我们的渲染工作分成一个个的指令,由这些指令来指导计算机来绘制,所以我们会生成一个绘制列表,绘制列表来决定先绘制背景还是先绘制边框呀这些。

我们可以在Chrome浏览器的开发者工具里看到这些绘制列表,在开发者工具里找到more tools,然后找到Layers面板,就能看到绘制列表了。还是我们熟悉的腾讯官网,我们可以来看看:



分割图块、生成位图:

在渲染进程生成绘制列表后便会开一个线程来专门处理绘制工作,这个线程叫做合成线程,通过commit消息将之前生成的绘制列表提交给合成线程

分割图块:我们知道很多情况下包含滚动条的页面是非常大的,如果一次性渲染出来是非常消耗性能的,所以我们合成线程需要做的第一件事便是将图层进行分块处理,这里有一个需要注意的地方,我们分割成的图块在后续是需要上传到GPU处理的,而从浏览器内存上传到GPU内存的速度是非常慢的,即使是一小块图块的绘制也是非常缓慢的,所以我们的Chrome浏览器为了提高显示的性能,会在第一次绘制图块时先采用低分辨率的图片进行绘制,之后等所有图块绘制完成之后然后在用高分辨的率的图片将其进行替代。这也是Chrome优化首屏加载速度的一种方法。

生成位图:在合成线程完成分割图块之后,我们的渲染进程会优先把位于视口附加的图块,发送给自己控制的一个叫栅格化线程池的东西,由其将图块转换为位图数据,转化完之后再发送给合成线程。这里还需要知道的是这里将图块转化为位图数据的过程会调用硬件GPU来进行加速。

在位图数据生成完之后我们最后需要做的就是将其通过显卡来渲染出来。


                                   


通过显卡显示页面:

合成线程接收到栅格化线程池发送过来的位图数据后会生成一个命令,随后将这个命令发送给浏览器进程,浏览器进程中的viz组件接收到这个命令之后,会根据这个命令将生成好的栅格化后的内容保存到内存中,也就是页面,然后将内存中保存的页面发送给显卡后缓冲区,由显卡通过显示器来显示出来。

这里还需要了解一下显示器屏幕显示的原理:我们屏幕显示图像的本质就像小时候玩快速翻动的那种小人书一样,当我们快速翻页的时候就可以看见类似于动画一样的效果。而这里屏幕翻动的速度就叫做固定刷新率,一般情况下是一秒60帧,可以理解为一秒翻动60页。我们的显卡拥有前缓冲区后缓冲区两部分,每次翻动时相当于前缓冲区与后缓冲区对调,循环执行,这样我们的图像就可以被显示出来了。

有些时候如果某个动画比较复杂的时候,从图块生成位图的过程就会非常缓慢,这样浏览器发送给显卡不及时,而显卡还是以60帧的速度进行刷新,所以就会出现页面卡顿的现象,也称为掉帧

到这里浏览器渲染部分就讲完了我们从输入URL到页面显示部分也就基本讲完了,但为了面试后面我们还会补充两个相关知识,回流重绘。接下来我们将渲染部分总结一下,大家可以根据上面的文字,再理清一下整个过程。


我们还可以看一下整个渲染过程的步骤,这里要注意在生成图块之前所有的操作基本上都是在渲染进程的主线程中进行的,之后分图块是在合成线程中进行的,通过栅格化线程池转化为位图数据再返回到合成线程,合成线程给浏览器进程发送命令,浏览器将页面保存到内存,然后再发送到显卡,通过显示器显示出来:

      




从我们输入URL开始,到经过计算机复杂的处理,最终在显示器上显示出来美轮美奂的页面,对于我们来说一切都是那么简单,一切都是那么自然这背后所凝结的技术却是多少IT人技术的积累。。。上头了上头了




回流:

我们的页面通常是很多个元素组成的,如果我们修改了页面中一个元素的大小,可能整个页面所有元素的位置都会发生改变,这个时候我们就要从生成DOM树开始重新计算一下页面中所有元素的位置,样式等。就相当于从构建DOM开始包括解析合成所有的流程再来一遍,这个过程就叫做回流(reflow),也叫做重排(当然也包括生成图块栅格化等后续处理)。可见其是非常消耗性能的,所以我们在使用JS的时候应尽量避免直接操作DOM,具体避免回流的方法还有很多在我的上一篇文章浅谈前端性能优化中有讲。

    


重绘:

重绘就简单多了,如果我们改变了页面中一个元素的样式,并没有修改其几何属性,那我们只需要重新计算一下这个元素的样式,然后直接生成绘制列表,然后分割图块栅格化等便好了,省略了生成DOM树布局树,建立图层这些过程。


  


可见回流是包含重绘的,回流一定引起重绘重绘不一定引起回流。所以从前端性能优化的角度我们一定要避免造成回流。



合成:

最后就是我们GPU硬件加速,我们CSS3的一些属性,如transform,filter,scale,opacity这些会直接使用GPU合成,不会经历回流以及重绘,所以我们做动画时一般会采用这些方法,但也要谨慎使用避免过度的消耗内存。



好了以上就是所有内容,还是有许多不够完善的地方,希望大家可以多提宝贵意见,祝大家在学习前端的路上越走越好😼😼😼🦄🦄🦄🐎🐎🐎


知乎:

Paula Hu《深入理解层叠上下文》

李银城 《从Chrome源码看浏览器如何构建DOM树》、《从Chrome源码看浏览器如何计算CSS》


掘金:

神三元《浏览器灵魂之问,请问你能接得住几个?》、《HTTP灵魂之问,巩固你的 HTTP 知识体系》


腾讯云:

《浏览器渲染(线程视角1)》


小狮子有话说

你好,我是 Chocolate,一个狮子座的前端攻城狮,希望成为优秀的前端博主,每周都会更新文章,与你一起变优秀~

  1. 关注小狮子前端,回复【小狮子】获取为大家整理好的文章、资源合集
  2. 我的博客地址:yangchaoyi.vip 欢迎收藏,可在博客留言板留下你的足迹,一起交流~
  3. 觉得文章不错,【点赞】【在看】支持一波 ✿✿ヽ(°▽°)ノ✿

叮咚~ 可以给小狮子加星标,便于查找。感谢加入小狮子前端,最好的我们最美的遇见,我们下期再见~





浏览 35
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报