你以为的你已经很了解了,每次读官方文档,都会发现有不一样的感悟,让你眼前一亮的感觉。

vue3

Vue (发音为 /vjuː/,类似 view) 是一款用于构建用户界面的 JavaScript 框架。它基于标准 HTML、CSS 和 JavaScript 构建,并提供了一套声明式的、组件化的编程模型,帮助你高效地开发用户界面。无论是简单还是复杂的界面,Vue 都可以胜任。

提供了 选项式 API 和 组合式 API 两种编程模型

响应式基础

  • ref()


在组合式 API 中,推荐使用 ref() 函数来声明响应式状态:

1
2
3
import { ref } from 'vue'

const count = ref(0)

ref() 接收参数,并将其包裹在一个带有 .value 属性的 ref 对象中返回:

1
2
3
4
5
6
const count = ref(0)

console.log(count.value) // 0

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

要在组件模板中访问 ref,请从组件的 setup() 函数中声明并返回它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { ref } from 'vue'

export default {
// `setup` 是一个特殊的钩子,专门用于组合式 API。
setup() {
const count = ref(0)

// 将 ref 暴露给模板
return {
count
}
}
}
1
<div>{{ count }}</div>

注意,在模板中使用 ref 时,我们不需要附加 .value。为了方便起见,当在模板中使用时,ref 会自动解包 (有一些注意事项)。

在模板中解包的注意事项:
在模板渲染上下文中,只有顶级的 ref 属性才会被解包。

在下面的例子中,count 和 object 是顶级属性,但 object.id 不是:

1
2
3
<template>
<div>{{ count }} {{ object }} {{ object.id }}</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script>
import { ref } from 'vue'

export default {
setup() {
const count = ref(0)
const object = ref({
id: 1
})

return {
count,
object
}
}
}

{{ count + 1 }} ✅
{{ object.id }} ❌
</script>
1
{{ object.id + 1 }}

渲染的结果将是 [object Object]1,因为在计算表达式时 object.id 没有被解包,仍然是一个 ref 对象。为了解决这个问题,我们可以将 id 解构为一个顶级属性:

1
2
const { id } = object
{{ id + 1 }}

现在渲染的结果将是 2。

<script setup>

setup() 函数中手动暴露大量的状态和方法非常繁琐。幸运的是,我们可以通过使用单文件组件 (SFC) 来避免这种情况。我们可以使用 <script setup> 来大幅度地简化代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup>
import { ref } from 'vue'

const count = ref(0)

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

<template>
<button @click="increment">
{{ count }}
</button>
</template>

为什么要使用 ref?
你可能会好奇:为什么我们需要使用带有 .value 的 ref,而不是普通的变量?为了解释这一点,我们需要简单地讨论一下 Vue 的响应式系统是如何工作的。

当你在模板中使用了一个 ref,然后改变了这个 ref 的值时,Vue 会自动检测到这个变化,并且相应地更新 DOM。这是通过一个基于依赖追踪的响应式系统实现的。当一个组件首次渲染时,Vue 会追踪在渲染过程中使用的每一个 ref。然后,当一个 ref 被修改时,它会触发追踪它的组件的一次重新渲染。

在标准的 JavaScript 中,检测普通变量的访问或修改是行不通的。然而,我们可以通过 getter 和 setter 方法来拦截对象属性的 get 和 set 操作。

该 .value 属性给予了 Vue 一个机会来检测 ref 何时被访问或修改。在其内部,Vue 在它的 getter 中执行追踪,在它的 setter 中执行触发。从概念上讲,你可以将 ref 看作是一个像这样的对象:

1
2
3
4
5
6
7
8
9
10
11
12
// 伪代码,不是真正的实现
const myRef = {
_value: 0,
get value() {
track()
return this._value
},
set value(newValue) {
this._value = newValue
trigger()
}
}

另一个 ref 的好处是,与普通变量不同,你可以将 ref 传递给函数,同时保留对最新值和响应式连接的访问。当将复杂的逻辑重构为可重用的代码时,这将非常有用。

深层响应性
Ref 可以持有任何类型的值,包括深层嵌套的对象、数组或者 JavaScript 内置的数据结构,比如 Map。

Ref 会使它的值具有深层响应性。这意味着即使改变嵌套对象或数组时,变化也会被检测到:

1
2
3
4
5
6
7
8
9
10
11
12
import { ref } from 'vue'

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

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

DOM 更新时机
当你修改了响应式状态时,DOM 会被自动更新。但是需要注意的是,DOM 更新不是同步的。Vue 会在“next tick”更新周期中缓存所有状态的修改,以确保不管你进行了多少次状态修改,每个组件都只会被更新一次。

要等待 DOM 更新完成后再执行额外的代码,可以使用 nextTick() 全局 API:

1
2
3
4
5
6
7
import { nextTick } from 'vue'

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


还有另一种声明响应式状态的方式,即使用 reactive() API。与将内部值包装在特殊对象中的 ref 不同,reactive() 将使对象本身具有响应性:

1
2
3
import { reactive } from 'vue'

const state = reactive({ count: 0 })

在模板中使用:

1
2
3
<button @click="state.count++">
{{ state.count }}
</button>

reactive()深层地 转换对象:当访问嵌套对象时,它们也会被 reactive() 包装。当 ref 的值是一个对象时,ref() 也会在内部调用它。与浅层 ref 类似,这里也有一个 shallowReactive() API 可以选择退出深层响应性。

reactive() 的局限性:

1.有限的值类型:它只能用于对象类型 (对象、数组和如 Map、Set 这样的集合类型)。它不能持有如 string、number 或 boolean 这样的原始类型。
2.不能替换整个对象:由于 Vue 的响应式跟踪是通过属性访问实现的,因此我们必须始终保持对响应式对象的相同引用。这意味着我们不能轻易地“替换”响应式对象,因为这样的话与第一个引用的响应性连接将丢失:

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

3.对解构操作不友好:当我们将响应式对象的原始类型属性解构为本地变量时,或者将该属性传递给函数时,我们将丢失响应性连接:

1
2
3
4
5
6
7
8
9
const state = reactive({ count: 0 })
// 当解构时,count 已经与 state.count 断开连接
let { count } = state
// 不会影响原始的 state
count++
// 该函数接收到的是一个普通的数字
// 并且无法追踪 state.count 的变化
// 我们必须传入整个对象以保持响应性
callSomeFunction(state.count)

由于这些限制,我们建议使用 ref() 作为声明响应式状态的主要 API。

ref 和 reactive 的区别

api 定义 原理 数据支持 访问方式 设计需求
ref 用于创建任何值类型(基本类型或对象)的响应式引用,返回一个包含.value属性的对象 通过getter/setter劫持实现响应式,对象类型内部自动调用reactive转换。‌‌ ref支持所有类型(如number、string、object) ref需通过.value访问(模板中自动解包) ref解决单一值的响应式需求
reactive 仅适用于对象类型(对象、数组、Map/Set等),返回原始对象的Proxy代理 基于Proxy递归劫持对象属性,实现深层响应式。‌ reactive支持对象类型 reactive直接访问属性 reactive处理复杂数据结构

1.ref简化基本类型响应式,reactive 优化对象操作(如嵌套属性直接修改)。‌
2.ref 包装对象时 内部 依赖 reactive,两者协同工作。‌

计算属性

模板中的表达式虽然方便,但也只能用来做简单的操作。如果在模板中写太多逻辑,会让模板变得臃肿,难以维护。比如说,我们有这样一个包含嵌套数组的对象:

1
2
3
4
5
6
7
8
const author = reactive({
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
})

我们想根据 author 是否已有一些书籍来展示不同的信息:

1
2
<p>Has published books:</p>
<span>{{ author.books.length > 0 ? 'Yes' : 'No' }}</span>

这里的模板看起来有些复杂。我们必须认真看好一会儿才能明白它的计算依赖于 author.books。更重要的是,如果在模板中需要不止一次这样的计算,我们可不想将这样的代码在模板里重复好多遍。

因此我们推荐使用计算属性来描述依赖响应式状态的复杂逻辑。这是重构后的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<script 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(() => {
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 方法

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

1
<p>{{ calculateBooksMessage() }}</p>
1
2
3
4
// 组件中
function calculateBooksMessage() {
return author.books.length > 0 ? 'Yes' : 'No'
}

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

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

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

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

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

可写计算属性

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

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

const firstName = ref('John')
const lastName = ref('Doe')

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

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

获取上一个值(仅 3.4+ 支持)

如果需要,可以通过访问计算属性的 getter 的第一个参数来获取计算属性返回的上一个值:

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

const count = ref(2)

// 这个计算属性在 count 的值小于或等于 3 时,将返回 count 的值。
// 当 count 的值大于等于 4 时,将会返回满足我们条件的最后一个值
// 直到 count 的值再次小于或等于 3 为止。
const alwaysSmall = computed((previous) => {
if (count.value <= 3) {
return count.value
}

return previous
})
</script>

如果你正在使用可写的计算属性的话:

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

const count = ref(2)

const alwaysSmall = computed({
get(previous) {
if (count.value <= 3) {
return count.value
}

return previous
},
set(newValue) {
count.value = newValue * 2
}
})
</script>

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

Class 与 Style 绑定

class 绑定

1. 绑定对象


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

上面的语法表示 active 是否存在取决于数据属性 isActive 的真假值。


1
2
3
4
5
6
const isActive = ref(true)
const hasError = ref(false)
<div
class="static"
:class="{ active: isActive, 'text-danger': hasError }"
></div>

result:

1
<div class="static active"></div>

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

这将渲染:

1
<div class="active"></div>


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

1
2
3
4
5
6
7
const isActive = ref(true)
const error = ref(null)

const classObject = computed(() => ({
active: isActive.value && !error.value,
'text-danger': error.value && error.value.type === 'fatal'
}))

1
<div :class="classObject"></div>

result:

1
<div class="active"></div>


2. 绑定数组

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

1
2
const activeClass = ref('active')
const errorClass = ref('text-danger')

1
<div :class="[activeClass, errorClass]"></div>

渲染的结果是:

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


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

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

errorClass 会一直存在,但 activeClass 只会在 isActive 为真时才存在。

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

1
<div :class="[{ [activeClass]: isActive }, errorClass]"></div>


style 绑定

1. 绑定对象

:style 支持绑定 JavaScript 对象值,对应的是 HTML 元素的 style 属性

1
2
const activeColor = ref('red')
const fontSize = ref(30)

1
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>

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

1
2
3
4
const styleObject = reactive({
color: 'red',
fontSize: '30px'
})

1
<div :style="styleObject"></div>
2. 绑定数组

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

1
<div :style="[baseStyles, overridingStyles]"></div>

条件渲染

v-if

v-else

v-else-if

<template> 上的 v-if

因为 v-if 是一个指令,他必须依附于某个元素。但如果我们想要切换不止一个元素呢?在这种情况下我们可以在一个

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

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

v-show

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

v-if vs v-show​

v-if 是“真实的”按条件渲染,因为它确保了在切换时,条件区块内的事件监听器和子组件都会被销毁与重建。
v-if 也是惰性的:如果在初次渲染时条件值为 false,则不会做任何事。条件区块只有当条件首次变为 true 时才被渲染。
相比之下,v-show 简单许多,元素无论初始条件如何,始终会被渲染,只有 CSS display 属性会被切换。
总的来说,v-if 有更高的切换开销,而 v-show 有更高的初始渲染开销。因此,如果需要频繁切换,则使用 v-show 较好;如果在运行时绑定条件很少改变,则 v-if 会更合适。

v-if 和 v-for(优先级和V2 有区别)

当 v-if 和 v-for 同时存在于一个元素上的时候,v-if 会首先被执行。

侦听器 - watch()


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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<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. ' + error
} finally {
loading.value = false
}
}
})
</script>

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

数据源类型

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const x = ref(0)
const y = ref(0)

// 单个 ref
watch(x, (newX) => {
console.log(`x is ${newX}`)
})

// getter 函数
watch(
() => x.value + y.value,
(sum) => {
console.log(`sum of x + y is: ${sum}`)
}
)

// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
console.log(`x is ${newX} and y is ${newY}`)
})

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

1
2
3
4
5
6
const obj = reactive({ count: 0 })

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

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

1
2
3
4
5
6
7
// 提供一个 getter 函数
watch(
() => obj.count,
(count) => {
console.log(`Count is: ${count}`)
}
)

深层侦听器 - deep (3.5 及以上版本)

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

1
2
3
4
5
6
7
8
9
const obj = reactive({ count: 0 })

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

obj.count++

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

1
2
3
4
5
6
watch(
() => state.someObject,
() => {
// 仅当 state.someObject 被替换时触发
}
)

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

1
2
3
4
5
6
7
watch(
() => state.someObject,
() => {
// 仅当 state.someObject 被替换时触发
},
{ deep: true }
)

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

即时回调的侦听器​ - immediate

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

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

1
2
3
4
5
6
7
watch(
source,
(newValue, oldValue) => {
// 立即执行,且当 `source` 改变时再次执行
},
{ immediate: true }
)

一次性侦听器 - once (3.4 及以上版本)

每当被侦听源发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次,请使用 once: true 选项。

1
2
3
4
5
6
7
watch(
source,
(newValue, oldValue) => {
// 当 `source` 变化时,仅触发一次
},
{ once: true }
)

watchEffect()

侦听器的回调使用与源完全相同的响应式状态是很常见的。例如下面的代码,在每当 todoId 的引用发生变化时使用侦听器来加载一个远程资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 的,一次是作为,另一次是在回调中

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

1
2
3
4
5
6
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() 可能会比深度侦听器更有效,因为它将只跟踪回调中被使用到的属性,而不是递归地跟踪所有的属性。

watch vs watchEffect

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

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

副作用清理

有时我们可能会在侦听器中执行副作用,例如异步请求:

1
2
3
4
5
watch(id, (newId) => {
fetch(`/api/${newId}`).then(() => {
// 回调逻辑
})
})

但是如果在请求完成之前 id 发生了变化怎么办?当上一个请求完成时,它仍会使用已经过时的 ID 值触发回调。理想情况下,我们希望能够在 id 变为新值时取消过时的请求。

我们可以使用 onWatcherCleanup() (3.5+) API 来注册一个清理函数,当侦听器失效并准备重新运行时会被调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { watch, onWatcherCleanup } from 'vue'

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

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

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

请注意,onWatcherCleanup 仅在 Vue 3.5+ 中支持,并且必须在 watchEffect 效果函数或 watch 回调函数的同步执行期间调用:你不能在异步函数的 await 语句之后调用它。

作为替代,onCleanup 函数还作为第三个参数传递给侦听器回调,以及 watchEffect 作用函数的第一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
watch(id, (newId, oldId, onCleanup) => {
const controller = new AbortController()

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

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

或者,如果你使用的是 watchEffect

1
2
3
4
5
6
7
8
9
10
11
12
watchEffect((onCleanup) => {
const controller = new AbortController()

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

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

这在 3.5 之前的版本有效。此外,通过函数参数传递的 onCleanup 与侦听器实例相绑定,因此不受 onWatcherCleanup 的同步限制。

模板引用


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

1
<input ref="input">

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

访问模板引用

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup>
import { useTemplateRef, onMounted } from 'vue'

// 第一个参数必须与模板中的 ref 值匹配
const input = useTemplateRef('my-input')

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

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

3.5 之前的版本,
在 3.5 之前的版本尚未引入 useTemplateRef(),我们需要声明一个与模板里 ref attribute 匹配的引用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script setup>
import { ref, onMounted } from 'vue'

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

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

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

如果不使用 <script setup>,需确保从 setup() 返回 ref:

1
2
3
4
5
6
7
8
9
export default {
setup() {
const input = ref(null)
// ...
return {
input
}
}
}

注意,你只可以在组件挂载后才能访问模板引用。如果你想在模板中的表达式上访问 input,在初次渲染时会是 null。这是因为在初次渲染前这个元素还不存在呢!

如果你需要侦听一个模板引用 ref 的变化,确保考虑到其值为 null 的情况:

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

组件上的 ref

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup>
import { useTemplateRef, onMounted } from 'vue'
import Child from './Child.vue'

const childRef = useTemplateRef('child')

onMounted(() => {
// childRef.value 将持有 <Child /> 的实例
})
</script>

<template>
<Child ref="child" />
</template>

3.5 之前的版本,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup>
import { ref, onMounted } from 'vue'
import Child from './Child.vue'

const child = ref(null)

onMounted(() => {
// child.value 是 <Child /> 组件的实例
})
</script>

<template>
<Child ref="child" />
</template>

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

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

1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
import { ref } from 'vue'

const a = 1
const b = ref(2)

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

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

请注意,defineExpose 必须在任何 await 操作之前调用。否则,在 await 操作后暴露的属性和方法将无法访问

生命周期

生命周期钩子函数

生命周期名称 描述
setup 组件初始化时调用,在 beforeCreate 和 created 之前调用,只能使用 Composition API 相关的函数,不能使用 this。
onBeforeMount 组件挂载之前调用,在 mounted 之前调用,不能使用 this。
onMounted 组件挂载完成后调用,在 mounted 之后调用,不能使用 this。
onBeforeUpdate 组件更新之前调用,在 updated 之前调用,不能使用 this。
onUpdated 组件更新完成后调用,在 updated 之后调用,不能使用 this。
onBeforeUnmount 组件卸载之前调用,在 unmounted 之前调用,不能使用 this。
onUnmounted 组件卸载完成后调用,在 unmounted 之后调用,不能使用 this。
onRenderTracked dev-only 注册一个调试钩子,当组件渲染过程中追踪到响应式依赖时调用。这个钩子仅在开发模式下可用,且在服务器端渲染期间不会被调用。
onRenderTriggered dev-only 注册一个调试钩子,当响应式依赖的变更触发了组件渲染时调用。这个钩子仅在开发模式下可用,且在服务器端渲染期间不会被调用。
onActivated 注册一个回调函数,若组件实例是 <KeepAlive> 缓存树的一部分,当组件被插入到 DOM 中时调用。这个钩子仅在开发模式下可用,且在服务器端渲染期间不会被调用。
onDeactivated 注册一个回调函数,若组件实例是 <KeepAlive> 缓存树的一部分,当组件从 DOM 中被移除时调用。这个钩子仅在开发模式下可用,且在服务器端渲染期间不会被调用。
onServerPrefetch SSR only 注册一个异步函数,在组件实例在服务器上被渲染之前调用。

props

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

defineProps()

在使用 <script setup> 的单文件组件中,props 可以使用 defineProps() 宏来声明:

1
2
3
4
5
<script setup>
const props = defineProps(['foo'])

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

在没有使用 <script setup> 的组件中,props 可以使用 props 选项来声明:

1
2
3
4
5
6
7
export default {
props: ['foo'],
setup(props) {
// setup() 接收一个上下文对象,其中包含组件的 props
console.log(props.foo)
}
}

注意传递给 defineProps() 的参数和提供给 props 选项的值是相同的,两种声明方式背后其实使用的都是 props 选项。

除了使用字符串数组来声明 props 外,还可以使用对象的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 使用 <script setup>
const props = defineProps({
itemData: {
type: Object
},
index: Number,
showRank: {
type: Boolean,
default: false
},
rank: {
type: Number,
default: 0
}
})
defineProps({
title: String,
likes: Number
})
// 非 <script setup>
export default {
props: {
title: String,
likes: Number
}
}
<script setup lang="ts">
defineProps<{
title?: string
likes?: number
}>()
</script>

事件

触发与监听事件

在组件的模板表达式中,可以直接使用 $emit 方法触发自定义事件 (例如:在 v-on 的处理函数中):

1
2
<!-- MyComponent -->
<button @click="$emit('someEvent')">Click Me</button>

在父组件中监听这个事件:

1
<MyComponent @some-event="callback" />

在父组件中,可以通过 v-on@ 来监听子组件触发的事件。事件名需要和子组件 $emit 的名称完全匹配。

事件参数

1
2
3
<button @click="$emit('increaseBy', 1)">
Increase by 1
</button>
1
<MyButton @increase-by="(n) => count += n" />

或者是

1
<MyButton @increase-by="increaseCount" />

1
2
3
function increaseCount(n) {
count.value += n
}

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

声明触发的事件 - defineEmits()

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

父组件

1
2
3
4
5
6
<tab-r id="tab15" @open="open"></tab-r>
<uni-popup ref="popup" type="bottom" border-radius="10px 10px 0 0">
<view style="padding: 40rpx;background: #fff;" class="popup-content">
<text>人生若只如初见,何事秋风悲画扇</text>
</view>
</uni-popup>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const open = async () => {
try {
// 等待DOM更新
await nextTick()

if (popup.value && popup.value.open) {
popup.value.open('bottom')
console.log('uni-popup opened successfully')
} else {
console.error('uni-popup ref not found')
}
} catch (error) {
console.error('Failed to open popup:', error)
}
}

子组件 tab-r子组件

1
2
3
4
5
6
7
<view class="university-info-bottom-item" @click="openFn">
<text class="num">66</text>
<view class="university-info-bottom-item-text">
<text>开学率</text>
<up-icon name="info-circle" color="#333" size="12"></up-icon>
</view>
</view>

1
2
3
4
const emits = defineEmits(['open'])
const openFn = () => {
emits('open', 1111)
}

事件校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup>
const emit = defineEmits({
// 没有校验
click: null,

// 校验 submit 事件
submit: ({ email, password }) => {
if (email && password) {
return true
} else {
console.warn('Invalid submit event payload!')
return false
}
}
})

function submitForm(email, password) {
emit('submit', { email, password })
}
</script>

组件 v-model - defineModel()

v-model 可以在组件上使用以实现双向绑定。
从 Vue 3.4 开始,推荐的实现方式是使用 defineModel() 宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- Child.vue -->
<script setup>
const model = defineModel()

function update() {
model.value++
}
</script>

<template>
<div>Parent bound v-model is: {{ model }}</div>
<button @click="update">Increment</button>
</template>

父组件可以用 v-model 绑定一个值:

1
2
<!-- Parent.vue -->
<Child v-model="countModel" />

defineModel() 返回的值是一个 ref。它可以像其他 ref 一样被访问以及修改,不过它能起到在父组件和当前变量之间的双向绑定的作用:

  • 它的 .value 和父组件的 v-model 的值同步;
  • 当它被子组件变更了,会触发父组件绑定的值一起更新。

这意味着你也可以用 v-model 把这个 ref 绑定到一个原生 input 元素上,在提供相同的 v-model 用法的同时轻松包装原生 input 元素:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- app.vue -->
<script setup>
import Child from './Child.vue'
import { ref } from 'vue'

const msg = ref('Hello World!')
</script>

<template>
<h1>{{ msg }}</h1>
<Child v-model="msg" />
</template>

1
2
3
4
5
6
7
8
<!-- Child.vue -->
<script setup>
const model = defineModel()
</script>

<template>
<span>My input</span> <input v-model="model">
</template>

底层机制

defineModel 是一个便利宏。编译器将其展开为以下内容:

  • 一个名为 modelValue 的 prop,本地 ref 的值与其同步;
  • 一个名为 update:modelValue 的事件,当本地 ref 的值发生变更时触发。

在 3.4 版本之前,你一般会按照如下的方式来实现上述相同的子组件:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- Child.vue -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>

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

然后,父组件中的 v-model="foo" 将被编译为:

1
2
3
4
5
<!-- parent.vue -->
<Child
:modelValue="foo"
@update:modelValue="$event => (foo = $event)"
/>

v-model 的参数

1
2
3
4
5
6
7
8
9
10
11
12
<!-- parent.vue -->
<script setup>
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'

const bookTitle = ref('v-model argument example')
</script>

<template>
<h1>{{ bookTitle }}</h1>
<MyComponent v-model:title="bookTitle" />
</template>
1
2
3
4
5
6
7
8
<!-- MyComponent.vue -->
<script setup>
const title = defineModel('title')
</script>

<template>
<input type="text" v-model="title" />
</template>

多个 v-model 绑定

1
2
3
4
5
<!-- parent.vue -->
<UserName
v-model:first-name="first"
v-model:last-name="last"
/>
1
2
3
4
5
6
7
8
9
10
<!-- UserName.vue -->
<script setup>
const firstName = defineModel('firstName')
const lastName = defineModel('lastName')
</script>

<template>
<input type="text" v-model="firstName" />
<input type="text" v-model="lastName" />
</template>

处理 v-model 修饰符

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

1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { ref } from 'vue'
import MyComponent from './MyComponent.vue'

const myText = ref('')
</script>

<template>
This input capitalizes the first letter you enter:
<MyComponent v-model.capitalize="myText" />
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// MyComponent.vue
const [model, modifiers] = defineModel({
set(value) {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
}
})
</script>

<template>
<input type="text" v-model="model" />
</template>

带参数的 v-model 修饰符​

1
2
3
4
<UserName
v-model:first-name.capitalize="first"
v-model:last-name.uppercase="last"
/>
1
2
3
4
5
6
7
<script setup>
const [firstName, firstNameModifiers] = defineModel('firstName')
const [lastName, lastNameModifiers] = defineModel('lastName')

console.log(firstNameModifiers) // { capitalize: true }
console.log(lastNameModifiers) // { uppercase: true }
</script>

透传 Attributes

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

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

1
2
<!-- <MyButton> 的模板 -->
<button>Click Me</button>

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

1
<MyButton class="large" />

最后渲染出的 DOM 结果是:

1
<button class="large">Click Me</button>

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

对 class 和 style 的合并

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

1
2
<!-- <MyButton> 的模板 -->
<button class="btn">Click Me</button>

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

1
<button class="btn large">Click Me</button>

v-on 监听器继承

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

1
<MyButton @click="onClick" />

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

深层组件继承

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

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

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

请注意:

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

禁用 Attributes 继承

如果你不想要一个组件自动地继承 attribute,你可以在组件选项中设置 inheritAttrs: false
3.3 开始你也可以直接在 <script setup> 中使用 defineOptions

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

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

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

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

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

有几点需要注意:

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

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

1
2
3
<div class="btn-wrapper">
<button class="btn">Click Me</button>
</div>

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

1
2
3
<div class="btn-wrapper">
<button class="btn" v-bind="$attrs">Click Me</button>
</div>

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

如果你有像这样的一个包含多个 attribute 的 JavaScript 对象:

1
2
3
4
5
const objectOfAttrs = {
id: 'container',
class: 'wrapper',
style: 'background-color:green'
}

通过不带参数的 v-bind,你可以将它们绑定到单个元素上:

1
<div v-bind="objectOfAttrs"></div>

多个根节点的 Attributes 继承

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

1
<CustomLayout id="custom-layout" @click="changeValue" />

如果 <CustomLayout> 有下面这样的多根节点模板,由于 Vue 不知道要将 attribute 透传到哪里,所以会抛出一个警告。

1
2
3
<header>...</header>
<main>...</main>
<footer>...</footer>

如果 $attrs 被显式绑定,则不会有警告:

1
2
3
<header>...</header>
<main v-bind="$attrs">...</main>
<footer>...</footer>

在 JavaScript 中访问透传 Attributes - useAttrs()

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

1
2
3
4
5
<script setup>
import { useAttrs } from 'vue'

const attrs = useAttrs()
</script>

如果没有使用 <script setup>attrs 会作为 setup() 上下文对象的一个属性暴露:

1
2
3
4
5
export default {
setup(props, ctx) {
console.log(ctx.attrs)
}
}

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

插槽 Slots

具名插槽

有时在一个组件中包含多个插槽出口是很有用的。举例来说,在一个 <BaseLayout> 组件中,有如下模板:

1
2
3
4
5
6
7
8
9
10
11
<div class="container">
<header>
<!-- 标题内容放这里 -->
</header>
<main>
<!-- 主要内容放这里 -->
</main>
<footer>
<!-- 底部内容放这里 -->
</footer>
</div>

对于这种场景,<slot> 元素可以有一个特殊的 attribute name,用来给各个插槽分配唯一的 ID,以确定每一处要渲染的内容:

1
2
3
4
5
6
7
8
9
10
11
<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”。

在父组件中使用 <BaseLayout> 时,我们需要一种方式将多个插槽内容传入到各自目标插槽的出口。此时就需要用到具名插槽了:

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

1
2
3
4
5
<BaseLayout>
<template v-slot:header>
<!-- header 插槽的内容放这里 -->
</template>
</BaseLayout>

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

下面我们给出完整的、向 <BaseLayout> 传递插槽内容的代码,指令均使用的是缩写形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<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> 节点都被隐式地视为默认插槽的内容。所以上面也可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
<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>

现在 <template> 元素中的所有内容都将被传递到相应的插槽。最终渲染出的 HTML 如下:

1
2
3
4
5
6
7
8
9
10
11
12
<div class="container">
<header>
<h1>Here might be a page title</h1>
</header>
<main>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</main>
<footer>
<p>Here's some contact info</p>
</footer>
</div>

条件插槽

有时你需要根据内容是否被传入了插槽来渲染某些内容。

你可以结合使用 $slots 属性与 v-if 来实现。

在下面的示例中,我们定义了一个卡片组件,它拥有三个条件插槽:headerfooterdefault。 当 header、footer 或 default 的内容存在时,我们希望包装它以提供额外的样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<div class="card">
<div v-if="$slots.header" class="card-header">
<slot name="header" />
</div>

<div v-if="$slots.default" class="card-content">
<slot />
</div>

<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<Card>
<template #header>
<h1>This is the header</h1>
</template>

<template #default>
<p>This is the content</p>
</template>

<template #footer>
<em>This is the footer</em>
</template>
</Card>
</template>

动态插槽名

动态指令参数v-slot 上也是有效的,即可以定义下面这样的动态插槽名:

1
2
3
4
5
6
7
8
9
10
<base-layout>
<template v-slot:[dynamicSlotName]>
...
</template>

<!-- 缩写为 -->
<template #[dynamicSlotName]>
...
</template>
</base-layout>

作用域插槽

在上面的渲染作用域中我们讨论到,插槽的内容无法访问到子组件的状态。

然而在某些场景下插槽的内容可能想要同时使用父组件域内和子组件域内的数据。要做到这一点,我们需要一种方法来让子组件在渲染时将一部分数据提供给插槽

我们也确实有办法这么做!可以像对组件传递 props 那样,向一个插槽的出口上传递 attributes:

1
2
3
4
5
6
7
<!-- <MyComponent> 的模板 -->
<script setup>
const greetingMessage = 'hello'
</script>
<div>
<slot :text="greetingMessage" :count="1"></slot>
</div>

当需要接收插槽 props 时,默认插槽和具名插槽的使用方式有一些小区别。下面我们将先展示默认插槽如何接受 props,通过子组件标签上的 v-slot 指令,直接接收到了一个插槽 props 对象:

1
2
3
4
<!-- app.vue -->
<MyComponent v-slot="slotProps">
{{ slotProps.text }} {{ slotProps.count }}
</MyComponent>

具名作用域插槽

具名作用域插槽的工作方式也是类似的,插槽 props 可以作为 v-slot 指令的值被访问到:v-slot:name="slotProps"。当使用缩写时是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
<MyComponent>
<template #header="headerProps">
{{ headerProps }}
</template>

<template #default="defaultProps">
{{ defaultProps }}
</template>

<template #footer="footerProps">
{{ footerProps }}
</template>
</MyComponent>

向具名插槽中传入 props:

1
<slot name="header" message="hello"></slot>

注意插槽上的 name 是一个 Vue 特别保留的 attribute,不会作为 props 传递给插槽。因此最终 headerProps 的结果是 { message: 'hello' }

如果你同时使用了具名插槽与默认插槽,则需要为默认插槽使用显式的 <template> 标签。尝试直接为组件添加 v-slot 指令将导致编译错误。这是为了避免因默认插槽的 props 的作用域而困惑。举例:

1
2
3
4
5
<!-- <MyComponent> template -->
<div>
<slot :message="hello"></slot>
<slot name="footer" />
</div>
1
2
3
4
5
6
7
8
<!-- 该模板无法编译 -->
<MyComponent v-slot="{ message }">
<p>{{ message }}</p>
<template #footer>
<!-- message 属于默认插槽,此处不可用 -->
<p>{{ message }}</p>
</template>
</MyComponent>

为默认插槽使用显式的 <template> 标签有助于更清晰地指出 message 属性在其他插槽中不可用:

1
2
3
4
5
6
7
8
9
10
<MyComponent>
<!-- 使用显式的默认插槽 -->
<template #default="{ message }">
<p>{{ message }}</p>
</template>

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

高级列表组件示例

你可能想问什么样的场景才适合用到作用域插槽,这里我们来看一个 <FancyList> 组件的例子。它会渲染一个列表,并同时会封装一些加载远端数据的逻辑、使用数据进行列表渲染、或者是像分页或无限滚动这样更进阶的功能。然而我们希望它能够保留足够的灵活性,将对单个列表元素内容和样式的控制权留给使用它的父组件。我们期望的用法可能是这样的:

1
2
3
4
5
6
7
8
9
<!-- app.vue -->
<FancyList :api-url="url" :per-page="10">
<template #item="{ body, username, likes }">
<div class="item">
<p>{{ body }}</p>
<p>by {{ username }} | {{ likes }} likes</p>
</div>
</template>
</FancyList>

<FancyList> 之中,我们可以多次渲染 <slot> 并每次都提供不同的数据 (注意我们这里使用了 v-bind 来传递插槽的 props):

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

const props = defineProps(['api-url', 'per-page'])

const items = ref([])

// mock remote data fetching
setTimeout(() => {
items.value = [
{ body: 'Scoped Slots Guide', username: 'Evan You', likes: 20 },
{ body: 'Vue Tutorial', username: 'Natalia Tepluhina', likes: 10 }
]
}, 1000)
</script>

<template>
<ul>
<li v-if="!items.length">
Loading...
</li>
<li v-for="item in items">
<slot name="item" v-bind="item"/>
</li>
</ul>
</template>

无渲染组件

上面的 <FancyList> 案例同时封装了可重用的逻辑 (数据获取、分页等) 和视图输出,但也将部分视图输出通过作用域插槽交给了消费者组件来管理。

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

这里有一个无渲染组件的例子,一个封装了追踪当前鼠标位置逻辑的组件:

1
2
3
4
5
6
7
8
9
10
<!-- app.vue -->
<script setup>
import MouseTracker from './MouseTracker.vue'
</script>

<template>
<MouseTracker v-slot="{ x, y }">
Mouse is at: {{ x }}, {{ y }}
</MouseTracker>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- MouseTracker.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const x = ref(0)
const y = ref(0)

const update = e => {
x.value = e.pageX
y.value = e.pageY
}

onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>

<template>
<slot :x="x" :y="y"/>
</template>

依赖注入

Prop 逐级透传问题 - provide / inject

通常情况下,当我们需要从父组件向子组件传递数据时,会使用 props。想象一下这样的结构:有一些多层级嵌套的组件,形成了一棵巨大的组件树,而某个深层的子组件需要一个较远的祖先组件中的部分数据。在这种情况下,如果仅使用 props 则必须将其沿着组件链逐级传递下去,这会非常麻烦:

注意,虽然这里的 <Footer> 组件可能根本不关心这些 props,但为了使 <DeepChild> 能访问到它们,仍然需要定义并向下传递。如果组件链路非常长,可能会影响到更多这条路上的组件。这一问题被称为“prop 逐级透传”,显然是我们希望尽量避免的情况。

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


Provide (提供)

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

1
2
3
4
5
<script setup>
import { provide } from 'vue'

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

如果不使用 <script setup>,请确保 provide() 是在 setup() 同步调用的:

1
2
3
4
5
6
7
import { provide } from 'vue'

export default {
setup() {
provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
}
}

provide() 函数接收两个参数。第一个参数被称为注入名,可以是一个字符串或是一个 Symbol。后代组件会用注入名来查找期望注入的值。一个组件可以多次调用 provide(),使用不同的注入名,注入不同的依赖值。

第二个参数是提供的值,值可以是任意类型,包括响应式的状态,比如一个 ref:

1
2
3
4
import { ref, provide } from 'vue'

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

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


应用层 Provide

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

1
2
3
4
5
6
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

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

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


Inject (注入)

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

1
2
3
4
5
<script setup>
import { inject } from 'vue'

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

如果有多个父组件提供了相同键的数据,注入将解析为组件链上最近的父组件所注入的值。

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


以下是完整的一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- app.vue -->
<script setup>
import { ref, provide } from 'vue'
import Child from './Child.vue'

const message = ref('hello')
provide('message', message)
</script>

<template>
<input v-model="message">
<Child />
</template>
1
2
3
4
5
6
7
8
<!-- child.vue -->
<script setup>
import GrandChild from './GrandChild.vue'
</script>

<template>
<GrandChild />
</template>
1
2
3
4
5
6
7
8
9
10
11
12
<!-- GrandChild.vue -->
<script setup>
import { inject } from 'vue'

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

<template>
<p>
Message to grand child: {{ message }}
</p>
</template>

以下是效果图:


注入默认值

默认情况下,inject 假设传入的注入名会被某个祖先链上的组件提供。如果该注入名的确没有任何组件提供,则会抛出一个运行时警告。

如果在注入一个值时不要求必须有提供者,那么我们应该声明一个默认值,和 props 类似:

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

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

1
const value = inject('message', () => new ExpensiveClass(), true)

第三个参数表示默认值应该被当作一个工厂函数。


和响应式数据配合使用

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!-- 在供给方组件内 -->
<script setup>
import { provide, ref } from 'vue'

const location = ref('North Pole')

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

provide('location', {
location,
updateLocation
})
</script>
```

```html
<!-- 在注入方组件 -->
<script setup>
import { inject } from 'vue'

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

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

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

1
2
3
4
import { ref, provide, readonly } from 'vue'

const count = ref(0)
provide('read-only-count', readonly(count))

使用 Symbol 作注入名

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

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

1
2
// keys.js
export const myInjectionKey = Symbol()

然后,在供给方组件中:

1
2
3
4
import { provide } from 'vue'
import { myInjectionKey } from './keys.js'

provide(myInjectionKey, 'hello!')

最后,在注入方组件中:

1
2
3
4
import { inject } from 'vue'
import { myInjectionKey } from './keys.js'

const message = inject(myInjectionKey)

异步组件 - async-components

基本用法 - basic-usage

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

1
2
3
4
5
6
7
8
9
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 单文件组件:

1
2
3
4
5
import { defineAsyncComponent } from 'vue'

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

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

与普通组件一样,异步组件可以使用 app.component() 全局注册

1
2
3
app.component('MyComponent', defineAsyncComponent(() =>
import('./components/MyComponent.vue')
))

你也可以在局部注册组件时使用 defineAsyncComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
import { defineAsyncComponent } from 'vue'

export default {
components: {
AdminPage: defineAsyncComponent(() =>
import('./components/AdminPageComponent.vue')
)
}
}
</script>

<template>
<AdminPage />
</template>

也可以直接在父组件中直接定义它们:

1
2
3
4
5
6
7
8
9
10
11
<script setup>
import { defineAsyncComponent } from 'vue'

const AdminPage = defineAsyncComponent(() =>
import('./components/AdminPageComponent.vue')
)
</script>

<template>
<AdminPage />
</template>

加载与错误状态 - loading-and-error-states

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const AsyncComp = defineAsyncComponent({
// 加载函数
loader: () => import('./Foo.vue'),

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

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

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

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

惰性激活

如果你正在使用服务器端渲染,这一部分才会适用。

在 Vue 3.5+ 中,异步组件可以通过提供激活策略来控制何时进行激活。

  • Vue 提供了一些内置的激活策略。这些内置策略需要分别导入,以便在未使用时进行 tree-shake。

  • 该设计有意保持在底层,以确保灵活性。将来可以在此基础上构建编译器语法糖,无论是在核心还是更上层的解决方案 (如 Nuxt) 中实现。

在空闲时进行激活 - hydrate-on-idle

通过 requestIdleCallback 进行激活:

1
2
3
4
5
6
import { defineAsyncComponent, hydrateOnIdle } from 'vue'

const AsyncComp = defineAsyncComponent({
loader: () => import('./Comp.vue'),
hydrate: hydrateOnIdle(/* 传递可选的最大超时 */)
})

在可见时激活 - hydrate-on-visible

通过 IntersectionObserver 在元素变为可见时进行激活。

1
2
3
4
5
6
import { defineAsyncComponent, hydrateOnVisible } from 'vue'

const AsyncComp = defineAsyncComponent({
loader: () => import('./Comp.vue'),
hydrate: hydrateOnVisible()
})

可以选择传递一个侦听器的选项对象值:

1
hydrateOnVisible({ rootMargin: '100px' })

在媒体查询匹配时进行激活 - hydrate-on-media-query

当指定的媒体查询匹配时进行激活。

1
2
3
4
5
6
import { defineAsyncComponent, hydrateOnMediaQuery } from 'vue'

const AsyncComp = defineAsyncComponent({
loader: () => import('./Comp.vue'),
hydrate: hydrateOnMediaQuery('(max-width:500px)')
})

交互时激活 - hydrate-on-interaction

当组件元素上触发指定事件时进行激活。完成激活后,触发激活的事件也将被重放。

1
2
3
4
5
6
import { defineAsyncComponent, hydrateOnInteraction } from 'vue'

const AsyncComp = defineAsyncComponent({
loader: () => import('./Comp.vue'),
hydrate: hydrateOnInteraction('click')
})

也可以是多个事件类型的列表:

1
hydrateOnInteraction(['wheel', 'mouseover'])

自定义策略 - custom-strategy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { defineAsyncComponent, type HydrationStrategy } from 'vue'

const myStrategy: HydrationStrategy = (hydrate, forEachElement) => {
// forEachElement 是一个遍历组件未激活的 DOM 中所有根元素的辅助函数,
// 因为根元素可能是一个片段而非单个元素
forEachElement(el => {
// ...
})
// 准备好时调用 `hydrate`
hydrate()
return () => {
// 如必要,返回一个销毁函数
}
}

const AsyncComp = defineAsyncComponent({
loader: () => import('./Comp.vue'),
hydrate: myStrategy
})

搭配 Suspense 使用 - using-with-suspense

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

自定义指令

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

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

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

1
2
3
4
5
6
7
8
9
10
// 在模板中启用 v-highlight
const vHighlight = {
mounted: (el) => {
el.classList.add('is-highlight')
}
}

<template>
<p v-highlight>This sentence is important!</p>
</template>

<script setup> 中,任何以 v 开头的驼峰式命名的变量都可以当作自定义指令使用。在上述例子中,vHighlight 可以在模板中以 v-highlight 的形式使用。

在不使用 <script setup> 的情况下,自定义指令需要通过 directives 选项注册:

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

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

1
2
3
4
5
6
const app = createApp({})

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

自定义指令的使用时机

只有当所需功能只能通过直接的 DOM 操作来实现时,才应该使用自定义指令。

一个常见例子是使元素获取焦点的 v-focus 指令。

1
2
3
4
5
6
7
8
9
10
<script setup>
// 在模板中启用 v-focus
const vFocus = {
mounted: (el) => el.focus()
}
</script>

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

该指令比 autofocus 属性更有用,因为它不仅在页面加载时有效,而且在 Vue 动态插入元素时也有效!

建议尽可能使用 v-bind 等内置指令声明模板,因为它们更高效,对服务端渲染也更友好。

指令钩子

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

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

钩子参数

指令钩子函数会被传入以下参数:

  • el:指令绑定到的元素。这可以用来直接操作 DOM。
  • binding:一个对象,包含以下属性:
    • value:传递给指令的值。例如,在 v-my-directive="1 + 1" 的情况下,值是 2
    • oldValue:之前的值,仅在 beforeUpdateupdated 中可用。无论值是否有改变都可用。
    • arg:传递给指令的参数 (如果有的话)。例如,在 v-my-directive:foo 中,参数是 "foo"
    • modifiers:一个包含修饰符的对象 (如果有的话)。例如,在 v-my-directive.foo.bar 中,修饰符对象是 { foo: true, bar: true }
    • instance:使用该指令的组件实例。
    • dir:指令的定义对象。
  • vnode:代表绑定元素的底层 VNode。
  • prevNode:之前的渲染中代表指令所绑定元素的 VNode。仅在 beforeUpdateupdated 钩子中可用。

示例:

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

binding 参数会是一个这样的对象:

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

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

1
<div v-example:[arg]="value"></div>

这里指令的参数会基于组件的 arg 数据属性响应式地更新。

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

简化形式

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

1
<div v-color="color"></div>
1
2
3
4
app.directive('color', (el, binding) => {
// 这会在 `mounted` 和 `updated` 时都调用
el.style.color = binding.value
})

对象字面量

如果你的指令需要多个值,你可以向它传递一个 JavaScript 对象字面量。别忘了,指令也可以接收任何合法的 JavaScript 表达式。

1
<div v-demo="{ color: 'white', text: 'hello!' }"></div>
1
2
3
4
app.directive('demo', (el, binding) => {
console.log(binding.value.color) // => "white"
console.log(binding.value.text) // => "hello!"
})