threeparse
前面,分两部分完成了 html 字符串的基础解析和属性解析。但是只能对单个标签进行解析,没有加入父子关系的解析。此次,将开始上下级关系的解析。
需要一点准备
再开始之前,有必要先理一下需求,这样目标性会更强。对于 <div>qwweerrwerw<span>dasdad</span></div>
这段字符串,期望解析出的最终结果如下:
root = {
tagName: "div",
attrList: [],
children: [
"qwweerrwerw",
{
tagName: "span",
attrList: [],
children: ["dasdad"],
parent: root,
},
],
};
可以看到,子元素大致分为两种:
- 文本:包含了插值文本和纯文本
- astNode 元素
由于存在层级关系,而且在合法的情况下,所有标签都是正常闭合时,可以用栈来维护父子关系,当一个标签顺利闭合时,栈顶的元素就是当前闭合元素的父元素;相反,这个元素就是当前父元素的子元素其中的一个。
对于文本节点而言,不需要维护其父节点元素,只需要将其加入父元素的子元素数组中即可。但是对于文本元素的解析,还是存在一些难度的。因为检测到 <
开头时,就认为是开始标签/结束标签的开始,但是也有可能就是在一段文本中存在 <
字符。下面先来加入对文本的解析。
需要的正则
// 开始标签
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 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]*[^>]*)>/;
代码如下:
function parse(input){
//... somecode
while(input){
let textEnd = input.indexOf('<')
//... somecode 第二篇文章中的解析开始和结束标签的代码
if(textEnd >= 0) {
let tmp = input.slice(textEnd)
// 这里如果存在 dadad< dadada 这样的字符串
while(
!startTag.test(tmp) && // 不是开始标签
!endTag.test(tmp) // 不是结束标签
){
// 1 表示从 < 的下一位开始搜索,也就是开始搜索的索引
const n = tmp.indexOf('<', 1)
if(n < 0) return
textEnd += n
tmp = tmp.slice(n)
}
const text = input.slice(0, textEnd)
currentPatent.children.push(text)
input = input.slice(textEnd)
}
}
}
这段代码不是完整的代码,但是是完整的解析出一点文本的代码,关键点就在于当遇到 <
后的处理,这里做的是尽可能多的将不是开始和结束标签的文本部分解析出来。
其实这里就出现了一个主意事项,如果你想在文本中使用 <
则必须在 <
后加入一些非标签的字符,比如: <
、<@
等,这样才会解析出文本来,不然就会被解析为非法的开始标签而被舍弃
加入父子关系解析
父子关系的维护需要栈的配合,这里先将完整的代码给贴出来再慢慢解析:
function parse(input) {
let root = null // 用来保存解析到的 ast 节点
let ele = null // 当前正在解析的元素
let tagName = '' // 当前正在解析的标签名称
let stack = [] // 用于维护父子关系的栈
// 不管怎么样,都要遍历字符串
while(input) {
let textEnd = input.indexOf('<')
if(textEnd === 0){
// < 打头的,可能是开始标签,也可能是结束标签,也可能只是个 <
// 首先尝试匹配开始标签
const match = input.match(startTag)
if(match){
// 说明是开始标签
input = input.slice(match[0].length)
// 检查标签是否正常闭合
let closeStart = null
let attr = null
let matchNode = {
tagName: match[1],
attrList: []
}
while(!(closeStart = input.match(startTagClose)) && (attr = input.match(dynamicArgAttribute) || input.match(attribute))){
// 收集属性
matchNode.attrList.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5]
})
input = input.slice(attr[0].length)
}
if(closeStart){
input = input.slice(closeStart[0].length)
// 开始标签结束,创建节点的 ast 捷信
ele = {
...matchNode,
parent: null,
children: []
}
// 跟节点不存在,则当前元素就是跟节点
if(!root){
root = ele
}
stack.push(ele) // 元素入栈,为解析其根元素做准备
if(closeStart[1] === '/'){
// 表示是自闭合标签
stack.pop() // 标签结束,出栈
if(stack.length){
// 设置当前元素的父子关系
ele.parent = stack[stack.length - 1]
stack[stack.length - 1].children.push(ele)
}
} else {
tagName = ele.tagName
}
continue;
}
}
const matchEnd = input.match(endTag)
if(matchEnd){
console.log(matchEnd);
// 说明匹配到了结束标签
if(matchEnd[1] !== tagName){
// 结束和开始标签不配对,说明不是合法标签,不进行保存
root = null
break
}
stack.pop() // 标签结束,出栈
if(stack.length){
// 设置当前元素的父子关系
ele.parent = stack[stack.length - 1]
stack[stack.length - 1].children.push(ele)
// 重置 tagName 为父节点的 tagName
tagName = stack[stack.length - 1].tagName
}
input = input.slice(matchEnd[0].length)
continue;
}
}
if(textEnd >= 0) {
let tmp = input.slice(textEnd)
// 这里如果存在 dadad< dadada 这样的字符串
while(
!startTag.test(tmp) && // 不是开始标签
!endTag.test(tmp) // 不是结束标签
){
// 1 表示从 < 的下一位开始搜索,也就是开始搜索的索引
const n = tmp.indexOf('<', 1)
if(n < 0) return
textEnd += n
tmp = tmp.slice(n)
}
const text = input.slice(0, textEnd)
stack[stack.length - 1].children.push(text)
input = input.slice(textEnd)
}
}
return root
}
console.log('parse', parse('<div id="app" :b="c" v-html="d" :[xxx] = "e">dsdasdasd< dasdasda<span>dasdasdsada</span></div>'));
这里新加入了 stack
用来维护层级关系,层级越低的节点,越靠近栈低,当前解析的元素的在栈底,所以当当前解析的节点结束时,它就要出栈。那么这时候的栈底元素就是当前结束的元素的父元素,借用这样的原理,在每个元素结束时,就可以顺利的对其父子关系进行维护。
其实这里还存在一下问题,当一个节点 <div dsadasdad><span>dsdasdasdada<span></span></div>
显然这是不合法的,因为第一个 span
标签没有正常闭合,但是上述代码并没有这种异常代码的兼容策略,当然,对于这种异常情况的兼容,一般是将这个不完整的标签变成闭合标签,这样的话,上述的第一个 span
就会包含所有的 div
的子标签,最终处理的结果为 <div dsadasdad><span>dsdasdasdada<span></span></span></div>
。
最终版的代码如下:
// 开始标签
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 attribute =
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
// 解析动态属性
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 开始标签结束
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]*[^>]*)>/
function parse(input) {
let root = null // 用来保存解析到的 ast 节点
let ele = null // 当前正在解析的元素
let tagName = '' // 当前正在解析的标签名称
let stack = [] // 用于维护父子关系的栈
// 不管怎么样,都要遍历字符串
while(input.trim()) {
let textEnd = input.indexOf('<')
if(textEnd === 0){
// < 打头的,可能是开始标签,也可能是结束标签,也可能只是个 <
// 首先尝试匹配开始标签
const match = input.match(startTag)
if(match){
// 说明是开始标签
input = input.slice(match[0].length)
// 检查标签是否正常闭合
let closeStart = null
let attr = null
let matchNode = {
tagName: match[1],
attrList: []
}
while(!(closeStart = input.match(startTagClose)) && (attr = input.match(dynamicArgAttribute) || input.match(attribute))){
// 收集属性
matchNode.attrList.push({
name: attr[1],
value: attr[3] || attr[4] || attr[5]
})
input = input.slice(attr[0].length)
}
if(closeStart){
input = input.slice(closeStart[0].length)
// 开始标签结束,创建节点的 ast 捷信
ele = {
...matchNode,
parent: null,
children: []
}
// 跟节点不存在,则当前元素就是跟节点
if(!root){
root = ele
}
stack.push(ele) // 元素入栈,为解析其根元素做准备
if(closeStart[1] === '/'){
// 表示是自闭合标签
stack.pop() // 标签结束,出栈
if(stack.length){
// 设置当前元素的父子关系
ele.parent = stack[stack.length - 1]
stack[stack.length - 1].children.push(ele)
}
} else {
tagName = ele.tagName
}
continue;
}
}
const matchEnd = input.match(endTag)
if(matchEnd){
// console.log(matchEnd);
// 说明匹配到了结束标签
if(matchEnd[1] !== tagName){
console.log('tagName',tagName, matchEnd[1]);
// 结束和开始标签不配对,说明不是合法标签,不进行保存
let pos = 0
for (pos = stack.length - 1; pos >=0 ; pos--) {
if(stack[pos].tagName === tagName) {
break
}
}
if(pos>=0) {
for (let i = stack.length - 1; i >=pos ; i--) {
// 设置当前元素的父子关系
stack[pos].parent = stack[i - 1]
stack[i - 1].children.push(stack[pos])
}
}
stack.length = pos
tagName = stack[pos - 1].tagName
} else {
stack.pop() // 标签结束,出栈
if(stack.length){
// 设置当前元素的父子关系
ele.parent = stack[stack.length - 1]
stack[stack.length - 1].children.push(ele)
// 重置 tagName 为父节点的 tagName
tagName = stack[stack.length - 1].tagName
}
input = input.slice(matchEnd[0].length)
continue;
}
}
}
if(textEnd >= 0) {
let tmp = input.slice(textEnd)
// 这里如果存在 dadad< dadada 这样的字符串
while(
!startTag.test(tmp) && // 不是开始标签
!endTag.test(tmp) // 不是结束标签
){
// 1 表示从 < 的下一位开始搜索,也就是开始搜索的索引
const n = tmp.indexOf('<', 1)
if(n < 0) return
textEnd += n
tmp = tmp.slice(n)
}
const text = input.slice(0, textEnd)
ele && ele.children.push(text)
input = input.slice(textEnd)
}
}
return root
}
let a = null
console.log('parse', a = parse(`
<div id="app" :b="c" v-html="d" :[xxx] = "e">
<span :s="ss">d
sdasdasddasdasda
<span>dasdasdsada</span>
<div><b>323213123</b></div>
</div>
`));
总结
至此,基本解析的代码已经完毕,但是还有很多不足之处和解释的不详尽之处,后续会对文章不断地优化。