浅谈DOM树构建

103 阅读5分钟

1.什么是DOM?

文档对象模型 (DOM) 是 HTML 和 XML 文档的编程接口。它提供了对文档的结构化的表述,并定义了一种方式可以使从程序中对该结构进行访问,从而改变文档的结构,样式和内容。DOM 将文档解析为一个由节点和对象(包含属性和方法的对象)组成的结构集合。简言之,它会将 web 页面和脚本或程序语言连接起来…… 以上是MDN上的官方解释,吧啦吧啦一堆的可能你并不能很清晰的知道它到底是干嘛的

简单说,dom其实就是一棵当前页面结构的树!当浏览器加载一个 HTML 页面时,浏览器/webview会将 HTML 文档解析成一个 DOM 树(Document Object Model Tree),这个树形结构描述了 HTML 文档的内容和结构,它由节点(Node)和元素(Element)组成,你可能还是疑惑,那这玩意是干啥使的呢?

2.为什么需要DOM树?它又在整个渲染阶段起到了什么作用呢?

因为HTML浏览器不认识啊,它它它读不懂啊,读都读不懂就更别说使用了,所以需要把html转成浏览器可以理解的结构,也就是DOM树。下图是一个简单的DOM树构建的流程,可以让大家有一个初步的印象。

graph LR
字符流 --> 状态机 --> token --> 栈结构 --> DOM树

一个小例子

<html>
    <body> 
        <p> Hello DOM </p> 
        <div><img src=”example.png” />
        </div>
    </body>
</html>

它会被转化为下面的结构

domtreeDemo.png

那你可能又有了新的疑问,我如何可以得到这样的一棵树呢?它的计算规则是什么呢

3.符号识别算法(The tokenization algorithm)

现在想让机器有结构的去按照规则读懂,我们需要把代码拆分为一个个最小且有意义的单元,也就是词法分析

词(token)是如何被拆分的呢?需要按照什么规则呢?
我们可以先来看一个最基础的标签

<p class="a">我是一个平平无奇的标签</p>

如果按照标签为最小单位会发现,里面还可能有属性,有包含的内容等等,所以这个标签需要进一步去拆分。
这段代码依次拆成词(token):
1.<p“标签开始”的开始
2.class="a"属性;
3.我是一个平平无奇的标签文本;
4.</p>标签结束

现在有了划分的规范,那浏览器是如何去判断划分的呢?
比如我们从HTTP/HTTPS协议获取到了字节流,浏览器它是如何去读取字符,并在这么可能的场景中准确的构建一颗DOM树呢?
其实进一步去思考可以发现,在接受第一个字符之前,我们完全无法判断这是哪一个词(token),不过,随着我们接受的字符越来越多,拼出其他的内容可能性就越来越少。

举个例子,当接收到<符号的时,可以判断出肯定不是文本节点,如果下一个字符为d我们可以判断这个token肯定也不会是注释(<!--注释-->),接下来我们就一直读,直到遇到“>”或者空格,这样就得到了一个完整的词(token)了。

实际上,我们每读入一个字符,其实都要做一次决策,而且这些决定是跟“当前状态”有关的。在这样的条件下,浏览器工程师要想实现把字符流解析成词(token),最常见的方案就是使用状态机。
所以进一步发现,当浏览器每读入一个字符后,其实都需要去做一次决策判断当前可能的状态,这时候就需要状态机的存在了

4.状态机

下图是一个简单场景下的状态机,方便大家理解状态机这一概念 image.png HTML官方规范(官方文档规定了80个状态) 这里这个demo比较粗略,只判断了几种状态,完整的HTML词法状态机规则可以参考HTML官方文档(官方文档规定了 80 个状态)

简单描述下上方的过程

var data = function(c){
    if(c=="&") {
        return characterReferenceInData;
    }
    if(c=="<") {
        return tagOpenState;
    }
    else if(c=="\0") {
        error();
        emitToken(c);
        return data;
    }
    else if(c==EOF) {
        emitToken(EOF);
        return data;
    }
    else {
        emitToken(c);
        return data;
    }
};
var tagOpenState = function tagOpenState(c){
    if(c=="/") {
        return endTagOpenState;
    }
    if(c.match(/[A-Z]/)) {
        token = new StartTagToken();
        token.name = c.toLowerCase();
        return tagNameState;
    }
    if(c.match(/[a-z]/)) {
        token = new StartTagToken();
        token.name = c;
        return tagNameState;
    }
    if(c=="?") {
        return bogusCommentState;
    }
    else {
        error();
        return dataState;
    }
};
//……

这段代码写了状态机的两个状态:data 即为初始状态,tagOpenState 是接受了一个“ < ” 字符,来判断标签类型的状态,可以理解为每一个状态其实就是一个函数,通过不断的读取新的字符流去对当前状态进行迁移

状态迁移逻辑(也就是从当前状态转为下一个状态函数)

let state = data;
let char
while(char = getInput())
    state = state(char);

关键一句是“ state = state(char) ”用于状态迁移,当我们拆分解析好一个token时,通过emitToken来输出这个词即可

5.开始构建dom!

终于到了最后构建dom树的时候,我们可以把拆分的token保存在栈里,接收完整个html后,栈顶其实就是最后的根节点

注意点:

  • 对于生成的token中,以下两个说需要去成对匹配的

    • tag start
    • tag end

去构建dom树的时候先需要一个Node类用作存储节点:

// dom
class HTMLDocument {
  constructor () {
    this.isDocument = true
    this.childNodes = []
  }
}

// node类
class Node {}

// 对于不同的子类可以继承Node类
class Element extends Node {
  constructor (token) {
    super(token)
    for (const key in token) {
      this[key] = token[key]
    }
    this.childNodes = []
  }
}

// 对于 Text 节点,我们则需要把相邻的 Text 节点合并起来,我们的做法是当词(token)入栈时,检查栈顶是否是 Text 节点,如果是的话就合并 Text 节点。
class Text extends Node {
  constructor (value) {
    super(value)
    this.value = value || ''
  }
}

一个直观的解析过程:

<!-- 现在有如下的结构 -->
<html lang="" >
    <head>
        <title>王者荣耀</title>
    </head>
    <body>
        <img src="hero-icon" />
    </body>
</html>

通过这个栈,我们可以构建 DOM 树:

  • 栈顶元素就是当前节点;
  • 遇到属性,就添加到当前节点;
  • 遇到文本节点,如果当前节点是文本节点,则跟文本节点合并,否则入栈成为当前节点的子节点;
  • 遇到注释节点,作为当前节点的子节点;
  • 遇到 tag start 就入栈一个节点,当前节点就是这个节点的父节点;
  • 遇到 tag end 就出栈一个节点(还可以检查是否匹配)

模拟结果:

{
  "isDocument": true,
  "childNodes": [
    {
      "name": "html",
      "lang": "xxx",
      "childNodes": [
        {
          "value": "\n    "
        },
        {
          "name": "head",
          "childNodes": [
            {
              "value": "\n        "
            },
            {
              "name": "title",
              "childNodes": [
                {
                  "value": "王者荣耀"
                }
              ]
            },
            {
              "value": "\n    "
            }
          ]
        },
        {
          "value": "\n    "
        },
        {
          "name": "body",
          "childNodes": [
            {
              "value": "\n        "
            },
            {
              "name": "img",
              "src": "hero-icon",
              "childNodes": []
            },
            {
              "value": "\n    "
            }
          ]
        },
        {
          "value": "\n"
        }
      ]
    }
  ]
}

模拟过程:

// 模拟过程
const EOF = void 0

function HTMLLexicalParser (syntaxer) {
  let state = data
  let token = null
  let attribute = null
  let characterReference = ''

  this.receiveInput = function (char) {
    if (state == null) {
      throw new Error('there is an error')
    } else {
      state = state(char)
    }
  }

  this.reset = function () {
    state = data
  }

  function data (c) {
    switch (c) {
      case '&':
        return characterReferenceInData

      case '<':
        return tagOpen

      default:
        emitToken(c)
        return data
    }
  }

  function characterReferenceInData (c) {
    if (c === ';') {
      characterReference += c
      emitToken(characterReference)
      characterReference = ''
      return data
    } else {
      characterReference += c
      return characterReferenceInData
    }
  }

  function tagOpen (c) {
    if (c === '/') {
      return endTagOpen
    }
    if (/[a-zA-Z]/.test(c)) {
      token = new StartTagToken()
      token.name = c.toLowerCase()
      return tagName
    }
    return error(c)
  }


  function tagName (c) {
    if  (c === '/') {
      return selfClosingTag
    }
    if  (/[\t \f\n]/.test(c)) {
      return beforeAttributeName
    }
    if (c === '>') {
      emitToken(token)
      return data
    }
    if (/[a-zA-Z]/.test(c)) {
      token.name += c.toLowerCase()
      return tagName
    }
  }

  function beforeAttributeName (c) {
    if (/[\t \f\n]/.test(c)) {
      return beforeAttributeName
    }
    if (c === '/') {
      return selfClosingTag
    }
    if (c === '>') {
      emitToken(token)
      return data
    }
    if (/["'<]/.test(c)) {
      return error(c)
    }

    attribute = new Attribute()
    attribute.name = c.toLowerCase()
    attribute.value = ''
    return attributeName
  }

  function attributeName (c) {
    if (c === '/') {
      token[attribute.name] = attribute.value
      return selfClosingTag
    }
    if (c === '=') {
      return beforeAttributeValue
    }
    if (/[\t \f\n]/.test(c)) {
      return beforeAttributeName
    }
    attribute.name += c.toLowerCase()
    return attributeName
  }

  function beforeAttributeValue (c) {
    if (c === '"') {
      return attributeValueDoubleQuoted
    }
    if (c === "'") {
      return attributeValueSingleQuoted
    }
    if (/\t \f\n/.test(c)) {
      return beforeAttributeValue
    }
    attribute.value += c
    return attributeValueUnquoted
  }

  function attributeValueDoubleQuoted (c) {
    if (c === '"') {
      token[attribute.name] = attribute.value
      return beforeAttributeName
    }
    attribute.value += c
    return attributeValueDoubleQuoted
  }

  function attributeValueSingleQuoted (c) {
    if (c === "'") {
      token[attribute.name] = attribute.value
      return beforeAttributeName
    }
    attribute.value += c
    return attributeValueSingleQuoted
  }

  function attributeValueUnquoted (c) {
    if (/[\t \f\n]/.test(c)) {
      token[attribute.name] = attribute.value
      return beforeAttributeName
    }
    attribute.value += c
    return attributeValueUnquoted
  }

  function selfClosingTag (c) {
    if (c === '>') {
      emitToken(token)
      endToken = new EndTagToken()
      endToken.name = token.name
      emitToken(endToken)
      return data
    }
  }

  function endTagOpen (c) {
    if (/[a-zA-Z]/.test(c)) {
      token = new EndTagToken()
      token.name = c.toLowerCase()
      return tagName
    }
    if (c === '>') {
      return error(c)
    }
  }

  function emitToken (token) {
    syntaxer.receiveInput(token)
  }

  function error (c) {
    console.log(`warn: unexpected char '${c}'`)
  }
}

class StartTagToken {}

class EndTagToken {}

class Attribute {}

module.exports = {
  HTMLLexicalParser,
  StartTagToken,
  EndTagToken
}

6.容错处理

HTML具备一定的容错能力,比如当只有开标签但是没有闭标签,W3C对这种场景有相应的复杂规则去应对,详细可见下方 HTML容错处理规则