用原生 JS 实现 innerHTML 功能

都知道浏览器和服务端是经过 HTTP 协议进行数据传输的,而 HTTP 协议又是纯文本协议,那么浏览器在获得服务端传输过来的 HTML 字符串,是如何解析成真实的 DOM 元素的呢,也就是咱们常说的生成 DOM Tree,最近了解到状态机这样一个概念,因而就萌生一个想法,实现一个 innerHTML 功能的函数,也算是小小的实践一下。html

函数原型

咱们实现一个以下的函数,参数是 DOM 元素和 HTML 字符串,将 HTML 字符串转换成真实的 DOM 元素且 append 在参数一传入的 DOM 元素中。git

function html(element, htmlString) {
    // 1. 词法分析

    // 2. 语法分析

    // 3. 解释执行
}
复制代码

在上面的注释我已经注明,这个步骤咱们分红三个部分,分别是词法分析、语法分析和解释执行。github

词法分析

词法分析是特别重要且核心的一部分,具体任务就是:把字符流变成 token 流。正则表达式

词法分析一般有两种方案,一种是状态机,一种是正则表达式,它们是等效的,选择你喜欢的就好。咱们这里选择状态机。浏览器

首先咱们须要肯定 token 种类,咱们这里不考虑太复杂的状况,由于咱们只对原理进行学习,不可能像浏览器那样有强大的容错能力。除了不考虑容错以外,对于自闭合节点、注释、CDATA 节点暂时均不作考虑。数据结构

接下来步入主题,假设咱们有以下节点信息,咱们会分出哪些 token 来呢。app

<p class="a" data="js">测试元素</p>
复制代码

对于上述节点信息,咱们能够拆分出以下 tokendom

  • 开始标签:<p
  • 属性标签:class="a"
  • 文本节点:测试元素
  • 结束标签:</p>

状态机的原理,将整个 HTML 字符串进行遍历,每次读取一个字符,都要进行一次决策(下一个字符处于哪一个状态),并且这个决策是和当前状态有关的,这样一来,读取的过程就会获得一个又一个完整的 token,记录到咱们最终须要的 tokens 中。函数

万事开头难,咱们首先要肯定起初可能处于哪一种状态,也就是肯定一个 start 函数,在这以前,对词法分析类进行简单的封装,具体以下学习

function HTMLLexicalParser(htmlString, tokenHandler) {
    this.token = [];
    this.tokens = [];
    this.htmlString = htmlString
    this.tokenHandler = tokenHandler
}
复制代码

简单解释下上面的每一个属性

  • token:token 的每一个字符
  • tokens:存储一个个已经获得的 token
  • htmlString:待处理字符串
  • tokenHandler:token 处理函数,咱们每获得一个 token 时,就已经能够进行流式解析

咱们能够很容易的知道,字符串要么以普通文本开头,要么以<开头,所以 start 代码以下

HTMLLexicalParser.prototype.start = function(c) {
    if(c === '<') {
        this.token.push(c)
        return this.tagState
    } else {
        return this.textState(c)
    }
}
复制代码

start处理的比较简单,若是是<字符,表示开始标签或结束标签,所以咱们须要下一个字符信息才能肯定究竟是哪一类 token,因此返回tagState函数去进行再判断,不然咱们就认为是文本节点,返回文本状态函数。

接下来分别展开tagStatetextState函数。tagState根据下一个字符,判断进入开始标签状态仍是结束标签状态,若是是/表示是结束标签,不然是开始标签,textState用来处理每个文本节点字符,遇到<表示获得一个完整的文本节点 token,代码以下

HTMLLexicalParser.prototype.tagState = function(c) {
    this.token.push(c)
    if(c === '/') {
        return this.endTagState
    } else {
        return this.startTagState
    }
}
HTMLLexicalParser.prototype.textState = function(c) {
    if(c === '<') {
        this.emitToken('text', this.token.join(''))
        this.token = []
        return this.start(c)
    } else {
        this.token.push(c)
        return this.textState
    }
}
复制代码

这里初次见面的函数是emitTokenstartTagStateendTagState

emitToken用来将产生的完整 token 存储在 tokens 中,参数是 token 类型和值。

startTagState用来处理开始标签,这里有三种情形

  • 若是接下来的字符是字母,则认定依旧处于开始标签态
  • 遇到空格,则认定开始标签态结束,接下来是处理属性了
  • 遇到>,一样认定为开始标签态结束,但接下来是处理新的节点信息

endTagState用来处理结束标签,结束标签不存在属性,所以只有两种情形

  • 若是接下来的字符是字母,则认定依旧处于结束标签态
  • 遇到>,一样认定为结束标签态结束,但接下来是处理新的节点信息

逻辑上面说的比较清楚了,代码也比较简单,看看就好啦

HTMLLexicalParser.prototype.emitToken = function(type, value) {
    var res = {
        type,
        value
    }
    this.tokens.push(res)
    // 流式处理
    this.tokenHandler && this.tokenHandler(res)
}

HTMLLexicalParser.prototype.startTagState = function(c) {
    if(c.match(/[a-zA-Z]/)) {
        this.token.push(c.toLowerCase())
        return this.startTagState
    }
    if(c === ' ') {
        this.emitToken('startTag', this.token.join(''))
        this.token = []
        return this.attrState
    }
    if(c === '>') {
        this.emitToken('startTag', this.token.join(''))
        this.token = []
        return this.start
    }
}

HTMLLexicalParser.prototype.endTagState = function(c) {
    if(c.match(/[a-zA-Z]/)) {
        this.token.push(c.toLowerCase())
        return this.endTagState
    }
    if(c === '>') {
        this.token.push(c)
        this.emitToken('endTag', this.token.join(''))
        this.token = []
        return this.start
    }
}
复制代码

最后只有属性标签须要处理了,也就是上面看到的attrState函数,也处理三种情形

  • 若是是字母、单引号、双引号、等号,则认定为依旧处于属性标签态
  • 若是遇到空格,则表示属性标签态结束,接下来进入新的属性标签态
  • 若是遇到>,则认定为属性标签态结束,接下来开始新的节点信息

代码以下

HTMLLexicalParser.prototype.attrState = function(c) {
    if(c.match(/[a-zA-Z'"=]/)) {
        this.token.push(c)
        return this.attrState
    }
    if(c === ' ') {
        this.emitToken('attr', this.token.join(''))
        this.token = []
        return this.attrState
    }
    if(c === '>') {
        this.emitToken('attr', this.token.join(''))
        this.token = []
        return this.start
    }
}
复制代码

最后咱们提供一个parse解析函数,和可能用到的getOutPut函数来获取结果便可,就不啰嗦了,上代码

HTMLLexicalParser.prototype.parse = function() {
    var state = this.start;
    for(var c of this.htmlString.split('')) {
        state = state.bind(this)(c)
    }
}

HTMLLexicalParser.prototype.getOutPut = function() {
    return this.tokens
}
复制代码

接下来简单测试一下,对于<p class="a" data="js">测试并列元素的</p><p class="a" data="js">测试并列元素的</p>HTML 字符串,输出结果为

1.png

看上去结果很 nice,接下来进入语法分析步骤

语法分析

首先们须要考虑到的状况有两种,一种是有多个根元素的,一种是只有一个根元素的。

咱们的节点有两种类型,文本节点和正常节点,所以声明两个数据结构。

function Element(tagName) {
    this.tagName = tagName
    this.attr = {}
    this.childNodes = []
}

function Text(value) {
    this.value = value || ''
}
复制代码

目标:将元素创建起父子关系,由于真实的 DOM 结构就是父子关系,这里我一开始实践的时候,将 childNodes 属性的处理放在了 startTag token 中,还给 Element 增长了 isEnd 属性,实属愚蠢,不但复杂化了,并且还很难实现。仔细思考 DOM 结构,token 也是有顺序的,合理利用栈数据结构,这个问题就变的简单了,将 childNodes 处理放在 endTag 中处理。具体逻辑以下

  • 若是是 startTag token,直接 push 一个新 element
  • 若是是 endTag token,则表示当前节点处理完成,此时出栈一个节点,同时将该节点纳入栈顶元素节点的 childNodes 属性,这里须要作个判断,若是出栈以后栈空了,表示整个节点处理完成,考虑到可能有平行元素,将元素 push 到 stacks。
  • 若是是 attr token,直接写入栈顶元素的 attr 属性
  • 若是是 text token,因为文本节点的特殊性,不存在有子节点、属性等,就认定为处理完成。这里须要作个判断,由于文本节点多是根级别的,判断是否存在栈顶元素,若是存在直接压入栈顶元素的 childNodes 属性,不存在 push 到 stacks。

代码以下

function HTMLSyntacticalParser() {
    this.stack = []
    this.stacks = []
}
HTMLSyntacticalParser.prototype.getOutPut = function() {
    return this.stacks
}
// 一开始搞复杂了,合理利用基本数据结构真是一件很酷炫的事
HTMLSyntacticalParser.prototype.receiveInput = function(token) {
    var stack = this.stack
    if(token.type === 'startTag') {
        stack.push(new Element(token.value.substring(1)))
    } else if(token.type === 'attr') {
        var t = token.value.split('='), key = t[0], value  = t[1].replace(/'|"/g, '')
        stack[stack.length - 1].attr[key] = value
    } else if(token.type === 'text') {
        if(stack.length) {
            stack[stack.length - 1].childNodes.push(new Text(token.value))
        } else {
            this.stacks.push(new Text(token.value))
        }
    } else if(token.type === 'endTag') {
        var parsedTag = stack.pop()
        if(stack.length) {
            stack[stack.length - 1].childNodes.push(parsedTag)
        } else {
            this.stacks.push(parsedTag)
        }
    }
}
复制代码

简单测试以下:

2.png

没啥大问题哈

解释执行

对于上述语法分析的结果,能够理解成 vdom 结构了,接下来就是映射成真实的 DOM,这里其实比较简单,用下递归便可,直接上代码吧

function vdomToDom(array) {
    var res = []
    for(let item of array) {
        res.push(handleDom(item))
    }
    return res
}

function handleDom(item) {
    if(item instanceof Element) {
        var element = document.createElement(item.tagName)
        for(let key in item.attr) {
            element.setAttribute(key, item.attr[key])
        }
        if(item.childNodes.length) {
            for(let i = 0; i < item.childNodes.length; i++) {
                element.appendChild(handleDom(item.childNodes[i]))
            }
        }
        return element
    } else if(item instanceof Text) {
        return document.createTextNode(item.value)
    }
}
复制代码

实现函数

上面三步骤完成后,来到了最后一步,实现最开始提出的函数

function html(element, htmlString) {
    // parseHTML
    var syntacticalParser = new HTMLSyntacticalParser()
    var lexicalParser = new HTMLLexicalParser(htmlString, syntacticalParser.receiveInput.bind(syntacticalParser))
    lexicalParser.parse()
    var dom = vdomToDom(syntacticalParser.getOutPut())
    var fragment = document.createDocumentFragment()
    dom.forEach(item => {
        fragment.appendChild(item)
    })
    element.appendChild(fragment)
}
复制代码

三个不一样状况的测试用例简单测试下

html(document.getElementById('app'), '<p class="a" data="js">测试并列元素的</p><p class="a" data="js">测试并列元素的</p>')
html(document.getElementById('app'), '测试<div>你好呀,我测试一下没有深层元素的</div>')
html(document.getElementById('app'), '<div class="div"><p class="p">测试一下嵌套很深的<span class="span">p的子元素</span></p><span>p同级别</span></div>')
复制代码

声明:简单测试下都没啥问题,本次实践的目的是对 DOM 这一块经过词法分析和语法分析生成 DOM Tree 有一个基本的认识,因此细节问题确定仍是存在不少的。

总结

其实在了解了原理以后,这一块代码写下来,并无太大的难度,但却让我很兴奋,有两个成果吧

  • 了解并初步实践了一下状态机
  • 数据结构的魅力

代码已经基本都列出来了,想跑一下的童鞋也能够 clone 这个 repo:domtree

相关文章
相关标签/搜索