baseparser
解析一段 html 文本时,可以先将其解析为简单的对象,然后在对属性和文本进行进一步的加工来丰富每个解析节点对象。最终形成一颗 AST。
第一步 — 想要什么
对于一点 html 文本,解析之前,应该知道我们想要什么,比如 <div id="app">name</div>
,在看到这段文本时,我们的解析目标应该
- 解析出标签的名字,即
tagName = div
- 解析出属性,即
attrList = [id = "app"]
- 解析出中间文本,也就是子元素,即
children = ['name']
上面的期望汇集一下,就得到了期望得到的节点对象
const astNode = {
tagName: "div",
attrList: ['id = "app"'],
children: ["name"],
};
复制代码
这只是最原始的 AST 节点,但是这也是我们必须解析出来的结构。对于一段 html 字符串而言,解析出这三部分,我们需要不断地对输入的字符串进行操作,解析一段,就截掉解析的这一段,一遍往下进行。
第二步 — 配套资料收集
对于字符串操作而言,遍历字符串是每个人都能想到的,而且这种方法可行。但是这里选择使用正则去整体匹配一段字符串,这样子会更快地解析出一段 html 包含的 ast 结构。
既然解析的目标是 html 字符串,那不妨先列举出要用到的正则。
// 开始标签
const startTag =
/^<((?:[a-zA-Z_][\-\.0-9_a-zA-Za-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]*\:)?[a-zA-Z_][\-\.0-9_a-zA-Za-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]*)/;
// 开始标签结束
const startTagClose = /^\s*(\/?)>/;
// 结束标签
const endTag = /^<\/([a-zA-Z_][\-\.0-9_a-zA-Za-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD]*[^>]*)>/
复制代码
上面的正则是无耻的从 vue
源码中直接扣出来的,如果你想了解这些正则的匹配模式,可以在这里 输入正则查看。
到这里,思想和资料已经准备完毕,下面就开始动手写出能解析出第一步结构的解析函数
第三步 — 动手
注意,这里的代码不是一步到位的,需要一步步的去完善,最终达到我们的效果。接下来,将从解析一段 html 字符串开始。
给出需要解析的第一段 html 字符串:<div></div>
function parse(input) {
let root = null // 用来保存解析到的 ast 节点
let tagName = '' // 当前正在解析的标签名称
// 不管怎么样,都要遍历字符串
while(input) {
let textEnd = input.indexOf('<')
if(textEnd === 0){
// < 打头的,可能是开始标签,也可能是结束标签,也可能只是个 <
// 首先尝试匹配开始标签
const match = input.match(startTag)
if(match){
// 说明是开始标签
input = input.slice(match[0].length)
// 检查标签是否正常闭合
const closeStart = input.match(startTagClose)
if(closeStart){
input = input.slice(closeStart[0].length)
// 表示标签正常闭合
root = {
tagName: match[1]
}
if(closeStart[1] === '/'){
// 表示是自闭合标签
input = input.slice(closeStart[0].length)
continue;
}
tagName = root.tagName
}
}
const matchEnd = input.match(endTag)
if(matchEnd){
// 说明匹配到了结束标签
if(matchEnd[1] !== tagName){
// 结束和开始标签不配对,说明不是合法标签,不进行保存
root = null
break
}
input = input.slice(matchEnd[0].length)
}
}
}
return root
}
console.log('parse', parse('<div></div>'));
复制代码
上述代码是一个流程代码,建立在若干假设的基础上:
-
当字符串的开头是
<
时,就认为是 开始标签 、结束标签、文本其中的一个。- 这里先不考虑是文本 的情况,所以只能是前两种
-
存在两种闭合标签
- 自闭合标签
<b />
- 双标签闭合
<div></div>
- 自闭合标签
明确了这两种前提,整个流程就清晰起来了。检测到字符串是以<
开头,则一次做开始标签匹配、结束标签匹配。
开始标签的处理
// 匹配开始标签
const match = input.match(startTag)
if(match){
// 说明是开始标签
input = input.slice(match[0].length)
// 检查标签是否正常闭合
const closeStart = input.match(startTagClose)
if(closeStart){
// 标签正常闭合
input = input.slice(closeStart[0].length)
root = {
tagName: match[1]
}
if(closeStart[1] === '/'){
// 表示是自闭合标签
input = input.slice(closeStart[0].length)
continue;
}
tagName = root.tagName
}
}
复制代码
对开始标签的处理并不难,难点在于你知道 match
的内容,下面举例说明:
const a = '<div>'
const match = a.match(startTag)
/**
* match 的主要内容如下:
*
* [
* '<div', // 匹配到的部分
* 'div' // 匹配到的标签名
* ]
*
*/
复制代码
要确保开始标签完整闭合,这才是一个完整的开始标签,所以就有了:
const a = '>'
const closeStart = input.match(startTagClose)
/**
* match 的主要内容如下:
*
* [
* '>', // 匹配到的部分
* undefined // 如果是自闭合标签,这里是 /
* ]
*
*/
复制代码
至此,一个开始标签完整的匹配完毕,下面整理一下思路:
对于 <div>
这个字符串
- 匹配
<div
部分,获得标签名div
- 匹配 1 中剩余的部分
>
来确定标签是否是个完整的标签
2.1 如果匹配到了/
说明是自闭合标签,整个标签匹配结束
2.2 没有匹配到/
说明不是自闭合标签,开始标签结束
结束标签的处理
const matchEnd = input.match(endTag)
if(matchEnd){
// 说明匹配到了结束标签
if(matchEnd[1] !== tagName){
// 结束和开始标签不配对,说明不是合法标签,不进行保存
root = null
break
}
input = input.slice(matchEnd[0].length)
}
复制代码
结束标签的处理相对来说简单很多,只需要确认下开始和结束的标签名是否对应的上就行。
总结
这篇文章简要的分析了解析 html 字符串需要的准备以及简要的实现。这里可以解析没有属性和子元素的 html 字符串。存在很多不足之处,后面将会有一些列文章来完善这个解析过程,最终获得一个完整的类似 vue complier 的 ast 树。
最后附上一个代码流程图链接