揭开 Vue3 语法糖的面纱

Q: Vue3 中的指令、插槽和 v-model,底层到底做了什么?

A: 借助 vite-plugin-inspect 查看编译后的渲染函数可以发现:最终编译出来的渲染函数中根本不存在什么指令,不同指令会被编译为不同的处理逻辑。插槽本质上是父组件向子组件传递一个函数对象,子组件调用这些函数获取虚拟 DOM。而 v-model 则是 props + update:modelValue 自定义事件的语法糖。

指令的本质

到目前为止,我们已经学习过很多 Vue 的内置指令:v-ifv-showv-forv-modelv-htmlv-bindv-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 编译结果

对于 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>

编译结果如下:

v-for 编译结果

生成的渲染函数里面,用到了一个名为 renderList 的内部方法,它的源码位于 packages/runtime-core/src/helpers/renderList.tsrenderList 负责遍历数据源,并为每一项调用渲染函数生成虚拟 DOM。

v-bind 的本质

<template>
  <div v-bind:id>dynamicId</div>
</template>

<script setup>
import { ref } from 'vue';
const id = ref('my-id');
</script>

编译后的结果如下:

v-bind 编译结果

这里就是将 $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>

编译结果如下:

v-on 编译结果

这个也非常简单,编译结果就是为 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 的用法总结起来就是两个场景:

  1. 表单元素和响应式数据双向绑定
  2. 父子组件之间传递数据

场景一:表单元素绑定

<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 表单元素编译结果

从编译结果可以看出,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 的编译结果:

父子组件 v-model 编译父组件

这里会向子组件传递一个名为 modelValue 的 props,props 对应的值就是 $setup.rating,这正是父组件上面的状态。同时也传递了一个名为 onUpdate:modelValue 的自定义事件:

($event) => ($setup.rating = $event);

再看子组件 RatingComponent.vue 的编译结果:

子组件 defineModel 编译结果

子组件通过 modelValue 这个 props 拿到父组件传递的数据,当更新数据时,触发父组件传递过来的 onUpdate:modelValue 自定义事件,并且将新的值传递过去。

具名 v-model

有些时候在子组件上面使用 v-model 时,可以使用具名的 v-model:


<MyComponent v-model:title="bookTitle" />
具名 v-model 编译结果

此时展开的 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 自定义事件的语法糖

理解这些原理后,在使用这些特性时就能做到「知其然,更知其所以然」,排查问题时也能更快定位根因。