前言
HTML语言在前端开发领域是非常基础且简单的设计型语言,可以不夸张的说,无论是前端小白还是非前端工程师,涉及到编程领域的人几乎没有不会这门语言的。HTML的语言特点是通过结构化的标记节点来描述页面结构,在浏览器中运行该代码时便会形成网页的基本布局,那么HTML的标记节点是如何被浏览器识别并画到网页中的呢?
网页渲染的流程
浏览器渲染引擎工作流程都差不多,大致分为5步,创建DOM树——创建StyleRules——创建Render树——布局Layout(重排)——绘制Painting(重绘)
第一步,用HTML分析器,分析HTML元素,构建一颗DOM树(标记化和树构建)。
第二步,用CSS分析器,分析CSS文件和元素上的inline样式,生成页面的样式表。
第三步,将DOM树和样式表,关联起来,构建一颗Render树(这一过程又称为Attachment)。每个DOM节点都有attach方法,接受样式信息,返回一个render对象(又名renderer)。这些render对象最终会被构建成一颗Render树。
第四步,有了Render树,浏览器开始布局,为每个Render树上的节点确定一个在显示屏上出现的精确坐标。
第五步,Render树和节点显示坐标都有了,就调用每个节点paint方法,把它们绘制出来。
**DOM树的构建是文档加载完成开始的?**构建DOM树是一个渐进过程,为达到更好用户体验,渲染引擎会尽快将内容显示在屏幕上。它不必等到整个HTML文档解析完毕之后才开始构建render树和布局。
**Render树是DOM树和CSSOM树构建完毕才开始构建的吗?**这三个过程在实际进行的时候又不是完全独立,而是会有交叉。会造成一边加载,一遍解析,一遍渲染的工作现象。
CSS的解析是从右往左逆向解析的(从DOM树的下-上解析比上-下解析效率高),嵌套标签越多,解析越慢。
今天只研究DOM树!
上面的内容简单的介绍了浏览器解释并渲染网页的整个流程,这个流程相信读者也并不会感到陌生,为了更清晰更深入的认识看似简单的HTML,本次以手写HTML解释器的方式将浏览器的神秘面纱揭开。
简单的HTML语言蕴含的知识
首先通过一个简单的HTML结构重温一下HTML语言:
<div id="d">
你好
<button class="btn">
按钮
</button>
</div>
上面便是日常生活中大家常写的一段基本HTML语法片段,之所以使用标记节点的方式构建页面是因为其天然的嵌套结构可以很好的描述页面中各节点的关系。这样无论是人类还是计算机都可以在不查看显示结果的前提下,很容易的通过阅读该代码了解节点的组成和关系。
浏览器眼中的HTML是什么样的?
对于开发者来说,通过简单的阅读代码便可以读取到代码中蕴含的信息,但是计算机并不是人类,计算机本身并没有语义识别的能力和自我思考的能力,他们只会机械的完成开发者发出的命令,所以在计算机眼中阅读的HTML代码是什么?
答案就是:DOM树!
所以在计算机的眼里,上面写的代码大概是这样的:
[
{
type:'node',
tag:'div',
attrs:{
id:'a'
},
children:[
{
type:'text',
value:'你好'
},
{
type:'node',
tag:'button',
attrs:{
class:'btn'
},
children:[
{
type:'text',
value:'按钮'
}
]
}
]
}
]
为什么计算机要把很清晰的标记节点变成结构化的对象呢?HTML标记语言要更简洁更清晰啊?原因非常简单,因为浏览器在运行代码时必须结合系统的堆栈空间来保存关键数据的信息,而HTML的标记结构在计算机的眼中仅仅是这一段字符串而已,并没有其中蕴含的信息,计算机想要知道这段脚本代表的真实含义必须将节点中的关键信息提取出来,那么就需要将提取到的信息分别保存在heap(堆)内存中,这样才能保证在绘制网页时有条理的一个一个的进行绘制。那么问题来了,浏览器是如何把一段标记语言变成DOM树的呢?
揭秘HTML解释器
首先要清楚一件事,浏览器在接收到开发者提交的HTML代码时,到他面前的代码本质上是这样的:
let code = `
<div id="root" class="abc" name="aaa" >
left
<span>
222
<button>
123
</button>
</span>
<img src="ddd" />
right
</div>
top
<div id="d">
<button id="btn"> xxx</button>
<div>
<span>
xxx
<button>
yyy
123
<button id="123" >
nnn
<img/>
</button>
</button>
</span>
</div>
</div>
<br/>
top-right
top-left
`
这样的字符组合对于浏览器来说,有缩进和无缩进没有任何意义,因为字符本身不带有任何的特殊能力。所以浏览器接下来的工作就变得枯燥且乏味了,也就是大学生活中计算机专业同学所必修的课程之一——编译原理。
想办法把字符代码搞成对象
想要从这段字符中提取关键信息的话,必定逃不掉的知识就是编译原理,这里涉及到几个关键的知识点:
- 词法分析(lexical analysis):进行词法分析的程序或者函数叫作词法分析器(Lexical analyzer,简称Lexer),也叫扫描器(Scanner,例如typescript源码中的scanner.ts),字符流转换成对应的Token流。
- tokenize:tokenize就是按照一定的规则,例如token令牌(通常代表关键字,变量名,语法符号等),将代码分割为一个个的“串”,也就是语法单元)。涉及到词法解析的时候,常会用到tokennize。
- 语法分析(parse analysis):是编译过程的一个逻辑阶段。语法分析的任务是在词法分析的基础上将单词序列组合成语法树,如“程序”,“语句”,“表达式”等等.语法分析程序判断源程序在结构上是否正确。源程序的结构由上下文无关文法描述。
理论上讲非常的抽象,实际上代码的编译过程翻译成人话就是,先从这一团杂乱的字符中提取关键信息,其中包含:关键字、单词、符号和分隔符等等,并将这些关键信息整理成一张线性表。得到线性表后的计算机程序会根据得到的关键词和符号等结构将程序整理成有规则的结构,然后将关键信息放在置顶的关键位置,这个结果通常是一棵树也被称为AST(abstract syntax tree)抽象语法树。这个过程进行完毕浏览器就成功的将HTML转换成了DOM树。
说干就干,词法分析器来了
本文通过类似AST的方式解决HTML到DOM的完成过程,但生成的结果并不会与AST一致,由于HTML为设计型语言,并不存在编程语言的特性,所以词法分析阶段与其他的语言识别器类似,但后续的语法分析则要简单很多。那么如何实现一个词法分析器呢?
对于浏览器来说,当它阅读开发者提交的HTML代码时实际是按照这样的步骤阅读的:
如上图,浏览器只能一个字符一个字符进行代码的读取,那么这样阅读之后得到的岂不是一大堆的单个字符?和我们的预期差距非常的大啊!所以接下来参考下图继续看浏览器读取代码的骚操作。
如上图,也就是说浏览器在阅读字符时也并不是完全不思考,当其阅读到符号内容时,会一个一个的记录单独的符号,而读取到的内容为字符时,若连续阅读的都是字符浏览器会记录从头到尾的所有字符,一旦下一个内容得到的是非字符元素时,浏览器便会收录一个关键字,这样便实现了词法分析器。
用JavaScript做一个词法分析器吧
实现词法分析器的实际算法前,需要准备三个变量来存放一些关键内容
let fh = ['<','>','/','\'','\"','=']
let space = ['\n','\r','\t',' ']
let letters = ['div','button','span','img','br','h1','h2']
需要有这些基础内容,在读取字符时才能知道读取到的内容属于哪个范畴方便记录。接下来便是词法分析器的代码实现:
function getStatus(letter){
if(space.includes(letter)){
return 0
}else if(fh.includes(letter)){
return 1
}else{
return 2
}
}
function makeTokens(code){
let index = 0
let status = 0
let lastStatus = 0
let nextStatus = 0
let keyword = ''
let letter,lastLetter,nextLetter = ''
let tokens = []
while(index<code.length){
letter = code[index]
if(index>0){
lastLetter = code[index-1]
}
if(index<code.length-1){
nextLetter = code[index+1]
}
status = getStatus(letter)
lastStatus = getStatus(lastLetter)
nextStatus = getStatus(nextLetter)
switch (status){
case 1://扫描到符号时直接装进tokens
keyword = letter
tokens.push({
type:'fh',
keyword
})
keyword = ''
break;
case 2://扫描到非符号字符时
keyword+=letter
if(nextStatus!=2){
if(letters.includes(keyword)){
tokens.push({
type:'word',
keyword
})
}else{
tokens.push({
type:'other',
keyword
})
}
keyword=''
}
break;
default:
break;
}
index++
}
return tokens
}
接下来准备一段代码,测试一下词法分析器的结果:
let code = `
<div id="d">
hello
<button class="btn">你好</button>
</div>
`
let tokens = makeTokens(code)
console.log(tokens)
运行代码后,会得到一个很大的JSON数组,该数组大致如下:
[
{
"type": "fh",
"keyword": "<"
},
{
"type": "word",
"keyword": "div"
},
{
"type": "other",
"keyword": "id"
},
{
"type": "fh",
"keyword": "="
},
{
"type": "fh",
"keyword": "\""
},
{
"type": "other",
"keyword": "d"
},
{
"type": "fh",
"keyword": "\""
},
{
"type": "fh",
"keyword": ">"
},
{
"type": "other",
"keyword": "hello"
},
{
"type": "fh",
"keyword": "<"
},
{
"type": "word",
"keyword": "button"
},
{
"type": "other",
"keyword": "class"
},
{
"type": "fh",
"keyword": "="
},
{
"type": "fh",
"keyword": "\""
},
{
"type": "other",
"keyword": "btn"
},
{
"type": "fh",
"keyword": "\""
},
{
"type": "fh",
"keyword": ">"
},
{
"type": "other",
"keyword": "你好"
},
{
"type": "fh",
"keyword": "<"
},
{
"type": "fh",
"keyword": "/"
},
{
"type": "word",
"keyword": "button"
},
{
"type": "fh",
"keyword": ">"
},
{
"type": "fh",
"keyword": "<"
},
{
"type": "fh",
"keyword": "/"
},
{
"type": "word",
"keyword": "div"
},
{
"type": "fh",
"keyword": ">"
}
]
通过阅读该结构发现,词法分析器的作用就是将不具备关键信息点的完整字符串的关键信息按类别提取并保存到线性表中。
胜利就在前方,DOM树来了
有了词法分析器构造的线性表之后,下一步的任务就是通过阅读线性表识别出HTML应用的节点层级关系并将其填装到一个对象中,此步骤涉及到部分基础算法(遍历树的深度优先算法以及其他基础算法思想),用文字描述很难表现出作者的诚意,所以完整的DOM树构建代码附上(内附完整注释,代码编写匆忙,有不严谨的地方望见谅):
function getObjByIndex(nodeIndex,deep,doc,obj){
let target = {}
let index = 0
let tree = doc
while(index<tree.length){
let item = tree[index]
if(nodeIndex == item.index){
target = item
while(deep<target.deep){
target = target.parentNode
}
return target
}
if(item.children){
let col = 0
while(col<item.children.length){
let obj = item.children[col]
if(nodeIndex == obj.index){
target = obj
while(deep<target.deep){
target = target.parentNode
}
return target
}
if(obj.children){
item = obj
col = 0
}else{
if(item.children[col+1]){
col++
}else{
let parent = item.parentNode
while(parent.children&&parent.children.indexOf(item) == parent.children.length-1){
item = parent
parent = parent.parentNode
}
if(parent.children){
col = parent.children.indexOf(item)
item = parent
}else{
col = item.length
}
col++
}
}
}
}
index++
}
return target
}
function makeObj(deep,leafIndex,doc,begin,end,status,nodeStatus){
let tag = ''
if(status == 1){
let attrStr = ''
let attrStatus = 0
while(begin<=end){
let item = tokens[begin]
let lastItem = tokens[begin-1]
if(item.type == 'word'){
tag = item.keyword
}
if((item.type == 'fh' || item.type == 'other') && item.keyword!='<' && item.keyword!='>' && item.keyword!='/'){
attrStr+=item.keyword
if(item.keyword=='\''&&lastItem.keyword != '='){
attrStr += ';'
}
if(item.keyword=='\"'&&lastItem.keyword != '='){
attrStr += ';'
}
}
begin++
}
let obj = {
type:'node',
deep,
index:leafIndex,
tag,
attrs:{},
nodeStatus
}
let target = getObjByIndex(leafIndex-1,deep,doc,obj)
attrStr.replace(/(\'|\")/g,'').split(';').forEach(item => {
if(item.trim().length>0){
let [key,value] = item.split('=')
obj.attrs[key] = value
}
})
if(Object.keys(target).length == 0){
obj.parentNode = doc
doc.push(obj)
}else{
if(deep>target.deep){
obj.parentNode = target
if(target.children){
target.children.push(obj)
}else{
target.children = [obj]
}
}else if(deep == target.deep){
obj.parentNode = target.parentNode
if(target.parentNode.children){
target.parentNode.children.push(obj)
}else{
doc.push(obj)
}
}
}
}
}
function makeTokensToTree(tokens){
let i = 0
let status = 0
let nextKeyword
let lastKeyword
let nextType
let lastType
let doc = []
let deep = 0
let leafIndex = 0
let begin = 0
let end = 0
while(i<tokens.length){
let { type,keyword } = tokens[i]
if(i<tokens.length-1){
nextKeyword = tokens[i+1].keyword
nextType = tokens[i+1].type
}
if(i>0){
lastKeyword = tokens[i-1].keyword
lastType = tokens[i-1].type
}
if(type == 'fh'){
if(keyword == '<'&& nextKeyword!= '/'){
status = 1
deep++
begin = i
leafIndex++
}
if(keyword == '<'&& nextKeyword == '/'){
status = 2
begin = i
deep--
}
if(keyword == '>'&& lastKeyword == '/'){
end = i
makeObj(deep,leafIndex,doc,begin,end,status,1)
deep--
status = 0
}
if(keyword == '>'&& lastKeyword != '/'){
end = i
makeObj(deep,leafIndex,doc,begin,end,status,2)
status = 0
}
}else{
if(status == 0){
let obj = {
type:'text',
value:keyword,
deep:deep+1,
index:leafIndex+1
}
let target = getObjByIndex(leafIndex,deep,doc,obj)
obj.parentNode = target
if(target.children){
target.children.push(obj)
}else{
if(target == doc){
target.push(obj)
}else{
target.children = [obj]
}
}
leafIndex++
}
}
i++
}
return doc
}
小贴士:
这里所有的树的遍历均采用while循环的方式处理主要目的是防止递归函数潜在的执行栈溢出风险。
试试我们的成果吧
在语法分析器和词法分析器的组合下,将下面的HTML结构整理成DOM树的形态:
let code = `
<div id="d">
hello
<button class="btn">你好</button>
<img src="url"/>
</div>
一段意外的文本 <br/>
<div id="root">
<span>
<h1>我是标题</h1>
<button>点我</button>
</span>
</div>
`
let tokens = makeTokens(code)
let tree = makeTokensToTree(tokens)
console.log(tree)
不出意外的话接下来控制台中会得到如下的结果:
看到这个结果,是不是才意识到,原来,浏览器其实并不认识HTML,一切的代码都是一段字符描述而已,程序执行引擎在运行程序时做了大量的基础工作,才让开发者编写的带有逻辑语法和关键字的编程语言真正的跑起来,这时是不是该致敬一下语法编译器的开发者们!!
作业(反向生成HTML)
这个简单吧~既然HTML语法解释器实现起来稍微复杂一点点,那么若将生成的DOM对象反向变回HTML代码是不是就简单很多了?
提前公布答案吧:
function DOM2HTML(tree){
let index = 0
let str = ''
while(index<tree.length){
let item = tree[index]
if(item.type== 'node'){
if(item.nodeStatus == 2){
str+=makeSpace(item.deep)+`<${item.tag} ${makeAttrs(item)} >\n`
}else{
str+=makeSpace(item.deep)+`<${item.tag} ${makeAttrs(item)} />\n`
}
}else if(item.type == 'text'){
str+=makeSpace(item.deep)+item.value+'\n'
}
if(item.children){
let col = 0
while(col<item.children.length){
let obj = item.children[col]
if(obj.type== 'node'){
if(obj.nodeStatus == 2){
str+=makeSpace(obj.deep)+`<${obj.tag} ${makeAttrs(obj)} >\n`
}else{
str+=makeSpace(obj.deep)+`<${obj.tag} ${makeAttrs(obj)}/>\n`
}
}else if(obj.type == 'text'){
str+=makeSpace(obj.deep)+obj.value+'\n'
}
if(obj.children){
item = obj
col = 0
}else{
if(item.children[col+1]){
if(obj.type== 'node'&&obj.nodeStatus == 2){
str+=makeSpace(obj.deep)+`</${obj.tag}>\n`
}
col++
}else{
let parent = item.parentNode
while(parent.children&&parent.children.indexOf(item) == parent.children.length-1){
if(item.nodeStatus == 2){
str+=makeSpace(item.deep)+`</${item.tag}>\n`
}
item = parent
parent = parent.parentNode
}
if(parent.children){
if(item.nodeStatus == 2){
str+=makeSpace(item.deep)+`</${item.tag}>\n`
}
col = parent.children.indexOf(item)
item = parent
}else{
col = item.length
}
col++
}
}
}
}
if(item.type== 'node'&& item.nodeStatus == 2){
str+=makeSpace(item.deep)+`</${item.tag}>\n`
}
index++
}
return str
}
这段代码便是根据上面的解释器构造的DOM树反向生成HTML的实现(附效果图)
尾声
后面的内容可能会结合本次的语法解释器,将生成的结果通过canvas渲染到画布上来模拟真正的浏览器绘制网页过程,让同学们更清晰的看到浏览器的实际工作内容,从而激发出大家的编程思想,喜欢的同学请加关注点赞收藏并分享。
|