Pure-JavaScript-HTML5-Parser源码解读

有个需求要用到html标签解析,又碰巧之前有人写过,就直接用了之前用的东西https://github.com/blowsie/Pure-JavaScript-HTML5-Parser,git上星不多,不过感觉思路比较特别,和我最开始想的不太一样,稍微看了看原理,总结一下。因为没有release版本,只能写一个commit版本号,3e8b2b1153a40495f9a16506c778d00150c6b7a3,2015年9月21日,真是久远,所以有些标签的定义也和现在不太一样了,大概。

PJHP(简写)提供了三个函数,HTMLParser,HTMLtoXML,HTMLtoDOM,其中后面两个是基于第一个的,也就是HTMLParser的用法之一,那么我们来看HTMLParser

先看官方例子

var results = "";

HTMLParser("<p id=test>hello <i>world", {
  start: function( tag, attrs, unary ) {
    results += "<" + tag;
 
    for ( var i = 0; i < attrs.length; i++ )
      results += " " + attrs[i].name + '="' + attrs[i].escaped + '"';
 
    results += ">";
  },
  end: function( tag ) {
    results += "</" + tag + ">";
  },
  chars: function( text ) {
    results += text;
  },
  comment: function( text ) {
    results += "<!--" + text + "-->";
  }
});

执行完后results为<p>hello <i>world</i></p>,去掉第二个参数里的一些属性也是可以照样工作的,当然全部去掉就没有了。代码一共不到两百行。

代码的大体结构:先定义了三个正则表达式,startTag、endTag、attr,还有一堆的标签和属性作为全局使用。
在HTMLParser函数体里,有一个大的while循环、两重的if判断和一个parseStartTag函数和一个parseEndTag函数,没了,比较简洁。

整个过程中我们要克服的困难主要有:

  1. 起始标签和结束标签不一致,或者只有起始标签没有结束标签
  2. 标签的嵌套,并且标签中还可能有文本,块级标签和行级标签的嵌套顺序
  3. 其他问题

首先来看if (html.indexOf("<!--") == 0)这一级(内层)的几个判断,先通过<!--</<分别来判断注释、结束标签和起始标签,如果都没有匹配到,进入if (chars)这个判断里去,if (chars)的大括号里有两种实现,有点意思,来看看

一种是这样的

index = html.indexOf("<");

var text = index < 0 ? html : html.substring(0, index);
html = index < 0 ? "" : html.substring(index);

if (handler.chars) handler.chars(text);

另一种是这样的

index = html.indexOf("<");
let text = "";
while (index === 0) {
    text += "<";
    html = html.substring(1);
    index = html.indexOf("<");
}

text += index < 0 ? html : html.substring(0, index);
html = index < 0 ? "" : html.substring(index);

if (handler.chars) handler.chars(text);

这两种的区别在于第二种多了个while循环,就这么看这个代码可能看不出具体是干嘛用的,但是如果用<p><<asd</p>作为输入参数就会发现,第一种会解析出错,因为解析到<<asd这里的时候,变量html没有被裁剪,导致下面有一句if (html == last)生效了,抛出了解析错误(这里可以看到只有当一次循环下来,html没有变化时,才会报解析出错)。第二种的while循环就是为了应对这种情况。

至此注释和文本的两个判断里的内容已经没啥可看的了,来看看起始标签和结束标签的两个判断。

起始标签

match = html.match(startTag);

if (match) {
    html = html.substring(match[0].length);
    match[0].replace(startTag, parseStartTag);
    chars = false;
}

先正则匹配startTag起始标签,如果匹配到了,通过startTag里的捕获组把捕获到的标签找出来,从html变量里去掉,然后通过replace函数来执行parseStartTag,这里用replace是为了组装parseStartTag的输入参数,不起到替换作用。

parseStartTag函数的四个参数分别是tag:标签, tagName:标签名,rest:属性,unary:是否单个标签,对于输入<p>hello <i>world</i></p>来说,第一次执行parseStartTag时四个参数是

tag: <p>
tagName: p
rest: ''
unary: ''

参数的形式还是和startTag的写法有关,如果输入是<img src='123' />hello <i>world</i>,第一次执行parseStartTag时四个参数是

tag: <img src='123' />
tagName: img
rest: ' src="123"'
unary: '/'

现在我们来看下parseStartTag干了什么

HTMLParser函数里唯一的一句stack.push就在parseStartTag函数里,当标签是成对出现的标签(比如div,p这种)时,会执行stack.push,自闭合标签(代码里写的是empty,直译是空标签,代码里定义的closeSelf,自闭合标签是colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr这些,并且代码里inline和block也有些和我所知的不一样),比如img,或者<div />这种形式,不会执行。

如果我们用<img src='123' alt="picture" />作为输入参数,会执行到rest.replace(attr, function (match, name)这句里去,里面是处理标签的属性的,按照当前的输入,function第一次执行的参数如下

0: "src="123""
1: "src"
2: "123"
3: undefined
4: undefined
5: 1
6:  src="123" alt="picture"

然后所有属性会保存到attrs这个变量中去,可惜这一句目前我还没看懂value.replace(/(^|[^\\])"/g, '$1\\\"'),parseStartTag里还有些代码要和parseEndTag一起看,所以我们来看下parseEndTag。

parseEndTag出现在这么几个地方,1.结束标签判断里,2.外层的else里,3.大while循环结束的地方,4.parseStartTag函数里的两个if里。出现次数很多。

我们用<div><img src="123" alt="picture" /></div>作为输入来看一看,首先会进入结束标签的判断else if (html.indexOf("</") == 0)里调用的那个parseEndTag,参数是标签和标签名,此时div在起始标签里push过一次stack,所以会执行else和第二个if,但是看不出什么端倪。

我们把输入改成<div><p><img src="123" alt="picture" /></p></div><div></a>再来看看,这时先进入parseEndTag的是</p>,而stack里已有的是一个div和一个p,在else中找到了和结束标签</p>一致的起始标签<p>,并标记了位置,在第二个if中将已经匹配的起始标签通过stack.length = pos;给去除。后面</div>进入parseEndTag,情况相同,但是</a>进入parseEndTag时,在else中匹配不到对应的标签,并且没有进入第二个if,直到while结束位置的parseEndTag,此时第二个if通过stack把先前没有匹配到的<div>还原出来,所以我们最开始输入的<p id=test>hello <i>world会输出<p>hello <i>world</i></p>

接下来我们把输入改成<span><div>lalala</div></span>来看看。当执行到<div>时,在parseStartTag中会进入第一个if,看下这个if的代码可以看出其实是要处理行级标签包裹了块级标签的情形,处理方式是通过parseEndTag闭合行级标签,这里看似stack.last()取的不对,因为可能不是最靠近当前的块级标签的那个标签是行级,但是再往前推一定会有一个行级标签和块级标签相邻。

if (block[tagName]) {
    while (stack.last() && inline[stack.last()]) {
        parseEndTag("", stack.last());
    }
}

第二个if的逻辑有点奇怪,并且关于浏览器会自动关闭的单个标签已经不止代码里定义的那几种了,所以第二个if就不讲了。

接下来我们把输入改成<head><style>.main{color:red;}</style></head>来看看,可以看到代码运行到了外层的else中去,然后通过html.replace(new RegExp("([\\s\\S]*?)<\/" + stack.last() + "[^>]*>")来匹配对应的</style>标签,然后通过parseEndTag来将style闭合。这里代码处理的不好的地方是如果只出现了起始的style,而没有出现闭合标签,因为匹配不到结束标签,解析会出错,我目前能想到的也就只有去匹配一个<作为结束,但是这样也不能完美解决问题。

这样parseEndTag出现的四种情形我们就都说完了,HTMLParser的代码也全部说完了。

总结下代码的主要逻辑:

  1. 传入的参数作为字符串,存在html这个变量里,从头到尾顺序往下匹配,匹配到一种类型(起始标签,结束标签,注释,文本)就把html截断到不属于这个类型的范围,一直截到html为空结束

  2. 起始的标签会push到一个stack变量中去,作为后面需要查询前面标签的保存

  3. 结束标签如果和起始标签不一致是会被丢弃的,起始标签作为基准,并且在parseEndTag中通过用户提供的处理函数处理结束标签

  4. 循环结束后还有一波收尾工作,闭合没有闭合的标签

  5. 遇到style标签和script标签有单独的处理逻辑,看着像是另一个作者写的

总结一下PJHP的优缺点,优点:短小精悍,缺点:可读性较差(其实还好),正则匹配较复杂,可能性能不好(下次再测)

还有一些算不上缺陷也不能算bug的东西:

  1. 对於单独的结束标签会直接丢弃,单独的起始标签会按先进后出的方式还原,但是如果是类似head和body一起出现的情况就坑了
  2. 只能处理成对出现的<style><script>,单个出现会报错
  3. 对于doctype什么的完全没处理,不过doctype不算html标签就是了

要注意的是,如果想要把script、style等标签里的内容给去掉,很麻烦,只能在解析开始前把不需要的标签都去掉。如果要解析成dom树的样子,可以结合这个https://github.com/Jxck/html2json。

下次有空的时候,会和其他的工具库做个比较,比如htmlparser2

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章