Vue3

本文档学习的内容如下:

1.Vite

2.Volar插件

Volar 重大更新:改名为Vue - Official;支持Vue3.4;Take over模式被弃用;TypeScript Vue Plugin被弃用;

3.响应式基础

ref的自动解包

为ref和reactive标注类型的方法

ref和reactive的区别

怎么选择ref和reactive

4.计算属性

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

深层侦听器

直接给 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
8
watch(
() => state.someObject,
(newValue, oldValue) => {
// 注意:`newValue` 此处和 `oldValue` 是相等的
// *除非* state.someObject 被整个替换了
},
{ deep: true }
)

谨慎使用

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

即时回调的侦听器

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

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

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

一次性侦听器

每当被侦听源发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次,请使用 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() 可以消除手动维护依赖列表的负担。此外,如果你需要侦听一个嵌套数据结构中的几个属性,watchEffect() 可能会比深度侦听器更有效,因为它将只跟踪回调中被使用到的属性,而不是递归地跟踪所有的属性。

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

watch vs. watchEffect

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

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

回调的触发时机

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

类似于组件更新,用户创建的侦听器回调函数也会被批量处理以避免重复调用。例如,如果我们同步将一千个项目推入被侦听的数组中,我们可能不希望侦听器触发一千次。

默认情况下,侦听器回调会在父组件更新 (如有) 之后、所属组件的 DOM 更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的 DOM,那么 DOM 将处于更新前的状态。

Post Watchers

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

1
2
3
4
5
6
7
watch(source, callback, {
flush: 'post'
})

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

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

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

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

同步侦听器

你还可以创建一个同步触发的侦听器,它会在 Vue 进行任何更新之前触发:

1
2
3
4
5
6
7
watch(source, callback, {
flush: 'sync'
})

watchEffect(callback, {
flush: 'sync'
})

同步触发的 watchEffect() 有个更方便的别名 watchSyncEffect()

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

watchSyncEffect(() => {
/* 在响应式数据变化时同步执行 */
})

6.事件处理

监听事件

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

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

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

内联事件处理器

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

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

方法事件处理器

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

举例来说:

1
2
3
4
5
6
7
8
9
const name = ref('Vue.js')

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

在演练场中尝试一下

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

你也可以看看为事件处理器标注类型这一章了解更多。

方法与内联事件判断

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

在内联处理器中调用方法

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

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

事件修饰符

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

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

  • .stop
  • .prevent
  • .self
  • .capture
  • .once
  • .passive
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 单击事件将停止传递 -->
<a @click.stop="doThis"></a>

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

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

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

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

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

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

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

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

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

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

7.表单输入绑定

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

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

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

1
<input v-model="text">

另外,v-model 还可以用于各种不同类型的输入,<textarea><select> 元素。它会根据所使用的元素自动使用对应的 DOM 属性和事件组合:

  • 文本类型的 <input><textarea> 元素会绑定 value property 并侦听 input 事件;
  • <input type="checkbox"><input type="radio"> 会绑定 checked property 并侦听 change 事件;
  • <select> 会绑定 value property 并侦听 change 事件。

修饰符

.lazy

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

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

.number

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

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

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

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

.trim

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

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

8.生命周期钩子

每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM

注册周期钩子

举例来说,onMounted 钩子可以用来在组件完成初始渲染并创建 DOM 节点后运行代码:

1
2
3
4
5
6
7
<script setup>
import { onMounted } from 'vue'

onMounted(() => {
console.log(`the component is now mounted.`)
})
</script>

还有其他一些钩子,会在实例生命周期的不同阶段被调用,最常用的是 onMountedonUpdatedonUnmounted。所有生命周期钩子的完整参考及其用法请参考 API 索引

当调用 onMounted 时,Vue 会自动将回调函数注册到当前正被初始化的组件实例上。这意味着这些钩子应当在组件初始化时被同步注册。例如,请不要这样做:

1
2
3
4
5
6
setTimeout(() => {
onMounted(() => {
// 异步注册时当前组件实例已丢失
// 这将不会正常工作
})
}, 100)

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

生命周期图示

9.ref

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

1
<input ref="input">

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

访问模板引用

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

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 { 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 都会自动解包,和一般的实例一样)。

10 组件基础

定义一个组件

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

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

const count = ref(0)
</script>

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

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

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

export default {
setup() {
const count = ref(0)
return { count }
},
template: `
<button @click="count++">
You clicked me {{ count }} times.
</button>`
// 也可以针对一个 DOM 内联模板:
// template: '#my-template-element'
}

这里的模板是一个内联的 JavaScript 字符串,Vue 将会在运行时编译它。你也可以使用 ID 选择器来指向一个元素 (通常是原生的 <template> 元素),Vue 将会使用其内容作为模板来源。

上面的例子中定义了一个组件,并在一个 .js 文件里默认导出了它自己,但你也可以通过具名导出在一个文件中导出多个组件。

使用组件

要使用一个子组件,我们需要在父组件中导入它。假设我们把计数器组件放在了一个叫做 ButtonCounter.vue 的文件中,这个组件将会以默认导出的形式被暴露给外部。

1
2
3
4
5
6
7
8
<script setup>
import ButtonCounter from './ButtonCounter.vue'
</script>

<template>
<h1>Here is a child component!</h1>
<ButtonCounter />
</template>

通过 <script setup>,导入的组件都在模板中直接可用。

当然,你也可以全局地注册一个组件,使得它在当前应用中的任何组件上都可以使用,而不需要额外再导入。关于组件的全局注册和局部注册两种方式的利弊,我们放在了组件注册这一章节中专门讨论。

组件可以被重用任意多次:

1
2
3
4
<h1>Here is a child component!</h1>
<ButtonCounter />
<ButtonCounter />
<ButtonCounter />

你会注意到,每当点击这些按钮时,每一个组件都维护着自己的状态,是不同的 count。这是因为每当你使用一个组件,就创建了一个新的实例

在单文件组件中,推荐为子组件使用 PascalCase 的标签名,以此来和原生的 HTML 元素作区分。虽然原生 HTML 标签名是不区分大小写的,但 Vue 单文件组件是可以在编译中区分大小写的。我们也可以使用 /> 来关闭一个标签。

如果你是直接在 DOM 中书写模板 (例如原生 <template> 元素的内容),模板的编译需要遵从浏览器中 HTML 的解析行为。在这种情况下,你应该需要使用 kebab-case 形式并显式地关闭这些组件的标签。

1
2
3
4
<!-- 如果是在 DOM 中书写该模板 -->
<button-counter></button-counter>
<button-counter></button-counter>
<button-counter></button-counter>

请看 DOM 内模板解析注意事项了解更多细节。

传递 props

如果我们正在构建一个博客,我们可能需要一个表示博客文章的组件。我们希望所有的博客文章分享相同的视觉布局,但有不同的内容。要实现这样的效果自然必须向组件中传递数据,例如每篇文章标题和内容,这就会使用到 props。

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

1
2
3
4
5
6
7
8
<!-- BlogPost.vue -->
<script setup>
defineProps(['title'])
</script>

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

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

1
2
const props = defineProps(['title'])
console.log(props.title)

TypeScript 用户请参考:为组件 props 标注类型

如果你没有使用 <script setup>,props 必须以 props 选项的方式声明,props 对象会作为 setup() 函数的第一个参数被传入:

1
2
3
4
5
6
export default {
props: ['title'],
setup(props) {
console.log(props.title)
}
}

一个组件可以有任意多的 props,默认情况下,所有 prop 都接受任意类型的值。

当一个 prop 被注册后,可以像这样以自定义 attribute 的形式传递数据给它:

1
2
3
<BlogPost title="My journey with Vue" />
<BlogPost title="Blogging with Vue" />
<BlogPost title="Why Vue is so fun" />

在实际应用中,我们可能在父组件中会有如下的一个博客文章数组:

1
2
3
4
5
const posts = ref([
{ id: 1, title: 'My journey with Vue' },
{ id: 2, title: 'Blogging with Vue' },
{ id: 3, title: 'Why Vue is so fun' }
])

这种情况下,我们可以使用 v-for 来渲染它们:

1
2
3
4
5
<BlogPost
v-for="post in posts"
:key="post.id"
:title="post.title"
/>

留意我们是如何使用 v-bind 来传递动态 prop 值的。当事先不知道要渲染的确切内容时,这一点特别有用。

监听事件

让我们继续关注我们的 <BlogPost> 组件。我们会发现有时候它需要与父组件进行交互。例如,要在此处实现无障碍访问的需求,将博客文章的文字能够放大,而页面的其余部分仍使用默认字号。

在父组件中,我们可以添加一个 postFontSize ref 来实现这个效果:

1
2
3
4
5
const posts = ref([
/* ... */
])

const postFontSize = ref(1)

在模板中用它来控制所有博客文章的字体大小:

1
2
3
4
5
6
7
<div :style="{ fontSize: postFontSize + 'em' }">
<BlogPost
v-for="post in posts"
:key="post.id"
:title="post.title"
/>
</div>

然后,给 <BlogPost> 组件添加一个按钮:

1
2
3
4
5
6
7
<!-- BlogPost.vue, 省略了 <script> -->
<template>
<div class="blog-post">
<h4>{{ title }}</h4>
<button>Enlarge text</button>
</div>
</template>

这个按钮目前还没有做任何事情,我们想要点击这个按钮来告诉父组件它应该放大所有博客文章的文字。要解决这个问题,组件实例提供了一个自定义事件系统。父组件可以通过 v-on@ 来选择性地监听子组件上抛的事件,就像监听原生 DOM 事件那样:

1
2
3
4
<BlogPost
...
@enlarge-text="postFontSize += 0.1"
/>

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

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

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

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

1
2
3
4
5
<!-- BlogPost.vue -->
<script setup>
defineProps(['title'])
defineEmits(['enlarge-text'])
</script>

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

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

1
2
3
4
5
<script setup>
const emit = defineEmits(['enlarge-text'])

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

如果你没有在使用 <script setup>,你可以通过 emits 选项定义组件会抛出的事件。你可以从 setup() 函数的第二个参数,即 setup 上下文对象上访问到 emit 函数:

1
2
3
4
5
6
export default {
emits: ['enlarge-text'],
setup(props, ctx) {
ctx.emit('enlarge-text')
}
}

11.插槽

通过插槽来分配内容

一些情况下我们会希望能和 HTML 元素一样向组件中传递内容:

1
2
3
<AlertBox>
Something bad happened.
</AlertBox>

我们期望能渲染成这样:

This is an Error for Demo Purposes

Something bad happened.

这可以通过 Vue 的自定义 <slot> 元素来实现:

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div class="alert-box">
<strong>This is an Error for Demo Purposes</strong>
<slot />
</div>
</template>

<style scoped>
.alert-box {
/* ... */
}
</style>

如上所示,我们使用 <slot> 作为一个占位符,父组件传递进来的内容就会渲染在这里。

12.自定义hook函数

  • 什么是hook?—— 本质是一个函数,把setup函数中使用的Composition API进行了封装。

  • 类似于vue2.x中的mixin。

  • 自定义hook的优势: 复用代码, 让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
35
import { onMounted, onBeforeUnmount, isRef, watch, unref } from 'vue'
import type { Ref } from 'vue'

/**
* 监听事件的自定义 Hook
* @param target 监听事件的目标对象,可以是 ref 或普通对象
* @param event 要监听的事件名称
* @param handler 事件处理函数
*/
export default function useEventListener(
target: Ref<EventTarget | null> | EventTarget,
event: string,
handler: (e: Event) => any
) {
if (isRef(target)) {
// 如果 target 是 ref,则监听其变化
watch(target, (value, oldValue) => {
// 移除旧的事件监听器
oldValue?.removeEventListener(event, handler)
// 添加新的事件监听器
value?.addEventListener(event, handler)
})
} else {
// 如果 target 不是 ref,则在组件挂载时添加事件监听器
onMounted(() => {
target.addEventListener(event, handler)
})
}

// 在组件销毁前移除事件监听器
onBeforeUnmount(() => {
unref(target)?.removeEventListener(event, handler)
})
}

13.Composition API

shallowReactive 与 shallowRef

  • shallowReactive : 只处理了对象内最外层属性的响应式(也就是浅响应式)

  • shallowRef: 只处理了value的响应式, 不进行对象的reactive处理

  • 什么时候用浅响应式呢?

    • 一般情况下使用ref和reactive即可
    • 如果有一个对象数据, 结构比较深, 但变化时只是外层属性变化 ==> shallowReactive
    • 如果有一个对象数据, 后面会产生新的对象来替换 ==> shallowRef

ref 的 内部

shallowRef的 内部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<template>
<h2>App</h2>

<h3>m1: {{m1}}</h3>
<h3>m2: {{m2}}</h3>
<h3>m3: {{m3}}</h3>
<h3>m4: {{m4}}</h3>

<button @click="update">更新</button>
</template>

<script lang="ts">
import { reactive, ref, shallowReactive, shallowRef } from 'vue'
/*
shallowReactive与shallowRef
shallowReactive: 只处理了对象内最外层属性的响应式(也就是浅响应式)
shallowRef: 只处理了value的响应式, 不进行对象的reactive处理
总结:
reactive与ref实现的是深度响应式, 而shallowReactive与shallowRef是浅响应式
什么时候用浅响应式呢?
一般情况下使用ref和reactive即可,
如果有一个对象数据, 结构比较深, 但变化时只是外层属性变化 ===> shallowReactive
如果有一个对象数据, 后面会产生新的对象来替换 ===> shallowRef
*/

export default {

setup () {

const m1 = reactive({a: 1, b: {c: 2}})
const m2 = shallowReactive({a: 1, b: {c: 2}})

const m3 = ref({a: 1, b: {c: 2}})
const m4 = shallowRef({a: 1, b: {c: 2}})

const update = () => {
// m1.b.c += 1
// m2.b.c += 1

// m3.value.a += 1
m4.value.a += 1
}

return {
m1,
m2,
m3,
m4,
update,
}
}
}
</script>

readonly 与 shallowReadonly

  • readonly:
    • 深度只读数据
    • 获取一个对象 (响应式或纯对象) 或 ref 并返回原始代理的只读代理。
    • 只读代理是深层的:访问的任何嵌套 property 也是只读的。
  • shallowReadonly
    • 浅只读数据
    • 创建一个代理,使其自身的 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<template>
<h2>App</h2>
<h3>{{state}}</h3>
<button @click="update">更新</button>
</template>

<script lang="ts">
import { reactive, readonly, shallowReadonly } from 'vue'
/*
readonly: 深度只读数据
获取一个对象 (响应式或纯对象) 或 ref 并返回原始代理的只读代理。
只读代理是深层的:访问的任何嵌套 property 也是只读的。
shallowReadonly: 浅只读数据
创建一个代理,使其自身的 property 为只读,但不执行嵌套对象的深度只读转换
应用场景:
在某些特定情况下, 我们可能不希望对数据进行更新的操作, 那就可以包装生成一个只读代理对象来读取数据, 而不能修改或删除
*/

export default {

setup () {

const state = reactive({
a: 1,
b: {
c: 2
}
})

// const rState1 = readonly(state)
const rState2 = shallowReadonly(state)

const update = () => {
// rState1.a++ // error
// rState1.b.c++ // error

// rState2.a++ // error
rState2.b.c++
}

return {
state,
update
}
}
}
</script>

toRaw 与 markRaw

  • toRaw
    • 返回由 reactivereadonly 方法转换成响应式代理的普通对象。
    • 这是一个还原方法,可用于临时读取,访问不会被代理/跟踪,写入时也不会触发界面更新。
  • markRaw
    • 标记一个对象,使其永远不会转换为代理。返回对象本身
    • 应用场景:
      • 有些值不应被设置为响应式的,例如复杂的第三方类实例或 Vue 组件对象。
      • 当渲染具有不可变数据源的大列表时,跳过代理转换可以提高性能。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<template>
<h2>{{state}}</h2>
<button @click="testToRaw">测试toRaw</button>
<button @click="testMarkRaw">测试markRaw</button>
</template>

<script lang="ts">
/*
toRaw: 得到reactive代理对象的目标数据对象
*/
import {
markRaw,
reactive, toRaw,
} from 'vue'
export default {
setup () {
const state = reactive<any>({
name: 'tom',
age: 25,
})

const testToRaw = () => {
const user = toRaw(state)
user.age++ // 界面不会更新

}

const testMarkRaw = () => {
const likes = ['a', 'b']
// state.likes = likes
state.likes = markRaw(likes) // likes数组就不再是响应式的了
setTimeout(() => {
state.likes[0] += '--'
}, 1000)
}

return {
state,
testToRaw,
testMarkRaw,
}
}
}
</script>

toRef

  • 为源响应式对象上的某个属性创建一个 ref对象, 二者内部操作的是同一个数据值, 更新时二者是同步的
  • 区别ref: 拷贝了一份新的数据值单独操作, 更新时相互不影响
  • 应用: 当要将 某个prop 的 ref 传递给复合函数时,toRef 很有用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
<template>
<h2>App</h2>
<p>{{state}}</p>
<p>{{foo}}</p>
<p>{{foo2}}</p>

<button @click="update">更新</button>

<Child :foo="foo"/>
</template>

<script lang="ts">
/*
toRef:
为源响应式对象上的某个属性创建一个 ref对象, 二者内部操作的是同一个数据值, 更新时二者是同步的
区别ref: 拷贝了一份新的数据值单独操作, 更新时相互不影响
应用: 当要将某个 prop 的 ref 传递给复合函数时,toRef 很有用
*/

import {
reactive,
toRef,
ref,
} from 'vue'
import Child from './Child.vue'

export default {

setup () {

const state = reactive({
foo: 1,
bar: 2
})

const foo = toRef(state, 'foo')
const foo2 = ref(state.foo)

const update = () => {
state.foo++
// foo.value++
// foo2.value++ // foo和state中的数据不会更新
}

return {
state,
foo,
foo2,
update,
}
},

components: {
Child
}
}
</script>

<template>
<h2>Child</h2>
<h3>{{foo}}</h3>
<h3>{{length}}</h3>
</template>

<script lang="ts">
import { computed, defineComponent, Ref, toRef } from 'vue'

const component = defineComponent({
props: {
foo: {
type: Number,
require: true
}
},

setup (props, context) {
const length = useFeatureX(toRef(props, 'foo'))

return {
length
}
}
})

function useFeatureX(foo: Ref) {
const lenth = computed(() => foo.value.length)

return lenth
}

export default component
</script>

customRef

  • 创建一个自定义的 ref,并对其依赖项跟踪和更新触发进行显式控制
  • 需求: 使用 customRef 实现 debounce也就是防抖的示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
<template>
<h2>App</h2>
<input v-model="keyword" placeholder="搜索关键字"/>
<p>{{keyword}}</p>
</template>

<script lang="ts">
/*
customRef:
创建一个自定义的 ref,并对其依赖项跟踪和更新触发进行显式控制

需求:
使用 customRef 实现 debounce 的示例
*/

import {
ref,
customRef
} from 'vue'

export default {

setup () {
const keyword = useDebouncedRef('', 500)
console.log(keyword)
return {
keyword
}
},
}

/*
实现函数防抖的自定义ref
*/
function useDebouncedRef<T>(value: T, delay = 200) {
let timeout: number
return customRef((track, trigger) => {
return {
get() {
// 告诉Vue追踪数据
track()
return value
},
set(newValue: T) {
clearTimeout(timeout)
timeout = setTimeout(() => {
value = newValue
// 告诉Vue去触发界面更新
trigger()
}, delay)
}
}
})
}

</script>

在Vue.js中,customRef是一个新的响应式引用工具,它允许您创建自定义的响应式引用,并在其读取和写入时执行自定义逻辑。这个功能是在Vue.js 3.1.0版本中引入的。

通常情况下,在Vue中,响应式引用是通过ref函数创建的,它会将普通的JavaScript值转换为响应式引用。而customRef允许您更灵活地定义响应式引用的行为。

使用customRef,您可以在引用被读取或写入时执行自定义的逻辑。例如,您可以在读取时执行惰性计算,或在写入时执行一些额外的验证或副作用操作。

下面是customRef的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { customRef } from 'vue';

const myCustomRef = customRef((track, trigger) => ({
get() {
track(); // 告诉Vue追踪此引用的依赖
// 在这里执行读取时的逻辑
// 您可以执行惰性计算或者其他自定义操作
return someValue;
},
set(value) {
// 在这里执行写入时的逻辑
// 您可以执行验证、副作用操作等
someValue = value;
trigger(); // 告诉Vue重新计算引用的依赖
}
}));

// 使用自定义引用
const myRef = myCustomRef();

console.log(myRef.value); // 读取引用
myRef.value = newValue; // 写入引用

在上面的示例中,customRef接受一个工厂函数作为参数,该工厂函数返回一个对象,包含getset方法。get方法用于读取引用时执行的逻辑,set方法用于写入引用时执行的逻辑。在这两个方法中,您可以执行自定义的逻辑,并使用tracktrigger函数告诉Vue要追踪或重新计算引用的依赖。

总之,customRef提供了一种灵活的方式来创建自定义的响应式引用,并在其读取和写入时执行自定义的逻辑。

provide 与 inject

  • provideinject提供依赖注入,功能类似 2.x 的provide/inject
  • 实现跨层级组件(祖孙)间通信
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
<template>
<h1>父组件</h1>
<p>当前颜色: {{color}}</p>
<button @click="color='red'">红</button>
<button @click="color='yellow'">黄</button>
<button @click="color='blue'">蓝</button>

<hr>
<Son />
</template>

<script lang="ts">
import { provide, ref } from 'vue'
/*
- provide` 和 `inject` 提供依赖注入,功能类似 2.x 的 `provide/inject
- 实现跨层级组件(祖孙)间通信
*/

import Son from './Son.vue'
export default {
name: 'ProvideInject',
components: {
Son
},
setup() {

const color = ref('red')

provide('color', color)

return {
color
}
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div>
<h2>子组件</h2>
<hr>
<GrandSon />
</div>
</template>

<script lang="ts">
import GrandSon from './GrandSon.vue'
export default {
components: {
GrandSon
},
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<h3 :style="{color}">孙子组件: {{color}}</h3>

</template>

<script lang="ts">
import { inject } from 'vue'
export default {
setup() {
const color = inject('color')

return {
color
}
}
}
</script>

响应式数据的判断

  • isRef: 检查一个值是否为一个 ref 对象
  • isReactive: 检查一个对象是否是由 reactive 创建的响应式代理
  • isReadonly: 检查一个对象是否是由 readonly 创建的只读代理
  • isProxy: 检查一个对象是否是由 reactive 或者 readonly 方法创建的代理

14.渲染函数

在绝大多数情况下,Vue 推荐使用模板语法来创建应用。然而在某些使用场景下,我们真的需要用到 JavaScript 完全的编程能力。

基本用法

创建 Vnodes

Vue 提供了一个 h() 函数用于创建 vnodes:

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

const vnode = h(
'div', // type
{ id: 'foo', class: 'bar' }, // props
[
/* children */
]
)

h()hyperscript 的简称——意思是“能生成 HTML (超文本标记语言) 的 JavaScript”。这个名字来源于许多虚拟 DOM 实现默认形成的约定。一个更准确的名称应该是 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
// 除了类型必填以外,其他的参数都是可选的
h('div')
h('div', { id: 'foo' })

// attribute 和 property 都能在 prop 中书写
// Vue 会自动将它们分配到正确的位置
h('div', { class: 'bar', innerHTML: 'hello' })

// 像 `.prop` 和 `.attr` 这样的的属性修饰符
// 可以分别通过 `.` 和 `^` 前缀来添加
h('div', { '.name': 'some-name', '^width': '100' })

// 类与样式可以像在模板中一样
// 用数组或对象的形式书写
h('div', { class: [foo, { bar }], style: { color: 'red' } })

// 事件监听器应以 onXxx 的形式书写
h('div', { onClick: () => {} })

// children 可以是一个字符串
h('div', { id: 'foo' }, 'hello')

// 没有 props 时可以省略不写
h('div', 'hello')
h('div', [h('span', 'hello')])

// children 数组可以同时包含 vnodes 与字符串
h('div', ['hello', h('span', 'hello')])
1
2
3
4
5
6
const vnode = h('div', { id: 'foo' }, [])

vnode.type // 'div'
vnode.props // { id: 'foo' }
vnode.children // []
vnode.key // null

声明渲染函数

当组合式 API 与模板一起使用时,setup() 钩子的返回值是用于暴露数据给模板。然而当我们使用渲染函数时,可以直接把渲染函数返回:

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

export default {
props: {
/* ... */
},
setup(props) {
const count = ref(1)

// 返回渲染函数
return () => h('div', props.msg + count.value)
}
}

setup() 内部声明的渲染函数天生能够访问在同一范围内声明的 props 和许多响应式状态。

除了返回一个 vnode,你还可以返回字符串或数组:

1
2
3
4
5
export default {
setup() {
return () => 'hello world!'
}
}
1
2
3
4
5
6
7
8
9
10
11
12
import { h } from 'vue'

export default {
setup() {
// 使用数组返回多个根节点
return () => [
h('div'),
h('div'),
h('div')
]
}
}

在Vue 3中,模板的渲染逻辑已经从组件本身中分离出来,转移到了render函数中。render函数是一个返回VNode(虚拟节点)的函数,它描述了组件的结构和行为。

因此,为了将模板的渲染逻辑从setup()函数中分离出来,并且确保setup()函数只执行一次,我们需要将渲染逻辑封装在一个函数中,并将这个函数作为setup()函数的返回值。这样做可以确保每次组件重新渲染时,都可以获取到最新的状态和数据。

如果setup()函数直接返回一个值而不是一个函数,那么这个值将会被视为组件的初始渲染结果,并且不会随着组件状态的变化而更新。因此,为了实现动态的、响应式的渲染逻辑,需要将渲染逻辑封装在一个函数中,并在需要时调用这个函数来获取最新的渲染结果。

Vnodes 必须唯一

组件树中的 vnodes 必须是唯一的

1
2
3
4
5
6
7
8
9
//错误示范:
function render() {
const p = h('p', 'hi')
return h('div', [
// 啊哦,重复的 vnodes 是无效的
p,
p
])
}

如果你真的非常想在页面上渲染多个重复的元素或者组件,你可以使用一个工厂函数来做这件事。比如下面的这个渲染函数就可以完美渲染出 20 个相同的段落:

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

15.JSX / TSX

JSX 是 JavaScript 的一个类似 XML 的扩展,有了它,我们可以用以下的方式来书写代码:

1
const vnode = <div>hello</div>

在 JSX 表达式中,使用大括号来嵌入动态值:

1
const vnode = <div id={dynamicId}>hello, {userName}</div>

create-vue 和 Vue CLI 都有预置的 JSX 语法支持。

如果你之前使用过 JSX 语法,那么请注意 Vue 的 JSX 转换方式与 React 中 JSX 的转换方式不同,因此你不能在 Vue 应用中使用 React 的 JSX 转换。与 React JSX 语法的一些明显区别包括:

  • 可以使用 HTML attributes 比如 classfor 作为 props - 不需要使用 classNamehtmlFor
  • 传递子元素给组件 (比如 slots) 的方式不同

Vue 的类型定义也提供了 TSX 语法的类型推导支持。当使用 TSX 语法时,确保在 tsconfig.json 中配置了 "jsx": "preserve",这样的 TypeScript 就能保证 Vue JSX 语法转换过程中的完整性。

渲染函数案例

v-if

模板:

1
2
3
4
<div>
<div v-if="ok">yes</div>
<span v-else>no</span>
</div>

等价于使用如下渲染函数 / JSX 语法:

1
h('div', [ok.value ? h('div', 'yes') : h('span', 'no')])
1
<div>{ok.value ? <div>yes</div> : <span>no</span>}</div>

v-for

模板:

1
2
3
4
5
<ul>
<li v-for="{ id, text } in items" :key="id">
{{ text }}
</li>
</ul>

等价于使用如下渲染函数 / JSX 语法:

1
2
3
4
5
6
7
h(
'ul',
// assuming `items` is a ref with array value
items.value.map(({ id, text }) => {
return h('li', { key: id }, text)
})
)
1
2
3
4
5
<ul>
{items.value.map(({ id, text }) => {
return <li key={id}>{text}</li>
})}
</ul>

v-on

on 开头,并跟着大写字母的 props 会被当作事件监听器。比如,onClick 与模板中的 @click 等价。

1
2
3
4
5
6
7
8
9
h(
'button',
{
//当作事件监听器
onClick(event) {
}
},
'click me'
)
1
2
3
4
5
6
<button
onClick={(event) => {
}}
>
click me
</button>

事件修饰符

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

实例:

1
2
3
4
5
6
7
8
9
10
11
h('input', {
onClickCapture() {
/* 捕捉模式中的监听器 */
},
onKeyupOnce() {
/* 只触发一次 */
},
onMouseoverOnceCapture() {
/* 单次 + 捕捉 */
}
})
1
2
3
4
5
<input
onClickCapture={() => {}}
onKeyupOnce={() => {}}
onMouseoverOnceCapture={() => {}}
/>

对于事件和按键修饰符,可以使用 withModifiers 函数:

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

h('div', {
onClick: withModifiers(() => {}, ['self'])
})
1
<div onClick={withModifiers(() => {}, ['self'])} />

组件

在给组件创建 vnode 时,传递给 h() 函数的第一个参数应当是组件的定义。这意味着使用渲染函数时不再需要注册组件了 —— 可以直接使用导入的组件:

1
2
3
4
5
6
import Foo from './Foo.vue'
import Bar from './Bar.jsx'

function render() {
return h('div', [h(Foo), h(Bar)])
}
1
2
3
4
5
6
7
8
function render() {
return (
<div>
<Foo />
<Bar />
</div>
)
}

不管是什么类型的文件,只要从中导入的是有效的 Vue 组件,h 就能正常运作。

动态组件在渲染函数中也可直接使用:

1
2
3
4
5
6
import Foo from './Foo.vue'
import Bar from './Bar.jsx'

function render() {
return ok.value ? h(Foo) : h(Bar)
}
1
2
3
function render() {
return ok.value ? <Foo /> : <Bar />
}

如果一个组件是用名字注册的,不能直接导入 (例如,由一个库全局注册),可以使用 resolveComponent() 来解决这个问题。

渲染插槽

在渲染函数中,插槽可以通过 setup() 的上下文来访问。每个 slots 对象中的插槽都是一个返回 vnodes 数组的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export default {
props: ['message'],
setup(props, { slots }) {
return () => [
// 默认插槽:
// <div><slot /></div>
h('div', slots.default()),

// 具名插槽:
// <div><slot name="footer" :text="message" /></div>
h(
'div',
slots.footer({
text: props.message
})
)
]
}
}

等价 JSX 语法:

1
2
3
4
5
// 默认插槽
<div>{slots.default()}</div>

// 具名插槽
<div>{slots.footer({ text: props.message })}</div>

传递插槽

向组件传递子元素的方式与向元素传递子元素的方式有些许不同。我们需要传递一个插槽函数或者是一个包含插槽函数的对象而非是数组,插槽函数的返回值同一个正常的渲染函数的返回值一样——并且在子组件中被访问时总是会被转化为一个 vnodes 数组。

1
2
3
4
5
6
7
8
9
10
11
// 单个默认插槽
h(MyComponent, () => 'hello')

// 具名插槽
// 注意 `null` 是必需的
// 以避免 slot 对象被当成 prop 处理
h(MyComponent, null, {
default: () => 'default slot',
foo: () => h('div', 'foo'),
bar: () => [h('span', 'one'), h('span', 'two')]
})

等价 JSX 语法:

1
2
3
4
5
6
7
8
9
// 默认插槽
<MyComponent>{() => 'hello'}</MyComponent>

// 具名插槽
<MyComponent>{{
default: () => 'default slot',
foo: () => <div>foo</div>,
bar: () => [<span>one</span>, <span>two</span>]
}}</MyComponent>

插槽以函数的形式传递使得它们可以被子组件懒调用。这能确保它被注册为子组件的依赖关系,而不是父组件。这使得更新更加准确及有效。

作用域插槽

为了在父组件中渲染作用域插槽,需要给子组件传递一个插槽。注意该插槽现在拥有一个 text 参数。该插槽将在子组件中被调用,同时子组件中的数据将向上传递给父组件。

1
2
3
4
5
6
7
8
// 父组件
export default {
setup() {
return () => h(MyComp, null, {
default: ({ text }) => h('p', text)
})
}
}

记得传递 null 以避免插槽被误认为 prop:

1
2
3
4
5
6
7
// 子组件
export default {
setup(props, { slots }) {
const text = ref('hi')
return () => h('div', null, slots.default({ text: text.value }))
}
}

等同于 JSX:

1
2
3
<MyComponent>{{
default: ({ text }) => <p>{ text }</p>
}}</MyComponent>

内置组件

诸如 <KeepAlive><Transition><TransitionGroup><Teleport><Suspense>内置组件在渲染函数中必须导入才能使用:

1
2
3
4
5
6
7
import { h, KeepAlive, Teleport, Transition, TransitionGroup } from 'vue'

export default {
setup () {
return () => h(Transition, { mode: 'out-in' }, /* ... */)
}
}

v-model

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

1
2
3
4
5
6
7
8
9
10
11
export default {
props: ['modelValue'],
emits: ['update:modelValue'],
setup(props, { emit }) {
return () =>
h(SomeComponent, {
modelValue: props.modelValue,
'onUpdate:modelValue': (value) => emit('update:modelValue', value)
})
}
}

自定义指令

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

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

// 自定义指令
const pin = {
mounted() { /* ... */ },
updated() { /* ... */ }
}

// <div v-pin:top.animate="200"></div>
const vnode = withDirectives(h('div'), [
[pin, 200, 'top', { animate: true }]
])

当一个指令是以名称注册并且不能被直接导入时,可以使用 resolveDirective 函数来解决这个问题。

模板引用

在组合式 API 中,模板引用通过将 ref() 本身作为一个属性传递给 vnode 来创建:

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

export default {
setup() {
const divEl = ref()

// <div ref="divEl">
return () => h('div', { ref: divEl })
}
}

16.内置组件

<Teleport>

Teleport 是 Vue 3 中引入的一个新功能,它允许你将子组件的内容渲染到 DOM 中的任何位置,而不受父组件的限制。这在需要在 DOM 结构中进行复杂布局或处理特定的样式和事件时非常有用。

使用 Teleport,你可以轻松地将子组件的内容渲染到 DOM 中的另一个位置,而不会打破 Vue 的响应性或组件结构。这在处理诸如模态框、对话框、下拉菜单等需要脱离正常文档流的组件时特别有用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<template>
<div class="son">
<h3>我是子组件</h3>
<button @click="isShow = true">打开一个弹窗</button>
<teleport to="body">
<!-- 遮罩层 -->
<div v-if="isShow" class="mask">
<div class="dialog">
<h3>我是一个弹窗</h3>
<button @click="isShow = false">关闭弹窗</button>
</div>
</div>
</teleport>
</div>
</template>

<script setup>
import { ref } from "vue";
let isShow = ref(false);
</script>

<style scoped>
.son {
width: 200px;
height: 200px;
background-color: blue;
}
.mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5)
}
.dialog {
width: 150px;
height: 150px;
background-color: green;
position: absolute;
text-align: center;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
</style>

遮罩的实现

1
2
3
4
5
6
7
8
.mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5)
}

<Suspense>

  • 等待异步组件时渲染一些额外内容,让应用有更好的用户体验

  • 使用步骤:

    • 异步引入组件

      1
      2
      import {defineAsyncComponent} from 'vue'
      const Child = defineAsyncComponent(()=>import('./components/Child.vue'))
    • 使用Suspense包裹组件,并配置好defaultfallback

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      <template>
      <div class="app">
      <h3>我是App组件</h3>
      <Suspense>
      <template v-slot:default>
      <Child/>
      </template>
      <template v-slot:fallback>
      <h3>加载中.....</h3>
      </template>
      </Suspense>
      </div>
      </template>

17.异步组件

基本用法

在大型项目中,我们可能需要拆分应用为更小的块,并仅在需要时再从服务器加载相关组件。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')
))

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

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>

加载与错误状态

异步操作不可避免地会涉及到加载和错误状态,因此 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 抛错时被渲染。你还可以指定一个超时时间,在请求耗时超过指定时间时也会渲染报错组件。

搭配 Suspense 使用

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

18.函数式组件

函数式组件是一种定义自身没有任何状态的组件的方式。它们很像纯函数:接收 props,返回 vnodes。函数式组件在渲染过程中不会创建组件实例 (也就是说,没有 this),也不会触发常规的组件生命周期钩子。

我们用一个普通的函数而不是一个选项对象来创建函数式组件。该函数实际上就是该组件的渲染函数。

函数式组件的签名与 setup() 钩子相同:

1
2
3
function MyComponent(props, { slots, emit, attrs }) {
// ...
}

大多数常规组件的配置选项在函数式组件中都不可用,除了 propsemits。我们可以给函数式组件添加对应的属性来声明它们:

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

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

对于有明确 props 的函数式组件,attribute 透传的原理与普通组件基本相同。然而,对于没有明确指定 props 的函数式组件,只有 classstyleonXxx 事件监听器将默认从 attrs 中继承。在这两种情况下,可以将 inheritAttrs 设置为 false 来禁用属性继承:

1
MyComponent.inheritAttrs = false

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

为函数式组件标注类型

函数式组件可以根据它们是否有命名来标注类型。在单文件组件模板中,Vue - Official 扩展还支持对正确类型化的函数式组件进行类型检查。

具名函数式组件

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
import type { SetupContext } from 'vue'
type FComponentProps = {
message: string
}

type Events = {
sendMessage(message: string): void
}

function FComponent(
props: FComponentProps,
context: SetupContext<Events>
) {
return (
<button onClick={() => context.emit('sendMessage', props.message)}>
{props.message} {' '}
</button>
)
}

FComponent.props = {
message: {
type: String,
required: true
}
}

FComponent.emits = {
sendMessage: (value: unknown) => typeof value === 'string'
}

匿名函数式组件

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
import type { FunctionalComponent } from 'vue'

type FComponentProps = {
message: string
}

type Events = {
sendMessage(message: string): void
}

const FComponent: FunctionalComponent<FComponentProps, Events> = (
props,
context
) => {
return (
<button onClick={() => context.emit('sendMessage', props.message)}>
{props.message} {' '}
</button>
)
}

FComponent.props = {
message: {
type: String,
required: true
}
}

FComponent.emits = {
sendMessage: (value) => typeof value === 'string'
}