「.vue文件的编译」2. 模板编译之 simple-html-parser.js

我心飞翔 分类:vue

是因为vue@2.6.11的模板编译用到这个库,因此拿过来分析下。

要想将html转成AST,首先是要正确的解析(遍历)出html的结构,simple-html-parser.js就是做这个事情的(vue@2.6.11就是用的这个库)。在这个解析的过程中会调用一些回调如startendchars等,在这些回调中会完成html的AST的构造。

主流程分析

demo 演示下整体过程

在编辑器中的形式

<div id="app" class="container">
  <div @click="clickHandler">
    before
    <span v-if="showSpan">span tag</span>
    <div v-for="item in items">
      <span> {{ item }}</span>
    </div>
  </div>
</div>

如果是runtime + compiler运行时版本上面内容是会先在浏览器中渲染出来的。而vue-loader版本是直接从template中读出的。不管哪种,都会被转为下面的字符串形式。

「.vue文件的编译」2. 模板编译之 simple-html-parser.js

显然算法的目的是要遍历完所有的字符,因此有一个指针(index = 0,初始值为0)来推动整个遍历向前不断推进。html字符串的核心标识就是标签的<符号,因此会查找这个符号,如果找到说明可能存在html标签,因此会继续判断是开始标签(如<div)还是结束标签(如</div>)。

显然合法的html中先从一个开始标签开始,如下,当确认是一个开始标签后会进一步从开始标签中找出所有的属性如下面的id="app"class="container",直到遇到开始标签的结束符>或者/>。每次匹配上一个标签指针都会不断往前推进,<div ...>遍历完后,因为当前标签还没有遇到结束标签</div>,因此会先保存到stack中。随后会进入下一次循环。

「.vue文件的编译」2. 模板编译之 simple-html-parser.js

这一次循环发现开始部分是文本如这里的\n ,获取文本后,指针直接往前推进到有<字符的位置。

「.vue文件的编译」2. 模板编译之 simple-html-parser.js

...又经过若干轮的上述步骤,开始标签和文本匹配的场景

来到了一个结束标签如这里的</span>,这里主要逻辑就是从栈(上面的stack存储着所有的开始标签)中弹出,说明这个标签已经解析结束。

「.vue文件的编译」2. 模板编译之 simple-html-parser.js

... 按照上面的三种case,指针不断往前推进,直到结束。

总结

上面demo给出了最普通的场景,也是整个html解析过程最核心的过程。其他的一些特殊场景(script, p, br等,自闭和标签,一元标签),在后面可能会补充一下。

主体流程

变量解释

// Regular Expressions for parsing tags and attributes
const attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)[[^=]+][^\s"'<>/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\-\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(/?)>/
const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`)

// Special Elements (can contain anything)
export const isPlainTextElement = makeMap('script,style,textarea', true)
  • attribute 和 dynamicArgAttribute 是用来匹配开始标签中的所有属性的
  • qnameCapture 给出了标签名称的合法字符
  • startTagOpen:匹配开始标签如<div
  • startTagClose:匹配开始标签的结束符 /> 或者 >
  • endTag:匹配结束标签如</div

核心逻辑

export function parseHTML(html, options) {
    const stack = []
    const isUnaryTag = options.isUnaryTag || no
    let index = 0
    let last, lastTag
    while (html) {
        last = html
        // Make sure we're not in a plaintext content element like script/style
        if (!lastTag || !isPlainTextElement(lastTag)) {
          // ... 普通场景(初始时或者,上一次解析的标签不是 scritp、style、textarea时)
        } else {
           //... lastTag 是 script、style、textarea 场景
           // 如 <script> .... </script>
        }

        // html是纯文本时,会进入下面的if
        // 看了半天还是下面的回调options.chars验证了想法
        if (html === last) { 
            options.chars &amp;&amp; options.chars(html)
            break
        }
    }

    // Clean up any remaining tags
    parseEndTag()

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

    function parseStartTag() {
      //... 
      // 找出 `<div id='app' ...各种属性 >` 中间的各种属性,截止到 `>` 或者`/>`
    }

    function handleStartTag(match) {
      //... 转换一下 parseStartTag 收集的属性为[{name, value}]形式
      // 如果不是一元标签(<img src='' />),则将该tag入栈
      // 一元标签在这里实际上是代表已经闭合了标签,也就是已经处理完的标签,对此不需要入栈
      // 而 <div
    }

    function parseEndTag(tagName, start, end) {
       //...
    }
}

看到核心流程就一个while循环,直到html遍历结束,while循环中分为if-else,其中else是针对scritpstyletextarea的,因此这些标签里面的内容是不需要被解析的。我们重点看下if里面的逻辑,其实就是我们上面demo中演示的过程。另外看到解释下这里涉及的几个方法

  • parseStartTag:找出开始标签中的各种属性
  • handlerStartTag:将parseStartTag正则匹配的属性转转换成对象数组格式,然后将开始标签push到stack中。也处理一些异常情况,p标签中不能包含phrase content,比如<p>before<caption>ddd</caption>after</p>,这种情况是不允许的。
  • parseEndTag:实际上核心逻辑是找到对应开始标签,然后从栈中弹出,但是这里的逻辑却写的相对复杂,是考虑到html异常的一些场景,比如<div><span></div>,此时会把span和div标签都弹出,显然这么做是合理的。
  • advance:很关键,推动index指针不断往前走

下面看下whileif中的代码

let textEnd = html.indexOf('<')
// 处理可能是标签的场景,如<div 或者 </div
if (textEnd === 0) {
  // ... 注释、条件注释、Doctype 场景,暂忽略

  // End tag: 如 </div
  const endTagMatch = html.match(endTag)
  if (endTagMatch) {
    const curIndex = index
    advance(endTagMatch[0].length)
    parseEndTag(endTagMatch[1], curIndex, index)
    continue
  }

  // Start tag: 如 <div
  const startTagMatch = parseStartTag()
  if (startTagMatch) {
    handleStartTag(startTagMatch)
    continue
  }
}

// 下面是处理普通文本的场景
let text, rest, next
if (textEnd >= 0) {
  rest = html.slice(textEnd)
  // ... 处理 text 中 有 < 字符的场景,暂忽略
  text = html.substring(0, textEnd)
}

if (textEnd < 0) {
  text = html
}

if (text) {
  advance(text.length)
}

if (options.chars &amp;&amp; text) {
  options.chars(text, index - text.length, index)
}

实际上逻辑很清晰,分为两个大的情况

  • 起始字符是<的情况,尝试判断是不是标签(开始标签还是结束标签)
    • 如果是开始标签,则获取属性,直到开始标签结束
    • 如果是结束标签,则将对应的开始标签从stack中弹出
  • 其实内容是文本的情况,index指针往前推进文本的长度,进入下次循环

特殊场景

不是很重要,暂遗留

  • 自闭和标签
  • 一元标签
  • style/script
  • p\br

总结

另外重要的点是:在上面的遍历的过程中,会有三个核心的回调事件:

  1. start:当找到一个开始标签,并且属性获取完,遇到开始标签的结束标志后,此时说明开始标签已经处理完了(该收集的信息也收集了),发布该事件
  2. end:解析到结束标签时,此时这个整个标签解析完成了,发布该事件
  3. chars:解析到文本时,发布该事件

注意,这个过程并没有构造ASTvue/src/compiler部分监听了这三个事件,在这些事件中来添加vue相关的一些特性如指令相关的,并在这些回调中创建AST节点,并建立父子关系来构建整颗AST

回复

我来回复
  • 暂无回复内容