Vue.js 源码(10)——HTML解析器

331 阅读1分钟

这是我参与更文挑战的第10天,活动详情查看: 更文挑战

前言

前面,我们简单学习了 HTML 解析器的基本原理。本文我们将一起学习具体实现。

截取开始标签

之前我们讲过,每一轮循环都是从模板的最前面截取,所以只有模板以开始标签开头,才需要进行开始标签的截取操作。

那么,如何确定模板是不是以开始标签开头?

在HTML解析器中,想分辨出模板是否以开始标签开头并不难,我们需要先判断HTML模板是不是以 < 开头。

如果模板以 < 开头,那么它有可能是以开始标签开头的模板,同时它也有可能是以结束标签开头的模板,还有可能是注释等其他标签,因为这些类型的片段都以 < 开头。那么,要进一步确定模板是不是以开始标签开头,还需要借助正则表达式来分辨模板的开始位置是否符合开始标签的特征。

如何使用正则表达式来匹配模板以开始标签开头?

const ncname = `[a-zA-Z_][\\w\\-\\.]*`;
const qnameCapture = `((?:${ncname}\\:?${ncname})`;
const startTagOpen = new RegExp(`^<${qnameCapture}`);


'<div></div>'.match(startTagOpen) // ["<div", "div", index: 0, inputt: "<div></div>"]

在分辨出模板以开始标签开始之后,就可以得到标签名,而属性和自闭合标识则需要进一步解析。

上面这个正则表达式虽然可以分辨出模板是否以开始标签开头,但是它的匹配规则并不是匹配整个开始标签,而是开始标签的一小部分。

开始标签被拆分成三个小部分,分别是 标签名属性结尾

image.png

通过“标签名”这一段字符,就可以分辨出模板是否以开始标签开头,此后要想得到属性和自闭合标识,则需要进一步解析。

解析标签属性

在分辨出模板以开始标签开头之后,会将开始标签中的标签名这一小部分截取掉,因此在解析标签属性时,我们得到的模板是下面伪代码中的样子:

' class="box"></div>'

下面的伪代码展示了如何解析开始标签中的属性,但是它只能解析一个属性:

const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box" id="el"></div>';
let attr = html.match(attribute);
html = html.substring(attr[0].length);
console.log(attr)
// [' class="box"', 'class', '=', 'box', undefined, undefined, index: 0, input: '  class="box" id="el"></div>']

上面只解析了一个 class 属性,还有一个 id 属性没有解析。实际上,属性也可以分成多个部分来解析,一部分一部分去解析和截取。

const startTagClose = /^\s*(\/?)>/
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
let html = ' class="box" id="el"></div>';
let end, attr;
const match = {
    tagName: 'div',
    attrs: []
};
while (!(end = html.match(startTagClose)) && (attr = html.match(atttribute))) {
       html = html.substring(attr[0].length);
       match.attrs.push(attr)
}

如果剩余HTML模板不符合开始标签结尾部分的特征,并且符合标签属性的特征,那么进入到循环中进行解析与截取操作。

最终,match 的结果为:

{
    tagName: "div",
    attrs: [
        [' class="box"', 'class', '=', 'box', null, null],
        [' id="el"', 'id', '=', 'el', null, null]
    ]
}

此时剩余模板是下面的样子:

"></div>"

解析自闭合标识

自闭合标签是没有子节点的,在解析的过程中,一个节点是否需要推入到栈中,可以使用这个自闭合标识来判断。

如果解析开始标签中的结尾标签?

function parseStartTagEnd(html){
    const startTagClose = /^\s*(\/?)>/;
    const end = html.match(startTagClose);
    const match = {};
    if (end) {
        match.unarySlash = end[1];
        html = html.substring(end[0].length);
        return match;
    }
}

parseStartTagEnd("/></div>") // { unarySlash: "/" }

自闭合标签解析后的 unarySlash 属性为 /,而非自闭合标签为空字符串

实现源码

function advance(n) {
  index += n;
  html = html.substring(n);
}

function parseStartTag() {
  // 解析标签名
  const start = html.match(startTagOpen);
  if (start) {
    const match = {
      tagName: start[1],
      attrs: [],
      start: index,
    };
    advance(start[0].length);
    let end, attr;
    // 解析标签属性
    while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
      attr.start = index;
      advance(attr[0].length);
      attr.end = index;
      match.attrs.push(attr);
    }
    // 判断标签是否是自闭合标签
    if (end) {
      match.unarySlash = end[1];
      advance(end[0].length);
      match.end = index;
      return match;
    }
  }
}

截取结束标签

结束标签的截取要比开始标签简单得多,因为它不需要解析什么,只需要分辨出当前是否已经截取到结束标签,如果是,那么触发钩子函数就可以了。

如果HTML模板的第一个字符不是 <,那么一定不是结束标签。只有 HTML 模板的第一个字符是 < 时,我们才需要进一步确认它到底是不是结束标签。

const ncname = `[a-zA-Z_][\\w\\-\\.]*`;
const qnameCapture = `((?:${ncname}\\:?${ncname})`;

const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)

'</div>'.match(endTag) // ["</div>", "div", index: 0, input: "</div>"]
"<div>".match(endTag) // null

当分辨出结束标签后,需要做两件事,一件事是截取模板,另一件事是触发钩子函数:

const  endTagMatch = html.match(ednTag);
if (endTagMatch) {
    html = html.substring(endTagMatch[0].length);
    options.end(endTagMatch[1])
    continue;
}

截取注释

分辨模板是否已经截取到注释的原理与开始标签和结束标签相同,先判断剩余 HTML 模板的第一个字符是不是 <,如果是,再用正则表达式来进一步匹配:

const comment = /^<!--/;
if (comment.test(html)){
    const commentEnd = html.indexOf('-->');
    if (commentEnd >= 0) {
        html = html.substring(commentEnd + 3);
    }
}

截取条件注释

截取条件注释的原理与截取注释非常相似。Vue.js 中条件注释其实没有用,写了也会被截取掉。