【VUE】源码分析 - vue中的 HTMLParser,模板解析的第一步

 

tip:本系列博客的代码部分(示例等除外),均出自vue源码内容,版本为2.6.14。但是为了增加易读性,会对不相关内容做选择性省略。如果大家想了解完整的源码,建议自行从官方下载。

GitHub - vuejs/vue: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web. - GitHub - vuejs/vue: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.https://github.com/vuejs/vueicon-default.png?t=M4ADhttps://github.com/vuejs/vue 本系列博客 ( 不定期更新 ):

【VUE】源码分析 - computed计算属性的实现原理_依然范特西fantasy的博客-CSDN博客

【VUE】源码分析 - watch侦听器的实现原理_依然范特西fantasy的博客-CSDN博客

【VUE】源码分析 - 数据劫持的基本原理_依然范特西fantasy的博客-CSDN博客

【VUE】源码分析 - 剖析$nextTick原理及VUE的刷新机制_依然范特西fantasy的博客-CSDN博客

引言

我们都知道,vue会生成vnode来代替DOM进行patch操作。而vnode其实就是render函数的产物。或许在使用vue的时候,你有通过render来代替template。那么你是否有思考过,二者之间又有什么奇妙的联系呢?

HTMLParser简介

HTMLParser并不是vue提出的概念。

简单来说,HTMLParser是浏览器的渲染进程中一个专门负责解析HTML文件的模块。浏览器并不能直接理解和使用HTML文档,因此就需要这样一种工具,能将HTML解析为浏览器能够理解的结构,也就是常说的DOM树。基于此DOM树,渲染引擎会继续做分层、绘制、合成等操作,最终将HTML转换为页面上一帧帧的内容。

vue只是借鉴了这样一种概念。但是区别在于,对于vue来说,最终目的并不是生成页面,而是生成render函数。而vue的后续操作,比如生成vnode,就是基于render函数进行的。

而要将template模板转换为render函数,抽象语法树AST就是绕不开的一个课题。AST的应用场景非常广泛,比如浏览器对JS的编译、babel对JS代码的转换、ESLint对代码的检测,都有AST的身影。对于vue来说,Parser的作用,就是将template模板转换为一颗AST,供后续生成render函数字符串使用vue编译模板的大致流程,可以结合下图理解:

 在浏览器中,HTMLParser代表的就是HTML解析为AST(也就是DOM树)的过程,包含词法分析和语法分析;而在vue中,将HTMLParser的概念做了简化,只代表词法分析阶段,也就是生成Token的过程。

构建AST的流程

首先通过浏览器中HTMLParser的大致流程,对AST的构建形成初步的认识:

1,将一个HTML标签分解为startTag,endTag和文本内容三部分对待;

2,创建一个栈结构,用来存放startTag;

3,遇到起始标签时入栈,遇到结束标签时出栈。每插入一个起始标签,都会以栈中上一个标签当作父节点;

4,对自闭合标签做特殊处理;

5,若出现嵌套问题,即结束标签与栈顶标签对应不上,则从栈顶一直循环出栈,直到找到相匹配的起始标签,并抛出错误;

用一个简单的例子来演示上述过程:

<div>
    <div>123</div>
    <span></span>
</div>

 通过上述不断的出栈入栈,就能构建出一颗完整的AST结构。而vue构建AST也与之类似,只是在具体实现的细节上稍有出入。

vue中的HTMLParser

在vue的HTMLParser中,会将构建AST的任务通过钩子抛给parse函数去实现,而它的核心,就只需要通过template模板生成一个个Token,并将Token组装为相应的数据结构,传递给parse即可。

如上述代码截图,parse函数内部会调用parseHTML函数,并将start、end、chars、comment四个钩子函数通过参数的形式传递给parseHTML。而这四个钩子分别就对应着起始标签、结束标签、文本内容和注释内容的语法分析。

再来看HTMLParser的内部:

其中,html为template模板的字符串形式,stack为前面所说到的“栈结构”。而整个词法过程,都在这个while(html)语句中进行。整个过程,会从头开始解析html字符串,并逐渐截取掉已解析的html字符串,直到html字符串为空,也就代表整个html都解析完成。

至于vue是如何判断标签类型的,很简单,用正则表达式去匹配。关于vue中的正则表达式,我在如下的博客中有做介绍,感兴趣的可以点进去看一看:

【JS】正则表达式。以vue词法分析、句法分析为例_依然范特西fantasy的博客-CSDN博客

当然,不了解正则也没关系,你只需要知道它会匹配到相应的标签类型即可。

可以看到,在while语句中,就是通过正则的匹配,对不同的标签做相应的处理。 

在本篇博客中,只会分析starTag和endTag的主流程。

startTag

我们先从起始标签看起。

const startTagMatch = parseStartTag()
if (startTagMatch) {
  handleStartTag(startTagMatch)
  continue
}

在对起始标签的处理中,会先去调用parseStartTag函数,该函数的代码如下:

  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
      }
    }
  }

1,首先会去匹配正则表达式,如果能成功匹配,则该正则还会捕获tagName信息;

2,创建一个match字面量对象,先给tagName和start属性赋值。tagName就为正则所匹配的tagName,也就是当前匹配的标签名。而index即为当前标签处于html字符串的下标位置;

3,将html前进所匹配内容长度个字符。advance代码很简单,如下所示:

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

4,对截取过后的html字符串,再做一个标签内部的while循环。而该循环的作用,简单来说,就是获取起始标签结束之前的所有属性内容(在起始标签的匹配中,也是分为了三部分:起始标签的开始startTagOpen,标签内的属性attribute,起始标签的结束startTagClose);

5,在标签内部的循环中,会通过正则获取到属性的信息,并将这些信息统统push到match对象的attr数组中。匹配的过程与主循环类似,都是匹配、捕获、截取(html前进)。直到匹配到startTagClose,退出标签的内部循环;

6,退出parseStartTag函数,并返回match对象。同样,需要做advance将html截取掉解析的部分。而在此给match对象添加上的unarySlash属性,即为通过startTagClose捕获到的 > 之前的 / 。如果该值为 / ,则代表标签自闭合,否则则代表为普通标签。

而最终返回的match对象,给大家一个实例:

'<div v-if="isSucceed" v-for="v in map"></div>'
//其startTag会匹配成如下
match = {
  tagName: "div",
  attrs: [
    [' v-if="isSucceed"', "v-if", "=", "isSucceed", undefined, undefined],
    [' v-for="v in map"', "v-for", "=", "v in map", undefined, undefined],
  ],
  start: index,
  unarySlash: undefined,
  end: index,
};

也就是包含许多语法片段Token的对象。

结束了parseStartTag函数之后,我们再回到主循环体当中:

const startTagMatch = parseStartTag()
if (startTagMatch) {
  handleStartTag(startTagMatch)
  continue
}

如果startTagMatch有值,也就是返回的match对象。此时,对match对象做handleStartTag处理,以下就是handleStartTag函数内容(有做删减):

  function handleStartTag (match) {
    const tagName = match.tagName
    const unarySlash = match.unarySlash

    const unary = isUnaryTag(tagName) || !!unarySlash

    const l = match.attrs.length
    const attrs = new Array(l)
    for (let i = 0; i < l; i++) {
      const args = match.attrs[i]
      const value = args[3] || args[4] || args[5] || ''

      attrs[i] = {
        name: args[1],
        value: decodeAttr(value)
      }
    }

    if (!unary) {
      stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
      lastTag = tagName
    }

    if (options.start) {
      options.start(tagName, attrs, unary, match.start, match.end)
    }
  }

该函数的主逻辑并不难懂:1,将attrs数组的每一项处理为更规范的name、value形式;2,通过判断是否为自闭合标签,决定是否将标签添加至stack栈(因为自闭合标签不需要入栈,其不会有内容,也不会有结束标签);3,调用start,也就是parse函数传递过来的函数钩子。

也就是说它一共做了三件事情:包装attrs、入栈、调用start钩子

其最终的产出如下:

{
  tag: "div",
  lowerCasedTag:'div',
  attrs: [
    {
      name: "v-if",
      value: "isSucceed",
    },
    {
      name: "v-for",
      value: "v in map",
    },
  ],
  start: index,
  end: index,
};

以上,就是整个startTag的词法分析过程。最终会将其入栈,并把生成的一些属性通过参数传递给parse的钩子函数start。

endTag

接下来我们看结束标签。

const endTagMatch = html.match(endTag)
if (endTagMatch) {
  const curIndex = index
  advance(endTagMatch[0].length)
  parseEndTag(endTagMatch[1], curIndex, index)
  continue
}

与startTag的处理逻辑很类似,依旧是先匹配,然后再做parseEndTag处理。而不同点在于,结束标签中,不会存在众多属性,因此endTag不需要做复杂的属性处理,只需要单独对当前标签做解析即可。

function parseEndTag (tagName, start, end) {
    let pos, lowerCasedTagName

    // Find the closest opened tag of the same type
    if (tagName) {
      lowerCasedTagName = tagName.toLowerCase()
      for (pos = stack.length - 1; pos >= 0; pos--) {
        if (stack[pos].lowerCasedTag === lowerCasedTagName) {
          break
        }
      }
    } else {
      // If no tag name is provided, clean shop
      pos = 0
    }

    if (pos >= 0) {
      // Close all the open elements, up the stack
      for (let i = stack.length - 1; i >= pos; i--) {
        if (process.env.NODE_ENV !== 'production' &&
          (i > pos || !tagName) &&
          options.warn
        ) {
          options.warn(
            `tag <${stack[i].tag}> has no matching end tag.`,
            { start: stack[i].start, end: stack[i].end }
          )
        }
        if (options.end) {
          options.end(stack[i].tag, start, end)
        }
      }

      // Remove the open elements from the stack
      stack.length = pos
      lastTag = pos && stack[pos - 1].tag
    } else if (lowerCasedTagName === 'br') {
      if (options.start) {
        options.start(tagName, [], true, start, end)
      }
    } else if (lowerCasedTagName === 'p') {
      if (options.start) {
        options.start(tagName, [], false, start, end)
      }
      if (options.end) {
        options.end(tagName, start, end)
      }
    }
  }

对于parseEndTag函数来说,主要逻辑就是去匹配stack中的起始标签,或者当匹配不上的时候做一些错误抛出处理。我们还是一步步分析:

首先是对pos属性做处理。该属性也就代表着结束标签在栈中匹配项的位置。一共有三种情况:a.匹配到了某一项,但是不确定是否为正确的位置是否正确,不过可以确定地是pos一定大于0;b.没有匹配到,则当循环结束后,pos的值应该为-1;c.tagName不存在,则此时将pos的值置为0。至于前面两点应该比较好懂,而c情况为何如此,我们会在之后做分析。

处理完pos之后,在根据pos的值来做相应操作。首先分析pos小于0的情况:意味着某个结束标签未能匹配到相应的开始标签。对于这种情况,vue并不会抛出错误,但是会处理两种特殊情情况:</br>和</p>。当解析到这两个标签的时候,会将其当作正常的起始标签处理,其目的是为了与浏览器的默认行为保持统一。其实不止这一处,在很多地方也会有类似的代码,根本目的就是不改变浏览器的默认行为,这也是一个优秀的框架必备因素之一:尽量不要改变一些默认行为。

接着我们再来分析pos大于等于0的情况。其实也不复杂,就是从stack的栈顶一直循环到pos的位置,然后对每一个都做报错处理,并将其出栈。为什么能确定从pos到栈顶的所有元素都是不匹配的元素呢?回过头再去看看pos的处理就明白了:因为pos的位置就对应的是从栈顶一直向下寻找,找到的第一个匹配元素的位置。那么如果当前的pos不为栈顶元素的index,就代表从pos一直到栈顶都是不匹配元素。因此,只需要做错误抛出,并将嵌套混乱的元素出栈即可。而对于结束标签与栈顶匹配的元素呢,依旧可以通过此逻辑实现:此时的 i不会大于pos,因此不会报错,只是正常出栈。

至此我们就分析完了parseEndTag函数的主逻辑。但是我们遗留了一个问题还未解答:

为何会存在tagName的情况,并且为何还要将此中情形下的pos赋值为0?

细心的同学可能在之前的代码就发现了奥妙。因为parseEndTag不仅仅会在主循环体内调用,还会在很多其他地方调用。而对于tagName为undefined的情景,是当循环结束时,也就是html解析完成时,紧接着while语句会调用一个不传参的parseEndTag函数,在函数的内部,对于此种情况会将pos赋值为0,从而导致stack从下标为0一直到栈顶,也就是栈内所有元素都会被作为错误情况对待。

大家可以思考以下这是为什么。其实很简单,当html解析完成之时,若此时栈未清空,则代表栈内的元素肯定为不闭合的标签,而对于这些在解析完成之后仍不闭合的标签,肯定是需要做错误抛出的。vue很巧妙的复用了parseEndTag函数来实现了最终错误清算的功能。

以上startTag和endTag的解析,也就是整个HTMLParser最核心的模块了。当然,我们还有注释内容、doctype标签、文本内容、纯文本标签并没有展开,但是其实它们的实现上也都是大同小异的。大家可以通过上述解析逻辑,自行阅读源码,完整的了解整个HTMLParser流程。

vue的parse函数

HTMLParser是词法分析的过程,而parse函数则是语法分析的过程。简单来说,就是通过对HTMLParser生成的语法片段的一些加工处理,最终生成AST。当然,这二者并不是分隔开执行的。大家或许有注意到,在startTag和endTag的最后一步,总是会调用start或end钩子,而这些钩子,就是用来生成AST。也就是说,二者是在功能上做区分,但是在执行顺序上不做区分

当然,parse函数的逻辑相对来说更复杂,牵涉到各个指令的解析。比如v-if、v-for、v-model、v-on等,就是在parse中做初步解析的。可以说,HTMLParser只是完成了整个模板解析阶段的一小步。关于parse函数,我会在之后的博客做详细的分析。

文中内容均带有个人理解,并不保证权威。若有错误,欢迎随时批评指正。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值