Vue3编译器 第一步Template转AST(上)

大家好,我是汽水,今天是我啃源码的第二个月🙏,浅浅过了一遍响应式与渲染器😋,只剩下最后一座大山😵编译器。另外我也在学习算法以及英语中。欢迎交个朋友😁。

话不多说,进入正题。

编译器的第一步是将模板字符串解析为抽象语法树(AST)。这个AST表示模板的结构和层次关系,它包含了模板中的标签、属性、文本内容等等,并且将它们组织成一个树状结构

下面给出了将模板"<div><p></p><span></span>Hi,{{message}}</div>"转化成AST的主要结构

  • type 用来表示插值,文本,元素等类型。type:'Root'是默认添加的根节点
  • children 子节点
  • tag 元素的标签名
  • props 标签上的属性这里没有给出

这个结构和VNode很像,注意区分。

js 复制代码
const content = ;
{
  type: "Root",
  children: [
    {
      type: "Element",
      tag: "div",
      children: [
        {
          type: "Element",
          tag: "p",
          children: [
          ],
        },
        {
          type: "Element",
          tag: "span",
          children: [
          ],
        },
        {
          type: "Text",
          content: "Hi,",
        },
        {
          type: "Interpolation",
          content: "message",
        },
      ],
    },
  ],
}

下面来介绍一下解析思路

先来创建一个parse函数,接受content(模板字符串)作为参数。

  1. 首先创建一个上下文对象context,为了在之后的递归中共享这个对象的使用。
js 复制代码
function parse(content) {
  const context = { source: content };
  return {
    type: "Root",
    children: parseChild(context),
  };
}
  1. parseChild是执行递归操作的核心函数。可以理解为:对于树的下一级调用这个函数,再根据子节点的类型调用不同的方法进行处理
  2. 很明显parseChild会返回一个数组类型,进入while循环,它需要一个停止判断,这个isEnd待会再讲。
  3. 进入循环,如果以<开头并且第二个元素是一个字母,表示将用parseElement处理一个标签元素。如果第二个元素是/,需要处理标签结束。parseTag负责处理其中的标签头/尾。
  4. 当以{{开头表示处理插值parseInterpolation,其他情况当成文本处理parseText

另外补充一下,< div>< /div>,这种前面有空格的是不合法的,但是后面有空字符是合法的例如 :<div >

js 复制代码
function parseChild(context) {
  const nodes = [];
  while (!isEnd(context)) {
    let node;
    if (context.source[0] === "<") {
      if (/[a-z]/i.test(context.source[1])) {
        node = parseElement(context);
      } else if (context.source[1] === "/") {
          parseTag(context, "End");
      }
    } else if (context.source.startsWith("{{")) {
      node = parseInterpolation(context);
    } else {
      node = parseText(context);
      }
  }
  return nodes;
}

用图表示就是:

下面讲讲parseElement(处理标签)

  1. parseTag用来处理标签名,第二个参数用来区分处理的是开始还是结束标签
  2. 处理完开始标签,意味着可以进行下一个递归。即调用parseChild,将它的返回值赋给element.children
js 复制代码
function parseElement(context, ancestors) {
  const element = parseTag(context, "Start");
  element.children = parseChild(context, ancestors);
  return element;
}

parseTag的实现

  1. advanceBy只是为了消费字符。因为数据已经被记录。

  2. 当type为'End',即结束标签不需要返回,直接调用 advanceBy将结束标签消费。

  3. 里面这个正则很重要,不熟悉正则的可以看看。

ruby 复制代码
  /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source);
 这个正则的意思是匹配以`<`开头,有一个,或者没有`/`
 把后面的小括号当成一个整体,/i忽略大小写
 再来看小括号里面的:*作用于[^\t\r\n\f />],表示零个或多个
 也就是表示匹配一个字母后面跟着零个或多个除制表符、回车符、换行符、进纸符、斜杠 `/` 或尖括号 `>` 之外的字符
 
  1. 对于这个match正则处理<div>结果为[<div,div,...],处理</div>结果为[</div,div,...],得到的tag就是标签名了。
  2. exec 方法会返回一个数组,数组的第一个元素是与整个正则表达式匹配的文本,接下来的元素是与每个捕获组匹配的文本,捕获组(小括号里的内容)就是匹配文本的子串
js 复制代码
function parseTag(context, type) {
  const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source);
  const tag = match[1];
  advanceBy(context, type === "Start" ? 2 + tag.length : 3 + tag.length);
  return type === "Start"
    ? {
        type: "Element",
        tag,
      }
    : null;
}

function advanceBy(context, nums) {
  context.source = context.source.slice(nums);
}

parseInterpolation的实现

  1. 找到插值的结束符号}}对应的下标。
  2. 很容易可以把其中的内容截取下来,再消费字符
js 复制代码
function parseInterpolation(context) {
  const index = context.source.indexOf("}}");
  const content = context.source.slice(2, index);
  advanceBy(context, index + 2);
  return {
    type: "Interpolation",
    content,
  };
}

parseText的实现

  1. 只需要比较离{{,还是<近,获取文本内容,再消费字符即可
js 复制代码
function parseText(context) {
  const indexI = context.source.indexOf("{{");
  const indexE = context.source.indexOf("<");
  const index = Math.min(indexI, indexE);
  const content = context.source.slice(0, index);
  advanceBy(context, content.length);
  return {
    type: "Text",
    content,
  };
}

最后思考一下循环的终止条件即实现isEnd函数,它返回一个布尔值。 while 循环应该要遇到父级节点的结束标签才会停止

所以应该维护一个父节点栈ancestors,判断一下在剩余字符中能够找到最新的栈节点的标签名对应的结束标签。另一种结束情况是字符全都被消费。

js 复制代码
function isEnd(context, ancestors) {
  const s = context.source;
  if (ancestors.length !== 0) {
    const tag = ancestors[ancestors.length - 1]?.tag;
    if (tag === s.slice(2, 2 + tag.length)) return true;
  }
  return !s;
}

因为需要维护ancestors,需要稍稍修改一下

  1. 在parse函数中给parseChildren再传入一个[],初始化ancestors
  2. parseChilrenparseElement都需要传入这个参数。
  3. parseElement中处理完开始标签之后,将parseTag的返回值push到栈里,执行完parseChildren也就是意味着递归结束,再退栈即可

另外还需要考虑文本模式对解析的影响,默认是DATA。比如:

<title>标签、<textarea> 标签,当解析器遇到这两个标签时,会切换到 RCDATA 模式,此时会将当前字符 < 作为普通字符处理,然后继续处理后面的字符。由此可知,在 RCDATA 状 态下,解析器不能识别标签元素。这里了解一下即可,下面给出了这几种模式对应的表格

模式 能否解析标签 是否支持 HTML 实体
DATA Y Y
RCDATA N Y
RAWTEXT N N
CDATA N N

所以需要在parseChild进行模式的判断

js 复制代码
function parseChild(context, ancestors) {
//省略
  while (!isEnd(context, ancestors)) {
    let node;
    // 只有 DATA 模式和 RCDATA 模式才支持插值节点的解析
    if (context.mode === "DATA" || context.mode === "RCDATA") {
      // 只有 DATA 模式才支持标签节点的解析
      if (context.mode === "DATA" && context.source[0] === "<") {
       //省略
    }
  }
//省略
}

接下来处理没有结束标签的情况,并能给出提示具体是哪个标签的没有结束标签

  • 首先改造一下isEnd,这种情况下会造成死循环,因为永远找不到对应的结束标签。
  • 现在的判断条件改成遍历父节点栈,只要找到有与之相对应的结束标签则结束这个循环
  • parseElement也需要做处理,例如<div><p></div>
  1. 第一次进入parseChildren,进入while,使用parseElement处理标签,ancestors.push({type:Element,tag:div}),剩余字符<p></div>
  2. 进入递归parsechildren,再进入parseElement,ancestors.push({type:Element,tag:p}),剩余字符,进入while判断,为flase,退出循环。
  3. ancestors.push(),剩余字符对应标签是div,而element.tag是p,所以就知道这个标签缺少结束标签。
js 复制代码
//省略代码
 ancestors.pop();
  if (context.source.startsWith(`</${element.tag}`)) {
    parseTag(context, "End");
  } else {
    // 缺少闭合标签
    console.error(`${element.tag} 标签缺少闭合标签`);
  }
  return element;

下一篇会在此基础上再完善一些内容,并且会解析标签上的属性props

相关推荐
前端风云志19 分钟前
TypeScript枚举类型应用:前后端状态码映射的最简方案
javascript
望获linux26 分钟前
【实时Linux实战系列】多核同步与锁相(Clock Sync)技术
linux·前端·javascript·chrome·操作系统·嵌入式软件·软件
魂祈梦29 分钟前
rsbuild的环境变量
前端
赫本的猫30 分钟前
告别生命周期!用Hooks实现更优雅的React开发
前端·react.js·面试
LaoZhangAI30 分钟前
Browser MCP完全指南:5分钟掌握AI浏览器自动化新范式(2025最新)
前端·后端
咸鱼青菜好好味32 分钟前
node的项目实战相关3-部署
前端
赫本的猫32 分钟前
React中的路由艺术:用react-router-dom实现无缝页面切换
前端·react.js·面试
极客三刀流32 分钟前
el-select 如何修改样式
前端
沐言人生32 分钟前
RN学习笔记——1.RN环境搭建和踩坑
前端·react native
markyankee10132 分钟前
Vue 组件系统深度解析
vue.js