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>
它会被转化为下面的结构
那你可能又有了新的疑问,我如何可以得到这样的一棵树呢?它的计算规则是什么呢
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.状态机
下图是一个简单场景下的状态机,方便大家理解状态机这一概念
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容错处理规则