【手写 Vue2.x 源码】第十五篇 - 生成 ast 语法树 - 构造树形结构

355 阅读8分钟

一,前言

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

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

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

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


二,构建树形结构

1,需要描述什么

前面提到,将html模板构造成为ast语法树,使用js树形结构来描述html语法;

对于一个html模板,主要有以下几点需要被描述和记录:

  • 标签信息;
  • 属性信息;
  • 文本信息;
  • html结构的层级关系,即元素间的父子关系;

2,如何构建父子关系

示例:

<div><sapn></span><p></p></div>

html标签特点:成对出现(开始标签 + 结束标签),如:<sapn></span>

基于模板解析从左到右的顺序和以上html标签特点,就可以借助栈型的数据结构来辅助构建元素的父子关系;

将解析到的标签名按顺序放入栈中:

[div,span]        spandiv 的儿子

[div,span,span]   span 标签闭合,span 出栈,将 span 作为 div 的儿子

[div,p]           pdiv 的儿子

[div,p,p]         p 标签闭合,p 出栈,将 p 作为 div 的儿子(即 pspan 是同层级的兄弟)

原理同上,略...

备注:结束标签有两种情况:</xxx>和</>,如果是自闭和标签</>,直接出栈即可

通过以上逻辑,借助栈的数据结构,就能够模拟出了html元素的父子关系了;

3,ast 节点元素的数据结构

在构建父子关系的对象中,需要记录以下信息:

// 构建 AST 元素节点
function createASTElement(tag, attrs, parent) {
  return {
    tag,          // 标签名
    type:1,       // 元素
    children:[],  // 儿子
    parent,       // 父亲
    attrs         // 属性
  }
}

4,处理开始标签

处理开始标签的逻辑

  • 如果当前标签是第一个节点,则自动成为整棵树的根节点 root;
  • 如果已存在根节点,则取栈中最后一个标签作为父节点,创建ast节点元素并入栈;

代码实现

// 处理开始标签,如:[div,p]
function start(tag, attrs) {
  // 取栈中最后一个标签,作为父节点
  let parent = stack[stack.length-1];
  // 创建当前 ast 节点
  let element = createASTElement(tag, attrs, parent);
  // 如果是第一个标签,则作为根节点
  if(root == null) root = element;
  // 如果存在父亲,就父子相认(为当前节点设置父亲,同时为父亲设置儿子)
  if(parent){
    element.parent = parent;
    parent.children.push(element)
  }
  // 当前 ast 元素节点构建完成后,加入到栈中
  stack.push(element)
}

5,处理结束标签

处理结束标签的逻辑

  • 抛出栈中最后一个标签,即与当前结束标签成对的开始标签;
  • 验证栈中抛出的标签是否与当前结束标签成对

代码实现

// 处理结束标签
function end(tagName) {
  console.log("发射匹配到的结束标签-end,tagName = " + tagName)
  // 从栈中抛出结束标签
  let endTag = stack.pop();
  // check:抛出的结束标签名与当前结束标签名是否一致
  // 开始/结束标签的特点是成对的,当抛出的元素名与当前元素名不一致时报错
  if(endTag.tag != tagName)console.log("标签出错")
}

6,处理文本

处理文本的逻辑

  • 处理文本:删除文本中可能存在的空白字符;
  • 若文本不为空,则取当前栈中最后一个标签作为父节点;

注意:文本不需要入栈,直接绑定为父节点的儿子即可;

代码实现

// 处理文本(文本中可能包含空白字符)
function text(chars) {
  console.log("发射匹配到的文本-text,chars = " + chars)
  // 找到文本的父亲,即当前栈中的最后一个元素
  let parent = stack[stack.length-1];
  // 删除文本中可能存在的空白字符:将空格替换为空
  chars = chars.replace(/\s/g, ""); 
  if(chars){
    // 绑定父子关系
    parent.children.push({
      type:2,     // type=2 表示文本类型
      text:chars,
    })
  }
}

备注:使用type=2 表示文本类型,与 astexplorer.net 一致;


三,代码重构

1,模块化

parserHTML相关处理逻辑提取为单独的js文件并对外抛出parserHTML方法:src/compile/parser.js

// src/compile/parser.js

// 匹配标签名:aa-xxx
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`;
// 命名空间标签:aa:aa-xxx
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
// 匹配标签名(索引1):<aa:aa-xxx
const startTagOpen = new RegExp(`^<${qnameCapture}`);
// 匹配标签名(索引1):</aa:aa-xxxdsadsa> 
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);
// 匹配属性(索引 1 为属性 key、索引 3、4、5 其中一直为属性值):aaa="xxx"、aaa='xxx'、aaa=xxx
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/;
// 匹配结束标签:> 或 />
const startTagClose = /^\s*(\/?)>/;
// 匹配 {{   xxx    }} ,匹配到 xxx
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

export function parserHTML(html) {
  console.log("***** 进入 parserHTML:将模板编译成 AST 语法树 *****")
  let stack = [];
  let root = null;
  // 构建父子关系
  function createASTElement(tag, attrs, parent) {
    return {
      tag,  // 标签名
      type:1, // 元素类型为 1
      children:[],  // 儿子
      parent, // 父亲
      attrs // 属性
    }
  }
  // 开始标签,如:[div,p]
  function start(tag, attrs) {
    console.log("发射匹配到的开始标签-start,tag = " + tag + ",attrs = " + JSON.stringify(attrs))
    // 遇到开始标签,就取栈中最后一个,作为父节点
    let parent = stack[stack.length-1];
    let element = createASTElement(tag, attrs, parent);
    // 还没有根节点时,作为根节点
    if(root == null) root = element;
    if(parent){ // 父节点存在
      element.parent = parent;  // 为当前节点设置父节点
      parent.children.push(element) // 同时,当前节点也称为父节点的子节点
    }
    stack.push(element)
  }
  // 结束标签
  function end(tagName) {
    console.log("发射匹配到的结束标签-end,tagName = " + tagName)
    // 如果是结束标签,就从栈中抛出
    let endTag = stack.pop();
    // check:抛出的结束标签名与当前结束标签名是否一直
    if(endTag.tag != tagName)console.log("标签出错")
  }
  // 文本
  function text(chars) {
    console.log("发射匹配到的文本-text,chars = " + chars)
    // 文本直接放到前一个中 注意:文本可能有空白字符
    let parent = stack[stack.length-1];
    chars = chars.replace(/\s/g, ""); // 将空格替换为空,即删除空格
    if(chars){
      parent.children.push({
        type:2, // 文本类型为 2
        text:chars,
      })
    }
  }
  /**
   * 截取字符串
   * @param {*} len 截取长度
   */
  function advance(len) {
    html = html.substring(len);
    console.log("截取匹配内容后的 html:" + html)
    console.log("===============================")
  }

  /**
   * 匹配开始标签,返回匹配结果
   */
  function parseStartTag() {
    console.log("***** 进入 parseStartTag,尝试解析开始标签,当前 html: " + html + "*****")
    // 匹配开始标签,开始标签名为索引 1
    const start = html.match(startTagOpen);
    if(start){// 匹配到开始标签再处理
      // 构造匹配结果,包含:标签名 + 属性
      const match = {
        tagName: start[1],
        attrs: []
      }
      console.log("html.match(startTagOpen) 结果:" + JSON.stringify(match))
      // 截取匹配到的结果
      advance(start[0].length)
      let end;  // 是否匹配到开始标签的结束符号>或/>
      let attr; // 存储属性匹配的结果
      // 匹配属性且不能为开始的结束标签,例如:<div>,到>就已经结束了,不再继续匹配该标签内的属性
      //    attr = html.match(attribute)  匹配属性并赋值当前属性的匹配结果
      //    !(end = html.match(startTagClose))   没有匹配到开始标签的关闭符号>或/>
      while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
        // 将匹配到的属性,push到attrs数组中,匹配到关闭符号>,while 就结束
        console.log("匹配到属性 attr = " + JSON.stringify(attr))
        // console.log("匹配到属性 name = " + attr[1] + "value = " + attr[3] || attr[4] || attr[5])
        match.attrs.push({ name: attr[1], value: attr[3] || attr[4] || attr[5] })
        advance(attr[0].length)// 截取匹配到的属性 xxx=xxx
      }
      // 匹配到关闭符号>,当前标签处理完成 while 结束,
      // 此时,<div id="app" 处理完成,需连同关闭符号>一起被截取掉
      if (end) {
        console.log("匹配关闭符号结果 html.match(startTagClose):" + JSON.stringify(end))
        advance(end[0].length)
      }

      // 开始标签处理完成后,返回匹配结果:tagName标签名 + attrs属性
      console.log(">>>>> 开始标签的匹配结果 startTagMatch = " + JSON.stringify(match))
      return match;
    }
    console.log("未匹配到开始标签,返回 false")
    console.log("===============================")
    return false;
  }

  // 对模板不停截取,直至全部解析完毕
  while (html) {
    // 解析标签和文本(看开头是否为<)
    let index = html.indexOf('<');
    if (index == 0) {// 标签
      console.log("解析 html:" + html + ",结果:是标签")
      // 如果是标签,继续解析开始标签和属性
      const startTagMatch = parseStartTag();// 匹配开始标签,返回匹配结果
      if (startTagMatch) {  // 匹配到了,说明是开始标签
        // 匹配到开始标签,调用start方法,传递标签名和属性
        start(startTagMatch.tagName, startTagMatch.attrs)
        continue; // 如果是开始标签,就不需要继续向下走了,继续 while 解析后面的部分
      }
      // 如果开始标签没有匹配到,有可能是结束标签 </div>
      let endTagMatch;
      if (endTagMatch = html.match(endTag)) {// 匹配到了,说明是结束标签
        // 匹配到开始标签,调用start方法,传递标签名和属性
        end(endTagMatch[1])
        advance(endTagMatch[0].length)
        continue; // 如果是结束标签,也不需要继续向下走了,继续 while 解析后面的部分
      }
    } else {// 文本
      console.log("解析 html:" + html + ",结果:是文本")
    }

    // 文本:index > 0 
    if(index > 0){
      // 将文本取出来并发射出去,再从 html 中拿掉
      let chars = html.substring(0,index) // hello</div>
      text(chars);
      advance(chars.length)
    }
  }
  console.log("当前 template 模板,已全部解析完成")
  return root;
}

2,导入模块

src/compile/index.js中,导入src/compile/parser.js#parserHTML

// src/compile/index.js

import { parserHTML } from "./parser";

export function compileToFunction(template) {
  console.log("***** 进入 compileToFunction:将 template 编译为 render 函数 *****")
  // 1,解析模板并构建成为 AST 语法树
  let ast = parserHTML(template);
  console.log("解析 HTML 返回 ast 语法树====>")
  console.log(ast)
}

3,测试 ast 构建逻辑

<div><p>{{message}}<sapn>HelloVue</span></p></div>

控制台输出:

image.png

对应元素结构如下:

div
  p
    {{message}}
    span
      HelloVue

四,结尾

本篇,生成 ast 语法树 - 构造树形结构部分

  • 基于 html 特点,使用栈型数据结构记录父子关系;
  • 分析开始标签,结束标签及文本的ast构建逻辑和代码实现;
  • 重构 html 解析与 ast 语法树构建部分代码;
  • 对 ast 语法树构建的过程分析;

下一篇,ast 语法树生成 render 函数


维护记录

  • 20230126: 优化内容使表述更加清晰,添加部分代码注释和说明; todo:代码重构部分可以放到之前或在本阶段之后单开一篇做阶段性重构;