Skip to content

前言

在之前的 vue文件编译成js文件 文章中我们讲过了vue文件是如何编译成js文件,通过那篇文章我们知道了,template编译为render函数底层就是调用了@vue/compiler-sfc包暴露出来的compileTemplate函数。由于文章篇幅有限,我们没有去深入探索compileTemplate函数是如何将template模块编译为render函数,在这篇文章中我们来了解一下。

@vue下面的几个包

先来介绍一下本文中涉及到vue下的几个包,分别是:@vue/compiler-sfc@vue/compiler-dom@vue/compiler-core

  • @vue/compiler-sfc:用于编译vue的SFC文件,这个包依赖vue下的其他包,比如@vue/compiler-dom@vue/compiler-core。这个包一般是给vue-loader 和 @vitejs/plugin-vue使用的。

  • @vue/compiler-dom:这个包专注于浏览器端的编译,处理浏览器dom相关的逻辑都在这里面。

  • @vue/compiler-core:从名字你也能看出来这个包是vue编译部分的核心,提供了通用的编译逻辑,不管是浏览器端还是服务端编译最终都会走到这个包里面来。

@vue/compiler-sfc包的compileTemplate函数

还是同样的套路,我们通过debug一个简单的demo来搞清楚compileTemplate函数是如何将template编译成render函数的。demo代码如下:

vue
<template>
  <input v-for="item in msgList" :key="item.id" v-model="item.value" />
</template>

<script setup lang="ts">
import { ref } from "vue";

const msgList = ref([
  {
    id: 1,
    value: "",
  },
  {
    id: 2,
    value: "",
  },
  {
    id: 3,
    value: "",
  },
]);
</script>

vue文件编译成js文件 文章中我们已经知道了在使用vite的情况下template编译为render函数是在node端完成的。所以我们需要启动一个debug终端,才可以在node端打断点。这里以vscode举例,首先我们需要打开终端,然后点击终端中的+号旁边的下拉箭头,在下拉中点击Javascript Debug Terminal就可以启动一个debug终端。 debug-terminal

compileTemplate函数在node_modules/@vue/compiler-sfc/dist/compiler-sfc.cjs.js文件中,找到compileTemplate函数打上断点,然后在debug终端中执行yarn dev(这里是以vite举例)。在浏览器中访问 http://localhost:5173/,此时断点就会走到compileTemplate函数中了。在我们这个场景中compileTemplate函数简化后的代码非常简单,代码如下:

js
function compileTemplate(options) {
  return doCompileTemplate(options);
}

@vue/compiler-sfc包的doCompileTemplate函数

我们接着将断点走进doCompileTemplate函数中,看看里面的代码是什么样的,简化后的代码如下:

js
import * as CompilerDOM from '@vue/compiler-dom'

function doCompileTemplate({
  source,
  ast: inAST,
  compiler
}) {
  const defaultCompiler = CompilerDOM;
  compiler = compiler || defaultCompiler;
  let { code, ast, preamble, map } = compiler.compile(inAST || source, {
    // ...省略传入的options
  });
  return { code, ast, preamble, source, errors, tips, map };
}

doCompileTemplate函数中代码同样也很简单,我们在debug终端中看看compilersourceinAST这三个变量的值是长什么样的。如下图: doCompileTemplate

从上图中我们可以看到此时的compiler变量的值为undefinedsource变量的值为template模块中的代码,inAST的值为由template模块编译而来的AST抽象语法树。不是说好的要经过parse函数处理后才会得到AST抽象语法树,为什么这里就已经有了AST抽象语法树?不要着急接着向下看,后面我会解释。

由于这里的compiler变量的值为undefined,所以compiler会被赋值为CompilerDOM。而CompilerDOM就是@vue/compiler-dom包中暴露的所有内容。执行compiler.compile函数,就是执行@vue/compiler-dom包中的compile函数。

compile函数接收的第一个参数为inAST || source,从这里我们知道第一个参数既可能是AST抽象语法树,也有可能是template模块中的html代码字符串。compile函数的返回值对象中的code字段就是编译好的render函数,然后return出去。

@vue/compiler-dom包中的compile函数

我们接着将断点走进@vue/compiler-dom包中的compile函数,发现代码同样也很简单,简化后的代码如下:

js
import {
  baseCompile,
} from '@vue/compiler-core'

function compile(src, options = {}) {
  return baseCompile(
    src,
    Object.assign({}, parserOptions, options, {
      nodeTransforms: [
        ...DOMNodeTransforms,
        ...options.nodeTransforms || []
      ],
      directiveTransforms: shared.extend(
        {},
        DOMDirectiveTransforms,
        options.directiveTransforms || {}
      )
    })
  );
}

从上面的代码中可以看到这里的compile函数也不是具体实现的地方,在这里调用的是@vue/compiler-core包的baseCompile函数。看到这里你可能会有疑问,为什么不在上一步的doCompileTemplate函数中直接调用@vue/compiler-core包的baseCompile函数,而是要从@vue/compiler-dom包中绕一圈再来调用呢baseCompile函数呢?

答案是baseCompile函数是一个处于@vue/compiler-core包中的API,而@vue/compiler-core可以运行在各种 JavaScript 环境下,比如浏览器端、服务端等各个平台。baseCompile函数接收这些平台专有的一些options,而我们这里的demo是浏览器平台。

所以才需要从@vue/compiler-dom包中绕一圈去调用@vue/compiler-core包中的baseCompile函数传入一些浏览器中特有的options。在上面的代码中我们看到使用DOMNodeTransforms数组对options中的nodeTransforms属性进行了扩展,使用DOMDirectiveTransforms对象对options中的directiveTransforms属性进行了扩展。

我们先来看看DOMNodeTransforms数组:

js
const DOMNodeTransforms = [
  transformStyle
];

options对象中的nodeTransforms属性是一个数组,里面包含了许多transform转换函数用于处理AST抽象语法树。经过@vue/compiler-domcompile函数处理后nodeTransforms数组中多了一个处理style的transformStyle函数。这里的transformStyle是一个转换函数用于处理dom上面的style,比如style="color: red"

我们再来看看DOMDirectiveTransforms对象:

js
const DOMDirectiveTransforms = {
  cloak: compilerCore.noopDirectiveTransform,
  html: transformVHtml,
  text: transformVText,
  model: transformModel,
  on: transformOn,
  show: transformShow
};

options对象中的directiveTransforms属性是一个对象,经过@vue/compiler-domcompile函数处理后directiveTransforms对象中增加了处理v-cloakv-htmlv-textv-modelv-onv-show等指令的transform转换函数。很明显我们这个demo中input标签上面的v-model指令就是由这里的transformModel转换函数处理。

你发现了没,不管是nodeTransforms数组还是directiveTransforms对象,增加的transform转换函数都是处理dom相关的。经过@vue/compiler-domcompile函数处理后,再调用baseCompile函数就有了处理dom相关的转换函数了。

@vue/compiler-core包的baseCompile函数

继续将断点走进vue/compiler-core包的baseCompile函数,简化后的baseCompile函数代码如下:

js
function baseCompile(
  source: string | RootNode,
  options: CompilerOptions = {},
): CodegenResult {
  const ast = isString(source) ? baseParse(source, options) : source

  const [nodeTransforms, directiveTransforms] = getBaseTransformPreset()

  transform(
    ast,
    Object.assign({}, options, {
      nodeTransforms: [
        ...nodeTransforms,
        ...(options.nodeTransforms || []), // user transforms
      ],
      directiveTransforms: Object.assign(
        {},
        directiveTransforms,
        options.directiveTransforms || {}, // user transforms
      ),
    }),
  )

  return generate(ast, options)
}

我们先来看看baseCompile函数接收的参数,第一个参数为source,类型为string | RootNode。这句话的意思是接收的source变量可能是html字符串,也有可能是html字符串编译后的AST抽象语法树。再来看看第二个参数options,我们这里只关注options.nodeTransforms数组属性和options.directiveTransforms对象属性,这两个里面都是存了一堆转换函数,区别就是一个是数组,一个是对象。

我们再来看看返回值类型CodegenResult,定义如下:

ts
interface CodegenResult {
  code: string
  preamble: string
  ast: RootNode
  map?: RawSourceMap
}

从类型中我们可以看到返回值对象中的code属性就是编译好的render函数,而这个返回值就是最后调用generate函数返回的。

明白了baseCompile函数接收的参数和返回值,我们再来看函数内的代码。主要分为四块内容:

  • 拿到由html字符串转换成的AST抽象语法树。

  • 拿到由一堆转换函数组成的nodeTransforms数组,和拿到由一堆转换函数组成的directiveTransforms对象。

  • 执行transform函数,使用合并后的nodeTransforms中的所有转换函数处理AST抽象语法树中的所有node节点,使用合并后的directiveTransforms中的转换函数对会生成props的指令进行处理,得到处理后的javascript AST抽象语法树

  • 调用generate函数根据上一步处理后的javascript AST抽象语法树进行字符串拼接,拼成render函数。

获取AST抽象语法树

我们先来看第一块的内容,代码如下:

js
const ast = isString(source) ? baseParse(source, options) : source

如果传入的source是html字符串,那就调用baseParse函数根据html字符串生成对应的AST抽象语法树,如果传入的就是AST抽象语法树那么就直接赋值给ast变量。为什么这里有这两种情况呢?

原因是baseCompile函数可以被直接调用,也可以像我们这样由vite的@vitejs/plugin-vue包发起,经过层层调用后最终执行baseCompile函数。在我们这个场景中,在前面我们就知道了走进compileTemplate函数之前就已经有了编译后的AST抽象语法树,所以这里不会再调用baseParse函数去生成AST抽象语法树了。那么又是什么时候生成的AST抽象语法树呢?

在之前的 vue文件编译成js文件 文章中我们讲了调用createDescriptor函数会将vue代码字符串转换为descriptor对象,descriptor对象中拥有template属性、scriptSetup属性、styles属性,分别对应vue文件中的template模块、<script setup>模块、<style>模块。如下图: progress-createDescriptorcreateDescriptor函数在生成template属性的时候底层同样也会调用@vue/compiler-core包的baseParse函数,将template模块中的html字符串编译为AST抽象语法树。

所以在我们这个场景中走到baseCompile函数时就已经有了AST抽象语法树了,其实底层都调用的是@vue/compiler-core包的baseParse函数。

获取转换函数

接着将断点走到第二块内容处,代码如下:

js
const [nodeTransforms, directiveTransforms] = getBaseTransformPreset()

从上面的代码可以看到getBaseTransformPreset函数的返回值是一个数组,对返回的数组进行解构,数组的第一项赋值给nodeTransforms变量,数组的第二项赋值给directiveTransforms变量。

将断点走进getBaseTransformPreset函数,代码如下:

js
function getBaseTransformPreset() {
  return [
    [
      transformOnce,
      transformIf,
      transformMemo,
      transformFor,
      transformFilter,
      trackVForSlotScopes,
      transformExpression
      transformSlotOutlet,
      transformElement,
      trackSlotScopes,
      transformText
    ],
    {
      on: transformOn,
      bind: transformBind,
      model: transformModel
    }
  ];
}

从上面的代码中不难看出由getBaseTransformPreset函数的返回值解构出来的nodeTransforms变量是一个数组,数组中包含一堆transform转换函数,比如处理v-oncev-ifv-memov-for等指令的转换函数。很明显我们这个demo中input标签上面的v-for指令就是由这里的transformFor转换函数处理。

同理由getBaseTransformPreset函数的返回值解构出来的directiveTransforms变量是一个对象,对象中包含处理v-onv-bindv-model指令的转换函数。

经过这一步的处理我们就拿到了由一系列转换函数组成的nodeTransforms数组,和由一系列转换函数组成的directiveTransforms对象。看到这里我想你可能有一些疑问,为什么nodeTransforms是数组,directiveTransforms却是对象呢?为什么有的指令转换转换函数是在nodeTransforms数组中,有的却是在directiveTransforms对象中呢?别着急,我们下面会讲。

transform函数

接着将断点走到第三块内容,transform函数处,代码如下:

js
transform(
  ast,
  Object.assign({}, options, {
    nodeTransforms: [
      ...nodeTransforms,
      ...(options.nodeTransforms || []), // user transforms
    ],
    directiveTransforms: Object.assign(
      {},
      directiveTransforms,
      options.directiveTransforms || {}, // user transforms
    ),
  }),
)

调用transform函数时传入了两个参数,第一个参数为当前的AST抽象语法树,第二个参数为传入的options,在options中我们主要看两个属性:nodeTransforms数组和directiveTransforms对象。

nodeTransforms数组由两部分组成,分别是上一步拿到的nodeTransforms数组,和之前在options.nodeTransforms数组中塞进去的转换函数。

directiveTransforms对象就不一样了,如果上一步拿到的directiveTransforms对象和options.directiveTransforms对象拥有相同的key,那么后者就会覆盖前者。

以我们这个例子举例:在上一步中拿到的directiveTransforms对象中有key为model的处理v-model指令的转换函数,但是我们在@vue/compiler-dom包中的compile函数同样也给options.directiveTransforms对象中塞了一个key为model的处理v-model指令的转换函数。

那么@vue/compiler-dom包中的v-model转换函数就会覆盖上一步中定义的v-model转换函数,那么@vue/compiler-core包中v-model转换函数是不是就没用了呢?答案是当然有用,在@vue/compiler-dom包中的v-model转换函数会手动调用@vue/compiler-core包中v-model转换函数。

这样设计的目的是对于一些指令的处理支持不同的平台传入不同的转换函数,并且在这些平台中也可以手动调用@vue/compiler-core包中提供的指令转换函数,根据手动调用的结果再针对各自平台进行一些特别的处理。

我们先来回忆一下前面demo中的代码:

vue
<template>
  <input v-for="item in msgList" :key="item.id" v-model="item.value" />
</template>

<script setup lang="ts">
import { ref } from "vue";

const msgList = ref([
  {
    id: 1,
    value: "",
  },
  {
    id: 2,
    value: "",
  },
  {
    id: 3,
    value: "",
  },
]);
</script>

接着在debug终端中看看执行transform函数前的AST抽象语法树是什么样的,如下图: AST

从上图中可以看到AST抽象语法树根节点下面只有一个children节点,这个children节点对应的就是input标签。在input标签上面有三个props,分别对应的是input标签上面的v-for指令、:key属性、v-model指令。说明在生成AST抽象语法树的阶段不会对指令进行处理,而是当做普通的属性一样使用正则匹配出来,然后塞到props数组中。

既然在生成AST抽象语法树的过程中没有对v-modelv-for等指令进行处理,那么又是在什么时候处理的呢?答案是在执行transform函数的时候处理的,在transform函数中会递归遍历整个AST抽象语法树,在遍历每个node节点时都会将nodeTransforms数组中的所有转换函数按照顺序取出来执行一遍,在执行时将当前的node节点和上下文作为参数传入。经过nodeTransforms数组中全部的转换函数处理后,vue提供的许多内置指令、语法糖、内置组件等也就被处理了,接下来只需要执行generate函数生成render函数就行了。

nodeTransforms数组

nodeTransforms 主要是对 node节点 进行操作,可能会替换或者移动节点。每个node节点都会将nodeTransforms数组中的转换函数按照顺序全部执行一遍,比如处理v-if指令的transformIf转换函数就要比处理v-for指令的transformFor函数先执行。所以nodeTransforms是一个数组,而且数组中的转换函数的顺序还是有讲究的。

在我们这个demo中input标签上面的v-for指令是由nodeTransforms数组中的transformFor转换函数处理的,很简单就可以找到transformFor转换函数。在函数开始的地方打一个断点,代码就会走到这个断点中,在debug终端上面看看此时的node节点是什么样的,如下图: before-transformFor

从上图中可以看到在执行transformFor转换函数之前的node节点和上一张图打印的node节点是一样的。

我们在执行完transformFor转换函数的地方打一个断点,看看执行完transformFor转换函数后node节点变成什么样了,如下图: after-transformFor

从上图我们可以看到经过transformFor转换函数处理后当前的node节点已经变成了一个新的node节点,而原来的input的node节点变成了这个节点的children子节点。

新节点的source.content里存的是v-for="item in msgList"中的msgList变量。新节点的valueAlias.content里存的是v-for="item in msgList"中的item。我们发现input子节点的props数组现在只有两项了,原本的v-for指令的props经过transformFor转换函数的处理后已经被消费掉了,所以就只有两项了。

看到这里你可能会有疑问,为什么执行transform函数后会将AST抽象语法树的结构都改变了呢?

这样做的目的是在后续的generate函数中递归遍历AST抽象语法树时,只想进行字符串拼接就可以拼成render函数。这里涉及到模版AST抽象语法树Javascript AST抽象语法树的概念。

我们来回忆一下template模块中的代码:

vue
<template>
<input v-for="item in msgList" :key="item.id" v-model="item.value" />
</template>

template模版经过parse函数拿到AST抽象语法树,此时的AST抽象语法树的结构和template模版的结构是一模一样的,所以我们称之为模版AST抽象语法树模版AST抽象语法树其实就是描述template模版的结构。如下图: template-AST

我们再来看看生成的render函数的代码:

js
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return _openBlock(true), _createElementBlock(
    _Fragment,
    null,
    _renderList($setup.msgList, (item) => {
      return _withDirectives((_openBlock(), _createElementBlock("input", {
        key: item.id,
        "onUpdate:modelValue": ($event) => item.value = $event
      }, null, 8, _hoisted_1)), [
        [_vModelText, item.value]
      ]);
    }),
    128
    /* KEYED_FRAGMENT */
  );
}

很明显模版AST抽象语法树无法通过简单的字符串拼接就可以拼成上面的render函数,所以我们需要一个结构和上面的render函数一模一样的Javascript AST抽象语法树Javascript AST抽象语法树的作用就是描述render函数的结构。如下图: javascript-AST

上面这个Javascript AST抽象语法树就是执行transform函数时根据模版AST抽象语法树生成的。有了Javascript AST抽象语法树后再来执行generate函数时就可以只进行简单的字符串拼接,就能得到render函数了。

directiveTransforms对象

directiveTransforms对象的作用是对指令进行转换,给node节点生成对应的props。比如给子组件上面使用了v-model指令,经过directiveTransforms对象中的transformModel转换函数处理后,v-mode节点上面就会多两个props属性:modelValueonUpdate:modelValue属性。directiveTransforms对象中的转换函数不会每次都全部执行,而是要node节点中有对应的指令,才会执行指令的转换函数。所以directiveTransforms是对象,而不是数组。

那为什么有的指令转换函数在directiveTransforms对象中,有的又在nodeTransforms数组中呢?

答案是在directiveTransforms对象中的指令全部都是会给node节点生成props属性的,那些不生成props属性的就在nodeTransforms数组中。

很容易就可以找到@vue/compiler-dom包的transformModel函数,然后打一个断点,让断点走进transformModel函数中,如下图: transformModel

从上面的图中我们可以看到在@vue/compiler-dom包的transformModel函数中会调用@vue/compiler-core包的transformModel函数,拿到返回的baseResult对象后再一些其他操作后直接return baseResult

从左边的call stack调用栈中我们可以看到transformModel函数是由一个buildProps函数调用的,看名字你应该猜到了buildProps函数的作用是生成props属性的。点击Step Out将断点跳出transformModel函数,走进buildProps函数中,可以看到buildProps函数中调用transformModel函数的代码如下图: buildProps

从上图中可以看到,name变量的值为modelcontext.directiveTransforms[name]的返回值就是transformModel函数,所以执行directiveTransform(prop, node, context)其实就是在执行transformModel函数。

在debug终端中可以看到返回的props2是一个数组,里面存的是v-model指令被处理后生成的props属性。props属性数组中只有一项是onUpdate:modelValue属性,看到这里有的小伙伴会疑惑了v-model指令不是会生成modelValueonUpdate:modelValue两个属性,为什么这里只有一个呢?

答案是只有给自定义组件上面使用v-model指令才会生成modelValueonUpdate:modelValue两个属性,对于这种原生input标签是不需要生成modelValue属性的,因为input标签本身是不接收名为modelValue属性,接收的是value属性。

其实transform函数中的内容是非常复杂的,里面包含了vue提供的指令、filter、slot等功能的处理逻辑。transform函数的设计高明之处就在于插件化,将处理这些功能的transform转换函数以插件的形式插入的,这样逻辑就会非常清晰了。

比如我想看v-model指令是如何实现的,我只需要去看对应的transformModel转换函数就行了。又比如哪天vue需要实现一个v-xxx指令,要实现这个指令只需要增加一个transformXxx的转换函数就行了。

generate函数

经过上一步transform函数的处理后,已经将描述模版结构的模版AST抽象语法树转换为了描述render函数结构的Javascript AST抽象语法树。在前面我们已经讲过了Javascript AST抽象语法树就是描述了最终生成render函数的样子。所以在generate函数中只需要递归遍历Javascript AST抽象语法树,通过字符串拼接的方式就可以生成render函数了。

将断点走到执行generate函数前,看看这会儿的Javascript AST抽象语法树是什么样的,如下图: before-generate

从上面的图中可以看到Javascript AST模版AST的区别主要有两个:

  • node节点中多了一个codegenNode属性,这个属性中存了许多node节点信息,比如codegenNode.props中就存了keyonUpdate:modelValue属性的信息。在generate函数中遍历每个node节点时就会读取这个codegenNode属性生成render函数

  • 模版AST中根节点下面的children节点就是input标签,但是在这里Javascript AST中却是根节点下面的children节点,再下面的children节点才是input标签。多了一层节点,在前面的transform函数中我们已经讲了多的这层节点是由v-for指令生成的,用于给v-for循环出来的多个节点当父节点。

将断点走到generate函数执行之后,可以看到已经生成render函数啦,如下图: after-generate

总结

现在我们再来看看最开始讲的流程图,我想你应该已经能将整个流程串起来了。如下图: full-progress

将template编译为render函数可以分为7步:

  • 执行@vue/compiler-sfc包的compileTemplate函数,里面会调用同一个包的doCompileTemplate函数。这一步存在的目的是作为一个入口函数给外部调用。

  • 执行@vue/compiler-sfc包的doCompileTemplate函数,里面会调用@vue/compiler-dom包中的compile函数。这一步存在的目的是入口函数的具体实现。

  • 执行@vue/compiler-dom包中的compile函数,里面会对options进行了扩展,塞了一些处理dom的转换函数进去。给options.nodeTransforms数组中塞了处理style的转换函数,和给options.directiveTransforms对象中塞了处理v-cloakv-htmlv-textv-modelv-onv-show等指令的转换函数。然后以扩展后的options去调用@vue/compiler-core包的baseCompile函数。

  • 执行@vue/compiler-core包的baseCompile函数,在这个函数中主要分为4部分。第一部分为检查传入的source是不是html字符串,如果是就调用同一个包下的baseParse函数生成模版AST抽象语法树

    否则就直接使用传入的模版AST抽象语法树。此时node节点中还有v-forv-model等指令,并没有被处理掉。这里的模版AST抽象语法树的结构和template中的结构一模一样,模版AST抽象语法树是对template中的结构进行描述。

  • 第二部分为执行getBaseTransformPreset函数拿到@vue/compiler-core包中内置的nodeTransformsdirectiveTransforms转换函数。

    nodeTransforms数组中的为一堆处理node节点的转换函数,比如处理v-once指令的transformOnce转换函数、处理v-if指令的transformIf转换函数。

    directiveTransforms对象中存的是对一些“会生成props的指令”进行转换的函数,用于给node节点生成对应的props。比如处理v-model指令的transformModel转换函数。

  • 第三部分为将传入的options.nodeTransformsoptions.directiveTransforms分别和本地的nodeTransformsdirectiveTransforms进行合并得到一堆新的转换函数。其中由于nodeTransforms是数组,所以在合并的过程中会将options.nodeTransformsnodeTransforms中的转换函数全部合并进去。

    由于directiveTransforms是对象,如果directiveTransforms对象和options.directiveTransforms对象拥有相同的key,那么后者就会覆盖前者。然后将合并的结果和模版AST抽象语法树一起传入到transform函数中执行,就可以得到转换后的javascript AST抽象语法树

    在这一过程中v-forv-model等指令已经被转换函数给处理了。得到的javascript AST抽象语法树的结构和render函数的结构一模一样,javascript AST抽象语法树就是对render函数的结构进行描述。

  • 第四部分为由于已经拿到了和render函数的结构一模一样的javascript AST抽象语法树,只需要在generate函数中遍历javascript AST抽象语法树进行字符串拼接就可以得到render函数了。

加入本书对应的「源码交流群」