最近在用vue3开发svg图标组件,使用webpack读取到svg内容后利用正则移除掉了<svg>,得到了<svg>的子节点,本打算用vue的render函数创建一个svg标签,然后再以 innerHTML的方式将svg的子节点插入进去,可我终究还是太年轻了,以innerHTML形式将svg子节点插入进去后并没有效果,图标渲染不出来,于是乎就想到了将字符串的svg解析成虚拟节点。

一开始本不打算自己去写代码解析,因为这事挺麻烦,于是就去“百度”,找到了一份大佬写的解析html为虚拟dom的代码,但这位大佬的代码有bug还不够完善(当遇到复杂点的属性时解析不正确,因为他是用正则去匹配标签的属性的,没有考虑到复杂属性的情况),代码见下:

let sign_enum = {
  SIGN_END: "SIGN_END",           // 结束标签读取 如 </xxxxx>
  SIGN_END_OK: "SIGN_EN_OK",      // 结束标签读取完成
  SIGN_START: "SIGN_START",       // 开始标签读取 如 <xxxxx>
  SIGN_START_OK: "SIGN_START_OK", // 开始标签读取完成 
};
function htmlStrParser(htmlStr) {
  const str = htmlStr.replace(/\n/g, "");
  let result = { nodeName: "root", children: [] };
  // 默认 result.children[0]插入, ,这里记录调试用的栈信息
  let use_line = [0];               
  let current_index = 0;            // 记录当前插入children的下标
  let node = result;                // 当前操作的节点
  let sign = "";                    // 标记标签字符串(可能包含属性字符)、文本信息
  let status = "";                  // 当前状态,为空的时候我们认为是在读取当前节点(node)的文本信息
  for (var i = 0; i < str.length; i++) {
    var current = str.charAt(i);
    var next = str.charAt(i + 1);
    if (current === "<") {
      // 在开始标签完成后记录文本信息到当前节点
      if (sign && status === sign_enum.SIGN_START_OK) {
        node.text = sign;
        sign = "";
      }
      // 根据“</”来区分是 结束标签的(</xxx>)读取中  还是开始的标签(<xxx>) 读取中
      if (next === "/") {
        status = sign_enum.SIGN_END;
      } else {
        status = sign_enum.SIGN_START;
      }
    } else if (current === ">") {
      // (<xxx>) 读取中,遇到“>”, (<xxx>) 读取中完成
      if (status === sign_enum.SIGN_START) {
        // 记录当前node所在的位置,并更改node
        node = result;
        use_line.map((_, index) => {
          if (!node.children) node.children = [];
          if (index === use_line.length - 1) {
            sign = sign.replace(/^\s*/g, "").replace(/\"/g, "");
            let mark = sign.match(/^[a-zA-Z0-9]*\s*/)[0].replace(/\s/g, ""); // 记录标签
            // 标签上定义的属性获取
            let attributeStr = sign.replace(mark, '').replace(/\s+/g, ",").split(",");
            let attrbuteObj = {};
            let style = {};
            attributeStr.map(attr => {
              if (attr) {
                let value = attr.split("=")[1];
                let key = attr.split("=")[0];
                if (key === "style") {
                  value.split(";").map(s => {
                    if (s) {
                      style[s.split(":")[0]] = s.split(":")[1]
                    }
                  })
                  return attrbuteObj[key] = style;
                }
                attrbuteObj[key] = value;
              }
            })
            node.children.push({ nodeName: mark, children: [], ...attrbuteObj })
          }
          current_index = node.children.length - 1;
          node = node.children[current_index];
        });
        use_line.push(current_index);
        sign = "";
        status = sign_enum.SIGN_START_OK;
      }
      // (</xxx>) 读取中,遇到“>”, (</xxx>) 读取中完成
      if (status === sign_enum.SIGN_END) {
        use_line.pop();
        node = result;
        // 重新寻找操作的node
        use_line.map((i) => {
          node = node.children[i];
        });
        sign = "";
        status = sign_enum.SIGN_END_OK;
      }
    } else {
      sign = sign + current;
    }
  }
  return result;
}

console.log(htmlStrParser(htmlStr))

上面代码中给解析的节点都设置了一个状态,这一点至关重要,这是我之前没有想到的,有了解析状态就能知道遇到的字符串是节点的属性,还是节点的文本,还是节点的子节点。

“栈”思想

“栈”的特性: 先进后出(栈只有一个入口和出口,入口就是出口)
现实生活中的物体描述栈: 乒乓球桶,它只有一端可以打开,另一端是封闭的,最先放进去的乒乓球最后才能拿的出来
数据结构: 在JavaScript中实现“栈”的最好数据结构是数组

思路

  • 定义节点解析状态
    。 0--开始标签解析中,
    。 1--开始标签解析完成
    。 2--结束标签解析中
    。 3--结束标签解析完成
  • 定义一个结果数组,用来存储最终解析的虚拟dom
  • 定义一个栈数组,用来存储当前正在解析的节点,遇到一个节点就往该数组中push进去,节点解析完成后需及时将其移出栈
  • 定义一个变量(比如: remainderHtml)用来存储剩余html字符串,该变量的初始值为待解析的html字符串
  • 定义一个变量用来存储当前截取的字符串(比如:currentChars)
  • 定义一个变量用来存储引号数量
  • 使用whlile循环remainderHtml,每循环一次取出字符串的第一个字符,然后判断该字符
  • 如果第一个字符是“<”号

    • 可能遇到了新标签,此时需创建虚拟dom并记录标签名、状态、节点类型、attrs、children等信息,并添加到栈顶
    • 可能遇到了注释节点,此时需创建虚拟dom并记录标签名、状态、节点类型、children等信息,并添加到栈顶
    • 如果栈顶有节点,且节点的状态为1,可能遇到了节点的结束标签,如果是,那么此时就需要将其移出栈顶
    • 如果栈顶有节点,且节点的状态为1,如果currentChars有值,那么该值可能是节点的文本
  • 如果第一个字符是“=”号

    • 如果栈顶节点的解析状态为0并且没有被引号引起,那么表示遇到了节点属性,currentChars为节点的属性名
  • 如果第一个字符是“空格

    • 如果栈顶节点的解析状态为0并且没有被引号引起,那么表示遇到了节点属性的结束
  • 如果第一个字符是“引号

    • 如果currentChars的最后一个字符不是“/”,那么表示遇到了节点属性值的结束
  • 如果第一个字符是“>

    • 如果栈顶节点状态为0,并且没有被引号引起,那么有可能是双标签节点的开始标签解析结束,也有可能是单标签节点解析完成,单标签节点移除栈顶
    • 如果栈顶节点状态为1,并且没有被引号引起,并且currentChars的最后两个字符为“--”,此时注释节点就解析完成了,注释节点移除栈顶
  • 其他情况,currentChars += 第一个字符

代码实现

function html2vDom(htmlStr) {
    var tagNameRegexp = /^([xa-zA-Z0-9\-_$]+)\s/; // 截取标签名称正则
    var tagNameRegexp2 = /^([a-zA-Z0-9\-_$]+)\s*>/; // 截取标签名称正则
    let endTagNameRegexp = /^\/([a-zA-Z0-9\-]+)>/; // 截取结束标签名正则,如:/div>、/div>fsdfsdf>dfsdf
    let attrSplitor = '|=__=|'; // 属性名与属性值的分割符
    var vdomResult = [];
    var domStack = []; // 当前dom栈
    var remainderHtml = htmlStr; // 剩余的字符串
    let quotCount = 0; // 引号数量
    var currentChars = '';
    let attrName = ''; // 标签属性名
    //往栈顶节点添加文本节点
    let appendTextNode = function (domStackTop) {
        if (currentChars.length === 0) {
            return;
        }
        let text = currentChars.trim();
        console.log('遇到了文本节点:', currentChars, domStackTop);
        if (text.length === 0) { // 如果文本节点全是空格,则只保留一个有效空格,因为html规范如此
            text = ' ';
        }
        if (domStackTop) {
            if (domStackTop.nodeType == 8 && text.substr(-2) == '--') {
                text = text.substr(0, text.length - 2);
            }
            domStackTop.children.push({
                nodeType: 3,
                status: 3,
                text: text
            });
            currentChars = '';
        }
    }
    while (remainderHtml.length > 0) {
        let firstChar = remainderHtml.charAt(0);
        remainderHtml = remainderHtml.substr(1);
        console.log('firstchar', firstChar);
        let domStackTop = domStack[domStack.length - 1]; // 获取栈顶元素
        let domStackTopStatus = domStackTop && domStackTop.status;
        console.log('currentChars', currentChars);
        if (firstChar === '<' && quotCount == 0) {
            console.log('遇到“<”号,并且没有被引号引起', tagNameRegexp.test(remainderHtml), remainderHtml);
            if (domStackTopStatus == 1 && endTagNameRegexp.test(remainderHtml)) { // 如果栈顶节点已经解析完开始标签,此时遇到“<”则判断是否该开始解析结束标签了
                let endTagName = RegExp.$1;
                if (endTagName === domStackTop.nodeName) { // 如果结束标签与开始标签相等则该标签解析完成
                    console.log('【' + endTagName + '】标签解析结束');
                    // 结束标签解析完成前若有字符都视为节点的文本
                    appendTextNode(domStackTop);
                    domStackTop.status = 3;
                    currentChars = '';
                    domStack.pop();
                    console.log('【' + endTagName + '】标签解析结束后的栈顶:', domStack.slice());
                    // 1 + endTagName.length + 1 = ('/' + 标签名 + '>')
                    remainderHtml = remainderHtml.substr(1 + endTagName.length + 1);
                    continue;
                }
            }
            if (domStackTop && domStackTop.status == 1) { // 遇到节点的文本节点
                // 开始标签解析完成后,遇到下一个节点开始标签前的内容视为当前节点文本
                appendTextNode(domStackTop);
            }
            if (tagNameRegexp.test(remainderHtml) || tagNameRegexp2.test(remainderHtml)) { // 如果第一个字符是“<”并且可以获取标签名称则表示遇到了一个节点(这2个正则的判断顺序别弄错了,否责会解析错误)
                let tagName = RegExp.$1;
                console.log('遇到新标签:', tagName, domStackTop);
                let vdom = {
                    nodeName: tagName,
                    nodeType: 1, // 1--dom节点,3--文本节点,8--注释节点
                    attrs: {},
                    status: 0, // 标签解析状态,0--开始标签解析中,1--开始标签解析完成,2--结束标签解析中,3--结束标签解析完成
                    children: []
                }
                if (vdomResult.length == 0 || !domStackTop) {
                    vdomResult.push(vdom);
                }
                if (domStackTop) {
                    domStackTop.children.push(vdom);
                }
                domStack.push(vdom);
                remainderHtml = remainderHtml.substr(tagName.length);
            } else if (remainderHtml.substr(0, 3) == '!--') { // 如果后面3个字符为“!--”表示遇到了注释节点
                console.log('遇到注释节点');
                let vdom = {
                    name: 'comment',
                    nodeType: 8, // 1--dom节点,3--文本节点,8--注释节点
                    status: 1, // 标签解析状态,0--开始标签解析中,1--开始标签解析完成,2--结束标签解析中,3--结束标签解析完成
                    children: []
                }
                if (vdomResult.length == 0 || !domStackTop) {
                    vdomResult.push(vdom);
                }
                if (domStackTop) {
                    domStackTop.children.push(vdom);
                }
                domStack.push(vdom);
                remainderHtml = remainderHtml.substr(3);
            } 
        
            // return;
        }  else if (firstChar === '=') { // 遇到"="号
            console.log('遇到了等于号', domStackTopStatus, domStackTopStatus, quotCount);
            if (domStackTopStatus == 0 && quotCount == 0) { // 处于开始标签解析中,此时遇到等于号表示遇到了节点属性的开始
                currentChars += attrSplitor;
            } else {
                currentChars += firstChar;
            }
        }  else if (firstChar === ' ' && domStackTopStatus == 0 && quotCount == 0) { // 开始标签解析中遇到了空格
            console.log('遇到了空格', domStackTopStatus, domStackTopStatus, quotCount);
            let nextChar = remainderHtml.charAt(0);
             
            if (nextChar != ' ') { // 如果下一个字符不是空格,说明currentChars为节点的属性并且是没有属性值的属性
                let attr = currentChars.trim();
                if (attr.length > 0) {
                    console.log('添加没有属性值的属性:', currentChars.trim());
                    domStackTop.attrs[currentChars.trim()] = void 0;
                    currentChars = '';
                }
            } else {
                currentChars += firstChar;
            }
        } else if (firstChar === '"' && currentChars.charAt(currentChars.length - 1) != '\\') { // 遇到双引号并且引号没有被注释
            console.log('遇到了引号', domStackTopStatus);
            if (domStackTopStatus == 0) { // 处于开始标签解析中,此时遇到引号表示遇到了节点的属性值的开始或结束
                quotCount += 1;
                if (quotCount == 2 && currentChars.indexOf(attrSplitor) > -1) { // 凑成一对引号,此时读取属性值结束
                    let attrInfo = currentChars.split(attrSplitor);
                    let domAttrName = (attrInfo[0] || '').trim();
                    console.log('attrInfo', attrInfo);
                    // 设置属性及属性值
                    if (domAttrName) {
                        domStackTop.attrs[domAttrName] = attrInfo[1];
                    }
                    currentChars = '';
                    quotCount = 0;
                }
            } else {
                currentChars += firstChar;
            }
        } else if (firstChar == '>') { // 遇到">"号
            console.log('遇到了“>”号', domStackTopStatus);
            let currentCharLast = currentChars.charAt(currentChars.length - 1);
            if (domStackTopStatus == 0 && quotCount == 0) { // 处于开始标签解析中,并且不在引号中,说明此时遇到了开始标签的结束
                if (currentCharLast === '/') { // 如果最后个字符为“/”表示遇到了单标签的结束
                    console.log('【' + domStackTop.nodeName + '】单标签解析结束!');
                    let remainderChars = currentChars.substr(0, currentChars.length - 1).trim();
                    if (remainderChars.length > 0) { // 如果还有剩余字符则当做节点的属性
                        console.log('单标签节点添加没有属性值的属性:', remainderChars);
                        domStackTop.attrs[remainderChars] = void 0;
                    }
                    domStackTop.status = 3; // 设置节点状态为已完成
                    domStack.pop(); // 栈顶节点出栈
                } else { // 开始标签的结束
                    console.log('开始标签的结束', domStackTop);
                    let remainderChars = currentChars.trim();
                    if (remainderChars.length > 0) {
                        console.log('开始标签的结束前还有字符,当做没有属性值的属性:', remainderChars);
                        domStackTop.attrs[remainderChars] = void 0;
                    }
                    domStackTop.status = 1;
                }
                currentChars = '';
            } else if (domStackTopStatus == 1 && quotCount == 0 && currentChars.substr(-2) == '--') { // 如果最后2个字符为“--”表示遇到了注释节点的结束
                console.log('注释节点解析结束!', currentChars);
                    appendTextNode(domStackTop);
                    domStackTop.status = 3;
                    domStack.pop();
            } else {
                currentChars += firstChar;
            }
        } else {
          currentChars += firstChar;
        }
    }
    console.log('解析结果:', vdomResult);
    return vdomResult;
}

经我使用简单的、复杂的、vue语法的html字符串测试,均能按预期解析成功,下面是我测试的字符串:

var svgStr1 = '<svg class="icon" data-icon="\"cipher-app\"" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path d="M868.6 422.9c0-196.6-159.9-356.6-356.6-356.6-196.6 0-356.6 159.9-356.6 356.6H96v534.9h832V422.9h-59.4zM512 185.1c131.1 0 237.7 106.6 237.7 237.7H274.3c0-131 106.6-237.7 237.7-237.7zm297.1 653.8H214.9V541.7h594.3v297.2z"/><path d="M469.6 614.5h84.9v127.3h-84.9z"/></svg>';
var svgStr2 = '<svg class="icon" data-aa="a==1" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path d="M96 838.9h832v118.9H96V838.9zm178.3-356.6c0-131.3 106.4-237.7 237.7-237.7v118.9c-65.6 0-118.9 53.2-118.9 118.9v59.4H274.3v-59.5zM512 66.3c229.7 0 416 186.2 416 416v297.1H96V482.3c0-229.8 186.3-416 416-416zM214.9 660.6h594.3V482.3c0-164.1-133-297.1-297.1-297.1S215 318.2 215 482.3v178.3z"/></svg>';
var svgStr3 = `<svg class="icon" data-aa="a==1" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="200" height="200">
    <path d="M96 838.9h832v118.9H96V838.9zm178.3-356.6c0-131.3"/>
    <path d="M98" hidden    required  disabled :area-label="ariaLabel"   :class="{'is-disabled': disabled}" />
    </svg>`;
var svgStr4 = `
<li
  class="icon-item"
  v-for="iconInfo in (visibledIconType === 'outlined' ? outlinedIcons : filledIcons)"
  :key="iconInfo.name">
  <bs-icon
    :icon-name="iconInfo.name"
    :is-filled="iconInfo.isFilled"
    :svg-content="iconInfo.childrenContent"></bs-icon>
</li>
`;
var svgStr5 = `
    <path d="M98" hidden    required  disabled :area-label="ariaLabel"  :interaction="a+b == 1 ? \\"你好呀\\" : 'hello!'"  :class="{'is-disabled': disabled}" />
`
var html1 =  `
    <button aria-label="89个点赞" type="button" agree class="commentItem__interaction__agree">
        赞一个
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.096 2.5c1.973 0 3.313 1.823 2.944 3.6a3.027 3.027 0 0 1-.35.91l-.344.59h6.909a2 2 0 0 1 1.95 2.437l-1.929 8.618A3 3 0 0 1 16.35 21H3.753a1.5 1.5 0 0 1-1.5-1.5V9.1a1.5 1.5 0 0 1 1.5-1.5H6.41l2.607-4.479a1.25 1.25 0 0 1 1.08-.621ZM6.03 9.1H3.753v10.4H6.03V9.1Zm1.5 10.4h8.82a1.5 1.5 0 0 0 1.464-1.172l1.93-8.619a.5.5 0 0 0-.488-.609h-6.909a1.5 1.5 0 0 1-1.296-2.255l.343-.59a1.5 1.5 0 0 0-1.157-2.249L7.53 8.658V19.5Z" fill="#0C0D0F"></path></svg>
        <span>89</span>个赞
    </button>
`;
var html2 =  `

        <button aria-label="89个点赞" type="button" agree class="commentItem__interaction__agree">
            赞一个
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.096 2.5c1.973" fill="#0C0D0F"></path></svg>
                
            点击图标点赞吧!啊 啊 。啊     啊
            <span>89</span>
        </button>
        <!--账号-->
        <button class="commentItem__interaction__reply">回复</button>
    
`;
var html3 =  `
<div class="commentItem__interaction_contaniner">
    <div class="commentItem__interaction">
        <button aria-label="89个点赞" type="button" agree class="commentItem__interaction__agree">
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.096 2.5c1.973 0 3.313 1.823 2.944 3.6a3.027 3.027 0 0 1-.35.91l-.344.59h6.909a2 2 0 0 1 1.95 2.437l-1.929 8.618A3 3 0 0 1 16.35 21H3.753a1.5 1.5 0 0 1-1.5-1.5V9.1a1.5 1.5 0 0 1 1.5-1.5H6.41l2.607-4.479a1.25 1.25 0 0 1 1.08-.621ZM6.03 9.1H3.753v10.4H6.03V9.1Zm1.5 10.4h8.82a1.5 1.5 0 0 0 1.464-1.172l1.93-8.619a.5.5 0 0 0-.488-.609h-6.909a1.5 1.5 0 0 1-1.296-2.255l.343-.59a1.5 1.5 0 0 0-1.157-2.249L7.53 8.658V19.5Z" fill="#0C0D0F"></path></svg>
            <span>89</span>
        </button>
        <button class="commentItem__interaction__reply">回复</button>
    </div>
    <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" class="dotMoreAction_trigger commentItem__moreOptions"><path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 10.5a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm6.5.003a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM20 12a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0Z" fill="#0C0D0F"></path></svg>
</div>
`;
var vueTemp1 = `
<custom-form
class="secret-create-form"
ref="secretCreateForm"
:items="formItems"
:rules="rules"
:data.sync="requestData"
:showConfirm="false"
:showReset="false"
:labelWidth="labelWidth"
v-loading="!!(committing && !setParentLoading) || loading"
:element-loading-text="!!(committing && !setParentLoading) ? '提交中...' : (loading ? '加载中...' : '')"
@submit="submitData"
@select="selectChange">
<template slot="custom" slot-scope="scope">
<!-- filterable remote reserve-keyword placeholder="请输入关键词" :remote-method="remoteMethod"
        :loading="loading" -->
<el-select @change="chooleSecretCode" :disabled="Boolean(requestData.id)" v-if="scope.prop === 'secretCode'" placeholder="请选择密钥名称"
         v-model="customData.secretCode" popper-class="custom-el-popper">
<el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value">
  <span style="float: left">{{ item.label }}</span>
  <span
    style="float: right; color: #aaa; font-size: 12px">{{ {item.value.substr(item.value.length-2)}{item.version ? '--' : ''}{item.version || ''} }}</span>
</el-option>
</el-select>
</template>
</custom-form>
`;

下图是解析变量html2的结果:
image.png
如代码有bug欢迎大佬评论指正!


heath_learning
1.4k 声望31 粉丝