都知道浏览器和服务端是经过 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
}
复制代码
简单解释下上面的每一个属性
咱们能够很容易的知道,字符串要么以普通文本开头,要么以<
开头,所以 start 代码以下
HTMLLexicalParser.prototype.start = function(c) {
if(c === '<') {
this.token.push(c)
return this.tagState
} else {
return this.textState(c)
}
}
复制代码
start
处理的比较简单,若是是<
字符,表示开始标签或结束标签,所以咱们须要下一个字符信息才能肯定究竟是哪一类 token,因此返回tagState
函数去进行再判断,不然咱们就认为是文本节点,返回文本状态函数。
接下来分别展开tagState
和textState
函数。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
}
}
复制代码
这里初次见面的函数是emitToken
、startTagState
和endTagState
。
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 字符串,输出结果为
看上去结果很 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 一个新 elementendTag
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)
}
}
}
复制代码
简单测试以下:
没啥大问题哈
对于上述语法分析的结果,能够理解成 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