threeparse

541 阅读3分钟

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>
`));

总结

至此,基本解析的代码已经完毕,但是还有很多不足之处和解释的不详尽之处,后续会对文章不断地优化。