先来看一下我画的transform
函数执行流程图,让你对整个流程有个大概的印象,后面的内容看着就不费劲了。如下图:
从上面的流程图可以看到transform
函数的执行过程主要分为下面这几步:
在transform
函数中调用createTransformContext
函数生成上下文对象。在上下文对象中存储了当前正在转换的node节点的信息,后面的traverseNode
、traverseChildren
、nodeTransforms
数组中的转换函数、directiveTransforms
对象中的转换函数都会依赖这个上下文对象。
然后执行traverseNode
函数,traverseNode
函数是一个典型的洋葱模型。第一次执行traverseNode
函数的时候会进入洋葱模型的第一层,先将nodeTransforms
数组中的转换函数全部执行一遍,对第一层的node节点进行第一次转换,将转换函数返回的回调函数存到第一层的exitFns
数组中。经过第一次转换后v-for等指令已经被初次处理了。
然后执行traverseChildren
函数,在traverseChildren
函数中对当前node节点的子节点执行traverseNode
函数。此时就会进入洋葱模型的第二层,和上一步一样会将nodeTransforms
数组中的转换函数全部执行一遍,对第二层的node节点进行第一次转换,将转换函数返回的回调函数存到第二层的exitFns
数组中。
假如第二层的node节点已经没有了子节点,洋葱模型就会从"进入阶段"变成"出去阶段"。将第二层的exitFns
数组中存的回调函数全部执行一遍,对node节点进行第二次转换,然后出去到第一层的洋葱模型。经过第二次转换后v-for等指令已经被完全处理了。
同样将第一层中的exitFns
数组中存的回调函数全部执行一遍,由于此时第二层的node节点已经全部处理完了,所以在exitFns
数组中存的回调函数中就可以根据子节点的情况来处理父节点。
执行nodeTransforms
数组中的transformElement
转换函数,会返回一个回调函数。在回调函数中会调用buildProps
函数,在buildProps
函数中只有当node节点中有对应的指令才会执行directiveTransforms
对象中对应的转换函数。比如当前node节点有v-model指令,才会去执行transformModel
转换函数。v-model等指令也就被处理了。
举个例子
还是同样的套路,我们通过debug一个简单的demo来带你搞清楚transform
函数内具体是如何处理vue内置的v-for、v-model等指令。demo代码如下:
xml
<template>
<div>
<input v-for="item in msgList" :key="item.id" v-model="item.value" />
<p>标题是:{{ title }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
const msgList = ref([
{
id: 1,
value: "",
},
{
id: 2,
value: "",
},
{
id: 3,
value: "",
},
]);
const title = ref("hello word");
</script>
在上面的代码中,我们给input标签使用了v-for和v-model指令,还渲染了一个p标签。p标签中的内容由foo
变量、bar
字符串、baz
变量拼接而来的。
比如我们这个demo,编译成模版AST抽象语法树 后。input标签对应的node节点中就增加了三个props属性,name分别为for、bind、model,分别对应的是v-for、v-bind、v-model。真正处理这些vue内置指令是在transform
函数中。
transform
函数
本文中使用的vue版本为3.4.19 ,transform
函数在node_modules/@vue/compiler-core/dist/compiler-core.cjs.js 文件中。找到transform
函数的代码,打上断点。
从上一篇文章我们知道了transform
函数是在node端执行的,所以我们需要启动一个debug
终端,才可以在node端打断点。这里以vscode举例,首先我们需要打开终端,然后点击终端中的+
号旁边的下拉箭头,在下拉中点击Javascript Debug Terminal
就可以启动一个debug
终端。
接着在debug
终端中执行yarn dev
(这里是以vite
举例)。在浏览器中访问 http://localhost:5173/,此时断点就会走到transform
函数中了。我们在debug终端中来看看调用transform
函数时传入的root
变量,如下图:
从上图中我们可以看到transform
函数接收的第一个参数root
变量是一个模版AST抽象语法树 ,为什么说他是模版AST抽象语法树 呢?因为这棵树的结构和template模块中的结构一模一样,root
变量也就是模版AST抽象语法树是对template模块进行描述。
根节点的children下面只有一个div子节点,对应的就是最外层的div标签。div节点children下面有两个子节点,分别对应的是input标签和p标签。input标签中有三个props,分别对应input标签上面的v-for指令、key属性、v-model指令。从这里我们可以看出来此时vue内置的指令还没被处理,在执行parse函数生成模版AST抽象语法树阶段只是将其当做普通的属性处理后,再塞到props属性中。
p标签中的内容由两部分组成:<p>标题是:{{ title }}</p>
。此时我们发现p标签的children也是有两个,分别是写死的文本和title
变量。
我们接着来看transform
函数,在我们这个场景中简化后的代码如下:
scss
function transform(root, options) {
const context = createTransformContext(root, options);
traverseNode(root, context);
}
从上面的代码中可以看到transform
函数内主要有两部分,从名字我想你应该就能猜出他们的作用。传入模版AST抽象语法树 和options
,调用createTransformContext
函数生成context
上下文对象。传入模版AST抽象语法树 和context
上下文对象,调用traverseNode
函数对树中的node节点进行转换。
createTransformContext
函数
在讲createTransformContext
函数之前我们先来了解一下什么是context(上下文)。
什么是上下文
上下文其实就是在某个范围内的"全局变量",在这个范围内的任意地方都可以拿到这个"全局变量"。举两个例子:
在vue中可以通过provied向整颗组件树提供数据,然后在树的任意节点可以通过inject拿到提供的数据。比如:
根组件App.vue,注入上下文。
scss
const count = ref(0)
provide('count', count)
业务组件list.vue,读取上下文。
ini
const count = inject('count')
在react中,我们可以使用React.createContext
函数创建一个上下文对象,然后注入到组件树中。
javascript
const ThemeContext = React.createContext('light');
function App() {
const [theme, setTheme] = useState('light');
// ...
return (
<ThemeContext.Provider value={theme}>
<Page />
</ThemeContext.Provider>
);
}
在这颗组件树的任意层级中都能拿到上下文对象中提供的数据:
ini
const theme = useContext(ThemeContext);
树中的节点一般可以通过children拿到子节点,但是父节点一般不容易通过子节点拿到。在转换的过程中我们有的时候需要拿到父节点进行一些操作,比如将当前节点替换为一个新的节点,又或者直接删掉当前节点。
所以在这里会维护一个context上下文对象,对象中会维护一些状态和方法。比如当前正在转换的节点是哪个,当前转换的节点的父节点是哪个,当前节点在父节点中是第几个子节点,还有replaceNode
、removeNode
等方法。
上下文中的一些属性和方法
我们将断点走进createTransformContext
函数中,简化后的代码如下:
javascript
function createTransformContext(
root,
{
nodeTransforms = [],
directiveTransforms = {},
// ...省略
}
) {
const context = {
// 所有的node节点都会将nodeTransforms数组中的所有的转换函数全部执行一遍
nodeTransforms,
// 只执行node节点的指令在directiveTransforms对象中对应的转换函数
directiveTransforms,
// 需要转换的AST抽象语法树
root,
// 转换过程中组件内注册的组件
components: new Set(),
// 转换过程中组件内注册的指令
directives: new Set(),
// 当前正在转换节点的父节点,默认转换的是根节点。根节点没有父节点,所以为null。
parent: null,
// 当前正在转换的节点,默认为根节点
currentNode: root,
// 当前转换节点在父节点中的index位置
childIndex: 0,
replaceNode(node) {
// 将当前节点替换为新节点
},
removeNode(node) {
// 删除当前节点
},
// ...省略
};
return context;
}
从上面的代码中可以看到createTransformContext
中的代码其实很简单,第一个参数为需要转换的模版AST抽象语法树 ,第二个参数对传入的options
进行解构,拿到options.nodeTransforms
数组和options.directiveTransforms
对象。
nodeTransforms
数组中存了一堆转换函数,在树的递归遍历过程中会将nodeTransforms
数组中的转换函数全部执行一遍。directiveTransforms
对象中也存了一堆转换函数,和nodeTransforms
数组的区别是,只会执行node节点的指令在directiveTransforms
对象中对应的转换函数。比如node节点中只有v-model指令,那就只会执行directiveTransforms
对象中的transformModel
转换函数。这里将拿到的nodeTransforms
数组和directiveTransforms
对象都存到了context
上下文中。
在context
上下文中存了一些状态属性:
-
root:需要转换的AST抽象语法树。
-
components:转换过程中组件内注册的组件。
-
directives:转换过程中组件内注册的指令。
-
parent:当前正在转换节点的父节点,默认转换的是根节点。根节点没有父节点,所以为null。
-
currentNode:当前正在转换的节点,默认为根节点。
-
childIndex:当前转换节点在父节点中的index位置。
在context
上下文中存了一些方法:
-
replaceNode:将当前节点替换为新节点。
-
removeNode:删除当前节点。
traverseNode
函数
接着将断点走进traverseNode
函数中,在我们这个场景中简化后的代码如下:
TypeScript
function traverseNode(node, context) {
context.currentNode = node;
const { nodeTransforms } = context;
const exitFns = [];
for (let i = 0; i < nodeTransforms.length; i++) {
const onExit = nodeTransforms[i](node, context);
if (onExit) {
if (isArray(onExit)) {
exitFns.push(...onExit);
} else {
exitFns.push(onExit);
}
}
if (!context.currentNode) {
return;
} else {
node = context.currentNode;
}
}
traverseChildren(node, context);
context.currentNode = node;
let i = exitFns.length;
while (i--) {
exitFns[i]();
}
}
从上面的代码中我们可以看到traverseNode
函数接收两个参数,第一个参数为当前需要处理的node节点,第一次调用时传的就是树的根节点。第二个参数是上下文对象。
我们再来看traverseNode
函数的内容,内容主要分为三部分。分别是:
-
将
nodeTransforms
数组内的转换函数全部执行一遍,如果转换函数的执行结果是一个回调函数,那么就将回调函数push到exitFns
数组中。 -
调用
traverseChildren
函数处理子节点。 -
将
exitFns
数组中存的回调函数依次从末尾取出来挨个执行。
traverseChildren
函数
我们先来看看第二部分的traverseChildren
函数,代码很简单,简化后的代码如下:
TypeScript
function traverseChildren(parent, context) {
let i = 0;
for (; i < parent.children.length; i++) {
const child = parent.children[i];
context.parent = parent;
context.childIndex = i;
traverseNode(child, context);
}
}
在traverseChildren
函数中会去遍历当前节点的子节点,在遍历过程中会将context.parent
更新为当前的节点,并且将context.childIndex
也更新为当前子节点所在的位置。然后再调用traverseNode
函数处理当前的子节点。
所以在traverseNode
函数执行的过程中,context.parent
总是指向当前节点的父节点,context.childIndex
总是指向当前节点在父节点中的index位置。如下图:
进入时执行的转换函数
我们现在回过头来看第一部分的代码,代码如下:
TypeScript
function traverseNode(node, context) {
context.currentNode = node;
const { nodeTransforms } = context;
const exitFns = [];
for (let i = 0; i < nodeTransforms.length; i++) {
const onExit = nodeTransforms[i](node, context);
if (onExit) {
if (isArray(onExit)) {
exitFns.push(...onExit);
} else {
exitFns.push(onExit);
}
}
if (!context.currentNode) {
return;
} else {
node = context.currentNode;
}
}
// ...省略
}
首先会将context.currentNode
更新为当前节点,然后从context上下文中拿到由转换函数组成的nodeTransforms
数组。
我们已经讲过了nodeTransforms
数组中主要存了下面这些转换函数,代码如下:
ini
const nodeTransforms = [
transformOnce,
transformIf,
transformMemo,
transformFor,
transformFilter,
trackVForSlotScopes,
transformExpression
transformSlotOutlet,
transformElement,
trackSlotScopes,
transformText
]
很明显我们这里的v-for指令就会被nodeTransforms
数组中的transformFor
转换函数处理。
看到这里有的小伙伴就会问了,怎么没有在nodeTransforms
数组中看到处理v-model
指令的转换函数呢?处理v-model
指令的转换函数是在directiveTransforms
对象中。在directiveTransforms
对象中主要存了下面这些转换函数:
yaml
const directiveTransforms = {
bind: transformBind,
cloak: compilerCore.noopDirectiveTransform,
html: transformVHtml,
text: transformVText,
model: transformModel,
on: transformOn,
show: transformShow
}
nodeTransforms
和directiveTransforms
的区别是,在递归遍历转换node节点时,每次都会将nodeTransforms
数组中的所有转换函数都全部执行一遍。比如当前转换的node节点中没有使用v-if指令,但是在转换当前node节点时还是会执行nodeTransforms
数组中的transformIf
转换函数。
而directiveTransforms
是在递归遍历转换node节点时,只会执行node节点中存在的指令对应的转换函数。比如当前转换的node节点中有使用v-model指令,所以就会执行directiveTransforms
对象中的transformModel
转换函数。由于node节点中没有使用v-html指令,所以就不会执行directiveTransforms
对象中的transformVHtml
转换函数。
我们前面讲过了context上下文中存了很多属性和方法。包括当前节点的父节点是谁,当前节点在父节点中的index位置,替换当前节点的方法,删除当前节点的方法。这样在转换函数中就可以通过context上下文对当前节点进行各种操作了。
将转换函数的返回值赋值给onExit
变量,如果onExit
不为空,说明转换函数的返回值是一个回调函数或者由回调函数组成的数组。将这些回调函数push进exitFns
数组中,在退出时会将这些回调函数倒序全部执行一遍。
执行完回调函数后会判断上下文中的currentNode
是否为空,如果为空那么就return掉整个traverseNode
函数,后面的traverseChildren
等函数都不会执行了。如果context.currentNode
不为空,那么就将本地的node
变量更新成context上下文中的currentNode
。
为什么需要判断context上下文中的currentNode
呢?原因是经过转换函数的处理后当前节点可能会被删除了,也有可能会被替换成一个新的节点,所以在每次执行完转换函数后都会更新本地的node变量,保证在下一个的转换函数执行时传入的是最新的node节点。
退出时执行的转换函数回调
我们接着来看traverseNode
函数中最后一部分,代码如下:
TypeScript
function traverseNode(node, context) {
// ...省略
context.currentNode = node;
let i = exitFns.length;
while (i--) {
exitFns[i]();
}
}
由于这段代码是在执行完traverseChildren
函数再执行的,前面已经讲过了在traverseChildren
函数中会将当前节点的子节点全部都处理了,所以当代码执行到这里时所有的子节点都已经处理完了。所以在转换函数返回的回调函数中我们可以根据当前节点转换后的子节点情况来决定如何处理当前节点。
在处理子节点的时候我们会将context.currentNode
更新为子节点,所以在处理完子节点后需要将context.currentNode
更新为当前节点。这样在执行转换函数返回的回调函数时,context.currentNode
始终就是指向的是当前的node节点。
请注意这里是倒序取出exitFns
数组中存的回调函数,在进入时会按照顺序去执行nodeTransforms
数组中的转换函数。在退出时会倒序去执行存下来的回调函数,比如在nodeTransforms
数组中transformIf
函数排在transformFor
函数前面。transformIf
用于处理v-if指令,transformFor
用于处理v-for指令。在进入时transformIf
函数会比transformFor
函数先执行,所以在组件上面同时使用v-if和v-for指令,会是v-if指令先生效。在退出阶段时transformIf
函数会比transformFor
函数后执行,所以在transformIf
回调函数中可以根据transformFor
回调函数的执行结果来决定如何处理当前的node节点。
traverseNode
函数其实就是典型的洋葱模型 ,依次从父组件到子组件挨着调用nodeTransforms
数组中所有的转换函数,然后从子组件到父组件倒序执行nodeTransforms
数组中所有的转换函数返回的回调函数。traverseNode
函数内的设计很高明,如果你还没反应过来,别着急我接下来会讲他高明在哪里。
洋葱模型traverseNode
函数
我们先来看看什么是洋葱模型,如下图:
洋葱模型就是:从外面一层层的进去,再一层层的从里面出来。
第一次进入traverseNode
函数的时候会进入洋葱模型的第1层,先依次将nodeTransforms
数组中所有的转换函数全部执行一遍,对当前的node节点进行第一次转换。如果转换函数的返回值是回调函数或者回调函数组成的数组,那就将这些回调函数依次push到第1层定义的exitFns
数组中。
然后再去处理当前节点的子节点,处理子节点的traverseChildren
函数其实也是在调用traverseNode
函数,此时已经进入了洋葱模型的第2层。同理在第2层也会将nodeTransforms
数组中所有的转换函数全部执行一遍,对第2层的node节点进行第一次转换,并且将返回的回调函数依次push到第2层定义的exitFns
数组中。
同样的如果第2层节点也有子节点,那么就会进入洋葱模型的第3层。在第3层也会将nodeTransforms
数组中所有的转换函数全部执行一遍,对第3层的node节点进行第一次转换,并且将返回的回调函数依次push到第3层定义的exitFns
数组中。
请注意此时的第3层已经没有子节点了,那么现在就要从一层层的进去,变成一层层的出去。首先会将第3层exitFns
数组中存的回调函数依次从末尾开始全部执行一遍,会对第3层的node节点进行第二次转换,此时第3层中的node节点已经被全部转换完了。
由于第3层的node节点已经被全部转换完了,所以会出去到洋葱模型的第2层。同样将第2层exitFns
数组中存的回调函数依次从末尾开始全部执行一遍,会对第2层的node节点进行第二次转换。值得一提的是由于第3层的node节点也就是第2层的children节点已经被完全转换了,所以在执行第2层转换函数返回的回调函数时就可以根据子节点的情况来处理父节点。
同理将第2层的node节点全部转换完了后,会出去到洋葱模型的第1层。将第1层exitFns
数组中存的回调函数依次从末尾开始全部执行一遍,会对第1层的node节点进行第二次转换。
当出去阶段的第1层全部处理完后了,transform
函数内处理内置的v-for等指令也就处理完了。执行完transform
函数后,描述template解构的模版AST抽象语法树 也被处理成了描述render函数结构的javascript AST抽象语法树 。后续只需要执行generate
函数,进行普通的字符串拼接就可以得到render函数。
继续debug
搞清楚了traverseNode
函数,接着来debug看看demo中的v-for指令和v-model指令是如何被处理的。
-
v-for指令对应的是
transformFor
转换函数。 -
v-model指令对应的是
transformModel
转换函数。
transformFor
转换函数
通过前面我们知道了用于处理v-for
指令的transformFor
转换函数是在nodeTransforms
数组中,每次处理node节点都会执行。我们给transformFor
转换函数打3个断点,分别是:
-
进入
transformFor
转换函数之前。 -
调用
transformFor
转换函数,第1次对node节点进行转换之后。 -
调用
transformFor
转换函数返回的回调函数,第2次对node节点进行转换之后。
我们将代码走到第1个断点,看看执行transformFor
转换函数之前input标签的node节点是什么样的,如下图:
从上图中可以看到input标签的node节点中还是有一个v-for的props属性,说明此时v-for指令还没被处理。
我们接着将代码走到第2个断点,看看调用transformFor
转换函数第1次对node节点进行转换之后是什么样的,如下图:
从上图中可以看到原本的input的node节点已经被替换成了一个新的node节点,新的node节点的children才是原来的node节点。并且input节点props属性中的v-for指令也被消费了。新节点的source.content
里存的是v-for="item in msgList"
中的msgList
变量。新节点的valueAlias.content
里存的是v-for="item in msgList"
中的item
。请注意此时arguments
数组中只有一个字段,存的是msgList
变量。
我们接着将代码走到第3个断点,看看调用transformFor
转换函数返回的回调函数,第2次对node节点进行转换之后是什么样的,如下图:
从上图可以看到arguments
数组中多了一个字段,input标签现在是当前节点的子节点。按照我们前面讲的洋葱模型,input子节点现在已经被转换完成了。所以多的这个字段就是input标签经过transform
函数转换后的node节点,将转换后的input子节点存到父节点上面,后面生成render函数时会用。
transformModel
转换函数
通过前面我们知道了用于处理v-model
指令的transformModel
转换函数是在directiveTransforms
对象中,只有当node节点中有对应的指令才会执行对应的转换函数。我们这里input上面有v-model指令,所以就会执行transformModel
转换函数。
我们已经讲过了处理v-model
指令是调用的@vue/compiler-dom
包的transformModel
函数,很容易就可以找到@vue/compiler-dom
包的transformModel
函数,然后打一个断点,让断点走进transformModel
函数中,如下图:
从上面的图中我们可以看到在@vue/compiler-dom
包的transformModel
函数中会调用@vue/compiler-core
包的transformModel
函数,拿到返回的baseResult
对象后再一些其他操作后直接return baseResult
。
从左边的call stack调用栈中我们可以看到transformModel
函数是由一个buildProps
函数调用的,buildProps
函数是由postTransformElement
函数调用的。而postTransformElement
函数则是transformElement
转换函数返回的回调函数,transformElement
转换函数是在nodeTransforms
数组中。
所以directiveTransforms
对象中的转换函数调用其实是由nodeTransforms
数组中的transformElement
转换函数调用的。如下图:
看名字你应该猜到了buildProps
函数的作用是生成props属性的。点击Step Out将断点跳出transformModel
函数,走进buildProps
函数中,可以看到buildProps
函数中调用transformModel
函数的代码如下图:
从上图中可以看到执行directiveTransforms
对象中的转换函数不仅可以对节点进行转换,还会返回一个props数组。比如我们这里处理的是v-model指令,返回的props数组就是由v-model指令编译而来的props属性,这就是所谓的v-model语法糖。
看到这里有的小伙伴会疑惑了v-model
指令不是会生成modelValue
和onUpdate:modelValue
两个属性,为什么这里只有一个onUpdate:modelValue
属性呢?
答案是只有给自定义组件上面使用v-model
指令才会生成modelValue
和onUpdate:modelValue
两个属性,对于这种原生input标签是不需要生成modelValue
属性的,而且input标签本身是不接收名为modelValue
属性,接收的是value属性。
总结
现在我们再来看看最开始讲的流程图,我想你应该已经能将整个流程串起来了。如下图:
transform
函数的执行过程主要分为下面这几步:
在transform
函数中调用createTransformContext
函数生成上下文对象。在上下文对象中存储了当前正在转换的node节点的信息,后面的traverseNode
、traverseChildren
、nodeTransforms
数组中的转换函数、directiveTransforms
对象中的转换函数都会依赖这个上下文对象。
然后执行traverseNode
函数,traverseNode
函数是一个典型的洋葱模型。第一次执行traverseNode
函数的时候会进入洋葱模型的第一层,先将nodeTransforms
数组中的转换函数全部执行一遍,对第一层的node节点进行第一次转换,将转换函数返回的回调函数存到第一层的exitFns
数组中。经过第一次转换后v-for等指令已经被初次处理了。
然后执行traverseChildren
函数,在traverseChildren
函数中对当前node节点的子节点执行traverseNode
函数。此时就会进入洋葱模型的第二层,和上一步一样会将nodeTransforms
数组中的转换函数全部执行一遍,对第二层的node节点进行第一次转换,将转换函数返回的回调函数存到第二层的exitFns
数组中。
假如第二层的node节点已经没有了子节点,洋葱模型就会从"进入阶段"变成"出去阶段"。将第二层的exitFns
数组中存的回调函数全部执行一遍,对node节点进行第二次转换,然后出去到第一层的洋葱模型。经过第二次转换后v-for等指令已经被完全处理了。
同样将第一层中的exitFns
数组中存的回调函数全部执行一遍,由于此时第二层的node节点已经全部处理完了,所以在exitFns
数组中存的回调函数中就可以根据子节点的情况来处理父节点。
执行nodeTransforms
数组中的transformElement
转换函数,会返回一个回调函数。在回调函数中会调用buildProps
函数,在buildProps
函数中只有当node节点中有对应的指令才会执行directiveTransforms
对象中对应的转换函数。比如当前node节点有v-model指令,才会去执行transformModel
转换函数。v-model等指令也就被处理了。