Skip to content

将 HTML 字符串转换为 AST

源码位于: src/compiler/parser/index.ts,源码内容有点多,作用就是解析传入的 template 模板字符串,最后得到 AST,先来看一下什么是 AST

一. AST

我们知道,用户在<template></template>标签中写的模板对 Vue 来说就是一堆字符串,那么如何解析这一堆字符串并且从中提取出元素的标签、属性、变量插值等有效信息呢?这就需要借助一个叫做抽象语法树的东西。

AST 全名为 Abstract Syntax Tree,也称作 抽象语法树 或简称 语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是 抽象 的,是因为这里的语法并不会表示出真实语法中出现的每个细节。

AST抽象语法树 直白一点 就是 用js对象来描述html

例如:嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于 if-condition-then 这样的条件跳转语句,可以使用带有三个分支的节点来表示。

和抽象语法树相对的是 具体语法树(通常称作分析树)。

一般的,在源代码的翻译和编译过程中,语法分析器创建出分析树,然后从分析树生成 AST。一旦 AST 被创建出来,在后续的处理过程中,比如语义分析阶段,会添加一些信息。

TIP

AST 运用广泛,比如:

  • 编辑器的错误提示、代码格式化、代码高亮、代码自动补全;
  • elint、pretiier 对代码错误或风格的检查;
  • webpack 通过 babel 转译 javascript 语法;

并且如果想了解 js 编译执行的原理,那么就得了解 AST。

AST 在线转换工具推荐:AST Explorer

下面是一个将var a = 1;转换为AST示例:

json
//  input:   var a = 1;
{
  "type": "Program",
  "start": 0,
  "end": 10,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 10,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 9,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 5,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 8,
            "end": 9,
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "var"
    }
  ],
  "sourceType": "module"
}

二. parse

提示

要从模板字符串中解析出不同的内容,那首先要知道模板字符串中都会包含哪些内容。那么通常我们所写的模板字符串中都会包含哪些内容呢?经过整理,通常模板内会包含如下内容:

  • 文本,例如 “我是一段文本”
  • HTML 注释,例如<!-- 我是注释 -->
  • 条件注释,例如<!-- [if !IE]> -->我是条件注释<!--< ![endif] -->
  • DOCTYPE,例如<!DOCTYPE html>
  • 开始标签,例如<div>
  • 结束标签,例如</div>

这几种内容都有其各自独有的特点,也就是说我们要根据不同内容所具有的不同的的特点通过编写不同的正则表达式将这些内容从模板字符串中一一解析出来,然后再把不同的内容做不同的处理。

parse(template.trim(), options)方法中前半部分都是在处理 options 参数,最终parse内部是调用了parseHTML,作用就是解析 HTML,且 parseHTML 没有返回值, 但 parse 方法最终返回了root,那么在 parseHTML 内部会通过调用传入 parseHTML 的几个钩子函数,钩子函数内部会将解析得到的 AST 赋值给root,来看一下parseHTML,第一个参数是 template 字符串,第二个参数中的内容是一个对象,其中有几个钩子函数

  • start(tag, attrs, unary, start, end){}:当解析到开始标签时,调用该函数
  • end(tag, start, end){}:当解析到结束标签时,调用该函数
  • chars(text: string, start?: number, end?: number){}:当解析到文本时,调用该函数
  • comment(text: string, start, end){}:当解析到注释时,调用该函数

parse 源码如下:

ts
/**
 * Convert HTML string to AST.
 */
export function parse(template, options) {
  // ...
  parseHTML(template, {
    warn,
    expectHTML: options.expectHTML,
    isUnaryTag: options.isUnaryTag,
    canBeLeftOpenTag: options.canBeLeftOpenTag,
    shouldDecodeNewlines: options.shouldDecodeNewlines,
    shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
    shouldKeepComment: options.comments,
    start(tag, attrs, unary) {},
    end() {},
    chars(text: string) {},
    comment(text: string) {}
  });
  return root;
}

parse方法通过 parseHTML 来解析传入的模板字符串为 AST,在 parseHTML 函数解析模板字符串的过程中,如果遇到文本信息,就会调用文本解析器parseText函数进行文本解析;如果遇到文本中包含过滤器,就会调用过滤器解析器parseFilters函数进行解析。

三. parseHTML

parseHTML 是 parse 中最重要的方法,因为 parseHTML 负责解析出模板种包含哪些内容,才能根据不同的内容调用不同的函数生成 AST

parseHTML 源码位于:src/compiler/parser/html-parser.ts,源码如下:

ts
export function parseHTML(html, options: HTMLParserOptions) {
  const stack: any[] = [];
  const expectHTML = options.expectHTML;
  const isUnaryTag = options.isUnaryTag || no;
  const canBeLeftOpenTag = options.canBeLeftOpenTag || 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)) {
      /* ......... */
    } else {
      /* ......... */
    }

    if (html === last) {
      /* ......... */
    }
  }

  // Clean up any remaining tags
  parseEndTag();

  function advance(n) {
    /* ......... */
  }

  function parseStartTag() {
    /* ......... */
  }

  function handleStartTag(match) {
    /* ......... */
  }

  function parseEndTag(tagName?: any, start?: any, end?: any) {
    /* ......... */
  }
}

上面说到,parseHTML 接收两个参数,第一个参数为开发中写的<template></template>模板, 重点在第二个参数,第二个参数提供了一些解析 HTML 模板时的一些参数,同时还定义了 4 个钩子函数

  • 当解析到开始标签时候,会调用options.start钩子函数,当调用该钩子函数时,内部会调用createASTElement函数来创建元素类型的 AST 节点;
ts
start(tag, attrs, unary, start, end) {
    // .........
    let element: ASTElement = createASTElement(tag, attrs, currentParent)
    // .........
}

// 创建元素类型AST
export function createASTElement(
  tag: string,
  attrs: Array<ASTAttr>,
  parent: ASTElement | void
): ASTElement {
  return {
    type: 1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    rawAttrsMap: {},
    parent,
    children: []
  }
}
  • 当解析到结束标签时候,会调用options.end钩子函;
ts
end(tag, start, end) {
    const element = stack[stack.length - 1]
    // pop stack
    stack.length -= 1
    currentParent = stack[stack.length - 1]
    if (__DEV__ && options.outputSourceRange) {
        element.end = end
    }
    closeElement(element)
}
  • 当解析到文本时调用chars函数生成文本类型的 AST 节点;
ts
// 当解析到标签的文本时,触发chars
chars (text) {
	if(text是带变量的动态文本){
    let element = {
      type: 2,
      expression: res.expression,
      tokens: res.tokens,
      text
    }
  } else {
    let element = {
      type: 3,
      text
    }
  }
}

当触发 chars 钩子函数时,在该钩子函数内部,首先会判断文本是不是一个带变量的动态文本,如 “hello ”。

如果是动态文本,则创建动态文本类型的 AST 节点; 如果不是动态文本,则创建纯静态文本类型的 AST 节点。

  • 当解析到注释时调用common生成注释类型的 AST;
ts
comment (text: string, start, end) {
  let element = {
    type: 3,
    text,
    isComment: true
  }
}

解析之前

在开始解析之前,先判断了当前 html 是否以<开头,因为只有以<开头的才会是HTML注释 条件注释 标签,才会进入对于的解析逻辑

ts
let textEnd = html.indexOf("<");
if (textEnd === 0) {
  // Comment:
  if (comment.test(html)) {
  }

  // https://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
  if (conditionalComment.test(html)) {
  }

  // Doctype:
  const doctypeMatch = html.match(doctype);
  if (doctypeMatch) {
  }

  // End tag:
  const endTagMatch = html.match(endTag);
  if (endTagMatch) {
  }

  // Start tag:
  const startTagMatch = parseStartTag();
  if (startTagMatch) {
  }
}

1. 解析 HTML 注释

HTML 注释是以<!-- 开头,以-->结尾,中间的内容就是注释的内容,源码就是用正则,匹配出这部分内容将其转换成注释 AST 的

ts
// 匹配HTML注释的正则
const comment = /^<!\--/;
let index = 0;

while (html) {
  /* ...... */
  if (comment.test(html)) {
    const commentEnd = html.indexOf("-->"); // 找到第一个注释的结束位置

    if (commentEnd >= 0) {
      // 是否要保持模板中的注释
      if (options.shouldKeepComment && options.comment) {
        // 调用parseHTML传入的common钩子函数,生成评论AST
        options.comment(
          html.substring(4, commentEnd), // <!--  -->
          index,
          index + commentEnd + 3
        );
      }

      // 将html字符串的位置向右移动 一个注释的长度 <!-- xxx  -->
      advance(commentEnd + 3);
      continue;
    }
  }
  /* ....... */
}

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

上面解析 HTML 注释的代码很清理了,里面有一个点就是advance,作用就是移动 html 字符串,因为是通过while(html)来处理 html 的,advance 原理如下图

advance1

调用 advance 函数:

js
advance(3);

得到结果:

advance2

从图中可以看到,解析游标index最开始在模板字符串的位置0处,当调用了advance(3)之后,解析游标到了位置 3 处,每次解析完一段内容就将游标向后移动一段,接着再从解析游标往后解析,这样就保证了解析过的内容不会被重复解析

2. 解析条件注释

ts
const conditionalComment = /^<!\[/;

// 条件注释说明见文档:https://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment

/*
<![if !IE]> 
< link  href = "non-ie.css"  rel = "stylesheet" > 
<![endif]>
*/
if (conditionalComment.test(html)) {
  const conditionalEnd = html.indexOf("]>");

  if (conditionalEnd >= 0) {
    advance(conditionalEnd + 2);
    continue;
  }
}

可以看到,遇到条件注释会直接跳过注释,移动里面的内容,由于条件注释不存在于真正的 DOM 树中,所以不需要调用钩子函数创建 AST 节点

3. 解析 DOCTYPE

ts
const doctype = /^<!DOCTYPE [^>]+>/i;
const doctypeMatch = html.match(doctype);
if (doctypeMatch) {
  advance(doctypeMatch[0].length);
  continue;
}

遇到 <!DOCTYPE>,直接移动到下一个块

4. 解析结束标签

ts
/**
 * unicode letters used for parsing html tags, component names and property paths.
 * using https://www.w3.org/TR/html53/semantics-scripting.html#potentialcustomelementname
 * skipping \u10000-\uEFFFF due to it freezing up PhantomJS
 */
export const unicodeRegExp =
  /a-zA-Z\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u037D\u037F-\u1FFF\u200C-\u200D\u203F-\u2040\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD/;

const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`;
const qnameCapture = `((?:${ncname}\\:)?${ncname})`;
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`);

// 首先判断剩余的模板字符串是否符合结束标签的特征 只有</div>这样的标签才有返回值

//</div> ==> ["</div>", "div", index: 0, input: "</div>", groups: undefined]
//<div> ==> null

const endTagMatch = html.match(endTag);
if (endTagMatch) {
  const curIndex = index;
  advance(endTagMatch[0].length); // 跳过结束标签
  parseEndTag(endTagMatch[1], curIndex, index); // 解析结束标签
  continue;
}

开始先判断剩余的模板字符串是否以结束标签开头,只有符合特征才会进入解析,解析函数parseEndTag

js
function parseEndTag(tagName?: any, start?: any, end?: any) {
  let pos, lowerCasedTagName;
  if (start == null) start = index;
  if (end == null) end = index;

  // 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 (__DEV__ && (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") {
    // br标签用解析开始标签的方法解析
    if (options.start) {
      options.start(tagName, [], true, start, end);
    }
  } else if (lowerCasedTagName === "p") {
    // p标签则先用开始标签解析处理, 再用结束标签解析处理
    if (options.start) {
      options.start(tagName, [], false, start, end);
    }
    if (options.end) {
      options.end(tagName, start, end);
    }
  }
}

5. 解析开始标签

源码: ::: code group

ts
// Start tag:
const startTagMatch = parseStartTag(); //
if (startTagMatch) {
  handleStartTag(startTagMatch);
  if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
    advance(1);
  }
  continue;
}
ts
// 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 startTagOpen = new RegExp(`^<${qnameCapture}`);
const startTagClose = /^\s*(\/?)>/;

function parseStartTag() {
  const start = html.match(startTagOpen);
  // '<div></div>'.match(startTagOpen)  => ['<div','div',index:0,input:'<div></div>']
  if (start) {
    const match = {
      tagName: start[1],
      attrs: [],
      start: index
    };
    advance(start[0].length);
    let end, attr;
    /**
     * <div a=1 b=2 c=3></div>
     * 从<div之后到开始标签的结束符号'>'之前,循环一直匹配属性attrs,匹配一段跳过html一段
     * 所有属性匹配完之后,html字符串还剩下
     * 自闭合标签剩下:'/>'
     * 非自闭合标签剩下:'></div>'
     */
    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);
    }

    /**
     * 这里判断了该标签是否为自闭合标签
     * 自闭合标签如:<input type='text' />
     * 非自闭合标签如:<div></div>
     * '></div>'.match(startTagClose) => [">", "", index: 0, input: "></div>", groups: undefined]
     * '/><div></div>'.match(startTagClose) => ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
     * 因此,我们可以通过end[1]是否是"/"来判断该标签是否是自闭合标签
     */
    if (end) {
      match.unarySlash = end[1];
      advance(end[0].length);
      match.end = index;
      return match;
    }
  }
}

:::

解析开始标签先对于前面几个解析代码里稍多,但是根本也是通过正则来匹配出模板中的内容,我们一步步来分析

js
const start = html.match(startTagOpen);

`<div></div>`.math(
  startTagOpen
) // [ '<div', 'div', index: 0, input: '<div></div>', groups: undefined ]
`测试</div>`.math(
  startTagOpen
) // null
`测试<div></div>`.math(
  startTagOpen
) // null
`</div><div></div>`.math(
  startTagOpen
) // null
`<div>测试`.math(startTagOpen); // [ '<div', 'div', index: 0, input: '<div></div>', groups: undefined ]

可见只有开始标签开头才能被匹配, 接着被游标处理跳过开始标签

假如当前 html 为<div class="color:red;"></div>

js
advance(start[0].length);

经过游标函数处理, html 变成 class="color:red;"></div>

接着通过 while 循环,取出标签上面的属性, 包括了Vue属性原有属性, 例如::xx @xx v-xxx calss style 等等其他, 然后将着一些属性匹配结果保存下来,放到 匹配结果 里面,最后parseStartTag 返回匹配到的属性

js
/* <div a=1 b=2 c=3></div> 会得到 */

const match = {
  tagName: "div",
  attrs: [
    [' a=1','a','=',undefined,undefined,'1',index: 0,input: ' a=1 b=2 c=3></div>',groups: undefined,start: 5,end: 9],
    [' b=2','b','=',undefined,undefined,'2',index: 0,input: ' b=2 c=3></div>',groups: undefined,start: 9,end: 13],
    [' c=3','c','=',undefined,undefined,'3',index: 0,input: ' c=3></div>',groups: undefined,start: 13,end: 17]
  ],
  start: 1,
  unarySlash: '',
  end: 18
};

若是一开始就没有匹配到开始标签,parseStartTag返回 undefined

回到 parseHTML 中,若parseStartTag 解析到了属性,则会进一步对提取的属性进行处理

js
handleStartTag(startTagMatch);

function handleStartTag(match) {
  const tagName = match.tagName; // 开始标签的标签名
  const unarySlash = match.unarySlash; // 是否为自闭合标签的标志,自闭合为"",非自闭合为"/"

  if (expectHTML) {
    if (lastTag === "p" && isNonPhrasingTag(tagName)) {
      parseEndTag(lastTag);
    }
    if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
      parseEndTag(tagName);
    }
  }

  const unary = isUnaryTag(tagName) || !!unarySlash; // 布尔值,标志是否为自闭合标签

  const l = match.attrs.length; // match.attrs 数组的长度
  const attrs: ASTAttr[] = new Array(l); // 一个与match.attrs数组长度相等的数组

  //   接下来是循环处理提取出来的标签属性数组match.attrs
  for (let i = 0; i < l; i++) {
    //args = ["class="a"", "class", "=", "a", undefined, undefined, index: 0, input: "class="a" id="b"></div>", groups: undefined]
    const args = match.attrs[i];
    const value = args[3] || args[4] || args[5] || "";
    // 要对属性值中的换行符或制表符做兼容处理
    const shouldDecodeNewlines =
      tagName === "a" && args[1] === "href"
        ? options.shouldDecodeNewlinesForHref
        : options.shouldDecodeNewlines;
    attrs[i] = {
      name: args[1], // 标签属性的属性名,如class
      value: decodeAttr(value, shouldDecodeNewlines) // 标签属性的属性值,如class对应的a
    };
    if (__DEV__ && options.outputSourceRange) {
      attrs[i].start = args.start + args[0].match(/^\s*/).length;
      attrs[i].end = args.end;
    }
  }
  //  如果该标签是非自闭合标签,则将标签推入栈中
  if (!unary) {
    stack.push({
      tag: tagName,
      lowerCasedTag: tagName.toLowerCase(),
      attrs: attrs,
      start: match.start,
      end: match.end
    });
    lastTag = tagName;
  }

  // 如果该标签是自闭合标签,现在就可以调用start钩子函数并传入处理好的参数来创建AST节点了
  if (options.start) {
    options.start(tagName, attrs, unary, match.start, match.end);
  }
}

handleStartTag 函数用来对 parseStartTag 函数的解析结果进行进一步处理,它接收 parseStartTag 函数的返回值作为参数。

shouldDecodeNewlines 这个常量主要是做一些兼容性处理, 如果 shouldDecodeNewlinestrue,意味着 Vue 在编译模板的时候,要对属性值中的换行符或制表符做兼容处理。而 shouldDecodeNewlinesForHreftrue 意味着 Vue 在编译模板的时候,要对 a 标签的 href 属性值中的换行符或制表符做兼容处理。

6. 解析文本

上面说到,解析开始之前,会先判断 html 是否以<开头,以<开头的才会进入标签以及 html 注释的解析,当代码执行到文本解析这部分的时候,就说明 html 已经不以<开头了

下面是文本解析的源码:

ts
let textEnd = html.indexOf("<");

// '<' 在第一个位置,为上述的5种类型
if (textEnd === 0) {
  // ...
}

// '<' 不在第一个位置,文本开头
let text, rest, next;
if (textEnd >= 0) {
  // 如果html字符串不是以'<'开头,说明'<'前面的都是纯文本,无需处理
  // 那就把'<'以后的内容拿出来赋给rest
  rest = html.slice(textEnd);
  while (
    !endTag.test(rest) &&
    !startTagOpen.test(rest) &&
    !comment.test(rest) &&
    !conditionalComment.test(rest)
  ) {
    // < in plain text, be forgiving and treat it as text
    /**
     * 用'<'以后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
     * 如果都匹配不上,表示'<'是属于文本本身的内容
     */
    // 在'<'之后查找是否还有'<'
    next = rest.indexOf("<", 1);
    // 如果没有了,表示'<'后面也是文本
    if (next < 0) break;
    // 如果还有,表示'<'是文本中的一个字符
    textEnd += next;
    // 那就把next之后的内容截出来继续下一轮循环匹配
    rest = html.slice(textEnd);
  }
  // '<'是结束标签的开始 ,说明从开始到'<'都是文本,截取出来
  text = html.substring(0, textEnd);
  advance(textEnd);
}
// 整个模板字符串里没有找到`<`,说明整个模板字符串都是文本
if (textEnd < 0) {
  text = html;
  html = "";
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
  options.chars(text);
}