You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

25 KiB

33 | 编译原理Vue Compiler模块全解析

你好,我是大圣。

上一讲我带你手写了一个迷你的Vue compiler还学习了编译原理的基础知识。通过实现这个迷你Vue compiler我们知道了tokenizer可以用来做语句分析而parse负责生成抽象语法树AST。然后我们一起分析AST中的Vue语法最后通过generate函数生成最终的代码。

今天我就带你深入Vue的compiler源码之中看看Vue内部到底是怎么实现的。有了上一讲编译原理的入门基础你会对Compiler执行全流程有更深的理解。

Vue compiler入口分析

Vue 3内部有4个和compiler相关的包。compiler-dom和compiler-core负责实现浏览器端的编译这两个包是我们需要深入研究的compiler-ssr负责服务器端渲染我们后面讲ssr的时候再研究compiler-sfc是编译.vue单文件组件的有兴趣的同学可以自行探索。

首先我们进入到vue-next/packages/compiler-dom/index.ts文件下GitHub上你可以找到下面这段代码。

compiler函数有两个参数第一个参数template它是我们项目中的模板字符串第二个参数options是编译的配置内部调用了baseCompile函数。我们可以看到这里的调用关系和runtime-dom、runtime-core的关系类似compiler-dom负责传入浏览器Dom相关的API实际编译的baseCompile是由compiler-core提供的。

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
    })
  )
}

我们先来看看compiler-dom做了哪些额外的配置。

首先parserOption传入了parse的配置通过parserOption传递的isNativeTag来区分element和component。这里的实现也非常简单把所有html的标签名存储在一个对象中然后就可以很轻松地判断出div是浏览器自带的element。

baseCompile传递的其他参数nodeTransforms和directiveTransforms它们做的也是和上面代码类似的事。


export const parserOptions: ParserOptions = {
  isVoidTag,
  isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag),
  isPreTag: tag => tag === 'pre',
  decodeEntities: __BROWSER__ ? decodeHtmlBrowser : decodeHtml,

  isBuiltInComponent: (tag: string): symbol | undefined => {
    if (isBuiltInType(tag, `Transition`)) {
      return TRANSITION
    } else if (isBuiltInType(tag, `TransitionGroup`)) {
      return TRANSITION_GROUP
    }
  },
  ...
}
const HTML_TAGS =
  'html,body,base,head,link,meta,style,title,address,article,aside,footer,' +
  'header,h1,h2,h3,h4,h5,h6,nav,section,div,dd,dl,dt,figcaption,' +
  'figure,picture,hr,img,li,main,ol,p,pre,ul,a,b,abbr,bdi,bdo,br,cite,code,' +
  'data,dfn,em,i,kbd,mark,q,rp,rt,ruby,s,samp,small,span,strong,sub,sup,' +
  'time,u,var,wbr,area,audio,map,track,video,embed,object,param,source,' +
  'canvas,script,noscript,del,ins,caption,col,colgroup,table,thead,tbody,td,' +
  'th,tr,button,datalist,fieldset,form,input,label,legend,meter,optgroup,' +
  'option,output,progress,select,textarea,details,dialog,menu,' +
  'summary,template,blockquote,iframe,tfoot'
export const isHTMLTag = /*#__PURE__*/ makeMap(HTML_TAGS)

Vue浏览器端编译的核心流程

然后我们进入到baseCompile函数中这就是Vue浏览器端编译的核心流程。

下面的代码中可以很清楚地看到我们先通过baseParse把传递的template解析成AST然后通过transform函数对AST进行语义化分析最后通过generate函数生成代码。

这个主要逻辑和我们写的迷你compiler基本一致这些函数大概要做的事你也心中有数了。这里你也能体验到亲手实现一个迷你版本对我们阅读源码很有帮助。

接下来我们就进入到这几个函数之中去看一下跟迷你compiler里的实现相比我们到底做了哪些优化。

export function baseCompile(
  template: string | RootNode,
  options: CompilerOptions = {}
): CodegenResult {
  const ast = isString(template) ? baseParse(template, options) : template
  const [nodeTransforms, directiveTransforms] =
    getBaseTransformPreset(prefixIdentifiers)

  transform(
    ast,
    extend({}, options, {
      prefixIdentifiers,
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []) // user transforms
      ],
      directiveTransforms: extend(
        {},
        directiveTransforms,
        options.directiveTransforms || {} // user transforms
      )
    })
  )
  return generate(
    ast,
    extend({}, options, {
      prefixIdentifiers
    })
  )
}


上一讲中我们体验了Vue的在线模板编译环境可以在console中看到Vue解析得到的AST。

如下图所示可以看到这个AST比迷你版多了很多额外的属性。loc用来描述节点对应代码的信息component和directive用来记录代码中出现的组件和指令等等

图片

然后我们进入到baseParse函数中, 这里的createParserContext和createRoot用来生成上下文其实就是创建了一个对象保存当前parse函数中需要共享的数据和变量最后调用parseChildren。

children内部开始判断<开头的标识符判断开始还是闭合标签后接着会生成一个nodes数组。其中advanceBy函数负责更新context中的source用来向前遍历代码最终对不同的场景执行不同的函数。

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)
  )
}
function parseChildren(
  context: ParserContext,
  mode: TextModes,
  ancestors: ElementNode[]
): TemplateChildNode[] {
  const parent = last(ancestors)
  // 依次生成node
  const nodes: TemplateChildNode[] = []
  // 如果遍历没结束
  while (!isEnd(context, mode, ancestors)) {

    const s = context.source
    let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined
    
    if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
      if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
        // 处理vue的变量标识符两个大括号 '{{'
        node = parseInterpolation(context, mode)
      } else if (mode === TextModes.DATA && s[0] === '<') {
        // 处理<开头的代码,可能是<div>也有可能是</div> 或者<!的注释
        if (s.length === 1) {
          // 长度是1只有一个< 有问题 报错
          emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 1)
        } else if (s[1] === '!') {
          // html注释
          if (startsWith(s, '<!--')) {
            node = parseComment(context)
          } else if (startsWith(s, '<!DOCTYPE')) {
              
            // DOCTYPE
            node = parseBogusComment(context)
          }
        } else if (s[1] === '/') {
           //</ 开头的标签,结束标签
          // https://html.spec.whatwg.org/multipage/parsing.html#end-tag-open-state
          if (/[a-z]/i.test(s[2])) {
            emitError(context, ErrorCodes.X_INVALID_END_TAG)
            parseTag(context, TagType.End, parent)
            continue
          } 
        } else if (/[a-z]/i.test(s[1])) {
          // 解析节点
          node = parseElement(context, ancestors)
          // 2.x <template> with no directive compat
          node = node.children
          }
        }
      }
    }
    if (!node) {
      // 文本
      node = parseText(context, mode)
    }
    // node树数组遍历puish
    if (isArray(node)) {
      for (let i = 0; i < node.length; i++) {
        pushNode(nodes, node[i])
      }
    } else {
      pushNode(nodes, node)
    }
  }

  return removedWhitespace ? nodes.filter(Boolean) : nodes
}

parseInterpolation和parseText函数的逻辑比较简单。parseInterpolation负责识别变量的分隔符 {{ 和}} 然后通过parseTextData获取变量的值并且通过innerStart和innerEnd去记录插值的位置parseText负责处理模板中的普通文本主要是把文本包裹成AST对象。

接着我们看看处理节点的parseElement函数都做了什么。首先要判断pre和v-pre标签然后通过isVoidTag判断标签是否是自闭合标签这个函数是从compiler-dom中传来的之后会递归调用parseChildren接着再解析开始标签、解析子节点最后解析结束标签。

const VOID_TAGS =
  'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'

export const isVoidTag = /*#__PURE__*/ makeMap(VOID_TAGS)
function parseElement(
  context: ParserContext,
  ancestors: ElementNode[]
): ElementNode | undefined {
  // Start tag.
  // 是不是pre标签和v-pre标签
  const wasInPre = context.inPre
  const wasInVPre = context.inVPre
  const parent = last(ancestors)
  // 解析标签节点
  const element = parseTag(context, TagType.Start, parent)
  const isPreBoundary = context.inPre && !wasInPre
  const isVPreBoundary = context.inVPre && !wasInVPre

  if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
    // #4030 self-closing <pre> tag
    if (isPreBoundary) {
      context.inPre = false
    }
    if (isVPreBoundary) {
      context.inVPre = false
    }
    return element
  }

  // Children.
  ancestors.push(element)
  const mode = context.options.getTextMode(element, parent)
  const children = parseChildren(context, mode, ancestors)
  ancestors.pop()
  element.children = children

  // End tag.
  if (startsWithEndTagOpen(context.source, element.tag)) {
    parseTag(context, TagType.End, parent)
  } else {
    emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
    if (context.source.length === 0 && element.tag.toLowerCase() === 'script') {
      const first = children[0]
      if (first && startsWith(first.loc.source, '<!--')) {
        emitError(context, ErrorCodes.EOF_IN_SCRIPT_HTML_COMMENT_LIKE_TEXT)
      }
    }
  }

  element.loc = getSelection(context, element.loc.start)

  if (isPreBoundary) {
    context.inPre = false
  }
  if (isVPreBoundary) {
    context.inVPre = false
  }
  return element
}

最后我们来看下解析节点的parseTag函数的逻辑匹配文本标签结束的位置后先通过parseAttributes函数处理属性然后对pre和v-pre标签进行检查最后通过isComponent函数判断是否为组件。

isComponent内部会通过compiler-dom传递的isNativeTag来辅助判断结果最终返回一个描述节点的对象包含当前节点所有解析之后的信息tag表示标签名children表示子节点的数组具体代码我放在了后面。

function parseTag(
  context: ParserContext,
  type: TagType,
  parent: ElementNode | undefined
): ElementNode | undefined {

  // Tag open. 
  const start = getCursor(context)
  //匹配标签结束的位置
  const match = /^<\/?([a-z][^\t\r\n\f />]*)/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

  // check <pre> tag 
  if (context.options.isPreTag(tag)) {
    context.inPre = true
  }
  // Attributes.
  // 解析属性
  let props = parseAttributes(context, type)
  // check v-pre
  if (){...}
  // Tag close.
  let isSelfClosing = false
  if (type === TagType.End) {
    return
  }

  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
  }
}


parse函数生成AST之后我们就有了一个完整描述template的对象它包含了template中所有的信息。

AST的语义化分析

下一步我们要对AST进行语义化的分析。transform函数的执行流程分支很多核心的逻辑就是识别一个个的Vue的语法并且进行编译器的优化我们经常提到的静态标记就是这一步完成的

我们进入到transform函数中可以看到内部通过createTransformContext创建上下文对象这个对象包含当前分析的属性配置包括是否ssr是否静态提升还有工具函数等等这个对象的属性你可以在 GitHub上看到。



export function transform(root: RootNode, options: TransformOptions) {
  const context = createTransformContext(root, options)
  traverseNode(root, context)
  if (options.hoistStatic) {
    hoistStatic(root, context)
  }
  if (!options.ssr) {
    createRootCodegen(root, context)
  }
  // finalize meta information
  root.helpers = [...context.helpers.keys()]
  root.components = [...context.components]
  root.directives = [...context.directives]
  root.imports = context.imports
  root.hoists = context.hoists
  root.temps = context.temps
  root.cached = context.cached

  if (__COMPAT__) {
    root.filters = [...context.filters!]
  }
}


然后通过traverseNode即可编译AST所有的节点。核心的转换流程是在遍历中实现内部使用switch判断node.type执行不同的处理逻辑。比如如果是Interpolation就需要在helper中导入toDisplayString工具函数这个迷你版本中我们也实现过。


export function traverseNode(
  node: RootNode | TemplateChildNode,
  context: TransformContext
) {
  context.currentNode = node
  // apply transform plugins
  const { nodeTransforms } = context
  const exitFns = []
  for (let i = 0; i < nodeTransforms.length; i++) {
    // 处理exitFns
  }
  swtch (node.type) {
    case NodeTypes.COMMENT:
      if (!context.ssr) {
        context.helper(CREATE_COMMENT)
      }
      break
    case NodeTypes.INTERPOLATION:
      if (!context.ssr) {
        context.helper(TO_DISPLAY_STRING)
      }
      break
    case NodeTypes.IF:
      for (let i = 0; i < node.branches.length; i++) {
        traverseNode(node.branches[i], context)
      }
      break
    case NodeTypes.IF_BRANCH:
    case NodeTypes.FOR:
    case NodeTypes.ELEMENT:
    case NodeTypes.ROOT:
      traverseChildren(node, context)
      break
  }

  // exit transforms
  context.currentNode = node
  let i = exitFns.length
  while (i--) {
    exitFns[i]()
  }
}


transform中还会调用transformElement来转换节点用来处理props和children的静态标记transformText用来转换文本这里的代码比较简单 你可以自行在Github上查阅。
transform函数参数中的nodeTransforms和directiveTransforms传递了Vue中template语法的配置这个两个函数由getBaseTransformPreset返回。

下面的代码中transformIf和transformFor函数式解析Vue中v-if和v-for的语法转换transformOn和transformModel是解析v-on和v-model的语法解析这里我们只关注v-开头的语法。



export function getBaseTransformPreset(
  prefixIdentifiers?: boolean
): TransformPreset {
  return [
    [
      transformOnce,
      transformIf,
      transformMemo,
      transformFor,
      ...(__COMPAT__ ? [transformFilter] : []),
      ...(!__BROWSER__ && prefixIdentifiers
        ? [
            // order is important
            trackVForSlotScopes,
            transformExpression
          ]
        : __BROWSER__ && __DEV__
        ? [transformExpression]
        : []),
      transformSlotOutlet,
      transformElement,
      trackSlotScopes,
      transformText
    ],
    {
      on: transformOn,
      bind: transformBind,
      model: transformModel
    }
  ]
}


然后我们再来看看transformIf的函数实现。首先判断v-if、v-else和v-else-if属性内部通过createCodegenNodeForBranch来创建条件分支在AST中标记当前v-if的处理逻辑。这段逻辑标记结束后在generate中就会把v-if标签和后面的v-else标签解析成三元表达式。

export const transformIf = createStructuralDirectiveTransform(
  /^(if|else|else-if)$/,
  (node, dir, context) => {
    return processIf(node, dir, context, (ifNode, branch, isRoot) => {
      const siblings = context.parent!.children
      let i = siblings.indexOf(ifNode)
      let key = 0
      while (i-- >= 0) {
        const sibling = siblings[i]
        if (sibling && sibling.type === NodeTypes.IF) {
          key += sibling.branches.length
        }
      }
      return () => {
        if (isRoot) {
          ifNode.codegenNode = createCodegenNodeForBranch(
            branch,
            key,
            context
          ) as IfConditionalExpression
        } else {
          // attach this branch's codegen node to the v-if root.
          const parentCondition = getParentCondition(ifNode.codegenNode!)
          parentCondition.alternate = createCodegenNodeForBranch(
            branch,
            key + ifNode.branches.length - 1,
            context
          )
        }
      }
    })
  }
)

transform对AST分析结束之后我们就得到了一个优化后的AST对象最后我们需要调用generate函数最终生成render函数。

template到render函数的转化

结合下面的代码我们可以看到generate首先通过createCodegenContext创建上下文对象然后通过genModulePreamble生成预先定义好的代码模板然后生成render函数最后生成创建虚拟DOM的表达式。

export function generate(
  ast,
  options
): CodegenResult {
  const context = createCodegenContext(ast, options)
  const {
    mode,
    push,
    prefixIdentifiers,
    indent,
    deindent,
    newline,
    scopeId,
    ssr
  } = context

  if (!__BROWSER__ && mode === 'module') {
    // 预设代码module风格 就是import语句
    genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)
  } else {
    // 预设代码,函数风格 就是import语句
    genFunctionPreamble(ast, preambleContext)
  }
  // render还是ssrRender
  const functionName = ssr ? `ssrRender` : `render`
  const args = ssr ? ['_ctx', '_push', '_parent', '_attrs'] : ['_ctx', '_cache']
  if (!__BROWSER__ && options.bindingMetadata && !options.inline) {
    // binding optimization args
    args.push('$props', '$setup', '$data', '$options')
  }
  const signature =
    !__BROWSER__ && options.isTS
      ? args.map(arg => `${arg}: any`).join(',')
      : args.join(', ')

  if (isSetupInlined) {
    push(`(${signature}) => {`)
  } else {
    push(`function ${functionName}(${signature}) {`)
  }
  indent()

  // 组件,指令声明代码
  if (ast.components.length) {
    genAssets(ast.components, 'component', context)
    if (ast.directives.length || ast.temps > 0) {
      newline()
    }
  }
  if (ast.components.length || ast.directives.length || ast.temps) {
    push(`\n`)
    newline()
  }

  if (ast.codegenNode) {
    genNode(ast.codegenNode, context)
  } else {
    push(`null`)
  }

  if (useWithBlock) {
    deindent()
    push(`}`)
  }

  deindent()
  push(`}`)

  return {
    ast,
    code: context.code,
    preamble: isSetupInlined ? preambleContext.code : ``,
    // SourceMapGenerator does have toJSON() method but it's not in the types
    map: context.map ? (context.map as any).toJSON() : undefined
  }
}


我们来看下关键的步骤genModulePreamble函数生成import风格的代码这也是我们迷你版本中的功能通过遍历helpers生成import字符串这对应了代码的第二行。

// 生成这个 
// import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

function genModulePreamble(
  ast: RootNode,
  context: CodegenContext,
  genScopeId: boolean,
  inline?: boolean
) {

  if (genScopeId && ast.hoists.length) {
    ast.helpers.push(PUSH_SCOPE_ID, POP_SCOPE_ID)
  }
  // generate import statements for helpers
  if (ast.helpers.length) {
      push(
        `import { ${ast.helpers
          .map(s => `${helperNameMap[s]} as _${helperNameMap[s]}`)
          .join(', ')} } from ${JSON.stringify(runtimeModuleName)}\n`
      )
    }
  }
  ...
}


接下来的步骤就是生成渲染函数render和component的代码最后通过genNode生成创建虚拟的代码执行switch语句生成不同的代码一共有十几种情况这里就不一一赘述了。我们可以回顾上一讲中迷你代码的逻辑总之针对变量标签v-if和v-for都有不同的代码生成逻辑最终才实现了template到render函数的转化。

function genNode(node: CodegenNode | symbol | string, context: CodegenContext) {
  if (isString(node)) {
    context.push(node)
    return
  }
  if (isSymbol(node)) {
    context.push(context.helper(node))
    return
  }
  switch (node.type) {
    case NodeTypes.ELEMENT:
    case NodeTypes.IF:
    case NodeTypes.FOR:
      genNode(node.codegenNode!, context)
      break
    case NodeTypes.TEXT:
      genText(node, context)
      break
    case NodeTypes.SIMPLE_EXPRESSION:
      genExpression(node, context)
      break
    case NodeTypes.INTERPOLATION:
      genInterpolation(node, context)
      break
    case NodeTypes.TEXT_CALL:
      genNode(node.codegenNode, context)
      break
    case NodeTypes.COMPOUND_EXPRESSION:
      genCompoundExpression(node, context)
      break
    case NodeTypes.COMMENT:
      genComment(node, context)
      break
    case NodeTypes.VNODE_CALL:
      genVNodeCall(node, context)
      break

    case NodeTypes.JS_CALL_EXPRESSION:
      genCallExpression(node, context)
      break
    case NodeTypes.JS_OBJECT_EXPRESSION:
      genObjectExpression(node, context)
      break
    case NodeTypes.JS_ARRAY_EXPRESSION:
      genArrayExpression(node, context)
      break
    case NodeTypes.JS_FUNCTION_EXPRESSION:
      genFunctionExpression(node, context)
      break
    case NodeTypes.JS_CONDITIONAL_EXPRESSION:
      genConditionalExpression(node, context)
      break
    case NodeTypes.JS_CACHE_EXPRESSION:
      genCacheExpression(node, context)
      break
    case NodeTypes.JS_BLOCK_STATEMENT:
      genNodeList(node.body, context, true, false)
      break

    /* istanbul ignore next */
    case NodeTypes.IF_BRANCH:
      // noop
      break

  }
}

总结

今天的内容到这就讲完了,我给你总结一下今天讲到的内容吧。

今天我们一起分析了Vue中的compiler执行全流程有了上一讲编译入门知识的基础之后今天的parsetransform和generate模块就是在上一讲的基础之上更加全面地实现代码的编译和转化。

上面的流程图中我们代码中的template是通过compiler函数进行编译转换compiler内部调用了compiler-core中的baseCompile函数并且传递了浏览器平台的转换逻辑。

比如isNativeTag等函数baseCompie函数中首先通过baseParse函数把template处理成为AST并且由transform函数进行标记优化transfom内部的transformIftransformOn等函数会对Vue中的语法进行标记这样在generate函数中就可以使用优化后的AST去生成最终的render函数。

最终render函数会和我们写的setup函数一起组成组件对象交给页面进行渲染。后面我特意为你绘制了一幅Vue全流程的架构图你可以保存下来随时查阅。

Vue源码中的编译优化也是Vue框架的亮点之一我们自己也要思考编译器优化的机制可以提高浏览器运行时的性能我们项目中该如何借鉴这种思路呢下一讲我会详细剖析编译原理在实战里的应用敬请期待。

思考题

最后留一个思考题transform函数中针对Vue中的语法有很多的函数处理比如transformIf会把v-if指令编译成为一个三元表达式请你从其余的函数选一个在评论区分享transform处理的结果吧。欢迎在评论区分享你的答案我们下一讲再见