Vue
Vue核心:
1. CDN引入
使用ESM版本和CDN引入:
html
<div id="app">{{ message }}</div>
<!-- 我们可以使用导入映射表 (Import Maps) 来告诉浏览器如何定位到导入的 vue: -->
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<script type="module">
import { createApp, ref } from "vue";
const app = createApp({
setup() {
const message = ref("Hello Vue!");
return {
message,
};
},
});
app.mount("#app");
</script>
警告
ES模块不能通过file://协议工作,也即是当你打开一个本地文件时,浏览器使用的协议。由于安全原因,ES模块只能通过 http://协议工作,也即是浏览器在打开网页时使用的协议。为了使ES模块在我们的本地机器上工作,我们需要使用本地的HTTP服务器,通过http://协议来提供index.html。
2. 基本概念
应用实例:
- 每个Vue应用都是通过
createApp函数创建一个新的应用实例:
html
<script type="module">
import { createApp } from "vue";
/*
根组件:
- 我们传入的createApp的对象实际上是一个组件,每个应用都需要一个“根组件”,其他组件将作为其子组件
- 大多数真实的应用都是由一颗嵌套的、可重用的组件树组成的。例如:一个待办事项(Todos)应用的组件书可能是这样的:
App (root component)
├─ TodoList
│ └─ TodoItem
│ ├─ TodoDeleteButton
│ └─ TodoEditButton
└─ TodoFooter
├─ TodoClearButton
└─ TodoStatistics
*/
const app = createApp({
/* 根组件选项 */
});
</script>
挂载应用
- 应用实例必须在调用
.mount()方法后才会渲染出来。该方法接收一个"容器"参数,可以是一个实际的DOM元素或是一个CSS选择器字符串:
html
<div id="app"></div>
<script type="module">
/*
应用根组件的内容将会被渲染在容器元素里面。容器元素自己将不会被视为应用的一部分。
.mount()方法应该始终在整个应用配置和资源注册完成之后被调用。同时请注意,不同于其他资源注册方法,它的返回值是根组件而非应用实例。
*/
app.mount("#app");
</script>
DOM中的根组件模板:
- 根组件的模板通常是组件本身的一部分,但也可以直接通过在挂载容器内编写的模板来单独提供
html
<div id="app">
<button @click="count++">{{ count }}</button>
</div>
<!--
当组件没有设置`template`选项时,Vue将会自动使用容器的`innerHTML`作为模板
-->
<script type="module">
import { createApp } from "vue";
const app = createApp({
data() {
return {
count: 0,
};
},
});
app.mount("#app");
</script>
应用配置:
- 应用实例会暴露一个
.config对象允许我们配置一些应用级的选项,例如定义一个应用级的错误处理器,用来捕获所有子组件上的错误:
html
<script type="module">
app.config.errorHandler = (err) => {
/* 处理错误 */
};
/*
应用实例还提供了一些方法来注册应用范围内可用的资源,例如注册一个组件:
这使得`TodoDeleteButton`在应用的任何地方都是可用的。
*/
app.component("TodoDeleteButton", TodoDeleteButton);
</script>
多个应用实例:
- 应用实例并不只限于一个,createAppAPI允许你在同一个页面中创建多个共存的Vue应用,而且每个应用都拥有自己的用于配置和全局资源的作用域。
html
<script type="module">
/*
如果你正在使用Vue来增强服务端渲染HTML,并且只想要Vue去控制一个大型页面中特殊的一小部分,应避免将一个单独的Vue应用实例挂载到整个页面上,而是应该创建多个小的应用实例,将它们分别挂载到所需的元素上去
*/
const app1 = createApp({
/* ... */
})
const app2 = createApp({
/* ... */
})
const app3 = createApp({
/* ... */
})
app1.mount('#app1')
app2.mount("#app2")
app3.mount("#app3")
...
</script>
3. 模板语法
指令: Directives
- 指令是带有
v-前缀的特殊attribute。Vue提供了许多内置指令,包括但不限于v-bind等。
- 指令
attribute的期望值为一个JavaScript表达式(除了少数几个例外,即之后要讨论到的v-for、v-on和v-slot)。一个指令的任务是在其表达式的值变化时响应式地更新DOM。
参数:Arguments
-
某些指令会需要一个参数,在指令名后面通过一个冒号隔开做标识。例如用v-bind指令来响应式地更新一个HTML attribute,另一个例子就是v-on指令,它将监听DOM事件。
-
动态参数:同样在指令参数上也可以使用一个JavaScript表达式,但需要包含在一对方括号内,例如:v-bind:[attributeName]="url"。(注意:参数表达式有一些约束,参见动态参数的限制与动态参数语法的限制章节的解释),这里的attributeName会作为一个JavaScript表达式被动态执行,计算得到的值会被用作最终的参数。(相似地,你还可以将一个函数绑定到动态的事件名称上 )
动态参数值的限制:
- 动态参数中表达式的值应当是一个字符串,或者是
null,特殊值null意为显示移除该绑定。其他非字符串的值会触发警告。
动态参数语法的限制:
- 动态参数表达式因为某些字符的缘故会有一些语法限制,比如空格和引号,在
HTML attribute名称中都是不合法的,例如:<a :['foo' + bar]="value">...</a>,这会触发一个编译器警告,如果你需要传入一个复杂的动态参数,推荐使用计算属性替换复杂的表达式,也就Vue最基础的概念之一,后续会讲到。
- 当使用
DOM内嵌模板(直接写在HTML文件里的模板)时,我们需要避免在名称中使用大写字母,因为浏览器会强制将其转换为小写,例如:<a :[someAttr]="value">...</a>,上面的例子将会在DOM内嵌模板中被转换为:[someattr]。如果你的组件拥有"someAttr"属性而非"someattr",那么这段代码将不会工作。单文件组件内的模板不受此限制。
修饰符:Modifiers
- 修饰符是以点开头的特殊后缀,表面指令需要以一些特殊的方式被绑定。例如:
.prevent修饰符会告知v-on指令对触发的事件调用event.preventDefault()。
疑问?
为什么动态参数里面不允许['foo' + bar]?
看上去如果bar是字符串的话,理论上['foo' + bar]的计算结果也是字符串,那为什么还会报警告呢?核心原因:空格是HTML属性的终结符。在HTML规范中,属性之间是用空格分隔的。Vue的模板首先要经过HTML解析器。
如果你写成:['bar' + foo]:
解析器视角:当解析器遇到[后面跟着的引号和空格时,它会认为当前的属性已经结束了。
语法冲突:HTML解析器会把+及其后面的内容误认为是另一个属性的开始,或者直接报错。

html
<!--
Vue使用一种基于HTML的模板语法,使我们能够声明式地将其组件实例的数据绑定到呈现DOM上。所有的Vue模板都是语法层面合法的HTML,可用被符合规范的浏览器和HTML解析器解析。
在底层机制中,Vue会将模板编译成高度优化的JavaScript代码。结合响应式系统,当应用状态变更时,Vue能够智能地推导出需要重新渲染的组件的最少数量,并应用最少的DOM操作。
-->
<!--
文本插值:最基本的数据绑定形式是文本插值,使用“Mustache”语法(即:双大括号)
双大括号标签会被替换为相应组件实例中的msg属性的值,同时每次msg属性更改时,它也会同步更新
-->
<span> Message: {{ msg }} </span>
<!--
v-html: 双大括号会将数据解释为纯文本,而不是HTML。若想插入HTML,则需要使用v-html指令
安全警告:
在网站上动态渲染任何HTML是非常危险的,因为这非常容器造成XSS漏洞。请仅在内容安全可信时再使用v-html,并且永远不要使用用户提供的HTML内容。
-->
<p>Using text interpolation: {{ rawHTML }}</p>
<p>Using v-html directive: <span v-html="rawHtml"></span></p>
<p>
<!--
Attribute绑定:
双大括号不能在HTML attribute中使用,想要响应式地绑定一个attribute,应该使用v-bind指令。
v-bind指令指示Vue将元素的id attribute与组件的dynamicId属性保存一致。如果绑定的值是null或undefined,那么该attribute将会从渲染的元素上移除
简写:
因为v-bind非常常用,所以提供了特定的简写语法: <div :id="dynamicId"></div>
同名简写:
仅支持3.4版本及以上,如果attribute的名称与绑定的JavaScript变量的名称相同,那么可用进一步简化语法,省略attribute值
<div :id></div> === <div v-bind:id></div> === <div :id="id"></div>
-->
</p>
<div v-bind:id="dynamicId"></div>
<div :id="dynamicId"></div>
<div v-bind:id></div>
<div :id></div>
<!--
布尔型 Attribute:
布尔型 Attribute: 依据 true / false 值来决定 attribute 是否应该存在于该元素上。
disabled就是最常见的例子之一,当isButtonDisable为真值或一个空字符串(即<button disable="">)时,元素包含这个disbaled的attribute。而当其为其他假值时,attribute将被忽略
-->
<button :disabled="isButtonDisabled">
Button<button>
<!--
动态绑定多个值:
如果有这样一个包含多个attribute的JavaScript对象:
const objectOfAttrs = {
id: 'container',
class: 'wrapper',
style: 'background-color: green'
}
通过不带参数的v-bind,可用将它们绑定到单个元素上
-->
<div v-bind="objectOfAttrs"></div>
<!--
使用JavaScript表达式:
Vue实际上在所有的数据绑定中,都支持完整的JavaScript表达式,这些表达式都会被作为JavaScript代码,以当前组件实例为作用域进行解析。
在Vue模板内,JavaScript表达式可用被使用在如下场景中:
- 在文本插值中(双大括号)
- 在任何Vue指令(以v-开头的特殊attribute)attribute的值中
仅支持表达式:
- 每个绑定仅支持单一表达式,也就是一段能被求值的JavaScript代码,一个简单的判断方法就是是否可用合法地写在return后面
调用函数:
可用在绑定的表达式中使用一个组件暴露的方法,但是绑定在表达式中的方法在组件每次更新时都会被重新调用,因此不应该产生任何副作用,比如改变数据或触发异步操作。
受限的全局访问:
模板中的表达式将被沙盒化,仅能够访问到有限的全局对象列表,该列表中会暴露常用的内置全局对象,比如Math和Date。没有显示包含在列表中的全局对象将不能够在模板内表达式中访问,列入用户附加在window上的属性。但是,你可以自行在app.config.globalProperties上显示地添加它们,供所有的Vue表达式使用。
-->
<div>{{ number + 1 }}</div>
<div>{{ ok ? 'yes' : 'no' }}</div>
<div>{{ message.split("").reverse().join('') }}</div>
<div :id="`list-${id}`"></div>
<time :title="toTitleDate(date)" :datetime="date"
>{{ formatDate(date) }}</time
>
<!--
v-if:指令会基于表达式的值的真假来移除或插入该元素
-->
<div v-if="seen">...</div>
<!--
v-on:它将监听DOM事件
-->
<a v-on:click="doSometing">...</a>
<a @click="doSomething">...</a>
<form @submit.prevent="onSubmit">...</form>
</button>
</button>
4. 响应式基础
<script setup> 中的顶层的导入、声明的变量和函数可在同一组件的模板中直接使用。你可以理解为模板是在同一作用域内声明的一个JavaScript函数——它自然可以访问与它一起声明的所有内容。
声明响应式状态:
javascript
import { ref } from "vue"
/* ref()接收参数,并将其包裹在一个带有.value属性的ref对象中返回 */
/* ref()可接受一个泛型来指定类型,或者让ref自行类型推导 */
/* 如果你指定了一个泛型参数但没有给出初始值,那么最后将会得到一个包含undefined的联合类型 */
const count<number> = ref(0)
const foo<number> = ref() // 推导得到的类型:Ref<number | undefined>
console.log(count) // { value: 0 }
console.log(count.value) // 0
自动解包:
vue
<template>
<!--
注意:在模板中使用ref时,不需要附加.value,因为当在模板中使用时,ref会自动解包
在模板中解包的注意事项:
在模板渲染上下文中,只有顶级的ref属性才会被自动解包
在下面的属性定义中,只有foo和bar是顶级属性,但是bar.id不是
另一个需要注意的点:如果ref是文本插值的最终计算值(即{{ }}标签),那么它将被解包,该特性仅仅是文本插值的一个便利特性,等价于{{ bar.id.value }}
-->
{{ foo }}
<!-- 因为bar.id不是顶级属性,所以不会进行解包,结果将是字符串拼接 -->
{{ bar.id + 1 }}
<!-- 因为bar.id在双大括号中是ref的最终计算值,所以会解包 -->
{{ bar.id }}
<template>
<script setup lang='ts'>
import { ref } from "vue"
const foo = ref(0)
const bar = { id: ref(0) }
<script>
为什么需要使用ref
- 为什么需要使用带有
.value的ref,而不是直接使用普通的变量呢?因为当在模板中使用了一个ref,当改变这个ref的值时,Vue会自动检测到这个变化,并且相应地更新DOM。这是通过一个基于依赖追踪的响应式系统实现的。
- 当一个组件
首次渲染时,Vue会追踪在渲染过程中使用的每一个ref,然后,当一个ref被修改时,它会触发追踪它的组件的一次重新渲染。
- 因为在标准的
JavaScript中,检测普通变量的访问或修改是行不通的。然而,可以通过getter和setter方法来拦截对象属性的get和set操作。该.value属性给予Vue一个机会来检测ref何时被访问或修改。在其内部,Vue在它的getter中执行追踪,在它的setter中执行触发。
- 从概念上说,可以将ref看作是一个像这样的对象:
javascript
/* 伪代码,不是真正的实现 */
const myRef = {
_value: 0,
get value() {
track();
return this._value;
},
set value(newValue) {
this._value = newValue;
trigger();
},
};
- 另一个使用ref的好处是,与普通变量不同,你可以将ref传递给函数,同时保留对最新值和响应式链接的访问。当将复杂逻辑重构为可重用的代码时,这将非常有用。
深层响应式:
- Ref可以持有任何类型的值,包括深层嵌套的
对象、数组或者JavaScript内置的数据结构,比如Map等。Ref会使他的值具有深层响应性,这意味这既是改变嵌套对象或数组时,变化也会被检测到。
javascript
import { ref } from "vue";
const obj = ref({
nested: { count: 0 },
arr: ["foo", "bar"],
});
function mutateDeeply() {
/* 以下都会按照期望工作 */
obj.value.nested.count++;
obj.value.arr.push("baz");
}
- 非原始值将通过
reactive()转换为响应式代理,该函数后续讨论。
- 还可以通过
shallow ref来放弃深层响应式。对于浅层ref,只有.value的访问会被追踪。浅层ref可以用于避免对大型数据的响应式开销来优化性能、或者有外部库来管理其内部状态的情况。
- 阅读更多:
DOM更新时机:
- 当修改了响应式状态时,
DOM会被自动更新。但DOM的更新并不是同步的。Vue会在"next tick"更新周期中缓冲所有的状态的修改,以确保不管你进行了多少次状态的修改,每个组件都只会被更新一次。
- 要等待
DOM更新完成之后再执行额外的代码 ,可以使用nextTick()全局API:
javascript
import { nextTick } from "vue";
async function increment() {
count.value++;
await nextTick();
/* 现在DOM已经更新了 */
}
reactive()
- 还有另一种声明响应式状态的方式,即使用
reactive() API。这个与将内部值包装在特殊对象的ref不同,reactive()将使对象本身具有响应式:
javascript
import { reactive } from "vue"
/* 在ts中会自动类型推导 */
const state = reactive({ count: 0 })
/* 显示的添加类型推荐 */
/* 不推荐使用reactive()的泛型参数,因为处理深层次ref解包的返回值与泛型参数的类型不同 */
interface Book {
title: string
year?: number
}
const book: Book = reactive({ title: "Vue 3指引" })
- 响应式对象是JavaScript代理,其行为就和普通对象一样。不同的是,Vue能够拦截对响应式对象所有属性的访问和修改,以便于依赖追踪和触发更新。
reactive()将深层地转换对象:当访问嵌套对象时,它们也会被reactive()包装。当ref的值是一个对象时,ref()也会在内部调用它。与浅层ref类似,这里也有一个shallowReactive() API可以选择推出深层响应式。
Reactive Proxy VS. Original
- 值得注意的是,
reactive()返回的是一个原始对象的Proxy,它和原始对象是不相等的。
javascript
import { reactive } from "vue";
const raw = {};
const proxy = reactive(raw);
/* 代理对象是和原始对象不是全等的 */
console.log(proxy === raw); /* false */
只有代理对象是响应式的,更改原始对象是不会触发更新的。因此,使用Vue的响应式系统的最佳实践是仅使用声明对象的代理版本。
为了保证访问代理的一致性,对同一个原始对象调用reactive(),会总是返回同样的代理对象,而对于一个已经存在的代理对象调用reactive()会返回其本身。
javascript
/* 在同一个对象上调用reactive()会返回相同的代理 */
console.log(reactive(raw) === proxy); // true
/* 在一个代理上调用reactive()会返回它自己 */
console.log(reactive(proxy) === proxy); // true
这个规则对嵌套对象也适用,依靠深层响应性,响应式对象内的嵌套对象依然是代理。
javascript
const proxy = reactive({})
const raw = {}··
proxy.nested = raw
console.log(proxy.nested === raw) // false
reactive()的局限性
javascript
let state = reacitve({ count: 0 });
/* 上面的({ count: 0 })引用将不再被追踪,响应性连接已丢失 */
state = reactive({ count: 1 });
- 对解构操作不友好:当我们将响应式对象的原始类型属性解构为本地变量时,或者将该属性传递给函数时,我们将丢失响应性连接:
javascript
const state = reacitve({ count: 0 });
/* 当解构时,count已经与state.count断开连接 */
let { count } = state;
count++;
/* 该函数接收到的是一个普通的数字 */
/* 并且无法追踪state.count的变化 */
/* 我们必须传入整个对象以保持响应式 */
callSomeFunction(state.count);
由于这些限制,Vue官方建议使用ref()作为声明响应式状态的主要API
额外的ref解包细节
- 一个ref会在作为响应式对象的属性被访问或修改时自动解包。换句话说,它的行为就像一个普通的属性:
javascript
const count = ref(0);
const state = reactive({ count });
console.log(state.count); // 0
state.count = 1;
console.log(count.value); // 1
/* 如果将一个新的ref赋值给一个关联了已有ref属性,那么它会替换掉旧的ref */
const otherCount = ref(2);
state.count = otherCount;
console.log(state.count); // 2
/* 原始ref现在已经和state.count失去了联系 */
console.log(count.value); // 1
- 只有当嵌套在一个深层响应式对象内时,才会发生ref解包。当其作为浅层响应式对象的属性被访问时不会解包。
数组和集合的注意事项:
- 与reactive对象不同的是,当ref作为响应式数组或原生集合类型(如Map)中的元素被访问时,它不会被解包。
javascript
const books = reactive([ref("vue 3 Guide")]);
/* 这里需要.value */
console.log(books[0].value);
const map = reacitve(new Map([["count", ref(0)]]));
/* 这里需要.value */
console.log(map.get("count").value);
在模板中解包的注意事项:
- 在模板渲染上下文中,只有顶级的ref属性才会被解包。
javascript
<script setup lang='ts'>
const count = ref(0)
const object = { id: ref(1) }
</script>
<!-- 这个表达式按预期工作 -->
<div>{{ count + 1 }}</div>
<!-- 但这个不会 -->
<!-- 渲染的结果将会时[object Object]1,因为在计算表达式时,object.id没有被解包,仍然是一个ref对象。为了解决这个问题,可以将id解构为一个顶级属性 -->
<div>{{ object.id + 1 }}</div>
<script setup lang='ts'>
const { id } = object
</script>
<!-- 现在渲染结果将是2 -->
<div>{{ id + 1 }}</div>
<!-- 另一个需要注意的点是,如果ref为文本插值的最终计算值(即{{}}标签),那么它将被解包,该特性仅仅是文本插值的一个便利特性,等价于{{ object.id.value }} -->
<div>{{ object.id }}</div>
5. 计算属性
- 在Vue中,
计算属性是用来描述依赖响应式状态的复杂逻辑,它与函数的区别在于,计算属性的值会基于其响应式依赖被缓存。一个计算属性当且仅当在其响应式依赖更新时才会重新计算,这意味着只要被依赖的响应式值不改变,无论多少次访问同一个计算属性,都会立即返回先前的计算结果,而无需重复执行getter函数。
vue
<script setup lang="ts">
import { reactive, computed } from "vue"
const author = reactive({
name: "John Doe",
books: [
"Vue 2 - Advanced Guide",
"Vue 3 - Basic Guide",
"Vue 4 - The Mystery"
]
})
/*
computed()方法期望接收一个getter函数,返回值为一个计算属性ref。和其他一般的ref类似,可以通过publishedBooksMessage.value访问计算结果。计算属性ref也会在模板中自动解包,因此在模板表达式中引用时,无序添加.value
Vue的计算属性会自动追踪响应式依赖。它会检测到publishedBooksMessage依赖于author.books,所以当author.books改变时,任何依赖于publishedBooksMessage的绑定都会同步更新
*/
const publishedBooksMessage = computed(() => {
return author.books.length > 0 'Yes' : 'No'
})
</script>
<template>
<p>Has published books:</p>
<span>{{ publishedBooksMessage }}</span>
</template>
vue
<!--
计算属性默认是只读的,当尝试修改一个计算属性时,你会收到一个运行时警告。只有在某些特殊场景中才可能需要用到可写属性,可以通过同时提供getter和setter来创建。
-->
<script setup lang="ts">
import { ref, computed } from "vue";
const firstName = ref("John");
const lastName = ref("Doe");
/* 现在当你运行fullName.value = "YQZ Mio时,会调用setter方法,firstName和lastName会随之更新,计算属性也会重新进行计算" */
const fullName = computed({
get() {
return firstName.value + " " + lastName.value;
},
set(newValue) {
[firstName.value, lastName.value] = newValue.split(" ");
},
});
</script>
获取上一个值和为计算属性添加类型标注
- 如果需要,可以通过访问计算属性的getter的第一个参数来获取计算属性返回的上一个值
vue
<!-- Vue 3.4+版本支持 -->
<script setup>
import { ref, computed } from "vue"
const count = ref(2)
/*
这个计算属性在count的值小于或等于3的时候,将返回count的值
当count的值大于等于4时,将会返回满足我们条件的最后一个值
直到count的值再次小于或等于3为止
computed函数接收一个泛型,可以在泛型里面为返回值添加类型标注,默认会自动推导(computed<T>)
*/
const alwaysSmall = computed((pervious) => {
if (count.value <= 3) {
return count.value
}
return previous
})
<script>
最佳实践
Getter不应该有副作用,计算属性的getter应只做计算而没有任何其他的副作用,这一点非常重要!!!举例来说,不要改变其他状态,在getter中做异步请求或更改DOM!一个计算属性的声明中描述的是如何根据其他值派生一个值。因此getter的职责应该仅为计算和返回值。
- 避免
直接修改计算属性值,从计算属性返回的值是派生状态。我们可以把它看作是一个"临时快照",每当源状态发生变化时,就会创建一个新的快照。更改快照是没有意义的,因此计算属性的返回值应该被视为只读的,并且永远不应该被更改,应该更新它所依赖的源状态以触发新的计算。
6. 类与样式绑定
通过v-bind动态的添加类名或样式
html
<div id="app">
<!-- 动态绑定字符串 -->
<!-- class="bbb foo bar baz" -->
<div class="bbb" :class="foo">{{ foo }}</div>
<!-- 绑定值为一个对象,那么会通过对象的value的真假值来决定是否把key添加到类中 -->
<!-- 这里isActive为true,渲染结果class='active' -->
<div :class="{ active: isActive }">{{ isActive }}</div>
<!-- 绑定的对象并不一定需要写成内联字面量的形式,也可以直接绑定一个对象 -->
<!-- 这将渲染:class='active' -->
<div :class="classObject">
{{ classObject.active }} - {{ classObject['text-danger'] }}
</div>
<!-- 也可以绑定一个返回对象的计算属性,这也是一个常见且很有用的技巧 -->
<div :class="classObjectComputed">Mio</div>
<!-- 我们还可以给:class绑定一个数组来渲染多个class -->
<!-- 数组里面的元素可以是字符串,对象和表达式等 -->
<div :class="classArray">YQZ</div>
</div>
<script setup lang="ts">
import { ref, reactive, computed } from "vue";
const foo = ref("foo bar baz");
const isActive = ref(true);
const classObject = reactive({
active: true,
"text-danger": false,
});
const classObjectComputed = computed(() => ({
active: isActive.value,
"text-danger": !isActive.value,
}));
const classArray = ref([
"foo",
"bar",
{ active: true },
classObjectComputed.value,
]);
</script>
在组件上使用
html
<!-- 对于只有一个根元素的组件,当使用class attribute时,这些class会被添加到根元素上并与该元素上已有的class合并 -->
<!-- 子组件模板 -->
<p class="foo bar">Hi!</p>
<!-- 在使用时添加了一些class -->
<my-component class="baz boo"></my-component>
<!-- 渲染出的HTML为 -->
<p class="foo bar baz boo">Hi!</p>
<!-- 如果你的组件有多个根元素,那么需要指定哪个根元素来接收这个class,可以通过$attrs属性来指定接收 -->
<!-- my-component 模板使用 $attrs时 -->
<p :class="$attrs.class">Hi!</p>
<span>This is a child component</span>
<my-component class="baz"></my-component>
<!-- 这将被渲染为 -->
<p class="baz">Hi!</p>
<span>This is a child component</span>
绑定内联样式
html
<div id="app">
<!-- :style支持绑定JavaScript对象值,对于的是HTML元素style属性 -->
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }">Mio</div>
<!-- 尽管推荐使用camelCase,但:style也支持kebab-cased形式的CSS属性key(对应其CSS中的实际名称) -->
<div :style="{ color: activeColor, 'font-size': fontSize + 'px' }">Mio</div>
<!-- 直接绑定一个样式对象和计算属性,这样可以使模板更加简洁 -->
<!-- 同时,:style指令也可以和常规的style attribute共存,就和:class一样 -->
<div style="color: red" :style="styleObject">YQZ</div>
<!-- 绑定数组 -->
<!-- 这些对象会被合并后渲染在同一元素上 -->
<div :style="[styleObject, 'color: red', { display: 'inline' }]">Foo</div>
</div>
<script setup lang="ts">
import { ref } from "vue";
const activeColor = ref("red");
const fontSize = ref(30);
const styleObject = ref({
fontSize: 24 + "px",
"background-color": "pink",
});
</script>
自动前缀:
- 当你在:style中使用了需要浏览器特殊前缀的CSS属性时,Vue会自动为它们加上相应的前缀。Vue是在运行时检查该属性是否支持在当前浏览器中使用。如果浏览器不支持某个属性,那么将尝试加上各个浏览器特殊前缀,以找到哪一个是被支持的。
- 这些功能应该是历史早期为了兼容遗留下来的问题,无需多关注,最终要加前缀还是用对应的库来处理。Vue的这个功能也是运行时环境。
7. 条件渲染
html
<!--
v-if、v-else-if和v-else
v-if和v-show
-->
<!-- v-if指令用于条件性地渲染一块内容,这块内容只会在指定的表达式返回值为真值时才会被渲染 -->
<!-- v-else-if和v-else必须是和v-if在同一级别的作用域下才会生效,和JavaScript的逻辑一样 -->
<div v-if='type === "A"'>A</div>
<div v-else-if="type === 'B'">B</div>
<div v-else-if="type === 'C'">C</div>
<div v-else>Not A/B/C</div>
template上的v-if
vue
<!-- 因为v-if是一个指令,它必须依附于某个元素,但是如果我们想要切换不止一个元素的话,那么在这种情况下我们可以使用template来进行包裹,最后渲染的结果并不会包含这个template元素 -->
<template v-if="isShow">
<h1>H1</h1>
<h2>H2</h2>
</template>
v-show和v-if
html
<!-- 另外一个可以按条件显示一个元素的指令是v-show,其用法基本和v-if一样 -->
<!-- 不同之处在于v-show会在DOM渲染中保留该元素,v-show仅切换了该元素上名为display的css属性 -->
<!-- v-show不支持在template元素上使用,并且也不能与v-else-if和v-else搭配使用 -->
<h1 v-show="ok">Hello!</h1>
- v-if是
"真实的"按条件渲染,因为它确保了在切换时,条件区块内的事件监听器和子组件都会被销毁与重建。
- v-if也是
惰性的:如果在初次渲染时条件为false,则不会做任何事。条件区块只有当条件首次变为true时才会被渲染。
- 相比之下,
v-show简单许多,元素无论初始条件如何,始终都会被渲染,只有CSS的display属性会被切换。
- 总的来说,
v-if有更高的切换开销,而v-show有更高的初始渲染开销。因此,如果需要频繁切换,则使用v-show较好;如果在运行时绑定条件很少改变,则v-if会更合适。
v-if和v-for
- 当
v-if和v-for同时存在于一个元素上的时候,v-if会首先被执行。
- 同时使用
v-if和v-for是不推荐的,因为这样二者的优先级不明显。
8. 列表渲染
v-for与数组
html
<!--
我们可以使用v-for指令来渲染一个数组列表,v-for指令的值需要使用`item in items`形式的特殊语法,其中items是源数据的数组,而item是迭代项的别名。
在v-for块中可以完整地访问父级作用域内的属性和变量,同时,v-for也支持使用可选的第二个参数表示当前项的位置索引。
对于多层嵌套的v-for,作用域的工作方式和函数的作用域很类似。每个v-for作用域都可以访问到父级作用域。
当然,也可以使用of作为分隔符来代替in,这更接近JavaScript的迭代器语法。
-->
<app id="app">
<ul>
<li v-for="({ foo }, index) in items">{{ foo }} - {{ index }}</li>
</ul>
</app>
<script setup lang="ts">
import { ref } from "vue";
const items = ref([{ foo: "Mio" }, { foo: "YQZ" }]);
</script>
v-for与对象
html
<!--
你也可以使用v-for来遍历一个对象的所有属性,遍历的顺序会基于该对象调用的Object.values()的返回值来决定。
其中,也可以通过提供第二个参数来表示属性名(例如Key),第三个参数将表示索引值
-->
<div id="app">
<h2 v-for="(value, key, index)">{{ value }} - {{ key }} - {{ index }}</h2>
</div>
<script setup lang="ts">
import { ref } from "vue";
const obj = ref({
name: "Mio",
age: 16,
});
</script>
在v-for里面使用范围值
- v-for可以直接接受一个整数值。在这种用例中,会将该模板基于1...n的取值范围重复多次。注意:此处n的初始值是从1开始的,并非0
html
<div id="app">
<ul>
<li v-for="item in range">{{ item }}</li>
</ul>
</div>
<script setup lang="ts">
import { ref } from "vue";
const range = ref(30);
</script>
<template>上的v-for
html
<!--
与模板上的v-if类似,你也可以在template标签上使用v-for来渲染一个包含多个元素的块
-->
<div id="app">
<template v-for="item in items">
<li>{{ item.msg }}</li>
<li class="divider" role="presentation">{{ item.desc }}</li>
</template>
</div>
v-for与v-if
- 当它们同时存在于一个节点上时,v-if比v-for的优先级更高。这意味着v-if的条件将无法访问到v-for作用域内定义的变量别名。
html
<!--
这会抛出一个错误,因为属性 todo 此时没有在该实例上定义
-->
<li v-for="todo in todos" v-if="!todo.isComplete">{{ todo.name }}</li>
<!--
在外先包装一层template再在其上使用v-for可以解决这个问题(当然,这也更加明显易读)
-->
<template v-for="todo in todos">
<li v-if="!todo.isComplete">{{ todo.name }}</li>
</template>
注意
-
同时使用v-if和v-for是不推荐的,因为这样二者的优先级不明显。
-
两者常见的情况可能导致这种用法:
-
过滤列表中的项目(例如:v-for="user in users" v-if="user.isActive")。在这种情况下,可以用一个新的计算属性来替换users,该属性返回过滤后的列表(例如:activeUsers)
-
避免渲染应该隐藏的列表(例如:v-for="user in users" v-if="shouldShowUsers")。在这种情况下,将v-if移至容器元素(如ul、ol)。
通过key管理状态
没有使用key的情况:
- Vue默认按照
"就地更新"的策略来更新通过v-for渲染的元素列表。当数据项的顺序改变时,Vue不会随之移动DOM元素的顺序,而是就地更新每个元素,确保它们在原本指定的索引位置上渲染。
- 默认模式是
高效的,但只适用于列表渲染输出的结果,不依赖子组件状态或者临时DOM状态(例如表单输入值)的情况。
- 总结就是:
DOM元素没有移动,它们只是被赋予新的数据内容,如果存在表达组件,它们的状态会混乱。
使用了key的情况:
- 为了给
Vue一个提示,以便于它可以跟踪每个节点的标识,从而重用和重新排序现有的元素,你需要为每个元素对应的块提供一个唯一的keyattribute。
- 总结:当使用了
key,Vue会在发现key变化时(即数据项被替换)才替换DOM;但在发现key只是改变了位置时,它会更高效地选择复用并移动DOM元素,以避免昂贵的DOM重建操作。
总结:
- 官方推荐在任何可行的时候为
v-for提供一个key,除非所迭代的DOM内容非常简单(例如:不包含组件或有状态的DOM元素),或者有意采用默认行为来提高性能。
html
<!--
在没有Key的情况下,删掉数组中第一个元素后,复用旧DOM 0,只替换标签和文本内容,导致了保留输入框的临时值, 发现新的列表长度是1,没有索引1上的VNode,然后销毁旧DOM1
有key的情况下:发现key=101在新列表中不存在了,删掉整个旧DOM 0, 发现key=102任然存在,但位置从索引1移动到了索引0,Vue会移动/重排序整个旧DOM 1到索引0的位置
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
.container {
width: 900px;
margin: 0 auto;
display: flex;
justify-content: space-between;
}
.button {
text-align: center;
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<div>
<h2>没有Key</h2>
<div v-for="item in forms">
{{ item.label }}({{item.id}})<input type="text" />
</div>
</div>
<div>
<h2>有Key</h2>
<div v-for="item in forms" :key="item.id">
{{ item.label }}({{item.id}})<input type="text" />
</div>
</div>
</div>
<div class="button">
<button @click="deleteFirstArry">删除第一项</button>
</div>
</div>
<!-- 我们可以使用导入映射表 (Import Maps) 来告诉浏览器如何定位到导入的 vue: -->
<script type="importmap">
{
"imports": {
"vue": "./../lib/vue.esm-browser.js"
}
}
</script>
<script type="module">
import { createApp, ref } from "vue";
const app = createApp({
setup() {
const forms = ref([
{
label: "姓名",
id: 101,
},
{
label: "邮箱",
id: 102,
},
]);
const deleteFirstArry = () => {
forms.value.shift();
};
return {
forms,
deleteFirstArry,
};
},
});
app.mount("#app");
</script>
</body>
</html>
组件上使用v-for
- 我们可以直接在组件上使用v-for,它和在一般元素上使用没有任何区别(但是别忘了提供一个key)
html
<!--
但是,不会自动将任何数据传递给组件,因为组件有自己独立的作用域。为了将迭代后的数据传递到组件中,我们还需要传递props
不自动将item注入组件的原因是,这会使组件与v-for的工作方式紧密耦合。明确其数据的来源可以使组件在其他情况下重用
-->
<my-component v-for="item in items" :key="item.id"></my-component>
<my-component
v-for="(item, index) in items"
:item="item"
:index="index"
:key="item.id"
>
</my-component>
数组变化侦测
javascript
/* items是一个数组的ref */
/* 你可能认为这将导致Vue丢弃现有的DOM并重新渲染整个列表,但是,情况并非如此。Vue实现了一些巧妙的方法来最大化对DOM元素的重用,因此使用另一个包含部分重叠对象的数组来做替换,仍会是一种非常高效的操作 */
items.value = items.value.filter((item) => item.message.match(/Foo/));
展示过滤或排序后的结果
- 有时候,我们希望显示数组经过过滤或排序后的内容,而不实际变更或重置原始数据。在这种情况下,我们可以创建返回已过滤或已排序数据的计算属性
html
<li v-for="n in evenNumbers">{{ n }}</li>
<!-- 在计算属性不可行的情况下(列入在多层嵌套的v-for循环中),可以使用如下方法 -->
<ul v-for="numbers in sets">
<li v-for="n in even(numbers)">{{ n }}</li>
</ul>
<script setup>
import { ref, computed } from "vue";
const numbers = ref([1, 2, 3, 4, 5]);
const sets = ref([
[1, 2, 3, 4, 5],
[6, 7, 8, 9, 10],
]);
const evenNumbers = computed(() => {
return numbers.value.filter((n) => n % 2 === 0);
});
function even(numbers) {
return numbers.filter((number) => number % 2 === 0);
}
/*
在计算属性中使用reverse()和sort()的时候务必小心! 这两个方法将变更原始数组,计算函数中不应该这么做,请在调用这些方法之前创建一个原数组的副本:
- return numbers.reverse()
+ return [...numbers].reverse()
*/
</script>
9. 事件处理
html
<!-- 内联事件处理器 -->
<button @click="count++">Add 1</button>
<p>Count is: {{ count }}</p>
<!-- 方法事件处理器 -->
<!--
方法事件处理器会自动接收原生DOM事件并触发执行。下面例子中,我们能够通过触发事件的`event.target`访问到该DOM元素
-->
<button @click="greet">Greet</button>
<!-- 在内联事件处理器中访问事件参数 -->
<!--
有时我们需要在内联事件处理器中访问原生DOM事件。那么可以向该处理器方法传入一个特殊的 `$event` 变量,或者使用内联箭头函数
-->
<!-- 使用特殊的 `$event` 变量 -->
<button @click="warn('Form cannot be submitted yet.', $event)">Submit</button>
<!-- 使用内联箭头函数 -->
<button @click="(event) => warn('Form cannot be sumiteed yet.', event)">
Submit
</button>
<script setup lang="ts">
import { ref } from "vue";
const count = ref(0);
const name = ref("Vue.js");
function greet(event) {
alert(`Hello ${name.value}!`);
/* `event` 是DOM原生事件 */
if (event) {
alert(event.target.tagName);
}
}
function warn(message, event) {
/* 这里可以访问原生事件 */
if (event) {
event.preventDefault();
}
alert(message);
}
</script>
为事件处理器标注类型
- 当没有
类型标注时,这个event参数会隐式地标注为any类型。
- 这也会在
tsconfig.json中配置了strict: true或noImplicitAny: true时报出一个TS错误。
- 因此,建议
显示地为事件处理函数的参数标注类型。此外,在访问event上的属性时可能需要使用类型断言。
html
<script setup lang="ts">
function handleChange(event) {
/* `event` 隐式地标注为 any 类型 */
console.log(event.target.value);
}
/* 使用类型断言 */
function handleChange(event: Event) {
console.log((event.target as HTMLInputElement).value);
}
</script>
<template>
<input type="text" @change="handleChange" />
</template>
事件修饰符
- 在处理事件时调用
event.preventDefault()或event.stopPropagation()是很常见的。尽管我们可以直接在方法内调用,但如果方法能更专注于数据逻辑而不用去处理DOM事件的细节会更好。
- 为了
解决这一问题,Vue为v-on提供了事件修饰符。修饰符是用.表示的指令后缀,包含如下:
.stop、.prevent、.self、.capture、.once和.passive。
html
<!-- 单击事件将停止传递 -->
<a @click.stop="doThis"></a>
<!-- 提交事件将不再重新加载页面 -->
<from @submit.prevent="onSubmit"></from>
<!-- 修饰语可以使用链式书写 -->
<a @click.stop.prevent="doThis"></a>
<!-- 也可以只有修饰符 -->
<form @submit.prevent></form>
<!-- 仅当 event.target 是元素本身时才会触发事件处理器 -->
<!-- 例如:事件处理器不来自子元素 -->
<div @click.self="doThis">...</div>
<!--
.capture、.once和.passive修饰符与原生`addEventListener`事件相对应
-->
<!-- 添加事件监听器,使用`capture`捕获模式 -->
<!-- 例如:指向内部元素的事件,在被内部元素处理器,先被外部处理 -->
<div @click.capture="doThis">...</div>
<!-- 点击事件最多被触发一次 -->
<a @click.once="doThis"></a>
<!-- 滚动事件的默认行为(scrolling)将立即发生而非等待 `onScroll` 完成 -->
<!-- 以防其中包含 `event.preventDefault()` -->
<!-- .passive修饰符一般用于触摸事件的监听器,可以用来改善移动端设备的滚屏性能 -->
<div @scroll.passive="onScroll">...</div>
注意
使用修饰符时需要注意调用顺序,因为相关代码是以相同的顺序生成的。因此使用@click.prevent.self会阻止元素及其子元素的所有点击事件的默认行为,而@click.self.prevent则只会阻止对元素本身的点击事件的默认行为。
请勿同时使用.passive和.prevent,因为,.passive已经向浏览器表明了你不想阻止事件的默认行为。如果这么做了,则.prevent会被忽略,并且浏览器会抛出警告。
按键修饰符
- 在监听
键盘事件时,我们经常需要检查特定的按键。Vue允许在v-on或@监听按键事件时添加按键修饰符。
- Vue为一些常用的按键提供了别名:
.enter、.tab、.delete(捕获"Delete"和"Backspace"两个按键)、.esc、.space、.up、.down、.left和.right
- 你可以使用以下
系统按键修饰符来触发鼠标或键盘事件监听器,只有当按键被按下时才会触发:
html
<!-- 仅在 `key` 为 `Enter` 时调用 `submit` -->
<input @keyup.enter="submit" />
<!--
你可以直接使用 `KeyboardEvent.key` 暴露的按键名称作为修饰符,但需要转为 kebab-case 形式
下面这个例子中,仅会在 `$event.key` 为 `PageDown` 时调用事件处理
-->
<input @keyup.page-down="onPageDown" />
<!--
TIP:
请注意,`系统按键修饰符`和`常规按键不同`,与`keyup`事件一起使用时,`该按键必须在事件发出时处于按下状态`。换句话说:`keyup.ctrl`只会在你仍然按 `CTRL` 但松开了`另一个键时被触`发。若你`单独松开ctrl键将不会触发`。
-->
<!-- Alt + Enter -->
<input @keyup.alt.enter="clear" />
<!-- Ctrl + 点击 -->
<div @click.ctrl="doSomething">Do something</div>
<!-- .exact修饰符允许精确控制触发事件所需的系统修饰符的组合 -->
<!-- 当按下ctrl时,即使同时按下Alt或shift也会触发 -->
<button @click.ctrl="onClick">A</button>
<!-- 仅当按下 ctrl 且未按任何其他键时才会触发 -->
<button @click.ctrl.exact="onCtrlClick">A</button>
<!-- 仅当没有按下任何系统按键时触发 -->
<button @click.exact="onClick">A</button>
Tip
在Mac键盘上,meta是Command键。在Windows键盘上,meta键是Windows键。在Sun微机系统键盘上,meta是钻石键。在某些键盘上,特别是MIT和Lisp机器的键盘及其后代版本的键盘,如Knight键盘,space-cadet键盘,meta都被标记为"META"。在Symbolics键盘上,meta也被标识为"META"或"Meta"。
鼠标按键修饰符
- 这些
修饰符将处理程序限定为由特定鼠标按键触发的事件。
html
<!-- .left .right .middle -->
<!--
但请注意,.left,.right 和 .middle 这些修饰符名称是基于常见的右手用鼠标布局设定的,但实际上它们分别指代设备事件触发器的“主”、“次”,“辅助”,而非实际的物理按键。因此,对于左手用鼠标布局而言,“主”按键在物理上可能是右边的按键,但却会触发 .left 修饰符对应的处理程序。又或者,触控板可能通过单指点击触发 .left 处理程序,通过双指点击触发 .right 处理程序,通过三指点击触发 .middle 处理程序。同样,产生“鼠标”事件的其他设备和事件源,也可能具有与“左”,“右”完全无关的触发模式。
-->
10. 表单输入绑定
- 在
前端处理表单时,我们常常需要将输入框的内容同步给JavaScript中相应的变量。手动连接值绑定和更改事件监听器可能会很麻烦,我们可以使用v-model指向来帮我们简化这一步骤。
html
<!--
另外,`v-model`还可以用于各种不同类型的输入,<textarea>、<select>元素。它会根据所使用的元素自动使用对应的DOM属性和事件组合。
文本类型的<input>和<textarea>元素会绑定value property并侦听input事件。
<input type="checkbox">和<input type="radio">会绑定checked property并侦听change事件。
<select>会绑定value property并侦听change事件。
-->
<!--
注意:
v-model会忽略任何表单上初始的value、checked或selected attribute。它将始终将当前绑定的JavaScript状态视为数据的正确来源。你应该在JavaScript中使用响应式系统的API声明该初始值。
-->
<input :value="text" @input="event => text = event.target.value" />
<input v-model="text" />
<script setup lang="ts">
import { ref } from "vue";
const text = ref("");
</script>
修饰符
html
<!-- 默认情况下,v-model会在每次input事件后更新(IME拼写阶段的状态例外),你可以添加lazy修饰符来改为在每次change事件后更新数据 -->
<!-- 在 "change" 事件后同步更新而不是 "input" -->
<input v-model.lazy="ms" />
<!--
.number: 如果想让用户输入自动转换为数字,可以在v-model后添加.number修饰符来管理输入
如果该值无法被parseFloat()处理,那么将返回原始值。特别时当输入为空时(例如用户清空输入字段之后),会返回一个空字符串。这种行为与DOM属性valueAsNumber有所不同。
number修饰符会在输入框有type="number"时自动启用。
-->
<input v-model.number="age" />
<!--
.trim: 如果想要默认自动去除用户输入内容两端的空格,可以在v-model后添加.trim修饰符
-->
<input v-model.trim="msg" />
11. 侦听器
- 计算属性允许我们
声明性地计算衍生值。但在某些情况下,我们需要在状态变化时执行一些"副作用",例如更改DOM,或是根据异步操作的结果去修改另一处的状态。
- 在
组合式API中,可以使用watch函数在每次响应式状态发生变化时触发回调函数。
html
<script setup>
import { ref, watch } from "vue";
const question = ref("");
const answer = ref("Questions usually contain a question mark. ;-)");
const loading = ref(false);
/* 可以直接侦听一个ref */
watch(question, async (newQuestion, oldQuestion) => {
if (newQuestion.includes("?")) {
loading.value = true;
answer.value = "Thinking...";
try {
const res = await fetch("https://yesno.wtf/api");
answer.value = (await res.json()).answer;
} catch (error) {
answer.value = "Error! Could not reach the API. " + err;
} finally {
loading.value = false;
}
}
});
</script>
<template>
<p>
Ask a yes/no question:
<input v-model="question" :disabled="loading" />
</p>
</template>
侦听数据源类型
watch的第一个参数可以是不同形式的"数据源",它可以是一个ref(包括计算属性)、一个响应式对象、一个getter函数或者多个数据源组成的数组。
html
<script setup>
import { ref } from "vue"
const x = ref(0)
const y = ref(0)
/* 单个 ref */
watch(x, (newX) => {
console.log(`X is ${newX}`)
})
/* getter 函数 */
watch(
() => x.value + y.value,
(sum) => {
console.log(`sum of x + y is: ${sum}`)
}
)
/* 多个来源组成的数组*/
watch([x, () => y.value], [newX, newY] => {
console.log(`x is ${newX} and y is ${newY}`)
})
</script>
html
<script setup>
import { reactive } from "vue";
const obj = reactive({ count: 0 });
/* 错误,因为 watch() 得到的参数是一个 number */
watch(obj.count, (count) => {
console.log(`Count is: ${count}`);
});
/* 这里需要用一个返回该属性的 getter 函数 */
watch(
() => obj.count,
(count) => {
console.log(`Count is: ${count}`);
},
);
</script>
深层侦听器
- 直接给
watch()传入一个响应式对象,会隐式地创建一个深层侦听器,该回调函数在所有嵌套的变更时都会被触发。
html
<script setup>
import { reactive } from "vue";
const obj = reactive({ count: 0 });
watch(obj, (newValue, oldValue) => {
/* 在嵌套的属性变更时触发 */
/* 注意:`newValue` 此处和 `oldValue` 是相等的 */
/* 因为它们是同一个对象 */
});
/* --------------------------- 内容分割线--------------------------- */
/* 相比之下,一个返回响应式对象的getter函数,只有在返回不同的对象时,才会触发回调 */
watch(
() => state.someObject,
() => {
/* 仅当 state.someObject 被替换时触发 */
},
);
/* 当然,也可以给上面这个例子显示地加上`deep`选项,强制转成深层侦听器 */
watch(
() => state.someObject,
(newValue, oldValue) => {
/* 注意: `newValue` 此处和 `oldValue` 是相等的 */
/* 除非 state.someObject 被整个替换掉了 */
},
{ deep: true },
);
</script>
谨慎使用
深度侦听需要遍历侦听对象的所有嵌套的属性,当用于大型数据解构时,开销很大,因此请在只在必要时才使用它,并且要留意性能。
在Vue 3.5+中,deep选项还可以是一个数字,表示最大遍历深度,即Vue应该遍历对象嵌套属性的级数。
即时回调的侦听器
watch默认是懒执行的,仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时,立即执行一遍回调。
- 举例来说:我们
想请求一些初始数据,然后在相关状态更改时重新请求数据。
html
<script setup>
/* 我们可以传入`immediate: true`选项来强制侦听器的回调立即执行 */
watch(
source,
() => {
/* 立即执行,且当`source`改变时再次执行 */
},
{ immediate: true },
);
</script>
一次性侦听器
- 每当被侦听源
发生变化时,侦听器的回调就会执行。如果希望回调只在源变化时触发一次,那么可以使用once: true选项。
html
<script setup>
watch(
source,
(newValue, oldValue) => {
/* 当 `source` 变化时,仅触发一次 */
},
{ once: true },
);
</script>
watchEffect()
侦听器的回调使用与源完全相同的响应式状态是很常见的。
watchEffect仅会在其同步执行期间,才会追踪依赖。在使用异步回调时,只有在第一个await正常工作前访问到的属性才会被追踪。
watch vs watchEffect:
watch只追踪明确侦听的数据源。它不会追踪任何在回调中访问到的东西。另外,仅在数据源确实改变时才会触发回调。watch会避免在发生副作用时追踪依赖,因此,我们能更加精确地控制回调函数的触发时机。
watchEffect,则会在副作用发生期间追踪依赖。它会在同步执行过程中,自动追踪所有能访问到的响应式属性。这更方便,而且代码往往更简洁,但有时其响应性依赖关系会不那么明确。
html
<script setup>
import { ref, watch, watchEffect } from "vue";
const todoId = ref(1);
const data = ref(null);
const foo = ref("FOO");
watch(
todoId,
async () => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todoId.value}`,
);
data.value = await response.json();
},
{ immediate: true },
);
/* 上面的watch可用watchEffect改写 */
/* 在第一次执行中,因为data.value是在await之后,不会被Vue添加到依赖列表中,就算data.value发生改变,也不会执行回调 */
watchEffect(async () => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todoId.value}`,
);
data.value = await response.json();
});
/* 因为在第一次执行回调中,遇到了return,没有执行到下面一行代码,所以foo不会被添加到Vue的依赖里面,就算foo.value的值发生了改变,也不会执行回调 */
watchEffect(() => {
if (true) return;
console.log(foo.value);
});
</script>
副作用清理
- 有时候我们
需要在侦听器中执行副作用,例如在异步请求中,然而,当我们想要终止上一次请求,我们可以使用副作用清理。
html
<script setup>
import { watch, watchEffect, ref, onWatcherCleanup } from "vue";
const id = ref(0);
/*
在这里案例中,如果在请求完成之前`id`发生了变化怎么办? 当上一个请求完成时,它仍会使用已经过时的ID值触发回调。
理想情况下,我们希望能够在`id`变为新值时取消过时的请求。
*/
watch(id, (newId) => {
fetch(`/api/${newId}`).then(() => {
/* 回调逻辑 */
});
});
/*
有两种方式可以做到,第一种可以使用`onWatcherCleanup()`API来注册一个清理函数,当侦听器失效并准备重新运行时会被调用。第二种可以使用`onCleanup`函数作为第三个参数传递给侦听器回调,以及watchEffect作用函数的第一个参数
*/
watch(id, (newId) => {
const controller = new AbortController();
fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
/* 回调逻辑 */
});
onWatcherCleanup(() => {
/* 终止过期请求 */
controller.abort();
});
});
watch(id, (newId, oldId, onCleanup) => {
const controller = new AbortController();
fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
/* 回调逻辑 */
});
/* 钩子在下一次 watch 运行前被调用 */
onCleanup(() => {
controller.abort();
});
});
</script>
javascript
watch(id, (newId, oldId, onCleanup) => { ... })
watchEffect((onCleanup) => { ... })
⏰oncleanup的执行时机总结:
oncleanup函数是用于清理上一次副作用的,它绝对不会在副作用(watch或watchEffect的回调函数)第一次执行或当前执行时执行。
oncleanup里的代码只在以下两种特定情况运行时被触发:
🥇侦听器准备重新运行时(Invalidation),这是最常见的触发时机,当侦听器依赖的响应式数据发生变化,并且Vue准备执行新的副作用之前,它会先运行上一次注册的清理函数。
🥈组件卸载时(Unmount),当拥有这个watch或watchEffect的Vue组件实例被销毁时,Vue会执行最后一次注册的onCleanup函数。
🛑 避免的误区
- 错误时机:
onCleanup不会在watch或watchEffect回调函数刚开始执行时运行,它总是在上一次副作用和下一次副作用之间运行。
- 注册 vs. 运行:在每次回调中,
onCleanup( () => { ... } )都是在注册一个函数;而只有在下一次回调运行前或组件卸载时,这个被注册的函数才会被执行。
回调的触发时机
- 当你更改了
响应式状态,它可能会同时触发Vue组件更新和侦听器回调。
- 类似于
组件更新,用户创建的侦听器回调函数也会被批量处理以避免重复调用。
- 例如:如果我们
同步将一千个项目推入被侦听的数组中,我们可能不希望侦听器触发一千次。(大白话,回调函数只会在一个特定的异步时机执行。这意味着,即使响应式数据在短时间内被同步修改了一千次,回调函数最终也只会触发一次。这是因为数据本身是同步更新到最新状态的,而系统只是将执行这个回调的通知进行了去重。当回调真正执行时,他读取的自然就是已经包含了所有一千次修改的最新数据)
- 默认情况下,
侦听器回调会在父组件(如有)之后、所属组件的DOM更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的DOM,那么DOM将处于更新前的状态。
html
<!-- Post Watchers -->
<!-- 如果想在侦听器回调中能访问被Vue更新之后的所属组件的DOM,需要指明`flush: 'post'`选项 -->
<script setup>
import { watch, watchEffect, watchPostEffect, watchSyncEffect } from "vue";
watch(source, callback, { flush: "post" });
watchEffect(callback, { flush: "post" });
/* 后置刷新的watchEffect有一个更方便的别名watchPostEffect */
watchPostEffect(() => {
/* 在Vue更新后执行 */
});
/* 同步侦听器 */
/* 还可以创建一个同步触发的侦听器,它会在Vue进行任何更新之前触发 */
watch(source, callback, { flush: "sync" });
watchEffect(callback, { flush: "sync" });
/* 当然,同步触发的watchEffect也有一个方便的别名,watchSyncEffect */
watchSyncEffect(() => {
/* 在响应式数据变化时同步执行 */
});
</script>
谨慎使用:
同步侦听器不会进行批处理,每当检测到响应式数据发生变化时就会触发。可以使用它来检测简单的布尔值,但应避免可能多次同步修改的数据源(如数组上)使用。
代码示例
javascript
import { ref, watchEffect, watchSyncEffect } from "vue";
const count = ref(0);
// 正常的:只会打印一次 "Total: 1000"
watchEffect(() => {
console.log("普通 Effect:", count.value);
});
// 同步的:会瞬间刷屏打印 1000 次!
watchSyncEffect(() => {
console.log("同步 Effect:", count.value);
});
// 同步修改 100 次
for (let i = 0; i < 1000; i++) {
count.value++;
}
停止侦听器
html
<script setup>
import { ref, watchEffect } from "vue";
/* 它会自动停止 */
watchEffect(() => {});
/* ...这个则不会 */
setTimeout(() => {
watchEffect(() => {});
}, 1000);
/* 要手动停止一个侦听器,需要调用watch或watchEffect返回的函数 */
const unwatch = watchEffect(() => {});
/* ...当侦听器不再需要时 */
unwatch();
/* 注意:需要异步创建侦听器的情况很少,尽可能选择同步创建。如果需要等待一些异步数据,可以使用条件式的侦听逻辑 */
const data = ref(null); /* 需要异步请求得到的数据 */
watchEffect(() => {
if (data.value) {
/* 数据加载后执行某些操作 */
}
});
</script>
12. 模板引用
html
<input ref="inputEl" />
<script setup>
import { ref, onMounted } from "vue";
const inputEl = ref(null);
onMounted(() => {
input.value.focus();
});
</script>
访问模板引用
html
<!-- 在使用TypeScript时,Vue的IDE支持和`vue-tsc`将根据匹配的`ref`attribute所用的元素或组件自动推断input.value的类型 -->
<script setup>
import { useTemplateRef, onMounted, watchEffect } from "vue";
/* Vue 3.5+ */
/* 第一个参数必须与模板中的 ref 值匹配 */
const input = useTemplateRef("my-input");
onMounted(() => {
input.value.focus();
});
watchEffect(() => {
if (input.value) {
input.value.focus();
} else {
/* 此时还未挂载,或此元素已经被卸载(例如通过v-if控制) */
}
});
</script>
<template>
<input ref="my-input" />
</template>
<!-- 在3.5之前的版本尚未引入useTempalteRef(),我们需要声明一个与模板里ref attribute匹配的引用 -->
<script setup>
import { ref, onMounted } from "vue";
/* 声明一个 ref 来存放该元素的引用 */
/* 必须和模板里的 ref 同名 */
const input = ref(null);
onMounted(() => {
input.value.focus();
});
</script>
<template>
<input ref="input" />
</template>
组件上使用模板引用
-
如果一个子组件使用的是选项式API或没有使用<script setup>,那么被引用的组件实例和该子组件的this完全一致,这意味着父组件对子组件的每一个属性和方法都有完全的访问权。这使得在父组件和子组件之间创建紧密耦合的实现细节变得很容易,当然也因此,应该只在绝对需要时才使用组件引用。大多数情况下,应该首先使用标准的props和emit接口来实现父子组件交互。
-
有一个例外的情况,使用<script setup>的组件是默认私有的,一个父组件无法访问到一个使用了<script setup>的子组件中的任何东西,除非子组件在其中通过defineExpose宏显示暴露。
html
<script setup>
import { ref } from "vue";
const a = 1;
const b = ref(2);
/* 像 defineExpose 这样的编译器宏不需要导入 */
/*
当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为{ a: number, b: number }(ref都会自动解包,和一般的实例一样)
请注意:defineExpose必须在任何await操作之前调用。否则,在await操作后暴露的属性和方法将无法访问
*/
defineExpose({
a,
b,
});
</script>
组件模板引用上使用TypeScript
- 在
Vue 3.5+和@vue/language-tools 2.1(为IDE语言服务和vue-tsc提供支持)中,在单文件组件中由useTemplateRef()创建的ref类型可以基于匹配的ref attribute所在的元素自动推断为静态类型。
- 在
无法自动推断的情况下(如非单文件组件使用或动态组件),仍然可以通过泛型参数将模板ref强制转换为显示类型。
- 请注意在
@vue/language-tools 2.1以上版本中,静态模板ref类型可以被自动推导,下面的案例中,仅在极端情况下需要。
html
<!--
为了获取导入组件的实例类型,我们需要先通过 typeof 获取其类型,然后使用TypeScript的内置 InstanceType 工具提取其实例类型
-->
<script setup lang="ts">
import { useTemplateRef } from "vue";
import Foo from "./Foo.vue";
import Bar from "./Bar.vue";
type FooType = InstanceType<typeof Foo>;
type BarType = InstanceType<typeof Bar>;
const compRef = useTemplateRef<FooType | BarType>("comp");
</script>
<template>
<component :is="Math.random() > 0.5 ? Foo : Bar" ref="comp" />
</template>
<!--
如果组件的具体类型无法获得,或者并不关心组件的具体类型,那么可以使用 `ComponentPublicInstance`,这是会包含所有组件都共享的属性,比如 $el
-->
<script setup lang="ts">
import { useTemplateRef } from "vue";
import type { ComponentPublicInstance } from "vue";
const child = useTemplateRef<ComponentPublicInstance>("child");
</script>
<!-- 如果引用的组件是一个泛型组件,例如MyGenericModal -->
<!-- MyGenericModal.vue -->
<script setup lang="ts" generic="ContentType extends string | number">
import { ref } from "vue";
const content = ref<ContentType | null>(null);
const open = (newContent: ContentType) => (content.value = newContent);
defineExpose({
open,
});
</script>
<!--
则需要使用 vue-component-type-helpers库中的ComponentExposed引用组件类型,因为InstanceType在这种场景下不起作用
-->
<!-- App.vue -->
<script setup lang="ts">
import { useTemplateRef } from "vue";
import MyGenericModal from "./MyGenericModal.vue";
import type { ComponentExposed } from "vue-component-type-helpers";
const modal =
useTemplateRef<ComponentExposed<typeof MyGenericModal>>("modal");
const openModal = () => {
modal.value?.open("newValue");
};
</script>
v-for中的模板引用
- 需要在
Vue 3.5+及以上版本,当在v-for中使用模板引用时,对应的ref中包含的值是一个数组,它将在元素被挂载后包含对应整个列表的所有元素。
- 在
3.5版本以前,useTemplateRef()尚未引入,需要声明一个与模板引用attribute同名的ref。该ref的值需要是一个数组。
- 注意:ref数组并不保证与源数组相同的顺序。
html
<script setup>
import { ref, useTemplateRef, onMounted } from "vue";
const list = ref([
/* ... */
]);
const itemRefs = useTemplateRef("items");
onMounted(() => console.log(itemRefs.value));
</script>
<template>
<ul>
<li v-for="item in list" ref="items">{{ item }}</li>
</ul>
</template>
<!-- Vue 3.5 版本以下的用法 -->
<script setup>
import { ref, onMounted } from "vue";
const list = ref([
/* ... */
]);
const itemRefs = ref([]);
onMounted(() => console.log(itemRefs.value));
</script>
<template>
<ul>
<li v-for="item in list" ref="items">{{ item }}</li>
</ul>
</template>
函数模板引用:
- 除了使用
字符串值作为名字,ref attribute还可以绑定一个函数,会在每次组件更新时都被调用。该函数会收到元素引用作为其第一个参数。
html
<input :ref="(el) => { /* 将el赋值给一个数据属性或ref变量 */ }" />
注意:
我们这里需要使用动态的:ref绑定才能够传入一个函数。当绑定的元素被卸载时,函数也会被调用一次,此时el参数会是null。当然也可以绑定一个组件方法而不是内联函数。
13. 组件基础
- 组件允许我们将
UI划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考。在实际应用中,组件常常被组织成一个层层嵌套的树状结构。

定义一个组件
html
<!-- child.vue -->
<script setup>
import { ref } from "vue";
const count = ref(0);
</script>
<template>
<button @click="count++">You clicked me {{ count }} times.</button>
</template>
<!-- 不适用构建工具 -->
<script type="module">
import { ref } from "vue";
/*
这里的模板是一个内联的JavaScript字符串,Vue将会在运行时编译它。你也可以使用ID选择器来指向一个元素(通常是原生的<template>元素),Vue将会使用其内容作为模板来源。
*/
export default {
setup() {
const count = ref(0);
return {
count,
};
},
/* 也可以针对一个DOM内联模板,使用选择器:template: '#child' */
template: `<button @click="count++"> You clicked me {{ count }} times.</button>`,
};
</script>
<div id="child">
<button @click="count++">You clicked me {{ count }} times.</button>
</div>
使用组件
- 在
单文件组件中,推荐子组件使用PascalCase的标签名,以此来和原生的HTML元素作区分。虽然元素HTML标签名是不区分大小写的,但Vue单文件组件是可以在编译中区分大小写的。我们也可以使用/>来关闭一个标签。
- 如果是直接在
DOM中书写模板(例如原生<template>元素的内容),模板的编译需要遵从浏览器中HTML的解析行为。在这种情况下,应该需要使用kebab-case形式并显示地关闭这些组件的标签。
传递props
- Props是一种
特别的attributes,你可以在组件上声明注册。需要传递给博客文章组件一个标题,我们必须在组件的props列表上声明它。这里需要用到defineProps宏。
一个组件可以有任意多的props,默认情况下,所有的props都接受任意类型的值。
- 当一个
prop被注册后,可以像这样以自定义attribute的形式传递数据给它。
html
<!-- BlogPost.vue -->
<!--
defineProps是一个仅<script setup>中可用的编译宏命令,并不需要显式地导入。声明的props会自动暴露给模板。defineProps会返回一个对象。其中包含了可以传递给组件的所有props
-->
<script setup>
const props = defineProps(["title"]);
console.log(props.title);
</script>
<template>
<h2>{{ title }}</h2>
</template>
<!-- 如果没有使用<script setup>,props必须以props选项的方式声明,props对象会作为setup()函数的第一个参数被传入 -->
<script>
export default {
props: ["title"],
setup(props) {
console.log(props.title);
},
};
</script>
<!-- 当一个prop被注册后,可以像这样以自定义attribute的形式传递数据给它 -->
<BlogPost title="My Journey with Vue" />
<BlogPost title="Blogging with Vue" />
<BlogPost title="Why Vue is so fun" />
<!-- 在实际运用中,我们可能在父组件中会有如下的一个博客文章数组 -->
<script setup>
import { ref } from "vue";
const posts = ref([
{ id: 1, title: "My journey with Vue" },
{ id: 2, title: "Blogging with Vue" },
{ id: 3, title: "Why Vue is so fun" },
]);
</script>
<!-- 这种情况下,我们可以使用v-for来渲染它们 -->
<BlogPost v-for="post in posts" :key="post.id" :title="post.title" />
为组件Props标注类型
- 当使用
<script setup>时,defineProps宏函数支持从它的参数中推导类型
html
<!--
这被称之为“运行时声明”,因为传递给defineProps()的参数会作为运行时的props选项使用
-->
<script setup lang="ts">
const props = defineProps({
foo: { type: string, required: true },
bar: Number,
});
props.foo; /* string */
props.bar; /* number | undefined */
</script>
<!-- 然而,通过泛型参数来定义props的类型通常更直接 -->
<!--
这被称之为“基于类型的声明”。编译器会尽可能地尝试根据类型参数推导出等价的运行时选项。在这种场景下,我们第二个例子中编译出的运行时选项和第一个完全一致的。
基于类型的声明或者运行时声明可以择一使用,但是不能同时使用
-->
<script setup lang="ts">
const props = defineProps<{
foo: string;
bar?: number;
}>();
</script>
父子组件之间的通信
子组件需要通过调用内置的$emit方法,通过传入的事件名称来抛出一个事件。然后父组件可以通过v-on或@来选择性地监听子组件上抛的事件,就像监听原生DOM事件那样。
html
<!-- Child.vue -->
<script setup>
</script>
<template>
<button @click="$emit('increment', 1)">+1<button>
<button @click="#emit('decrement', -1)">-1<button>
</template>
<!-- 我们也可以通过defineEmits宏来声明需要抛出的事件 -->
<script setup>
const emit = defineEmits(["increment", "decrement"])
const incremenClicktEvent = () => {
emit('increment', 1)
}
const decrementClickEvent = () => {
emit('decrement', -1)
}
</script>
<template>
<button @click="incremenClicktEvent">+1</button>
<button @click="decrementClickEvent">-1</button>
</template>
<!-- 父组件监听子组件抛出来的事件 -->
<template>
<Child @increment='handleIncrement'
@decrement='handleDecrement("自己的参数", $event)'/>
</template>
<script setup>
import Child from "./Child.vue"
const handleIncrement = (amount) => { /* ... */ }
const handleDecrement = (x, y) => { /* ... */ }
</script>
<!-- 如果在没有使用<script setip>,可以通过emits选项定义组件会抛出的事件。你可以从setup()函数的第二个参数,即setup上下文对象访问到emit函数 -->
<script>
export default {
emits: ['enlarge-text'],
setup(props, ctx) {
ctx.emit('enlarge-text')
}
}
</script>
通过插槽来分配内容
- 一些情况下我们会希望能和
HTML元素一样向组件中传递内容,这可以通过Vue的自定义<slot>元素来实现。
html
<!-- AlertBox.vue> -->
<template>
<div class="alert-box">
<strong>This is an Error for Demo Purposes</strong>
<slot />
</div>
</template>
<!-- 父组件 -->
<AlertBox> Something bad happened. </AlertBox>
<!-- 最终渲染 -->
<div class="alert-box">
<strong>This is an Error for Demo Purposes</strong>
Something bad happened.
</div>
动态组件
- 有些场景会需要在
两个组件间来回切换,比如Tab界面,可以使用Vue的<component>元素和特殊的isattribute实现。
- 下面的例子中,被传入的
:is的值可以是以下几种:
html
<!-- currentTab 改变时组件也会改变 -->
<component :is="tabs[currentTab]"></component>
<!--
当使用<component :is="...">来在多个组件间作切换时,被切换掉的组件会被卸载。我们可以通过<KeepAlive>组件强制被切换掉的组件仍然保持“存活”的状态
-->
<KeepAlive>
<component :is="tabs[currentTab]" />
</KeepAlive>
元素位置限制
- 某些
HTML元素对于放在其中的元素类型有限制,例如<ul>、<ol>、<table>和<select>,相应的,某些元素仅在放置于特定元素中时才会显示,例如<li>,<tr>和<option>。
这将导致在使用带有此类限制元素的组件时出现问题,例如:
html
<!--
🧐 为什么需要 is Attribute?
这个问题核心在于 HTML 的解析限制,而不是 Vue 或 Vite 的限制
1. 🌐 HTML 解析限制(只在 DOM 环境出现)
HTML 有一套严格的父子元素结构要求。如果一个父元素只允许特定的子元素,而你尝试在里面使用一个 Vue 自定义组件标签,浏览器在解析 HTML 时会进行 自动修正(Correction),导致结构破坏。
-->
<table>
<blog-post-row></blog-post-row>
</table>
<!-- 自定义组件<blog-post-row>将作为无效的内容被忽略,因而在最终呈现的输出中造成错误。我们可以使用特殊的 is attribute作为一种解决方案 -->
<table>
<!-- 当使用原生HTML元素上时,id的值必须加上前缀vue: 才可以被解析为一个Vue组件。这一点是必要的,为了避免和原生的自定义内置元素相混淆 -->
<tr is="vue:blog-post-row"></tr>
</table>
14. 生命周期
- 每个
Vue组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到DOM,以及在数据改变时更新DOM。在此过程中,它也会运行被称之为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。
Vue巧妙地将时序(调用栈)和全局状态结合起来,确保了在权限范围内,生命周期钩子能在正确的时机注册到正确的实例上。
html
<!--
Vue在执行钩子函数的时候,Vue会自动将回调函数注册到当前正被初始化的组件上,这意味着这些钩子应当在组件初始化时被同步注册
-->
<script setup>
import { onMounted } from "vue";
/*
注意:这并不意味着对onMounted的调用必须放在setup()或<script setup>内的词法上下文中。
onMounted()也可以在一个外部函数中调用,只要调用栈是同步的,且最终起源自setup()就可以。
*/
setTimeout(() => {
onMounted(() => {
/* 异步注册当前组件实例已丢失 */
/* 这将不会正常工作 */
});
}, 1000);
</script>
生命周期图示

15. 组件注册
一个Vue组件在使用前需要先被"注册",这样Vue才能在渲染模板时找到其对应的实现。组件注册有两种方式:全局注册和局部注册。
全局注册
我们可以使用Vue应用实例的.component()方法,让组件在当前Vue应用中全局可用
javascript
import { createApp } from "vue"
const app = createApp({})
app.component(
{
/* 组件的名字 */
'MyComponent',
/* 组件的实现 */
{
/* ... */
}
}
)
- 全局注册的组件可以在此应用的任意组件的模板中使用。
局部注册
全局注册虽然很方便,但有以下几个问题:
相比之下,局部注册的组件需要在使用它的父组件中显式导入,并且只能在该父组件中使用。它的优点是使组件之间的依赖关系更加明确,并且对 tree-shaking 更加友好。
在使用 <script setup> 的单文件组件中,导入的组件可以直接在模板中使用,无需注册:
html
<script setup>
import ComponentA from "./ComponentA.vue";
</script>
<template>
<ComponentA />
</template>
- 请注意:
局部注册的组件在后代组件中不可用。在这个例子中,ComponentA 注册后仅在当前组件可用,而在任何的子组件或更深层的子组件中都不可用。
16. Props
一个组件需要显式声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传 attribute
vue
<script setup>
const props = defineProps(["foo"]);
console.log(props.foo);
</script>
- 注意传递给
defineProps() 的参数和提供给 props 选项的值是相同的,两种声明方式背后其实使用的都是 props 选项。
- 除了使用
字符串数组来声明 props 外,还可以使用对象的形式:
vue
/* 使用 <script setup> */
defineProps({
title: String,
likes: Number,
});
- 对于以对象形式声明的每个属性,
key 是 prop 的名称,而值则是该 prop 预期类型的构造函数。比如,如果要求一个 prop 的值是 number 类型,则可使用 Number 构造函数作为其声明的值。
- 如果你正在搭配
TypeScript 使用 <script setup>,也可以使用类型标注来声明 props:
vue
<script setup lang="ts">
defineProps<{
title?: string;
likes?: number;
}>();
</script>
响应式 Props 解构
Vue 的响应系统基于属性访问跟踪状态的使用情况。例如,在计算属性或侦听器中访问 props.foo 时,foo 属性将被跟踪为依赖项。
因此,在以下代码的情况下:
vue
const { foo } = defineProps(["foo"]);
watchEffect(() => {
// 在 3.5 之前只运行一次
// 在 3.5+ 中在 "foo" prop 变化时重新执行
console.log(foo);
});
在 3.4及以下版本,foo 是一个实际的常量,永远不会改变。在 3.5 及以上版本,当在同一个 <script setup> 代码块中访问由 defineProps 解构的变量时,Vue 编译器会自动在前面添加 props.。因此,上面的代码等同于以下代码:
vue
const props = defineProps(["foo"]);
watchEffect(() => {
// `foo` 由编译器转换为 `props.foo`
console.log(props.foo);
});
- 此外,你可以使用 JavaScript 原生的默认值语法声明 props 默认值。这在使用基于类型的 props 声明时特别有用。
vue
const { foo = "hello" } = defineProps<{ foo?: string }>();
单向数据流
所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。
- 更改一个
prop 的需求通常来源于以下两种场景:
vue
/* 1. prop 被用于传入初始值;而子组件想在之后将其作为一个局部数据属性。在这种情况下,最好是新定义一个局部数据属性,从 props 上获取初始值即可: */
const props = defineProps(["initialCounter"]);
// 计数器只是将 props.initialCounter 作为初始值
// 像下面这样做就使 prop 和后续更新无关了
const counter = ref(props.initialCounter);
/* 2. 需要对传入的 prop 值做进一步的转换。在这种情况中,最好是基于该 prop 值定义一个计算属性 */
const props = defineProps(["size"]);
// 该 prop 变更时计算属性也会自动更新
const normalizedSize = computed(() => props.size.trim().toLowerCase());
- 在
最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件来通知父组件做出改变。
Prop 校验
Vue 组件可以更细致地声明对传入的 props 的校验要求。比如我们上面已经看到过的类型声明,如果传入的值不满足类型要求,Vue 会在浏览器控制台中抛出警告来提醒使用者。这在开发给其他开发者使用的组件时非常有用。
- 要声明对 props 的校验,你可以向
defineProps() 宏提供一个带有 props 校验选项的对象,例如:
javascript
defineProps({
// 基础类型检查
// (给出 `null` 和 `undefined` 值则会跳过任何类型检查)
propA: Number,
// 多种可能的类型
propB: [String, Number],
// 必传,且为 String 类型
propC: {
type: String,
required: true,
},
// 必传但可为 null 的字符串
propD: {
type: [String, null],
required: true,
},
// Number 类型的默认值
propE: {
type: Number,
default: 100,
},
// 对象类型的默认值
propF: {
type: Object,
// 对象或数组的默认值
// 必须从一个工厂函数返回。
// 该函数接收组件所接收到的原始 prop 作为参数。
default(rawProps) {
return { message: "hello" };
},
},
// 自定义类型校验函数
// 在 3.4+ 中完整的 props 作为第二个参数传入
propG: {
validator(value, props) {
// The value must match one of these strings
return ["success", "warning", "danger"].includes(value);
},
},
// 函数类型的默认值
propH: {
type: Function,
// 不像对象或数组的默认,这不是一个
// 工厂函数。这会是一个用来作为默认值的函数
default() {
return "Default function";
},
},
});
defineProps()宏中的参数不可以访问 <script setup> 中定义的其他变量,因为在编译时整个表达式都会被移到外部的函数中。
一些补充细节:
-
所有 prop 默认都是可选的,除非声明了 required: true。
-
除 Boolean 外的未传递的可选 prop 将会有一个默认值 undefined。
-
Boolean 类型的未传递 prop 将被转换为 false。这可以通过为它设置 default 来更改——例如:设置为 default: undefined 将与非布尔类型的 prop 的行为保持一致。
-
如果声明了 default 值,那么在 prop 的值被解析为 undefined 时,无论 prop 是未被传递还是显式指明的 undefined,都会改为 default 值。
-
当 prop 的校验失败后,Vue 会抛出一个控制台警告 (在开发模式下)。
-
如果使用了基于类型的 prop 声明 ,Vue 会尽最大努力在运行时按照 prop 的类型标注进行编译。举例来说,defineProps<{ msg: string }> 会被编译为 { msg: { type: String, required: true }}。
另外,type 也可以是自定义的类或构造函数,Vue 将会通过 instanceof 来检查类型是否匹配。例如下面这个类:
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}
javascript
defineProps({
author: Person,
});
- Vue 会通过
instanceof Person 来校验 author prop 的值是否是 Person 类的一个实例。
可为 null 的类型
如果该类型是必传但可为 null 的,你可以用一个包含 null 的数组语法:
javascript
defineProps({
id: {
type: [String, null],
required: true,
},
});
- 注意如果
type 仅为 null 而非使用数组语法,它将允许任何类型。
Boolean 类型转换
为了更贴近原生 boolean attributes 的行为,声明为 Boolean 类型的 props 有特别的类型转换规则。以带有如下声明的 <MyComponent> 组件为例:
defineProps({
disabled: Boolean,
});
该组件可以被这样使用:
vue
<!-- 等同于传入 :disabled="true" -->
<MyComponent disabled />
<!-- 等同于传入 :disabled="false" -->
<MyComponent />
当一个 prop 被声明为允许多种类型时,Boolean 的转换规则也将被应用。然而,当同时允许 String 和 Boolean 时,有一种边缘情况——只有当 Boolean 出现在 String 之前时,Boolean 转换规则才适用:
javascript
// disabled 将被转换为 true
defineProps({
disabled: [Boolean, Number],
});
// disabled 将被转换为 true
defineProps({
disabled: [Boolean, String],
});
// disabled 将被转换为 true
defineProps({
disabled: [Number, Boolean],
});
// disabled 将被解析为空字符串 (disabled="")
defineProps({
disabled: [String, Boolean],
});
17. 事件
触发与监听事件
- 在组件的模板表达式中,可以直接使用 $emit 方法触发自定义事件 (例如:在 v-on 的处理函数中):
Vue
<!-- MyComponent -->
<button @click="$emit('someEvent')">Click me</button>
<!-- 父组件可以通过v-on(缩写为@)来监听事件 -->
<MyComponent @some-event="callback" />
<!-- 同样,组件的事件监听器也支持.once修饰符 -->
<MyComponent @some-event.once="callback" />
- 像
组件与prop一样,事件的名字也提供了自动的格式转换。
- 注意
这里我们触发了一个以camelCase形式命名的事件,但在父组件中可以使用kebab-case形式来监听。与prop大小写格式一样,在模板中我们也推荐使用kebab-case形式来编写监听器。
注意
和原生DOM事件不一样,组件触发的事件没有冒泡机制。你只能监听子组件触发的事件。平级组件或是跨越多层嵌套的组件间通信,应使用一个外部的事件总线,或是使用一个全局状态管理方案。
事件参数
- 有时候
我们会需要在触发事件时附带一个特定的值。举例来说,我们想要<BlogPost>组件来管理文本会缩放得多大。在这个场景下,我们可以给$emit提供一个额外得参数。
Vue
<button @click="$emit('increaseBy', 1)">Increase by 1</button>
- 然后再父组件的监听事件中,我们可以先简单写一个内联的箭头函数作为监听器,此函数会接受到事件附带的参数:
Vue
<MyButton @increase-by="(n) => count += n" />
Vue
<MyButton @increase-by="increaseCount" />
Vue
function increaseCount(n) {
count.value += n;
}
Tip
所有传入$emit()的额外参数都会被直接传向监听器。举例来说,$emit('foo', 1, 2, 3)触发后,监听器函数将会收到这三个参数值。
声明触发的事件
- 组件可以显示地通过
defineEmits()宏来声明它要触发的事件:
Vue
<script setup>
defineEmits(["inFocus", "submit"]);
</script>
我们在<template>中使用的$emit方法不能在组件的<script setup>部分中使用,但defineEmits()会返回一个相同作用的函数供我们使用:
Vue
<script setup>
const emit = defineEmits(["inFocus", "submit"]);
function buttonClick() {
emit("submit", 1, 2, 3);
}
</script>
defineEmits()宏不能在子函数中使用。如上所示,它必须放置在<script setup>的顶层作用域下。
为组件的 emits 标注类型
Vue
<script setup lang="ts">
// 运行时
const emit = defineEmits(["change", "update"]);
// 基于选项
const emit = defineEmits({
change: (id: number) => {
// 返回 `true` 或 `false`
// 表明验证通过或失败
},
update: (value: string) => {
// 返回 `true` 或 `false`
// 表明验证通过或失败
},
});
// 基于类型
/* 这是 TypeScript 的 调用签名(Call Signatures) 语法 */
const emit = defineEmits<{
(e: "change", id: number): void;
(e: "update", value: string): void;
}>();
// 3.3+: 可选的、更简洁的语法
const emit = defineEmits<{
change: [id: number];
update: [value: string];
}>();
</script>
- 尽管
事件声明是可选的,我们还是推荐你完整地声明所有要触发的事件,以此在代码中作为文档记录组件的用法。同时,事件声明能让Vue更好地将事件和透传attribute做出区分,从而避免一些由第三方代码触发的自定义DOM事件所导致的边界情况。
Tip
如果一个原生事件的名字 (例如 click) 被定义在 emits 选项中,则监听器只会监听组件触发的 click 事件而不会再响应原生的 click 事件。
事件校验
Vue
<script setup>
const emit = defineEmits({
// 没有校验
click: null,
// 校验 submit 事件
submit: ({ email, password }) => {
if (email && password) {
return true;
} else {
console.warn("Invalid submit event payload!");
return false;
}
},
});
function submitForm(email, password) {
emit("submit", { email, password });
}
</script>