我们每天都在用v-model
,并且大家都知道在vue3中v-model
是:modelvalue
和@update:modelvalue
的语法糖。那你知道v-model
指令是如何变成组件上的modelvalue
属性和@update:modelvalue
事件呢?将v-model
指令转换为modelvalue
属性和@update:modelvalue
事件这一过程是在编译时还是运行时进行的呢?
下面这个是我画的处理v-model
指令的完整流程图:
首先会调用parse
函数将template模块中的代码转换为ast抽象语法树,此时使用v-model
的node节点的props属性中还是v-model
。接着会调用transform
函数,经过transform
函数处理后在node
节点中多了一个codegennode
属性。在codegennode
属性中我们看到没有v-model
指令,取而代之的是modelvalue
和onupdate:modelvalue
属性。经过transform
函数处理后已经将v-model
指令编译为modelvalue
和onupdate:modelvalue
属性,此时还是ast抽象语法树。所以接下来就是调用generate
函数将ast抽象语法树转换为render
函数,到此为止编译时做的事情已经做完了,经过编译时的处理v-model
指令已经变成了modelvalue
和onupdate:modelvalue
属性。
接着就是运行时阶段,在浏览器中执行render
函数生成虚拟dom。在生成虚拟dom的过程中由于props属性中有modelvalue
和onupdate:modelvalue
属性,所以就会给组件对象加上modelvalue
属性和@update:modelvalue
事件。最后就是调用mount
方法将虚拟dom转换为真实dom。所以v-model
指令转换为modelvalue
属性和@update:modelvalue
事件这一过程是在编译时进行的。
vue是一个编译时 运行时一起工作的框架,之前有小伙伴私信我说自己傻傻分不清楚在vue中什么时候是编译时,什么时候是运行时。要回答小伙伴的这个问题我们要从一个vue文件是如何渲染到浏览器窗口中说起。
我们的vue代码一般都是写在后缀名为vue的文件上,显然浏览器是不认识vue文件的,浏览器只认识html、css、jss等文件类型。所以第一步就是通过webpack或者vite将一个vue文件编译为一个包含render
函数的js文件,在这一步中代码的执行环境是在nodejs中进行,也就是我们所说的编译时。相比浏览器端来说能够拿到的权限更多,也能做更多的事情。后面就是执行render
函数生成虚拟dom,再调用浏览器的dom api根据虚拟dom生成真实dom挂载到浏览器上。在第一步后面的这些过程中代码执行环境都是在浏览器中,也就是我们所说的运行时。在客户端渲染的场景下,一句话总结就是:代码跑在nodejs端的时候就是编译时,代码跑在浏览器端的时候就是运行时。
我们来看一个v-model
的例子,父组件index.vue
的代码如下:
input value is: {{ inputvalue }}
我们上面是一个很简单的v-model
的例子,在commonchild
子组件上使用v-model
绑定一个叫inputvalue
的ref变量,然后将这个inputvalue
变量渲染到p标签上面。
前面我们已经讲过了客户端渲染的场景下,在nodejs端工作的时候是编译时,在浏览器端工作的时候是运行时。那我们现在先来看看经过编译时
阶段处理后,刚刚进入到浏览器端运行时
阶段的js代码是什么样的。我们要如何在浏览器中找到这个js文件呢?其实很简单直接在network上面找到你的那个vue文件就行了,比如我这里的文件是index.vue
,那我只需要在network上面找叫index.vue
的文件就行了。但是需要注意一下network上面有两个index.vue
的js请求,分别是template模块 script模块编译后的js文件,和style模块编译后的js文件。
那怎么区分这两个index.vue
文件呢?很简单,通过query就可以区分。由style模块编译后的js文件的url中有type=style的query,如下图所示:
这时有的小伙伴就开始疑惑了不是说好的浏览器不认识vue文件吗?怎么这里的文件名称是index.vue
而不是index.js
呢?其实很简单,在开发环境时index.vue
文件是在app.vue
文件中import导入的,而app.vue
文件是在main.js
文件中import导入的。所以当浏览器中执行main.js
的代码时发现import导入了app.vue
文件,那浏览器就会去加载app.vue
文件。当浏览器加载完app.vue
文件后执行时发现import导入了index.vue
文件,所以浏览器就会去加载index.vue
文件,而不是index.js
文件。
至于什么时候将index.vue
文件中的template模块、script模块、style模块编译成js代码,我们在 文章中已经讲过了当import加载一个文件时会触发@vitejs/plugin-vue
包中的transform
钩子函数,在这个transform
钩子函数中会将template模块、script模块、style模块编译成js代码。所以在浏览器中拿到的index.vue文件就是经过编译后的js代码了。
现在我们在浏览器的network中来看刚刚进入编译时index.vue
文件代码,简化后的代码如下:
import {
fragment as _fragment,
createelementblock as _createelementblock,
createelementvnode as _createelementvnode,
createvnode as _createvnode,
definecomponent as _definecomponent,
openblock as _openblock,
todisplaystring as _todisplaystring,
ref,
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";
import commonchild from "/src/components/vmodel/child.vue?t=1710943659056";
import "/src/components/vmodel/index.vue?vue&type=style&index=0&scoped=0ebe7d62&lang.css";
const _sfc_main = _definecomponent({
__name: "index",
setup(__props, { expose: __expose }) {
__expose();
const inputvalue = ref();
const __returned__ = { inputvalue, commonchild };
return __returned__;
},
});
function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openblock(),
_createelementblock(
_fragment,
null,
[
_createvnode(
$setup["commonchild"],
{
modelvalue: $setup.inputvalue,
"onupdate:modelvalue":
_cache[0] ||
(_cache[0] = ($event) => ($setup.inputvalue = $event)),
},
null,
8,
["modelvalue"]
),
_createelementvnode(
"p",
null,
"input value is: " _todisplaystring($setup.inputvalue),
1
/* text */
),
],
64
/* stable_fragment */
)
);
}
_sfc_main.render = _sfc_render;
export default _sfc_main;
从上面的代码中我们可以看到编译后的js代码主要分为两块,第一块是_sfc_main
组件对象,里面有name属性和setup方法。一个vue组件在运行时实际就是一个对象,这里的_sfc_main
就是一个vue组件对象。至于definecomponent
函数的作用是在定义 vue 组件时提供类型推导的辅助函数,所以在我们这个场景没什么用。我们接着来看第二块_sfc_render
,从名字我想你应该已经猜到了他是一个render函数。执行这个_sfc_render
函数就会生成虚拟dom,然后再由虚拟dom生成浏览器上面的真实dom。
我们再来看这个render
函数,在这个render
函数前面会调用openblock
函数和createelementblock
函数。他的作用是在编译时尽可能的提取多的关键信息,可以减少运行时比较新旧虚拟dom带来的性能开销,我们这篇文章不关注这点,所以我们接下来会直接看下面的_createvnode
函数和_createelementvnode
函数。
我们接着来看render
函数中的_createvnode
函数和_createelementvnode
函数,代码如下:
import {
createelementvnode as _createelementvnode,
createvnode as _createvnode,
} from "/node_modules/.vite/deps/vue.js?v=23bfe016";
_createvnode(
$setup["commonchild"],
{
modelvalue: $setup.inputvalue,
"onupdate:modelvalue":
_cache[0] ||
(_cache[0] = ($event) => ($setup.inputvalue = $event)),
},
null,
8,
["modelvalue"]
),
_createelementvnode(
"p",
null,
"input value is: " _todisplaystring($setup.inputvalue),
1
/* text */
),
从这两个函数的名字我想你也能猜出来他们的作用是创建虚拟dom,再仔细一看这两个函数不就是对应的我们template模块中的这两行代码吗。
input value is: {{ inputvalue }}
第一个_createvnode
函数对应的是commonchild
,第二个_createelementvnode
对应的是p
标签。我们将重点放在_createvnode
函数上,从import导入来看_createvnode
函数是从vue中导出的createvnode
函数。你是不是觉得createvnode
这个名字比较熟悉呢,其实在 中有提到。
h()
是 hyperscript 的简称——意思是“能生成 html (超文本标记语言) 的 javascript”。这个名字来源于许多虚拟 dom 实现默认形成的约定。一个更准确的名称应该是createvnode()
,但当你需要多次使用渲染函数时,一个简短的名字会更省力。
vued88尊龙官网手机app官网中h()
函数用于生成虚拟dom,其实h()函数
底层就是调用的createvnode
函数。同样的createvnode
函数和h()
函数接收的参数也差不多,第一个参数可以是一个组件对象也可以是像p
这样的html标签,也可以是一个虚拟dom。第二个参数为给组件或者html标签传递的props属性或者attribute。第三个参数是该节点的children子节点。现在我们再来仔细看这个_createvnode
函数你应该已经明白了:
_createvnode(
$setup["commonchild"],
{
modelvalue: $setup.inputvalue,
"onupdate:modelvalue":
_cache[0] ||
(_cache[0] = ($event) => ($setup.inputvalue = $event)),
},
null,
8,
["modelvalue"]
),
我们在 文章中已经讲过了render
函数中的$setup
变量就是setup
函数的返回值经过proxy
处理后的对象,由于proxy
的拦截处理让我们在template中使用ref变量时无需再写.value
。在上面的setup
函数中我们看到commonchild
组件对象也在返回值对象中,所以这里传入给createvnode
函数的第一个参数为commonchild
组件对象。
我们再来看第二个参数对象,对象中有两个key,分别是modelvalue
和onupdate:modelvalue
。这两个key就是传递给commonchild
组件的两个props,等等这里有两个问题。第一个问题是这里怎么是onupdate:modelvalue
,我们知道的v-model
是:modelvalue
和@update:modelvalue
的语法糖,不是说好的@update
怎么变成了onupdate
了呢?第二个问题是onupdate:modelvalue
明显是事件监听而不是props属性,怎么是“通过props属性”而不是“通过事件”传递给了commonchild
子组件呢?
因为在编译时处理v-on事件监听会将监听的事件首字母变成大写然后在前面加一个on
,塞到props属性对象中,所以这里才是onupdate:modelvalue
。所以在组件上不管是v-bind的attribute和prop,还是v-on事件监听,经过编译后都会被塞到一个大的props对象中。以on
开头的属性我们都视作事件监听,用于和普通的attribute和prop区分。所以你在组件上绑定一个onconfirm
属性,属性值为一个handleclick
的函数。在子组件中使用emit('confirm')
是可以触发handleclick
函数的执行的,但是一般情况下还是不要这样写,维护代码的人会看着一脸蒙蔽的。
我们接着来看传递给commonchild
组件的这两个属性值。
{
modelvalue: $setup.inputvalue,
"onupdate:modelvalue":
_cache[0] ||
(_cache[0] = ($event) => ($setup.inputvalue = $event)),
}
第一个modelvalue
的属性值是$setup.inputvalue
。前面我们已经讲过了$setup.inputvalue
就是指向setup
中定义的名为inputvalue
的ref变量,所以第一个属性的作用就是给commonchild
组件添加:modelvalue="inputvalue"
的属性。
我们再来看第二个属性onupdate:modelvalue
,属性值为_cache[0] ||(_cache[0] = ($event) => ($setup.inputvalue = $event))
。这里为什么要加一个_cache
缓存呢?原因是每次页面刷新都会重新触发render
函数的执行,如果不加缓存那不就变成了每次执行render
函数都会生成一个事件处理函数。这里的事件处理函数也很简单,接收一个$event
变量然后赋值给setup
中的inputvalue
变量。接收的$event
变量就是我们在子组件中调用emit
触发事件传过来的第二个变量,比如:emit('update:modelvalue', 'helllo word')
。为什么是第二个变量呢?是因为emit
函数接收的第一个变量为要触发的事件名称。所以第二个属性的作用就是给commonchild
组件添加@update:modelvalue
的事件绑定。
前面我们已经讲过了在运行时已经拿到了key为modelvalue
和onupdate:modelvalue
的props属性对象了,我们知道这个props
属性对象是在编译时由v-model
指令编译而来的,那在这个编译过程中是如何处理v-model
指令的呢?请看下面编译时的流程图:
首先会调用parse
函数将template模块中的代码转换为ast抽象语法树,此时使用v-model
的node节点的props属性中还是v-model
。接着会调用transform
函数,经过transform
函数处理后在node
节点中多了一个codegennode
属性。在codegennode
属性中我们看到没有v-model
指令,取而代之的是modelvalue
和onupdate:modelvalue
属性。经过transform
函数处理后已经将v-model
指令编译为modelvalue
和onupdate:modelvalue
属性,此时还是ast抽象语法树。所以接下来就是调用generate
函数将ast抽象语法树转换为render
函数,到此为止编译时做的事情已经做完了。
parse
函数
首先是使用parse
函数将template模块中的代码编译成ast抽象语法树,在这个过程中会使用到大量的正则表达式对字符串进行解析。我们直接来看编译后的ast抽象语法树是什么样子:
从上图中我们可以看到使用v-model
指令的node节点中有了name
为model
和rawname
为v-model
的props了,明显可以看出将template中code代码字符串转换为ast抽象语法树时没有处理v-model
指令。那么什么时候处理的v-model
指令呢?
transform
函数
其实是在后面的一个transform
函数中处理的,在这个函数中主要调用的是traversenode
函数处理ast抽象语法树。在traversenode
函数中会去递归的去处理ast抽象语法树中的所有node节点,这也解释了为什么还要在transform
函数中再抽取出来一个traversenode
函数。
我们再来思考一个问题,由于traversenode
函数会处理node节点的所有情况,比如v-model
指令、v-for
指令、v-on
、v-bind
。如果将这些的逻辑全部都放到traversenode
函数中,那traversenode
函数的体量将会是非常大的。所以抽取出来一个nodetransforms
的概念,这个nodetransforms
是一个数组。里面存了一组transform
函数,用于处理node节点。每个transform
函数都有自己独有的作用,比如transformmodel
函数用于处理v-model
指令,transformif
函数用于处理v-if
指令。我们来看看经过transform
函数处理后的ast抽象语法树是什么样的:
从上图中我们可以看到同一个使用v-model
指令的node节点,经过transform
函数处理后的和第一步经过parse
函数处理后比起来node节点最外层多了一个codegennode
属性。
我们接下来看看codegennode
属性里面是什么样的:
从上图中我们可以看到在codegennode
中还有一个props
属性,在props
属性下面还有一个properties
属性。这个properties
属性是一个数组,里面就是存的是node节点经过transform函数处理后的props属性的内容。我们看到properties
数组中的每一个item都有key
和value
属性,我想你应该已经反应过来了,这个key
和value
分别对应的是props属性中的属性名和属性值。从上图中我们看到第一个属性的属性名key
的值为modelvalue
,属性值value
为$setup.inputvalue
。这个刚好就对应上v-model
指令编译后的:modelvalue="$setup.inputvalue"
。
我们再来接着看第二个属性:
从上图中我们同样也可以看到第二个属性的属性名key
的值为onupdate:modelvalue
,属性值value
的值拼起来就是为一串箭头函数,和我们前面编译后的代码一模一样。第二个属性刚好就对应上v-model
指令编译后的@update:modelvalue="($event) => ($setup.inputvalue = $event)"
。
从上面的分析我们看到经过transform
函数的处理后已经将v-model
指令处理为对应的代码了,接下来我们要做的事情就是调用generate
函数将ast抽象语法树转换成render
函数
generate
函数
在generate
函数中会递归遍历ast抽象语法树,然后生成对应的浏览器可执行的js代码。如下图:
从上图中我们可以看到经过generate
函数处理后生成的render
函数和我们之前在浏览器的network中看到的经过编译后的index.vue
文件中的render
函数一模一样。这也证明了modelvalue
属性和@update:modelvalue
事件塞到组件上是在编译时进行的。
现在我们可以回答前面提的两个问题了:
-
v-model
指令是如何变成组件上的modelvalue
属性和@update:modelvalue
事件呢?首先会调用
parse
函数将template模块中的代码转换为ast抽象语法树,此时使用v-model
的node节点的props属性中还是v-model
。接着会调用transform
函数,经过transform
函数处理后在node
节点中多了一个codegennode
属性。在codegennode
属性中我们看到没有v-model
指令,取而代之的是modelvalue
和onupdate:modelvalue
属性。经过transform
函数处理后已经将v-model
指令编译为modelvalue
和onupdate:modelvalue
属性。其实在运行时onupdate:modelvalue
属性就是等同于@update:modelvalue
事件。接着就是调用generate
函数,将ast抽象语法树生成render
函数。然后在浏览器中执行render
函数时,将拿到的modelvalue
和onupdate:modelvalue
属性塞到组件对象上,所以在组件上就多了两个modelvalue
属性和@update:modelvalue
事件。 -
将
v-model
指令转换为modelvalue
属性和@update:modelvalue
事件这一过程是在编译时还是运行时进行的呢?从上面的问题答案中我们可以知道将
v-model
指令转换为modelvalue
属性和@update:modelvalue
事件这一过程是在编译时进行的。
在transform
函数中是调用transformmodel
函数处理v-model
指令,这篇文章没有深入到transformmodel
函数源码内去讲解。如果大家对transformmodel
函数的源码感兴趣请在评论区留言或者给我发信息,我会在后面的文章安排上。