阅读浏览器工作原理(二)

420 阅读11分钟

这是我参与更文挑战的第3天,活动详情查看: 更文挑战

阅读浏览器工作原理(二)

将请求的内容呈现在浏览器上,默认显示html、xml内容,还可以通过插件拓展显示pdf等内容。

总流程

  1. 从网络层获取请求文档内容
  2. 解析文档
    1. html文档 ->(gecko会形成内容槽content sink,用于生成dom元素)-> 形成dom树
    2. 解析css->形成css规则树
    3. 两者结合(attachment)形成呈现树(gecko称为框架树)
  3. 布局,为每个节点分配呈现坐标(gecko称为重排)
  4. 绘制,渲染引擎遍历呈现树,用户界面后端绘制每个节点

解析

解析分为两个部分:词法分析器负责词法分析、解析器负责语法分析。词法分析就是把文本分割成标记,语法分析就是应用语法规则去分析文档结构,从而构建解析树。

词汇通常用正则表达式定义,而语法通常用bnf格式定义。

解析器首先向词法分析器请求标记,然后将它和语法规则进行匹配。如果匹配到了某条规则就会将该标记(如html)对应的节点(生成html元素节点并添加)添加到解析树中,然后请求下一个标记。如果找不到匹配规则,就报语法错误。

解析器分为自上而下的解析器(起点总是最高级别的规则,比如说一个字符是项,项+项=表达式,那么就会直接匹配表达式)和自下而上的解析器(一项一项的扫描并匹配规则)。

HTML解析器

html无法用常规解析器来解析,也不能用xml解析器来解析,因为他比较宽容,能够包容很多语法错误。HTML4基于SGML,所以采用dtd规定标记,其中严格模式(标准模式)下完全遵守html规范,但是其他模式可以兼容一些以前的浏览器标记(怪异模式),HTML5不基于SGML,所以只有一种dtd声明:

DOM

HTML解析器输出的解析树是DOM树。

html5规范中,解析流程为:标记化(词法分析)-> 树构建 -> 形成dom树。其中如果在树构建阶段执行了document.write,就会回到标记化阶段去添加额外的标记。

标记化算法

<html>
    <body>
        hello
    </body>
</html>

一开始是数据状态

  1. 遇到<:转为标记打开状态

    遇到a-z字符:创建一个起始标记,同时转为标记名称状态

    遇到>:发送标记html,转为数据状态

  2. <body>标签同上

  3. 遇到h,创建字符标记并发送。

    遇到e,创建字符标记并发送....

  4. 遇到<,转为标记打开状态

    遇到/,创建结束标记,转为标记名称状态

    遇到>,发送标记body,转为数据状态

  5. </html>标签同上

树构建算法

以document为根节点,不断根据标记创建并添加各种元素。这些元素还会被添加到一个堆栈中,可以纠正嵌套错误和处理未关闭的标记。

<html>
    <body>
        hello
    </body>
</html>
  1. initial mode状态

  2. 接受html标记 -> before html状态

    创建HTMLhtmlElement元素,添加document对象上

    -> before head状态

  3. 接受body标记

    隐式创建一个HTMLHeadElement,添加到树上

    -> after head状态

    创建HTMLBodyElement,添加到树上

    -> in body状态

  4. 接受h

    创建一个text节点,添加到树上,其他字符也都添加到该节点上

  5. 接受body结束标记 -> after body状态

  6. 接受html结束标记 -> after after body状态

解析结束后,会将文档状态设置为完成。

以chrome为例构建DOM树

chrome构建dom的基本流程:

img

名词解释:

HTMLDocumentParser:负责将html文本解析成一些标签文本(tokens)

HTMLTreeBuilder:对tokens进行分类处理,然后根据不同类型调用HTMLConstructionSite的不同函数来构建dom

HTMLConstructionSite:对不同类型标签创建不同html,并且建立父子关系。其中的m_docunebt变量就是window.document。

流程解释:

  1. 发送url请求之后,会通过ipc进程间通信,触发DocumentLoader。

    其中会初始化HTMLDocumentParser,并且实例化documment对象。

    之后将收到的数据(html文本)带给HTMLDocumentParser去解析,形成tokens。

  2. 形成tokens

    chrome定义7种标签类型:

    enum TokenType {
        Uninitialized,
        DOCTYPE, // <!DOCTYPE html>
        StartTag, // 起始标签<div>等
        EndTag, // 结束标签</div>等
        Comment, //注释
        Character, //字符串
        EndOfFile, //文档结束
      };
    

    以一个html文本为例:

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

    每一段文本对应的token如下:

    <!DOCTYPE html> <!--tagname: html,type: DOCTYPE-->
    <html> <!--tagname: html,type: startTag-->
    <!--text: \n,type: Character -->
    <head> <!--tagname: head,type: startTag-->
    <!--text: \n,type: Character -->
        <meta charset="utf-8"> <!--tagname: meta,type: startTag-->
    <!--text: \n,type: Character -->
    </head> <!--tagname: head,type: EndTag-->
    <!--text: \n,type: Character -->
    <body> <!--tagname: body ,type: EndTag-->
    <!--text: \n,type: Character -->
        <div> <!--tagname: div ,type: startTag-->
    <!--text: \n,type: Character -->
            <h1 class="title"> <!--tagname: h1 ,type: startTag-->
                demo <!--test: demo ,type: Character-->
            </h1> <!--tagname: h1 ,type: EndTag-->
    <!--text: \n,type: Character -->
            <input value="hello"> <!--tagname: input ,type: startTag,attr:value=hello-->
    <!--text: \n,type: Character -->
        </div> <!--tagname: div ,type: endTag-->
    <!--text: \n,type: Character -->
    </body> <!--tagname: body ,type: endTag-->
        
        
    <!--text: \n,type: Character -->
    </html> <!--tagname: html ,type: endTag-->
    <!--text: \n,type: Character -->
    <!--type: EndOfFile -->
    

    到这里已经形成了document,和一些格式化好的token,即将开始构建dom树。

  3. 构建dom树

    主要是做什么:

    建立父子、兄弟节点关系。以p标签HTMLParagraphElement为例:(图片来自参考文章)

    v2-1dd498d1142b1b6841739dbd32b5a30d_720w.png

    • treeScope:记录属于哪个document
    • Node:最顶层的父类,有三个指针:父节点、前一个节点、后一个节点
    • ContainerNode:继承自node,添加两个指针:第一个子元素,最后一个子元素
    • Element:添加关于attribute相关、clientWidth、scrollTop等函数
    • HTMLElement:draggable,setDraggable等方法
    • HTMLParagraphElement:创建元素

    处理token:

    void HTMLTreeBuilder::processToken(AtomicHTMLToken* token) {
      if (token->type() == HTMLToken::Character) {  //文本节点
        processCharacter(token);
        return;
      }
     
      switch (token->type()) {  //doctype节点
        case HTMLToken::DOCTYPE:
          processDoctypeToken(token);
          break;
        case HTMLToken::StartTag:  //开标签
          processStartTag(token);
          break;
        case HTMLToken::EndTag:  //闭标签
          processEndTag(token);
          break;
        //othercode
      }
    }
    
    1. processDoctypeToken

      负责创建doctype节点,然后创建一个插dom的任务,最后设置文档类型。

      如果tagName不是html或没有声明,那么就是怪异模式。如果是html4写法,由于有systemId:w3.org..,被判断为有限怪异模式。

      <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
      "http://www.w3.org/TR/html4/loose.dtd">
      

      怪异模式:模拟ie(input,textarea的盒模型为border-box、标准模式下文档高度由内容撑开,怪异模式下文档高度为窗口可视区域。)而且css解析比较宽松。

      有限怪异模式:和标准模式的唯一区别在于inline的行高处理不一样(div里面的图片下方不会留白,标准模式下会留白,空白处为div的行高),标准模式会遵循文档规定。

    2. processStartTag

      处理标签的时候:

      1. 先创建html节点(HtmlElement元素)

      2. 加入任务队列,传入当前节点和父节点(openElements.topNode)

        主要是创建一个task,然后记录父节点和当前节点,如果超过dom树的最大深度(512)就会把当前节点当做父元素的同级节点。

      3. 压栈,该栈存放未遇到闭标签的开标签(用openElements建立父子关系)

      4. 执行队列里面的任务(用lastChild建立兄弟关系)

        根据task的类型执行不同操作,先检查是否支持子元素,不支持就直接返回,不进行插入操作。

        接下来设置子元素父节点、firstChild、lastChild。html的父节点即document。如果发现没有lastChild,就将子元素作为firstChild。但是由于document下已经有了docype节点,所以该元素的previousSibling就是上一个lastChild(docype),上一个lastChild(docype)的nexSibling就是这个html节点,最后再把该节点设置为lastChild。

      处理的时候同上,创建head节点,插入队列,压栈时栈顶是,所以他就是的子节点。

    3. processEndTag

      遇到闭标签的时候会把栈里面的元素pop出来,直到看到第一个和标签名字一样的。

      当我们把head标签pop出来,栈顶元素就又变成html,下一个插入的body元素就成了他的子节点。

      但是关闭body标签的时候不会pop出来,如果body闭标签之后又写了标签就还是会被当成body的子元素。

      <div>
          <p><b>hello</b></p>
          <p>demo</p>
      </div>
      

      如果不写,最后形成的结构是

      <div>
          <p><b>hello</b></p> <!--后面会不断插入b标签-->
          <b><p>demo</p></b>
      </div>
      

      因为(a, b, big, code, em, font, i, nobr, s, small, strike, strong, tt, u)这样带有格式化的标签会特殊处理,遇到时会放到一个列表里面,如果遇到</b>就从列表里删掉。之后每次处理新标签(如

      )就会检查和列表和栈里的是否对应,不对应的话就会重新插入一个,直到遇到下一个</b>。

    4. 关于自定义标签

      会被实例化为HTMLUnknownElement,只提供create函数,也继承自HTMLElement,与span表现相同。

      可以用js注册自定义标签:

      class HighSchoolElement extends HTMLElement{
          constructor(){
              super();
              this._country = null;
          }
          get country(){
              return this._country;
          }
          set country(country){
              this.setAttribute("country", _country);
          }
          static get observedAttributes() { 
              return ["country"]; 
          }
          attributeChangedCallback(name, oldValue, newValue) {
              this._country = newValue;
              this._updateRender(name, oldValue, newValue);
          }
          _updateRender(name, oldValue, newValue){
              console.log(name + " change from " + oldValue + " " + newValue);
          }
      }
      window.customElements.define("high-school", HighSchoolElement);
      //<high-school country="China">NO. 2 high school</high-school>
      

      让该标签继承自htmlElement,可以直接取到country,而不用通过getAttribute去获取属性,还可以实现在属性变化时更新元素渲染。

      这种方式创建之后就不是HTMLUnknownElement,v8会把构造函数转为c++函数,实例化一个HTMLElement对象。

CSS解析

css是上下文无关的语法,可以用常规解析器解析。webkit使用Flex(创建词法分析器,需要输入正则表达式定义的标记文件)和Bison(创建自下而上的解析器,需要输入BNF格式的语法规则)解析器生成器,解析器会将css文件解析成styleSheet对象,而且每个对象都包含cssRule,cssRule里面会包含选择器selector(如p、div)和声明对象(margin-top)等。

以chrome为例解析css

  1. 形成tokens

    css的token很详细,具体定义点此查看,举例如下:(图片来自参考文章)

    v2-9b7ee6c8545b3408b5ea98af235c1286_720w.png

    如果使用rgb,会被归类为函数类型的function-token

  2. 构建css规则树

    对于以下css内容:

    .text .hello{
        color: rgb(200, 200, 200);
        width: calc(100% - 20px);
    }
     
    #world{
        margin: 20px;
    }
    
    1. 解析选择器

      blink定义了几种matchType

       enum MatchType {
          Unknown,
          Tag,               // Example: div
          Id,                // Example: #id
          Class,             // example: .class
          PseudoClass,       // Example:  :nth-child(2)
          PseudoElement,     // Example: ::first-child
          PagePseudoClass,   //  Example: :hover
          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
      };
      

      (题外话:关于Shadow DOM,可以看想了解Shadow DOM?看这里。

      所以上面的css解析结果为:

      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”
      

      其中selector text为识别到的选择器字符串,value为当前匹配到的选择器,matchType为选择器匹配类型,relation为关系型选择器类型。

      可以看出解析是从右往左的,方便它判断父选择器。如果有一个元素的classname刚好是hello,那么他会判断该元素的所有父元素,是否有一个classname刚好是text的。

    2. 解析声明对象

      在解析的时候,每一个属性都对应一个唯一id,包括:

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

      所以最后解析出来的声明对象结果为:

      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”
      //可以看出这里会把margin分成4个属性
      
    3. 选择器+声明对象构成一个cssRule,Rule构成cssStyleSheet(图片来自参考文章)

      v2-8b0e9fef3335c499478c40b28ea42570_720w.png

      用户定义的styleSheet会被存放到m_authorStyleSheets中。浏览器的默认样式会被存放到DefaultStyleSheet中,包括如把style/link/script等标签display: none,把div/h1/p等标签display: block,设置p/h1/h2等标签的margin值等。

      如果是怪异模式,blink还会加载怪异模式表,里面设置了input、textarea的盒模型为border-box。

    4. 生成哈希map

      CompactRuleMap m_idRules;
      CompactRuleMap m_classRules;
      CompactRuleMap m_tagRules; //标签
      CompactRuleMap m_shadowPseudoElementRules; //伪类
      

      把生成的rule集放到四个类型的hashmap上,每个rule属于什么类型由最后一个selector决定。

      这样可以方便快速取出可以匹配第一个(位置上是最右边那个)selector的所有rule,然后检查每条rule里面的每一级selector是否全都符合该元素的环境。

解析顺序

css是异步加载,不影响dom的构建,可能在css加载完成前DOM就已经构建好了。(但是在css加载完成前页面会一直白屏,不然会出现抖动造成不好的体验)。但是在没加载解析css的时候,为了防止脚本访问到错误的css数据,webkit会在脚本尝试访问的属性可能受到该影响时,禁止脚本的运行。

参考文章

从Chrome源码看浏览器如何构建DOM树

从Chrome源码看浏览器如何计算CSS

从Chrome源码看浏览器如何加载资源

想了解Shadow DOM?看这里。