【手写 Vue2.x 源码】第十四篇 - 生成 ast 语法树 - 模板解析

448 阅读8分钟

一,前言

上篇,主要介绍了生成 ast 语法树-正则说明部分,涉及以下几个点:

  • 介绍了html模板的解析原理;
  • 详解源码中模板解析相关的7类正则;
    • 匹配标签名
    • 匹配命名空间标签名
    • 匹配开始标签-开始部分
    • 匹配结束标签
    • 匹配属性
    • 匹配开始标签-闭合部分
    • 匹配插值表达式

本篇,生成 ast 语法树-代码实现


二,模板解析实现

模板解析的思路:对模板不停截取,直至全部解析完毕;

模板解析代码实现:在while循环,使用正则不断的对html模板中的有效信息进行匹配和截取;

1,判断是标签 or 文本?

上篇提到,判断是标签还是文本,主要是看内容的开头第一个字符是否为<尖角号:

  • 如果是尖角号,说明是标签;
  • 如果不是尖角号,说明是文本;
// src/compiler/index.js#parserHTML

function parserHTML(html) {
  while(html){
    // 解析标签or文本,判断html的第一个字符,是否为 < 尖角号
    let index = html.indexOf('<');
    if(index == 0){
      console.log("是标签")
    } else{
      console.log("是文本")
    }
  }
}

输出结果:

image.png

2,解析开始标签-parseStartTag方法

包含尖叫号<的情况: 可能是开始标签 <div>,也可能是结束标签</div>;

所以,当解析到标签时,应先使用正则匹配开始标签;如果没有匹配成功,再使用结束标签进行匹配

parseStartTag方法:匹配开始标签,返回匹配结果,即标签名;

备注:匹配结果的索引 1 为标签名

// src/compiler/index.js#parserHTML

/**
* 匹配开始标签,返回匹配结果(开始标签名)
*/
function parseStartTag() {
  // 匹配开始标签
  const start = html.match(startTagOpen);
  // 构造匹配结果对象:包含标签名和属性
  const match = {
    tagName:start[1], // 数组索引 1 为标签名
    attrs:[]
  }
  console.log("match结果:" + match)
  
  // todo 删除字符串中匹配完成的部分
}
  
function parserHTML(html) {

  // 对模板不停做匹配和截取操作,直至全部解析完毕
  while (html) {
    // 解析标签和文本(看开头是否为 <)
    let index = html.indexOf('<');
    if (index == 0) {
      console.log("解析 html:" + html + ",结果:是标签")
      // 如果是标签,继续解析开始标签和属性
      const startTagMatch = parseStartTag();// 匹配开始标签,返回匹配结果
      // 1,匹配到开始标签:无需执行后续逻辑,直接进入下一次 while,继续解析后续内容
      if (startTagMatch) { 
        continue; 
      }
      // 2,未匹配到开始标签:此时有可能为结束标签</div>
      // 如果匹配到结束标签,无需执行后续逻辑,直接进入下一次 while,继续解析后续内容
      if (html.match(endTag)) {
        continue;
      }
    } else {
      console.log("解析 html:" + html + ",结果:是文本")
    }
  }
}

调试并查看开始标签的match结果:

image.png

3,截取匹配完成的部分-advance方法

开始标签解析完成后,需要将匹配完成的部分截掉,达到如下效果:

<!-- 解析前 -->
<div id=app>{{message}}</div>
<!-- 解析后:开始标签<div被截掉 -->
 id=app>{{message}}</div>

为达到以上效果,创建advance(前进)方法:截取html内容至当前已解析的位置,即删除已处理完成的部分;

// src/compiler/index.js#parserHTML

function parserHTML(html) {

  /**
   * 截取字符串
   * @param {*} len 截取长度
   */
  function advance(len){
    html = html.substring(len);
  }
  
  /**
   * 匹配开始标签,返回匹配结果
   */
  function parseStartTag() {
    // 匹配开始标签,开始标签名为索引 1
    const start = html.match(startTagOpen);
    // 构造匹配结果,包含标签名和属性
    const match = {
      tagName:start[1],
      attrs:[]
    }
    console.log("match 结果:" + match)
    // 截取匹配到的结果
    advance(start[0].length)
    console.log("截取后的 html:" + html)
  }
  ...略
}

调试并查看截取后的html片段:

image.png

4,解析开始标签中的属性-while循环

 id="app">{{message}}</div>

在开始标签中,由于可能存在多个属性,因此这部分需要进行多次处理;

// src/compiler/index.js#parserHTML#parseStartTag

function parseStartTag() {
  const start = html.match(startTagOpen);
  const match = {
    tagName: start[1],
    attrs: []
  }
  console.log("match 结果:" + match)
  // 截取匹配到的结果
  advance(start[0].length)
  console.log("截取后的 html:" + html)
  
  // ******* 开始解析标签的属性 id="app" a=1 b=2>*******//
  let end;  // 是否匹配到开始标签的结束符号 > 或 /> 
  let attr; // 存储属性匹配的结果
  // 匹配并获取属性,放入 match.attrs 数组
  // 例如 <div>标签:当匹配到字符 >,表示标签结束,不再继续匹配标签内的属性
  //    attr = html.match(attribute)  匹配属性并赋值当前属性的匹配结果
  //    !(end = html.match(startTagClose))   没有匹配到开始标签的结束符号 > 或 />
  while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
    // 将匹配到的属性记录到数组 match.attrs(属性对象包含属性名和属性值)
    match.attrs.push({ name: attr[1], value: attr[3] || attr[4] || attr[5] })
    // 截取掉已匹配完成的属性,如:xxx=xxx
    advance(attr[0].length) 
  }
  
  // 当匹配到开始标签的关闭符号 > 时,当前标签处理完成,while 结束
  // 此时,<div id="app" 处理完成,需要连同关闭符号 > 一起截取掉
  if (end) {
    advance(end[0].length)
  }

  // 开始标签处理完成后,返回匹配结果:tagName 标签名 + attrs 属性对象
  return match
}

备注:前面提到过,属性的值可能是"xxx"'xxx'xxx三种之一,所以属性取值为attr[3] || attr[4] || attr[5],即取匹配结果中有值的一个即可;

5,总结:开始标签处理过程的详细说明

<div id="app" a=1 b=2>

对开始标签处理过程的每一步进行说明:

  1. <开头,说明是标签;此时,可能是开始标签,也可能是结束标签;
  2. 匹配正则startTagOpen,获取属性名和属性;
  3. 匹配<div id="app" a=1 b=2>
  4. 匹配 id="app" a=1 b=2>
  5. 匹配 a=1 b=2>
  6. 匹配 b=2>
  7. 匹配到>时,匹配结束 while循环终止`;

至此,开始标签就解析完成了

注意:对于match的匹配结果,match[0]表示到的匹配内容,match[1]表示捕获到的内容;

6,抛出开始标签、结束标签及文本

todo:需要从流程上说明“发射”目的,或放到下一篇中;目前读到这里可能理解上会生硬一些;

继续,将开始标签的状态(开始标签、结束标签、文本标签)发射出去

编写三个发射状态的方法,分别用于向外发射开始标签、结束标签、文本标签

// src/compiler/index.js#parserHTML#start
// src/compiler/index.js#parserHTML#end
// src/compiler/index.js#parserHTML#text

// 开始标签
function start(tagName, attrs) {
  console.log("start", tagName, attrs)
}

// 结束标签
function end(tagName) {
  console.log("end", tagName)
}

// 文本标签
function text(chars) {
  console.log("text", chars)
}

当匹配到开始标签、结束标签、文本时,将数据发送出去

// src/compiler/index.js#parserHTML#parseStartTag

function parserHTML(html) {

  /**
    * 匹配开始标签,返回匹配结果
    */
  function parseStartTag() {
    const start = html.match(startTagOpen);
    if(start){
      const match = {
        tagName: start[1],
        attrs: []
      }
      advance(start[0].length)
      let end;
      let attr;
      while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
        match.attrs.push({ name: attr[1], value: attr[3] || attr[4] || attr[5] })
        advance(attr[0].length)
      }
      
      if (end) {
        advance(end[0].length)
      }
      return match; // 没有匹配到
    }
    return false;
  }

while (html) {
  let index = html.indexOf('<');
  if (index == 0) {
    console.log("解析 html:" + html + ",结果:是标签")
    const startTagMatch = parseStartTag();
    console.log("开始标签的匹配结果 startTagMatch = " + JSON.stringify(startTagMatch))
    
    if (startTagMatch) {
      // 匹配到开始标签,调用start方法,传递标签名和属性
      start(startTagMatch.tagName, startTagMatch.attrs)
      continue;
    }
    
    // 如果开始标签没有匹配到,有可能是结束标签 </div>
    let endTagMatch;
    if (endTagMatch = html.match(endTag)) {// 匹配到了,说明是结束标签
      // 匹配到开始标签,调用 start 方法,向外传递标签名和属性
      end(endTagMatch[1])
      // 删除已匹配完成的部分
      advance(endTagMatch[0].length)
      continue;
    }
  }
  
  // 如果是文本:将文本内容取出来并发射出去,并从html片段中截取掉
  if(index > 0){
    // 此时的 html 片段为:hello</div>
    let chars = html.substring(0, index)
    // 向外传递文本
    text(chars);
    // 删除已匹配完成的部分
    advance(chars.length)
  }
}

至此,通过对html模板的解析,已经获取到了模板中的标签名、属性等关键信息,后续再通过这些信息构建出AST语法树;


三,模板解析测试-复杂模板

<body>
  <div id="app" a='1' b=2 > <p>{{message}} <span>Hello Vue</span></p></div>
  
  <script src="./vue.js"></script>
  <script>
    let vm = new Vue({
      el: '#app',
      data() {
        return { message:  "Brave" }
      },
    }); 
  </script>
</body>

输出结果:

进入 state.js - initData,数据初始化操作
***** 进入 $mountel = #app*****
获取真实的元素,el = [object HTMLDivElement]
options 中没有 render , 继续取 template
options 中没有 template, 取 el.outerHTML = <div id="app" a="1" b="2"> <p>{{message}} <span>Hello Vue</span></p></div>
***** 进入 compileToFunction:将 template 编译为 render 函数 *****
***** 进入 parserHTML:将模板编译成 AST 语法树*****
解析 html:<div id="app" a="1" b="2"> <p>{{message}} <span>Hello Vue</span></p></div>,结果:是标签
***** 进入 parseStartTag,尝试解析开始标签,当前 html: <div id="app" a="1" b="2"> <p>{{message}} <span>Hello Vue</span></p></div>*****
html.match(startTagOpen) 结果:{"tagName":"div","attrs":[]}
截取匹配内容后的 html: id="app" a="1" b="2"> <p>{{message}} <span>Hello Vue</span></p></div>
===============================
匹配到属性 attr = [" id=\"app\"","id","=","app",null,null]
截取匹配内容后的 html: a="1" b="2"> <p>{{message}} <span>Hello Vue</span></p></div>
===============================
匹配到属性 attr = [" a=\"1\"","a","=","1",null,null]
截取匹配内容后的 html: b="2"> <p>{{message}} <span>Hello Vue</span></p></div>
===============================
匹配到属性 attr = [" b=\"2\"","b","=","2",null,null]
截取匹配内容后的 html:> <p>{{message}} <span>Hello Vue</span></p></div>
===============================
匹配关闭符号结果 html.match(startTagClose):[">",""]
截取匹配内容后的 html: <p>{{message}} <span>Hello Vue</span></p></div>
===============================
>>>>> 开始标签的匹配结果 startTagMatch = {"tagName":"div","attrs":[{"name":"id","value":"app"},{"name":"a","value":"1"},{"name":"b","value":"2"}]}
发射匹配到的开始标签-start,tagName = div,attrs = [{"name":"id","value":"app"},{"name":"a","value":"1"},{"name":"b","value":"2"}]
解析 html: <p>{{message}} <span>Hello Vue</span></p></div>,结果:是文本
发射匹配到的文本-text,chars =  
截取匹配内容后的 html:<p>{{message}} <span>Hello Vue</span></p></div>
===============================
解析 html:<p>{{message}} <span>Hello Vue</span></p></div>,结果:是标签
***** 进入 parseStartTag,尝试解析开始标签,当前 html: <p>{{message}} <span>Hello Vue</span></p></div>*****
html.match(startTagOpen) 结果:{"tagName":"p","attrs":[]}
截取匹配内容后的 html:>{{message}} <span>Hello Vue</span></p></div>
===============================
匹配关闭符号结果 html.match(startTagClose):[">",""]
截取匹配内容后的 html:{{message}} <span>Hello Vue</span></p></div>
===============================
>>>>> 开始标签的匹配结果 startTagMatch = {"tagName":"p","attrs":[]}
发射匹配到的开始标签-start,tagName = p,attrs = []
解析 html:{{message}} <span>Hello Vue</span></p></div>,结果:是文本
发射匹配到的文本-text,chars = {{message}} 
截取匹配内容后的 html:<span>Hello Vue</span></p></div>
===============================
解析 html:<span>Hello Vue</span></p></div>,结果:是标签
***** 进入 parseStartTag,尝试解析开始标签,当前 html: <span>Hello Vue</span></p></div>*****
html.match(startTagOpen) 结果:{"tagName":"span","attrs":[]}
截取匹配内容后的 html:>Hello Vue</span></p></div>
===============================
匹配关闭符号结果 html.match(startTagClose):[">",""]
截取匹配内容后的 html:Hello Vue</span></p></div>
===============================
>>>>> 开始标签的匹配结果 startTagMatch = {"tagName":"span","attrs":[]}
发射匹配到的开始标签-start,tagName = span,attrs = []
解析 htmlHello Vue</span></p></div>,结果:是文本
发射匹配到的文本-text,chars = Hello Vue
截取匹配内容后的 html:</span></p></div>
===============================
解析 html:</span></p></div>,结果:是标签
***** 进入 parseStartTag,尝试解析开始标签,当前 html: </span></p></div>*****
未匹配到开始标签,返回 false
===============================
发射匹配到的结束标签-end,tagName = span
截取匹配内容后的 html:</p></div>
===============================
解析 html:</p></div>,结果:是标签
***** 进入 parseStartTag,尝试解析开始标签,当前 html: </p></div>*****
未匹配到开始标签,返回 false
===============================
发射匹配到的结束标签-end,tagName = p
截取匹配内容后的 html:</div>
===============================
解析 html:</div>,结果:是标签
***** 进入 parseStartTag,尝试解析开始标签,当前 html: </div>*****
未匹配到开始标签,返回 false
===============================
发射匹配到的结束标签-end,tagName = div
截取匹配内容后的 html:
===============================
当前 template 模板,已全部解析完成

四,结尾

本篇,主要介绍了生成 ast 语法树 - 模板解析部分,主要涉及以下几个点:

使用正则对 html 模板进行解析和处理,匹配到模板中的标签和属性

  • 解析开始标签-parseStartTag方法
  • 截取匹配完成的部分-advance方法
  • 解析开始标签中的属性-while循环
  • 开始标签处理过程的详细说明
  • 对开始标签、结束标签及文本的发射处理

下一篇,生成 ast 语法树 - 构造树形结构


维护日志

  • 20230125:重新梳理文章目录结构,对模板解析过程进行详细说明,优化部分代码并添加必要注释;