Vue

Vue核心:

  • 声明式渲染Vue基于标准HTML拓展了一套模板语法,使得我们可以声明式地描述最终输出的HTMLJavaScript状态之间的关系。

  • 响应性Vue会自动追踪JavaScript状态并在其发生变化时响应式地更新DOM

1. CDN引入

使用ESM版本和CDN引入:

html
<div id="app">{{ message }}</div>

<!-- 我们可以使用导入映射表 (Import Maps) 来告诉浏览器如何定位到导入的 vue: -->
<script type="importmap">
  {
    "imports": {
      "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
    }
  }
</script>

<script type="module">
  import { createApp, ref } from "vue";
  const app = createApp({
    setup() {
      const message = ref("Hello Vue!");
      return {
        message,
      };
    },
  });
  app.mount("#app");
</script>
警告

    ES模块不能通过file://协议工作,也即是当你打开一个本地文件时浏览器使用的协议由于安全原因,ES模块只能通过 http://协议工作,也即是浏览器在打开网页时使用的协议。为了使ES模块在我们的本地机器上工作,我们需要使用本地的HTTP服务器,通过http://协议提供index.html


2. 基本概念

应用实例:

  • 每个Vue应用都是通过createApp函数创建一个新的应用实例:
html
<script type="module">
  import { createApp } from "vue";

  /*
    根组件:
        - 我们传入的createApp的对象实际上是一个组件,每个应用都需要一个“根组件”,其他组件将作为其子组件
        - 大多数真实的应用都是由一颗嵌套的、可重用的组件树组成的。例如:一个待办事项(Todos)应用的组件书可能是这样的:
        App (root component)
            ├─ TodoList
            │  └─ TodoItem
            │     ├─ TodoDeleteButton
            │     └─ TodoEditButton
            └─ TodoFooter
               ├─ TodoClearButton
               └─ TodoStatistics
*/

  const app = createApp({
    /* 根组件选项 */
  });
</script>

挂载应用

  • 应用实例必须在调用.mount()方法后才会渲染出来。该方法接收一个"容器"参数,可以是一个实际的DOM元素或是一个CSS选择器字符串:
html
<div id="app"></div>

<script type="module">
  /*  
      应用根组件的内容将会被渲染在容器元素里面。容器元素自己将不会被视为应用的一部分。
      .mount()方法应该始终在整个应用配置和资源注册完成之后被调用。同时请注意,不同于其他资源注册方法,它的返回值是根组件而非应用实例。
  */
  app.mount("#app");
</script>

DOM中的根组件模板:

  • 根组件的模板通常是组件本身的一部分,但也可以直接通过在挂载容器内编写的模板来单独提供
html
<div id="app">
  <button @click="count++">{{ count }}</button>
</div>

<!--
    当组件没有设置`template`选项时,Vue将会自动使用容器的`innerHTML`作为模板
-->

<script type="module">
  import { createApp } from "vue";

  const app = createApp({
    data() {
      return {
        count: 0,
      };
    },
  });

  app.mount("#app");
</script>

应用配置:

  • 应用实例会暴露一个.config对象允许我们配置一些应用级的选项,例如定义一个应用级的错误处理器,用来捕获所有子组件上的错误:
html
<script type="module">
  app.config.errorHandler = (err) => {
    /* 处理错误 */
  };

  /* 
        应用实例还提供了一些方法来注册应用范围内可用的资源,例如注册一个组件:
        
        这使得`TodoDeleteButton`在应用的任何地方都是可用的。
    */
  app.component("TodoDeleteButton", TodoDeleteButton);
</script>

多个应用实例:

  • 应用实例并不只限于一个,createAppAPI允许你在同一个页面中创建多个共存的Vue应用,而且每个应用都拥有自己的用于配置和全局资源的作用域。
html
<script type="module">
  /*
      如果你正在使用Vue来增强服务端渲染HTML,并且只想要Vue去控制一个大型页面中特殊的一小部分,应避免将一个单独的Vue应用实例挂载到整个页面上,而是应该创建多个小的应用实例,将它们分别挂载到所需的元素上去
  */

  const app1 = createApp({
      /* ... */
  })

   const app2 = createApp({
      /* ... */
  })

   const app3 = createApp({
      /* ... */
  })

  app1.mount('#app1')
  app2.mount("#app2")
  app3.mount("#app3")

  ...
</script>

3. 模板语法

指令: Directives

  • 指令是带有v-前缀的特殊attribute。Vue提供了许多内置指令,包括但不限于v-bind等。
  • 指令attribute的期望值为一个JavaScript表达式(除了少数几个例外,即之后要讨论到的v-forv-onv-slot)。一个指令的任务是在其表达式的值变化时响应式地更新DOM

参数:Arguments

  • 某些指令会需要一个参数,在指令名后面通过一个冒号隔开做标识。例如用v-bind指令来响应式地更新一个HTML attribute,另一个例子就是v-on指令,它将监听DOM事件

  • 动态参数:同样在指令参数上也可以使用一个JavaScript表达式,但需要包含在一对方括号内,例如:v-bind:[attributeName]="url"。(注意:参数表达式有一些约束,参见动态参数的限制与动态参数语法的限制章节的解释),这里的attributeName会作为一个JavaScript表达式被动态执行,计算得到的值会被用作最终的参数。(相似地,你还可以将一个函数绑定到动态的事件名称上

动态参数值的限制:

  • 动态参数中表达式的值应当是一个字符串,或者是null,特殊值null意为显示移除该绑定。其他非字符串的值会触发警告。

动态参数语法的限制:

  • 动态参数表达式因为某些字符的缘故会有一些语法限制,比如空格和引号,在HTML attribute名称中都是不合法的,例如:<a :['foo' + bar]="value">...</a>,这会触发一个编译器警告,如果你需要传入一个复杂的动态参数,推荐使用计算属性替换复杂的表达式,也就Vue最基础的概念之一,后续会讲到。
  • 当使用DOM内嵌模板(直接写在HTML文件里的模板)时,我们需要避免在名称中使用大写字母,因为浏览器会强制将其转换为小写,例如:<a :[someAttr]="value">...</a>上面的例子将会在DOM内嵌模板中被转换为:[someattr]。如果你的组件拥有"someAttr"属性而非"someattr",那么这段代码将不会工作。单文件组件内的模板不受此限制

修饰符:Modifiers

  • 修饰符是以点开头的特殊后缀,表面指令需要以一些特殊的方式被绑定。例如:.prevent修饰符会告知v-on指令对触发的事件调用event.preventDefault()
疑问?

为什么动态参数里面不允许['foo' + bar]?

    看上去如果bar是字符串的话,理论上['foo' + bar]的计算结果也是字符串,那为什么还会报警告呢?核心原因:空格是HTML属性的终结符。在HTML规范中,属性之间是用空格分隔的。Vue的模板首先要经过HTML解析器

如果你写成:['bar' + foo]:

    解析器视角:当解析器遇到[后面跟着的引号和空格时,它会认为当前的属性已经结束了。

    语法冲突:HTML解析器会把+及其后面的内容误认为是另一个属性的开始,或者直接报错。

vue指令图解

html
<!--
    Vue使用一种基于HTML的模板语法,使我们能够声明式地将其组件实例的数据绑定到呈现DOM上。所有的Vue模板都是语法层面合法的HTML,可用被符合规范的浏览器和HTML解析器解析。
    在底层机制中,Vue会将模板编译成高度优化的JavaScript代码。结合响应式系统,当应用状态变更时,Vue能够智能地推导出需要重新渲染的组件的最少数量,并应用最少的DOM操作。
-->

<!--
    文本插值:最基本的数据绑定形式是文本插值,使用“Mustache”语法(即:双大括号)
    双大括号标签会被替换为相应组件实例中的msg属性的值,同时每次msg属性更改时,它也会同步更新
-->
<span> Message: {{ msg }} </span>

<!--
    v-html: 双大括号会将数据解释为纯文本,而不是HTML。若想插入HTML,则需要使用v-html指令
    
    安全警告:
        在网站上动态渲染任何HTML是非常危险的,因为这非常容器造成XSS漏洞。请仅在内容安全可信时再使用v-html,并且永远不要使用用户提供的HTML内容。
-->
<p>Using text interpolation: {{ rawHTML }}</p>
<p>Using v-html directive: <span v-html="rawHtml"></span></p>
<p>
  <!--
    Attribute绑定:    
        双大括号不能在HTML attribute中使用,想要响应式地绑定一个attribute,应该使用v-bind指令。
        v-bind指令指示Vue将元素的id attribute与组件的dynamicId属性保存一致。如果绑定的值是null或undefined,那么该attribute将会从渲染的元素上移除
        
    简写:
        因为v-bind非常常用,所以提供了特定的简写语法: <div :id="dynamicId"></div>
        
    同名简写:
        仅支持3.4版本及以上,如果attribute的名称与绑定的JavaScript变量的名称相同,那么可用进一步简化语法,省略attribute值  
        <div :id></div> === <div v-bind:id></div> === <div :id="id"></div>
 -->
</p>

<div v-bind:id="dynamicId"></div>
<div :id="dynamicId"></div>
<div v-bind:id></div>
<div :id></div>

<!--
    布尔型 Attribute:
        布尔型 Attribute: 依据 true / false 值来决定 attribute 是否应该存在于该元素上。
        disabled就是最常见的例子之一,当isButtonDisable为真值或一个空字符串(即<button disable="">)时,元素包含这个disbaled的attribute。而当其为其他假值时,attribute将被忽略
-->
<button :disabled="isButtonDisabled">
  Button<button>
    <!--
    动态绑定多个值:
        如果有这样一个包含多个attribute的JavaScript对象:
        const objectOfAttrs = {
            id: 'container',
            class: 'wrapper',
            style: 'background-color: green'
        }
    通过不带参数的v-bind,可用将它们绑定到单个元素上
-->
    <div v-bind="objectOfAttrs"></div>

    <!--
    使用JavaScript表达式:
        Vue实际上在所有的数据绑定中,都支持完整的JavaScript表达式,这些表达式都会被作为JavaScript代码,以当前组件实例为作用域进行解析。
        在Vue模板内,JavaScript表达式可用被使用在如下场景中:
            - 在文本插值中(双大括号)
            - 在任何Vue指令(以v-开头的特殊attribute)attribute的值中
        仅支持表达式:
            - 每个绑定仅支持单一表达式,也就是一段能被求值的JavaScript代码,一个简单的判断方法就是是否可用合法地写在return后面
        调用函数:
            可用在绑定的表达式中使用一个组件暴露的方法,但是绑定在表达式中的方法在组件每次更新时都会被重新调用,因此不应该产生任何副作用,比如改变数据或触发异步操作。
        受限的全局访问:
            模板中的表达式将被沙盒化,仅能够访问到有限的全局对象列表,该列表中会暴露常用的内置全局对象,比如Math和Date。没有显示包含在列表中的全局对象将不能够在模板内表达式中访问,列入用户附加在window上的属性。但是,你可以自行在app.config.globalProperties上显示地添加它们,供所有的Vue表达式使用。
-->
    <div>{{ number + 1 }}</div>
    <div>{{ ok ? 'yes' : 'no' }}</div>
    <div>{{ message.split("").reverse().join('') }}</div>
    <div :id="`list-${id}`"></div>
    <time :title="toTitleDate(date)" :datetime="date"
      >{{ formatDate(date) }}</time
    >

    <!--
    v-if:指令会基于表达式的值的真假来移除或插入该元素
-->
    <div v-if="seen">...</div>

    <!--
    v-on:它将监听DOM事件
-->
    <a v-on:click="doSometing">...</a>
    <a @click="doSomething">...</a>
    <form @submit.prevent="onSubmit">...</form>
  </button>
</button>

4. 响应式基础

    <script setup> 中的顶层的导入、声明的变量和函数可在同一组件的模板中直接使用。你可以理解为模板是在同一作用域内声明的一个JavaScript函数——它自然可以访问与它一起声明的所有内容。

声明响应式状态:

javascript
import { ref } from "vue"

/* ref()接收参数,并将其包裹在一个带有.value属性的ref对象中返回 */
/* ref()可接受一个泛型来指定类型,或者让ref自行类型推导 */
/* 如果你指定了一个泛型参数但没有给出初始值,那么最后将会得到一个包含undefined的联合类型 */

const count<number> = ref(0)
const foo<number> = ref() // 推导得到的类型:Ref<number | undefined>

console.log(count) // { value: 0 }
console.log(count.value) // 0

自动解包:

vue
<template>
<!--
    注意:在模板中使用ref时,不需要附加.value,因为当在模板中使用时,ref会自动解包
    在模板中解包的注意事项:
        在模板渲染上下文中,只有顶级的ref属性才会被自动解包
        在下面的属性定义中,只有foo和bar是顶级属性,但是bar.id不是
        另一个需要注意的点:如果ref是文本插值的最终计算值(即{{ }}标签),那么它将被解包,该特性仅仅是文本插值的一个便利特性,等价于{{ bar.id.value }}
-->
{{ foo }}
<!-- 因为bar.id不是顶级属性,所以不会进行解包,结果将是字符串拼接 -->
{{ bar.id + 1 }}
<!-- 因为bar.id在双大括号中是ref的最终计算值,所以会解包 -->
{{ bar.id }}
<template>

<script setup lang='ts'>
 import { ref } from "vue"

 const foo = ref(0)
 const bar = { id: ref(0) }
<script>

为什么需要使用ref

  • 为什么需要使用带有.value的ref,而不是直接使用普通的变量呢?因为当在模板中使用了一个ref,当改变这个ref的值时,Vue会自动检测到这个变化,并且相应地更新DOM。这是通过一个基于依赖追踪的响应式系统实现的。
  • 当一个组件首次渲染时,Vue会追踪在渲染过程中使用的每一个ref,然后,当一个ref被修改时,它会触发追踪它的组件的一次重新渲染
  • 因为在标准的JavaScript中,检测普通变量的访问或修改是行不通的。然而,可以通过gettersetter方法来拦截对象属性的getset操作。该.value属性给予Vue一个机会来检测ref何时被访问或修改。在其内部,Vue在它的getter中执行追踪,在它的setter中执行触发。
  • 从概念上说,可以将ref看作是一个像这样的对象:
javascript
/* 伪代码,不是真正的实现 */
const myRef = {
  _value: 0,
  get value() {
    track();
    return this._value;
  },
  set value(newValue) {
    this._value = newValue;
    trigger();
  },
};
  • 另一个使用ref的好处是,与普通变量不同,你可以将ref传递给函数,同时保留对最新值和响应式链接的访问。当将复杂逻辑重构为可重用的代码时,这将非常有用。

深层响应式:

  • Ref可以持有任何类型的值,包括深层嵌套的对象数组或者JavaScript内置的数据结构,比如Map等。Ref会使他的值具有深层响应性,这意味这既是改变嵌套对象或数组时,变化也会被检测到。
javascript
import { ref } from "vue";

const obj = ref({
  nested: { count: 0 },
  arr: ["foo", "bar"],
});

function mutateDeeply() {
  /* 以下都会按照期望工作 */
  obj.value.nested.count++;
  obj.value.arr.push("baz");
}
  • 非原始值将通过reactive()转换为响应式代理,该函数后续讨论。
  • 还可以通过shallow ref来放弃深层响应式。对于浅层ref,只有.value的访问会被追踪。浅层ref可以用于避免对大型数据的响应式开销来优化性能、或者有外部库来管理其内部状态的情况。
  • 阅读更多:

DOM更新时机:

  • 当修改了响应式状态时,DOM会被自动更新。但DOM的更新并不是同步的。Vue会在"next tick"更新周期中缓冲所有的状态的修改,以确保不管你进行了多少次状态的修改,每个组件都只会被更新一次
  • 要等待DOM更新完成之后再执行额外的代码 ,可以使用nextTick()全局API:
javascript
import { nextTick } from "vue";

async function increment() {
  count.value++;
  await nextTick();
  /* 现在DOM已经更新了 */
}

reactive()

  • 还有另一种声明响应式状态的方式,即使用reactive() API。这个与将内部值包装在特殊对象的ref不同,reactive()将使对象本身具有响应式
javascript
import { reactive } from "vue"

/* 在ts中会自动类型推导 */
const state = reactive({ count: 0 })

/* 显示的添加类型推荐 */
/* 不推荐使用reactive()的泛型参数,因为处理深层次ref解包的返回值与泛型参数的类型不同 */
interface Book {
    title: string
    year?: number
}
const book: Book = reactive({ title: "Vue 3指引" })
  • 响应式对象是JavaScript代理,其行为就和普通对象一样。不同的是,Vue能够拦截对响应式对象所有属性的访问和修改,以便于依赖追踪和触发更新。
  • reactive()将深层地转换对象:当访问嵌套对象时,它们也会被reactive()包装当ref的值是一个对象时,ref()也会在内部调用它。与浅层ref类似,这里也有一个shallowReactive() API可以选择推出深层响应式。

Reactive Proxy VS. Original

  • 值得注意的是,reactive()返回的是一个原始对象的Proxy,它和原始对象是不相等的。
javascript
import { reactive } from "vue";

const raw = {};
const proxy = reactive(raw);

/* 代理对象是和原始对象不是全等的 */
console.log(proxy === raw); /* false */
  • 只有代理对象是响应式的更改原始对象是不会触发更新的。因此,使用Vue的响应式系统的最佳实践是仅使用声明对象的代理版本。
  • 为了保证访问代理的一致性,对同一个原始对象调用reactive(),会总是返回同样的代理对象,而对于一个已经存在的代理对象调用reactive()会返回其本身。
javascript
/* 在同一个对象上调用reactive()会返回相同的代理 */
console.log(reactive(raw) === proxy); // true

/* 在一个代理上调用reactive()会返回它自己 */
console.log(reactive(proxy) === proxy); // true
  • 这个规则对嵌套对象也适用,依靠深层响应性,响应式对象内的嵌套对象依然是代理
javascript
const proxy = reactive({})

const raw = {}··

proxy.nested = raw

console.log(proxy.nested === raw) // false

reactive()的局限性

  • 有限的值类型:它只能用于对象类型(对象、数组和如Map、Set这样的集合类型)。它不能持有如stringnumberboolean这样的原始类型

  • 不能替换整个对象由于Vue的响应式跟踪是通过属性访问实现的,因此必须始终保持对响应式对象的相同引用。这意味着我们不能轻易地"替换"响应式对象,因为这样会与第一个引用的响应式连接将丢失

javascript
let state = reacitve({ count: 0 });

/* 上面的({ count: 0 })引用将不再被追踪,响应性连接已丢失 */
state = reactive({ count: 1 });
  • 对解构操作不友好:当我们将响应式对象的原始类型属性解构为本地变量时,或者将该属性传递给函数时,我们将丢失响应性连接:
javascript
const state = reacitve({ count: 0 });

/* 当解构时,count已经与state.count断开连接 */
let { count } = state;
count++;

/* 该函数接收到的是一个普通的数字 */
/* 并且无法追踪state.count的变化 */
/* 我们必须传入整个对象以保持响应式 */
callSomeFunction(state.count);
  • 由于这些限制,Vue官方建议使用ref()作为声明响应式状态的主要API

额外的ref解包细节

  • 一个ref会在作为响应式对象的属性被访问或修改时自动解包。换句话说,它的行为就像一个普通的属性:
javascript
const count = ref(0);
const state = reactive({ count });

console.log(state.count); // 0

state.count = 1;
console.log(count.value); // 1

/* 如果将一个新的ref赋值给一个关联了已有ref属性,那么它会替换掉旧的ref */
const otherCount = ref(2);

state.count = otherCount;

console.log(state.count); // 2
/* 原始ref现在已经和state.count失去了联系 */
console.log(count.value); // 1
  • 只有当嵌套在一个深层响应式对象内时,才会发生ref解包。当其作为浅层响应式对象的属性被访问时不会解包。

数组和集合的注意事项:

  • 与reactive对象不同的是,当ref作为响应式数组或原生集合类型(如Map)中的元素被访问时,它不会被解包。
javascript
const books = reactive([ref("vue 3 Guide")]);

/* 这里需要.value */
console.log(books[0].value);

const map = reacitve(new Map([["count", ref(0)]]));
/* 这里需要.value */
console.log(map.get("count").value);

在模板中解包的注意事项:

  • 在模板渲染上下文中,只有顶级的ref属性才会被解包。
javascript
<script setup lang='ts'>
const count = ref(0)
const object = { id: ref(1) }
</script>

<!-- 这个表达式按预期工作 -->
<div>{{ count + 1 }}</div>

<!-- 但这个不会 -->
<!-- 渲染的结果将会时[object Object]1,因为在计算表达式时,object.id没有被解包,仍然是一个ref对象。为了解决这个问题,可以将id解构为一个顶级属性 -->
<div>{{ object.id + 1 }}</div>

<script setup lang='ts'>
const { id } = object
</script>

<!-- 现在渲染结果将是2 -->
<div>{{ id + 1 }}</div>

<!-- 另一个需要注意的点是,如果ref为文本插值的最终计算值(即{{}}标签),那么它将被解包,该特性仅仅是文本插值的一个便利特性,等价于{{ object.id.value }} -->
<div>{{ object.id }}</div>

5. 计算属性

  • 在Vue中,计算属性是用来描述依赖响应式状态的复杂逻辑它与函数的区别在于,计算属性的值会基于其响应式依赖被缓存。一个计算属性当且仅当在其响应式依赖更新时才会重新计算,这意味着只要被依赖的响应式值不改变,无论多少次访问同一个计算属性,都会立即返回先前的计算结果,而无需重复执行getter函数。
vue
<script setup lang="ts">
  import { reactive, computed } from "vue"

  const author = reactive({
      name: "John Doe",
      books: [
          "Vue 2 - Advanced Guide",
          "Vue 3 - Basic Guide",
          "Vue 4 - The Mystery"
      ]
  })

/*
    computed()方法期望接收一个getter函数,返回值为一个计算属性ref。和其他一般的ref类似,可以通过publishedBooksMessage.value访问计算结果。计算属性ref也会在模板中自动解包,因此在模板表达式中引用时,无序添加.value
    Vue的计算属性会自动追踪响应式依赖。它会检测到publishedBooksMessage依赖于author.books,所以当author.books改变时,任何依赖于publishedBooksMessage的绑定都会同步更新
*/

const publishedBooksMessage = computed(() => {
    return author.books.length > 0 'Yes' : 'No'
})
</script>

<template>
  <p>Has published books:</p>
  <span>{{ publishedBooksMessage }}</span>
</template>
  • 可写计算属性
vue
<!-- 
    计算属性默认是只读的,当尝试修改一个计算属性时,你会收到一个运行时警告。只有在某些特殊场景中才可能需要用到可写属性,可以通过同时提供getter和setter来创建。
 -->

<script setup lang="ts">
import { ref, computed } from "vue";

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

/* 现在当你运行fullName.value = "YQZ Mio时,会调用setter方法,firstName和lastName会随之更新,计算属性也会重新进行计算" */
const fullName = computed({
  get() {
    return firstName.value + " " + lastName.value;
  },
  set(newValue) {
    [firstName.value, lastName.value] = newValue.split(" ");
  },
});
</script>

获取上一个值和为计算属性添加类型标注

  • 如果需要,可以通过访问计算属性的getter的第一个参数来获取计算属性返回的上一个值
vue
<!-- Vue 3.4+版本支持 -->
<script setup>
  import { ref, computed } from "vue"

  const count = ref(2)

  /*
    这个计算属性在count的值小于或等于3的时候,将返回count的值
    当count的值大于等于4时,将会返回满足我们条件的最后一个值
    直到count的值再次小于或等于3为止

    computed函数接收一个泛型,可以在泛型里面为返回值添加类型标注,默认会自动推导(computed<T>)
  */
  const alwaysSmall = computed((pervious) => {
    if (count.value <= 3) {
        return count.value
    }
    return previous
  })
<script>

最佳实践

  • Getter不应该有副作用,计算属性的getter只做计算没有任何其他的副作用这一点非常重要!!!举例来说,不要改变其他状态,在getter中做异步请求或更改DOM!一个计算属性的声明中描述的是如何根据其他值派生一个值。因此getter的职责应该仅为计算和返回值。
  • 避免直接修改计算属性值,从计算属性返回的值是派生状态。我们可以把它看作是一个"临时快照",每当源状态发生变化时,就会创建一个新的快照。更改快照是没有意义的,因此计算属性的返回值应该被视为只读的,并且永远不应该被更改,应该更新它所依赖的源状态以触发新的计算。

6. 类与样式绑定

通过v-bind动态的添加类名或样式

html
<div id="app">
  <!-- 动态绑定字符串 -->
  <!-- class="bbb foo bar baz" -->
  <div class="bbb" :class="foo">{{ foo }}</div>

  <!-- 绑定值为一个对象,那么会通过对象的value的真假值来决定是否把key添加到类中 -->
  <!-- 这里isActive为true,渲染结果class='active' -->
  <div :class="{ active: isActive }">{{ isActive }}</div>

  <!-- 绑定的对象并不一定需要写成内联字面量的形式,也可以直接绑定一个对象 -->
  <!-- 这将渲染:class='active' -->
  <div :class="classObject">
    {{ classObject.active }} - {{ classObject['text-danger'] }}
  </div>

  <!-- 也可以绑定一个返回对象的计算属性,这也是一个常见且很有用的技巧 -->
  <div :class="classObjectComputed">Mio</div>

  <!-- 我们还可以给:class绑定一个数组来渲染多个class -->
  <!-- 数组里面的元素可以是字符串,对象和表达式等 -->
  <div :class="classArray">YQZ</div>
</div>

<script setup lang="ts">
  import { ref, reactive, computed } from "vue";
  const foo = ref("foo bar baz");
  const isActive = ref(true);

  const classObject = reactive({
    active: true,
    "text-danger": false,
  });

  const classObjectComputed = computed(() => ({
    active: isActive.value,
    "text-danger": !isActive.value,
  }));

  const classArray = ref([
    "foo",
    "bar",
    { active: true },
    classObjectComputed.value,
  ]);
</script>

在组件上使用

  • 在组件上使用动态类名会有如下规则
html
<!-- 对于只有一个根元素的组件,当使用class attribute时,这些class会被添加到根元素上并与该元素上已有的class合并 -->

<!-- 子组件模板 -->
<p class="foo bar">Hi!</p>

<!-- 在使用时添加了一些class -->
<my-component class="baz boo"></my-component>

<!-- 渲染出的HTML为 -->
<p class="foo bar baz boo">Hi!</p>

<!-- 如果你的组件有多个根元素,那么需要指定哪个根元素来接收这个class,可以通过$attrs属性来指定接收 -->

<!-- my-component 模板使用 $attrs时 -->
<p :class="$attrs.class">Hi!</p>
<span>This is a child component</span>

<my-component class="baz"></my-component>
<!-- 这将被渲染为 -->
<p class="baz">Hi!</p>
<span>This is a child component</span>

绑定内联样式

html
<div id="app">
  <!-- :style支持绑定JavaScript对象值,对于的是HTML元素style属性 -->
  <div :style="{ color: activeColor, fontSize: fontSize + 'px' }">Mio</div>

  <!-- 尽管推荐使用camelCase,但:style也支持kebab-cased形式的CSS属性key(对应其CSS中的实际名称) -->
  <div :style="{ color: activeColor, 'font-size': fontSize + 'px' }">Mio</div>

  <!-- 直接绑定一个样式对象和计算属性,这样可以使模板更加简洁 -->
  <!-- 同时,:style指令也可以和常规的style attribute共存,就和:class一样 -->
  <div style="color: red" :style="styleObject">YQZ</div>

  <!-- 绑定数组 -->
  <!-- 这些对象会被合并后渲染在同一元素上 -->
  <div :style="[styleObject, 'color: red', { display: 'inline' }]">Foo</div>
</div>

<script setup lang="ts">
  import { ref } from "vue";
  const activeColor = ref("red");
  const fontSize = ref(30);

  const styleObject = ref({
    fontSize: 24 + "px",
    "background-color": "pink",
  });
</script>

自动前缀:

  • 当你在:style中使用了需要浏览器特殊前缀的CSS属性时,Vue会自动为它们加上相应的前缀。Vue是在运行时检查该属性是否支持在当前浏览器中使用。如果浏览器不支持某个属性,那么将尝试加上各个浏览器特殊前缀,以找到哪一个是被支持的。
  • 这些功能应该是历史早期为了兼容遗留下来的问题,无需多关注,最终要加前缀还是用对应的库来处理。Vue的这个功能也是运行时环境。

7. 条件渲染

html
<!--
    v-if、v-else-if和v-else
    v-if和v-show
-->

<!-- v-if指令用于条件性地渲染一块内容,这块内容只会在指定的表达式返回值为真值时才会被渲染 -->
<!-- v-else-if和v-else必须是和v-if在同一级别的作用域下才会生效,和JavaScript的逻辑一样 -->
<div v-if='type === "A"'>A</div>

<div v-else-if="type === 'B'">B</div>

<div v-else-if="type === 'C'">C</div>

<div v-else>Not A/B/C</div>

template上的v-if

vue
<!-- 因为v-if是一个指令,它必须依附于某个元素,但是如果我们想要切换不止一个元素的话,那么在这种情况下我们可以使用template来进行包裹,最后渲染的结果并不会包含这个template元素 -->
<template v-if="isShow">
  <h1>H1</h1>
  <h2>H2</h2>
</template>

v-show和v-if

html
<!-- 另外一个可以按条件显示一个元素的指令是v-show,其用法基本和v-if一样 -->
<!-- 不同之处在于v-show会在DOM渲染中保留该元素,v-show仅切换了该元素上名为display的css属性 -->
<!-- v-show不支持在template元素上使用,并且也不能与v-else-if和v-else搭配使用 -->
<h1 v-show="ok">Hello!</h1>
  • v-if是"真实的"按条件渲染,因为它确保了在切换时,条件区块内的事件监听器子组件都会被销毁重建
  • v-if也是惰性的:如果在初次渲染时条件为false,则不会做任何事。条件区块只有当条件首次变为true时才会被渲染
  • 相比之下,v-show简单许多,元素无论初始条件如何,始终都会被渲染,只有CSSdisplay属性会被切换。
  • 总的来说,v-if有更高的切换开销,而v-show有更高的初始渲染开销。因此,如果需要频繁切换,则使用v-show较好;如果在运行时绑定条件很少改变,则v-if会更合适。

v-if和v-for
  • v-ifv-for同时存在于一个元素上的时候,v-if会首先被执行。
  • 同时使用v-ifv-for不推荐的,因为这样二者的优先级不明显。

8. 列表渲染

v-for与数组

html
<!--
  我们可以使用v-for指令来渲染一个数组列表,v-for指令的值需要使用`item in items`形式的特殊语法,其中items是源数据的数组,而item是迭代项的别名。
  
  在v-for块中可以完整地访问父级作用域内的属性和变量,同时,v-for也支持使用可选的第二个参数表示当前项的位置索引。
  
  对于多层嵌套的v-for,作用域的工作方式和函数的作用域很类似。每个v-for作用域都可以访问到父级作用域。
  
  当然,也可以使用of作为分隔符来代替in,这更接近JavaScript的迭代器语法。
-->

<app id="app">
  <ul>
    <li v-for="({ foo }, index) in items">{{ foo }} - {{ index }}</li>
  </ul>
</app>

<script setup lang="ts">
  import { ref } from "vue";

  const items = ref([{ foo: "Mio" }, { foo: "YQZ" }]);
</script>

v-for与对象

html
<!-- 
   你也可以使用v-for来遍历一个对象的所有属性,遍历的顺序会基于该对象调用的Object.values()的返回值来决定。
   
   其中,也可以通过提供第二个参数来表示属性名(例如Key),第三个参数将表示索引值
 -->

<div id="app">
  <h2 v-for="(value, key, index)">{{ value }} - {{ key }} - {{ index }}</h2>
</div>

<script setup lang="ts">
  import { ref } from "vue";

  const obj = ref({
    name: "Mio",
    age: 16,
  });
</script>

在v-for里面使用范围值

  • v-for可以直接接受一个整数值。在这种用例中,会将该模板基于1...n的取值范围重复多次。注意:此处n的初始值是从1开始的,并非0
html
<div id="app">
  <ul>
    <li v-for="item in range">{{ item }}</li>
  </ul>
</div>

<script setup lang="ts">
  import { ref } from "vue";

  const range = ref(30);
</script>

<template>上的v-for

html
<!-- 
  与模板上的v-if类似,你也可以在template标签上使用v-for来渲染一个包含多个元素的块
 -->

<div id="app">
  <template v-for="item in items">
    <li>{{ item.msg }}</li>
    <li class="divider" role="presentation">{{ item.desc }}</li>
  </template>
</div>

v-for与v-if

  • 当它们同时存在于一个节点上时,v-if比v-for的优先级更高。这意味着v-if的条件将无法访问到v-for作用域内定义的变量别名。
html
<!--
  这会抛出一个错误,因为属性 todo 此时没有在该实例上定义
-->
<li v-for="todo in todos" v-if="!todo.isComplete">{{ todo.name }}</li>

<!--
  在外先包装一层template再在其上使用v-for可以解决这个问题(当然,这也更加明显易读)
-->
<template v-for="todo in todos">
  <li v-if="!todo.isComplete">{{ todo.name }}</li>
</template>

注意
  • 同时使用v-ifv-for不推荐的,因为这样二者的优先级不明显

  • 两者常见的情况可能导致这种用法

  • 过滤列表中的项目(例如:v-for="user in users" v-if="user.isActive")。在这种情况下,可以用一个新的计算属性来替换users,该属性返回过滤后的列表(例如:activeUsers)

  • 避免渲染应该隐藏的列表(例如:v-for="user in users" v-if="shouldShowUsers")。在这种情况下,将v-if移至容器元素(如ul、ol)。


通过key管理状态

没有使用key的情况:

  • Vue默认按照"就地更新"的策略来更新通过v-for渲染的元素列表。当数据项的顺序改变时,Vue不会随之移动DOM元素的顺序,而是就地更新每个元素,确保它们在原本指定的索引位置上渲染。
  • 默认模式是高效的,但只适用于列表渲染输出的结果,不依赖子组件状态或者临时DOM状态(例如表单输入值)的情况。
  • 总结就是:DOM元素没有移动,它们只是被赋予新的数据内容如果存在表达组件,它们的状态会混乱

使用了key的情况:

  • 为了给Vue一个提示,以便于它可以跟踪每个节点的标识,从而重用重新排序现有的元素,你需要为每个元素对应的块提供一个唯一的keyattribute
  • 总结:当使用了key,Vue会在发现key变化时(即数据项被替换)才替换DOM但在发现key只是改变了位置时,它会更高效地选择复用并移动DOM元素,以避免昂贵的DOM重建操作

总结:

  • 官方推荐在任何可行的时候为v-for提供一个key除非所迭代的DOM内容非常简单(例如:不包含组件或有状态的DOM元素),或者有意采用默认行为来提高性能
html
<!--
    在没有Key的情况下,删掉数组中第一个元素后,复用旧DOM 0,只替换标签和文本内容,导致了保留输入框的临时值, 发现新的列表长度是1,没有索引1上的VNode,然后销毁旧DOM1
    
    有key的情况下:发现key=101在新列表中不存在了,删掉整个旧DOM 0, 发现key=102任然存在,但位置从索引1移动到了索引0,Vue会移动/重排序整个旧DOM 1到索引0的位置
-->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>

    <style>
      .container {
        width: 900px;
        margin: 0 auto;
        display: flex;
        justify-content: space-between;
      }

      .button {
        text-align: center;
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="container">
        <div>
          <h2>没有Key</h2>
          <div v-for="item in forms">
            {{ item.label }}({{item.id}})<input type="text" />
          </div>
        </div>

        <div>
          <h2>有Key</h2>
          <div v-for="item in forms" :key="item.id">
            {{ item.label }}({{item.id}})<input type="text" />
          </div>
        </div>
      </div>
      <div class="button">
        <button @click="deleteFirstArry">删除第一项</button>
      </div>
    </div>

    <!-- 我们可以使用导入映射表 (Import Maps) 来告诉浏览器如何定位到导入的 vue: -->
    <script type="importmap">
      {
        "imports": {
          "vue": "./../lib/vue.esm-browser.js"
        }
      }
    </script>

    <script type="module">
      import { createApp, ref } from "vue";
      const app = createApp({
        setup() {
          const forms = ref([
            {
              label: "姓名",
              id: 101,
            },
            {
              label: "邮箱",
              id: 102,
            },
          ]);

          const deleteFirstArry = () => {
            forms.value.shift();
          };

          return {
            forms,
            deleteFirstArry,
          };
        },
      });
      app.mount("#app");
    </script>
  </body>
</html>

组件上使用v-for

  • 我们可以直接在组件上使用v-for,它和在一般元素上使用没有任何区别(但是别忘了提供一个key)
html
<!-- 
  但是,不会自动将任何数据传递给组件,因为组件有自己独立的作用域。为了将迭代后的数据传递到组件中,我们还需要传递props
  不自动将item注入组件的原因是,这会使组件与v-for的工作方式紧密耦合。明确其数据的来源可以使组件在其他情况下重用
 -->
<my-component v-for="item in items" :key="item.id"></my-component>

<my-component
  v-for="(item, index) in items"
  :item="item"
  :index="index"
  :key="item.id"
>
</my-component>

数组变化侦测

  • Vue能够侦听响应式数组的变更方法,并在它们被调用时触发相关的更新。这些变更方法包括:

    • push()、pop()、shift()、unshift()、splice()、sort()和reverse()
  • 替换一个数组

    • 变更方法,顾名思义,就是会对调用它们的原数组进行变更。相对地,也有一些不可变(immutable)方法,例如filter,concat和slice,这些都不会更改原数组,而总是返回一个新数组。当遇到的是非变更方法时,我们需要将旧的数组替换为新的。
javascript
/* items是一个数组的ref */
/* 你可能认为这将导致Vue丢弃现有的DOM并重新渲染整个列表,但是,情况并非如此。Vue实现了一些巧妙的方法来最大化对DOM元素的重用,因此使用另一个包含部分重叠对象的数组来做替换,仍会是一种非常高效的操作 */
items.value = items.value.filter((item) => item.message.match(/Foo/));

展示过滤或排序后的结果

  • 有时候,我们希望显示数组经过过滤或排序后的内容,而不实际变更或重置原始数据。在这种情况下,我们可以创建返回已过滤或已排序数据的计算属性
html
<li v-for="n in evenNumbers">{{ n }}</li>

<!-- 在计算属性不可行的情况下(列入在多层嵌套的v-for循环中),可以使用如下方法 -->
<ul v-for="numbers in sets">
  <li v-for="n in even(numbers)">{{ n }}</li>
</ul>

<script setup>
  import { ref, computed } from "vue";

  const numbers = ref([1, 2, 3, 4, 5]);
  const sets = ref([
    [1, 2, 3, 4, 5],
    [6, 7, 8, 9, 10],
  ]);

  const evenNumbers = computed(() => {
    return numbers.value.filter((n) => n % 2 === 0);
  });

  function even(numbers) {
    return numbers.filter((number) => number % 2 === 0);
  }

  /* 
   在计算属性中使用reverse()和sort()的时候务必小心! 这两个方法将变更原始数组,计算函数中不应该这么做,请在调用这些方法之前创建一个原数组的副本:
   - return numbers.reverse()
   + return [...numbers].reverse()
 */
</script>

9. 事件处理

  • 我们可以使用v-on指令(简写@)来监听DOM事件,并在事件触发时执行对应的JavaScript。用法:v-on:click='handler'@click="handler"

  • 事件处理器(handler)的值可以是:

    1. 内联事件处理器:事件被触发时执行的内联JavaScript语句(与onclick类似)。
    2. 方法事件处理器:一个指向组件上定义的方法属性名或是路径
  • 方法内联事件判断

    • 模板编译器会通过检查v-on的值是否是合法的JavaScript标识符属性访问路径来断定是何种形式的事件处理器。举例来说:foofoo.barfoo['bar']会被视为方法事件处理器,而foo()count++会被视为内联事件处理器
    • 总结:就是一个会以事件的回调函数直接传入,另一个会包裹一层匿名函数来进行调用
html
<!-- 内联事件处理器 -->
<button @click="count++">Add 1</button>
<p>Count is: {{ count }}</p>

<!-- 方法事件处理器 -->
<!-- 
  方法事件处理器会自动接收原生DOM事件并触发执行。下面例子中,我们能够通过触发事件的`event.target`访问到该DOM元素
 -->
<button @click="greet">Greet</button>

<!-- 在内联事件处理器中访问事件参数 -->
<!-- 
  有时我们需要在内联事件处理器中访问原生DOM事件。那么可以向该处理器方法传入一个特殊的 `$event` 变量,或者使用内联箭头函数
-->
<!-- 使用特殊的 `$event` 变量 -->
<button @click="warn('Form cannot be submitted yet.', $event)">Submit</button>

<!-- 使用内联箭头函数 -->
<button @click="(event) => warn('Form cannot be sumiteed yet.', event)">
  Submit
</button>

<script setup lang="ts">
  import { ref } from "vue";
  const count = ref(0);
  const name = ref("Vue.js");

  function greet(event) {
    alert(`Hello ${name.value}!`);
    /* `event` 是DOM原生事件 */
    if (event) {
      alert(event.target.tagName);
    }
  }

  function warn(message, event) {
    /* 这里可以访问原生事件 */
    if (event) {
      event.preventDefault();
    }
    alert(message);
  }
</script>

为事件处理器标注类型

  • 当没有类型标注时,这个event参数隐式地标注为any类型
  • 这也会在tsconfig.json中配置了strict: truenoImplicitAny: true时报出一个TS错误
  • 因此,建议显示地事件处理函数的参数标注类型。此外,在访问event上的属性时可能需要使用类型断言
html
<script setup lang="ts">
  function handleChange(event) {
    /* `event` 隐式地标注为 any 类型 */
    console.log(event.target.value);
  }

  /* 使用类型断言 */
  function handleChange(event: Event) {
    console.log((event.target as HTMLInputElement).value);
  }
</script>

<template>
  <input type="text" @change="handleChange" />
</template>

事件修饰符

  • 在处理事件时调用event.preventDefault()event.stopPropagation()是很常见的。尽管我们可以直接在方法内调用,但如果方法能更专注于数据逻辑而不用去处理DOM事件的细节会更好
  • 为了解决这一问题Vue为v-on提供了事件修饰符。修饰符是用.表示的指令后缀,包含如下:
    • .stop.prevent.self.capture.once.passive
html
<!-- 单击事件将停止传递 -->
<a @click.stop="doThis"></a>

<!-- 提交事件将不再重新加载页面 -->
<from @submit.prevent="onSubmit"></from>

<!-- 修饰语可以使用链式书写 -->
<a @click.stop.prevent="doThis"></a>

<!-- 也可以只有修饰符 -->
<form @submit.prevent></form>

<!-- 仅当 event.target 是元素本身时才会触发事件处理器 -->
<!-- 例如:事件处理器不来自子元素 -->
<div @click.self="doThis">...</div>

<!--
  .capture、.once和.passive修饰符与原生`addEventListener`事件相对应
-->
<!-- 添加事件监听器,使用`capture`捕获模式 -->
<!-- 例如:指向内部元素的事件,在被内部元素处理器,先被外部处理 -->
<div @click.capture="doThis">...</div>

<!-- 点击事件最多被触发一次 -->
<a @click.once="doThis"></a>

<!-- 滚动事件的默认行为(scrolling)将立即发生而非等待 `onScroll` 完成 -->
<!-- 以防其中包含 `event.preventDefault()` -->
<!-- .passive修饰符一般用于触摸事件的监听器,可以用来改善移动端设备的滚屏性能 -->
<div @scroll.passive="onScroll">...</div>

注意

    使用修饰符时需要注意调用顺序,因为相关代码是以相同的顺序生成的。因此使用@click.prevent.self阻止元素及其子元素的所有点击事件的默认行为,而@click.self.prevent则只会阻止对元素本身的点击事件的默认行为

    请勿同时使用.passive和.prevent,因为,.passive已经向浏览器表明了你不想阻止事件的默认行为。如果这么做了,则.prevent会被忽略,并且浏览器会抛出警告


按键修饰符

  • 在监听键盘事件时,我们经常需要检查特定的按键。Vue允许在v-on@监听按键事件时添加按键修饰符
  • Vue为一些常用的按键提供了别名:
    • .enter.tab.delete(捕获"Delete""Backspace"两个按键)、.esc.space.up.down.left.right
  • 你可以使用以下系统按键修饰符触发鼠标键盘事件监听器,只有当按键被按下时才会触发:
    • .ctrl.alt.shift.meta
html
<!-- 仅在 `key` 为 `Enter` 时调用 `submit` -->
<input @keyup.enter="submit" />

<!-- 
  你可以直接使用 `KeyboardEvent.key` 暴露的按键名称作为修饰符,但需要转为 kebab-case 形式 
  下面这个例子中,仅会在 `$event.key` 为 `PageDown` 时调用事件处理
-->
<input @keyup.page-down="onPageDown" />

<!--
  TIP:
    请注意,`系统按键修饰符`和`常规按键不同`,与`keyup`事件一起使用时,`该按键必须在事件发出时处于按下状态`。换句话说:`keyup.ctrl`只会在你仍然按 `CTRL` 但松开了`另一个键时被触`发。若你`单独松开ctrl键将不会触发`。
-->

<!-- Alt + Enter -->
<input @keyup.alt.enter="clear" />

<!-- Ctrl + 点击 -->
<div @click.ctrl="doSomething">Do something</div>

<!-- .exact修饰符允许精确控制触发事件所需的系统修饰符的组合 -->
<!-- 当按下ctrl时,即使同时按下Alt或shift也会触发 -->
<button @click.ctrl="onClick">A</button>

<!-- 仅当按下 ctrl 且未按任何其他键时才会触发 -->
<button @click.ctrl.exact="onCtrlClick">A</button>

<!-- 仅当没有按下任何系统按键时触发 -->
<button @click.exact="onClick">A</button>

Tip

    在Mac键盘上,meta是Command键。在Windows键盘上,meta键是Windows键。在Sun微机系统键盘上meta是钻石键。在某些键盘上,特别是MITLisp机器键盘及其后代版本的键盘,如Knight键盘space-cadet键盘meta都被标记为"META"。在Symbolics键盘上meta也被标识为"META"或"Meta"


鼠标按键修饰符

  • 这些修饰符处理程序限定为由特定鼠标按键触发的事件
html
<!-- .left .right .middle -->
<!--
  但请注意,.left,.right 和 .middle 这些修饰符名称是基于常见的右手用鼠标布局设定的,但实际上它们分别指代设备事件触发器的“主”、“次”,“辅助”,而非实际的物理按键。因此,对于左手用鼠标布局而言,“主”按键在物理上可能是右边的按键,但却会触发 .left 修饰符对应的处理程序。又或者,触控板可能通过单指点击触发 .left 处理程序,通过双指点击触发 .right 处理程序,通过三指点击触发 .middle 处理程序。同样,产生“鼠标”事件的其他设备和事件源,也可能具有与“左”,“右”完全无关的触发模式。
-->

10. 表单输入绑定

  • 前端处理表单时,我们常常需要将输入框的内容同步给JavaScript中相应的变量手动连接值绑定更改事件监听器可能会很麻烦,我们可以使用v-model指向来帮我们简化这一步骤。
html
<!--
  另外,`v-model`还可以用于各种不同类型的输入,<textarea>、<select>元素。它会根据所使用的元素自动使用对应的DOM属性和事件组合。
  文本类型的<input>和<textarea>元素会绑定value property并侦听input事件。
  <input type="checkbox">和<input type="radio">会绑定checked property并侦听change事件。
  <select>会绑定value property并侦听change事件。
-->
<!--
  注意:
    v-model会忽略任何表单上初始的value、checked或selected attribute。它将始终将当前绑定的JavaScript状态视为数据的正确来源。你应该在JavaScript中使用响应式系统的API声明该初始值。
-->

<input :value="text" @input="event => text = event.target.value" />
<input v-model="text" />

<script setup lang="ts">
  import { ref } from "vue";

  const text = ref("");
</script>

修饰符

html
<!-- 默认情况下,v-model会在每次input事件后更新(IME拼写阶段的状态例外),你可以添加lazy修饰符来改为在每次change事件后更新数据 -->

<!-- 在 "change" 事件后同步更新而不是 "input" -->
<input v-model.lazy="ms" />

<!--
  .number: 如果想让用户输入自动转换为数字,可以在v-model后添加.number修饰符来管理输入
  如果该值无法被parseFloat()处理,那么将返回原始值。特别时当输入为空时(例如用户清空输入字段之后),会返回一个空字符串。这种行为与DOM属性valueAsNumber有所不同。
  number修饰符会在输入框有type="number"时自动启用。
-->
<input v-model.number="age" />

<!-- 
  .trim: 如果想要默认自动去除用户输入内容两端的空格,可以在v-model后添加.trim修饰符
 -->
<input v-model.trim="msg" />

11. 侦听器

  • 计算属性允许我们声明性地计算衍生值。但在某些情况下,我们需要在状态变化时执行一些"副作用",例如更改DOM,或是根据异步操作的结果去修改另一处的状态。
  • 组合式API中,可以使用watch函数每次响应式状态发生变化时触发回调函数
html
<script setup>
  import { ref, watch } from "vue";

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

  /* 可以直接侦听一个ref */
  watch(question, async (newQuestion, oldQuestion) => {
    if (newQuestion.includes("?")) {
      loading.value = true;
      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. " + err;
      } finally {
        loading.value = false;
      }
    }
  });
</script>

<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" :disabled="loading" />
  </p>
</template>

侦听数据源类型

  • watch的第一个参数可以是不同形式的"数据源",它可以是一个ref(包括计算属性)一个响应式对象一个getter函数或者多个数据源组成的数组
html
<script setup>
    import { ref } from "vue"

    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}`)
  })
</script>

  • 注意:不能直接侦听响应式对象的属性值
html
<script setup>
  import { reactive } from "vue";

  const obj = reactive({ count: 0 });

  /* 错误,因为 watch() 得到的参数是一个 number */
  watch(obj.count, (count) => {
    console.log(`Count is: ${count}`);
  });

  /* 这里需要用一个返回该属性的 getter 函数 */
  watch(
    () => obj.count,
    (count) => {
      console.log(`Count is: ${count}`);
    },
  );
</script>

深层侦听器

  • 直接给watch()传入一个响应式对象,会隐式地创建一个深层侦听器该回调函数在所有嵌套的变更时都会被触发
html
<script setup>
  import { reactive } from "vue";

  const obj = reactive({ count: 0 });

  watch(obj, (newValue, oldValue) => {
    /* 在嵌套的属性变更时触发 */
    /* 注意:`newValue` 此处和 `oldValue` 是相等的 */
    /* 因为它们是同一个对象 */
  });

  /* --------------------------- 内容分割线--------------------------- */

  /* 相比之下,一个返回响应式对象的getter函数,只有在返回不同的对象时,才会触发回调 */
  watch(
    () => state.someObject,
    () => {
      /* 仅当 state.someObject 被替换时触发 */
    },
  );

  /* 当然,也可以给上面这个例子显示地加上`deep`选项,强制转成深层侦听器 */
  watch(
    () => state.someObject,
    (newValue, oldValue) => {
      /* 注意: `newValue` 此处和 `oldValue` 是相等的 */
      /* 除非 state.someObject 被整个替换掉了 */
    },
    { deep: true },
  );
</script>

谨慎使用

    深度侦听需要遍历侦听对象的所有嵌套的属性,当用于大型数据解构时,开销很大,因此请在只在必要时才使用它,并且要留意性能

    在Vue 3.5+中,deep选项还可以是一个数字,表示最大遍历深度,即Vue应该遍历对象嵌套属性的级数


即时回调的侦听器

  • watch默认是懒执行的,仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时立即执行一遍回调
  • 举例来说:我们想请求一些初始数据,然后在相关状态更改时重新请求数据
html
<script setup>
  /* 我们可以传入`immediate: true`选项来强制侦听器的回调立即执行 */
  watch(
    source,
    () => {
      /* 立即执行,且当`source`改变时再次执行 */
    },
    { immediate: true },
  );
</script>

一次性侦听器

  • 每当被侦听源发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次,那么可以使用once: true选项。
html
<script setup>
  watch(
    source,
    (newValue, oldValue) => {
      /* 当 `source` 变化时,仅触发一次 */
    },
    { once: true },
  );
</script>

watchEffect()

  • 侦听器的回调使用与源完全相同的响应式状态是很常见的
  • watchEffect仅会在其同步执行期间才会追踪依赖。在使用异步回调时,只有在第一个await正常工作前访问到的属性才会被追踪
  • watch vs watchEffect:
    • watch追踪明确侦听的数据源。它不会追踪任何在回调中访问到的东西。另外,仅在数据源确实改变时才会触发回调。watch会避免在发生副作用时追踪依赖,因此,我们能更加精确地控制回调函数触发时机
    • watchEffect,则会在副作用发生期间追踪依赖。它会在同步执行过程中自动追踪所有能访问到的响应式属性。这更方便,而且代码往往更简洁,但有时其响应性依赖关系会不那么明确
html
<script setup>
  import { ref, watch, watchEffect } from "vue";

  const todoId = ref(1);
  const data = ref(null);
  const foo = ref("FOO");

  watch(
    todoId,
    async () => {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/todos/${todoId.value}`,
      );
      data.value = await response.json();
    },
    { immediate: true },
  );

  /* 上面的watch可用watchEffect改写 */
  /* 在第一次执行中,因为data.value是在await之后,不会被Vue添加到依赖列表中,就算data.value发生改变,也不会执行回调 */
  watchEffect(async () => {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/todos/${todoId.value}`,
    );
    data.value = await response.json();
  });

  /* 因为在第一次执行回调中,遇到了return,没有执行到下面一行代码,所以foo不会被添加到Vue的依赖里面,就算foo.value的值发生了改变,也不会执行回调 */
  watchEffect(() => {
    if (true) return;
    console.log(foo.value);
  });
</script>

副作用清理

  • 有时候我们需要在侦听器中执行副作用,例如在异步请求中,然而,当我们想要终止上一次请求,我们可以使用副作用清理
html
<script setup>
  import { watch, watchEffect, ref, onWatcherCleanup } from "vue";

  const id = ref(0);

  /* 
    在这里案例中,如果在请求完成之前`id`发生了变化怎么办? 当上一个请求完成时,它仍会使用已经过时的ID值触发回调。
    理想情况下,我们希望能够在`id`变为新值时取消过时的请求。
  */
  watch(id, (newId) => {
    fetch(`/api/${newId}`).then(() => {
      /* 回调逻辑 */
    });
  });

  /*
    有两种方式可以做到,第一种可以使用`onWatcherCleanup()`API来注册一个清理函数,当侦听器失效并准备重新运行时会被调用。第二种可以使用`onCleanup`函数作为第三个参数传递给侦听器回调,以及watchEffect作用函数的第一个参数
  */
  watch(id, (newId) => {
    const controller = new AbortController();

    fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
      /* 回调逻辑 */
    });

    onWatcherCleanup(() => {
      /* 终止过期请求 */
      controller.abort();
    });
  });

  watch(id, (newId, oldId, onCleanup) => {
    const controller = new AbortController();

    fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
      /* 回调逻辑 */
    });

    /* 钩子在下一次 watch 运行前被调用 */
    onCleanup(() => {
      controller.abort();
    });
  });
</script>
javascript
watch(id, (newId, oldId, onCleanup) => { ... })

watchEffect((onCleanup) => { ... })

⏰oncleanup的执行时机总结:

  • oncleanup函数是用于清理上一次副作用的,它绝对不会副作用(watch或watchEffect的回调函数)第一次执行或当前执行时执行
  • oncleanup里的代码只在以下两种特定情况运行时被触发
    • 🥇侦听器准备重新运行时(Invalidation),这是最常见的触发时机,当侦听器依赖的响应式数据发生变化,并且Vue准备执行新的副作用之前,它会先运行上一次注册的清理函数
    • 🥈组件卸载时(Unmount),当拥有这个watchwatchEffectVue组件实例被销毁时,Vue会执行最后一次注册的onCleanup函数
时机触发条件onCleanup运行的目的
下一次运行前侦听的依赖项发生变化,并且watch或watchEffect即将被触发执行新的回调终止旧任务:释放上一次副作用中启动的资源(如取消fetch请求,清除旧计时器),以避免竞态条件和资源泄露
组件生命周期结束组件从DOM中移除,即将被销毁最终清理:确保组件生命周期结束时,所有残留的副作用(如未清除的全局事件监听器,未停止的计时器)都被释放,防止内存泄漏

🛑 避免的误区

  • 错误时机:onCleanup不会在watchwatchEffect回调函数刚开始执行时运行,它总是在上一次副作用和下一次副作用之间运行
  • 注册 vs. 运行:在每次回调中,onCleanup( () => { ... } )都是在注册一个函数;而只有在下一次回调运行前组件卸载时,这个被注册的函数才会被执行

回调的触发时机

  • 当你更改了响应式状态,它可能会同时触发Vue组件更新侦听器回调
  • 类似于组件更新,用户创建的侦听器回调函数也会被批量处理以避免重复调用
  • 例如:如果我们同步将一千个项目推入被侦听的数组中,我们可能不希望侦听器触发一千次。(大白话,回调函数只会在一个特定的异步时机执行。这意味着,即使响应式数据在短时间内被同步修改了一千次,回调函数最终也只会触发一次。这是因为数据本身同步更新到最新状态的,而系统只是将执行这个回调的通知进行了去重。当回调真正执行时,他读取的自然就是已经包含了所有一千次修改的最新数据
  • 默认情况下,侦听器回调会在父组件(如有)之后所属组件的DOM更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的DOM,那么DOM将处于更新前的状态
html
<!-- Post Watchers -->
<!-- 如果想在侦听器回调中能访问被Vue更新之后的所属组件的DOM,需要指明`flush: 'post'`选项 -->

<script setup>
  import { watch, watchEffect, watchPostEffect, watchSyncEffect } from "vue";

  watch(source, callback, { flush: "post" });
  watchEffect(callback, { flush: "post" });

  /* 后置刷新的watchEffect有一个更方便的别名watchPostEffect */
  watchPostEffect(() => {
    /* 在Vue更新后执行 */
  });

  /* 同步侦听器 */
  /* 还可以创建一个同步触发的侦听器,它会在Vue进行任何更新之前触发 */
  watch(source, callback, { flush: "sync" });
  watchEffect(callback, { flush: "sync" });

  /* 当然,同步触发的watchEffect也有一个方便的别名,watchSyncEffect */
  watchSyncEffect(() => {
    /* 在响应式数据变化时同步执行 */
  });
</script>
谨慎使用:
  • 同步侦听器不会进行批处理,每当检测到响应式数据发生变化时就会触发。可以使用它来检测简单的布尔值但应避免可能多次同步修改的数据源(如数组上)使用

代码示例

javascript
import { ref, watchEffect, watchSyncEffect } from "vue";

const count = ref(0);

// 正常的:只会打印一次 "Total: 1000"
watchEffect(() => {
  console.log("普通 Effect:", count.value);
});

// 同步的:会瞬间刷屏打印 1000 次!
watchSyncEffect(() => {
  console.log("同步 Effect:", count.value);
});

// 同步修改 100 次
for (let i = 0; i < 1000; i++) {
  count.value++;
}

停止侦听器

  • setup()<script setup>中用同步语句创建的侦听器,会自动绑定宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,无需关心怎么停止一个侦听器

  • 一个关键点是,侦听器必须用同步语句创建,如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏

html
<script setup>
  import { ref, watchEffect } from "vue";

  /* 它会自动停止 */
  watchEffect(() => {});

  /* ...这个则不会 */
  setTimeout(() => {
    watchEffect(() => {});
  }, 1000);

  /* 要手动停止一个侦听器,需要调用watch或watchEffect返回的函数 */
  const unwatch = watchEffect(() => {});
  /* ...当侦听器不再需要时 */
  unwatch();

  /* 注意:需要异步创建侦听器的情况很少,尽可能选择同步创建。如果需要等待一些异步数据,可以使用条件式的侦听逻辑 */
  const data = ref(null); /* 需要异步请求得到的数据 */

  watchEffect(() => {
    if (data.value) {
      /* 数据加载后执行某些操作 */
    }
  });
</script>

12. 模板引用

  • 虽然Vue声明性渲染模型为你抽象了大部分对DOM直接操作,但在某些情况下,我们仍然需要直接访问底层DOM元素。要实现这一点,需要使用到特殊的ref attribute

  • ref是一个特殊的attribute,它允许我们在一个特定的DOM元素子组件实例被挂载后,获得对它的直接引用。比如我们需要在组件挂载时焦点设置到一个input元素上,或在一个元素上初始化一个第三方库

html
<input ref="inputEl" />

<script setup>
  import { ref, onMounted } from "vue";

  const inputEl = ref(null);

  onMounted(() => {
    input.value.focus();
  });
</script>

访问模板引用

  • 要在组合式API中获取引用,我们可以使用辅助函数useTemplateRef()

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

html
<!-- 在使用TypeScript时,Vue的IDE支持和`vue-tsc`将根据匹配的`ref`attribute所用的元素或组件自动推断input.value的类型 -->

<script setup>
  import { useTemplateRef, onMounted, watchEffect } from "vue";

  /* Vue 3.5+ */
  /* 第一个参数必须与模板中的 ref 值匹配 */
  const input = useTemplateRef("my-input");

  onMounted(() => {
    input.value.focus();
  });

  watchEffect(() => {
    if (input.value) {
      input.value.focus();
    } else {
      /* 此时还未挂载,或此元素已经被卸载(例如通过v-if控制) */
    }
  });
</script>

<template>
  <input ref="my-input" />
</template>

<!-- 在3.5之前的版本尚未引入useTempalteRef(),我们需要声明一个与模板里ref attribute匹配的引用 -->
<script setup>
  import { ref, onMounted } from "vue";

  /* 声明一个 ref 来存放该元素的引用 */
  /* 必须和模板里的 ref 同名 */
  const input = ref(null);

  onMounted(() => {
    input.value.focus();
  });
</script>

<template>
  <input ref="input" />
</template>

组件上使用模板引用

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

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

html
<script setup>
  import { ref } from "vue";

  const a = 1;
  const b = ref(2);

  /* 像 defineExpose 这样的编译器宏不需要导入 */
  /* 
    当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为{ a: number, b: number }(ref都会自动解包,和一般的实例一样) 
    请注意:defineExpose必须在任何await操作之前调用。否则,在await操作后暴露的属性和方法将无法访问
  */
  defineExpose({
    a,
    b,
  });
</script>

组件模板引用上使用TypeScript

  • Vue 3.5+@vue/language-tools 2.1(为IDE语言服务和vue-tsc提供支持)中,在单文件组件中由useTemplateRef()创建的ref类型可以基于匹配的ref attribute所在的元素自动推断为静态类型
  • 无法自动推断的情况下(如非单文件组件使用或动态组件),仍然可以通过泛型参数将模板ref强制转换为显示类型
  • 请注意在@vue/language-tools 2.1以上版本中,静态模板ref类型可以被自动推导,下面的案例中,仅在极端情况下需要。
html
<!-- 
  为了获取导入组件的实例类型,我们需要先通过 typeof 获取其类型,然后使用TypeScript的内置 InstanceType 工具提取其实例类型
-->
<script setup lang="ts">
  import { useTemplateRef } from "vue";

  import Foo from "./Foo.vue";
  import Bar from "./Bar.vue";

  type FooType = InstanceType<typeof Foo>;
  type BarType = InstanceType<typeof Bar>;

  const compRef = useTemplateRef<FooType | BarType>("comp");
</script>

<template>
  <component :is="Math.random() > 0.5 ? Foo : Bar" ref="comp" />
</template>

<!--
  如果组件的具体类型无法获得,或者并不关心组件的具体类型,那么可以使用 `ComponentPublicInstance`,这是会包含所有组件都共享的属性,比如 $el
-->
<script setup lang="ts">
  import { useTemplateRef } from "vue";
  import type { ComponentPublicInstance } from "vue";

  const child = useTemplateRef<ComponentPublicInstance>("child");
</script>

<!-- 如果引用的组件是一个泛型组件,例如MyGenericModal -->
<!-- MyGenericModal.vue -->
<script setup lang="ts" generic="ContentType extends string | number">
  import { ref } from "vue";

  const content = ref<ContentType | null>(null);

  const open = (newContent: ContentType) => (content.value = newContent);

  defineExpose({
    open,
  });
</script>

<!-- 
  则需要使用 vue-component-type-helpers库中的ComponentExposed引用组件类型,因为InstanceType在这种场景下不起作用
-->
<!-- App.vue -->
<script setup lang="ts">
  import { useTemplateRef } from "vue";
  import MyGenericModal from "./MyGenericModal.vue";
  import type { ComponentExposed } from "vue-component-type-helpers";

  const modal =
    useTemplateRef<ComponentExposed<typeof MyGenericModal>>("modal");

  const openModal = () => {
    modal.value?.open("newValue");
  };
</script>

v-for中的模板引用

  • 需要在 Vue 3.5+以上版本,当在v-for中使用模板引用时,对应的ref中包含的值是一个数组,它将在元素被挂载后包含对应整个列表的所有元素
  • 3.5版本以前useTemplateRef()尚未引入需要声明一个与模板引用attribute同名的ref。该ref的值需要是一个数组
  • 注意:ref数组并不保证与源数组相同的顺序。
html
<script setup>
  import { ref, useTemplateRef, onMounted } from "vue";

  const list = ref([
    /* ... */
  ]);

  const itemRefs = useTemplateRef("items");

  onMounted(() => console.log(itemRefs.value));
</script>

<template>
  <ul>
    <li v-for="item in list" ref="items">{{ item }}</li>
  </ul>
</template>

<!-- Vue 3.5 版本以下的用法 -->
<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="items">{{ item }}</li>
  </ul>
</template>

函数模板引用:

  • 除了使用字符串值作为名字ref attribute还可以绑定一个函数,会在每次组件更新时都被调用。该函数会收到元素引用作为其第一个参数
html
<input :ref="(el) => { /* 将el赋值给一个数据属性或ref变量 */ }" />

注意:

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


13. 组件基础

  • 组件允许我们将UI划分为独立的可重用的部分,并且可以对每个部分进行单独的思考。在实际应用中,组件常常被组织成一个层层嵌套的树状结构

组件树


定义一个组件

  • 当使用构建步骤时,我们一般会将Vue组件定义在一个单独的.vue文件中,这被叫做单文件组件(简称 SFC)

  • 不使用构建步骤时,一个Vue组件以一个包含Vue的特定选项的JavasScript对象来定义

html
<!-- child.vue -->
<script setup>
  import { ref } from "vue";

  const count = ref(0);
</script>

<template>
  <button @click="count++">You clicked me {{ count }} times.</button>
</template>

<!-- 不适用构建工具 -->
<script type="module">
  import { ref } from "vue";

  /*
    这里的模板是一个内联的JavaScript字符串,Vue将会在运行时编译它。你也可以使用ID选择器来指向一个元素(通常是原生的<template>元素),Vue将会使用其内容作为模板来源。
  */
  export default {
    setup() {
      const count = ref(0);
      return {
        count,
      };
    },
    /* 也可以针对一个DOM内联模板,使用选择器:template: '#child' */
    template: `<button @click="count++"> You clicked me {{ count }} times.</button>`,
  };
</script>

<div id="child">
  <button @click="count++">You clicked me {{ count }} times.</button>
</div>

使用组件

  • 单文件组件中,推荐子组件使用PascalCase标签名,以此来和原生的HTML元素作区分。虽然元素HTML标签名不区分大小写的,但Vue单文件组件是可以在编译中区分大小写的。我们也可以使用/>来关闭一个标签
  • 如果是直接在DOM中书写模板(例如原生<template>元素的内容),模板的编译需要遵从浏览器中HTML的解析行为。在这种情况下,应该需要使用kebab-case形式并显示地关闭这些组件的标签

传递props

  • Props是一种特别的attributes,你可以在组件上声明注册。需要传递给博客文章组件一个标题,我们必须在组件的props列表上声明它。这里需要用到defineProps宏
  • 一个组件可以有任意多props,默认情况下,所有的props都接受任意类型的值
  • 当一个prop被注册后,可以像这样以自定义attribute的形式传递数据给它。
html
<!-- BlogPost.vue -->
<!--
  defineProps是一个仅<script setup>中可用的编译宏命令,并不需要显式地导入。声明的props会自动暴露给模板。defineProps会返回一个对象。其中包含了可以传递给组件的所有props
-->
<script setup>
  const props = defineProps(["title"]);
  console.log(props.title);
</script>

<template>
  <h2>{{ title }}</h2>
</template>

<!-- 如果没有使用<script setup>,props必须以props选项的方式声明,props对象会作为setup()函数的第一个参数被传入 -->
<script>
  export default {
    props: ["title"],
    setup(props) {
      console.log(props.title);
    },
  };
</script>

<!-- 当一个prop被注册后,可以像这样以自定义attribute的形式传递数据给它 -->
<BlogPost title="My Journey with Vue" />
<BlogPost title="Blogging with Vue" />
<BlogPost title="Why Vue is so fun" />

<!-- 在实际运用中,我们可能在父组件中会有如下的一个博客文章数组 -->
<script setup>
  import { ref } from "vue";

  const posts = ref([
    { id: 1, title: "My journey with Vue" },
    { id: 2, title: "Blogging with Vue" },
    { id: 3, title: "Why Vue is so fun" },
  ]);
</script>

<!-- 这种情况下,我们可以使用v-for来渲染它们 -->
<BlogPost v-for="post in posts" :key="post.id" :title="post.title" />

为组件Props标注类型

  • 当使用<script setup>时,defineProps宏函数支持从它的参数中推导类型
html
<!-- 
  这被称之为“运行时声明”,因为传递给defineProps()的参数会作为运行时的props选项使用
-->
<script setup lang="ts">
  const props = defineProps({
    foo: { type: string, required: true },
    bar: Number,
  });

  props.foo; /* string */
  props.bar; /* number | undefined */
</script>

<!-- 然而,通过泛型参数来定义props的类型通常更直接 -->
<!--
  这被称之为“基于类型的声明”。编译器会尽可能地尝试根据类型参数推导出等价的运行时选项。在这种场景下,我们第二个例子中编译出的运行时选项和第一个完全一致的。
  基于类型的声明或者运行时声明可以择一使用,但是不能同时使用
-->
<script setup lang="ts">
  const props = defineProps<{
    foo: string;
    bar?: number;
  }>();
</script>

父子组件之间的通信

  • 子组件需要通过调用内置的$emit方法,通过传入的事件名称抛出一个事件。然后父组件可以通过v-on@选择性地监听子组件上抛的事件,就像监听原生DOM事件那样。
html
<!-- Child.vue -->

<script setup>

</script>

<template>
  <button @click="$emit('increment', 1)">+1<button>
  <button @click="#emit('decrement', -1)">-1<button>
</template>

<!-- 我们也可以通过defineEmits宏来声明需要抛出的事件 -->
<script setup>
  const emit = defineEmits(["increment", "decrement"])
  const incremenClicktEvent = () => {
    emit('increment', 1)
  }
  const decrementClickEvent = () => {
    emit('decrement', -1)
  }
</script>

<template>
  <button @click="incremenClicktEvent">+1</button>
  <button @click="decrementClickEvent">-1</button>
</template>



<!-- 父组件监听子组件抛出来的事件 -->
<template>
  <Child @increment='handleIncrement'
         @decrement='handleDecrement("自己的参数", $event)'/>
</template>

<script setup>
  import Child from "./Child.vue"

  const handleIncrement = (amount) => { /* ... */ }
  const handleDecrement = (x, y) => { /* ... */ }
</script>



<!-- 如果在没有使用<script setip>,可以通过emits选项定义组件会抛出的事件。你可以从setup()函数的第二个参数,即setup上下文对象访问到emit函数 -->
<script>
  export default {
    emits: ['enlarge-text'],
    setup(props, ctx) {
      ctx.emit('enlarge-text')
    }
  }
</script>

通过插槽来分配内容

  • 一些情况下我们会希望能和HTML元素一样向组件中传递内容,这可以通过Vue的自定义<slot>元素来实现。
html
<!-- AlertBox.vue> -->
<template>
  <div class="alert-box">
    <strong>This is an Error for Demo Purposes</strong>
    <slot />
  </div>
</template>

<!-- 父组件 -->
<AlertBox> Something bad happened. </AlertBox>

<!-- 最终渲染 -->
<div class="alert-box">
  <strong>This is an Error for Demo Purposes</strong>
  Something bad happened.
</div>

动态组件

  • 有些场景会需要在两个组件来回切换,比如Tab界面,可以使用Vue<component>元素和特殊的isattribute实现。
  • 下面的例子中,被传入的:is的值可以是以下几种:
    • 被注册的组件名
    • 导入的组件对象
html
<!-- currentTab 改变时组件也会改变 -->
<component :is="tabs[currentTab]"></component>

<!--
  当使用<component :is="...">来在多个组件间作切换时,被切换掉的组件会被卸载。我们可以通过<KeepAlive>组件强制被切换掉的组件仍然保持“存活”的状态
-->
<KeepAlive>
  <component :is="tabs[currentTab]" />
</KeepAlive>

元素位置限制

  • 某些HTML元素对于放在其中的元素类型有限制,例如<ul><ol><table><select>,相应的,某些元素仅在放置于特定元素中时才会显示,例如<li><tr><option>
  • 这将导致在使用带有此类限制元素的组件时出现问题,例如:
html
<!--
  🧐 为什么需要 is Attribute?
    这个问题核心在于 HTML 的解析限制,而不是 Vue 或 Vite 的限制
    1. 🌐 HTML 解析限制(只在 DOM 环境出现)
      HTML 有一套严格的父子元素结构要求。如果一个父元素只允许特定的子元素,而你尝试在里面使用一个 Vue 自定义组件标签,浏览器在解析 HTML 时会进行 自动修正(Correction),导致结构破坏。
-->

<table>
  <blog-post-row></blog-post-row>
</table>

<!-- 自定义组件<blog-post-row>将作为无效的内容被忽略,因而在最终呈现的输出中造成错误。我们可以使用特殊的 is attribute作为一种解决方案 -->
<table>
  <!-- 当使用原生HTML元素上时,id的值必须加上前缀vue: 才可以被解析为一个Vue组件。这一点是必要的,为了避免和原生的自定义内置元素相混淆 -->
  <tr is="vue:blog-post-row"></tr>
</table>

14. 生命周期

  • 每个Vue组件实例创建时都需要经历一系列初始化步骤,比如设置好数据侦听编译模板挂载实例到DOM,以及在数据改变时更新DOM。在此过程中,它也会运行被称之为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码
  • Vue巧妙地将时序(调用栈)全局状态结合起来,确保了在权限范围内生命周期钩子能在正确的时机注册到正确的实例上。
html
<!-- 
  Vue在执行钩子函数的时候,Vue会自动将回调函数注册到当前正被初始化的组件上,这意味着这些钩子应当在组件初始化时被同步注册 
-->
<script setup>
  import { onMounted } from "vue";

  /*
    注意:这并不意味着对onMounted的调用必须放在setup()或<script setup>内的词法上下文中。
    onMounted()也可以在一个外部函数中调用,只要调用栈是同步的,且最终起源自setup()就可以。
  */

  setTimeout(() => {
    onMounted(() => {
      /* 异步注册当前组件实例已丢失 */
      /* 这将不会正常工作 */
    });
  }, 1000);
</script>

生命周期图示

vue生命周期


15. 组件注册

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


全局注册

我们可以使用Vue应用实例.component()方法,让组件在当前Vue应用中全局可用

javascript
import { createApp } from "vue"

const app = createApp({})

app.component(
  {
    /* 组件的名字 */
    'MyComponent',
    /* 组件的实现 */
    {
      /* ... */
    }
  }
)
  • 全局注册的组件可以在此应用的任意组件的模板中使用。

局部注册

全局注册虽然很方便,但有以下几个问题:

  • 全局注册,没有被使用的组件无法在生产打包时被自动移除 (也叫“tree-shaking”)。如果你全局注册了一个组件,即使它并没有被实际使用,它仍然会出现在打包后的 JS 文件中。

  • 全局注册在大型项目中使项目的依赖关系变得不那么明确。在父组件中使用子组件时,不太容易定位子组件的实现。和使用过多的全局变量一样,这可能会影响应用长期的可维护性

相比之下,局部注册的组件需要在使用它的父组件显式导入,并且只能在该父组件中使用。它的优点是使组件之间的依赖关系更加明确,并且对 tree-shaking 更加友好。

在使用 <script setup> 的单文件组件中,导入的组件可以直接在模板中使用,无需注册

html
<script setup>
  import ComponentA from "./ComponentA.vue";
</script>

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

16. Props

一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute

vue
<script setup>
const props = defineProps(["foo"]);

console.log(props.foo);
</script>
  • 注意传递给 defineProps() 的参数和提供给 props 选项的值是相同的,两种声明方式背后其实使用的都是 props 选项。

  • 除了使用字符串数组来声明 props 外,还可以使用对象的形式:
vue
/* 使用 <script setup> */
defineProps({
  title: String,
  likes: Number,
});
  • 对于以对象形式声明的每个属性,keyprop 的名称,而值则是该 prop 预期类型的构造函数。比如,如果要求一个 prop 的值是 number 类型,则可使用 Number 构造函数作为其声明的值。

  • 如果你正在搭配 TypeScript 使用 <script setup>,也可以使用类型标注来声明 props
vue
<script setup lang="ts">
defineProps<{
  title?: string;
  likes?: number;
}>();
</script>

响应式 Props 解构

    Vue 的响应系统基于属性访问跟踪状态使用情况。例如,在计算属性侦听器中访问 props.foo 时,foo 属性将被跟踪为依赖项

因此,在以下代码的情况下:

vue
const { foo } = defineProps(["foo"]);

watchEffect(() => {
  // 在 3.5 之前只运行一次
  // 在 3.5+ 中在 "foo" prop 变化时重新执行
  console.log(foo);
});

    在 3.4及以下版本foo 是一个实际的常量,永远不会改变。在 3.5 及以上版本,当在同一个 <script setup> 代码块中访问由 defineProps 解构的变量时,Vue 编译器会自动在前面添加 props.。因此,上面的代码等同于以下代码:

vue
const props = defineProps(["foo"]);

watchEffect(() => {
  // `foo` 由编译器转换为 `props.foo`
  console.log(props.foo);
});
  • 此外,你可以使用 JavaScript 原生的默认值语法声明 props 默认值。这在使用基于类型的 props 声明时特别有用。
vue
const { foo = "hello" } = defineProps<{ foo?: string }>();

单向数据流

    所有的 props 都遵循着单向绑定原则,props父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱难以理解

  • 更改一个 prop 的需求通常来源于以下两种场景
vue
/* 1. prop 被用于传入初始值;而子组件想在之后将其作为一个局部数据属性。在这种情况下,最好是新定义一个局部数据属性,从 props 上获取初始值即可: */

const props = defineProps(["initialCounter"]);

// 计数器只是将 props.initialCounter 作为初始值
// 像下面这样做就使 prop 和后续更新无关了
const counter = ref(props.initialCounter);

/* 2. 需要对传入的 prop 值做进一步的转换。在这种情况中,最好是基于该 prop 值定义一个计算属性 */
const props = defineProps(["size"]);

// 该 prop 变更时计算属性也会自动更新
const normalizedSize = computed(() => props.size.trim().toLowerCase());
  • 最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件通知父组件做出改变

Prop 校验

    Vue 组件可以更细致地声明对传入的 props 的校验要求。比如我们上面已经看到过的类型声明,如果传入的值不满足类型要求,Vue 会在浏览器控制台抛出警告提醒使用者。这在开发给其他开发者使用的组件时非常有用

  • 要声明对 props 的校验,你可以向 defineProps() 宏提供一个带有 props 校验选项的对象,例如:
javascript
defineProps({
  // 基础类型检查
  // (给出 `null` 和 `undefined` 值则会跳过任何类型检查)
  propA: Number,
  // 多种可能的类型
  propB: [String, Number],
  // 必传,且为 String 类型
  propC: {
    type: String,
    required: true,
  },
  // 必传但可为 null 的字符串
  propD: {
    type: [String, null],
    required: true,
  },
  // Number 类型的默认值
  propE: {
    type: Number,
    default: 100,
  },
  // 对象类型的默认值
  propF: {
    type: Object,
    // 对象或数组的默认值
    // 必须从一个工厂函数返回。
    // 该函数接收组件所接收到的原始 prop 作为参数。
    default(rawProps) {
      return { message: "hello" };
    },
  },
  // 自定义类型校验函数
  // 在 3.4+ 中完整的 props 作为第二个参数传入
  propG: {
    validator(value, props) {
      // The value must match one of these strings
      return ["success", "warning", "danger"].includes(value);
    },
  },
  // 函数类型的默认值
  propH: {
    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 来检查类型是否匹配。例如下面这个类:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
}
  • 你可以将其作为一个 prop 的类型:
javascript
defineProps({
  author: Person,
});
  • Vue 会通过 instanceof Person 来校验 author prop 的值是否是 Person 类的一个实例。

可为 null 的类型

    如果该类型是必传但可为 null 的,你可以用一个包含 null 的数组语法:

javascript
defineProps({
  id: {
    type: [String, null],
    required: true,
  },
});
  • 注意如果 type 仅为 null 而非使用数组语法,它将允许任何类型。

Boolean 类型转换

    为了更贴近原生 boolean attributes 的行为,声明为 Boolean 类型的 props 有特别的类型转换规则。以带有如下声明的 <MyComponent> 组件为例:

defineProps({
  disabled: Boolean,
});

    该组件可以被这样使用:

vue
<!-- 等同于传入 :disabled="true" -->
<MyComponent disabled />

<!-- 等同于传入 :disabled="false" -->
<MyComponent />

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

javascript
// disabled 将被转换为 true
defineProps({
  disabled: [Boolean, Number],
});

// disabled 将被转换为 true
defineProps({
  disabled: [Boolean, String],
});

// disabled 将被转换为 true
defineProps({
  disabled: [Number, Boolean],
});

// disabled 将被解析为空字符串 (disabled="")
defineProps({
  disabled: [String, Boolean],
});

17. 事件

触发与监听事件

  • 在组件的模板表达式中,可以直接使用 $emit 方法触发自定义事件 (例如:在 v-on 的处理函数中):
Vue
<!-- MyComponent -->
<button @click="$emit('someEvent')">Click me</button>

<!-- 父组件可以通过v-on(缩写为@)来监听事件 -->
<MyComponent @some-event="callback" />

<!-- 同样,组件的事件监听器也支持.once修饰符 -->
<MyComponent @some-event.once="callback" />
  • 组件与prop一样,事件的名字也提供了自动的格式转换
  • 注意这里我们触发了一个以camelCase形式命名的事件,但在父组件中可以使用kebab-case形式来监听。与prop大小写格式一样,在模板中我们也推荐使用kebab-case形式来编写监听器
注意

    和原生DOM事件不一样,组件触发的事件没有冒泡机制。你只能监听子组件触发的事件平级组件或是跨越多层嵌套的组件间通信,应使用一个外部的事件总线,或是使用一个全局状态管理方案


事件参数

  • 有时候我们会需要在触发事件时附带一个特定的值。举例来说,我们想要<BlogPost>组件来管理文本会缩放得多大。在这个场景下,我们可以给$emit提供一个额外得参数
Vue
<button @click="$emit('increaseBy', 1)">Increase by 1</button>
  • 然后再父组件的监听事件中,我们可以先简单写一个内联的箭头函数作为监听器,此函数会接受到事件附带的参数:
Vue
<MyButton @increase-by="(n) => count += n" />
  • 或者,也可以用一个组件方法来作为事件处理函数:
Vue
<MyButton @increase-by="increaseCount" />
  • 该方法也会接收到事件所传递的参数:
Vue
function increaseCount(n) {
  count.value += n;
}
Tip

    所有传入$emit()额外参数都会被直接传向监听器。举例来说,$emit('foo', 1, 2, 3)触发后,监听器函数将会收到这三个参数值


声明触发的事件

  • 组件可以显示地通过defineEmits()宏来声明它要触发的事件:
Vue
<script setup>
defineEmits(["inFocus", "submit"]);
</script>

    我们在<template>中使用的$emit方法不能在组件的<script setup>部分中使用,但defineEmits()会返回一个相同作用的函数供我们使用

Vue
<script setup>
const emit = defineEmits(["inFocus", "submit"]);

function buttonClick() {
  emit("submit", 1, 2, 3);
}
</script>
  • defineEmits()宏不能在子函数中使用。如上所示,它必须放置在<script setup>的顶层作用域下。

为组件的 emits 标注类型

Vue
<script setup lang="ts">
// 运行时
const emit = defineEmits(["change", "update"]);

// 基于选项
const emit = defineEmits({
  change: (id: number) => {
    // 返回 `true` 或 `false`
    // 表明验证通过或失败
  },
  update: (value: string) => {
    // 返回 `true` 或 `false`
    // 表明验证通过或失败
  },
});

// 基于类型
/* 这是 TypeScript 的 调用签名(Call Signatures) 语法 */
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>
  • 尽管事件声明是可选的,我们还是推荐你完整地声明所有要触发的事件,以此在代码中作为文档记录组件的用法。同时,事件声明能让Vue更好地将事件透传attribute做出区分,从而避免一些由第三方代码触发的自定义DOM事件所导致的边界情况
Tip

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


事件校验

  • 和对 props 添加类型校验的方式类似,所有触发的事件也可以使用对象形式来描述。

  • 要为事件添加校验,那么事件可以被赋值为一个函数接受的参数就是抛出事件时传入 emit 的内容返回一个布尔值来表明事件是否合法

Vue
<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>