Vue3源码12: 编译过程介绍及AST的生成过程分析

2022-09-27 14:29:38 浏览数 (1)

  • Vue3源码01 : 代码管理策略-monorepo
  • Vue3源码02: 项目构建流程和源码调试方法
  • Vue3源码03: Vue3响应式核心原理
  • Vue3源码04: Vue3响应式系统源码实现1/2
  • Vue3源码05 : Vue3响应式系统源码实现(2/2)
  • Vue3源码06: reactive、ref相关api源码实现
  • Vue3源码07: 故事要从createApp讲起
  • Vue3源码08: 虚拟Node到真实Node的路其实很长
  • Vue3源码09: 组件的渲染和更新流程
  • Vue3源码10: 名动江湖的diff算法
  • Vue3源码11: 编译优化之Block Tree 与 PatchFlags

本文先会分析Vue3的编译过程主要包含哪些环节,理解了主体环节后,再带着大家深入分析AST的生成过程。

编译过程

我们进入core/packages/compiler-dom/src/index.ts,会发现有这样的代码:

代码语言:javascript复制
// 代码片段1
export function compile(
  template: string,
  options: CompilerOptions = {}
): CodegenResult {
  return baseCompile(
    template,
    extend({}, parserOptions, options, {
      nodeTransforms: [
        // ignore <script> and <tag>
        // this is not put inside DOMNodeTransforms because that list is used
        // by compiler-ssr to generate vnode fallback branches
        ignoreSideEffectTags,
        ...DOMNodeTransforms,
        ...(options.nodeTransforms || [])
      ],
      directiveTransforms: extend(
        {},
        DOMDirectiveTransforms,
        options.directiveTransforms || {}
      ),
      transformHoist: __BROWSER__ ? null : stringifyStatic
    })
  )
}

函数compile的作用就是把模版字符串转化成一个render函数。而函数compile函数内部仅仅是调用了函数baseCompilebaseCompile函数是从compiler-core导入的,也就是关于编译相关的功能主要是在compiler-core中完成的。而compiler-dom主要是向函数baseCompiler传入了一系列的参数,这些参数代表什么含义我们在后文会在恰当的地方解释。

我们来看一下compiler-core中函数baseCompile的代码:

代码语言:javascript复制
// 代码片段2
export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {

  // 此处省略许多代码...
  const ast = isString(template) ? baseParse(template, options) : template
  // 此处省略许多代码...
  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )
  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}

我们对函数baseCompile进行精简了之后,做了三件重要的事情,调用baseParse函数返回ast,接着调用transformast进行处理,最后调用generate函数返回结果,其实这三个函数完成了下面三项工作:

  1. 将模版字符串转化成AST
  2. AST转化成可以用来描述JavaScriptAST
  3. 根据第2步生成的可以描述JavaScriptAST生成一个函数。

上面三项工作,每一项都涉及到大量代码,本文只分析Vue3是如何将模版字符串转化成AST的。

AST的生成

什么是AST

为了直观的体会AST是什么,我们在这个网址上https://vue-next-template-explorer.netlify.app/输入下面的代码:

代码语言:javascript复制
<!--代码片段3-->
<div>
  <div>yangyitao</div>
</div>

此时查看控制台输出的AST,我们提取其中的部分内容显示在这里:

代码语言:javascript复制
// 代码片段4
{
    children: [{type: 2, content: 'yangyitao'}],
    isSelfClosing: false,
    loc: {start: {…}, end: {…}, source: '<div>yangyitao</div>'},
    tag: "div",
    tagType: 0,
    type: 1
}

代码片段4中没有对每一项都展开,但是我们依然可以直观的看到,所谓的AST其实就是一个对象,该对象可以用来描述我们传入的模版字符串。下面我们就进入baseParse函数,分析模版字符串转化为render函数的具体过程。

baseParse函数

我们来看函数baseParse的代码实现:

代码语言:javascript复制
// 代码片段5
export function baseParse(
  content: string,
  options: ParserOptions = {}
): RootNode {
  const context = createParserContext(content, options)
  const start = getCursor(context)
  return createRoot(
    parseChildren(context, TextModes.DATA, []),
    getSelection(context, start)
  )
}

发现函数baseParse内部主要通过createParserContextgetCursorcreateRootparseChildrengetSelection等5个函数来完成工作。接下来我们就进入到这5个函数中去一探究竟。

createParserContext

代码语言:javascript复制
// 代码片段6
function createParserContext(
  content: string,
  rawOptions: ParserOptions
): ParserContext {
  const options = extend({}, defaultParserOptions)

  let key: keyof ParserOptions
  for (key in rawOptions) {
    // @ts-ignore
    options[key] =
      rawOptions[key] === undefined
        ? defaultParserOptions[key]
        : rawOptions[key]
  }
  return {
    options,
    column: 1, 
    line: 1, 
    offset: 0, 
    originalSource: content, 
    source: content, 
    inPre: false,
    inVPre: false,
    onWarn: options.onWarn
  }
}

对于函数createParserContext,我们先忽略对rawOptions参数的处理,会发现该函数返回了一个对象。该对象记录了很多信息,我们这里主要关注5个属性:columnlineoffsetoriginalSourcesource。这5个属性具体代表什么含义呢?假设我们有这样的代码片段:

代码语言:javascript复制
<!--代码片段7-->
<script src="./compiler-dom.global.js"></script>
<script>
    const { compile } = VueCompilerDOM;
    let ast = compile('<div>yangyitao</div>')
</script>

我们查看函数createParserContext的首次调用的返回值,省略一些内容后有下面的信息:

代码语言:javascript复制
// 代码片段8
{
    column: 1,
    line: 1,
    offset: 0,
    originalSource: "<div>yangyitao</div>",
    source: "<div>yangyitao</div>"
}

接下来我们解释下面5个属性的含义。

  1. column:所谓列,是对模版字符串指解析到哪一列了,举个例子<div>yangyitao</div>,如果程序处理完开始标签<div>,那么此时相当于程序已经解析到第6列,需要注意的是这里的列是相对于行的位置,比如代码解析到了第二行的第一个字符,那列的值依然是1;
  2. line:代码片段7中我们只有一行代码,所以这个值始终是1;
  3. offset:与column不同,偏移量offset是相对于我们要解析的整个模版字符串的位置。举个例子,如果我们要解析下面的模版字符串:
代码语言:javascript复制
<div>
yangyitao</div>

当解析完第一个标签<div>column是1,但偏移量是5。需要注意的是偏移量是从0开始计数,而columnline是从1开始计数;

  1. originalSource:代表整个待解析的模版字符串,对应代码片段7的<div>yangyitao</div>
  2. source:代表尚未解析模版字符串,比如代码片段7中的模版字符串<div>yangyitao</div>如果将开始标签<div>解析完毕,那么source的值就应该是yangyitao</div>

我们想一想,为什么要有这样一个上下文对象呢?所谓上下文对象,实际上就是维护了一个对象,这个对象记录了当前对模版字符串进行解析的状态,比如解析到什么地方了,还剩多少内容没有处理,同时还记录了当前处理节点的类型等等。代码片段6中我们刚才忽略了参数rawOptions,我们来看看这个rawOptions可能包括哪些内容:

代码语言:javascript复制
// 代码片段9
export const defaultParserOptions: MergedParserOptions = {
  delimiters: [`{{`, `}}`],
  getNamespace: () => Namespaces.HTML,
  getTextMode: () => TextModes.DATA,
  isVoidTag: NO,
  isPreTag: NO,
  isCustomElement: NO,
  decodeEntities: (rawText: string): string =>
    rawText.replace(decodeRE, (_, p1) => decodeMap[p1]),
  onError: defaultOnError,
  onWarn: defaultOnWarn,
  comments: __DEV__
}

从代码片段9中可以看出,我们的上下文不仅维护了状态还具备一些能力,通过这些能力可以获取当前操作节点的类型等等,至于什么是TextModesNamespaces我们在后续用到的地方再讲解。

getCursor

代码语言:javascript复制
// 代码片段10
function getCursor(context: ParserContext): Position {
  const { column, line, offset } = context
  return { column, line, offset }
}

该函数逻辑非常简单,只是从上下文中获取了几个属性,这几个属性能反映出当前对模版字符串解析到什么位置了。

getSelection

代码语言:javascript复制
// 代码片段11
function getSelection(
  context: ParserContext,
  start: Position,
  end?: Position
): SourceLocation {
  end = end || getCursor(context)
  return {
    start,
    end,
    source: context.originalSource.slice(start.offset, end.offset)
  }
}

该函数返回的是一个对象,这个对象代表了一个完整节点的代码内容以及这些内容在整个模版字符串中的开始位置和结束位置。至于开始位置和结束位置是通过上文介绍过的列、行、偏移量来进行描述。

createRoot

代码语言:javascript复制
// 代码片段12
export function createRoot(
  children: TemplateChildNode[],
  loc = locStub
): RootNode {
  return {
    type: NodeTypes.ROOT,
    children,
    helpers: [],
    components: [],
    directives: [],
    hoists: [],
    imports: [],
    cached: 0,
    temps: 0,
    codegenNode: undefined,
    loc
  }
}

该函数返回的其实就是一个我们所说的ASTAST是什么,AST就是一个对象,就是一个用来描述模版字符串的对象。需要注意的是,根节点有一个特殊的类型来标识NodeTypes.ROOT

parseChildren

函数parseChildren可以说是整个解析模版字符串功能的灵魂,鉴于代码量太大,我们来看精简过后的代码:

代码语言:javascript复制
// 代码片段13
function parseChildren(
  context: ParserContext,
  mode: TextModes,
  ancestors: ElementNode[]
): TemplateChildNode[] {
  const parent = last(ancestors)
  const nodes: TemplateChildNode[] = []

  while (!isEnd(context, mode, ancestors)) {
    const s = context.source
    if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
      if (startsWith(s, context.options.delimiters[0])) {
        node = parseInterpolation(context, mode)
      } else if (mode === TextModes.DATA && s[0] === '<') {
        if (s[1] === '!') {
          if (startsWith(s, '<!--')) {
            node = parseComment(context)
          } else if (startsWith(s, '<!DOCTYPE')) {
            node = parseBogusComment(context)
          } else if (startsWith(s, '<![CDATA[')) {
            if (ns !== Namespaces.HTML) {
              node = parseCDATA(context, ancestors)
            }
          }
        } else if (/[a-z]/i.test(s[1])) {
          node = parseElement(context, ancestors)
        }
      }
    }
    
    if (!node) {
      node = parseText(context, mode)
    }

    if (isArray(node)) {
      for (let i = 0; i < node.length; i  ) {
        pushNode(nodes, node[i])
      }
    } else {
      pushNode(nodes, node)
    }
  }
  return nodes
}

该函数原本还有很多其他比较重要的逻辑,但核心的逻辑就如代码片段13所描述的那样。这段代码该如何去理解呢?里面有两个关键的变量,一个是parent用来描述解析的子节点归属于哪个父节点,另一个变量是nodes表示解析得到的子节点的数组。同时,代码片段13中还调用了几个关键的函数parseElementparseCDATAparseTextparseCommentparseBogusCommentparseInterpolation

在解释这几个函数具体是如何实现的之前,我们需要搞清楚TextModes具备什么作用,才能明白为什么需要这几个函数来分工协作。

TextModes

我们先来观察TextModes的代码:

代码语言:javascript复制
// 代码片段14
export const enum TextModes {
  //          | Elements | Entities | End sign              | Inside of
  DATA, //    | ✔        | ✔        | End tags of ancestors |
  RCDATA, //  | ✘        | ✔        | End tag of the parent | <textarea>
  RAWTEXT, // | ✘        | ✘        | End tag of the parent | <style>,<script>
  CDATA,
  ATTRIBUTE_VALUE
}

Vue3通过TextModes来区分,当前处理的模版字符串内容属于什么类型。普通标签用DATA来表示,textarea中的文本用RCDATA来表示,模版字符串原则上不会有<style><script>标签存在的,所以对这类型的字符串不做任何处理。RCDATA相较于DATA,有一个不同点,DATA类型的处理方式会对元素标签进行正常解析,但是RCDATA不会解析标签,但这二者都会解析实体字符串。而至于CDATA也是对其中的内容不做解析。而ATTRIBUTE_VALUE则主要用来标识标签的属性部分内容。

其实可以理解为,对于模版字符串的不同的内容类型,采取不同的策略来进行解析。而这些策略,就是我们上面提到过的parseElementparseCDATAparseTextparseCommentparseBogusCommentparseInterpolation等函数。虽然对不同类型的数据具体处理各不相同,但是解析的方式主要逻辑是很相似的。所以本文不打算对每一个函数进行详细分析,而只是对极具代表性的函数parseElement进行讲解。

parseElement

代码语言:javascript复制
// 代码片段14
function parseElement(
  context: ParserContext,
  ancestors: ElementNode[]
): ElementNode | undefined {
  // 此处省略很多代码...
  const parent = last(ancestors)
  const element = parseTag(context, TagType.Start, parent)
  // 此处省略很多代码...
  ancestors.push(element)
  const mode = context.options.getTextMode(element, parent)
  const children = parseChildren(context, mode, ancestors)
  ancestors.pop()
  // 此处省略很多代码...
  element.children = children
  // 此处省略很多代码...
  return element
}

省略大量代码留下核心逻辑,里面有有三个点值得关注:

  1. 利用ancestors维护当前节点的所有父节点;
  2. 通过parseChildren获取子节点的内容;
  3. 将获取的子节点内容赋值给当前节点element.children = children;
  4. 利用parseTag解析节点本身的内容。

上面后三个点中的后两个点其实概括了parseElement的核心工作。而变量accestors目前来看主要和命名空间ns相关,但是ns在目前项目中主要是枚举值HTML,所以其作用比较微弱,拿出来讲一讲是为了防止大家疑惑,唯一值得注意的一个点是accestors是一个栈,从代码片段14中可以看出,在解析子节点之前先push该节点,紧接着解析完子节点后再pop出当前节点,这样就保证了解析的子节点都能获取到自己正确的父节点。

第2个关键点,相当于递归执行代码片段13。而第3个关键点则是直接将解析到的结果赋值给当前节点,第4个关键点是解析标签本身,下面开始分析parseTag函数。

parseTag

请看parseTag函数的代码实现:

代码语言:javascript复制
// 代码片段15
function parseTag(
  context: ParserContext,
  type: TagType,
  parent: ElementNode | undefined
): ElementNode | undefined {
  // Tag open.
  const start = getCursor(context)
  const match = /^</?([a-z][^trnf />]*)/i.exec(context.source)!
  const tag = match[1]
  const ns = context.options.getNamespace(tag, parent)

  advanceBy(context, match[0].length)
  advanceSpaces(context)

  // save current state in case we need to re-parse attributes with v-pre
  const cursor = getCursor(context)
  const currentSource = context.source
  
  // Attributes.
  let props = parseAttributes(context, type)
  advanceBy(context, isSelfClosing ? 2 : 1
  
  let tagType = ElementTypes.ELEMENT
  if (!context.inVPre) {
    if (tag === 'slot') {
      tagType = ElementTypes.SLOT
    } else if (tag === 'template') {
      if (
        props.some(
          p =>
            p.type === NodeTypes.DIRECTIVE && isSpecialTemplateDirective(p.name)
        )
      ) {
        tagType = ElementTypes.TEMPLATE
      }
    } else if (isComponent(tag, props, context)) {
      tagType = ElementTypes.COMPONENT
    }
  }

  return {
    type: NodeTypes.ELEMENT,
    ns,
    tag,
    tagType,
    props,
    isSelfClosing,
    children: [],
    loc: getSelection(context, start),
    codegenNode: undefined // to be created during transform phase
  }
}

省略了很多代码后,我们能比较容易得出下面几个结论:

  1. 函数parseTag返回的是一个对象;
  2. 函数parseTag返回的对象中包括一个重要的属性props;
  3. 函数parseTag返回的对象中包括一个重要的属性type;

其实可以简单理解该函数返回了一个对象,该对象描述了一个html标签,比如下面的模版代码:<div id='app'>yangyitao</div><div id='app'>就是parseTag返回的对象所需要描述的东西。至于解析属性的函数parseAttributes实现逻辑比较简单代码量也不多,大家可以自己进行分析。

小结

本文从compiler-dom中的compile函数讲起,分析了模版字符串解析成AST的核心流程,希望大家能在阅读文章后多进行调试,深入掌握AST的生成过程。

0 人点赞