AST 解析器工做中常常用到,vue中的VNode就是如此!
其实若是有须要将 非结构化数据转 换成 结构化对象用 来分析、处理、渲染的场景,咱们均可以用此思想作转换。 html
咱们知道 html 源码只是一个文本数据,尽管它里面包含复杂的含义和嵌套节点逻辑,可是对于浏览器,babel 或者 vue 来讲,输入的就是一个长字符串,显然,纯粹的一个字符串是表示不出来啥含义,那么就须要转换成结构化的数据,可以清晰的表达每一节点是干吗的。字符串的处理,天然而然就是强大的正则表达式了。vue
本文阐述 AST 解析器的实现方法和主要细节,简单易懂~~~~~~~~,总共解析器代码不过百行!node
本次目标,一步一步将以下 html 结构文档转换成 AST 抽象语法树git
<div class="classAttr" data-type="dataType" data-id="dataId" style="color:red">我是外层div
<span>我是内层span</span>
</div>
复制代码
结构比较简单,外层一个div,内层嵌套一个span,外层有class,data,stye等属性。
麻雀虽小,五脏俱全,基本包含咱们常常用到的了。其中转换后的 AST 结构 有哪些属性,须要怎样的形式显示,均可以根据须要本身定义便可。
本次转换后的结构:github
{
"node": "root",
"child": [{
"node": "element",
"tag": "div",
"class": "classAttr",
"dataset": {
"type": "dataType",
"id": "dataId"
},
"attrs": [{
"name": "style",
"value": "color:red"
}],
"child": [{
"node": "text",
"text": "我是外层div"
}, {
"node": "element",
"tag": "span",
"dataset": {},
"attrs": [],
"child": [{
"node": "text",
"text": "我是内层span"
}]
}]
}]
}
复制代码
不难发现,外层是根节点,而后内层用child一层一层标记子节点,有 attr 标记节点的属性,classStr 来标记 class 属性,data来标记 data- 属性,type 来标记节点类型,好比自定义的 data-type="title" 等。正则表达式
先来看几组简单的正则表达式:数组
首先咱们将以下的 html 字符串用正则表达式表示出来:浏览器
<div>我是一个div</div>
复制代码
这个字符串用正则描述大体以下:微信
以 < 开头 跟着 div 字符,而后接着 > ,而后是中文 “我是一个 div”,再跟着 </ ,而后继续是元素 div 最后已 > 结尾。babel
div 是html的标签,咱们知道html标签是已字母和下划线开头,包含字母、数字、下滑线、中划线、点号组成的,对应正则以下:
const ncname = '[a-zA-Z_][\w-.]*'
复制代码
因而组合的正则表达式以下:
`<${ncname}>`
复制代码
根据上面分析,很容易得出正则表达式为下:
`<${ncname}></${ncname}>`
复制代码
标签内能够是任意字符,那么任意字符如何描述呢?
\s 匹配一个空白字符 \S 匹配一个非空白字符 \w 是字母数字数字下划线
\W 是非\w的
同理还有\d和\D等。
咱们一般采用\s和\S来描述任何字符(一、通用,二、规则简单,利于正则匹配):
`<${ncname}>[\s\S]*</${ncname}>`
复制代码
html标签上的属性名称有哪些呢,常见的有class,id,style,data-属性,固然也能够用户随便定义。可是属性名称咱们也须要遵循原则,一般是用字母、下划线、冒号开头(vue的绑定属性用:开头,一般咱们不会这么定义)的,而后包含字母数字下划线中划线冒号和点的,正则描述以下:
const attrKey = /[a-zA-Z_:][-a-zA-Z0-9_:.]*/
复制代码
html的属性的写法目前有如下几种:
const attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)=("([^"]*)"|'([^']*)'|([^\s"'=<>`]+)/
复制代码
attrKey 跟着 = ,而后跟着三种状况:
咱们测试一下attr的正则
"class=abc".match(attr);
// output
(6) ["class=abc", "class", "abc", undefined, undefined, "abc", index: 0, input: "class=abc", groups: undefined]
"class='abc'".match(attr);
// output
(6) ["class='abc'", "class", "'abc'", undefined, "abc", undefined, index: 0, input: "class='abc'", groups: undefined]
复制代码
咱们发现,第二个带单引号的,匹配的结果是"‘abc’",多了一个单引号‘,所以咱们须要用到正则里面的非匹配获取(?:)了。
例子:
"abcde".match(/a(?:b)c(.*)/); 输出 ["abcde", "de", index: 0, input: "abcde"]
复制代码
这里匹配到了b,可是在output的结果里面并无b字符。
场景:正则须要匹配到存在b,可是输出结果中不须要有该匹配的字符。
因而我么增长空格和非匹配获取的属性匹配表达式以下:
const attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/
复制代码
= 两边能够增长零或多个空格,= 号右边的匹配括号使用非匹配获取,那么相似 = 号右侧的最外层大括号的获取匹配失效,而内层的括号获取匹配的是在双引号和单引号里面。效果以下:
从图中咱们清晰看到,匹配的结果的数组的第二位是属性名称,第三位若是有值就是双引号的,第四位若是有值就是单引号的,第五位若是有值就是没有引号的。
有了上面的标签匹配和属性匹配以后,那么将二者合起来就是以下:
/<[a-zA-Z_][\w\-\.]*(?:\s+([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))*>[\s\S]*<\/[a-zA-Z_][\w\-\.]*>/ 复制代码
上述正则完整描述了一个节点,理解了签名的描述,如今看起来是否是很简答啦~
有了前面的html节点的正则表达式的基础,咱们如今开始解析上面的节点元素。
显然,html 节点拥有复杂的多层次的嵌套,咱们没法用一个正则表达式就把 html 的结构都一次性的表述出来,所以咱们须要一段一段处理。
咱们将字符串分段处理,总共分红三段:
因而将上述正则拆分:
const DOM = /<[a-zA-Z_][\w\-\.]*(?:\s+([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))*>[\s\S]*<\/[a-zA-Z_][\w\-\.]*>/;
// 增长()分组输出
const startTag = /<([a-zA-Z_][\w\-\.]*)((?:\s+([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))*)\s*(\/?)>/;
const endTag = /<\/([a-zA-Z_][\w\-\.]*)>/;
const attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g
// 其余的就是标签里面的内容了
复制代码
不难发现,标签已 < 开头,为标签起始标识位置,已 </ 开头的为标签结束标识位置。
咱们将 html 拼接成字符串形式,就是以下了。
let html = '<div class="classAttr" data-type="dataType" data-id="dataId" style="color:red">我是外层div<span>我是内层span</span></div>';
复制代码
咱们开始一段一段处理上面的 html 字符串吧~
const bufArray = [];
const results = {
node: 'root',
child: [],
};
let chars;
let match;
while (html&&last!=html){
last = html;
chars = true;// 是否是文本内容
// do something parse html
}
复制代码
bufArray: 用了存储未匹配完成的起始标签
results: 定义一个开始的 AST 的节点。
咱们再循环处理html的时候,若是已经处理的字符,则将其删除,这里判断 last!=html 若是处理一轮以后,html 仍是等于 last,说明没有须要处理的了,结束循环。
首先判断是不是 </ 开头,若是是则说明是标签结尾标识
if(html.indexOf("</")==0){
match = html.match(endTag);
if(match){
chars = false;
html = html.substring(match[0].length);
match[0].replace(endTag, parseEndTag);
}
}
复制代码
已 </ 开头,且能匹配上实时截止标签的正则,则该 html 字符串内容要向后移动匹配到的长度,继续匹配剩下的。
这里使用了 replace 方法,parseEndTag 的参数就是"()"匹配的输出结果了,已经匹配到的字符再 parseEndTag 处理标签。
若是不是已 </ 开头的,则判断是不是 < 开头的,若是是说明是标签起始标识,同理,须要 substring 来剔除已经处理过的字符。
else if(html.indexOf("<")==0){
match = html.match(startTag);
if(match){
chars = false;
html = html.substring(match[0].length);
match[0].replace(startTag, parseStartTag);
}
}
复制代码
若是既不是起始标签,也不是截止标签,或者是不符合起始和截止标签的正则,咱们统一当文本内容处理。
if(chars){
let index = html.indexOf('<');
let text;
if(index < 0){
text = html;
html = '';
}else{
text = html.substring(0,index);
html = html.substring(index);;
}
const node = {
node: 'text',
text,
};
pushChild(node);
}
复制代码
若是是文本节点,咱们则加入文本节点到目标 AST 上,咱们着手 pushChild 方法,bufArray 是匹配起始和截止标签的临时数组,存放尚未找到截止标签的起始标签内容。
function pushChild (node) {
if (bufArray.length === 0) {
results.child.push(node);
} else {
const parent = bufArray[bufArray.length - 1];
if (typeof parent.child == 'undefined') {
parent.child = [];
}
parent.child.push(node);
}
}
复制代码
若是没有 bufArray ,说明当前Node是一个新Node,不是上一个节点的嵌套子节点,则新push一个节点;不然 取最后一个bufArray的值,也就是最近的一个未匹配标签起始节点,将当前节点当作为最近节点的子节点。
<div><div></div></div>
复制代码
显然,第一个 </div> 截止节点,匹配这里的第二个起始节点
在每一轮循环中,若是是符合预期,html字符串会愈来愈少,直到被处理完成。
接下来咱们来处理 parseStartTag 方法,也是稍微复杂一点的方法。
function parseStartTag (tag, tagName, rest) {
tagName = tagName.toLowerCase();
const ds = {};
const attrs = [];
let unary = !!arguments[7];
const node = {
node: 'element',
tag:tagName
};
rest.replace(attr, function (match, name) {
const value = arguments[2] ? arguments[2] :
arguments[3] ? arguments[3] :
arguments[4] ? arguments[4] :'';
if(name&&name.indexOf('data-')==0){
ds[name.replace('data-',"")] = value;
}else{
if(name=='class'){
node.class = value;
}else{
attrs.push({
name,
value
});
}
}
});
node.dataset = ds;
node.attrs = attrs;
if (!unary){
bufArray.push(node);
}else{
pushChild(node);
}
}
复制代码
遇到起始标签,若是该起始标签不是一个结束标签(unary为true,如:,若是自己是截止标签,那么直接处理完便可),则将起始标签入栈,等待找到下一个匹配的截止标签。
起始标签除了标签名称外的属性内容,咱们将 dataset 内容放在dataset字段,其余属性放在attrs
咱们接下来看下处理截止标签
function parseEndTag (tag, tagName) {
let pos = 0;
for (pos = bufArray.length - 1; pos >= 0; pos--){
if (bufArray[pos].tag == tagName){
break;
}
}
if (pos >= 0) {
pushChild(bufArray.pop());
}
}
复制代码
记录还未匹配到的起始标签的bufArray数组,从最后的数组位置开始查找,找到最近匹配的标签。
好比:
<div class="One"><div class="Two"></div></div>
复制代码
class One的标签先入栈,class Two的再入栈,而后遇到第一个</div>,匹配的则是class Two的起始标签,而后再匹配的是class One的起始标签。
到此,一个简单的 AST解析器已经完成了。
固然,本文是实现一个简单的 AST解析器,基本主逻辑已经包含,完整版参考以下:
本文的 AST解析器的完整代码以下:
若是你以为这篇内容对你有价值,请点赞,并关注咱们的官网和咱们的微信公众号(WecTeam),每周都有优质文章推送: