本文主要记录了关于 Vue3 的相关笔记,主要记录 Vue3 及其生态的使用与技巧,不定时更新。

基础

创建一个 app 实例并挂载

1
2
3
4
5
6
7
8
9
10
11
import { createApp } from "vue";
// 从一个单文件组件中导入根组件
import App from "./App.vue";

const app = createApp(App);
//
app.config.errorHandler = (err) => {
  /* 处理错误 */
};
// 挂在到跟组件 #app 容器里面
app.mount("#app");

应用根组件的内容将会被渲染在容器元素里面。容器元素自己将不会被视为应用的一部分。
.mount() 方法应该始终在整个应用配置和资源注册完成后被调用。同时请注意,不同于其他资源注册方法,它的返回值是根组件实例而非应用实例。
这几个方法的返回值都是应用实例本身,也就是通过 createApp() 方法创建的对象。这样可以方便地链式调用这些方法,而不需要每次都写 app. 前缀。

1
2
3
4
5
6
7
8
9
10
11
// 创建应用实例
const app = createApp(App);

// 链式调用注册方法
app
  .component("my-button", MyButton)
  .directive("focus", focus)
  .use(vuex)
  .mixin(mixin)
  .provide("foo", "bar")
  .mount("#app"); // 最后调用挂载方法

模板语法

  1. 动态绑定多个值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <script setup lang="ts">
    const objectOfAttrs = {
      id: "container",
      class: "wrapper",
    };
    </script>
    
    <template>
      <div v-bind="objectOfAttrs"></div>
    </template>
    <!-- 或者 -->
    <template>
      <div :="objectOfAttrs"></div>
    </template>
  2. v-html指令相当于将元素的innerHTML与之同步。

    1
    2
    <p>Using text interpolation: {{ rawHtml }}</p>
    <p>Using v-html directive: <span v-html="rawHtml"></span></p>
  3. 调用函数

    1
    2
    3
    <time :title="toTitleDate(date)" :datetime="date">
      {{ formatDate(date) }}
    </time>

    绑定在表达式中的方法在组件每次更新时都会被重新调用,因此不应该产生任何副作用,比如改变数据或触发异步操作。

    模板中的表达式将被沙盒化,仅能够访问到有限的全局对象列表。该列表中会暴露常用的内置全局对象,比如 Math 和 Date。
    没有显式包含在列表中的全局对象将不能在模板内表达式中访问,例如用户附加在 window 上的属性。然而,你也可以自行在 app.config.globalProperties 上显式地添加它们,供所有的 Vue 表达式使用。

  4. 动态参数

    1
    2
    3
    4
    <a v-on:[eventName]="doSomething"> ... </a>
    
    <!-- 简写 -->
    <a @[eventName]="doSomething"></a>

响应式基础

  1. 为保证访问代理的一致性,对同一个原始对象调用 reactive() 会总是返回同样的代理对象,而对一个已存在的代理对象调用 reactive() 会返回其本身:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    const raw = {};
    const proxy = reactive(raw);
    
    // 代理对象和原始对象不是全等的
    console.log(proxy === raw); // false
    
    // 在同一个对象上调用 reactive() 会返回相同的代理
    console.log(reactive(raw) === proxy); // true
    
    // 在一个代理上调用 reactive() 会返回它自己
    console.log(reactive(proxy) === proxy); // true
    // 这个规则对嵌套对象也适用。依靠深层响应性,响应式对象内的嵌套对象依然是代理:
    const proxy = reactive({});
    
    const raw = {};
    proxy.nested = raw;
    
    console.log(proxy.nested === raw); // false
  2. ref对象在reactive对象中的解包。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    const count = ref(0);
    const state = reactive({
      count,
    });
    
    console.log(state.count); // 0
    
    state.count = 1;
    console.log(count.value); // 1
    
    const otherCount = ref(2);
    
    state.count = otherCount;
    console.log(state.count); // 2
    // 原始 ref 现在已经和 state.count 失去联系
    console.log(count.value); // 1

    reactive 对象不同的是,当 ref 作为响应式数组或原生集合类型(如 Map) 中的元素被访问时,它不会被解包:

    1
    2
    3
    4
    5
    6
    7
    const books = reactive([ref("Vue 3 Guide")]);
    // 这里需要 .value
    console.log(books[0].value);
    
    const map = reactive(new Map([["count", ref(0)]]));
    // 这里需要 .value
    console.log(map.get("count").value);
  3. Vue3中的内联事件处理器中的ref对象解包,与模板解包相同。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <script setup lang="ts">
    // 注意 ref 对象在模板中的解包只有顶层属性才能解包,类似下面的 count,正如上面所说,内联事件处理器中的解包规则与模板解包完全相同
    const count = ref(0);
    const object = ref({ foo: ref(1) });
    const object = { foo: ref(1) };
    const object = ref({ foo: 1 });
    </script>
    
    <template>
      <button ref="btn" @click="test">{{ a }}</button>
      {{ b }}
      <button @click="count++">Add 1</button>
      <p>Count is: {{ count }}</p>
      {{ object.foo }} {{ object.foo.value }}/ {{ object.foo }}
    </template>
查看 ref 与 reactive 简单实现
  1. 基础的refreactive函数的定义,注意refvalue对象为gettersetter值用来适配基本的数据类型。Reflectreceiver绑定get或者setthis

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    // ref的函数
    function ref(val) {
      // 此处源码中为了保持一致,在对象情况下也做了用value 访问的情况value->proxy对象
      // 我们在对象情况下就不在使用value 访问
      return new refObj(val);
    }
    //创建响应式对象
    class refObj {
      constructor(val) {
        this._value = reactive(val);
      }
      get value() {
        // 在第一次执行之后触发get来收集依赖
        track(this, "value");
        return this._value;
      }
      set value(newVal) {
        console.log(newVal);
        this._value = newVal;
        trigger(this, "value");
      }
    }
    // 对象的响应式处理 在这里我们为了理解原理原理暂时不考虑对象里嵌套对象的情况
    // 其实对象的响应式处理也就是重复执行reactive
    function reactive(target) {
      if (!isObject(target)) {
        return target;
      }
      return new Proxy(target, {
        get(target, key, receiver) {
          // Reflect用于执行对象默认操作,更规范、函数式
          // Proxy和Object的方法Reflect都有对应
          const res = Reflect.get(target, key, receiver);
          track(target, key);
          return isObject(res) ? reactive(res) : res;
        },
        set(target, key, value, receiver) {
          console.log(target, key, value);
          const res = Reflect.set(target, key, value, receiver);
          trigger(target, key);
          return res;
        },
        deleteProperty(target, key) {
          const res = Reflect.deleteProperty(target, key);
          trigger(target, key);
          return res;
        },
      });
    }
  2. 追踪依赖,这部分代码比较难理解,具体思路可这样理解
    判断当前target在整体WeakMap中是否出现,如果没出现,则设置为Map对象,有无key属性的值,则设置为Set对象,防止重复收集依赖,然后将依赖函数放到Set,对象中。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    function track(target, key) {
      // 取出最后一个数据内容
      const effect = effectStack[effectStack.length - 1];
      // 如果当前变量有依赖
      if (effect) {
        //判断当前的map中是否有target
        let depsMap = targetMap.get(target);
        // 如果没有
        if (!depsMap) {
          // new map存储当前weakmap
          depsMap = new Map();
          targetMap.set(target, depsMap);
        }
        // 获取key对应的响应函数集
        let deps = depsMap.get(key);
        if (!deps) {
          // 建立当前key 和依赖的关系,因为一个key 会有多个依赖
          // 为了防止重复依赖,使用set
          deps = new Set();
          depsMap.set(key, deps);
        }
        // 存入当前依赖
        if (!deps.has(effect)) {
          deps.add(effect);
        }
      }
    }
  3. 依赖收集部分

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    // 但是还是没有响应式的能力,那么他是怎样实现响应式的呢----依赖收集,触发更新=
    // 用来做依赖收集
    // 在源码中为为了方法的通用性,他还传入了很多参数用于兼容不同情况
    // 我们意在理解原理,只需要包装fn 即可
    function effect(fn) {
      // 包装当前依赖函数
      const effect = function reactiveEffect() {
        // 模拟源码中也加入错误处理,为了避免你瞎写出现错误的情况,这就是框架的高明之处
        if (!effectStack.includes(effect)) {
          try {
            // 给当前函数放入临时栈中,为在下面执行中,触发get,在依赖收集中能找到当前变量的依赖项来建立关系
            effectStack.push(fn);
            // 执行当前函数,开始依赖收集了
            return fn();
          } finally {
            // 执行成功了出栈
            effectStack.pop();
          }
        }
      };
    
      effect();
    }
  4. 触发更新,取对象取key执行。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 用于触发更新
    function trigger(target, key) {
      // 获取所有依赖内容
      const depsMap = targetMap.get(target);
      // 如果有依赖的话全部拉出来执行
      if (depsMap) {
        // 获取响应函数集合
        const deps = depsMap.get(key);
        if (deps) {
          // 执行所有响应函数
          const run = (effect) => {
            // 源码中有异步调度任务,我们在这里省略
            effect();
          };
          deps.forEach(run);
        }
      }
    }
  5. 完整代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    // 保存临时依赖函数用于包装
    const effectStack = [];
    // 依赖关系的map对象只能接受对象
    let targetMap = new WeakMap();
    // 判断是不是对象
    const isObject = (val) => val !== null && typeof val === "object";
    // ref的函数
    function ref(val) {
      // 此处源码中为了保持一致,在对象情况下也做了用value 访问的情况value->proxy对象
      // 我们在对象情况下就不在使用value 访问
      return new refObj(val);
    }
    //创建响应式对象
    class refObj {
      constructor(val) {
        this._value = reactive(val);
      }
      get value() {
        // 在第一次执行之后触发get来收集依赖
        track(this, "value");
        return this._value;
      }
      set value(newVal) {
        console.log(newVal);
        this._value = newVal;
        trigger(this, "value");
      }
    }
    // 对象的响应式处理 在这里我们为了理解原理原理暂时不考虑对象里嵌套对象的情况
    // 其实对象的响应式处理也就是重复执行reactive
    function reactive(target) {
      if (!isObject(target)) {
        return target;
      }
      return new Proxy(target, {
        get(target, key, receiver) {
          // Reflect用于执行对象默认操作,更规范、函数式
          // Proxy和Object的方法Reflect都有对应
          const res = Reflect.get(target, key, receiver);
          track(target, key);
          return isObject(res) ? reactive(res) : res;
        },
        set(target, key, value, receiver) {
          console.log(target, key, value);
          const res = Reflect.set(target, key, value, receiver);
          trigger(target, key);
          return res;
        },
        deleteProperty(target, key) {
          const res = Reflect.deleteProperty(target, key);
          trigger(target, key);
          return res;
        },
      });
    }
    
    // 到此处,当前的ref 对象就已经实现了对数据改变的监听
    const newRef = ref({ a: { b: 1 } });
    const newRef1 = ref(0);
    // 但是还是没有响应式的能力,那么他是怎样实现响应式的呢----依赖收集,触发更新=
    // 用来做依赖收集
    // 在源码中为为了方法的通用性,他还传入了很多参数用于兼容不同情况
    // 我们意在理解原理,只需要包装fn 即可
    function effect(fn) {
      // 包装当前依赖函数
      const effect = function reactiveEffect() {
        // 模拟源码中也加入错误处理,为了避免你瞎写出现错误的情况,这就是框架的高明之处
        if (!effectStack.includes(effect)) {
          try {
            // 给当前函数放入临时栈中,为在下面执行中,触发get,在依赖收集中能找到当前变量的依赖项来建立关系
            effectStack.push(fn);
            // 执行当前函数,开始依赖收集了
            return fn();
          } finally {
            // 执行成功了出栈
            effectStack.pop();
          }
        }
      };
    
      effect();
    }
    //  在收集的依赖中建立关系
    function track(target, key) {
      // 取出最后一个数据内容
      const effect = effectStack[effectStack.length - 1];
      // 如果当前变量有依赖
      if (effect) {
        //判断当前的map中是否有target
        let depsMap = targetMap.get(target);
        // 如果没有
        if (!depsMap) {
          // new map存储当前weakmap
          depsMap = new Map();
          targetMap.set(target, depsMap);
        }
        // 获取key对应的响应函数集
        let deps = depsMap.get(key);
        if (!deps) {
          // 建立当前key 和依赖的关系,因为一个key 会有多个依赖
          // 为了防止重复依赖,使用set
          deps = new Set();
          depsMap.set(key, deps);
        }
        // 存入当前依赖
        if (!deps.has(effect)) {
          deps.add(effect);
        }
      }
    }
    // 用于触发更新
    function trigger(target, key) {
      // 获取所有依赖内容
      const depsMap = targetMap.get(target);
      // 如果有依赖的话全部拉出来执行
      if (depsMap) {
        // 获取响应函数集合
        const deps = depsMap.get(key);
        if (deps) {
          // 执行所有响应函数
          const run = (effect) => {
            // 源码中有异步调度任务,我们在这里省略
            effect();
          };
          deps.forEach(run);
        }
      }
    }
    effect(() => {
      console.log(11111);
      // 在自己实现的effect中,由于为了演示原理,没有做兼容,不能来触发set,否则会死循环
      // vue源码中触发对effect中的做了兼容处理只会执行一次
      newRef.value.a.b;
      newRef1.value;
    });
    newRef.value.a.b++;
    newRef1.value++;

计算属性

计算属性是vue3中用于从现有数据派生衍生数据的一种API,它们基于vue实例中已有的数据,通过对这些数据进行计算得出新的值,因此我们只需要声明衍生数据的计算逻辑即可,同时计算属性会自动跟踪依赖的数据,并在相关数据发生变化时自动更新计算结果,这样,当依赖数据发生改变时,计算属性会自动重新计算,不需要手动控制何时进行计算或更新,而JavaScript表达式则需要我们在特定的时机通过代码执行来计算值。

计算属性的原理是基于vue3的响应式系统,它利用了Proxy对象和Ref对象来实现数据的拦截和追踪。具体来说,计算属性的实现过程可以分为以下几个步骤:

  1. 创建一个计算属性ref,它是一个特殊的ref对象,它有一个.value属性,用于存储计算结果,以及一个.effect属性,用于存储计算逻辑。
  2. 调用computed()函数,传入一个getter函数,作为计算逻辑,返回一个计算属性ref
  3. getter函数中,访问响应式数据,如reactive对象或ref对象,这样就会触发响应式数据的get拦截器,从而收集当前计算属性ref作为依赖,建立依赖关系。
  4. getter函数中,返回计算结果,将其赋值给计算属性ref.value属性,这样就会触发计算属性refset拦截器,从而触发计算属性refeffect属性,执行计算逻辑。
  5. 在模板或者JavaScript中,访问计算属性ref.value属性,获取计算结果,同时检查计算属性refdirty属性,如果为true,说明计算属性ref需要重新计算,如果为false,说明计算属性ref可以直接返回缓存的结果。
  6. 当响应式数据发生变化时,会触发响应式数据的set拦截器,从而触发响应式数据的依赖列表,通知所有依赖于该数据的计算属性ref更新,将其dirty属性设为true,表示需要重新计算。

以下是一个简单的示例,演示了计算属性的原理和用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 创建一个响应式对象
const state = reactive({
  count: 0,
});
// 创建一个计算属性ref
const double = computed(() => {
  // 在getter函数中,访问响应式数据
  // 这样就会收集当前计算属性ref作为依赖
  console.log("double computed");
  // 在getter函数中,返回计算结果
  return state.count * 2;
});
// 在模板或者JavaScript中,访问计算属性ref的.value属性
// 获取计算结果,同时检查计算属性ref的dirty属性
console.log(double.value); // 0
// 当响应式数据发生变化时,会触发响应式数据的依赖列表
// 通知所有依赖于该数据的计算属性ref更新,将其dirty属性设为true
state.count++;
// 再次访问计算属性ref的.value属性时,会重新计算
console.log(double.value); // 2

计算属性的优点是:

  • 可以简化模板或者 JavaScript 中的复杂逻辑,提高代码的可读性和可维护性。
  • 可以自动缓存计算结果,提高性能和效率,避免重复计算。
  • 可以自动追踪和更新依赖数据,保证数据的一致性和正确性,避免数据的过时和错误。

计算属性的使用场景是:

  • 当需要从现有数据派生衍生数据时,如对数据进行格式化、过滤、排序、统计等操作。
  • 当需要对数据进行复杂的计算时,如对数据进行数学、逻辑、字符串等运算。
  • 当需要对数据进行条件判断时,如根据数据的值返回不同的结果或状态。

计算属性默认是只读的。当你尝试修改一个计算属性时,你会收到一个运行时警告。只在某些特殊场景中你可能才需要用到“可写”的属性,你可以通过同时提供 gettersetter 来创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<script setup>
import { ref, computed } from "vue";

const firstName = ref("John");
const lastName = ref("Doe");

const fullName = computed({
  // getter
  get() {
    return firstName.value + " " + lastName.value;
  },
  // setter
  set(newValue) {
    // 注意:我们这里使用的是解构赋值语法
    [firstName.value, lastName.value] = newValue.split(" ");
  },
});
</script>

类与样式绑定

  1. Vue3中多个class的绑定

    需要注意对象写法,必须以键值对的形式,不能少
    数组写法没定义字符串的话得用字符串形式
    组件样式和组件内模板合并

    1
    2
    const activeClass = ref("active");
    const errorClass = ref("text-danger");
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <div
      class="static"
      :class="{ active: isActive, 'text-danger': hasError }"
    ></div>
    <div :class="[{ active: isActive }, errorClass]"></div>
    <div :class="[{ test1: true }, 'test2']"></div>
    
    <!-- 子组件模板 -->
    <p class="foo bar">Hi!</p>
    
    <!-- 在使用组件时 -->
    <MyComponent class="baz boo" />
    
    <!-- 结果为 -->
    <p class="foo bar baz boo">Hi!</p>
    
    <!-- MyComponent 模板使用 $attrs 时 -->
    <p :class="$attrs.class">Hi!</p>
    <span>This is a child component</span>
  2. 绑定内联样式

    1
    2
    3
    4
    5
    6
    const activeColor = ref("red");
    const fontSize = ref(30);
    const styleObject = reactive({
      color: "red",
      fontSize: "13px",
    });
    1
    2
    3
    4
    5
    6
    7
    <div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>
    <div :style="{ 'font-size': fontSize + 'px' }"></div>
    <div :style="styleObject"></div>
    <!-- 还可以给 :style 绑定一个包含多个样式对象的数组。这些对象会被合并后渲染到同一元素上 -->
    <div :style="[baseStyles, overridingStyles]"></div>
    <!-- 你可以对一个样式属性提供多个 (不同前缀的) 值,数组仅会渲染浏览器支持的最后一个值。在这个示例中,在支持不需要特别前缀的浏览器中都会渲染为 display: flex。 -->
    <div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }"></div>

条件渲染

  1. 一个 v-else 元素必须跟在一个 v-if 或者 v-else-if 元素后面,否则它将不会被识别。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <div v-if="type === 'A'">
      A
    </div>
    <!-- 和 v-else 类似,一个使用 v-else-if 的元素必须紧跟在一个 v-if 或一个 v-else-if 元素后面。 -->
    <div v-else-if="type === 'B'">
      B
    </div>
    <div v-else-if="type === 'C'">
      C
    </div>
    <div v-else>
      Not A/B/C
    </div>
  2. template上面的v-ifv-elsev-else-if 也可以在 <template> 上使用。

    1
    2
    3
    4
    5
    6
    <!-- 一次性切换多个元素的显隐 -->
    <template v-if="ok">
      <h1>Title</h1>
      <p>Paragraph 1</p>
      <p>Paragraph 2</p>
    </template>

    v-show 不支持在<template> 元素上使用,也不能和 v-else 搭配使用。

  3. v-ifv-for不应该一起使用。原因为v-if的优先级比vue-for更高,推荐的用法如下。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!-- 使用计算属性或者与子元素作判断 -->
    <ul>
      <li
        v-for="user in activeUsers"
        :key="user.id"
      >
        {{ user.name }}
      </li>
    </ul>
    <ul>
      <template v-for="user in users" :key="user.id">
        <li v-if="user.isActive">
          {{ user.name }}
        </li>
      </template>
    </ul>
    1
    2
    3
    4
    5
    6
    7
    <!--
    这会抛出一个错误,因为属性 todo 此时
    没有在该实例上定义
    -->
    <li v-for="todo in todos" v-if="!todo.isComplete">
      {{ todo.name }}
    </li>

列表渲染

  1. v-for与对象

    1
    2
    3
    4
    5
    const myObject = reactive({
      title: "How to do lists in Vue",
      author: "Jane Doe",
      publishedAt: "2016-04-10",
    });
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!-- value:属性值,key:属性名,index:索引 -->
    <li v-for="(value, key, index) in myObject">
    {{ index }}. {{ key }}: {{ value }}
    </li>
    <!-- 渲染结果 -->
    <!-- 0. title: How to do lists in Vue
         1. author: Jane Doe
         2. publishedAt: 2016-04-10 -->
    
    <!-- 你也可以使用 of 作为分隔符来替代 in,这更接近 JavaScript 的迭代器,二者行为基本一致 -->
    <div v-for="item of items"></div>
    <!-- v-for 可以直接接受一个整数值。在这种用例中,会将该模板基于 1...n 的取值范围重复多次。注意此处 n 的初值是从 1 开始而非 0。 -->
    <span v-for="n in 10">{{ n }}</span>

事件处理

  1. 关于vue中事件的传参,@click="showInfo1"类似这样的默认会传过来event参数,可以在函数中接受,showInfo1(event){},注意不能加括号,这样不会传event参数。传多个参数的时候需要注意占位问题。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <!-- 使用特殊的 $event 变量 -->
    <button @click="warn('Form cannot be submitted yet.', $event)">
       Submit
    </button>
    
    <!-- 使用内联箭头函数 -->
    <button @click="(event) => warn('Form cannot be submitted yet.', event)">
       Submit
    </button>
  2. 内联事件修饰符

    • .stop
    • .prevent
    • .self
    • .capture
    • .once
    • .passive
  3. 按键修饰符
    常用按键别名

    • .enter
    • .tab
    • .delete (捕获“Delete”和“Backspace”两个按键)
    • .esc
    • .space
    • .up
    • .down
    • .left
    • .right

    你可以使用以下系统按键修饰符来触发鼠标或键盘事件监听器,只有当按键被按下时才会触发。

    • .ctrl
    • .alt
    • .shift
    • .meta

    .exact 修饰符允许控制触发一个事件所需的确定组合的系统按键修饰符。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <!-- 仅在 `key` 为 `Enter` 时调用 `submit` -->
    <input @keyup.enter="submit" />
    <input @keyup.page-down="onPageDown" />
    <!-- Alt + Enter -->
    <input @keyup.alt.enter="clear" />
    
    <!-- Ctrl + 点击 -->
    <div @click.ctrl="doSomething">Do something</div>
    
    <!-- 当按下 Ctrl 时,即使同时按下 Alt 或 Shift 也会触发 -->
    <button @click.ctrl="onClick">A</button>
    
    <!-- 仅当按下 Ctrl 且未按任何其他键时才会触发 -->
    <button @click.ctrl.exact="onCtrlClick">A</button>
    
    <!-- 仅当没有按下任何系统按键时触发 -->
    <button @click.exact="onClick">A</button>
  4. 鼠标按键修饰符

    • .left
    • .right
    • .middle

表单输入绑定

  1. 收集表单数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    <!--
    我们可以使用 v-model 指令在状态和表单输入之间创建双向绑定。
    -->
    
    <script setup>
    import { ref } from 'vue'
    
    const text = ref('Edit me')
    const checked = ref(true)
    const checkedNames = ref(['Jack'])
    const picked = ref('One')
    const selected = ref('A')
    const multiSelected = ref(['A'])
    </script>
    
    <template>
      <h2>Text Input</h2>
      <input v-model="text"> {{ text }}
    
      <h2>Checkbox</h2>
      <input type="checkbox" id="checkbox" v-model="checked">
      <label for="checkbox">Checked: {{ checked }}</label>
    
      <!--
        多个复选框可以绑定到
        相同的 v-model 数组
      -->
      <h2>Multi Checkbox</h2>
      <input type="checkbox" id="jack" value="Jack" v-model="checkedNames">
      <label for="jack">Jack</label>
      <input type="checkbox" id="john" value="John" v-model="checkedNames">
      <label for="john">John</label>
      <input type="checkbox" id="mike" value="Mike" v-model="checkedNames">
      <label for="mike">Mike</label>
      <p>Checked names: <pre>{{ checkedNames }}</pre></p>
    
      <h2>Radio</h2>
      <input type="radio" id="one" value="One" v-model="picked">
      <label for="one">One</label>
      <br>
      <input type="radio" id="two" value="Two" v-model="picked">
      <label for="two">Two</label>
      <br>
      <span>Picked: {{ picked }}</span>
    
      <h2>Select</h2>
      <!-- 如果 v-model 表达式的初始值不匹配任何一个选择项,<select> 元素会渲染成一个“未选择”的状态。在 iOS 上,这将导致用户无法选择第一项,因为 iOS 在这种情况下不会触发一个 change 事件。因此,我们建议提供一个空值的禁用选项,如上面的例子所示。 -->
      <select v-model="selected">
        <option disabled value="">Please select one</option>
        <option>A</option>
        <option>B</option>
        <option>C</option>
      </select>
      <span>Selected: {{ selected }}</span>
    
      <h2>Multi Select</h2>
      <select v-model="multiSelected" multiple style="width:100px">
        <option>A</option>
        <option>B</option>
        <option>C</option>
      </select>
      <span>Selected: {{ multiSelected }}</span>
    </template>
  2. 复选框

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <input type="checkbox" v-model="toggle" true-value="yes" false-value="no" />
    <input
      type="checkbox"
      v-model="toggle"
      true-value="yes"
      false-value="no" />
    <!-- true-value 和 false-value 是 Vue 特有的 attributes,仅支持和 v-model 配套使用。这里 toggle 属性的值会在选中时被设为 'yes',取消选择时设为 'no'。你同样可以通过 v-bind 将其绑定为其他动态值: -->
    <input
      type="checkbox"
      v-model="toggle"
      :true-value="dynamicTrueValue"
      :false-value="dynamicFalseValue"
    />
  3. 选择器对象

    1
    2
    3
    4
    <select v-model="selected">
    <!-- 内联对象字面量 -->
     <option :value="{ number: 123 }">123</option>
    </select>

生命周期

Vue3中的生命周期选项式和组合式有一些区别。

  • 选项式:

    可以看到,在选项式api中,setup()函数的执行时机最早。

  • 组合式

    在组合式api中移除了createonBeforeCreate这两个钩子,因为,其功能与setup()函数重复。

    其他的声明周期钩子,完整示例请参考声明周期钩子索引

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <script setup>
    import { ref, onServerPrefetch, onMounted } from "vue";
    
    const data = ref(null);
    
    onServerPrefetch(async () => {
      // 组件作为初始请求的一部分被渲染
      // 在服务器上预抓取数据,因为它比在客户端上更快。
      data.value = await fetchOnServer(/* ... */);
    });
    
    onMounted(async () => {
      if (!data.value) {
        // 如果数据在挂载时为空值,这意味着该组件
        // 是在客户端动态渲染的。将转而执行
        // 另一个客户端侧的抓取请求
        data.value = await fetchOnClient(/* ... */);
      }
    });
    </script>

侦听器

对于vue3中的计算属性,我们知道需要确保其只做计算而没有副作用。然而在有些情况下,我们需要在状态变化时执行一些“副作用”:例如更改 DOM,或是根据异步操作的结果去修改另一处的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script setup>
import { ref, watch } from "vue";

const question = ref("");
const answer = ref("Questions usually contain a question mark. ;-)");

// 可以直接侦听一个 ref
watch(question, async (newQuestion, oldQuestion) => {
  if (newQuestion.indexOf("?") > -1) {
    answer.value = "Thinking...";
    try {
      const res = await fetch("https://yesno.wtf/api");
      answer.value = (await res.json()).answer;
    } catch (error) {
      answer.value = "Error! Could not reach the API. " + error;
    }
  }
});
</script>

<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" />
  </p>
  <p>{{ answer }}</p>
</template>
  1. watch 的第一个参数可以是不同形式的“数据源”:它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    const x = ref(0);
    const y = ref(0);
    
    // 单个 ref
    watch(x, (newX) => {
      console.log(`x is ${newX}`);
    });
    
    // getter 函数
    watch(
      () => x.value + y.value,
      (sum) => {
        console.log(`sum of x + y is: ${sum}`);
      }
    );
    
    // 多个来源组成的数组
    watch([x, () => y.value], ([newX, newY]) => {
      console.log(`x is ${newX} and y is ${newY}`);
    });
  2. watch函数的疑难解析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    //情况一:监视 ref 定义的响应式数据
    watch(
      sum,
      (newValue, oldValue) => {
        console.log("sum变化了", newValue, oldValue);
      },
      { immediate: true }
    );
    
    //情况二:监视多个 ref 定义的响应式数据 newValue: 新值的数组,按顺序排列, oldValue:  旧值的数组,按顺序排列
    watch([sum, msg], (newValue, oldValue) => {
      console.log("sum或msg变化了", newValue, oldValue);
    });
    
    /* 情况三:监视 reactive 定义的响应式数据
          若 watch 监视的是 reactive 定义的响应式数据,则无法正确获得 oldValue!!
          若watch监视的是 reactive 定义的响应式数据,则强制开启了深度监视 
    */
    watch(
      person,
      (newValue, oldValue) => {
        console.log("person变化了", newValue, oldValue);
      },
      { immediate: true, deep: false }
    ); //此处的 deep 配置不再奏效
    
    //情况四:监视 reactive 定义的响应式数据中的某个属性
    watch(
      () => person.job,
      (newValue, oldValue) => {
        console.log("person的job变化了", newValue, oldValue);
      },
      { immediate: true, deep: true }
    );
    
    //情况五:监视 reactive 定义的响应式数据中的某些属性
    watch(
      [() => person.job, () => person.name],
      (newValue, oldValue) => {
        console.log("person的job变化了", newValue, oldValue);
      },
      { immediate: true, deep: true }
    );
    
    //特殊情况
    watch(
      () => person.job,
      (newValue, oldValue) => {
        console.log("person的job变化了", newValue, oldValue);
      },
      { deep: true }
    ); // 此处由于监视的是 reactive 是定义的对象中的某个属性,所以 deep 配置有效
  3. watchEffectwatchEffect函数可以降低手动维护依赖列表的负担。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 使用 watch
    const todoId = ref(1);
    const data = ref(null);
    
    watch(
      todoId,
      async () => {
        const response = await fetch(
          `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
        );
        data.value = await response.json();
      },
      { immediate: true }
    );
    
    // 使用 watchEffect
    watchEffect(async () => {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
      );
      data.value = await response.json();
    });

    watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

  4. 回调的触发时机
    默认情况下,用户创建的侦听器回调,都会在 Vue 组件更新之前被调用。这意味着你在侦听器回调中访问的 DOM 将是被 Vue 更新之前的状态。如果想在侦听器回调中能访问被 Vue 更新之后的 DOM,你需要指明 flush: 'post' 选项:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    watch(source, callback, {
      flush: "post",
    });
    
    watchEffect(callback, {
      flush: "post",
    });
    // 后置刷新的 watchEffect() 有个更方便的别名 watchPostEffect():
    import { watchPostEffect } from "vue";
    
    watchPostEffect(() => {
      /* 在 Vue 更新后执行 */
    });
  5. 停止侦听器
    setup()<script setup> 中用同步语句创建的侦听器,会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,你无需关心怎么停止一个侦听器。
    一个关键点是,侦听器必须用同步语句创建:如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏。如下方这个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <script setup>
    import { watchEffect } from "vue";
    
    // 它会自动停止
    watchEffect(() => {});
    
    // ...这个则不会!
    setTimeout(() => {
      watchEffect(() => {});
    }, 100);
    </script>

    要手动停止一个侦听器,请调用 watchwatchEffect 返回的函数:

    1
    2
    3
    4
    const unwatch = watchEffect(() => {});
    
    // ...当该侦听器不再需要时
    unwatch();

    注意,需要异步创建侦听器的情况很少,请尽可能选择同步创建。如果需要等待一些异步数据,你可以使用条件式的侦听逻辑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 需要异步请求得到的数据
    const data = ref(null);
    // 一个异步的函数,用来从 API 获取数据
    async function fetchData() {
      const response = await fetch("https://some-api.com/data");
      data.value = await response.json();
    }
    
    // 在 setup 函数中调用 fetchData 函数
    fetchData();
    watchEffect(() => {
      if (data.value) {
        // 数据加载后执行某些操作...
      }
    });

模板引用

  1. 基本示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    <script setup>
    import { ref, onMounted } from "vue";
    
    // 声明一个 ref 来存放该元素的引用
    // 必须和模板里的 ref 同名
    const input = ref(null);
    
    onMounted(() => {
      input.value.focus();
    });
    </script>
    
    <template>
      <input ref="input" />
    </template>

    注意,你只可以在组件挂载后才能访问模板引用。如果你想在模板中的表达式上访问 input,在初次渲染时会是 null。这是因为在初次渲染前这个元素还不存在呢!
    果你需要侦听一个模板引用 ref 的变化,确保考虑到其值为 null 的情况:

    1
    2
    3
    4
    5
    6
    7
    watchEffect(() => {
      if (input.value) {
        input.value.focus();
      } else {
        // 此时还未挂载,或此元素已经被卸载(例如通过 v-if 控制)
      }
    });
  2. v-for 中的模板引用:当在 v-for 中使用模板引用时,对应的 ref 中包含的值是一个数组,它将在元素被挂载后包含对应整个列表的所有元素(DOM 对象):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <script setup>
    import { ref, onMounted } from "vue";
    
    const list = ref([
      /* ... */
    ]);
    
    const itemRefs = ref([]);
    
    onMounted(() => console.log(itemRefs.value));
    </script>
    
    <template>
      <ul>
        <li v-for="item in list" ref="itemRefs">
          {{ item }}
        </li>
      </ul>
    </template>

    应该注意的是,ref 数组并不保证与源数组相同的顺序。

  3. 函数模板引用
    除了使用字符串值作名字,ref attribute 还可以绑定为一个函数,会在每次组件更新时都被调用。该函数会收到元素引用DOM对象或者组件实例作为其第一个参数:

    1
    2
    <!-- el 为绑定的 DOM 元素 -->
    <input :ref="(el) => { /* 将 el 赋值给一个数据属性或 ref 变量 */ }">

    注意我们这里需要使用动态的 :ref绑定才能够传入一个函数。当绑定的元素被卸载时,函数也会被调用一次,此时的 el 参数会是 null。你当然也可以绑定一个组件方法而不是内联函数。

  4. 组件上的ref
    模板引用也可以被用在一个子组件上。这种情况下引用中获得的值是组件实例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <script setup>
    import { ref, onMounted } from "vue";
    import Child from "./Child.vue";
    
    const child = ref(null);
    
    onMounted(() => {
      // child.value 是 <Child /> 组件的实例
    });
    </script>
    
    <template>
      <Child ref="child" />
    </template>

    如果一个子组件使用的是选项式 API 或没有使用 <script setup>,被引用的组件实例和该子组件的 this 完全一致,这意味着父组件对子组件的每一个属性和方法都有完全的访问权。这使得在父组件和子组件之间创建紧密耦合的实现细节变得很容易,当然也因此,应该只在绝对需要时才使用组件引用。大多数情况下,你应该首先使用标准的 propsemit 接口来实现父子组件交互。

    有一个例外的情况,使用了 <script setup> 的组件是默认私有的:一个父组件无法访问到一个使用了 <script setup> 的子组件中的任何东西,除非子组件在其中通过 defineExpose 宏显式暴露:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <script setup>
    import { ref } from "vue";
    
    const a = 1;
    const b = ref(2);
    
    // 像 defineExpose 这样的编译器宏不需要导入
    defineExpose({
      a,
      b,
    });
    </script>

    当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为 { a: number, b: number } (ref 都会自动解包,和一般的实例一样,因为拿到的this是一个proxy对象,类似reactive对象的解包)。

深入组件

组件注册

一个 Vue 组件在使用前需要先被“注册”,这样 Vue 才能在渲染模板时找到其对应的实现。组件注册有两种方式:全局注册和局部注册。

  1. 全局注册
    基本示例:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    import { createApp } from "vue";
    
    const app = createApp({});
    
    app.component(
      // 注册的名字
      "MyComponent",
      // 组件的实现
      {
        /* ... */
      }
    );
    // 插件形式
    import ImgView from "./ImgView/index.vue";
    import Sku from "./XtxSku/index.vue";
    
    export const componentPlugin = {
      install(app) {
        // app.component('组件名字',组件配置对象)
        app.component("ImgView", ImgView);
        app.component("XtxSku", Sku);
      },
    };

    .component()方法可以被链式调用

    1
    2
    3
    4
    app
      .component("ComponentA", ComponentA)
      .component("ComponentB", ComponentB)
      .component("ComponentC", ComponentC);
  2. 局部注册

    • 组合式:

      1
      2
      3
      4
      5
      6
      7
      <script setup>
      import ComponentA from "./ComponentA.vue";
      </script>
      
      <template>
        <ComponentA />
      </template>
    • 选项式

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      <script>
      import ComponentA from "./ComponentA.js";
      
      export default {
        components: {
          ComponentA,
        },
        setup() {
          // ...
        },
      };
      </script>

      请注意:局部注册的组件在后代组件中并不可用。在这个例子中,ComponentA 注册后仅在当前组件可用,而在任何的子组件或更深层的子组件中都不可用。

Props

一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute(后续详细讨论)。

  1. 采用数组的形式声明

    1
    2
    3
    4
    5
    <script setup>
    const props = defineProps(["foo"]);
    
    console.log(props.foo);
    </script>
  2. 采用对象的形式添加校验

    1
    2
    3
    4
    5
    // 使用 <script setup>
    defineProps({
      title: String,
      likes: Number,
    });
  3. 详细的校验规则

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    defineProps({
      // 基础类型检查
      // (给出 `null` 和 `undefined` 值则会跳过任何类型检查)
      propA: Number,
      // 多种可能的类型
      propB: [String, Number],
      // 必传,且为 String 类型
      propC: {
        type: String,
        required: true,
      },
      // Number 类型的默认值
      propD: {
        type: Number,
        default: 100,
      },
      // 对象类型的默认值
      propE: {
        type: Object,
        // 对象或数组的默认值
        // 必须从一个工厂函数返回。
        // 该函数接收组件所接收到的原始 prop 作为参数。
        default(rawProps) {
          return { message: "hello" };
        },
      },
      // 自定义类型校验函数
      propF: {
        validator(value) {
          // The value must match one of these strings
          return ["success", "warning", "danger"].includes(value);
        },
      },
      // 函数类型的默认值
      propG: {
        type: Function,
        // 不像对象或数组的默认,这不是一个
        // 工厂函数。这会是一个用来作为默认值的函数
        default() {
          return "Default function";
        },
      },
    });

    defineProps() 宏中的参数不可以访问 <script setup> 中定义的其他变量,因为在编译时整个表达式都会被移到外部的函数中。

    一些补充细节:

    • 所有 prop 默认都是可选的,除非声明了required: true

    • Boolean 外的未传递的可选 prop 将会有一个默认值 undefined

    • Boolean 类型的未传递 prop 将被转换为 false。这可以通过为它设置 default 来更改——例如:设置为 default: undefined 将与非布尔类型的 prop 的行为保持一致。

    • 如果声明了 default 值,那么在 prop 的值被解析为 undefined 时,无论 prop 是未被传递还是显式指明的 undefined,都会改为 default 值。

    • prop 的校验失败后,Vue 会抛出一个控制台警告 (在开发模式下)。

    • 如果使用了基于类型的 prop 声明 ,Vue 会尽最大努力在运行时按照 prop 的类型标注进行编译。举例来说,defineProps<{ msg: string }> 会被编译为 { msg: { type: String, required: true }}

    另外,type 也可以是自定义的类或构造函数,Vue 将会通过 instanceof 来检查类型是否匹配。例如下面这个类:

    1
    2
    3
    4
    5
    6
    class Person {
      constructor(firstName, lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
      }
    }

    你可以将其作为一个 prop 的类型:

    1
    2
    3
    defineProps({
      author: Person,
    });
  4. Boolean类型转换
    为了更贴近原生 boolean attributes 的行为,声明为 Boolean 类型的 props 有特别的类型转换规则。以带有如下声明的 <MyComponent> 组件为例:

    1
    2
    3
    4
    5
    <!-- 等同于传入 :disabled="true" -->
    <MyComponent disabled />
    
    <!-- 等同于传入 :disabled="false" -->
    <MyComponent />

    当一个 prop 被声明为允许多种类型时,Boolean 的转换规则也将被应用。然而,当同时允许 StringBoolean 时,有一种边缘情况——只有当 Boolean 出现在 String 之前时,Boolean 转换规则才适用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // disabled 将被转换为 true
    defineProps({
      disabled: [Boolean, Number],
    });
    
    // disabled 将被转换为 true
    defineProps({
      disabled: [Boolean, String],
    });
    
    // disabled 将被转换为 true
    defineProps({
      disabled: [Number, Boolean],
    });
    
    // disabled 将被解析为空字符串 (disabled="")
    defineProps({
      disabled: [String, Boolean],
    });
  5. 单向数据流

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const props = defineProps(["initialCounter"]);
    
    // 计数器只是将 props.initialCounter 作为初始值
    // 像下面这样做就使 prop 和后续更新无关了
    const counter = ref(props.initialCounter);
    
    const props = defineProps(["size"]);
    
    // 该 prop 变更时计算属性也会自动更新
    const normalizedSize = computed(() => props.size.trim().toLowerCase());

组件事件

  1. 触发与监听事件

    1
    2
    3
    4
    5
    6
    <!-- MyComponent -->
    <button @click="$emit('someEvent')">click me</button>
    
    <MyComponent @some-event="callback" />
    <!-- 同样,组件的事件监听器也支持 .once 修饰符: -->
    <MyComponent @some-event.once="callback" />

    像组件与 prop 一样,事件的名字也提供了自动的格式转换。注意这里我们触发了一个以 camelCase 形式命名的事件,但在父组件中可以使用 kebab-case 形式来监听。与 prop 大小写格式一样,在模板中我们也推荐使用 kebab-case 形式来编写监听器。

  2. 事件参数

    1
    2
    3
    function increaseCount(n) {
      count.value += n;
    }
    1
    2
    3
    4
    5
    <button @click="$emit('increaseBy', 1)">
     Increase by 1
    </button>
    <MyButton @increase-by="(n) => (count += n)" />
    <MyButton @increase-by="increaseCount" />
  3. 声明触发的事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <script setup>
    const emit = defineEmits(["inFocus", "submit"]);
    
    function buttonClick() {
      emit("submit");
    }
    </script>
    <!-- 这个 emits 选项和 defineEmits() 宏还支持对象语法,它允许我们对触发事件的参数进行验证: -->
    <script setup lang="ts">
    const emit = defineEmits<{
      (e: "change", id: number): void;
      (e: "update", value: string): void;
    }>();
    </script>

    如果一个原生事件的名字 (例如 click) 被定义在 emits 选项中,则监听器只会监听组件触发的 click 事件而不会再响应原生的 click 事件。

  4. 事件校验
    和对 props 添加类型校验的方式类似,所有触发的事件也可以使用对象形式来描述。
    要为事件添加校验,那么事件可以被赋值为一个函数,接受的参数就是抛出事件时传入 emit 的内容,返回一个布尔值来表明事件是否合法。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <script setup>
    const emit = defineEmits({
      // 没有校验
      click: null,
    
      // 校验 submit 事件
      submit: ({ email, password }) => {
        if (email && password) {
          return true;
        } else {
          console.warn("Invalid submit event payload!");
          return false;
        }
      },
    });
    
    function submitForm(email, password) {
      emit("submit", { email, password });
    }
    </script>

组件 v-model

  1. 当使用在一个组件上时,v-model 会被展开为如下的形式:

    1
    2
    3
    4
    <CustomInput
      :model-value="searchText"
      @update:model-value="(newValue) => (searchText = newValue)"
    />

    要让这个例子实际工作起来,<CustomInput> 组件内部需要做两件事:

    将内部原生 <input> 元素的 value attribute 绑定到 modelValue prop
    当原生的 input 事件触发时,触发一个携带了新值的 update:modelValue 自定义事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!-- CustomInput.vue -->
    <script setup>
    defineProps(["modelValue"]);
    defineEmits(["update:modelValue"]);
    </script>
    
    <template>
      <input
        :value="modelValue"
        @input="$emit('update:modelValue', $event.target.value)"
      />
    </template>

    另一种在组件内实现 v-model 的方式是使用一个可写的,同时具有 gettersettercomputed 属性。get 方法需返回 modelValue prop,而 set 方法需触发相应的事件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <!-- CustomInput.vue -->
    <script setup>
    import { computed } from "vue";
    
    const props = defineProps(["modelValue"]);
    const emit = defineEmits(["update:modelValue"]);
    
    const value = computed({
      get() {
        return props.modelValue;
      },
      set(value) {
        emit("update:modelValue", value);
      },
    });
    </script>
    
    <template>
      <input v-model="value" />
    </template>

    HTML 中的原生表单元素的v-model Vue对其的实现与组件不同,涉及到输入开始与结束的事件,所以在IME的输入法中与input事件的行为有所不同。

  2. v-model的参数,给propsemit事件添加名字
    默认情况下,v-model 在组件上都是使用 modelValue 作为 prop,并以 update:modelValue 作为对应的事件。我们可以通过给 v-model 指定一个参数来更改这些名字:

    1
    <UserName v-model:first-name="first" v-model:last-name="last" />
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    <script setup>
    defineProps({
      firstName: String,
      lastName: String,
    });
    
    defineEmits(["update:firstName", "update:lastName"]);
    </script>
    
    <template>
      <input
        type="text"
        :value="firstName"
        @input="$emit('update:firstName', $event.target.value)"
      />
      <input
        type="text"
        :value="lastName"
        @input="$emit('update:lastName', $event.target.value)"
      />
    </template>
  3. 处理v-model修饰符
    在某些场景下,你可能想要一个自定义组件的 v-model 支持自定义的修饰符。我们来创建一个自定义的修饰符 capitalize,它会自动将 v-model 绑定输入的字符串值第一个字母转为大写:

    1
    <MyComponent v-model.capitalize="myText" />

    组件的 v-model 上所添加的修饰符,可以通过 modelModifiers prop 在组件内访问到。在下面的组件中,我们声明了 modelModifiers 这个 prop,它的默认值是一个空对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <script setup>
    const props = defineProps({
      modelValue: String,
      modelModifiers: { default: () => ({}) },
    });
    defineEmits(["update:modelValue"]);
    
    console.log(props.modelModifiers); // { capitalize: true }
    </script>
    
    <template>
      <input
        type="text"
        :value="modelValue"
        @input="$emit('update:modelValue', $event.target.value)"
      />
    </template>

    注意这里组件的 modelModifiers prop 包含了 capitalize 且其值为 true,因为它在模板中的 v-model 绑定 v-model.capitalize="myText" 上被使用了。

    有了这个 prop,我们就可以检查 modelModifiers 对象的键,并编写一个处理函数来改变抛出的值。在下面的代码里,我们就是在每次 <input /> 元素触发 input 事件时将值的首字母大写:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <script setup>
    const props = defineProps({
      modelValue: String,
      modelModifiers: { default: () => ({}) },
    });
    
    const emit = defineEmits(["update:modelValue"]);
    
    function emitValue(e) {
      let value = e.target.value;
      if (props.modelModifiers.capitalize) {
        value = value.charAt(0).toUpperCase() + value.slice(1);
      }
      emit("update:modelValue", value);
    }
    </script>
    
    <template>
      <input type="text" :value="modelValue" @input="emitValue" />
    </template>
  4. 带参数的 v-model 修饰符
    对于又有参数又有修饰符的 v-model 绑定,生成的 prop 名将是 arg + "Modifiers"。举例来说:

    1
    <MyComponent v-model:title.capitalize="myText">

    相应的声明应该是:

    1
    2
    3
    4
    const props = defineProps(["title", "titleModifiers"]);
    defineEmits(["update:title"]);
    
    console.log(props.titleModifiers); // { capitalize: true }

    这里是另一个例子,展示了如何在使用多个不同参数的 v-model 时使用修饰符:

    1
    2
    3
    4
    <UserName
      v-model:first-name.capitalize="first"
      v-model:last-name.uppercase="last"
    />
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <script setup>
    const props = defineProps({
      firstName: String,
      lastName: String,
      firstNameModifiers: { default: () => ({}) },
      lastNameModifiers: { default: () => ({}) },
    });
    defineEmits(["update:firstName", "update:lastName"]);
    
    console.log(props.firstNameModifiers); // { capitalize: true }
    console.log(props.lastNameModifiers); // { uppercase: true}
    </script>

透传 Attributes

  1. Attributes 继承
    “透传 attribute”指的是传递给一个组件,却没有被该组件声明为 propsemitsattribute 或者 v-on 事件监听器。最常见的例子就是 classstyleid

    1
    2
    3
    4
    5
    6
    <!-- <MyButton> 的模板 -->
    <button>click me</button>
    <!-- 一个父组件使用了这个组件,并且传入了 class: -->
    <MyButton class="large" />
    <!-- 最后渲染出的 DOM 结果是: -->
    <button class="large">click me</button>

    这里,<MyButton> 并没有将 class 声明为一个它所接受的 prop,所以 class 被视作透传 attribute,自动透传到了 <MyButton> 的根元素上。

  2. classstyle 的合并

    1
    2
    3
    4
    <!-- <MyButton> 的模板 -->
    <button class="btn">click me</button>
    <!-- 则最后渲染出的 DOM 结果会变成 -->
    <button class="btn large">click me</button>
  3. v-on 监听器继承
    同样的规则也适用于 v-on 事件监听器:

    1
    <MyButton @click="onClick" />

    click 监听器会被添加到 <MyButton> 的根元素,即那个原生的 <button> 元素之上。当原生的 <button> 被点击,会触发父组件的 onClick 方法。同样的,如果原生 button 元素自身也通过 v-on 绑定了一个事件监听器,则这个监听器和从父组件继承的监听器都会被触发。

  4. 深层组件继承
    有些情况下一个组件会在根节点上渲染另一个组件。例如,我们重构一下 <MyButton>,让它在根节点上渲染 <BaseButton>

    1
    2
    <!-- <MyButton/> 的模板,只是渲染另一个组件 -->
    <BaseButton />

    此时 <MyButton> 接收的透传 attribute 会直接继续传给 <BaseButton>

    请注意:

    1. 透传的 attribute 不会包含 <MyButton> 上声明过的 props 或是针对 emits 声明事件的 v-on 侦听函数,换句话说,声明过的 props 和侦听函数被 <MyButton>“消费”了。

    2. 透传的 attribute 若符合声明,也可以作为 props 传入 <BaseButton>

  5. 禁用 Attributes 继承

    1
    2
    3
    4
    5
    6
    <script setup>
    defineOptions({
      inheritAttrs: false,
    });
    // ...setup 逻辑
    </script>

    最常见的需要禁用 attribute 继承的场景就是 attribute 需要应用在根节点以外的其他元素上。通过设置 inheritAttrs 选项为 false,你可以完全控制透传进来的 attribute 被如何使用。

    这些透传进来的 attribute 可以在模板的表达式中直接用 $attrs 访问到。

    1
    <span>Fallthrough attribute: {{ $attrs }}</span>

    这个 $attrs 对象包含了除组件所声明的 propsemits 之外的所有其他 attribute,例如 classstylev-on 监听器等等。

    有几点需要注意:

    props 有所不同,透传 attributesJavaScript 中保留了它们原始的大小写,所以像 foo-bar 这样的一个 attribute 需要通过 $attrs['foo-bar'] 来访问。

    @click 这样的一个 v-on 事件监听器将在此对象下被暴露为一个函数 $attrs.onClick

    现在我们要再次使用一下之前小节中的 <MyButton> 组件例子。有时候我们可能为了样式,需要在 <button> 元素外包装一层 <div>

    1
    2
    3
    <div class="btn-wrapper">
      <button class="btn">click me</button>
    </div>

    我们想要所有像 classv-on 监听器这样的透传 attribute 都应用在内部的 <button> 上而不是外层的 <div> 上。我们可以通过设定 inheritAttrs: false 和使用 v-bind="$attrs" 来实现:

    1
    2
    3
    <div class="btn-wrapper">
      <button class="btn" v-bind="$attrs">click me</button>
    </div>
  6. 多根节点的 Attributes 继承
    和单根节点组件有所不同,有着多个根节点的组件没有自动 attribute 透传行为。如果 $attrs 没有被显式绑定,将会抛出一个运行时警告。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <CustomLayout id="custom-layout" @click="changeValue" />
    <!-- 如果 <CustomLayout> 有下面这样的多根节点模板,由于 Vue 不知道要将 attribute 透传到哪里,所以会抛出一个警告。 -->
    <header>...</header>
    <main>...</main>
    <footer>...</footer>
    <!-- 如果 $attrs 被显式绑定,则不会有警告: -->
    <header>...</header>
    <main v-bind="$attrs">...</main>
    <footer>...</footer>
  7. setup中访问Attributes

    1
    2
    3
    4
    5
    <script setup>
    import { useAttrs } from "vue";
    
    const attrs = useAttrs();
    </script>

    需要注意的是,虽然这里的 attrs 对象总是反映为最新的透传 attribute,但它并不是响应式的 (考虑到性能因素),即不像props一样随着父元素的更新而更新。你不能通过侦听器去监听它的变化。如果你需要响应性,可以使用 prop。或者你也可以使用 onUpdated() 使得在每次更新时结合最新的 attrs 执行副作用。

插槽

  1. 插槽内容可以是元素,文本甚至组件
  2. 插槽的默认内容,默认显示,但提供了插槽的内容则会取代默认内容

    1
    2
    3
    4
    5
    6
    <button type="submit">
      <slot>
        <!-- 默认内容 -->
        Submit
      </slot>
    </button>
  3. 具名插槽

    没有提供name<slot> 出口会隐式地命名为“default”。

    v-slot 有对应的简写 #,因此 <template v-slot:header> 可以简写为 <template #header>。其意思就是“将这部分模板片段传入子组件的 header 插槽中”

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    <div class="container">
      <header>
        <slot name="header"></slot>
      </header>
      <main>
        <slot></slot>
      </main>
      <footer>
        <slot name="footer"></slot>
      </footer>
    </div>
    
    <BaseLayout>
      <template v-slot:header>
        <!-- header 插槽的内容放这里 -->
      </template>
    </BaseLayout>
    
    <BaseLayout>
      <template #header>
        <h1>Here might be a page title</h1>
      </template>
    
      <!-- 隐式的默认插槽 -->
      <p>A paragraph for the main content.</p>
      <p>And another one.</p>
    
      <template #footer>
        <p>Here's some contact info</p>
      </template>
    </BaseLayout>
  4. 动态插槽

    1
    2
    3
    4
    5
    6
    <base-layout>
      <template v-slot:[dynamicSlotName]> ... </template>
    
      <!-- 缩写为 -->
      <template #[dynamicSlotName]> ... </template>
    </base-layout>
  5. 作用域插槽

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    <MyComponent>
      <template #header="headerProps"> {{ headerProps }} </template>
    
      <template #default="defaultProps"> {{ defaultProps }} </template>
    
      <template #footer="footerProps"> {{ footerProps }} </template>
    </MyComponent>
    
    <!-- 该模板无法编译,message仅默认插槽可见 -->
    <template>
      <MyComponent v-slot="{ message }">
        <p>{{ message }}</p>
        <template #footer>
          <!-- message 属于默认插槽,此处不可用 -->
          <p>{{ message }}</p>
        </template>
      </MyComponent>
    </template>
    
    <slot name="header" message="hello"></slot>
    
    <template>
    <!-- 显示声明默认插槽作用域 -->
      <MyComponent>
        <!-- 使用显式的默认插槽 -->
        <template #default="{ message }">
          <p>{{ message }}</p>
        </template>
        <template #footer>
          <p>Here's some contact info</p>
        </template>
      </MyComponent>
    </template>

依赖注入

  1. 示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    <script setup>
    import { provide } from "vue";
    
    provide(/* 注入名 */ "message", /* 值 */ "hello!");
    
    // 全局注入
    import { createApp } from "vue";
    
    const app = createApp({});
    
    app.provide(/* 注入名 */ "message", /* 值 */ "hello!");
    </script>
    
    <!-- 接收 -->
    <script setup>
    import { inject } from "vue";
    
    const message = inject("message");
    </script>
  2. 默认值

    1
    2
    3
    // 如果没有祖先组件提供 "message"
    // `value` 会是 "这是默认值"
    const value = inject("message", "这是默认值");

    在一些场景中,默认值可能需要通过调用一个函数或初始化一个类来取得。为了避免在用不到默认值的情况下进行不必要的计算或产生副作用,我们可以使用工厂函数来创建默认值:

    1
    const value = inject("key", () => new ExpensiveClass(), true);

    第三个参数表示默认值应该被当作一个工厂函数,即返回一个函数,按需调用。

  3. 如果你想确保提供的数据不能被注入方的组件更改,你可以使用 readonly() 来包装提供的值。

    1
    2
    3
    4
    5
    6
    <script setup>
    import { ref, provide, readonly } from "vue";
    
    const count = ref(0);
    provide("read-only-count", readonly(count));
    </script>

    provide传递ref对象不会发生解包。

异步组件

  1. 基本用法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import { defineAsyncComponent } from "vue";
    
    const AsyncComp = defineAsyncComponent(() => {
      return new Promise((resolve, reject) => {
        // ...从服务器获取组件
        resolve(/* 获取到的组件 */);
      });
    });
    // ... 像使用其他一般组件一样使用 `AsyncComp`
    
    import { defineAsyncComponent } from "vue";
    
    const AsyncComp = defineAsyncComponent(() =>
      import("./components/MyComponent.vue")
    );

    最后得到的 AsyncComp 是一个外层包装过的组件,仅在页面需要它渲染时才会调用加载内部实际组件的函数。它会将接收到的 props 和插槽传给内部组件,所以你可以使用这个异步的包装组件无缝地替换原始组件,同时实现延迟加载。

  2. 全局注册和父组件定义

    1
    2
    3
    4
    app.component(
      "MyComponent",
      defineAsyncComponent(() => import("./components/MyComponent.vue"))
    );
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <script setup>
    import { defineAsyncComponent } from "vue";
    
    const AdminPage = defineAsyncComponent(() =>
      import("./components/AdminPageComponent.vue")
    );
    </script>
    
    <template>
      <AdminPage />
    </template>
  3. 加载与错误状态
    异步操作不可避免地会涉及到加载和错误状态,因此 defineAsyncComponent() 也支持在高级选项中处理这些状态:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    const AsyncComp = defineAsyncComponent({
      // 加载函数
      loader: () => import("./Foo.vue"),
    
      // 加载异步组件时使用的组件
      loadingComponent: LoadingComponent,
      // 展示加载组件前的延迟时间,默认为 200ms
      delay: 200,
    
      // 加载失败后展示的组件
      errorComponent: ErrorComponent,
      // 如果提供了一个 timeout 时间限制,并超时了
      // 也会显示这里配置的报错组件,默认值是:Infinity
      timeout: 3000,
    });

    如果提供了一个加载组件,它将在内部组件加载时先行显示。在加载组件显示之前有一个默认的 200ms 延迟——这是因为在网络状况较好时,加载完成得很快,加载组件和最终组件之间的替换太快可能产生闪烁,反而影响用户感受。

    如果提供了一个报错组件,则它会在加载器函数返回的 Promise 抛错时被渲染。你还可以指定一个超时时间,在请求耗时超过指定时间时也会渲染报错组件。

逻辑复用

组合式函数

Vue 的组合式 API 逻辑服用很大程度上借鉴了React Hooks的使用,核心目的为将一个函数或者逻辑抽离到一个文件中形成复用。

  1. 异步状态示例

    • App.vue

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      <script setup>
      import { ref, computed } from "vue";
      import { useFetch } from "./useFetch.js";
      
      const baseUrl = "https://jsonplaceholder.typicode.com/todos/";
      const id = ref("1");
      const url = computed(() => baseUrl + id.value);
      
      const { data, error, retry } = useFetch(url);
      </script>
      
      <template>
        Load post id:
        <button v-for="i in 5" @click="id = i">{{ i }}</button>
      
        <div v-if="error">
          <p>Oops! Error encountered: {{ error.message }}</p>
          <button @click="retry">Retry</button>
        </div>
        <div v-else-if="data">
          Data loaded:
          <pre>{{ data }}</pre>
        </div>
        <div v-else>Loading...</div>
      </template>
    • useFetch.js

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      import { ref, watchEffect, toValue } from "vue";
      
      export function useFetch(url) {
        const data = ref(null);
        const error = ref(null);
      
        watchEffect(async () => {
          // reset state before fetching..
          data.value = null;
          error.value = null;
      
          // resolve the url value synchronously so it's tracked as a
          // dependency by watchEffect()
          const urlValue = toValue(url);
      
          try {
            // artificial delay / random error
            await timeout();
            // unref() will return the ref value if it's a ref
            // otherwise the value will be returned as-is
            const res = await fetch(urlValue);
            data.value = await res.json();
          } catch (e) {
            error.value = e;
          }
        });
      
        return { data, error };
      }
      
      // artificial delay
      function timeout() {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            if (Math.random() > 0.3) {
              resolve();
            } else {
              reject(new Error("Random Error"));
            }
          }, 300);
        });
      }

      toValue() 是一个在 3.3 版本中新增的 API。它的设计目的是将 refgetter 规范化为值。如果参数是 ref,它会返回 ref 的值;如果参数是函数,它会调用函数并返回其返回值。否则,它会原样返回参数。它的工作方式类似于 unref(),但对函数有特殊处理。

  2. 约定与最佳实践

    • 命名:组合式函数约定用驼峰命名法命名,并以 use 作为开头。
    • 输入参数:即便不依赖于 refgetter 的响应性,组合式函数也可以接收它们作为参数。如果你正在编写一个可能被其他开发者使用的组合式函数,最好处理一下输入参数是 refgetter 而非原始值的情况。可以利用 toValue() 工具函数来实现:

      1
      2
      3
      4
      5
      6
      7
      8
      import { toValue } from "vue";
      
      function useFeature(maybeRefOrGetter) {
        // 如果 maybeRefOrGetter 是一个 ref 或 getter,
        // 将返回它的规范化值。
        // 否则原样返回。
        const value = toValue(maybeRefOrGetter);
      }

      如果你的组合式函数在输入参数是 refgetter 的情况下创建了响应式 effect,为了让它能够被正确追踪,请确保要么使用 watch() 显式地监视 refgetter,要么在 watchEffect() 中调用 toValue(),以确保能在await之前正确追踪依赖。

    • 返回值:你可能已经注意到了,我们一直在组合式函数中使用 ref() 而不是 reactive()。我们推荐的约定是组合式函数始终返回一个包含多个 ref 的普通的非响应式对象,这样该对象在组件中被解构为 ref 之后仍可以保持响应性:

      1
      2
      // x 和 y 是两个 ref
      const { x, y } = useMouse();

      从组合式函数返回一个响应式对象会导致在对象解构过程中丢失与组合式函数内状态的响应性连接。与之相反,ref 则可以维持这一响应性连接。
      如果你更希望以对象属性的形式来使用组合式函数中返回的状态,你可以将返回的对象用 reactive() 包装一次,这样其中的 ref 会被自动解包,例如:

      1
      2
      3
      const mouse = reactive(useMouse());
      // mouse.x 链接到了原来的 x ref
      console.log(mouse.x);
      1
      Mouse position is at: {{ mouse.x }}, {{ mouse.y }}
    • 副作用

      在组合式函数中的确可以执行副作用 (例如:添加 DOM 事件监听器或者请求数据),但请注意以下规则:
      如果你的应用用到了服务端渲染 (SSR),请确保在组件挂载后才调用的生命周期钩子中执行 DOM 相关的副作用,例如:onMounted()。这些钩子仅会在浏览器中被调用,因此可以确保能访问到 DOM
      确保在 onUnmounted() 时清理副作用。举例来说,如果一个组合式函数设置了一个事件监听器,它就应该在 onUnmounted() 中被移除 (就像我们在 useMouse() 示例中看到的一样)。当然也可以像之前的 useEventListener() 示例那样,使用一个组合式函数来自动帮你做这些事。

    • 使用限制

      组合式函数只能在 <script setup>setup() 钩子中被调用。在这些上下文中,它们也只能被同步调用。在某些情况下,你也可以在像 onMounted() 这样的生命周期钩子中调用它们。

      这些限制很重要,因为这些是 Vue 用于确定当前活跃的组件实例的上下文。访问活跃的组件实例很有必要,这样才能:

      1. 将生命周期钩子注册到该组件实例上。

      2. 将计算属性和监听器注册到该组件实例上,以便在该组件被卸载时停止监听,避免内存泄漏。

  3. 和无渲染组件的对比
    我们推荐在纯逻辑复用时使用组合式函数,在需要同时复用逻辑和视图布局时使用无渲染组件。

    插件无渲染组件的示例

    如果我们将这个概念拓展一下,可以想象的是,一些组件可能只包括了逻辑而不需要自己渲染内容,视图输出通过作用域插槽全权交给了消费者组件。我们将这种类型的组件称为无渲染组件

    1. App.vue

      1
      2
      3
      4
      5
      6
      7
      8
      9
      <script setup>
      import MouseTracker from "./MouseTracker.vue";
      </script>
      
      <template>
        <MouseTracker v-slot="{ x, y }">
          Mouse is at: {{ x }}, {{ y }}
        </MouseTracker>
      </template>
    2. MouseTracker.vue

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      <script setup>
      import { ref, onMounted, onUnmounted } from "vue";
      
      const x = ref(0);
      const y = ref(0);
      
      const update = (e) => {
        x.value = e.pageX;
        y.value = e.pageY;
      };
      
      onMounted(() => window.addEventListener("mousemove", update));
      onUnmounted(() => window.removeEventListener("mousemove", update));
      </script>
      
      <template>
        <slot :x="x" :y="y" />
      </template>

自定义指令

基本使用

  1. <script setup>中:在<script setup> 中,任何以 v 开头的驼峰式命名的变量都可以被用作一个自定义指令。在上面的例子中,vFocus 即可以在模板中以 v-focus 的形式使用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <script setup>
    // 在模板中启用 v-focus
    // 假设你还未点击页面中的其他地方,那么上面这个 input 元素应该会被自动聚焦。该指令比 autofocus attribute 更有用,因为它不仅仅可以在页面加载完成后生效,还可以在 Vue 动态插入元素后生效。
    const vFocus = {
      mounted: (el) => el.focus(),
    };
    </script>
    
    <template>
      <input v-focus />
    </template>
  2. 选项式api:在没有使用 <script setup> 的情况下,自定义指令需要通过 directives 选项注册:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    export default {
      setup() {
        /*...*/
      },
      directives: {
        // 在模板中启用 v-focus
        focus: {
          /* ... */
        },
      },
    };
  3. 全局注册

    1
    2
    3
    4
    5
    6
    const app = createApp({});
    
    // 使 v-focus 在所有组件中都可用
    app.directive("focus", {
      /* ... */
    });

    只有当所需功能只能通过直接的 DOM 操作来实现时,才应该使用自定义指令。其他情况下应该尽可能地使用 v-bind 这样的内置指令来声明式地使用模板,这样更高效,也对服务端渲染更友好。

指令钩子

  1. 基本使用

    一个指令的定义对象可以提供几种钩子函数 (都是可选的):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    const myDirective = {
      // 在绑定元素的 attribute 前
      // 或事件监听器应用前调用
      created(el, binding, vnode, prevVnode) {
        // 下面会介绍各个参数的细节
      },
      // 在元素被插入到 DOM 前调用
      beforeMount(el, binding, vnode, prevVnode) {},
      // 在绑定元素的父组件
      // 及他自己的所有子节点都挂载完成后调用
      mounted(el, binding, vnode, prevVnode) {},
      // 绑定元素的父组件更新前调用
      beforeUpdate(el, binding, vnode, prevVnode) {},
      // 在绑定元素的父组件
      // 及他自己的所有子节点都更新后调用
      updated(el, binding, vnode, prevVnode) {},
      // 绑定元素的父组件卸载前调用
      beforeUnmount(el, binding, vnode, prevVnode) {},
      // 绑定元素的父组件卸载后调用
      unmounted(el, binding, vnode, prevVnode) {},
    };
  2. 钩子参数
    指令的钩子会传递以下几种参数:

    • el:指令绑定到的元素。这可以用于直接操作 DOM

    • binding:一个对象,包含以下属性。

      • value:传递给指令的值。例如在 v-my-directive="1 + 1" 中,值是 2。
      • oldValue:之前的值,仅在 beforeUpdateupdated 中可用。无论值是否更改,它都可用。
      • arg:传递给指令的参数 (如果有的话)。例如在 v-my-directive:foo 中,参数是 "foo"
      • modifiers:一个包含修饰符的对象 (如果有的话)。例如在 v-my-directive.foo.bar 中,修饰符对象是 { foo: true, bar: true }
      • instance:使用该指令的组件实例。
      • dir:指令的定义对象。
    • vnode:代表绑定元素的底层 VNode

    • prevNode:代表之前的渲染中指令所绑定元素的 VNode。仅在 beforeUpdateupdated 钩子中可用。

    举例来说,像下面这样使用指令:

    1
    <div v-example:foo.bar="baz">

    binding参数是一个这样的对象

    1
    2
    3
    4
    5
    6
    {
      arg: 'foo',
      modifiers: { bar: true },
      value: /* `baz` 的值 */,
      oldValue: /* 上一次更新时 `baz` 的值 */
    }

    和内置指令类似,自定义指令的参数也可以是动态的。举例来说:

    1
    2
    <div v-example:[arg]="value"></div>
    <!-- 这里指令的参数会基于组件的 arg 数据属性响应式地更新。 -->

    除了 el 外,其他参数都是只读的,不要更改它们。若你需要在不同的钩子间共享信息,推荐通过元素的 dataset attribute 实现。

简化形式

对于自定义指令来说,一个很常见的情况是仅仅需要在 mountedupdated 上实现相同的行为,除此之外并不需要其他钩子。这种情况下我们可以直接用一个函数来定义指令,如下所示:

1
2
3
<div v-color="color"></div>
<!-- 如果你的指令需要多个值,你可以向它传递一个 JavaScript 对象字面量。别忘了,指令也可以接收任何合法的 JavaScript 表达式。 -->
<div v-demo="{ color: 'white', text: 'hello!' }"></div>
1
2
3
4
5
6
7
8
9
app.directive("color", (el, binding) => {
  // 这会在 `mounted` 和 `updated` 时都调用
  el.style.color = binding.value;
});

app.directive("demo", (el, binding) => {
  console.log(binding.value.color); // => "white"
  console.log(binding.value.text); // => "hello!"
});

在组件上使用

当在组件上使用自定义指令时,它会始终应用于组件的根节点,和透传 attributes 类似。

1
2
3
4
5
6
7
8
<MyComponent v-demo="test" />

<!-- MyComponent 的模板 -->

<div>
<!-- v-demo 指令会被应用在此处 -->
  <span>My component content</span>
</div>

需要注意的是组件可能含有多个根节点。当应用到一个多根组件时,指令将会被忽略且抛出一个警告。和 attribute 不同,指令不能通过 v-bind="$attrs" 来传递给一个不同的元素。总的来说,不推荐在组件上使用自定义指令。

查看使用自定义指令实现图片懒加载的部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useIntersectionObserver } from "@vueuse/core";

export const lazyPlugin = {
  install(app) {
    app.directive("img-lazy", {
      mounted(el, binding) {
        const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
          if (isIntersecting) {
            el.src = binding.value;
            stop();
          }
        });
      },
    });
  },
};

插件

  1. 基本使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import { createApp } from "vue";
    
    const app = createApp({});
    
    app.use(myPlugin, {
      /* 可选的选项 */
    });
    
    const myPlugin = {
      install(app, options) {
        // 配置此应用
      },
    };

    插件没有严格定义的使用范围,但是插件发挥作用的常见场景主要包括以下几种:

    1. 通过 app.component()app.directive() 注册一到多个全局组件或自定义指令。

    2. 通过 app.provide() 使一个资源可被注入进整个应用。

    3. app.config.globalProperties 中添加一些全局实例属性或方法

    4. 一个可能上述三种都包含了的功能库 (例如 vue-router)。

  2. 插件示例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // plugins/i18n.js
    export default {
      install: (app, options) => {
        // 注入一个全局可用的 $translate() 方法
        app.config.globalProperties.$translate = (key) => {
          // 获取 `options` 对象的深层属性,即选择对应的语言读取对象,层层读入
          // 使用 `key` 作为索引
          return key.split(".").reduce((o, i) => {
            if (o) return o[i];
          }, options);
        };
      },
    };
    
    // main.js
    import i18nPlugin from "./plugins/i18n";
    
    app.use(i18nPlugin, {
      greetings: {
        hello: "Bonjour!",
      },
    });

内置组件

Transition

  1. 基本使用

    1
    2
    3
    4
    <button @click="show = !show">Toggle</button>
    <Transition>
       <p v-if="show">hello</p>
    </Transition>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /* 下面我们会解释这些 class 是做什么的 */
    .v-enter-active,
    .v-leave-active {
      transition: opacity 0.5s ease;
    }
    
    .v-enter-from,
    .v-leave-to {
      opacity: 0;
    }
    1
    2
    3
    4
    <button @click="show = !show">Toggle</button>
    <Transition name="slide-fade">
      <p v-if="show">hello</p>
    </Transition>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /*
       进入和离开动画可以使用不同
       持续时间和速度曲线。
     */
    .slide-fade-enter-active {
      transition: all 0.3s ease-out;
    }
    
    .slide-fade-leave-active {
      transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1);
    }
    
    .slide-fade-enter-from,
    .slide-fade-leave-to {
      transform: translateX(20px);
      opacity: 0;
    }

    <Transition> 仅支持单个元素或组件作为其插槽内容。如果内容是一个组件,这个组件必须仅有一个根元素。

  2. 触发时机

    • v-if 所触发的切换
    • v-show 所触发的切换
    • 由特殊元素 <component> 切换的动态组件
    • 改变特殊的 key 属性
  3. CSS过渡class

  4. 为过渡效果命名

    1
    2
    3
    <Transition name="fade">
      ...
    </Transition>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    .fade-enter-active,
    .fade-leave-active {
      transition: opacity 0.5s ease;
    }
    
    .fade-enter-from,
    .fade-leave-to {
      opacity: 0;
    }
  5. CSSanimation

    1
    2
    3
    4
    5
    <Transition name="bounce">
      <p v-if="show" style="text-align: center;">
        Hello here is some bouncy text!
      </p>
    </Transition>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    .bounce-enter-active {
      animation: bounce-in 0.5s;
    }
    .bounce-leave-active {
      animation: bounce-in 0.5s reverse;
    }
    @keyframes bounce-in {
      0% {
        transform: scale(0);
      }
      50% {
        transform: scale(1.25);
      }
      100% {
        transform: scale(1);
      }
    }
  6. 自定义过渡class
    你也可以向 <Transition> 传递以下的 props 来指定自定义的过渡 class

    • enter-from-class
    • enter-active-class
    • enter-to-class
    • leave-from-class
    • leave-active-class
    • leave-to-class

    你传入的这些 class 会覆盖相应阶段的默认 class 名。这个功能在你想要在 Vue 的动画机制下集成其他的第三方 CSS 动画库时非常有用,比如 Animate.css

  7. 同时使用transitionanimation
    然而在某些场景中,你或许想要在同一个元素上同时使用它们两个。举例来说,Vue 触发了一个 CSS 动画,同时鼠标悬停触发另一个 CSS 过渡。此时你需要显式地传入 type prop 来声明,告诉 Vue 需要关心哪种类型,传入的值是 animationtransition:即只关心一个。

    1
    <Transition type="animation">...</Transition>
  8. 深层级过渡与显式过渡时长
    默认情况下,<Transition> 组件会通过监听过渡根元素上的第一个 transitionend 或者 animationend 事件来尝试自动判断过渡何时结束。而在嵌套的过渡中,期望的行为应该是等待所有内部元素的过渡完成。

    在这种情况下,你可以通过向 <Transition> 组件传入 duration prop 来显式指定过渡的持续时间 (以毫秒为单位)。总持续时间应该匹配延迟加上内部元素的过渡持续时间:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <Transition :duration="550">
    
      <div v-if="show" class="outer">
        <div class="inner">
          Hello
        </div>
      </div>
    </Transition>
    <!-- 如果有必要的话,你也可以用对象的形式传入,分开指定进入和离开所需的时间: -->
    <Transition :duration="{ enter: 500, leave: 800 }">...</Transition>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    /* 应用于嵌套元素的规则 */
    .nested-enter-active .inner,
    .nested-leave-active .inner {
      transition-delay: 0.25s;
      transition: all 0.3s ease-in-out;
    }
    
    .nested-enter-from .inner,
    .nested-leave-to .inner {
      transform: translateX(30px);
      opacity: 0;
    }
    
    /* ... 省略了其他必要的 CSS */
  9. JS 钩子
    你可以通过监听 <Transition> 组件事件的方式在过渡过程中挂上钩子函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <Transition
      @before-enter="onBeforeEnter"
      @enter="onEnter"
      @after-enter="onAfterEnter"
      @enter-cancelled="onEnterCancelled"
      @before-leave="onBeforeLeave"
      @leave="onLeave"
      @after-leave="onAfterLeave"
      @leave-cancelled="onLeaveCancelled"
    >
      <!-- ... -->
    </Transition>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    // 在元素被插入到 DOM 之前被调用
    // 用这个来设置元素的 "enter-from" 状态
    function onBeforeEnter(el) {}
    
    // 在元素被插入到 DOM 之后的下一帧被调用
    // 用这个来开始进入动画
    function onEnter(el, done) {
      // 调用回调函数 done 表示过渡结束
      // 如果与 CSS 结合使用,则这个回调是可选参数
      done();
    }
    
    // 当进入过渡完成时调用。
    function onAfterEnter(el) {}
    
    // 当进入过渡在完成之前被取消时调用
    function onEnterCancelled(el) {}
    
    // 在 leave 钩子之前调用
    // 大多数时候,你应该只会用到 leave 钩子
    function onBeforeLeave(el) {}
    
    // 在离开过渡开始时调用
    // 用这个来开始离开动画
    function onLeave(el, done) {
      // 调用回调函数 done 表示过渡结束
      // 如果与 CSS 结合使用,则这个回调是可选参数
      done();
    }
    
    // 在离开过渡完成、
    // 且元素已从 DOM 中移除时调用
    function onAfterLeave(el) {}
    
    // 仅在 v-show 过渡中可用
    function onLeaveCancelled(el) {}

    这些钩子可以与 CSS 过渡或动画结合使用,也可以单独使用。

    在使用仅由 JavaScript 执行的动画时,最好是添加一个 :css="false" prop。这显式地向 Vue 表明可以跳过对 CSS 过渡的自动探测。除了性能稍好一些之外,还可以防止 CSS 规则意外地干扰过渡效果。
    在有了 :css="false" 后,我们就自己全权负责控制什么时候过渡结束了。这种情况下对于 @enter@leave 钩子来说,回调函数 done 就是必须的。否则,钩子将被同步调用,过渡将立即完成。

  10. 可复用过渡效果
    有点类似无渲染组件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    <!-- MyTransition.vue -->
    <script>
    // JavaScript 钩子逻辑...
    </script>
    
    <template>
      <!-- 包装内置的 Transition 组件 -->
      <Transition name="my-transition" @enter="onEnter" @leave="onLeave">
        <slot></slot>
        <!-- 向内传递插槽内容 -->
      </Transition>
    </template>
    
    <style>
    /*
       必要的 CSS...
       注意:避免在这里使用 <style scoped>
       因为那不会应用到插槽内容上
     */
    </style>
    1
    2
    3
    <MyTransition>
      <div v-if="show">Hello</div>
    </MyTransition>
  11. 元素间过渡

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    <script setup>
    import { ref } from "vue";
    
    const docState = ref("saved");
    </script>
    
    <template>
      <span style="margin-right: 20px">Click to cycle through states:</span>
      <div class="btn-container">
        <Transition name="slide-up">
          <button v-if="docState === 'saved'" @click="docState = 'edited'">
            Edit
          </button>
          <button
            v-else-if="docState === 'edited'"
            @click="docState = 'editing'"
          >
            Save
          </button>
          <button
            v-else-if="docState === 'editing'"
            @click="docState = 'saved'"
          >
            Cancel
          </button>
        </Transition>
      </div>
    </template>
    
    <style>
    .btn-container {
      display: inline-block;
      position: relative;
      height: 1em;
    }
    
    button {
      position: absolute;
    }
    
    .slide-up-enter-active,
    .slide-up-leave-active {
      transition: all 0.25s ease-out;
    }
    
    .slide-up-enter-from {
      opacity: 0;
      transform: translateY(30px);
    }
    
    .slide-up-leave-to {
      opacity: 0;
      transform: translateY(-30px);
    }
    </style>
  12. 过渡模式
    在之前的例子中,进入和离开的元素都是在同时开始动画的,因此我们不得不将它们设为 position: absolute 以避免二者同时存在时出现的布局问题。(利用定位元素可以互相重叠覆盖)

    然而,很多情况下这可能并不符合需求。我们可能想要先执行离开动画,然后在其完成之后再执行元素的进入动画。手动编排这样的动画是非常复杂的,好在我们可以通过向 <Transition> 传入一个 mode prop 来实现这个行为:

    1
    2
    3
    <Transition mode="out-in">
      ...
    </Transition>
  13. 组件间过渡

    1
    2
    3
    <Transition name="fade" mode="out-in">
      <component :is="activeComponent"></component>
    </Transition>
  14. 动态过渡

    1
    2
    3
    <Transition :name="transitionName">
      <!-- ... -->
    </Transition>

TransitionGroup

  1. Transition的区别
    <TransitionGroup> 支持和 <Transition> 基本相同的 propsCSS 过渡 classJavaScript 钩子监听器,但有以下几点区别:

    • 默认情况下,它不会渲染一个容器元素。但你可以通过传入 tag prop 来指定一个元素作为容器元素来渲染。

    • 过渡模式在这里不可用,因为我们不再是在互斥的元素之间进行切换。

    • 列表中的每个元素都必须有一个独一无二的 key attribute

    • CSS 过渡 class 会被应用在列表内的元素上,而不是容器元素上。

    1
    2
    3
    4
    5
    <TransitionGroup name="list" tag="ul">
      <li v-for="item in items" :key="item">
        {{ item }}
      </li>
    </TransitionGroup>
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /* 对移动中的元素应用的过渡 */
    .list-move,
    .list-enter-active,
    .list-leave-active {
      transition: all 0.5s ease;
    }
    
    .list-enter-from,
    .list-leave-to {
      opacity: 0;
      transform: translateX(30px);
    }
    
    /* 确保将离开的元素从布局流中删除
       以便能够正确地计算移动的动画。 */
    .list-leave-active {
      position: absolute;
    }

    经典示例

KeepAlive

  1. 基本使用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <!-- 非活跃的组件将会被缓存! -->
    <KeepAlive>
     <component :is="activeComponent" />
    </KeepAlive>
    <!-- 路由懒加载 -->
    <router-view v-slot="{ component }">
     <keep-alive>
       <component :is="component" />
     </keep-alive>
    </router-view>
  2. 包含与排除
    <KeepAlive> 默认会缓存内部的所有组件实例,但我们可以通过 includeexclude prop 来定制该行为。这两个 prop 的值都可以是一个以英文逗号分隔的字符串、一个正则表达式,或是包含这两种类型的一个数组:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <!-- 以英文逗号分隔的字符串 -->
    <KeepAlive include="a,b">
     <component :is="view" />
    </KeepAlive>
    
    <!-- 正则表达式 (需使用 `v-bind`) -->
    <KeepAlive :include="/a|b/">
      <component :is="view" />
    </KeepAlive>
    
    <!-- 数组 (需使用 `v-bind`) -->
    <KeepAlive :include="['a', 'b']">
      <component :is="view" />
    </KeepAlive>

    它会根据组件的 name 选项进行匹配,所以组件如果想要条件性地被 KeepAlive 缓存,就必须显式声明一个 name 选项。

    在 3.2.34 或以上的版本中,使用 <script setup> 的单文件组件会自动根据文件名生成对应的 name 选项,无需再手动声明。

  3. 最大缓存实例数

    1
    2
    3
    <KeepAlive :max="10">
      <component :is="activeComponent" />
    </KeepAlive>
  4. 缓存实例的生命周期

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <script setup>
    import { onActivated, onDeactivated } from "vue";
    
    onActivated(() => {
      // 调用时机为首次挂载
      // 以及每次从缓存中被重新插入时
    });
    
    onDeactivated(() => {
      // 在从 DOM 上移除、进入缓存
      // 以及组件卸载时调用
    });
    </script>
    • onActivated 在组件挂载时也会调用,并且 onDeactivated 在组件卸载时也会调用。

    • 这两个钩子不仅适用于 <KeepAlive> 缓存的根组件,也适用于缓存树中的后代组件。

Teleport

<Teleport> 是一个内置组件,它可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。

  1. 模态框示例

    • App.vue

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      <!--
         可定制插槽和 CSS 过渡效果的模态框组件。
         -->
      
      <script setup>
      import Modal from "./Modal.vue";
      import { ref } from "vue";
      
      const showModal = ref(false);
      </script>
      
      <template>
        <button id="show-modal" @click="showModal = true">Show Modal</button>
      
        <Teleport to="body">
          <!-- 使用这个 modal 组件,传入 prop -->
          <modal :show="showModal" @close="showModal = false">
            <template #header>
              <h3>custom header</h3>
            </template>
          </modal>
        </Teleport>
      </template>
    • Modal.vue

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      <script setup>
      const props = defineProps({
        show: Boolean,
      });
      </script>
      
      <template>
        <Transition name="modal">
          <div v-if="show" class="modal-mask">
            <div class="modal-container">
              <div class="modal-header">
                <slot name="header">default header</slot>
              </div>
      
              <div class="modal-body">
                <slot name="body">default body</slot>
              </div>
      
              <div class="modal-footer">
                <slot name="footer">
                  default footer
                  <button class="modal-default-button" @click="$emit('close')">
                    OK
                  </button>
                </slot>
              </div>
            </div>
          </div>
        </Transition>
      </template>
      
      <style>
      .modal-mask {
        position: fixed;
        z-index: 9998;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background-color: rgba(0, 0, 0, 0.5);
        display: flex;
        transition: opacity 0.3s ease;
      }
      
      .modal-container {
        width: 300px;
        margin: auto;
        padding: 20px 30px;
        background-color: #fff;
        border-radius: 2px;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
        transition: all 0.3s ease;
      }
      
      .modal-header h3 {
        margin-top: 0;
        color: #42b983;
      }
      
      .modal-body {
        margin: 20px 0;
      }
      
      .modal-default-button {
        float: right;
      }
      
      /*
       * 对于 transition="modal" 的元素来说
       * 当通过 Vue.js 切换它们的可见性时
       * 以下样式会被自动应用。
       *
       * 你可以简单地通过编辑这些样式
       * 来体验该模态框的过渡效果。
       */
      
      .modal-enter-from {
        opacity: 0;
      }
      
      .modal-leave-to {
        opacity: 0;
      }
      
      .modal-enter-from .modal-container,
      .modal-leave-to .modal-container {
        -webkit-transform: scale(1.1);
        transform: scale(1.1);
      }
      </style>
  2. 搭配组件使用
    <Teleport> 只改变了渲染的 DOM 结构,它不会影响组件间的逻辑关系。也就是说,如果 <Teleport> 包含了一个组件,那么该组件始终和这个使用了 <teleport> 的组件保持逻辑上的父子关系。传入的 props 和触发的事件也会照常工作。

  3. 禁用Teleport

    1
    2
    3
    <Teleport :disabled="isMobile">
     ...
    </Teleport>
  4. 多个 Teleport 共享目标

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <Teleport to="#modals">
      <div>A</div>
    </Teleport>
    <Teleport to="#modals">
      <div>B</div>
    </Teleport>
    
    <!-- 渲染结果 -->
    <div id="modals">
      <div>A</div>
      <div>B</div>
    </div>

Suspense

<Suspense> 是一个内置组件,用来在组件树中协调对异步依赖的处理。它让我们可以在组件树上层等待下层的多个嵌套异步依赖项解析完成,并可以在等待时渲染一个加载状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<RouterView v-slot="{ Component }">
  <template v-if="Component">
    <Transition mode="out-in">
      <KeepAlive>
        <Suspense>
          <!-- 主要内容 -->
          <component :is="Component"></component>

          <!-- 加载中状态 -->
          <template #fallback>
            正在加载...
          </template>
        </Suspense>
      </KeepAlive>
    </Transition>
  </template>
</RouterView>

Feature

包体积与 Tree-shaking 优化

  1. 尽量使用构建版本(Production)

    • Tree-shaking减少无用代码。
    • Vue编译器无需载入减少体积,模板会被预编译。
  2. 在引入新的依赖项时要小心包体积膨胀!尽量使用ES版本的包,其对tree-shaking更加友好。权衡依赖体积与功能的性价比。

  3. 代码分割
    代码分割是指构建工具将构建后的 JavaScript 包拆分为多个较小的,可以按需或并行加载的文件。通过适当的代码分割,页面加载时需要的功能可以立即下载,而额外的块只在需要时才加载,从而提高性能。像 Rollup (Vite 就是基于它之上开发的) 或者 webpack 这样的打包工具可以通过分析 ESM 动态导入的语法来自动进行代码分割:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // lazy.js 及其依赖会被拆分到一个单独的文件中
    // 并只在 `loadLazy()` 调用时才加载
    function loadLazy() {
      return import("./lazy.js");
    }
    import { defineAsyncComponent } from "vue";
    
    // 会为 Foo.vue 及其依赖创建单独的一个块
    // 它只会按需加载
    //(即该异步组件在页面中被渲染时)
    const Foo = defineAsyncComponent(() => import("./Foo.vue"));

    对于使用了 Vue Router 的应用,强烈建议使用异步组件作为路由组件。Vue Router 已经显性地支持了独立于 defineAsyncComponent 的懒加载。查看懒加载路由了解更多细节。

  4. 保持props的稳定新,移除无用的更新步骤

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <ListItem
     v-for="item in list"
     :id="item.id"
     :active-id="activeId" />
    
    <ListItem
     v-for="item in list"
     :id="item.id"
     :active="item.id === activeId" />
  5. 内置指令

    • v-oncev-once 是一个内置的指令,可以用来渲染依赖运行时数据但无需再更新的内容。它的整个子树都会在未来的更新中被跳过。
    • v-memov-memo 是一个内置指令,可以用来有条件地跳过某些大型子树或者 v-for 列表的更新。
  6. 虚拟列表

  7. 浅层响应式对象
  8. 大型列表中避免不必要的组件抽象。

组合式 API 的优势

  1. 更好的逻辑复用。
  2. 更加灵活的代码组织。
  3. 更好的类型推导。
    近几年来,越来越多的开发者开始使用 TypeScript 书写更健壮可靠的代码,TypeScript 还提供了非常好的 IDE 开发支持。然而选项式 API 是在 2013 年被设计出来的,那时并没有把类型推导考虑进去,因此我们不得不做了一些复杂到夸张的类型体操才实现了对选项式 API 的类型推导。但尽管做了这么多的努力,选项式 API 的类型推导在处理 mixins 和依赖注入类型时依然不甚理想。
  4. 更小的生产包体积
    搭配 <script setup> 使用组合式 API 比等价情况下的选项式 API 更高效,对代码压缩也更友好。这是由于 <script setup> 形式书写的组件模板被编译为了一个内联函数,和 <script setup> 中的代码位于同一作用域。不像选项式 API 需要依赖 this 上下文对象访问属性,被编译的模板可以直接访问 <script setup> 中定义的变量,无需从实例中代理。这对代码压缩更友好,因为本地变量的名字可以被压缩,但对象的属性名则不能。

虚拟 DOM 的算法改进

  • Vue3 使用了片段(fragments)的概念,可以让一个组件返回多个根节点,而不需要使用额外的包裹元素。这样可以减少不必要的 DOM 节点,提高渲染效率。
  • Vue3 使用了静态标记(static hoisting)的技术,可以在编译阶段识别出不会改变的静态内容,比如文本、属性、样式等,并将它们提升到渲染函数之外,避免每次重新渲染时都要重新创建。这样可以减少不必要的内存分配和垃圾回收,提高性能。
  • Vue3 使用了事件侦听器缓存(event listener caching)的技术,可以在编译阶段识别出不会改变的事件侦听器,并将它们缓存起来,避免每次重新渲染时都要重新绑定。这样可以减少不必要的事件侦听器的创建和销毁,提高性能。
  • Vue3 使用了块跟踪(block tracking)的技术,可以在编译阶段识别出动态内容的边界,并将它们分割成不同的块,每个块都有一个唯一的标识符。这样可以在运行时只对发生变化的块进行 diff,而不需要遍历整个模板。这样可以减少不必要的比较和更新,提高性能。
  • Vue3 使用了优化的 diff 算法,可以在处理完首尾节点后,对剩余节点的进行更高效的匹配和移动。Vue3 使用了LCS(最长公共子序列)的算法,可以找出新旧节点列表中的最长公共子序列,然后将其保持不变,只对非公共子序列的节点进行移动。这样可以减少不必要的节点移动,提高性能。

最佳实践

TypeScript 与组合式 API

  1. 为组件的 props 标注类型

    • 运行时声明:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      <script setup lang="ts">
      const props = defineProps({
        foo: { type: String, required: true },
        bar: Number,
      });
      
      props.foo; // string
      props.bar; // number | undefined
      </script>
    • 基于类型的声明

      1
      2
      3
      4
      5
      6
      <script setup lang="ts">
      const props = defineProps<{
        foo: string;
        bar?: number;
      }>();
      </script>
  2. props解构默认值
    当使用基于类型的声明时,我们失去了为 props 声明默认值的能力。这可以通过 withDefaults 编译器宏解决:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    export interface Props {
      msg?: string;
      labels?: string[];
    }
    
    const props = withDefaults(defineProps<Props>(), {
      msg: "hello",
      labels: () => ["one", "two"],
    });
  3. 复杂的prop类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <script setup lang="ts">
    interface Book {
      title: string;
      author: string;
      year: number;
    }
    
    const props = defineProps<{
      book: Book;
    }>();
    </script>

    对于运行时声明,我们可以使用 PropType 工具类型:

    1
    2
    3
    4
    5
    import type { PropType } from "vue";
    
    const props = defineProps({
      book: Object as PropType<Book>,
    });
  4. 为组件的 emits 标注类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <script setup lang="ts">
    // 运行时
    const emit = defineEmits(["change", "update"]);
    
    // 基于类型
    const emit = defineEmits<{
      (e: "change", id: number): void;
      (e: "update", value: string): void;
    }>();
    
    // 3.3+: 可选的、更简洁的语法
    const emit = defineEmits<{
      change: [id: number];
      update: [value: string];
    }>();
    </script>
  5. ref标注类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import { ref } from "vue";
    
    // 推导出的类型:Ref<number>
    const year = ref(2020);
    
    // => TS Error: Type 'string' is not assignable to type 'number'.
    year.value = "2020";
    
    import { ref } from "vue";
    import type { Ref } from "vue";
    
    const year: Ref<string | number> = ref("2020");
    
    year.value = 2020; // 成功!
    
    // 得到的类型:Ref<string | number>
    const year = ref<string | number>("2020");
    
    year.value = 2020; // 成功!
    
    // 推导得到的类型:Ref<number | undefined>
    const n = ref<number>();
  6. reactive标注类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import { reactive } from "vue";
    
    // 推导得到的类型:{ title: string }
    const book = reactive({ title: "Vue 3 指引" });
    
    import { reactive } from "vue";
    
    interface Book {
      title: string;
      year?: number;
    }
    
    const book: Book = reactive({ title: "Vue 3 指引" });

    不推荐使用 reactive() 的泛型参数,因为处理了深层次 ref 解包的返回值与泛型参数的类型不同。

  7. computed() 标注类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import { ref, computed } from "vue";
    
    const count = ref(0);
    
    // 推导得到的类型:ComputedRef<number>
    const double = computed(() => count.value * 2);
    
    // => TS Error: Property 'split' does not exist on type 'number'
    const result = double.value.split("");
    
    const double = computed<number>(() => {
      // 若返回值不是 number 类型则会报错
    });
  8. 为事件处理函数标注类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <script setup lang="ts">
    function handleChange(event) {
      // `event` 隐式地标注为 `any` 类型
      console.log(event.target.value);
    }
    </script>
    
    <template>
      <input type="text" @change="handleChange" />
    </template>
    1
    2
    3
    function handleChange(event: Event) {
      console.log((event.target as HTMLInputElement).value);
    }
  9. provide / inject 标注类型
    provideinject 通常会在不同的组件中运行。要正确地为注入的值标记类型,Vue 提供了一个 InjectionKey 接口,它是一个继承自 Symbol 的泛型类型,可以用来在提供者和消费者之间同步注入值的类型:

    1
    2
    3
    4
    5
    6
    7
    8
    import { provide, inject } from "vue";
    import type { InjectionKey } from "vue";
    
    const key = Symbol() as InjectionKey<string>;
    
    provide(key, "foo"); // 若提供的是非字符串值会导致错误
    
    const foo = inject(key); // foo 的类型:string | undefined

    建议将注入 key 的类型放在一个单独的文件中,这样它就可以被多个组件导入。

    当使用字符串注入 key 时,注入值的类型是 unknown,需要通过泛型参数显式声明:

    1
    const foo = inject<string>("foo"); // 类型:string | undefined

    注意注入的值仍然可以是 undefined,因为无法保证提供者一定会在运行时 provide 这个值。

    当提供了一个默认值后,这个 undefined 类型就可以被移除:

    1
    const foo = inject<string>("foo", "bar"); // 类型:string

    如果你确定该值将始终被提供,则还可以强制转换该值:

    1
    const foo = inject("foo") as string;
  10. 为模板引用标注类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <script setup lang="ts">
    import { ref, onMounted } from "vue";
    
    const el = ref<HTMLInputElement | null>(null);
    
    onMounted(() => {
      el.value?.focus();
    });
    </script>
    
    <template>
      <input ref="el" />
    </template>
  11. 为组件模板引用标注类型
    有时,你可能需要为一个子组件添加一个模板引用,以便调用它公开的方法。举例来说,我们有一个 MyModal 子组件,它有一个打开模态框的方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    <!-- MyModal.vue -->
    <script setup lang="ts">
    import { ref } from "vue";
    
    const isContentShown = ref(false);
    const open = () => (isContentShown.value = true);
    
    defineExpose({
      open,
    });
    </script>

    为了获取 MyModal 的类型,我们首先需要通过 typeof 得到其类型,再使用 TypeScript 内置的 InstanceType 工具类型来获取其实例类型:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <!-- App.vue -->
    <script setup lang="ts">
    import MyModal from "./MyModal.vue";
    
    const modal = ref<InstanceType<typeof MyModal> | null>(null);
    
    const openModal = () => {
      modal.value?.open();
    };
    </script>
  12. 如果组件的具体类型无法获得,或者你并不关心组件的具体类型,那么可以使用 ComponentPublicInstance。这只会包含所有组件都共享的属性,比如 $el

    1
    2
    3
    4
    5
    import { ref } from "vue";
    
    import type { ComponentPublicInstance } from "vue";
    
    const child = ref<ComponentPublicInstance | null>(null);