揭开 Vue3 语法糖的面纱
Q: Vue3 中的指令、插槽和 v-model,底层到底做了什么?
A: 借助 vite-plugin-inspect 查看编译后的渲染函数可以发现:最终编译出来的渲染函数中根本不存在什么指令,不同指令会被编译为不同的处理逻辑。插槽本质上是父组件向子组件传递一个函数对象,子组件调用这些函数获取虚拟 DOM。而 v-model 则是 props + update:modelValue 自定义事件的语法糖。
指令的本质
到目前为止,我们已经学习过很多 Vue 的内置指令:v-if、v-show、v-for、v-model、v-html、v-bind、v-on 等等。接下来借助 vite-plugin-inspect 插件的编译结果来逐一分析它们的本质。
v-if 的本质
先来看一段使用 v-if / v-else-if / v-else 的代码:
<template>
<div v-if="type === 1">type 的值为 1</div>
<div v-else-if="type === 2">type 的值为 2</div>
<div v-else-if="type === 3">type 的值为 3</div>
<div v-else-if="type === 4">type 的值为 4</div>
<div v-else>Not 1/2/3/4 is 0</div>
<button @click="toogleFunc">Toggle</button>
</template>
<script setup>
import { ref } from 'vue';
const type = ref(1);
function toogleFunc() {
type.value = Math.floor(Math.random() * 5);
}
</script>
编译结果如下:
对于 v-if 指令,背后对应的就是三目运算符写的不同分支。每一次 $setup.type 值的变化就会导致渲染函数重新执行,然后进入到不同的分支。
v-for 的本质
再看 v-for 的使用:
<template>
<div>
<h2>商品列表</h2>
<ul>
<li v-for="(product, index) in products" :key="index">
{{ product.name }} - ${{ product.price }}
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
const products = ref([
{ name: '键盘', price: 99.99 },
{ name: '鼠标', price: 59.99 },
{ name: '显示器', price: 299.99 },
]);
</script>
编译结果如下:
生成的渲染函数里面,用到了一个名为 renderList 的内部方法,它的源码位于 packages/runtime-core/src/helpers/renderList.ts。renderList 负责遍历数据源,并为每一项调用渲染函数生成虚拟 DOM。
v-bind 的本质
<template>
<div v-bind:id>dynamicId</div>
</template>
<script setup>
import { ref } from 'vue';
const id = ref('my-id');
</script>
编译后的结果如下:
这里就是将 $setup.id 的值作为 div 的 id 属性值。由于涉及响应式数据的读取,$setup.id 的值发生变化时,渲染函数会重新执行,div 对应的属性也会随之变化。
v-on 的本质
<template>
<div>{{ count }}</div>
<button v-on:click="count++">+1</button>
</template>
<script setup>
import { ref } from 'vue';
const count = ref(0);
</script>
编译结果如下:
这个也非常简单,编译结果就是为 button 元素添加 click 事件,对应的事件处理函数为:
($event) => $setup.count++;
小结
通过这几个例子对比编译前后的结果,可以得出一个结论:最终编译出来的渲染函数,根本不存在什么指令,不同的指令会被编译为不同的处理:
| 指令 | 编译结果 |
|---|---|
v-if |
三目运算符分支 |
v-for |
renderList 方法调用 |
v-bind |
直接的属性赋值(读取响应式数据) |
v-on |
事件监听注册 |
插槽的本质
先复习插槽的使用层面概念:
- 子组件:通过
<slot>来设置插槽位置。 - 父组件:使用子组件时可以往 slot 的位置插入模板内容。
插槽的使用层面本质是:父组件向子组件传递模板内容,分为三种类型:
- 默认插槽:拥有默认内容。
- 具名插槽:给插槽取一个名字,支持多个插槽。
- 作用域插槽:数据来自于子组件,通过插槽的形式传递给父组件使用。
父组件传递内容的本质
父组件传递给子组件的,实际上是一个函数对象:
{
default: function() { /* 返回默认插槽对应的虚拟 DOM */ },
header: function() { /* 返回 header 插槽对应的虚拟 DOM */ },
footer: function() { /* 返回 footer 插槽对应的虚拟 DOM */ },
}
例如下面这个卡片组件的例子,父组件传递的就是这样的对象:
{
default: function() {
return (
<div class="card-content">
<img src="./assets/landscape.jpeg" alt="Beautiful landscape" class="card-image" />
<p>探索未知的自然风光,记录下每一个令人惊叹的瞬间。</p>
</div>
);
},
header: function() {
return <div>摄影作品</div>;
}
}
关键理解:父组件向子组件传递过去的东西本质上是函数,通过调用这些函数,能够得到对应结构的虚拟 DOM。
子组件设置插槽的本质
子组件内部设置插槽,其实就是对父组件传递过来的函数进行调用,得到对应的虚拟 DOM:
const slots = {
default: function() { /* ... */ },
header: function() { /* ... */ },
footer: function() { /* ... */ },
}; // 该对象是父组件传递过来的
slots.default(); // 得到默认插槽对应的虚拟 DOM
slots.header(); // 得到 header 插槽对应的虚拟 DOM
slots.footer(); // 得到 footer 插槽对应的虚拟 DOM
用渲染函数验证
我们可以用渲染函数来验证上述说法:
import { defineComponent, h, ref } from 'vue';
export default defineComponent({
name: 'CardComponent',
setup(_, { slots }) {
const title = ref('这是子组件标题222');
// 调用父组件传递过来的函数,获取虚拟 DOM
const defaultSlotsVNode = slots.default();
let headerSlotsVnode = null;
if (slots.header) {
headerSlotsVnode = slots.header({
title: title.value, // 向父组件的插槽函数传递数据(作用域插槽)
});
}
if (!headerSlotsVnode.length) {
headerSlotsVnode = h('div', null, '默认标题');
}
return () =>
h('div', { class: 'card' }, [
h('div', { class: 'card-header' }, headerSlotsVnode),
h('div', { class: 'card-body' }, defaultSlotsVNode),
]);
},
});
从这段代码中可以看到,slots 对象包含了父组件传递的所有插槽函数,子组件可以直接调用这些函数来获取虚拟 DOM。如果是作用域插槽,子组件还可以向这些函数传递参数,父组件通过 v-slot 的值来接收这些参数。
v-model 的本质
v-model 的用法总结起来就是两个场景:
- 表单元素和响应式数据双向绑定
- 父子组件之间传递数据
场景一:表单元素绑定
<template>
<div>
<p>输入的内容为:{{ message }}</p>
<input type="text" v-model="message" placeholder="请输入内容" />
</div>
</template>
<script setup>
import { ref } from 'vue';
const message = ref('Hello');
</script>
通过 vite-plugin-inspect 查看编译结果:
从编译结果可以看出,v-model 会被展开为一个名为 onUpdate:modelValue 的自定义事件,该事件对应的事件处理函数为:
($event) => ($setup.message = $event);
这解释了为什么输入框输入的值会影响响应式数据。与此同时,输入框的 value 本身又和 $setup.message 绑定在一起,$setup.message 一变化,就会导致渲染函数重新执行,从而更新输入框的显示内容。
场景二:父子组件绑定
父组件 App.vue:
<template>
<div class="app-container">
<h1>请给产品打分:</h1>
<RatingComponent v-model="rating" />
<p v-if="rating > 0">您的评分:{{ rating }}/5</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
import RatingComponent from '@/components/RatingComponent.vue';
const rating = ref(3);
</script>
子组件 RatingComponent.vue:
<template>
<div class="rating-container">
<span v-for="star in 5" :key="star" class="star" @click="setRating(star)">
{{ model >= star ? '★' : '☆' }}
</span>
</div>
</template>
<script setup>
const model = defineModel();
function setRating(newRating) {
model.value = newRating;
}
</script>
父组件通过 v-model 将自身的数据传递给子组件,子组件通过 defineModel 拿到父组件传递的数据,不仅可以使用还可以修改。
来看看父组件 App.vue 的编译结果:
这里会向子组件传递一个名为 modelValue 的 props,props 对应的值就是 $setup.rating,这正是父组件上面的状态。同时也传递了一个名为 onUpdate:modelValue 的自定义事件:
($event) => ($setup.rating = $event);
再看子组件 RatingComponent.vue 的编译结果:
子组件通过 modelValue 这个 props 拿到父组件传递的数据,当更新数据时,触发父组件传递过来的 onUpdate:modelValue 自定义事件,并且将新的值传递过去。
具名 v-model
有些时候在子组件上面使用 v-model 时,可以使用具名的 v-model:
<MyComponent v-model:title="bookTitle" />
此时展开的 props 和自定义事件的名称会有所不同:
| 类型 | Props | 自定义事件 |
|---|---|---|
| 默认 v-model | modelValue |
update:modelValue |
| 具名 v-model:title | title |
update:title |
至此,官方文档对 defineModel 的定义就完全清晰了:
defineModel是一个便利宏。编译器将其展开为以下内容:
- 一个名为
modelValue的 prop,本地 ref 的值与其同步;- 一个名为
update:modelValue的事件,当本地 ref 的值发生变更时触发。
总结
Vue3 的模板语法虽然丰富,但背后的原理其实非常清晰:
| 语法特性 | 本质 |
|---|---|
| 指令(v-if/v-for/v-bind/v-on) | 编译为不同的渲染函数逻辑,运行时不存在指令 |
| 插槽(slot) | 父组件传递函数对象,子组件调用获取虚拟 DOM |
| v-model | props + update:xxx 自定义事件的语法糖 |
理解这些原理后,在使用这些特性时就能做到「知其然,更知其所以然」,排查问题时也能更快定位根因。