快速开始

本文最后更新于:2023年12月5日 晚上

基础

模板语法

attribute

{{}}不能在 HTML attributes 中使用。想要响应式的绑定一个 attribute,应该使用v-bind指令是:

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

因为v-bind非常常用,我们提供了特定的简写语法:

<div :id="dynamicId"></div>

使用 js 表达式

实际上,vue 在所有的数据绑定中都支持完整的 js 表达式。

每个帮顶仅支持单一表达式,也就是一段能够被求值的 js 代码。一个简单的判断是是否可以合法的写在return后面。

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

受限的全局访问:

模板中的表达式将被沙盒化,仅能够访问到有限的全局对象列表。该列表中会暴露常用的内置全局对象,比如 MathDate

没有显式包含在列表中的全局对象将不能在模板内表达式中访问,例如用户附加在 window 上的属性。然而,你也可以自行在 app.config.globalProperties 上显式地添加它们,供所有的 Vue 表达式使用。

指令

指令是带有v-前缀的特殊 attribute,vue 提供了许多内置指令,包括上面提到的v-bind

指令 attribute 的期望值是一个 js 表达式(除了v-forv-onv-slot 这几个少数的例外)。一个指令的任务是在其表达式的值变化时响应式地更新 DOM。

v-if为例:

<p v-if="seen">Now you see me</p>

这里,v-if 指令会基于表达式 seen 的值的真假来移除/插入该 <p> 元素。

响应式基础

reactive()

我们可以使用 reactive() 函数创建一个响应式对象或数组:

import { reactive } from "vue"

const state = reactive({ count: 0 })

<script setup>

要在组件模板中使用响应式状态,需要在 setup() 函数中定义并返回。

<script>
import { reactive } from "vue"

export default {
 setup() {
  const state = reactive({ count: 0 })

  function increment() {
   state.count++
  }

  // 不要忘记同时暴露 increment 函数
  return {
   state,
   increment,
  }
 },
}
</script>

在 setup() 函数中手动暴露大量的状态和方法非常繁琐。幸运的是,我们可以通过使用构建工具来简化该操作。当使用单文件组件(SFC)时,我们可以使用 <script setup> 来大幅度地简化代码。

<script setup>
import { reactive } from "vue"

const state = reactive({ count: 0 })

function increment() {
 state.count++
}
</script>

<script setup> 中的顶层的导入和变量声明可在同一组件的模板中直接使用。你可以理解为模板中的表达式和 <script setup> 中的代码处在同一个作用域中。

DOM 更新时机

当你更改响应式状态后,DOM 会自动更新。然而,你得注意 DOM 的更新并不是同步的。相反,Vue 将缓冲它们直到更新周期的 “下个时机” 以确保无论你进行了多少次状态更改,每个组件都只更新一次。

若要等待一个状态改变后的 DOM 更新完成,你可以使用 nextTick() 这个全局 API:

import { nextTick } from "vue"

function increment() {
 state.count++
 nextTick(() => {
  // DOM更新后执行...
  // 访问更新后的DOM
 })
}

深层响应性

在 Vue 中,状态都是默认深层响应式的。这意味着即使在更改深层次的对象或数组,你的改动也能被检测到。

import { reactive } from "vue"

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

function mutateDeeply() {
 // 以下都会按照期望工作
 obj.nested.count++
 obj.arr.push("baz")
}

你也可以直接创建一个浅层响应式对象。它们仅在顶层具有响应性,一般仅在某些特殊场景中需要。

响应式代理 vs 原始对象

值得注意的是,reactive() 返回的是一个原始对象的 Proxy,它和原始对象是不相等的:

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

// 代理对象和原始对象不是全等的
console.log(proxy === raw) // false

只有代理对象是响应式的,更改原始对象不会触发更新。因此,使用 vue 的响应式系统的最佳实践是 仅使用你声明对象的代理版本

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

// 在同一个对象上调用 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

reactive()的局限性

因为 js 没有可以作用于所有值类型的“引用”机制。所以reactive() API 有两条限制:

  1. 仅对对象类型有效(对象、数组和 MapSet 这样的集合类型),而对 stringnumberboolean 这样的 原始类型 无效。
  2. 必须始终保持对响应式对象的相同引用。不可以随意地“替换”一个响应式对象,因为这将导致对初始引用的响应性连接丢失:
let state = reactive({ count: 0 })

// 上面的引用({{count: 0})将不再被追踪(响应性连接已丢失!)
state = reactive({ count: 1 })

同时这也意味着当我们将响应式对象的属性赋值或解构至本地变量时,或是将该属性传入一个函数时,我们会失去响应性:

const state = reactive({ count: 0 })

let n = state.count // n是一个局部变量,同 state.count 失去响应性连接
n++ // 不影响原始的state.count

let { count } = state // count也和state.count失去了响应性连接
count++ // 不会影响到原始的state

callSomeFunction(state.count) // 该函数接收一个普通数字,并且将无法跟踪state.count的变化

ref()

js 没有可以作用于所有值类型的“引用”机制,为此,vue 提供了一个ref()方法来允许我们创建可以使用任何值类型的响应式 ref:

import { ref } from "vue"

const count = ref(0)

ref()将传入参数的值包装为一个带.value属性的 ref 对象:

const count: Ref<number> = ref(0)

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

count.value++
console.log(count.value) // 1

和响应式对象的属性类似,ref 的 .value 属性也是响应式的。同时,当值为对象类型时,会用 reactive() 自动转换它的 .value

一个包含对象类型值的 ref 可以响应式地替换整个对象:

const objectRef = ref({ count: 0 })

// 这是响应式的替换
objectRef.value = { count: 1 }

ref 被传递给函数或是从一般对象上被解构时,不会丢失响应性:

const obj = {
 foo: ref(1),
 bar: ref(2),
}

// 该函数接收一个 ref
// 需要通过 .value 取值
// 但它会保持响应性
callSomeFunction(obj.foo)

// 仍然是响应式的
const { foo, bar } = obj

简言之,ref() 让我们能创造一种对任意值的 “引用”,并能够在不丢失响应性的前提下传递这些引用。这个功能很重要,因为它经常用于将逻辑提取到 组合函数 中。

ref 在模板中的解包

当 ref 在模板中作为顶层属性被访问时,它们会自动“解包”,所以不需要使用.value,示例:

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

const count = ref(0)

function increment() {
 count.value++
}
</script>

<template>
 <button @click="increment">
  {{ count }}
  <!-- 无需 .value -->
 </button>
</template>

请注意,仅当 ref 是模板渲染上下文的顶层属性时才适用自动“解包”。 例如:

const object = { foo: ref(1) }

下面的表达式将不会像预期的那样工作:

{
 {
  object.foo + 1
 }
}

因为此时 ref 所在的上下文是object而不是模板。我们可以将foo提取出来,这样 ref 的上下文就是模板了:

const { foo } = object
{
 {
  foo + 1
 }
}

需要注意的是,如果是下面这种情况,直接渲染,不参与计算,则也会被自动解包:

{
 {
  object.foo
 }
} // 相当于 {{ object.foo.value }}

ref 在响应式对象中的解包

当一个ref被嵌套在一个响应式对象中,作为属性被访问或更改时,它会自动解包,因此会表现的和一般属性一样:

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 解包,当其作为浅层响应式对象的属性被访问时不会解包。

数组和集合类型的 ref 解包

跟响应式对象不同,当 ref 作为响应式数组或像 Map 这种原生集合类型的元素被访问时,不会进行解包。

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)

计算属性

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

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

// 一个计算属性 ref
const publishedBooksMessage = computed<"Yes" | "No">(() => {
 return author.books.length > 0 ? "Yes" : "No"
})
</script>

<template>
 <p>Has published books:</p>
 <span>{{ publishedBooksMessage }}</span>
</template>

我们在这里定义了一个计算属性 publishedBooksMessagecomputed() 方法期望接收一个 getter 函数,返回值为一个计算属性 ref。和其他一般的 ref 类似,你可以通过 publishedBooksMessage.value 访问计算结果。计算属性 ref 也会在模板中自动解包,因此在模板表达式中引用时无需添加 .value

vue 的计算属性会自动追踪响应式依赖。它会检测到 publishedBooksMessage 依赖于 author.books,所以当 author.books 改变时,任何依赖于 publishedBooksMessage 的绑定都会同时更新。

计算属性缓存 vs 方法

你可能注意到我们在表达式中像这样调用一个函数也会获得和计算属性相同的结果:

<p>{{ calculateBooksMessage() }}</p>
// 组件中
function calculateBooksMessage() {
 return author.books.length > 0 ? "Yes" : "No"
}

若我们将同样的函数定义为一个方法而不是计算属性,两种方式在结果上确实是完全相同的,然而,不同之处在于计算属性值会基于其响应式依赖被缓存。一个计算属性仅会在其响应式依赖更新时才重新计算。这意味着只要 author.books 不改变,无论多少次访问 publishedBooksMessage 都会立即返回先前的计算结果,而不用重复执行 getter 函数。

这也解释了为什么下面的计算属性永远不会更新,因为 Date.now() 并不是一个响应式依赖:

const now = computed(() => Date.now())

相比之下,方法调用总是会在重渲染发生时再次执行函数。

为什么需要缓存呢?想象一下我们有一个非常耗性能的计算属性 list,需要循环一个巨大的数组并做许多计算逻辑,并且可能也有其他计算属性依赖于 list。没有缓存的话,我们会重复执行非常多次 list 的 getter,然而这实际上没有必要!如果你确定不需要缓存,那么也可以使用方法调用。

可写计算属性

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

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

现在当你再运行 fullName.value = 'John Doe' 时,setter 会被调用而 firstNamelastName 会随之更新。

最佳实践

Getter 不应有副作用

计算属性的 getter 应只做计算而没有任何其他的副作用,这一点非常重要,请务必牢记。举例来说,不要在 getter 中做异步请求或者更改 DOM!一个计算属性的声明中描述的是如何根据其他值派生一个值。因此 getter 的职责应该仅为计算和返回该值。在之后的指引中我们会讨论如何使用监听器根据其他响应式状态的变更来创建副作用。

避免直接修改计算属性值

从计算属性返回的值是派生状态。可以把它看作是一个“临时快照”,每当源状态发生变化时,就会创建一个新的快照。更改快照是没有意义的,因此计算属性的返回值应该被视为只读的,并且永远不应该被更改——应该更新它所依赖的源状态以触发新的计算。

Class 与 Style 绑定

因为 classstyle 都是 attribute,我们可以和其他 attribute 一样使用 v-bind 将它们和动态的字符串绑定。但是,在处理比较复杂的绑定时,通过拼接生成字符串是麻烦且易出错的。因此,Vue 专门为 classstylev-bind 用法提供了特殊的功能增强。除了字符串外,表达式的值也可以是对象或数组。

绑定 HTML class

绑定对象

:classv-bind:class 的缩写。

<div :class="{ active: isActive }"></div>

也可以直接绑定一个对象:

const classObject = reactive({
 active: true,
 "text-danger": false,
})
<div :class="classObject"></div>

也可以绑定一个返回对象的计算属性。这是一个常见且很有用的技巧:

const isActive = ref(true)
const error = ref(null)

const classObject = computed(() => ({
 active: isActive.value && !error.value,
 "text-danger": error.value && error.value.type === "fatal",
}))
<div :class="classObject"></div>
绑定数组

我们可以给 :class 绑定一个数组来渲染多个 CSS class:

const activeClass = ref("active")
const errorClass = ref("text-danger")
<div :class="[activeClass, errorClass]"></div>

渲染的结果是:

<div class="active text-danger"></div>

如果你也想在数组中有条件地渲染某个 class,你可以使用三元表达式:

<div :class="[isActive ? activeClass : '', errorClass]"></div>

然而,这可能在有多个依赖条件的 class 时会有些冗长。因此也可以在数组中嵌套对象:

<div :class="[{ active: isActive }, errorClass]"></div>
在组件上使用
<!-- 子组件模板 -->
<p class="foo bar">Hi!</p>

<!-- 在使用组件时 -->
<MyComponent class="baz boo" />

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

绑定内联样式

绑定对象
const activeColor = ref("red")
const fontSize = ref(30)
<div :style="{ 'font-size': fontSize + 'px' }"></div>

直接绑定一个样式对象通常是一个好主意,这样可以使模板更加简洁:

const styleObject = reactive({
 color: "red",
 fontSize: "13px",
})
<div :style="styleObject"></div>

同样的,如果样式对象需要更复杂的逻辑,也可以使用返回样式对象的计算属性。

绑定数组

我们还可以给 :style 绑定一个包含多个样式对象的数组。这些对象会被合并后渲染到同一元素上:

<div :style="[baseStyles, overridingStyles]"></div>
自动前缀
样式多值

条件渲染

v-if

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

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

v-ifv-elsev-else-if 也可以在 <template> 上使用。

<template v-if="ok">
 <h1>Title</h1>
 <p>Paragraph 1</p>
 <p>Paragraph 2</p>
</template>

v-show

另一个可以用来按条件显示一个元素的指令是 v-show。其用法基本一样:

<h1 v-show="ok">Hello!</h1>

不同之处在于 v-show 会在 DOM 渲染中保留该元素;v-show 仅切换了该元素上名为 display 的 CSS 属性。

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

v-ifv-show

v-if 是“真实的”按条件渲染,因为它确保了在切换时,条件区块内的事件监听器和子组件都会被销毁与重建。

v-if 也是惰性的:如果在初次渲染时条件值为 false,则不会做任何事。条件区块只有当条件首次变为 true 时才被渲染。

相比之下,v-show 简单许多,元素无论初始条件如何,始终会被渲染,只有 CSS display 属性会被切换。

总的来说,v-if 有更高的切换开销,而 v-show 有更高的初始渲染开销。因此,如果需要频繁切换,则使用 v-show 较好;如果在运行时绑定条件很少改变,则 v-if 会更合适。

列表渲染

v-for

const items = ref([{ message: "Foo" }, { message: "Bar" }])
<li v-for="item in items">{{ item.message }}</li>

你也可以使用 of 作为分隔符来替代 in,这更接近 JavaScript 的迭代器语法:

<div v-for="item of items"></div>

v-for 与对象

你也可以使用 v-for 来遍历一个对象的所有属性。遍历的顺序会基于对该对象调用 Object.keys() 的返回值来决定。

const myObject = reactive({
 title: "How to do lists in Vue",
 author: "Jane Doe",
 publishedAt: "2016-04-10",
})
<ul>
 <li v-for="value in myObject">{{ value }}</li>
</ul>

<!-- 可以通过提供第二个参数表示属性名 (例如 key) -->
<li v-for="(value, key) in myObject">{{ key }}: {{ value }}</li>

<!-- 第三个参数表示位置索引 -->
<li v-for="(value, key, index) in myObject">{{ index }}. {{ key }}: {{ value }}</li>

在 v-for 里使用范围值

v-for 可以直接接受一个整数值。在这种用例中,会将该模板基于 1...n 的取值范围重复多次。

<span v-for="n in 10">{{ n }}</span>

注意此处 n 的初值是从 1 开始而非 0

<template> 上的 v-for

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

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

v-forv-if

警告:

同时使用 v-ifv-for不推荐的,因为这样二者的优先级不明显。请查看风格指南获得更多信息。

当它们同时存在于一个节点上时,v-ifv-for 的优先级更高。这意味着 v-if 的条件将无法访问到 v-for 作用域内定义的变量别名:

<!--
 这会抛出一个错误,因为属性 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>

通过 key 管理状态

vue 默认按照“就地更新”的策略来更新通过 v-for 渲染的元素列表。当数据项的顺序改变时,vue 不会随之移动 DOM 元素的顺序,而是就地更新每个元素,确保它们在原本指定的索引位置上渲染。

默认模式是高效的,但只适用于列表渲染输出的结果不依赖子组件状态或者临时 DOM 状态 (例如表单输入值) 的情况

为了给 vue 一个提示,以便它可以跟踪每个节点的标识,从而重用和重新排序现有的元素,你需要为每个元素对应的块提供一个唯一的 key attribute:

<div v-for="item in items" :key="item.id">
 <!-- 内容 -->
</div>

当你使用 <template v-for> 时,key 应该被放置在这个 <template> 容器上:

<template v-for="todo in todos" :key="todo.name">
 <li>{{ todo.name }}</li>
</template>

注意:key 在这里是一个通过 v-bind 绑定的特殊 attribute。请不要和v-for 中使用对象里所提到的对象属性名相混淆。

推荐在任何可行的时候为 v-for 提供一个 key attribute,除非所迭代的 DOM 内容非常简单 (例如:不包含组件或有状态的 DOM 元素),或者你想有意采用默认行为来提高性能。

key 绑定的值期望是一个基础类型的值,例如字符串或 number 类型。不要用对象作为 v-for 的 key。关于 key attribute 的更多用途细节,请参阅 key API 文档

组件上使用v-for

我们可以直接在组件上使用 v-for,和在一般的元素上使用没有区别 (别忘记提供一个 key):

<MyComponent v-for="item in items" :key="item.id" />

但是,这不会自动将任何数据传递给组件,因为组件有自己独立的作用域。为了将迭代后的数据传递到组件中,我们还需要传递 props:

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

不自动将 item 注入组件的原因是,这会使组件与 v-for 的工作方式紧密耦合。明确其数据的来源可以使组件在其他情况下重用。

数组变化侦测

变更方法

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

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

变更方法,顾名思义,就是会对调用它们的原数组进行变更。相对地,也有一些不可变 (immutable) 方法,例如 filter()concat()slice(),这些都不会更改原数组,而总是返回一个新数组。当遇到的是非变更方法时,我们需要将旧的数组替换为新的:

// `items` 是一个数组的 ref
items.value = items.value.filter(item => item.message.match(/Foo/))

你可能认为这将导致 vue 丢弃现有的 DOM 并重新渲染整个列表——幸运的是,情况并非如此。vue 实现了一些巧妙的方法来最大化对 DOM 元素的重用,因此用另一个包含部分重叠对象的数组来做替换,仍会是一种非常高效的操作。

展示过滤或排序后的结果

有时,我们希望显示数组经过过滤或排序后的内容,而不实际变更或重置原始数据。在这种情况下,你可以创建返回已过滤或已排序数组的计算属性。

举例:

const numbers = ref([1, 2, 3, 4, 5])

const evenNumbers = computed(() => {
 return numbers.value.filter(n => n % 2 === 0)
})
<li v-for="n in evenNumbers">{{ n }}</li>

在计算属性不可行的情况下 (例如在多层嵌套的 v-for 循环中),你可以使用以下方法:

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

function even(numbers) {
 return numbers.filter(number => number % 2 === 0)
}
<ul v-for="numbers in sets">
 <li v-for="n in even(numbers)">{{ n }}</li>
</ul>

在计算属性中使用 reverse()sort() 的时候务必小心!这两个方法将变更原始数组,计算函数中不应该这么做。请在调用这些方法之前创建一个原数组的副本:

- return numbers.reverse()
+ return [...numbers].reverse()

事件处理

监听事件

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

事件处理器的值可以是:

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

内联事件处理器

内联事件处理器通常用于简单场景,例如:

const count = ref(0)
<button @click="count++">Add 1</button>
<p>Count is: {{ count }}</p>

方法事件处理器

随着事件处理器的逻辑变得愈发复杂,内联代码方式变得不够灵活。因此 v-on 也可以接受一个方法名或对某个方法的调用。

举例:

const name = ref("Vue.js")

function greet(event) {
 alert(`Hello ${name.value}!`)
 // `event` 是 DOM 原生事件
 if (event) {
  alert(event.target.tagName)
 }
}
<!-- `greet` 是上面定义过的方法名 -->
<button @click="greet">Greet</button>

方法事件处理器会自动接收原生 DOM 事件并触发执行。在上面的例子中,我们能够通过被触发事件的 event.target.tagName 访问到该 DOM 元素。

方法与内联事件判断

模板编译器会通过检查 v-on 的值是否是合法的 JavaScript 标识符或属性访问路径来断定是何种形式的事件处理器。举例来说,foofoo.barfoo['bar'] 会被视为方法事件处理器,而 foo()count++ 会被视为内联事件处理器。

在内联处理器中调用方法

除了直接绑定方法名,你还可以在内联事件处理器中调用方法。这允许我们向方法传入自定义参数以代替原生事件:

function say(message) {
 alert(message)
}
<button @click="say('hello')">Say hello</button> <button @click="say('bye')">Say bye</button>

在内联事件处理器中访问事件参数

有时我们需要在内联事件处理器中访问原生 DOM 事件。你可以向该处理器方法传入一个特殊的 $event 变量,或者使用内联箭头函数:

<!-- 使用特殊的 $event 变量 -->
<button @click="warn('Form cannot be submitted yet.', $event)">
  Submit
</button>

<!-- 使用内联箭头函数 -->
<button @click="e => warn('Form cannot be submitted yet.', e)">
  Submit
</button>
function warn(message, event) {
 // 这里可以访问原生事件
 if (event) {
  event.preventDefault()
 }
 alert(message)
}

事件修饰符

在处理事件时调用 event.preventDefault()event.stopPropagation() 是很常见的。尽管我们可以直接在方法内调用,但如果方法能更专注于数据逻辑而不用去处理 DOM 事件的细节会更好。

为解决这一问题,Vue 为 v-on 提供了事件修饰符。修饰符是用 . 表示的指令后缀,包含以下这些:

  • .stop
  • .prevent
  • .self
  • .capture
  • .once
  • .passive
<!-- 单击事件将停止传递 -->
<a @click.stop="doThis"></a>

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

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

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

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

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

.capture.once.passive 修饰符与原生 addEventListener 事件相对应:

<!-- 添加事件监听器时,使用 `capture` 捕获模式 -->
<!-- 例如:指向内部元素的事件,在被内部元素处理前,先被外部处理 -->
<div @click.capture="doThis">...</div>

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

<!-- 滚动事件的默认行为 (scrolling) 将立即发生而非等待 `onScroll` 完成 -->
<!-- 以防其中包含 `event.preventDefault()` -->
<div @scroll.passive="onScroll">...</div>

.passive 修饰符一般用于触摸事件的监听器,可以用来改善移动端设备的滚屏性能

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

按键修饰符

在监听键盘事件时,我们经常需要检查特定的按键。Vue 允许在 v-on@ 监听按键事件时添加按键修饰符。

<!-- 仅在 `key` 为 `Enter` 时调用 `submit` -->
<input @keyup.enter="submit" />

你可以直接使用 KeyboardEvent.key 暴露的按键名称作为修饰符,但需要转为 kebab-case 形式。

<input @keyup.page-down="onPageDown" />

在上面的例子中,仅会在 $event.key'PageDown' 时调用事件处理。

按键别名

Vue 为一些常用的按键提供了别名:

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

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

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

在 Mac 键盘上,meta 是 Command 键 (⌘)。在 Windows 键盘上,meta 键是 Windows 键 (⊞)。在 Sun 微机系统键盘上,meta 是钻石键 (◆)。在某些键盘上,特别是 MIT 和 Lisp 机器的键盘及其后代版本的键盘,如 Knight 键盘,space-cadet 键盘,meta 都被标记为“META”。在 Symbolics 键盘上,meta 也被标识为“META”或“Meta”。

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

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

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

.exact修饰符

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

<!-- 当按下 Ctrl 时,即使同时按下 Alt 或 Shift 也会触发 -->
<button @click.ctrl="onClick">A</button>

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

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

鼠标按键修饰符

  • .left
  • .right
  • .middle

这些修饰符将处理程序限定为由特定鼠标按键触发的事件。

表单输入与绑定

在前端处理表单时,我们常常需要将表单输入框的内容同步给 JavaScript 中相应的变量。手动连接值绑定和更改事件监听器可能会很麻烦:

<input
  :value="text"
  @input="event => text = event.target.value">

v-model 指令帮我们简化了这一步骤:

<input v-model="text">

注意:

v-model 会忽略任何表单元素上初始的 valuecheckedselected attribute。它将始终将当前绑定的 JavaScript 状态视为数据的正确来源。你应该在 JavaScript 中使用响应式系统的 API 来声明该初始值。

值绑定

有时我们可能希望将该值绑定到当前组件实例上的动态数据。这可以通过使用 v-bind 来实现。此外,使用 v-bind 还使我们可以将选项值绑定为非字符串的数据类型。

复选框

<input type="checkbox" v-model="toggle" true-value="yes" false-value="no" />

true-valuefalse-value 是 Vue 特有的 attributes,仅支持和 v-model 配套使用。这里 toggle 属性的值会在选中时被设为 'yes',取消选择时设为 'no'。你同样可以通过 v-bind 将其绑定为其他动态值:

<input type="checkbox" v-model="toggle" :true-value="dynamicTrueValue" :false-value="dynamicFalseValue" />

提示:

true-valuefalse-value attributes 不会影响 value attribute,因为浏览器在表单提交时,并不会包含未选择的复选框。为了保证这两个值 (例如:“yes”和“no”) 的其中之一被表单提交,请使用单选按钮作为替代。

修饰符

.lazy

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

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

如果你想让用户输入自动转换为数字,你可以在 v-model 后添加 .number 修饰符来管理输入:

<input v-model.number="age" />

如果该值无法被 parseFloat() 处理,那么将返回原始值。

number 修饰符会在输入框有 type="number" 时自动启用。

.trim

如果你想要默认自动去除用户输入内容中两端的空格,你可以在 v-model 后添加 .trim 修饰符:

<input v-model.trim="msg" />

组件上的v-model

HTML 的内置表单输入类型并不总能满足所有需求。幸运的是,我们可以使用 Vue 构建具有自定义行为的可复用输入组件,并且这些输入组件也支持 v-model!要了解更多关于此的内容,请在组件指引中阅读配合 v-model 使用

生命周期钩子

每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。

侦听器

计算属性允许我们声明性地计算衍生值。然而在有些情况下,我们需要在状态变化时执行一些“副作用”:例如更改 DOM,或是根据异步操作的结果去修改另一处的状态。

侦听器 和 计算属性

有“副作用”,使用侦听器;没有“副作用”,使用计算属性。

watch()

在组合式 API 中,我们可以使用 watch 函数在每次响应式状态发生变化时触发回调函数:

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

watch 的第一个参数可以是不同形式的“数据源”:它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:

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

注意,你不能直接侦听响应式对象的属性值,例如:

const obj = reactive({ count: 0 })

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

这里需要用一个返回该属性的 getter 函数:

// 提供一个 getter 函数
watch(
 () => obj.count,
 count => {
  console.log(`count is: ${count}`)
 }
)

深层侦听器

直接给 watch() 传入一个响应式对象,会隐式地创建一个深层侦听器——该回调函数在所有嵌套的变更时都会被触发:

const obj = reactive({ count: 0 })

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

obj.count++

相比之下,一个返回响应式对象的 getter 函数,只有在返回不同的对象时,才会触发回调:

watch(
 () => state.someObject,
 () => {
  // 仅当 state.someObject 被替换时触发
 }
)

你也可以给上面这个例子显式地加上 deep 选项,强制转成深层侦听器:

watch(
 () => state.someObject,
 (newValue, oldValue) => {
  // 注意:`newValue` 此处和 `oldValue` 是相等的
  // *除非* state.someObject 被整个替换了
 },
 { deep: true }
)

谨慎使用:

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

即时回调的侦听器

watch 默认是懒执行的:仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时,立即执行一遍回调。举例来说,我们想请求一些初始数据,然后在相关状态更改时重新请求数据。

我们可以通过传入 immediate: true 选项来强制侦听器的回调立即执行:

watch(
 source,
 (newValue, oldValue) => {
  // 立即执行,且当 `source` 改变时再次执行
 },
 { immediate: true }
)

watchEffect()

下面的例子中,在每当 todoId 的引用发生变化时使用侦听器来加载一个远程资源:

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 }
)

侦听的数据源是todoId,而回调中也使用到了todoId,这种情况是很常见的。

我们可以用 watchEffect 函数 来简化上面的代码。watchEffect() 允许我们自动跟踪回调的响应式依赖。上面的侦听器可以重写为:

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

这个例子中,回调会立即执行,不需要指定 immediate: true。在执行期间,它会自动追踪 todoId.value 作为依赖(和计算属性类似)。每当 todoId.value 变化时,回调会再次执行。有了 watchEffect(),我们不再需要明确传递 todoId 作为源值。

对于这种只有一个依赖项的例子来说,watchEffect() 的好处相对较小。但是对于有多个依赖项的侦听器来说,使用 watchEffect() 可以消除手动维护依赖列表的负担。此外,如果你需要侦听一个嵌套数据结构中的几个属性,watchEffect() 可能会比深度侦听器更有效,因为它将只跟踪回调中被使用到的属性,而不是递归地跟踪所有的属性。

提示:

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

watchwatchEffect

watchwatchEffect 都能响应式地执行有副作用的回调。它们之间的主要区别是追踪响应式依赖的方式:

  • watch 只追踪明确侦听的数据源。它不会追踪任何在回调中访问到的东西。另外,仅在数据源确实改变时才会触发回调。watch 会避免在发生副作用时追踪依赖,因此,我们能更加精确地控制回调函数的触发时机。
  • watchEffect,则会在副作用发生期间追踪依赖。它会在同步执行过程中,自动追踪所有能访问到的响应式属性。这更方便,而且代码往往更简洁,但有时其响应性依赖关系会不那么明确。

回调的触发时机

当你更改了响应的状态,它可能会同时触发 vue 组件更新和侦听器回调。

默认情况下,用户创建的侦听器回调,都会在 vue 组件更新之前被调用。这意味着你在侦听器回调中访问的 DOM 将是被 vue 更新的状态。

如果想在侦听器回调中能访问被 vue 更新之后的 DOM,你需要指明flush 'post'选项:

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

watchEffect(callback, {
 flush: "post",
})

后置刷新的watchEffect()有个更方便的别名watchPostEffect()

import { watchPostEffect } from "vue"

watchPostEffect(() => {
 /* 在 Vue 更新后执行 */
})

停止侦听器

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

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

<script setup>
import { watchEffect } from "vue"

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

// ...这个则不会!
setTimeout(() => {
 watchEffect(() => {})
}, 100)
</script>

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

const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()

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

// 需要异步请求得到的数据
const data = ref(null)

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

模板引用

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

<input ref="input">

ref 是一个特殊的 attribute,和 v-for 章节中提到的 key 类似。它允许我们在一个特定的 DOM 元素或子组件实例被挂载后,获得对它的直接引用。这可能很有用,比如说在组件挂载时将焦点设置到一个 input 元素上,或在一个元素上初始化一个第三方库。

访问模板引用

为了通过组合式 API 获得该模板引用,我们需要声明一个同名的 ref:

<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 的情况:

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

v-for中的模板引用

当在 v-for 中使用模板引用时,对应的 ref 中包含的值是一个数组,它将在元素被挂载后包含对应整个列表的所有元素:

<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 数组并不保证与源数组相同的顺序。

函数模板引用

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

<input :ref="(el) => { /* 将 el 赋值给一个数据属性或 ref 变量 */ }" />

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

组件上的 ref

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

<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 完全一致,这意味着父组件对子组件的每一个属性和方法都有完全的访问权。这使得在父组件和子组件之间创建紧密耦合的实现细节变得很容易,当然也因此,应该只在绝对需要时才使用组件引用。大多数情况下,你应该首先使用标准的 props 和 emit 接口来实现父子组件交互。

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

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

const a = 1
const b = ref(2)

// 像 defineExpose 这样的编译器宏不需要导入
defineExpose({
 a,
 b,
})
</script>

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

组件基础

传递 props

props 是一种特别的 attributes,你可以在组件上声明注册。要传递给博客文章组件一个标题,我们必须在组件的 props 列表上声明它。这里要用到 defineProps 宏:

<!-- BlogPost.vue -->
<script setup>
defineProps(["title"])
</script>

<template>
 <h4>{{ title }}</h4>
</template>

defineProps 是一个仅 <script setup> 中可用的编译宏命令,并不需要显式地导入。声明的 props 会自动暴露给模板。defineProps 会返回一个对象,其中包含了可以传递给组件的所有 props:

const props = defineProps(["title"])
console.log(props.title)

监听事件

父组件可以通过 v-on@ 来选择性地监听子组件上抛的事件,就像监听原生 DOM 事件那样:

<BlogPost ... @enlarge-text="postFontSize += 0.1" />

子组件可以通过调用内置的 $emit 方法,通过传入事名称来抛出一个事件:

<!-- BlogPost.vue, 省略了 <script> -->
<template>
 <div class="blog-post">
  <h4>{{ title }}</h4>
  <button @click="$emit('enlarge-text')">Enlarge text</button>
 </div>
</template>

因为有了 @enlarge-text="postFontSize += 0.1" 的监听,父组件会接收这一事件,从而更新 postFontSize 的值。

我们可以通过 defineEmits 宏来声明需要抛出的事件:

<!-- BlogPost.vue -->
<script setup>
defineProps(["title"])
defineEmits(["enlarge-text"])
</script>

这声明了一个组件可能触发的所有事件,还可以对事件的参数进行验证。同时,这还可以让 Vue 避免将它们作为原生事件监听器隐式地应用于子组件的根元素。

defineProps 类似,defineEmits 仅可用于 <script setup> 之中,并且不需要导入,它返回一个等同于 $emit 方法的 emit 函数。它可以被用于在组件的 <script setup> 中抛出事件,因为此处无法直接访问 $emit

<script setup>
const emit = defineEmits(["enlarge-text"])

emit("enlarge-text")
</script>

深入组件

注册

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

全局注册

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

import MyComponent from "./App.vue"

app.component("MyComponent", MyComponent)

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

app.component("ComponentA", ComponentA).component("ComponentB", ComponentB).component("ComponentC", ComponentC)

全局注册的组件可以在此应用的任意组件的模板中使用。并且相互可以在彼此内部使用。

局部注册

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

组件名格式

在 SFC 中,推荐为子组件使用PascalCase的标签名,以此来和原声的 HTML 元素作区分。

但是,PascalCase 的标签名在 DOM 模板中是不可用的,详情参见 DOM 模板解析注意事项,在这种情况下,需要使用 kebab-case 形式。

什么是 DOM 模板?就是直接写在 DOM 中的模板,会被浏览器直接解析:

<!DOCTYPE <html>
    <head>
        <meta charset="utf-8">
        <title>Vue Component</title>
    </head>
    <body>
        <div id="app">
            <!-- 在 HTML 中是 kebab-case (短横线命名) 的会被渲染 -->
            <my-component></my-component>
            <my-Component></my-Component>
            <My-component></My-component>
            <My-Component></My-Component>
        </div>
    </body>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <script>
        // 注册时:PascalCase (首字母大写命名)、camelCase (驼峰命名)、kebab-case (短横线命名) 都可以
        Vue.component('MyComponent', {
            template: '<div>Hello Vue</div>'
        });
        new Vue ({
            el: '#app'
        });
    </script>
</html>

<my-component></my-component> 就是 DOM 模板。

props

props 声明

一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute (关于透传 attribute,我们会在专门的章节中讨论)。

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

console.log(props.foo)
</script>

如果使用了 ts,也可以这么声明:

<script setup lang="ts">
interface Props {
 foo: string
 bar?: number
}

const props = defineProps<Props>()
</script>

这被称之为“基于类型的声明”。感觉怪怪的。

当使用基于类型的声明时,我们失去了为 props 声明默认值的能力。这可以通过 withDefaults 编译器宏解决:

export interface Props {
 msg?: string
 labels?: string[]
}

const props = withDefaults(defineProps<Props>(), {
 msg: "hello",
 labels: () => ["one", "two"],
})

传递 prop 细节

prop 名字格式

prop 名字使用 camelCase 形式:

defineProps({
 greetingMessage: String,
})

然而对于传递 props 来说,使用 camelCase 并没有太多优势,因此我们推荐更贴近 HTML 的书写风格,使用 kebab-case 形式:

<MyComponent greeting-message="hello" />
静态 和 动态 prop

静态:

<BlogPost title="My journey with Vue" />

动态绑定:

<!-- 根据一个变量的值动态传入 -->
<BlogPost :title="post.title" />

<!-- 根据一个更复杂表达式的值动态传入 -->
<BlogPost :title="post.title + ' by ' + post.author.name" />
传递不同的值类型

不仅仅是字符串,实际上任何类型的值都可以作为 props 的值被传递。

Number:

<!-- 虽然 `42` 是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :likes="42" />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :likes="post.likes" />

Boolean:

<!-- 仅写上 prop 但不传值,会隐式转换为 `true` -->
<BlogPost is-published />

<!-- 虽然 `false` 是静态的值,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :is-published="false" />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :is-published="post.isPublished" />

Array:

<!-- 虽然这个数组是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost :comment-ids="[234, 266, 273]" />

<!-- 根据一个变量的值动态传入 -->
<BlogPost :comment-ids="post.commentIds" />

Object:

<!-- 虽然这个对象字面量是个常量,我们还是需要使用 v-bind -->
<!-- 因为这是一个 JavaScript 表达式而不是一个字符串 -->
<BlogPost
 :author="{
  name: 'Veronica',
  company: 'Veridian Dynamics',
 }"
/>

<!-- 根据一个变量的值动态传入 -->
<BlogPost :author="post.author" />

使用一个对象绑定多个 prop

如果你想要将一个对象的所有属性都当作 props 传入,你可以使用没有参数的 v-bind,即只使用 v-bind 而非 :prop-name。例如,这里有一个 post 对象:

const post = {
 id: 1,
 title: "My Journey with Vue",
}
<BlogPost v-bind="post" />

等价于:

<BlogPost :id="post.id" :title="post.title" />

单向数据流

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

另外,每次父组件更新后,所有的子组件中的 props 都会被更新到最新值,这意味着你不应该在子组件中去更改一个 prop。若你这么做了,Vue 会在控制台上向你抛出警告。

更改对象 / 数组类型的 props

当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,而对 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,比较得不偿失。

这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。

组件事件

触发与监听事件

在组件的模板表达式中,可以直接使用 $emit 方法触发自定义事件:

<!-- MyComponent -->
<button @click="$emit('increaseBy', 1)">
  Increase by 1
</button>

父组件监听事件:

<MyButton @increase-by="n => (count += n)" />

同样,组件的事件监听器也支持 .once 修饰符:

<MyComponent @some-event.once="callback" />

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

提示:

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

声明触发的事件

组件可以显式地通过 defineEmits() 宏来声明它要触发的事件:

<script setup lang="ts">
const emit = defineEmits<{
 (e: "change", id: number): void
 (e: "update", value: string): void
}>()
</script>

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

<script setup>
const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'update', value: string): void
}>()

function buttonClick() {
  emit('submit')
}
</script>

组件 v-model

v-model 可以在组件上使用以实现双向绑定。

首先让我们回忆一下 v-model 在原生元素上的用法:

<input v-model="searchText" />

模板编译器会对 v-model 进行冗长的等价展开。因此上面的代码其实等价于下面这段:

<input :value="searchText" @input="searchText = $event.target.value" />

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

<CustomInput :modelValue="searchText" @update:modelValue="newValue => (searchText = newValue)" />

所以,<CustomInput> 组件内部需要做两件事:

  1. 将内部原生 <input> 元素的 value attribute 绑定到 modelValue prop
  2. 当原生的 input 事件触发时,触发一个携带了新值的 update:modelValue 自定义事件
<!-- CustomInput.vue -->
<script setup>
defineProps(["modelValue"])
defineEmits(["update:modelValue"])
</script>

<template>
 <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" />
</template>

现在 v-model 可以在这个组件上正常工作了:

<CustomInput v-model="searchText" />

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

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

v-model 的参数

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

<MyComponent v-model:title="bookTitle" />

在这个例子中,子组件应声明一个 title prop,并通过触发 update:title 事件更新父组件值:

<!-- MyComponent.vue -->
<script setup>
defineProps(["title"])
defineEmits(["update:title"])
</script>

<template>
 <input type="text" :value="title" @input="$emit('update:title', $event.target.value)" />
</template>

多个v-model绑定

<UserName v-model:first-name="first" v-model:last-name="last" />
<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>

处理v-model修饰符

在学习输入绑定时,我们知道了 v-model 有一些内置的修饰符,例如 .trim.number.lazy。在某些场景下,你可能想要一个自定义组件的 v-model 支持自定义的修饰符。

我们来创建一个自定义的修饰符 capitalize,它会自动将 v-model 绑定输入的字符串值第一个字母转为大写:

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

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

<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 事件时将值的首字母大写:

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

对于又有参数又有修饰符的 v-model 绑定,生成的 prop 名将是 arg + "Modifiers"。举例来说:

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

相应的声明应该是:

const props = defineProps(["title", "titleModifiers"])
defineEmits(["update:title"])

console.log(props.titleModifiers) // { capitalize: true }

透传 attributes

attributes 继承

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

当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上。举例来说,假如我们有一个 <MyButton> 组件,它的模板长这样:

<!-- <MyButton> 的模板 -->
<button>click me</button>

一个父组件使用了这个组件,并且传入了 class

<MyButton class="large" />

最后渲染出的 DOM 结果是:

<button class="large">click me</button>

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

classstyle 的合并

如果一个子组件的根元素已经有了 classstyle attribute,它会和从父组件上继承的值合并。如果我们将之前的 <MyButton> 组件的模板改成这样:

<!-- <MyButton> 的模板 -->
<button class="btn">click me</button>

则最后渲染出的 DOM 结果会变成:

<button class="btn large">click me</button>
v-on 监听器继承

同样的规则也适用于 v-on 事件监听器:

<MyButton @click="onClick" />

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

深层组件继承

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

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

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

请注意:

  1. 透传的 attribute 不会包含 <MyButton> 上声明过的 props 或是针对 emits 声明事件的 v-on 侦听函数,换句话说,声明过的 props 和侦听函数被 <MyButton>“消费”了
  2. 透传的 attribute 若符合声明,也可以作为 props 传入 <BaseButton>

禁用 attributes 继承

如果你不想要一个组件自动地继承 attribute,你可以在组件选项中设置 inheritAttrs: false

如果你使用了 <script setup>,你需要一个额外的 <script> 块来书写这个选项声明:

<script>
// 使用普通的 <script> 来声明选项
export default {
 inheritAttrs: false,
}
</script>

<script setup>
// ...setup 部分逻辑
</script>

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

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

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

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

有几点需要注意:

  • 和 props 有所不同,透传 attributes 在 JavaScript 中保留了它们原始的大小写,所以像 foo-bar 这样的一个 attribute 需要通过 $attrs['foo-bar'] 来访问。
  • @click 这样的一个 v-on 事件监听器将在此对象下被暴露为一个函数 $attrs.onClick
<div class="btn-wrapper">
  <button class="btn" v-bind="$attrs">click me</button>
</div>

小提示:没有参数的 v-bind 会将一个对象的所有属性都作为 attribute 应用到目标元素上。

多根节点的 attributes 继承

和单根节点组件有所不同,有着多个根节点的组件没有自动 attribute 透传行为。如果 $attrs 没有被显式绑定,将会抛出一个运行时警告。

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

在 JavaScript 中访问透传 Attributes

如果需要,你可以在 <script setup> 中使用 useAttrs() API 来访问一个组件的所有透传 attribute:

<script setup>
import { useAttrs } from "vue"

const attrs = useAttrs()
</script>

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

插槽 slots

依靠 props 传值,还是不够,如果要传递模板内容,则需要使用插槽 slots。

<FancyButton>
  Click me! <!-- 插槽内容 -->
</FancyButton>

<FancyButton> 模板是这样的:

<button class="fancy-btn">
  <slot></slot> <!-- 插槽出口 -->
</button>

<slot> 元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。

最终渲染出的 DOM 是这样:

<button class="fancy-btn">Click me!</button>

多个插槽

如果有多个插槽,需要给插槽命名。

<div class="container">
  <header>
    <slot name="header"></slot>
  </header>
  <main>
    <slot></slot>
  </main>
  <footer>
    <slot name="footer"></slot>
  </footer>
</div>

这类带 name 的插槽被称为具名插槽 (named slots)。没有提供 name<slot> 出口会隐式地命名为“default”。

要为具名插槽传入内容,我们需要使用一个含 v-slot 指令的 <template> 元素,并将目标插槽的名字传给该指令,v-slot 有对应的简写 #

<BaseLayout>
  <template #header>
    <h1>Here might be a page title</h1>
  </template>

  <template #default>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </template>

  <template #footer>
    <p>Here's some contact info</p>
  </template>
</BaseLayout>

当一个组件同时接收默认插槽和具名插槽时,所有位于顶级的非 <template> 节点都被隐式地视为默认插槽的内容。所以上面也可以写成:

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

向插槽传参

Vue 模板中的表达式只能访问其定义时所处的作用域,这和 JavaScript 的词法作用域规则是一致的。

子组件定义插槽,父组件中使用子组件的时候,定义插槽中的内容。

插槽中内容是在父组件中定义的,所以插槽中只能访问父组件的作用域,但是如果插槽中需要使用到子组件作用域中的数据,怎么办?

子组件在定义插槽的时候,将需要使用到的数据传入插槽,这样,父组件在定义插槽中的内容时,就能使用传入的数据了。

定义组件 FacyList,并将item传入:

<ul>
  <li v-for="item in items">
    <slot name="item" :body="item.body" :username="item.username" :likes="item.links"></slot>
  </li>
</ul>

或者:

<ul>
  <li v-for="item in items">
    <slot name="item" v-bind="item"></slot>
  </li>
</ul>

注意:插槽上的 name 是一个 Vue 特别保留的 attribute,不会作为 props 传递给插槽。

在父组件中引用子组件 FancyList,可以使用传入的参数:

<FancyList>
  <template #item="{ body, username, likes }">
    <div class="item">
      <p>{{ body }}</p>
      <p>by {{ username }} | {{ likes }} likes</p>
    </div>
  </template>
</FancyList>

无渲染组件

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

大部分能用无渲染组件实现的功能都可以通过组合式 API 以另一种更高效的方式实现,并且还不会带来额外组件嵌套的开销。

依赖注入

深层的组件需要顶层的数据,如果通过层层组件逐级传递 props,会很麻烦,中间层的组件可能根本不关系这些 props。

这个问题被称为 “prop 逐级透传”。

provide(提供) 和 inject(注入) 可以帮助我们解决这一问题。一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

provide(提供)

要为组件后代提供数据,需要使用到 provide() 函数:

<script setup>
import { provide } from "vue"

provide(/* 注入名 */ "message", /* 值 */ "hello!")
</script>

注入名 可以是字符串或是 Symbol

后代组件会用注入名来查找期望注入的值。

可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。

可以是任意类型,包括响应式的状态,比如一个 ref:

import { ref, provide } from "vue"

const count = ref(0)
provide("key", count)

提供的响应式状态使后代组件可以由此和提供者建立响应式的联系。

应用层 provide

除了在一个组件中提供依赖,我们还可以在整个应用层面提供依赖:

import { createApp } from "vue"

const app = createApp({})

app.provide(/* 注入名 */ "message", /* 值 */ "hello!")

在应用级别提供的数据在该应用内的所有组件中都可以注入。这在你编写插件时会特别有用,因为插件一般都不会使用组件形式来提供值。

inject(注入)

要注入上层组件提供的数据,需使用 inject() 函数:

<script setup>
import { inject } from "vue"

const message = inject("message")
</script>

如果提供的值是一个 ref,注入进来的会是该 ref 对象,而不会自动解包为其内部的值。这使得注入方组件能够通过 ref 对象保持了和供给方的响应性链接。

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

或者:

s
const value = inject('key', () => new ExpensiveClass())

和响应式数据配合使用

当提供 / 注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中。这样可以确保所提供状态的声明和变更操作都内聚在同一个组件内,使其更容易维护。

有的时候,我们可能需要在注入方组件中更改数据。在这种情况下,我们推荐在供给方组件内声明并提供一个更改数据的方法函数:

<!-- 在供给方组件内 -->
<script setup>
import { provide, ref } from "vue"

const location = ref("North Pole")

function updateLocation() {
 location.value = "South Pole"
}

provide("location", {
 location,
 updateLocation,
})
</script>
<!-- 在注入方组件 -->
<script setup>
import { inject } from "vue"

const { location, updateLocation } = inject("location")
</script>

<template>
 <button @click="updateLocation">{{ location }}</button>
</template>

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

<script setup>
import { ref, provide, readonly } from "vue"

const count = ref(0)
provide("read-only-count", readonly(count))
</script>

使用 symbol 作为注入名

如果你正在构建大型的应用,包含非常多的依赖提供,或者你正在编写提供给其他开发者使用的组件库,建议最好使用 Symbol 来作为注入名以避免潜在的冲突。

我们通常推荐在一个单独的文件中导出这些注入名 Symbol:

// keys.js
export const myInjectionKey = Symbol()
// 在供给方组件中
import { provide } from "vue"
import { myInjectionKey } from "./keys.js"

provide(myInjectionKey, {
 /*
  要提供的数据
*/
})
// 注入方组件
import { inject } from "vue"
import { myInjectionKey } from "./keys.js"

const injected = inject(myInjectionKey)

异步组件

基本用法

在大型项目中,我们可能需要拆分应用为更小的块,并仅在需要时再从服务器加载相关组件。Vue 提供了 defineAsyncComponent 方法来实现此功能:

import { defineAsyncComponent } from "vue"

const AsyncComp = defineAsyncComponent(() => {
 return new Promise((resolve, reject) => {
  // ...从服务器获取组件
  resolve(/* 获取到的组件 */)
 })
})
// ... 像使用其他一般组件一样使用 `AsyncComp`

如你所见,defineAsyncComponent 方法接收一个返回 Promise 的加载函数。这个 Promise 的 resolve 回调方法应该在从服务器获得组件定义时调用。你也可以调用 reject(reason) 表明加载失败。

ES 模块动态导入也会返回一个 Promise,所以多数情况下我们会将它和 defineAsyncComponent 搭配使用。类似 Vite 和 Webpack 这样的构建工具也支持此语法 (并且会将它们作为打包时的代码分割点),因此我们也可以用它来导入 Vue 单文件组件:

import { defineAsyncComponent } from "vue"

const AsyncComp = defineAsyncComponent(() => import("./components/MyComponent.vue"))

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

加载与错误状态

异步操作不可避免地会涉及到加载和错误状态,因此 defineAsyncComponent() 也支持在高级选项中处理这些状态:

const AsyncComp = defineAsyncComponent({
 // 加载函数
 loader: () => import("./Foo.vue"),

 // 加载异步组件时使用的组件
 loadingComponent: LoadingComponent,
 // 展示加载组件前的延迟时间,默认为 200ms
 delay: 200,

 // 加载失败后展示的组件
 errorComponent: ErrorComponent,
 // 如果提供了一个 timeout 时间限制,并超时了
 // 也会显示这里配置的报错组件,默认值是:Infinity
 timeout: 3000,
})

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

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

搭配 Suspense 使用

异步组件可以搭配内置的 <Suspense> 组件一起使用,若想了解 <Suspense> 和异步组件之间交互,请参阅 Suspense 章节。

逻辑复用

组合式函数

复用无状态逻辑的库有很多,比如你可能已经用过的 lodash 或是 date-fns

在 Vue 中,复用有状态逻辑使用“组合式函数”(Composables) 。

和组件一样,可以在组合式函数中使用所有的 组合式 API,并返回需要暴露的状态。

更酷的是,你还可以嵌套多个组合式函数:一个组合式函数可以调用一个或多个其他的组合式函数。这使得我们可以像使用多个组件组合成整个应用一样,用多个较小且逻辑独立的单元来组合形成复杂的逻辑。实际上,这正是为什么我们决定将实现了这一设计模式的 API 集合命名为组合式 API。

异步状态示例示例

// fetch.js
import { ref } from "vue"

export const useFetch = (url: string) => {
 const data = ref(null)
 const loading = ref(true)
 const error = ref(null)
 const request = () =>
  window
   .fetch(url)
   .then(res => res.json())
   .then(json => (data.value = json))
   .catch(err => (error.value = err))
   .finally(() => (loading.value = false))

 request()

 return {
  data,
  loading,
  error,
  refetch: request,
 }
}
<script lang="ts" setup>
import { useFetch } from "@/utils/fetch"

const { data, loading, error, refetch } = useFetch("http://101.43.187.22:9501/api/nav/wallPaper")
</script>

<template>
 <el-button @click="refetch">refresh</el-button>
 <el-row v-for="url of data?.result" :key="url" v-loading="loading">{{ url }}</el-row>
</template>

推荐使用 TanStack Query 库。

约定和最佳实践

命名

组合式函数约定用驼峰命名法命名,并以“use”作为开头。

输入参数
返回值

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

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

从组合式函数返回一个响应式对象会导致在对象解构过程中丢失与组合式函数内状态的响应性连接。与之相反,ref 则可以维持这一响应性连接。

如果你更希望以对象属性的形式来使用组合式函数中返回的状态,你可以将返回的对象用 reactive() 包装一次,这样其中的 ref 会被自动解包,例如:

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

在组合式函数中的确可以执行副作用 (例如:添加 DOM 事件监听器或者请求数据),但请注意以下规则:

  • 如果你的应用用到了服务端渲染 (SSR),请确保在组件挂载后才调用的生命周期钩子中执行 DOM 相关的副作用,例如:onMounted()。这些钩子仅会在浏览器中被调用,因此可以确保能访问到 DOM。

  • 确保在 onUnmounted() 时清理副作用。举例来说,如果一个组合式函数设置了一个事件监听器,它就应该在 onUnmounted() 中被移除。

    // event.js
    import { onMounted, onUnmounted } from "vue"
    
    export function useEventListener(target, event, callback) {
     // 如果你想的话,
     // 也可以用字符串形式的 CSS 选择器来寻找目标 DOM 元素
     onMounted(() => target.addEventListener(event, callback))
     onUnmounted(() => target.removeEventListener(event, callback)) // 清理
    }
使用限制

组合式函数在 <script setup>setup() 钩子中,应始终被同步地调用。在某些场景下,你也可以在像 onMounted() 这样的生命周期钩子中使用他们。

这个限制是为了让 Vue 能够确定当前正在被执行的到底是哪个组件实例,只有能确认当前组件实例,才能够:

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

提示:

<script setup> 是唯一在调用 await 之后仍可调用组合式函数的地方。编译器会在异步操作之后自动为你恢复当前的组件实例。

自定义指令

除了 Vue 内置的一系列指令 (比如 v-modelv-show) 之外,Vue 还允许你注册自定义的指令 (Custom Directives)。

我们已经介绍了两种在 Vue 中重用代码的方式:组件组合式函数。组件是主要的构建模块,而组合式函数则侧重于有状态的逻辑。另一方面,自定义指令主要是为了重用涉及普通元素的底层 DOM 访问的逻辑。

一个自定义指令由一个包含类似组件生命周期钩子的对象来定义。钩子函数会接收到指令所绑定元素作为其参数。下面是一个自定义指令的例子,当一个 input 元素被 Vue 插入到 DOM 中后,它会被自动聚焦:

<script setup>
// 在模板中启用 v-focus
const vFocus = {
 mounted: el => el.focus(),
}
</script>

<template>
 <input v-focus />
</template>

假设你还未点击页面中的其他地方,那么上面这个 input 元素应该会被自动聚焦。该指令比 autofocus attribute 更有用,因为它不仅仅可以在页面加载完成后生效,还可以在 Vue 动态插入元素后生效。

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

将一个自定义指令全局注册到应用层级也是一种常见的做法:

const app = createApp({})

// 使 v-focus 在所有组件中都可用
app.directive("focus", {
 /* ... */
})

提示:

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

指令钩子

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

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) {},
}

钩子参数

简化形式

对象字面量

在组件上使用

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

总的来说,推荐在组件上使用自定义指令。

插件

插件 (Plugins) 是一种能为 Vue 添加全局功能的工具代码。下面是如何安装一个插件的示例:

import { createApp } from "vue"

const app = createApp({})

app.use(myPlugin, {
 /* 可选的选项 */
})

一个插件可以是一个拥有 install() 方法的对象,也可以直接是一个安装函数本身。安装函数会接收到安装它的应用实例和传递给 app.use() 的额外选项作为参数:

const myPlugin = {
 install(app, options) {
  // 配置此应用
 },
}

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

  1. 通过 app.component()app.directive() 注册一到多个全局组件或自定义指令。
  2. 通过 app.provide() 使一个资源 可被注入 进整个应用。
  3. app.config.globalProperties 中添加一些全局实例属性或方法
  4. 一个可能上述三种都包含了的功能库 (例如 vue-router)。

编写一个插件

内置组件

Transition

在一个元素或组件进入和离开 DOM 时应用动画。

TransitionGroup

在一个 v-for 列表中的元素或组件被插入,移动,或移除时应用动画。

KeepAlive

想要组件能在被“切走”的时候保留它们的状态。

可以用 <KeepAlive> 内置组件将这些动态组件包装起来。

Teleport

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

Suspense

<Suspense> 是一项实验性功能。它不一定会最终成为稳定功能,并且在稳定之前相关 API 也可能会发生变化。

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

应用规模化

状态管理 pinia

测试

服务端渲染 (SSR)

最佳实践

TypeScript

进阶主题


快速开始
http://blog.lujinkai.cn/前端/Vue3/快速开始/
作者
像方便面一样的男子
发布于
2023年2月23日
更新于
2023年12月5日
许可协议