【Vue.js 3.0源码】模板解析构造 AST 抽象语法树完整流程

459 阅读5分钟

自我介绍:大家好,我是吉帅振的网络日志;微信公众号:吉帅振的网络日志;前端开发工程师,工作4年,去过上海、北京,经历创业公司,进过大厂,现在郑州敲代码。

一、前言

Vue.js 3.0 的编译场景分服务端 SSR 编译和 web 编译,本文我们只分析 web 的编译。我们先来看 web 编译的入口 compile 函数,分析它的实现原理:

function compile(template, options = {}) { 
  return baseCompile(template, extend({}, parserOptions, options, { 
    nodeTransforms: [...DOMNodeTransforms, ...(options.nodeTransforms || [])], 
    directiveTransforms: extend({}, DOMDirectiveTransforms, options.directiveTransforms || {}), 
    transformHoist:  null 
  })) 
}

compile 函数支持两个参数,第一个参数 template 是待编译的模板字符串,第二个参数 options 是编译的一些配置信息。compile 内部通过执行 baseCompile 方法完成编译工作,可以看到 baseCompile 在参数 options 的基础上又扩展了一些配置。

function baseCompile(template,  options = {}) { 
  const prefixIdentifiers = false 
  // 解析 template 生成 AST 
  const ast = isString(template) ? baseParse(template, options) : template 
  const [nodeTransforms, directiveTransforms] = getBaseTransformPreset() 
  // AST 转换 
  transform(ast, extend({}, options, { 
    prefixIdentifiers, 
    nodeTransforms: [ 
      ...nodeTransforms, 
      ...(options.nodeTransforms || []) 
    ], 
    directiveTransforms: extend({}, directiveTransforms, options.directiveTransforms || {} 
    ) 
  })) 
  // 生成代码 
  return generate(ast, extend({}, options, { 
    prefixIdentifiers 
  })) 
}

二、解析 template 生成 AST

<div class="app"> 
  <!-- 这是一段注释 --> 
  <hello> 
    <p>{{ msg }}</p> 
  </hello> 
  <p>This is an app</p> 
</div> 
//解析后,生成相应的 AST 对象:
{ 
  "type": 0, 
  "children": [ 
    { 
      "type": 1, 
      "ns": 0, 
      "tag": "div", 
      "tagType": 0, 
      "props": [ 
        { 
          "type": 6, 
          "name": "class", 
          "value": { 
            "type": 2, 
            "content": "app", 
            "loc": { 
              "start": { 
                "column": 12, 
                "line": 1, 
                "offset": 11 
              }, 
              "end": { 
                "column": 17, 
                "line": 1, 
                "offset": 16 
              }, 
              "source": "\"app\"" 
            } 
          }, 
          "loc": { 
            "start": { 
              "column": 6, 
              "line": 1, 
              "offset": 5 
            }, 
            "end": { 
              "column": 17, 
              "line": 1, 
              "offset": 16 
            }, 
            "source": "class=\"app\"" 
          } 
        } 
      ],
}

可以看到,AST 是树状结构,对于树中的每个节点,会有 type 字段描述节点的类型,tag 字段描述节点的标签,props 描述节点的属性,loc 描述节点对应代码相关信息,children 指向它的子节点对象数组。当然 AST 中的节点还包含其他的一些属性,我在这里就不一一介绍了,你现在要理解的是 AST 中的节点是可以完整地描述它在模板中映射的节点信息。注意,AST 对象根节点其实是一个虚拟节点,它并不会映射到一个具体节点,另外它还包含了其他的一些属性,这些属性在后续的 AST 转换的过程中会赋值,并在生成代码阶段用到。

那么,为什么要设计一个虚拟节点呢?因为 Vue.js 3.0 和 Vue.js 2.x 有一个很大的不同——Vue.js 3.0 支持了 Fragment 的语法,即组件可以有多个根节点,比如:

<img src="./logo.jpg"> 
<hello :msg="msg"></hello> 

//这种写法在 Vue.js 2.x 中会报错,提示模板只能有一个根节点,
//而 Vue.js 3.0 允许了这种写法。但是对于一棵树而言,必须有一个根节点,
//所以虚拟节点在这种场景下就非常有用了,它可以作为 AST 的根节点,
//然后其 children 包含了 img 和 hello 的节点。 baseParse 的实现:
function baseParse(content, options = {}) { 
    // 创建解析上下文 
    const context = createPa  rserContext(content, options) 
    const start = getCursor(context) 
    // 解析子节点,并创建 AST  
    return createRoot(parseChildren(context, 0 /* DATA */, []), getSelection(context, start)) 
} 

1.创建解析上下文

// 默认解析配置 
const defaultParserOptions = { 
  delimiters: [`{{`, `}}`], 
  getNamespace: () => 0 /* HTML */, 
  getTextMode: () => 0 /* DATA */, 
  isVoidTag: NO, 
  isPreTag: NO, 
  isCustomElement: NO, 
  decodeEntities: (rawText) => rawText.replace(decodeRE, (_, p1) => decodeMap[p1]), 
  onError: defaultOnError 
} 
function createParserContext(content, options) { 
  return { 
    options: extend({}, defaultParserOptions, options), 
    column: 1, 
    line: 1, 
    offset: 0, 
    originalSource: content, 
    source: content, 
    inPre: false, 
    inVPre: false 
  } 
} 

解析上下文实际上就是一个 JavaScript 对象,它维护着解析过程中的上下文,其中 options 表示解析相关配置 ,column 表示当前代码的列号,line 表示当前代码的行号,originalSource 表示最初的原始代码,source 表示当前代码,offset 表示当前代码相对于原始代码的偏移量,inPre 表示当前代码是否在 pre 标签内,inVPre 表示当前代码是否在 v-pre 指令的环境下。在后续解析的过程中,会始终维护和更新这个解析上下文,它能够表示当前解析的状态。创建完解析上下文,接下来就开始解析子节点了。

2.解析子节点

function parseChildren(context, mode, ancestors) { 
  const parent = last(ancestors) 
  const ns = parent ? parent.ns : 0 /* HTML */ 
  const nodes = [] 
   
  // 自顶向下分析代码,生成 nodes 
   
  let removedWhitespace = false 
  // 空白字符管理 
   
  return removedWhitespace ? nodes.filter(Boolean) : nodes 
} 

parseChildren 的目的就是解析并创建 AST 节点数组。它有两个主要流程,第一个是自顶向下分析代码,生成 AST 节点数组 nodes;第二个是空白字符管理,用于提高编译的效率。

function parseChildren(context, mode, ancestors) { 
  // 父节点 
  const parent = last(ancestors) 
  const ns = parent ? parent.ns : 0 /* HTML */ 
  const nodes = [] 
  // 判断是否遍历结束 
  while (!isEnd(context, mode, ancestors)) { 
    const s = context.source 
    let node = undefined 
    if (mode === 0 /* DATA */ || mode === 1 /* RCDATA */) { 
      if (!context.inVPre && startsWith(s, context.options.delimiters[0])) { 
        // 处理 {{ 插值代码 
        node = parseInterpolation(context, mode) 
      } 
      else if (mode === 0 /* DATA */ && s[0] === '<') { 
        // 处理 < 开头的代码 
        if (s.length === 1) { 
          // s 长度为 1,说明代码结尾是 <,报错 
          emitError(context, 5 /* EOF_BEFORE_TAG_NAME */, 1) 
        } 
        else if (s[1] === '!') { 
          // 处理 <! 开头的代码 
          if (startsWith(s, '<!--')) { 
            // 处理注释节点 
            node = parseComment(context) 
          } 
          else if (startsWith(s, '<!DOCTYPE')) { 
            // 处理 <!DOCTYPE 节点 
            node = parseBogusComment(context) 
          } 
          else if (startsWith(s, '<![CDATA[')) { 
            // 处理 <![CDATA[ 节点 
            if (ns !== 0 /* HTML */) { 
              node = parseCDATA(context, ancestors) 
            } 
            else { 
              emitError(context, 1 /* CDATA_IN_HTML_CONTENT */) 
              node = parseBogusComment(context) 
            } 
          } 
          else { 
            emitError(context, 11 /* INCORRECTLY_OPENED_COMMENT */) 
            node = parseBogusComment(context) 
          } 
        } 
        else if (s[1] === '/') { 
          // 处理 </ 结束标签 
          if (s.length === 2) { 
            // s 长度为 2,说明代码结尾是 </,报错 
            emitError(context, 5 /* EOF_BEFORE_TAG_NAME */, 2) 
          } 
          else if (s[2] === '>') { 
            // </> 缺少结束标签,报错 
            emitError(context, 14 /* MISSING_END_TAG_NAME */, 2) 
            advanceBy(context, 3) 
            continue 
          } 
          else if (/[a-z]/i.test(s[2])) { 
            // 多余的结束标签 
            emitError(context, 23 /* X_INVALID_END_TAG */) 
            parseTag(context, 1 /* End */, parent) 
            continue 
          } 
          else { 
            emitError(context, 12 /* INVALID_FIRST_CHARACTER_OF_TAG_NAME */, 2) 
            node = parseBogusComment(context) 
          } 
        } 
        else if (/[a-z]/i.test(s[1])) { 
          // 解析标签元素节点 
          node = parseElement(context, ancestors) 
        } 
        else if (s[1] === '?') { 
          emitError(context, 21 /* UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME */, 1) 
          node = parseBogusComment(context) 
        } 
        else { 
          emitError(context, 12 /* INVALID_FIRST_CHARACTER_OF_TAG_NAME */, 1) 
        } 
      } 
    } 
    if (!node) { 
      // 解析普通文本节点 
      node = parseText(context, mode) 
    } 
    if (isArray(node)) { 
      // 如果 node 是数组,则遍历添加 
      for (let i = 0; i < node.length; i++) { 
        pushNode(nodes, node[i]) 
      } 
    } 
    else { 
      // 添加单个 node 
      pushNode(nodes, node) 
    } 
  } 
} 

这些代码看起来很复杂,但它的思路就是自顶向下地去遍历代码,然后根据不同的情况尝试去解析代码,然后把生成的 node 添加到 AST nodes 数组中。在解析的过程中,解析上下文 context 的状态也是在不断发生变化的,我们可以通过 context.source 拿到当前解析剩余的代码 s,然后根据 s 不同的情况走不同的分支处理逻辑。在解析的过程中,可能会遇到各种错误,都会通过 emitError 方法报错。

三、创建 AST 根节点

function createRoot(children, loc = locStub) {
  return {
    type: 0 /* ROOT */,
    children,
    helpers: [],
    components: [],
    directives: [],
    hoists: [],
    imports: [],
    cached: 0,
    temps: 0,
    codegenNode: undefined,
    loc
  }
}

createRoot 的实现非常简单,它就是返回一个 JavaScript 对象,作为 AST 根节点。其中 type 表示它是一个根节点类型,children 是我们前面解析的子节点数组。

四、总结

掌握 Vue.js 编译过程的第一步,即把 template 解析生成 AST 对象。整个解析过程是一个自顶向下的分析过程,也就是从代码开始,通过语法分析,找到对应的解析处理逻辑,创建 AST 节点,处理的过程中也在不断前进代码,更新解析上下文,最终根据生成的 AST 节点数组创建 AST 根节点。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿