前言

我们每天都在用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指令,取而代之的是modelValueonUpdate:modelValue属性。经过transform函数处理后已经将v-model指令编译为modelValueonUpdate:modelValue属性,此时还是AST抽象语法树。所以接下来就是调用generate函数将AST抽象语法树转换为render函数,到此为止编译时做的事情已经做完了,经过编译时的处理v-model指令已经变成了modelValueonUpdate:modelValue属性。

接着就是运行时阶段,在浏览器中执行render函数生成虚拟DOM。在生成虚拟DOM的过程中由于props属性中有modelValueonUpdate: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的代码如下:

<template>
<CommonChild v-model="inputValue" />
<p>input value is: {{ inputValue }}</p>
</template> <script setup lang="ts">
import { ref } from "vue";
import CommonChild from "./child.vue"; const inputValue = ref();
</script>

我们上面是一个很简单的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代码,我们在 通过debug搞清楚.vue文件怎么变成.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函数。

v-model语法糖怎么工作的

我们接着来看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模块中的这两行代码吗。

<CommonChild v-model="inputValue" />
<p>input value is: {{ inputValue }}</p>

第一个_createVNode函数对应的是CommonChild,第二个_createElementVNode对应的是p标签。我们将重点放在_createVNode函数上,从import导入来看_createVNode函数是从vue中导出的createVNode函数。你是不是觉得createVNode这个名字比较熟悉呢,其实在 vue官网中有提到。

h() 是 hyperscript 的简称——意思是“能生成 HTML (超文本标记语言) 的 JavaScript”。这个名字来源于许多虚拟 DOM 实现默认形成的约定。一个更准确的名称应该是 createVnode(),但当你需要多次使用渲染函数时,一个简短的名字会更省力。

vue官网中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"]
),

我们在 Vue 3 的 setup语法糖到底是什么东西?文章中已经讲过了render函数中的$setup变量就是setup函数的返回值经过Proxy处理后的对象,由于Proxy的拦截处理让我们在template中使用ref变量时无需再写.value。在上面的setup函数中我们看到CommonChild组件对象也在返回值对象中,所以这里传入给createVNode函数的第一个参数为CommonChild组件对象。

我们再来看第二个参数对象,对象中有两个key,分别是modelValueonUpdate: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的事件绑定。

编译时如何处理v-model

前面我们已经讲过了在运行时已经拿到了key为modelValueonUpdate:modelValue的props属性对象了,我们知道这个props属性对象是在编译时由v-model指令编译而来的,那在这个编译过程中是如何处理v-model指令的呢?请看下面编译时的流程图:

首先会调用parse函数将template模块中的代码转换为AST抽象语法树,此时使用v-model的node节点的props属性中还是v-model。接着会调用transform函数,经过transform函数处理后在node节点中多了一个codegenNode属性。在codegenNode属性中我们看到没有v-model指令,取而代之的是modelValueonUpdate:modelValue属性。经过transform函数处理后已经将v-model指令编译为modelValueonUpdate:modelValue属性,此时还是AST抽象语法树。所以接下来就是调用generate函数将AST抽象语法树转换为render函数,到此为止编译时做的事情已经做完了。

parse函数

首先是使用parse函数将template模块中的代码编译成AST抽象语法树,在这个过程中会使用到大量的正则表达式对字符串进行解析。我们直接来看编译后的AST抽象语法树是什么样子:

从上图中我们可以看到使用v-model指令的node节点中有了namemodelrawNamev-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-onv-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都有keyvalue属性,我想你应该已经反应过来了,这个keyvalue分别对应的是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指令,取而代之的是modelValueonUpdate:modelValue属性。经过transform函数处理后已经将v-model指令编译为modelValueonUpdate:modelValue属性。其实在运行时onUpdate:modelValue属性就是等同于@update:modelValue事件。接着就是调用generate函数,将AST抽象语法树生成render函数。然后在浏览器中执行render函数时,将拿到的modelValueonUpdate:modelValue属性塞到组件对象上,所以在组件上就多了两个modelValue属性和@update:modelValue事件。

  • v-model指令转换为modelValue属性和@update:modelValue事件这一过程是在编译时还是运行时进行的呢?

    从上面的问题答案中我们可以知道将v-model指令转换为modelValue属性和@update:modelValue事件这一过程是在编译时进行的。

transform函数中是调用transformModel函数处理v-model指令,这篇文章没有深入到transformModel函数源码内去讲解。如果大家对transformModel函数的源码感兴趣请在评论区留言或者给我发信息,我会在后面的文章安排上。

关注公众号:前端欧阳,解锁我更多vue干货文章。还可以加我微信,私信我想看哪些vue原理文章,我会根据大家的反馈进行创作。



面试官:只知道v-model是:modelValue和@onUpdate语法糖,那你可以走了的更多相关文章

  1. 我以为我对Mysql索引很了解,直到我遇到了阿里的面试官

    GitHub 4.8k Star 的Java工程师成神之路 ,不来了解一下吗? GitHub 4.8k Star 的Java工程师成神之路 ,真的不来了解一下吗? GitHub 4.8k Star 的 ...

  2. 如何准备Java面试?如何把面试官的提问引导到自己准备好的范围内?

    Java能力和面试能力,这是两个方面的技能,可以这样说,如果不准备,一些大神或许也能通过面试,但能力和工资有可能被低估.再仔细分析下原因,面试中问的问题,虽然在职位介绍里已经给出了范围,但针对每个点, ...

  3. 8年经验面试官详解 Java 面试秘诀

      作者 | 胡书敏 责编 | 刘静 出品 | CSDN(ID:CSDNnews) 本人目前在一家知名外企担任架构师,而且最近八年来,在多家外企和互联网公司担任Java技术面试官,前后累计面试了有两三 ...

  4. 面试中AOP这样说,面试官只有一个字:服!

  5. 面试官:服务器安装 JDK 还是 JRE?可以只安装 JRE 吗?

    前些日子有知友面试时被问到如题所示的问题,由于他之前没有准备到这些最最基础的知识,没有考虑过这个问题,所以被问到时竟一脸萌币,回答的不是很好.这道题主要考的是对 Java 基础知识的了解,有些同学可能 ...

  6. 三年Android开发,竟只会增删改查,被面试官一顿怼!

    最近看到某公司面试官发的这样一个帖子: 我面试了一个有三年Android开发经验的小伙子,也是我有史以来给别人面试时间最短的一次,不到十分钟就结束了,原因很简单,底子太差只会curd,很多技术性的问题 ...

  7. 阿里面试官:字符串在JVM中如何存放?90%的人就真的只回答在哪里存放

    目录: 一道面试题的引出 案例分析 intern 源码分析 总结 1. 一道面试题的引出 在面试BAT这种一线大厂时,如果面试官问道:字符串在 JVM 中如何存放?大多数人能顺利的给出如下答案: 字符 ...

  8. 面试官最爱的volatile关键字

    在Java相关的岗位面试中,很多面试官都喜欢考察面试者对Java并发的了解程度,而以volatile关键字作为一个小的切入点,往往可以一问到底,把Java内存模型(JMM),Java并发编程的一些特性 ...

  9. 面试官问我,Redis分布式锁如何续期?懵了。

    前言 上一篇[面试官问我,使用Dubbo有没有遇到一些坑?我笑了.]之后,又有一位粉丝和我说在面试过程中被虐了.鉴于这位粉丝是之前肥朝的粉丝,而且周一又要开启新一轮的面试,为了回馈他长期以来的支持,所 ...

  10. C++陷阱系列:让面试官倒掉的题

    http://blog.chinaunix.net/uid-22754909-id-3969535.html 今天和几位同仁一起探讨了一下C++的一些基础知识,在座的同仁都是行家了,有的多次当过C++ ...

随机推荐

  1. Java设计模式-装饰者模式Decorator

    介绍 装饰者模式的核心思想是通过创建一个装饰对象(即装饰者),动态扩展目标对象的功能,并且不会改变目标对象的结构,提供了一种比继承更灵活的替代方案.需要注意的是,装饰对象要与目标对象实现相同的接口,或 ...

  2. ORACLE FORALL介绍

    ORACLE 10G OFFICIAL DOCUMNET  ---------------------------------------------------------------------- ...

  3. Java I/O 教程(十 一) BufferedWriter和BufferedReader

    Java BufferedWriter 类 Java BufferedWriter class 继承了Writer类,为Writer实例提供缓冲. 提升了写字符和字符串性能. 类定义: public ...

  4. 如何在C#中使用 Excel 动态函数生成依赖列表

    前言 在Excel 中,依赖列表或级联下拉列表表示两个或多个列表,其中一个列表的项根据另一个列表而变化.依赖列表通常用于Excel的业务报告,例如学术记分卡中的[班级-学生]列表.区域销售报告中的[区 ...

  5. "explicit" 的使用

    今天在编译项目时,代码审查提示 "Single-parameter constructors should be marked explicit" 于是就在构造函数前加上 expl ...

  6. 硬件开发笔记(五): 硬件开发基本流程,制作一个USB转RS232的模块(四):创建CON连接器件封装并关联原理图元器件

    前言   有了原理图,可以设计硬件PCB,在设计PCB之间还有一个协同优先动作,就是映射封装,原理图库的元器件我们是自己设计的.为了更好的表述封装设计过程,本文描述了一个创建CON标准连接件封装,创建 ...

  7. 【Java复健指南10】OOP高级01-类变量、类方法和main

    类变量 什么是类变量 类变量也叫静态变量/静态属性,是该类的所有对象共享的变量,任何一个该类的对象去访问它时,取到的都是相同的值,同样任何一个该类的对象去修改它时,修改的也是同一个变量. 如何定义类变 ...

  8. RibbonRoutingFilter是如何工作的

    在讲RibbonRoutingFilter是如何工作之前,也有一些比较重要的类需要去提前了解. 重要的类 RequestContext 请求上下文,用于存储线程中对应的请求以及响应 public cl ...

  9. 【Azure 应用服务】Java ODBC代码中,启用 Managed Identity 登录 SQL Server 报错 Managed Identity authentication is not available

    问题描述 在App Service中启用Identity后,使用系统自动生成 Identity. 使用如下代码连接数据库 SQL Server: SQLServerDataSource dataSou ...

  10. C#/.NET/.NET Core优秀项目和框架2024年2月简报

    前言 公众号每月定期推广和分享的C#/.NET/.NET Core优秀项目和框架(每周至少会推荐两个优秀的项目和框架当然节假日除外),公众号推文中有项目和框架的介绍.功能特点.使用方式以及部分功能截图 ...