0%

Vue学习(四)——可复用和组合

一、组合式API

1. 什么是组合式API?

通过创建 Vue 组件,我们可以将界面中重复的部分连同其功能一起提取为可重用的代码段。仅此一项就可以使我们的应用在可维护性和灵活性方面走得相当远。然而,我们的经验已经证明,光靠这一点可能并不够,尤其是当你的应用变得非常大的时候——想想几百个组件。处理这样的大型应用时,共享和重用代码变得尤为重要。

假设我们的应用中有一个显示某个用户的仓库列表的视图。此外,我们还希望有搜索和筛选功能。实现此视图组件的代码可能如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/components/UserRepositories.vue

export default {
components: { RepositoriesFilters, RepositoriesSortBy, RepositoriesList },
props: {
user: {
type: String,
required: true
}
},
data () {
return {
repositories: [], // 1
filters: { ... }, // 3
searchQuery: '' // 2
}
},
computed: {
filteredRepositories () { ... }, // 3
repositoriesMatchingSearchQuery () { ... }, // 2
},
watch: {
user: 'getUserRepositories' // 1
},
methods: {
getUserRepositories () {
// 使用 `this.user` 获取用户仓库
}, // 1
updateFilters () { ... }, // 3
},
mounted () {
this.getUserRepositories() // 1
}
}

该组件有以下几个职责:

  1. 从假定的外部 API 获取该用户的仓库,并在用户有任何更改时进行刷新
  2. 使用 searchQuery 字符串搜索仓库
  3. 使用 filters 对象筛选仓库

使用 (datacomputedmethodswatch) 组件选项来组织逻辑通常都很有效。然而,当我们的组件开始变得更大时,逻辑关注点的列表也会增长。尤其对于那些一开始没有编写这些组件的人来说,这会导致组件难以阅读和理解。

Vue 选项式 API: 按选项类型分组的代码

这是一个大型组件的示例,其中逻辑关注点按颜色进行分组。

这种碎片化使得理解和维护复杂组件变得困难。选项的分离掩盖了潜在的逻辑问题。此外,在处理单个逻辑关注点时,我们必须不断地“跳转”相关代码的选项块。

如果能够将同一个逻辑关注点相关代码收集在一起会更好。而这正是组合式 API 使我们能够做到的。

2. 组合式API基础

为了开始使用组合式 API,我们首先需要一个可以实际使用它的地方。在 Vue 组件中,我们将此位置称为 setup

2.1 setup 组件选项

新的 setup 选项在组件被创建之前执行,一旦 props 被解析完成,它就将被作为组合式 API 的入口。

在 setup 中你应该避免使用 this,因为它不会找到组件实例。setup 的调用发生在 data property、computed property 或 methods 被解析之前,所以它们无法在 setup 中被获取。

setup 选项是一个接收 props 和 context 的函数。此外,我们将 setup 返回的所有内容都暴露给组件的其余部分 (计算属性、方法、生命周期钩子等等) 以及组件的模板。

把 setup 添加到组件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/components/UserRepositories.vue

export default {
components: { RepositoriesFilters, RepositoriesSortBy, RepositoriesList },
props: {
user: {
type: String,
required: true
}
},
setup(props) {
console.log(props) // { user: '' }

return {} // 这里返回的任何内容都可以用于组件的其余部分
}
// 组件的“其余部分”
}

现在让我们从提取第一个逻辑关注点开始 (在原始代码段中标记为“1”)。

  1. 从假定的外部 API 获取该用户的仓库,并在用户有任何更改时进行刷新

我们将从最明显的部分开始:

  • 仓库列表
  • 更新仓库列表的函数
  • 返回列表和函数,以便其他组件选项可以对它们进行访问

2.2 带 ref 的响应式变量

在 Vue 3.0 中,我们可以通过一个新的 ref 函数使任何响应式变量在任何地方起作用,如下所示:

1
2
3
import { ref } from 'vue'

const counter = ref(0)

ref 接收参数并将其包裹在一个带有 value property 的对象中返回,然后可以使用该 property 访问或更改响应式变量的值:

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

const counter = ref(0)

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

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

将值封装在一个对象中,看似没有必要,但为了保持 JavaScript 中不同数据类型的行为统一,这是必须的。这是因为在 JavaScript 中,Number 或 String 等基本类型是通过值而非引用传递的:

按引用传递与按值传递

在任何值周围都有一个封装对象,这样我们就可以在整个应用中安全地传递它,而不必担心在某个地方失去它的响应性。

换句话说,ref 为我们的值创建了一个响应式引用。在整个组合式 API 中会经常使用引用的概念。

现在,每当我们调用 getUserRepositories 时,repositories 都将发生变化,视图也会更新以反映变化。我们的组件现在应该如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// src/components/UserRepositories.vue
import { fetchUserRepositories } from '@/api/repositories'
import { ref } from 'vue'

export default {
components: { RepositoriesFilters, RepositoriesSortBy, RepositoriesList },
props: {
user: {
type: String,
required: true
}
},
setup (props) {
const repositories = ref([])
const getUserRepositories = async () => {
repositories.value = await fetchUserRepositories(props.user)
}

return {
repositories,
getUserRepositories
}
},
data () {
return {
filters: { ... }, // 3
searchQuery: '' // 2
}
},
computed: {
filteredRepositories () { ... }, // 3
repositoriesMatchingSearchQuery () { ... }, // 2
},
watch: {
user: 'getUserRepositories' // 1
},
methods: {
updateFilters () { ... }, // 3
},
mounted () {
this.getUserRepositories() // 1
}
}

我们已经将第一个逻辑关注点中的几个部分移到了 setup 方法中,它们彼此非常接近。剩下的就是在 mounted 钩子中调用 getUserRepositories,并设置一个监听器,以便在 user prop 发生变化时执行此操作。

我们将从生命周期钩子开始。

2.3 在 setup 内注册生命周期钩子

为了使组合式 API 的功能和选项式 API 一样完整,我们还需要一种在 setup 中注册生命周期钩子的方法。这要归功于 Vue 导出的几个新函数。组合式 API 上的生命周期钩子与选项式 API 的名称相同,但前缀为 on:即 mounted 看起来会像 onMounted

这些函数接受一个回调,当钩子被组件调用时,该回调将被执行。

让我们将其添加到 setup 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/components/UserRepositories.vue `setup` function
import { fetchUserRepositories } from '@/api/repositories'
import { ref, onMounted } from 'vue'

// 在我们的组件中
setup (props) {
const repositories = ref([])
const getUserRepositories = async () => {
repositories.value = await fetchUserRepositories(props.user)
}

onMounted(getUserRepositories) // 在 `mounted` 时调用 `getUserRepositories`

return {
repositories,
getUserRepositories
}
}

现在我们需要对 user prop 的变化做出反应。为此,我们将使用独立的 watch 函数。

2.4 watch 响应式更改

就像我们在组件中使用 watch 选项并在 user property 上设置侦听器一样,我们也可以使用从 Vue 导入的 watch 函数执行相同的操作。它接受 3 个参数:

  • 一个想要侦听的响应式引用或 getter 函数
  • 一个回调
  • 可选的配置选项

下面让我们快速了解一下它是如何工作的

1
2
3
4
5
6
import { ref, watch } from 'vue'

const counter = ref(0)
watch(counter, (newValue, oldValue) => {
console.log('The new counter value is: ' + counter.value)
})

每当 counter 被修改时,例如 counter.value=5,侦听将触发并执行回调 (第二个参数),在本例中,它将把 'The new counter value is:5' 记录到控制台中

以下是等效的选项式 API:

1
2
3
4
5
6
7
8
9
10
11
12
export default {
data() {
return {
counter: 0
}
},
watch: {
counter(newValue, oldValue) {
console.log('The new counter value is: ' + this.counter)
}
}
}

有关 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
// src/components/UserRepositories.vue `setup` function
import { fetchUserRepositories } from '@/api/repositories'
import { ref, onMounted, watch, toRefs } from 'vue'

// 在我们组件中
setup (props) {
// 使用 `toRefs` 创建对 `props` 中的 `user` property 的响应式引用
const { user } = toRefs(props)

const repositories = ref([])
const getUserRepositories = async () => {
// 更新 `prop.user` 到 `user.value` 访问引用值
repositories.value = await fetchUserRepositories(user.value)
}

onMounted(getUserRepositories)

// 在 user prop 的响应式引用上设置一个侦听器
watch(user, getUserRepositories)

return {
repositories,
getUserRepositories
}
}

你可能已经注意到在我们的 setup 的顶部使用了 toRefs。这是为了确保我们的侦听器能够根据 user prop 的变化做出反应。

有了这些变化,我们就把第一个逻辑关注点移到了一个地方。我们现在可以对第二个关注点执行相同的操作——基于 searchQuery 进行过滤,这次是使用计算属性。

2.5 独立的 computed 属性

与 ref 和 watch 类似,也可以使用从 Vue 导入的 computed 函数在 Vue 组件外部创建计算属性。让我们回到 counter 的例子:

1
2
3
4
5
6
7
8
import { ref, computed } from 'vue'

const counter = ref(0)
const twiceTheCounter = computed(() => counter.value * 2)

counter.value++
console.log(counter.value) // 1
console.log(twiceTheCounter.value) // 2

这里我们给 computed 函数传递了第一个参数,它是一个类似 getter 的回调函数,输出的是一个只读响应式引用。为了访问新创建的计算变量的 value,我们需要像 ref 一样使用 .value property。

让我们将搜索功能移到 setup 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// src/components/UserRepositories.vue `setup` function
import { fetchUserRepositories } from '@/api/repositories'
import { ref, onMounted, watch, toRefs, computed } from 'vue'

// 在我们的组件中
setup (props) {
// 使用 `toRefs` 创建对 props 中的 `user` property 的响应式引用
const { user } = toRefs(props)

const repositories = ref([])
const getUserRepositories = async () => {
// 更新 `props.user ` 到 `user.value` 访问引用值
repositories.value = await fetchUserRepositories(user.value)
}

onMounted(getUserRepositories)

// 在 user prop 的响应式引用上设置一个侦听器
watch(user, getUserRepositories)

const searchQuery = ref('')
const repositoriesMatchingSearchQuery = computed(() => {
return repositories.value.filter(
repository => repository.name.includes(searchQuery.value)
)
})

return {
repositories,
getUserRepositories,
searchQuery,
repositoriesMatchingSearchQuery
}
}

对于其他的逻辑关注点我们也可以这样做,但是你可能已经在问这个问题了——这不就是把代码移到 setup 选项并使它变得非常大吗?嗯,确实是这样的。这就是为什么我们要在继续其他任务之前,我们首先要将上述代码提取到一个独立的组合式函数中。让我们从创建 useUserRepositories 函数开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// src/composables/useUserRepositories.js

import { fetchUserRepositories } from '@/api/repositories'
import { ref, onMounted, watch } from 'vue'

export default function useUserRepositories(user) {
const repositories = ref([])
const getUserRepositories = async () => {
repositories.value = await fetchUserRepositories(user.value)
}

onMounted(getUserRepositories)
watch(user, getUserRepositories)

return {
repositories,
getUserRepositories
}
}

然后是搜索功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/composables/useRepositoryNameSearch.js

import { ref, computed } from 'vue'

export default function useRepositoryNameSearch(repositories) {
const searchQuery = ref('')
const repositoriesMatchingSearchQuery = computed(() => {
return repositories.value.filter(repository => {
return repository.name.includes(searchQuery.value)
})
})

return {
searchQuery,
repositoriesMatchingSearchQuery
}
}

现在我们有了两个单独的功能模块,接下来就可以开始在组件中使用它们了。以下是如何做到这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// src/components/UserRepositories.vue
import useUserRepositories from '@/composables/useUserRepositories'
import useRepositoryNameSearch from '@/composables/useRepositoryNameSearch'
import { toRefs } from 'vue'

export default {
components: { RepositoriesFilters, RepositoriesSortBy, RepositoriesList },
props: {
user: {
type: String,
required: true
}
},
setup (props) {
const { user } = toRefs(props)

const { repositories, getUserRepositories } = useUserRepositories(user)

const {
searchQuery,
repositoriesMatchingSearchQuery
} = useRepositoryNameSearch(repositories)

return {
// 因为我们并不关心未经过滤的仓库
// 我们可以在 `repositories` 名称下暴露过滤后的结果
repositories: repositoriesMatchingSearchQuery,
getUserRepositories,
searchQuery,
}
},
data () {
return {
filters: { ... }, // 3
}
},
computed: {
filteredRepositories () { ... }, // 3
},
methods: {
updateFilters () { ... }, // 3
}
}

此时,你可能已经知道了其中的奥妙,所以让我们跳到最后,迁移剩余的过滤功能。我们不需要深入了解实现细节,因为这并不是本指南的重点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// src/components/UserRepositories.vue
import { toRefs } from 'vue'
import useUserRepositories from '@/composables/useUserRepositories'
import useRepositoryNameSearch from '@/composables/useRepositoryNameSearch'
import useRepositoryFilters from '@/composables/useRepositoryFilters'

export default {
components: { RepositoriesFilters, RepositoriesSortBy, RepositoriesList },
props: {
user: {
type: String,
required: true
}
},
setup(props) {
const { user } = toRefs(props)

const { repositories, getUserRepositories } = useUserRepositories(user)

const {
searchQuery,
repositoriesMatchingSearchQuery
} = useRepositoryNameSearch(repositories)

const {
filters,
updateFilters,
filteredRepositories
} = useRepositoryFilters(repositoriesMatchingSearchQuery)

return {
// 因为我们并不关心未经过滤的仓库
// 我们可以在 `repositories` 名称下暴露过滤后的结果
repositories: filteredRepositories,
getUserRepositories,
searchQuery,
filters,
updateFilters
}
}
}

我们只触及了组合式 API 的表面以及它允许我们做什么。要了解更多信息,请参阅深入指南。

2. Setup

2.1 参数

使用 setup 函数时,它将接收两个参数:

  1. props
  2. context

让我们更深入地研究如何使用每个参数。

2.1.1 Props

setup 函数中的第一个参数是 props。正如在一个标准组件中所期望的那样,setup 函数中的 props 是响应式的,当传入新的 prop 时,它将被更新。

1
2
3
4
5
6
7
8
9
10
// MyBook.vue

export default {
props: {
title: String
},
setup(props) {
console.log(props.title)
}
}

因为 props 是响应式的,你不能使用 ES6 解构,它会消除 prop 的响应性。

如果需要解构 prop,可以在 setup 函数中使用 toRefs 函数来完成此操作:

1
2
3
4
5
6
7
8
9
// MyBook.vue

import { toRefs } from 'vue'

setup(props) {
const { title } = toRefs(props)

console.log(title.value)
}

如果 title 是可选的 prop,则传入的 props 中可能没有 title 。在这种情况下,toRefs 将不会为 title 创建一个 ref 。你需要使用 toRef 替代它:

1
2
3
4
5
6
// MyBook.vue
import { toRef } from 'vue'
setup(props) {
const title = toRef(props, 'title')
console.log(title.value)
}

2.1.2 Context

传递给 setup 函数的第二个参数是 contextcontext 是一个普通 JavaScript 对象,暴露了其它可能在 setup 中有用的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// MyBook.vue

export default {
setup(props, context) {
// Attribute (非响应式对象,等同于 $attrs)
console.log(context.attrs)

// 插槽 (非响应式对象,等同于 $slots)
console.log(context.slots)

// 触发事件 (方法,等同于 $emit)
console.log(context.emit)

// 暴露公共 property (函数)
console.log(context.expose)
}
}

context 是一个普通的 JavaScript 对象,也就是说,它不是响应式的,这意味着你可以安全地对 context 使用 ES6 解构。

1
2
3
4
5
6
// MyBook.vue
export default {
setup(props, { attrs, slots, emit, expose }) {
...
}
}

attrs 和 slots 是有状态的对象,它们总是会随组件本身的更新而更新。这意味着你应该避免对它们进行解构,并始终以 attrs.x 或 slots.x 的方式引用 property。请注意,与 props 不同,attrs 和 slots 的 property 是响应式的。如果你打算根据 attrs 或 slots 的更改应用副作用,那么应该在 onBeforeUpdate 生命周期钩子中执行此操作。

我们将在稍后解释 expose 所扮演的角色。

2.2 访问组件的 property

执行 setup 时,你只能访问以下 property:

  • props
  • attrs
  • slots
  • emit

换句话说,你将无法访问以下组件选项:

  • data
  • computed
  • methods
  • refs (模板 ref)

2.3 结合模板使用

如果 setup 返回一个对象,那么该对象的 property 以及传递给 setup 的 props 参数中的 property 就都可以在模板中访问到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- MyBook.vue -->
<template>
<div>{{ collectionName }}: {{ readersNumber }} {{ book.title }}</div>
</template>

<script>
import { ref, reactive } from 'vue'

export default {
props: {
collectionName: String
},
setup(props) {
const readersNumber = ref(0)
const book = reactive({ title: 'Vue 3 Guide' })

// 暴露给 template
return {
readersNumber,
book
}
}
}
</script>

注意,从 setup 返回的 refs 在模板中访问时是被自动浅解包的,因此不应在模板中使用 .value

2.4 使用渲染函数

setup 还可以返回一个渲染函数,该函数可以直接使用在同一作用域中声明的响应式状态:

1
2
3
4
5
6
7
8
9
10
11
12
// MyBook.vue

import { h, ref, reactive } from 'vue'

export default {
setup() {
const readersNumber = ref(0)
const book = reactive({ title: 'Vue 3 Guide' })
// 请注意这里我们需要显式使用 ref 的 value
return () => h('div', [readersNumber.value, book.title])
}
}

返回一个渲染函数将阻止我们返回任何其它的东西。从内部来说这不应该成为一个问题,但当我们想要将这个组件的方法通过模板 ref 暴露给父组件时就不一样了。

我们可以通过调用 expose 来解决这个问题,给它传递一个对象,其中定义的 property 将可以被外部组件实例访问:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { h, ref } from 'vue'
export default {
setup(props, { expose }) {
const count = ref(0)
const increment = () => ++count.value

expose({
increment
})

return () => h('div', count.value)
}
}

这个 increment 方法现在将可以通过父组件的模板 ref 访问。

2.5 使用 this

在 setup() 内部,this 不是该活跃实例的引用,因为 setup() 是在解析其它组件选项之前被调用的,所以 setup() 内部的 this 的行为与其它选项中的 this 完全不同。这使得 setup() 在和其它选项式 API 一起使用时可能会导致混淆。

3. 生命周期钩子

你可以通过在生命周期钩子前面加上 “on” 来访问组件的生命周期钩子。

下表包含如何在 setup () 内部调用生命周期钩子:

选项式 API Hook inside setup
beforeCreate Not needed*
created Not needed*
beforeMount onBeforeMount
mounted onMounted
beforeUpdate onBeforeUpdate
updated onUpdated
beforeUnmount onBeforeUnmount
unmounted onUnmounted
errorCaptured onErrorCaptured
renderTracked onRenderTracked
renderTriggered onRenderTriggered
activated onActivated
deactivated onDeactivated

因为 setup 是围绕 beforeCreate 和 created 生命周期钩子运行的,所以不需要显式地定义它们。换句话说,在这些钩子中编写的任何代码都应该直接在 setup 函数中编写。

这些函数接受一个回调函数,当钩子被组件调用时将会被执行:

1
2
3
4
5
6
7
8
9
10
// MyBook.vue

export default {
setup() {
// mounted
onMounted(() => {
console.log('Component is mounted!')
})
}
}

4. Provide/Inject

我们也可以在组合式 API 中使用 provide/inject。两者都只能在当前活动实例的 setup() 期间调用。

4.1 设想场景

假设我们要重写以下代码,其中包含一个 MyMap 组件,该组件使用组合式 API 为 MyMarker 组件提供用户的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- src/components/MyMap.vue -->
<template>
<MyMarker />
</template>

<script>
import MyMarker from './MyMarker.vue'

export default {
components: {
MyMarker
},
provide: {
location: 'North Pole',
geolocation: {
longitude: 90,
latitude: 135
}
}
}
</script>
1
2
3
4
5
6
<!-- src/components/MyMarker.vue -->
<script>
export default {
inject: ['location', 'geolocation']
}
</script>

4.2 使用Provide

在 setup() 中使用 provide 时,我们首先从 vue 显式导入 provide 方法。这使我们能够调用 provide 来定义每个 property。

provide 函数允许你通过两个参数定义 property:

  1. name (<String> 类型)
  2. value

使用 MyMap 组件后,provide 的值可以按如下方式重构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- src/components/MyMap.vue -->
<template>
<MyMarker />
</template>

<script>
import { provide } from 'vue'
import MyMarker from './MyMarker.vue'

export default {
components: {
MyMarker
},
setup() {
provide('location', 'North Pole')
provide('geolocation', {
longitude: 90,
latitude: 135
})
}
}
</script>

4.3 使用inject

在 setup() 中使用 inject 时,也需要从 vue 显式导入。导入以后,我们就可以调用它来定义暴露给我们的组件方式。

inject 函数有两个参数:

  1. 要 inject 的 property 的 name
  2. 默认值 (可选)

使用 MyMarker 组件,可以使用以下代码对其进行重构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- src/components/MyMarker.vue -->
<script>
import { inject } from 'vue'

export default {
setup() {
const userLocation = inject('location', 'The Universe')
const userGeolocation = inject('geolocation')

return {
userLocation,
userGeolocation
}
}
}
</script>

4.4 响应性

4.4.1 添加响应性

为了增加 provide 值和 inject 值之间的响应性,我们可以在 provide 值时使用 ref 或 reactive

使用 MyMap 组件,我们的代码可以更新如下:

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
<!-- src/components/MyMap.vue -->
<template>
<MyMarker />
</template>

<script>
import { provide, reactive, ref } from 'vue'
import MyMarker from './MyMarker.vue'

export default {
components: {
MyMarker
},
setup() {
const location = ref('North Pole')
const geolocation = reactive({
longitude: 90,
latitude: 135
})

provide('location', location)
provide('geolocation', geolocation)
}
}
</script>

现在,如果这两个 property 中有任何更改,MyMarker 组件也将自动更新!

4.4.2 修改响应式property

当使用响应式 provide / inject 值时,建议尽可能将对响应式 property 的所有修改限制在定义 provide 的组件内部

例如,在需要更改用户位置的情况下,我们最好在 MyMap 组件中执行此操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!-- src/components/MyMap.vue -->
<template>
<MyMarker />
</template>

<script>
import { provide, reactive, ref } from 'vue'
import MyMarker from './MyMarker.vue'

export default {
components: {
MyMarker
},
setup() {
const location = ref('North Pole')
const geolocation = reactive({
longitude: 90,
latitude: 135
})

provide('location', location)
provide('geolocation', geolocation)

return {
location
}
},
methods: {
updateLocation() {
this.location = 'South Pole'
}
}
}
</script>

然而,有时我们需要在注入数据的组件内部更新 inject 的数据。在这种情况下,我们建议 provide 一个方法来负责改变响应式 property。

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
<!-- src/components/MyMap.vue -->
<template>
<MyMarker />
</template>

<script>
import { provide, reactive, ref } from 'vue'
import MyMarker from './MyMarker.vue'

export default {
components: {
MyMarker
},
setup() {
const location = ref('North Pole')
const geolocation = reactive({
longitude: 90,
latitude: 135
})

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

provide('location', location)
provide('geolocation', geolocation)
provide('updateLocation', updateLocation)
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- src/components/MyMarker.vue -->
<script>
import { inject } from 'vue'

export default {
setup() {
const userLocation = inject('location', 'The Universe')
const userGeolocation = inject('geolocation')
const updateUserLocation = inject('updateLocation')

return {
userLocation,
userGeolocation,
updateUserLocation
}
}
}
</script>

最后,如果要确保通过 provide 传递的数据不会被 inject 的组件更改,我们建议对提供者的 property 使用 readonly

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
<!-- src/components/MyMap.vue -->
<template>
<MyMarker />
</template>

<script>
import { provide, reactive, readonly, ref } from 'vue'
import MyMarker from './MyMarker.vue'

export default {
components: {
MyMarker
},
setup() {
const location = ref('North Pole')
const geolocation = reactive({
longitude: 90,
latitude: 135
})

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

provide('location', readonly(location))
provide('geolocation', readonly(geolocation))
provide('updateLocation', updateLocation)
}
}
</script>

5. 模板引用

在使用组合式 API 时,响应式引用模板引用的概念是统一的。为了获得对模板内元素或组件实例的引用,我们可以像往常一样声明 ref 并从 setup() 返回:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template> 
<div ref="root">This is a root element</div>
</template>

<script>
import { ref, onMounted } from 'vue'

export default {
setup() {
const root = ref(null)

onMounted(() => {
// DOM 元素将在初始渲染后分配给 ref
console.log(root.value) // <div>This is a root element</div>
})

return {
root
}
}
}
</script>

这里我们在渲染上下文中暴露 root,并通过 ref="root",将其绑定到 div 作为其 ref。在虚拟 DOM 补丁算法中,如果 VNode 的 ref 键对应于渲染上下文中的 ref,则 VNode 的相应元素或组件实例将被分配给该 ref 的值。这是在虚拟 DOM 挂载/打补丁过程中执行的,因此模板引用只会在初始渲染之后获得赋值。

作为模板使用的 ref 的行为与任何其他 ref 一样:它们是响应式的,可以传递到 (或从中返回) 复合函数中。

5.1 JSX中的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
export default {
setup() {
const root = ref(null)

return () =>
h('div', {
ref: root
})

// with JSX
return () => <div ref={root} />
}
}

5.2 v-for 中的用法

组合式 API 模板引用在 v-for 内部使用时没有特殊处理。相反,请使用函数引用执行自定义处理:

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
<template>
<div v-for="(item, i) in list" :ref="el => { if (el) divs[i] = el }">
{{ item }}
</div>
</template>

<script>
import { ref, reactive, onBeforeUpdate } from 'vue'

export default {
setup() {
const list = reactive([1, 2, 3])
const divs = ref([])

// 确保在每次更新之前重置ref
onBeforeUpdate(() => {
divs.value = []
})

return {
list,
divs
}
}
}
</script>

5.3 侦听模板引用

侦听模板引用的变更可以替代前面例子中演示使用的生命周期钩子。

但与生命周期钩子的一个关键区别是,watch() 和 watchEffect() 在 DOM 挂载或更新之前运行副作用,所以当侦听器运行时,模板引用还未被更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<div ref="root">This is a root element</div>
</template>

<script>
import { ref, watchEffect } from 'vue'

export default {
setup() {
const root = ref(null)

watchEffect(() => {
// 这个副作用在 DOM 更新之前运行,因此,模板引用还没有持有对元素的引用。
console.log(root.value) // => null
})

return {
root
}
}
}
</script>

因此,使用模板引用的侦听器应该用 flush: 'post' 选项来定义,这将在 DOM 更新运行副作用,确保模板引用与 DOM 保持同步,并引用正确的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<div ref="root">This is a root element</div>
</template>

<script>
import { ref, watchEffect } from 'vue'

export default {
setup() {
const root = ref(null)

watchEffect(() => {
console.log(root.value) // => <div>This is a root element</div>
},
{
flush: 'post'
})

return {
root
}
}
}
</script>

二、Mixin

1. 基础

Mixin 提供了一种非常灵活的方式,来分发 Vue 组件中的可复用功能。一个 mixin 对象可以包含任意组件选项。当组件使用 mixin 对象时,所有 mixin 对象的选项将被“混合”进入该组件本身的选项。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定义一个 mixin 对象
const myMixin = {
created() {
this.hello()
},
methods: {
hello() {
console.log('hello from mixin!')
}
}
}

// 定义一个使用此 mixin 对象的应用
const app = Vue.createApp({
mixins: [myMixin]
})

app.mount('#mixins-basic') // => "hello from mixin!"

2. 选项合并

当组件和 mixin 对象含有同名选项时,这些选项将以恰当的方式进行“合并”。

比如,每个 mixin 可以拥有自己的 data 函数。每个 data 函数都会被调用,并将返回结果合并。在数据的 property 发生冲突时,会以组件自身的数据为优先。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const myMixin = {
data() {
return {
message: 'hello',
foo: 'abc'
}
}
}

const app = Vue.createApp({
mixins: [myMixin],
data() {
return {
message: 'goodbye',
bar: 'def'
}
},
created() {
console.log(this.$data) // => { message: "goodbye", foo: "abc", bar: "def" }
}
})

同名钩子函数将合并为一个数组,因此都将被调用。另外,mixin 对象的钩子将在组件自身钩子之前调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const myMixin = {
created() {
console.log('mixin 对象的钩子被调用')
}
}

const app = Vue.createApp({
mixins: [myMixin],
created() {
console.log('组件钩子被调用')
}
})

// => "mixin 对象的钩子被调用"
// => "组件钩子被调用"

值为对象的选项,例如 methodscomponents 和 directives,将被合并为同一个对象。两个对象键名冲突时,取组件对象的键值对。

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
const myMixin = {
methods: {
foo() {
console.log('foo')
},
conflicting() {
console.log('from mixin')
}
}
}

const app = Vue.createApp({
mixins: [myMixin],
methods: {
bar() {
console.log('bar')
},
conflicting() {
console.log('from self')
}
}
})

const vm = app.mount('#mixins-basic')

vm.foo() // => "foo"
vm.bar() // => "bar"
vm.conflicting() // => "from self"

3. 全局mixin

你还可以为 Vue 应用程序全局应用 mixin:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const app = Vue.createApp({
myOption: 'hello!'
})

// 为自定义的选项 'myOption' 注入一个处理器。
app.mixin({
created() {
const myOption = this.$options.myOption
if (myOption) {
console.log(myOption)
}
}
})

app.mount('#mixins-global') // => "hello!"

Mixin 也可以进行全局注册。使用时格外小心!一旦使用全局 mixin,它将影响每一个之后创建的组件 (例如,每个子组件)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const app = Vue.createApp({
myOption: 'hello!'
})

// 为自定义的选项 'myOption' 注入一个处理器。
app.mixin({
created() {
const myOption = this.$options.myOption
if (myOption) {
console.log(myOption)
}
}
})

// 将myOption也添加到子组件
app.component('test-component', {
myOption: 'hello from component!'
})

app.mount('#mixins-global')

// => "hello!"
// => "hello from component!"

大多数情况下,只应当应用于自定义选项,就像上面示例一样。推荐将其作为插件发布,以避免重复应用 mixin。

4. 自定义选项合并策略

自定义选项在合并时,默认策略为简单地覆盖已有值。如果想让某个自定义选项以自定义逻辑进行合并,可以在 app.config.optionMergeStrategies 中添加一个函数:

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

app.config.optionMergeStrategies.customOption = (toVal, fromVal) => {
// return mergedVal
}

合并策略接收在父实例和子实例上定义的该选项的值,分别作为第一个和第二个参数。让我们来检查一下使用 mixin 时,这些参数有哪些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const app = Vue.createApp({
custom: 'hello!'
})

app.config.optionMergeStrategies.custom = (toVal, fromVal) => {
console.log(fromVal, toVal)
// => "goodbye!", undefined
// => "hello", "goodbye!"
return fromVal || toVal
}

app.mixin({
custom: 'goodbye!',
created() {
console.log(this.$options.custom) // => "hello!"
}
})

如你所见,在控制台中,我们先从 mixin 打印 toVal 和 fromVal,然后从 app 打印。如果存在,我们总是返回 fromVal,这就是为什么 this.$options.custom 设置为 hello!。最后,让我们尝试将策略更改为优先返回子实例的值:

1
2
3
4
5
6
7
8
9
10
11
12
const app = Vue.createApp({
custom: 'hello!'
})

app.config.optionMergeStrategies.custom = (toVal, fromVal) => toVal || fromVal

app.mixin({
custom: 'goodbye!',
created() {
console.log(this.$options.custom) // => "goodbye!"
}
})

5. 不足

在 Vue 2 中,mixin 是将部分组件逻辑抽象成可重用块的主要工具。但是,他们有几个问题:

  • Mixin 很容易发生冲突:因为每个 mixin 的 property 都被合并到同一个组件中,所以为了避免 property 名冲突,你仍然需要了解其他每个特性。

  • 可重用性是有限的:我们不能向 mixin 传递任何参数来改变它的逻辑,这降低了它们在抽象逻辑方面的灵活性。

为了解决这些问题,我们添加了一种通过逻辑关注点组织代码的新方法:组合式 API

三、自定义指令

1. 简介

除了核心功能默认内置的指令 (例如 v-model 和 v-show),Vue 也允许注册自定义指令。注意,在 Vue 中,代码复用和抽象的主要形式是组件。然而,有的情况下,你仍然需要对普通 DOM 元素进行底层操作,这时候就会用到自定义指令。举个聚焦输入框的例子,如下:

当页面加载时,该元素将获得焦点 (注意:autofocus 在移动版 Safari 上不工作)。事实上,如果你在打开这个页面后还没有点击过任何内容,那么此时这个输入框就应当处于聚焦状态。此外,你可以单击 Rerun 按钮,输入框将被聚焦。

现在让我们用指令来实现这个功能:

1
2
3
4
5
6
7
8
9
const app = Vue.createApp({})
// 注册一个全局自定义指令 `v-focus`
app.directive('focus', {
// 当被绑定的元素挂载到 DOM 中时……
mounted(el) {
// 聚焦元素
el.focus()
}
})

如果想注册局部指令,组件中也接受一个 directives 的选项:

1
2
3
4
5
6
7
8
directives: {
focus: {
// 指令的定义
mounted(el) {
el.focus()
}
}
}

然后你可以在模板中任何元素上使用新的 v-focus attribute,如下:

1
<input v-focus />

2. 钩子函数

一个指令定义对象可以提供如下几个钩子函数 (均为可选):

  • created:在绑定元素的 attribute 或事件监听器被应用之前调用。在指令需要附加在普通的 v-on 事件监听器调用前的事件监听器中时,这很有用。

  • beforeMount:当指令第一次绑定到元素并且在挂载父组件之前调用。

  • mounted:在绑定元素的父组件被挂载后调用。

  • beforeUpdate:在更新包含组件的 VNode 之前调用。我们会在稍后讨论渲染函数时介绍更多 VNodes 的细节。

  • updated:在包含组件的 VNode 及其子组件的 VNode 更新后调用。

  • beforeUnmount:在卸载绑定元素的父组件之前调用

  • unmounted:当指令与元素解除绑定且父组件已卸载时,只调用一次。

接下来我们来看一下在自定义指令 API 钩子函数的参数 (即 elbindingvnode 和 prevVnode)

2.1 动态指令函数

指令的参数可以是动态的。例如,在 v-mydirective:[argument]="value" 中,argument 参数可以根据组件实例数据进行更新!这使得自定义指令可以在应用中被灵活使用。

例如你想要创建一个自定义指令,用来通过固定布局将元素固定在页面上。我们可以创建一个自定义指令,它的值以像素为单位更新被固定元素的垂直位置,如下所示:

1
2
3
4
<div id="dynamic-arguments-example" class="demo">
<p>Scroll down the page</p>
<p v-pin="200">Stick me 200px from the top of the page</p>
</div>
1
2
3
4
5
6
7
8
9
10
11
const app = Vue.createApp({})

app.directive('pin', {
mounted(el, binding) {
el.style.position = 'fixed'
// binding.value 是我们传递给指令的值——在这里是 200
el.style.top = binding.value + 'px'
}
})

app.mount('#dynamic-arguments-example')

这会把该元素固定在距离页面顶部 200 像素的位置。但如果场景是我们需要把元素固定在左侧而不是顶部又该怎么办呢?这时使用动态参数就可以非常方便地根据每个组件实例来进行更新。

1
2
3
4
<div id="dynamicexample">
<h3>Scroll down inside this section ↓</h3>
<p v-pin:[direction]="200">I am pinned onto the page at 200px to the left.</p>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const app = Vue.createApp({
data() {
return {
direction: 'right'
}
}
})

app.directive('pin', {
mounted(el, binding) {
el.style.position = 'fixed'
// binding.arg 是我们传递给指令的参数
const s = binding.arg || 'top'
el.style[s] = binding.value + 'px'
}
})

app.mount('#dynamic-arguments-example')

我们的自定义指令现在已经足够灵活,可以支持一些不同的用例。为了使其更具动态性,我们还可以允许修改绑定值。让我们创建一个附加属性 pinPadding,并将其绑定到 <input type="range">

1
2
3
4
5
<div id="dynamicexample">
<h2>Scroll down the page</h2>
<input type="range" min="0" max="500" v-model="pinPadding">
<p v-pin:[direction]="pinPadding">Stick me {{ pinPadding + 'px' }} from the {{ direction || 'top' }} of the page</p>
</div>
1
2
3
4
5
6
7
8
const app = Vue.createApp({
data() {
return {
direction: 'right',
pinPadding: 200
}
}
})

让我们扩展指令逻辑以在组件更新后重新计算固定的距离。

1
2
3
4
5
6
7
8
9
10
11
app.directive('pin', {
mounted(el, binding) {
el.style.position = 'fixed'
const s = binding.arg || 'top'
el.style[s] = binding.value + 'px'
},
updated(el, binding) {
const s = binding.arg || 'top'
el.style[s] = binding.value + 'px'
}
})

3. 函数简写

在前面的例子中,你可能想在 mounted 和 updated 时触发相同行为,而不关心其他的钩子函数。那么你可以通过将这个回调函数传递给指令来实现:

1
2
3
4
5
app.directive('pin', (el, binding) => {
el.style.position = 'fixed'
const s = binding.arg || 'top'
el.style[s] = binding.value + 'px'
})

4. 对象字面量

如果指令需要多个值,可以传入一个 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!"
})

5. 在组件中使用

非 prop 的 attribute 类似,当在组件中使用时,自定义指令总是会被应用在组件的根节点上。

1
<my-component v-demo="test"></my-component>
1
2
3
4
5
6
7
app.component('my-component', {
template: `
<div> // v-demo 指令将会被应用在这里
<span>My component content</span>
</div>
`
})

和 attribute 不同,指令不会通过 v-bind="$attrs" 被传入另一个元素。

有了片段支持以后,组件可能会有多个根节点。当被应用在一个多根节点的组件上时,指令会被忽略,并且会抛出一个警告。

四、Teleport

Vue 鼓励我们通过将 UI 和相关行为封装到组件中来构建 UI。我们可以将它们嵌套在另一个内部,以构建一个组成应用程序 UI 的树。

然而,有时组件模板的一部分逻辑上属于该组件,而从技术角度来看,最好将模板的这一部分移动到 DOM 中 Vue app 之外的其他位置。

一个常见的场景是创建一个包含全屏模式的组件。在大多数情况下,你希望模态框的逻辑存在于组件中,但是模态框的快速定位就很难通过 CSS 来解决,或者需要更改组件组合。

考虑下面的 HTML 结构。

1
2
3
4
5
6
7
8
<body>
<div style="position: relative;">
<h3>Tooltips with Vue 3 Teleport</h3>
<div>
<modal-button></modal-button>
</div>
</div>
</body>

让我们来看看 modal-button 组件:

该组件将有一个 button 元素来触发模态框的打开,以及一个带有 class .modal 的 div 元素,它将包含模态框的内容和一个用于自关闭的按钮。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const app = Vue.createApp({});

app.component('modal-button', {
template: `
<button @click="modalOpen = true">
Open full screen modal!
</button>

<div v-if="modalOpen" class="modal">
<div>
I'm a modal!
<button @click="modalOpen = false">
Close
</button>
</div>
</div>
`,
data() {
return {
modalOpen: false
}
}
})

当在初始的 HTML 结构中使用这个组件时,我们可以看到一个问题——模态框是在深度嵌套的 div 中渲染的,而模态框的 position:absolute 以父级相对定位的 div 作为引用。

Teleport 提供了一种干净的方法,允许我们控制在 DOM 中哪个父节点下渲染了 HTML,而不必求助于全局状态或将其拆分为两个组件。

让我们修改 modal-button 以使用 <teleport>,并告诉 Vue “将这个 HTML 传送到‘body’标签下”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
app.component('modal-button', {
template: `
<button @click="modalOpen = true">
Open full screen modal! (With teleport!)
</button>

<teleport to="body">
<div v-if="modalOpen" class="modal">
<div>
I'm a teleported modal!
(My parent is "body")
<button @click="modalOpen = false">
Close
</button>
</div>
</div>
</teleport>
`,
data() {
return {
modalOpen: false
}
}
})

因此,一旦我们单击按钮打开模态框,Vue 将正确地将模态框内容渲染为 body 标签的子级。

1. 与 Vue components 一起使用

如果 <teleport> 包含 Vue 组件,则它仍将是 <teleport> 父组件的逻辑子组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const app = Vue.createApp({
template: `
<h1>Root instance</h1>
<parent-component />
`
})

app.component('parent-component', {
template: `
<h2>This is a parent component</h2>
<teleport to="#endofbody">
<child-component name="John" />
</teleport>
`
})

app.component('child-component', {
props: ['name'],
template: `
<div>Hello, {{ name }}</div>
`
})

在这种情况下,即使在不同的地方渲染 child-component,它仍将是 parent-component 的子级,并将从中接收 name prop。

这也意味着来自父组件的注入会正常工作,在 Vue Devtools 中你会看到子组件嵌套在父组件之下,而不是出现在他会被实际移动到的位置。

2. 在同一目标上使用多个 teleport

一个常见的用例场景是一个可重用的 <Modal> 组件,它可能同时有多个实例处于活动状态。对于这种情况,多个 <teleport> 组件可以将其内容挂载到同一个目标元素。顺序将是一个简单的追加——稍后挂载将位于目标元素中较早的挂载之后。

1
2
3
4
5
6
7
8
9
10
11
12
<teleport to="#modals">
<div>A</div>
</teleport>
<teleport to="#modals">
<div>B</div>
</teleport>

<!-- result-->
<div id="modals">
<div>A</div>
<div>B</div>
</div>

你可以在 API 参考 查看 teleport 组件。

五、渲染函数

Vue 推荐在绝大多数情况下使用模板来创建你的 HTML。然而在一些场景中,你真的需要 JavaScript 的完全编程的能力。这时你可以用渲染函数,它比模板更接近编译器。

让我们深入一个简单的例子,这个例子里 render 函数很实用。假设我们要生成一些带锚点的标题:

1
2
3
4
5
<h1>
<a name="hello-world" href="#hello-world">
Hello world!
</a>
</h1>

锚点标题的使用非常频繁,我们应该创建一个组件:

1
<anchored-heading :level="1">Hello world!</anchored-heading>

当开始写一个只能通过 level prop 动态生成标题 (heading) 的组件时,我们很快就可以得出这样的结论:

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
const { createApp } = Vue

const app = createApp({})

app.component('anchored-heading', {
template: `
<h1 v-if="level === 1">
<slot></slot>
</h1>
<h2 v-else-if="level === 2">
<slot></slot>
</h2>
<h3 v-else-if="level === 3">
<slot></slot>
</h3>
<h4 v-else-if="level === 4">
<slot></slot>
</h4>
<h5 v-else-if="level === 5">
<slot></slot>
</h5>
<h6 v-else-if="level === 6">
<slot></slot>
</h6>
`,
props: {
level: {
type: Number,
required: true
}
}
})

这个模板感觉不太好。它不仅冗长,而且我们为每个级别标题重复书写了 <slot></slot>。当我们添加锚元素时,我们必须在每个 v-if/v-else-if 分支中再次重复它。

虽然模板在大多数组件中都非常好用,但是显然在这里它就不合适了。那么,我们来尝试使用 render 函数重写上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const { createApp, h } = Vue

const app = createApp({})

app.component('anchored-heading', {
render() {
return h(
'h' + this.level, // 标签名
{}, // prop 或 attribute
this.$slots.default() // 包含其子节点的数组
)
},
props: {
level: {
type: Number,
required: true
}
}
})

render() 函数的实现要精简得多,但是需要非常熟悉组件的实例 property。在这个例子中,你需要知道,向组件中传递不带 v-slot 指令的子节点时,比如 anchored-heading 中的 Hello world! ,这些子节点被存储在组件实例中的 $slots.default 中。如果你还不了解,在深入渲染函数之前推荐阅读实例 property API

1. DOM树

在深入渲染函数之前,了解一些浏览器的工作原理是很重要的。以下面这段 HTML 为例:

1
2
3
4
5
<div>
<h1>My title</h1>
Some text content
<!-- TODO: Add tagline -->
</div>

当浏览器读到这些代码时,它会建立一个 ”DOM 节点“ 树 来保持追踪所有内容,如同你会画一张家谱树来追踪家庭成员的发展一样。

上述 HTML 对应的 DOM 节点树如下图所示

DOM Tree Visualization

每个元素都是一个节点。每段文字也是一个节点。甚至注释也都是节点。一个节点就是页面的一个部分。就像家谱树一样,每个节点都可以有孩子节点 (也就是说每个部分可以包含其它的一些部分)。

高效地更新所有这些节点会是比较困难的,不过所幸你不必手动完成这个工作。你只需要告诉 Vue 你希望页面上的 HTML 是什么,这可以是在一个模板里:

1
<h1>{{ blogTitle }}</h1>

或者一个渲染函数里:

1
2
3
render() {
return h('h1', {}, this.blogTitle)
}

在这两种情况下,Vue 都会自动保持页面的更新,即便 blogTitle 发生了改变。

2. 虚拟DOM树

Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM。请仔细看这行代码:

1
return h('h1', {}, this.blogTitle)

h() 到底会返回什么呢?其实不是一个实际的 DOM 元素。它更准确的名字可能是 createNodeDescription,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,及其子节点的描述信息。我们把这样的节点描述为“虚拟节点 (virtual node)”,也常简写它为 VNode。“虚拟 DOM”是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。

3. h() 参数

h() 函数是一个用于创建 VNode 的实用程序。也许可以更准确地将其命名为 createVNode(),但由于频繁使用和简洁,它被称为 h() 。它接受三个参数:

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
// @returns {VNode}
h(
// {String | Object | Function} tag
// 一个 HTML 标签名、一个组件、一个异步组件、或
// 一个函数式组件。
//
// 必需的。
'div',

// {Object} props
// 与 attribute、prop 和事件相对应的对象。
// 这会在模板中用到。
//
// 可选的。
{},

// {String | Array | Object} children
// 子 VNodes, 使用 `h()` 构建,
// 或使用字符串获取 "文本 VNode" 或者
// 有插槽的对象。
//
// 可选的。
[
'Some text comes first.',
h('h1', 'A headline'),
h(MyComponent, {
someProp: 'foobar'
})
]
)

如果没有 prop,那么通常可以将 children 作为第二个参数传入。如果会产生歧义,可以将 null 作为第二个参数传入,将 children 作为第三个参数传入。

4. 完整实例

有了这些知识,我们现在可以完成我们最开始想实现的组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const { createApp, h } = Vue

const app = createApp({})

/** 递归地从子节点获取文本 */
function getChildrenTextContent(children) {
return children
.map(node => {
return typeof node.children === 'string'
? node.children
: Array.isArray(node.children)
? getChildrenTextContent(node.children)
: ''
})
.join('')
}

app.component('anchored-heading', {
render() {
// 从 children 的文本内容中创建短横线分隔 (kebab-case) id。
const headingId = getChildrenTextContent(this.$slots.default())
.toLowerCase()
.replace(/\W+/g, '-') // 用短横线替换非单词字符
.replace(/(^-|-$)/g, '') // 删除前后短横线

return h('h' + this.level, [
h(
'a',
{
name: headingId,
href: '#' + headingId
},
this.$slots.default()
)
])
},
props: {
level: {
type: Number,
required: true
}
}
})

5. 约束

5.1 VNodes必须唯一

组件树中的所有 VNode 必须是唯一的。这意味着,下面的渲染函数是不合法的:

1
2
3
4
5
6
7
render() {
const myParagraphVNode = h('p', 'hi')
return h('div', [
// 错误 - 重复的 Vnode!
myParagraphVNode, myParagraphVNode
])
}

如果你真的需要重复很多次的元素/组件,你可以使用工厂函数来实现。例如,下面这渲染函数用完全合法的方式渲染了 20 个相同的段落:

1
2
3
4
5
6
7
render() {
return h('div',
Array.from({ length: 20 }).map(() => {
return h('p', 'hi')
})
)
}

6. 创建组件VNode

要为某个组件创建一个 VNode,传递给 h 的第一个参数应该是组件本身。

1
2
3
render() {
return h(ButtonCounter)
}

如果我们需要通过名称来解析一个组件,那么我们可以调用 resolveComponent

1
2
3
4
5
6
7
8
const { h, resolveComponent } = Vue

// ...

render() {
const ButtonCounter = resolveComponent('ButtonCounter')
return h(ButtonCounter)
}

resolveComponent 是模板内部用来解析组件名称的同一个函数。

render 函数通常只需要对全局注册的组件使用 resolveComponent。而对于局部注册的却可以跳过,请看下面的例子:

1
2
3
4
5
6
7
// 此写法可以简化
components: {
ButtonCounter
},
render() {
return h(resolveComponent('ButtonCounter'))
}

我们可以直接使用它,而不是通过名称注册一个组件,然后再查找:

1
2
3
render() {
return h(ButtonCounter)
}

7. 使用JavaScript代替模板功能

7.1 v-if 和 v-for

只要在原生的 JavaScript 中可以轻松完成的操作,Vue 的渲染函数就不会提供专有的替代方法。比如,在模板中使用的 v-if 和 v-for

1
2
3
4
<ul v-if="items.length">
<li v-for="item in items">{{ item.name }}</li>
</ul>
<p v-else>No items found.</p>

这些都可以在渲染函数中用 JavaScript 的 if/else 和 map() 来重写:

1
2
3
4
5
6
7
8
9
10
props: ['items'],
render() {
if (this.items.length) {
return h('ul', this.items.map((item) => {
return h('li', item.name)
}))
} else {
return h('p', 'No items found.')
}
}

7.2 v-model

v-model 指令扩展为 modelValue 和 onUpdate:modelValue 在模板编译过程中,我们必须自己提供这些 prop:

1
2
3
4
5
6
7
8
props: ['modelValue'],
emits: ['update:modelValue'],
render() {
return h(SomeComponent, {
modelValue: this.modelValue,
'onUpdate:modelValue': value => this.$emit('update:modelValue', value)
})
}

7.3 v-on

我们必须为事件处理程序提供一个正确的 prop 名称,例如,要处理 click 事件,prop 名称应该是 onClick

1
2
3
4
5
render() {
return h('div', {
onClick: $event => console.log('clicked', $event.target)
})
}

7.3.1 事件修饰符

对于 .passive 、.capture 和 .once 事件修饰符,可以使用驼峰写法将他们拼接在事件名后面:

实例:

1
2
3
4
5
6
7
render() {
return h('input', {
onClickCapture: this.doThisInCapturingMode,
onKeyupOnce: this.doThisOnce,
onMouseoverOnceCapture: this.doThisOnceInCapturingMode
})
}

对于所有其它的修饰符,私有前缀都不是必须的,因为你可以在事件处理函数中使用事件方法:

修饰符 处理函数中的等价操作
.stop event.stopPropagation()
.prevent event.preventDefault()
.self if (event.target !== event.currentTarget) return
按键:
.enter.13
if (event.keyCode !== 13) return (对于别的按键修饰符来说,可将 13 改为另一个按键码
修饰键:
.ctrl.alt.shift.meta
if (!event.ctrlKey) return (将 ctrlKey 分别修改为 altKeyshiftKey, 或 metaKey)

这里是一个使用所有修饰符的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
render() {
return h('input', {
onKeyUp: event => {
// 如果触发事件的元素不是事件绑定的元素
// 则返回
if (event.target !== event.currentTarget) return
// 如果向上键不是回车键,则终止
// 没有同时按下按键 (13) 和 shift 键
if (!event.shiftKey || event.keyCode !== 13) return
// 停止事件传播
event.stopPropagation()
// 阻止该元素默认的 keyup 事件
event.preventDefault()
// ...
}
})
}

7.4 插槽

你可以通过 this.$slots 访问静态插槽的内容,每个插槽都是一个 VNode 数组:

1
2
3
4
render() {
// `<div><slot></slot></div>`
return h('div', {}, this.$slots.default())
}
1
2
3
4
5
6
7
props: ['message'],
render() {
// `<div><slot :text="message"></slot></div>`
return h('div', {}, this.$slots.default({
text: this.message
}))
}

要使用渲染函数将插槽传递给子组件,请执行以下操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const { h, resolveComponent } = Vue

render() {
// `<div><child v-slot="props"><span>{{ props.text }}</span></child></div>`
return h('div', [
h(
resolveComponent('child'),
{},
// 将 `slots` 以 { name: props => VNode | Array<VNode> } 的形式传递给子对象。
{
default: (props) => h('span', props.text)
}
)
])
}

插槽以函数的形式传递,允许子组件控制每个插槽内容的创建。任何响应式数据都应该在插槽函数内访问,以确保它被注册为子组件的依赖关系,而不是父组件。相反,对 resolveComponent 的调用应该在插槽函数之外进行,否则它们会相对于错误的组件进行解析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// `<MyButton><MyIcon :name="icon" />{{ text }}</MyButton>`
render() {
// 应该是在插槽函数外面调用 resolveComponent。
const Button = resolveComponent('MyButton')
const Icon = resolveComponent('MyIcon')

return h(
Button,
null,
{
// 使用箭头函数保存 `this` 的值
default: (props) => {
// 响应式 property 应该在插槽函数内部读取,
// 这样它们就会成为 children 渲染的依赖。
return [
h(Icon, { name: this.icon }),
this.text
]
}
}
)
}

如果一个组件从它的父组件中接收到插槽,它们可以直接传递给子组件。

1
2
3
render() {
return h(Panel, null, this.$slots)
}

也可以根据情况单独传递或包裹住。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
render() {
return h(
Panel,
null,
{
// 如果我们想传递一个槽函数,我们可以通过
header: this.$slots.header,

// 如果我们需要以某种方式对插槽进行操作,
// 那么我们需要用一个新的函数来包裹它
default: (props) => {
const children = this.$slots.default ? this.$slots.default(props) : []

return children.concat(h('div', 'Extra child'))
}
}
)
}

7.5 <component> 和 is

在底层实现里,模板使用 resolveDynamicComponent 来实现 is attribute。如果我们在 render 函数中需要 is 提供的所有灵活性,我们可以使用同样的函数:

1
2
3
4
5
6
7
8
9
const { h, resolveDynamicComponent } = Vue

// ...

// `<component :is="name"></component>`
render() {
const Component = resolveDynamicComponent(this.name)
return h(Component)
}

就像 isresolveDynamicComponent 支持传递一个组件名称、一个 HTML 元素名称或一个组件选项对象。

通常这种程度的灵活性是不需要的。通常 resolveDynamicComponent 可以被换做一个更直接的替代方案。

例如,如果我们只需要支持组件名称,那么可以使用 resolveComponent 来代替。

如果 VNode 始终是一个 HTML 元素,那么我们可以直接把它的名字传递给 h

1
2
3
4
// `<component :is="bold ? 'strong' : 'em'"></component>`
render() {
return h(this.bold ? 'strong' : 'em')
}

同样,如果传递给 is 的值是一个组件选项对象,那么不需要解析什么,可以直接作为 h 的第一个参数传递。

与 <template> 标签一样,<component> 标签仅在模板中作为语法占位符需要,当迁移到 render 函数时,应被丢弃。

7.6 自定义指令

可以使用 withDirectives 将自定义指令应用于 VNode:

1
2
3
4
5
6
7
8
9
10
11
12
const { h, resolveDirective, withDirectives } = Vue

// ...

// <div v-pin:top.animate="200"></div>
render () {
const pin = resolveDirective('pin')

return withDirectives(h('div'), [
[pin, 200, 'top', { animate: true }]
])
}

resolveDirective 是模板内部用来解析指令名称的同一个函数。只有当你还没有直接访问指令的定义对象时,才需要这样做。

7.7 内置组件

诸如 <keep-alive><transition><transition-group> 和 <teleport> 等内置组件默认并没有被全局注册。这使得打包工具可以 tree-shake,因此这些组件只会在被用到的时候被引入构建。不过这也意味着我们无法通过 resolveComponent 或 resolveDynamicComponent 访问它们。

在模板中这些组件会被特殊处理,即在它们被用到的时候自动导入。当我们编写自己的 render 函数时,需要自行导入它们:

1
2
3
4
5
const { h, KeepAlive, Teleport, Transition, TransitionGroup } = Vue
// ...
render () {
return h(Transition, { mode: 'out-in' }, /* ... */)
}

8. 渲染函数的返回值

在我们目前看过的所有示例中,render 函数返回的是单个根 VNode。但其实也有别的选项。

返回一个字符串时会创建一个文本 VNode,而不被包裹任何元素:

1
2
3
render() {
return 'Hello world!'
}

我们也可以返回一个子元素数组,而不把它们包裹在一个根结点里。这会创建一个片段 (fragment):

1
2
3
4
5
6
7
8
// 相当于模板 `Hello<br>world!`
render() {
return [
'Hello',
h('br'),
'world!'
]
}

可能是因为数据依然在加载中的关系,组件不需要渲染,这时它可以返回 null。这样我们在 DOM 中会渲染一个注释节点。

9. JSX

如果你写了很多渲染函数,可能会觉得下面这样的代码写起来很痛苦:

1
2
3
4
5
6
7
8
9
h(
'anchored-heading',
{
level: 1
},
{
default: () => [h('span', 'Hello'), ' world!']
}
)

特别是对应的模板如此简单的情况下:

1
<anchored-heading :level="1"> <span>Hello</span> world! </anchored-heading>

这就是为什么会有一个 Babel 插件,用于在 Vue 中使用 JSX 语法,它可以让我们回到更接近于模板的语法上。

1
2
3
4
5
6
7
8
9
10
11
12
13
import AnchoredHeading from './AnchoredHeading.vue'

const app = createApp({
render() {
return (
<AnchoredHeading level={1}>
<span>Hello</span> world!
</AnchoredHeading>
)
}
})

app.mount('#demo')

有关 JSX 如何映射到 JavaScript 的更多信息,请参阅使用文档 。

10. 函数式组件

函数式组件是自身没有任何状态的组件的另一种形式。它们在渲染过程中不会创建组件实例,并跳过常规的组件生命周期。

我们使用的是一个简单函数,而不是一个选项对象,来创建函数式组件。该函数实际上就是该组件的 render 函数。而因为函数式组件里没有 this 引用,Vue 会把 props 当作第一个参数传入:

1
2
3
const FunctionalComponent = (props, context) => {
// ...
}

第二个参数 context 包含三个 property:attrsemit 和 slots。它们分别相当于实例的 $attrs$emit 和 $slots 这几个 property。

大多数常规组件的配置选项在函数式组件中都不可用。然而我们还是可以把 props 和 emits 作为 property 加入,以达到定义它们的目的:

1
2
FunctionalComponent.props = ['value']
FunctionalComponent.emits = ['click']

如果这个 props 选项没有被定义,那么被传入函数的 props 对象就会像 attrs 一样会包含所有 attribute。除非指定了 props 选项,否则每个 prop 的名字将不会基于驼峰命名法被一般化处理。

函数式组件可以像普通组件一样被注册和消费。如果你将一个函数作为第一个参数传入 h,它将会被当作一个函数式组件来对待。

11. 模板编译

你可能会有兴趣知道,Vue 的模板实际上被编译成了渲染函数。这是一个实现细节,通常不需要关心。但如果你想看看模板的功能具体是怎样被编译的,可能会发现会非常有意思。下面是一个使用 Vue.compile 来实时编译模板字符串的简单示例:

六、插件

插件是自包含的代码,通常向 Vue 添加全局级功能。它可以是公开 install() 方法的 object,也可以是 function

插件的功能范围没有严格的限制——一般有下面几种:

  1. 添加全局方法或者 property。如:vue-custom-element

  2. 添加全局资源:指令/过渡等。如:vue-touch

  3. 通过全局 mixin 来添加一些组件选项。(如vue-router)

  4. 添加全局实例方法,通过把它们添加到 config.globalProperties 上实现。

  5. 一个库,提供自己的 API,同时提供上面提到的一个或多个功能。如 vue-router

1. 编写插件

为了更好地理解如何创建自己的 Vue.js 版插件,我们将创建一个非常简化的插件版本,它显示 i18n 准备好的字符串。

每当这个插件被添加到应用程序中时,如果它是一个对象,就会调用 install 方法。如果它是一个 function,则函数本身将被调用。在这两种情况下——它都会收到两个参数:由 Vue 的 createApp 生成的 app 对象和用户传入的选项。

让我们从设置插件对象开始。建议在单独的文件中创建它并将其导出,如下所示,以保持包含的逻辑和分离的逻辑。

1
2
3
4
5
6
// plugins/i18n.js
export default {
install: (app, options) => {
// Plugin code goes here
}
}

我们想要一个函数来翻译整个应用程序可用的键,因此我们将使用 app.config.globalProperties 暴露它。

该函数将接收一个 key 字符串,我们将使用它在用户提供的选项中查找转换后的字符串。

1
2
3
4
5
6
7
8
9
10
// plugins/i18n.js
export default {
install: (app, options) => {
app.config.globalProperties.$translate = key => {
return key.split('.').reduce((o, i) => {
if (o) return o[i]
}, options)
}
}
}

我们假设用户使用插件时,将在 options 参数中传递一个包含翻译后的键的对象。我们的 $translate 函数将使用诸如 greetings.hello 之类的字符串,查看用户提供的配置内部并返回转换后的值-在这种情况下为 Bonjour!

例如:

1
2
3
greetings: {
hello: 'Bonjour!'
}

插件还允许我们使用 inject 为插件的用户提供功能或 attribute。例如,我们可以允许应用程序访问 options 参数以能够使用翻译对象。

1
2
3
4
5
6
7
8
9
10
11
12
// plugins/i18n.js
export default {
install: (app, options) => {
app.config.globalProperties.$translate = key => {
return key.split('.').reduce((o, i) => {
if (o) return o[i]
}, options)
}

app.provide('i18n', options)
}
}

插件用户现在可以将 inject[i18n] 注入到他们的组件并访问该对象。

另外,由于我们可以访问 app 对象,因此插件可以使用所有其他功能,例如使用 mixin 和 directive。要了解有关 createApp 和应用程序实例的更多信息,请查看 Application API 文档

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
// plugins/i18n.js
export default {
install: (app, options) => {
app.config.globalProperties.$translate = (key) => {
return key.split('.')
.reduce((o, i) => { if (o) return o[i] }, options)
}

app.provide('i18n', options)

app.directive('my-directive', {
mounted (el, binding, vnode, oldVnode) {
// some logic ...
}
...
})

app.mixin({
created() {
// some logic ...
}
...
})
}
}

2. 使用插件

在使用 createApp() 初始化 Vue 应用程序后,你可以通过调用 use() 方法将插件添加到你的应用程序中。

我们将使用在编写插件部分中创建的 i18nPlugin 进行演示。

use() 方法有两个参数。第一个是要安装的插件,在这种情况下为 i18nPlugin

它还会自动阻止你多次使用同一插件,因此在同一插件上多次调用只会安装一次该插件。

第二个参数是可选的,并且取决于每个特定的插件。在演示 i18nPlugin 的情况下,它是带有转换后的字符串的对象。

如果你使用的是第三方插件 (例如 Vuex 或 Vue Router),请始终查看文档以了解特定插件期望作为第二个参数接收的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { createApp } from 'vue'
import Root from './App.vue'
import i18nPlugin from './plugins/i18n'

const app = createApp(Root)
const i18nStrings = {
greetings: {
hi: 'Hallo!'
}
}

app.use(i18nPlugin, i18nStrings)
app.mount('#app')

awesome-vue 集合了大量由社区贡献的插件和库。