Vue3
1. 开始
1.1 简介
1.1.1 什么是 Vue?
Vue 是一款用于构建用户界面的 JavaScript 框架。它基于标准 HTML、CSS 和 JavaScript 构建的渐进式框架。
Vue 有两个核心功能:
- 声明式渲染:Vue 基于标准 HTML 拓展了一套自己的模板语法,使得我们可以声明式地描述最终输出的 HTML 和 JavaScript 状态之间的关系
- 响应式:Vue 会自动追踪 JavaScript 状态,并在其发生变化时响应式地更新 DOM。
1.1.2 渐进式框架
Vue3 是一个框架,也是一个生态,具有“可以被逐步集成”这个特点(比如路由和状态管理,用到时再引入 Vue 中集成)。
- 无需构建步骤,渐进式增强静态的 HTML
- 在任何页面中作为 Web Component 嵌入
- 单页应用(SPA)
- 全栈/服务端渲染(SSR)
- Jamstack / 静态站点生成(SSG)
- 开发桌面端、移动端、WebGL、甚至是命令行终端的界面
1.1.3 单文件组件
单文件组件也被称为 *.vue 文件,英文 Single-File Components,缩写为 SFC。它把一个组件的模板 HTML ,逻辑 JavaScript 和样式 CSS 封装在同一个文件里。
1.1.4 API 风格
-
选项式 API(OPtions API)
其中的选项就是例如data
、methods
、computed
等,可以用包含这些选项的对象来描述组件逻辑,Vue2 使用的是选项式 API。<script> export default { data() { return { count: 0 }; }, methods: { increment() { this.count++; }, }, mounted() { console.log(`The initial count is ${this.count}.`); }, }; </script>
-
组合式 API(Composition API)
在单文件组建中,组合式 API 通常会与<script setup>
搭配使用。这个setup
attribute 是一个标识,告诉 Vue 需要在编译时进行一些处理,让我们可以更简洁地使用组合式 API。
<script setup>
中的导入和顶层变量/函数都能够在模板中直接使用。<script setup> import { ref, onMounted } from 'vue'; const count = ref(0); function increment() { count.value++; } onMounted(() => { console.log(`The initial count is ${count.value}`); }); </script> <template> <button @click="increment"></button> </template>
在生产项目中:
- 不用构建工具,或者主要在低复杂度的场景中使用 Vue,例如渐进增强的应用场景,推荐采用选项式 API。
- 打算用 Vue 构建完整的单页应用,推荐采用组合式 API + 单文件组件。
1.2 快速上手
1.2.1 创建一个 Vue 应用
-
使用 npm/yarn 创建
# npm 创建 npm create vue@latest # yarn 创建 yarn create vue@latest
-
安装并启动开发服务器
cd <your-project-name> yarn install yarn run dev
1.2.2 通过 CDN 使用 Vue
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
通过 CDN 使用 Vue 时,不涉及“构建步骤”,这使得设置更为简单,并且可以用于增强静态的 HTML 或与后端框架集成。但是没法使用单文件组件语法。
-
使用全局构建版本
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <div id="app">{{ message }}</div> <script> const { createApp, ref } = Vue; createApp({ setup() { const message = ref('Hello Vue!'); return { message }; }, }).mount('#app'); </script>
-
使用 ES 模块构建版本
现代浏览器大多都已原生支持 ES 模块,因此我们可以像这样通过 CDN 以及原生 ES 模块使用 Vue:<div id="app">{{ message }}</div> <script type="module"> import { createApp, ref, } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js'; createApp({ setup() { const message = ref('Hello Vue!'); }, }); </script>
-
启用 Import maps
在上面的示例中,我们使用了完整的 CDN URL 来导入,但在文档的其余部分中,你将看到如下代码:import { createApp } from 'vue';
告诉浏览器如何定位导入的 vue:
<script type="importmap"> { "imports": { "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js" } } </script>
-
拆分模块
将代码分割成单独的 JavaScript 文件,以便更容易管理。例如:<!-- index.html --> <div id="app"></div> <script type="module"> import { createApp } from 'vue'; import MyComponent from './my-component.js'; createApp(MyComponent).mount('#app'); </script>
// my-component.js import { ref } from 'vue'; export default { setup() { const count = ref(0); return { count }; }, template: `<div>count is {{ count }}</div>`, };
2. 基础
2.1 创建一个应用
2.2.1 应用实例
每个 Vue 应用都是通过 createApp
函数创建一个新的应用实例:
import { createApp } from 'vue';
const app = createApp({
/** 根组件选项 */
});
2.2.2 根组件
如果使用的是单文件组件,我们可以直接从另一个文件中导入根组件。
import { createApp } from 'vue';
import App from './App.vue';
const app = createApp(App);
2.2.3 挂载应用
<div id="app"></div>
app.mount('#app');
2.2.4 应用配置
应用实例会暴露一个 .config
对象,可以配置一些应用级的选项,例如定义个应用级的错误处理器,来捕获所有子组件的错误。
app.config.errorHandler(err) => {
/** 处理错误 */
}
应用实例还提供了一些方法来注册应用范围内可用资源,如注册一个组件:
app.component('my-component', MyComponent);
2.2 模板语法
Vue 使用一种基于 HTML 的模板语法,可以声明式地将组件实例的数据绑定到呈现的 DOM 上。
在底层机制中,Vue 会将模板编译成高度优化的 JavaScript 代码。结合响应式系统,当应用的数据状态变更时,Vue 会智能的更新 DOM,使得需要重新渲染的组件的数量最少,并最小化 DOM 操作。
2.2.1 文本插值
这是最基本的数据绑定格式,使用“Mustache”语法——双大括号。
<span>{{msg}}</span>
2.2.2 原始 HTML
在模板中,如果要显示原始 HTML,需要使用v-html
指令:
<div v-html="html"></div>
安全警告
在王网站中动态渲染 HTML 是很危险的,因为这个非常容易造成 XSS 漏洞,使用 v-html 时要进行 xss 过滤
2.2.3 Attribute 绑定
双大括号不能在 HTML attribute 中使用,要想响应式地绑定一个 attribute,应该使用 v-bind 指令:
<div v-bind:id="id"></div>
如果绑定的值是 null 或者 undefined,那么这个 attribute 将从渲染的元素上移除。
-
简写
<div :id="id"></div>
-
同名简写(3.4+)
<div :id></div> <div v-bind:id></div>
-
布尔型 Attribute 绑定
<button v-bind:disabled="disabled"></button>
当 disabled 的值为真值或者空字符串(disabled=“”)时,元素会包含这个值。
-
动态绑定多个值
<div v-bind="objectAttrs"></div>
<script>
const objectAttrs = {
id: 'container',
class: 'wrapper',
}
</script>
2.2.4 JavaScript 表达式
指的是能够被求值的 JavaScript 代码,它是可以放到 return 后面的。
-
仅支持表达式
<div>{{ num + 1 }}</div> <div>{{ ok ? 'Yes' : 'No' }}</div> <div>{{ message.split(' ').join('-') }}</div> <div :id="`list-${id}`"></div>
-
调用函数
<div>{{ capitalize(message) }}</div>
TIP
表达式中的函数在组件每次更新时会被重新调用,所以该函数不能产生任何副作用,不能改变数据或者触发异步操作。
-
受限的全局访问
模板中的表达式是沙盒化的,只能访问这些全局对象import { makeMap } from './makeMap'; const GLOBALS_ALLOWED = 'Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,' + 'decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,' + 'Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error'; export const isGloballyAllowed = /*#__PURE__*/ makeMap(GLOBALS_ALLOWED); /** @deprecated use `isGloballyAllowed` instead */ export const isGloballyWhitelisted = isGloballyAllowed;
要使用其他的全局变量,可以在
app.config.globalProperties
上显示地添加它们。
2.2.5 指令 Directives
<div v-show="show">Hello</div>
- 参数 Arguments
一些指令后面会跟着参数,如 v-bind 指令,下面的例子中href
就是一个参数
<a v-bind:href="href"></a>
-
动态参数
指令的参数也可以是一个 JavaScript 表达式,需要包含在一对方括号里面。<div v-bind:[key]="value"></div>
动态参数值的限制:动态参数中表达式的值应当是一个字符串,或者是 null 。特殊值 null 意为显式移除该绑定。
动态参数语法限制:动态参数表达式因为某些字符的缘故有一些语法限制,如空格和引号,在 HTML Attribute 名称中是不合法的。如果需要传入一个复杂的动态参数,建议使用
计算属性
。 -
修饰符 Modifiers
修饰符是以点开头的特殊后缀,表明指令需要一些特殊的方式被绑定。例如.prevent
修饰符,会告知 v-on 指令对触发的事件调用event.preventDefault()
:<form @submit.prevent="onSubmit"></form>
2.3 响应式基础
2.3.1 声明响应式状态
-
ref()
在组合式 API 中,推荐使用ref()
函数来声明响应式状态:import { ref } from 'vue'; const count = ref(0);
ref()
接收参数,并将其包裹在一个带有.value
属性的 ref 对象中返回。const count = ref(0); console.log(count); // { value: 0 } console.log(count.value); // 0 count.value++; console.log(count.value); // 1
要在组件模板中访问 ref,请从组建的 setup() 函数中声明并返回它们:
<div>{{ count }}</div> <script> import { ref } from 'vue'; export default { setup() { const count = ref(0); return { count }; }, }; </script>
注意
在模板中使用 ref 时,我们不需要加.value
。在模板中使用时,ref 会自动解包。<button @click="count++">{{ count }}</button>
-
<script setup>
在setup()
函数中手动暴露大量的状态和方法非常繁琐。可以使用 SFC 来避免这种情况,使用<script setup>
来大幅度简化代码:<script setup> import { ref } from 'vue'; const count = ref(0); function increment() { count.value++; } </script> <template> <button @click="increment">{{ count }}</button> </template>
-
为什么要使用 ref ?
在组合式 API 中,不再是将响应式数据放到data
里,而是借助ref
生成了一个响应式的对象,对象中的 value 变化同样会触发视图更新,但是要比选项式data
响应式数据灵活,它可以做到跨 SFC 访问,多个 SFC 可以访问同一个ref
对象,还能进行代码复用【一处定义,多处使用】。
ref
的伪代码实现:const myRef = { _value: 0, get value() { track(); return this._value; }, set value(newValue) { this._value = newValue; trigger(); }, };
-
深层响应性
Ref
会使得它的值具有深层响应性,即使改变嵌套对象或者数组是,变化也会被检测到。import { ref } from 'vue'; const obj = ref({ nested: { count: 0 }, arr: ['foo', 'bar'], }); function mutateDeeply() { obj.value.nested.count++; boj.value.arr.push('baz'); }
-
DOM 更新时机
DOM 更新不是同步的,Vue 会在“next tick”更新周期中缓冲所有状态的修改,确保不管进行了多少次状态修改,每个组件都只会更新一次。
import { nextTick } from 'vue';
async function increment() {
count.value++;
await nextTick();
// 现在 DOM 已经更新了
}
2.3.2 reactive()
reactive()
将使对象本身具有响应性:
import { reactive } from 'vue';
const state = reactive({ count: 0 });
在模板中使用:
<button @click="state.count++">{{ state.count }}</button>
-
Reactive Proxy vs. Original
reactive()
返回的是一个原始对象的 Proxy,它和原始对象不对等。const raw = {}; const proxy = reactive(raw); console.log(proxy === raw); // false
为了保证访问代理的一致性,对同一个原始对象调用
reactive()
会总是返回同样的代理对象,而对一个已存在的代理对象调用reactive()
会返回其本身:console.log(reactive(raw) === proxy); // true console.log(reactive(proxy) === proxy); // true
响应式对象的嵌套对象依然是代理:
const proxy = reactive({}); const raw = {}; // 嵌套对象也变成了代理,代理对象和原始对象不等。 proxy.nested = raw; console.log(proxy.nested === raw); // false
-
reactive()
局限性- 有限的值类型:他只能用于对象类型(对象、数组和如
Map
、Set
这样的集合类型)。它不能持有如string
、number
或者boolean
这样的原始类型。 - 不能替换整个对象:
let state = reactive({ count: 0 }); // 上面的({count:0})引用将不再被追踪。 // (响应性连接已丢失) state = reactive({ count: 1 });
- 对解构操作不友好:当我们将响应式对象的
原始类型
属性结构为本地变量时,或者将该属性传递给函数时,我们将丢失响应式连接:
const state = reactive({ count: 0 }); let { count } = state; count++; callSomeFunction(state.count);
由于这些限制,建议使用
ref()
作为声明响应式状态的主要 API。 - 有限的值类型:他只能用于对象类型(对象、数组和如
2.3.3 额外的 ref 解包细节
-
作为 reactive 对象的属性
一个 ref 会在作为响应式对象的属性被访问或修改时自动解包。它的行为像是一个普通的属性:const count = ref(0); const state = reactive({ count }); console.log(state.count); // 0 state.count = 1; console.log(count.value); // 1
-
数组和集合的注意事项
当
ref
作为响应式数组或原生集合类型(如 Map)中的元素被访问时,它不会被解包:const books = reactive([ref('Vue 3 Guide')]); // 这里需要 .value console.log(books[0].value); const map = reactive(new Map([['count', ref(0)]])); // 这里需要 .value console.log(map.get('count').value);
2.4 计算属性
计算属性是响应式属性,当它的依赖项发生变化时,它也会发生变化,常用于将模板中复杂的逻辑判断抽取到计算属性中。
2.4.1 基础示例
<script setup>
import { reactive, computed } from 'vue';
const author = reactive({
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery',
],
});
// 一个计算属性 ref
const publishedBooksMessage = computed(() => {
return author.books.length > 0 ? 'Yes' : 'No';
});
</script>
<template>
<p>Has published books:</p>
<span>{{ publishedBooksMessage }}</span>
</template>
2.4.2 计算属性缓存 vs 方法
计算属性只在依赖更新时,它的值才会发生变化,是带有缓存性质的,而模板中的方法总会在渲染时重新调用。
2.4.3 可写计算属性
计算属性默认是只读的。当你尝试修改一个计算属性时,会产生一个运行时警告。只在某些特殊场景中你可能才需要用到“可写”属性,可以通过同时提供 getter 和 setter 来创建:
<script setup>
import { ref, computed } from 'vue';
const firstName = ref('John');
const lastName = ref('Doe');
const fullName = computed({
// getter
get() {
return firstName.value + ' ' + lastName.value;
},
// setter
set(newValue) {
// 注意:我们这里使用的是解构赋值语法
[firstName.value, lastName.value] = newValue.split(' ');
},
});
</script>
2.4.4 最佳实践
- Getter 不应有副作用
不要改变其他状态,在 getter 中做异步请求或者更改 DOM。 - 避免直接修改计算属性值
从计算属性返回的值是派生状态,可以把它看做是一个“临时快照”,更改快照没有实际意义。
2.5 类与样式绑定
2.5.1 绑定 HTML class
- 绑定对象
可以给:class 传递一个对象动态切换 class:
<div :class="{ active: isActive }"></div>
- 绑定数组
<div :class="[activeClass, errorClass]"></div>
<script setup>
import { ref } from 'vue';
const activeClass = ref('active');
const errorClass = ref('text-danger');
</script>
- 在组件上使用
对于只有一个根元素的组件,使用class
attribute 时,这些 class 会添加到根元素上并与该元素上已有的 class 合并。
<!-- 子组件模板 -->
<p class="foo bar">Hi!</p>
在使用时添加一些 class
<!-- 在使用组件时 -->
<MyComponent class="baz boo" />
渲染出的 HTML 为:
<p class="foo bar baz boo">Hi!</p>
Class 的绑定也是同样的:
<MyComponent :class="{ active: isActive }" />
当 isActive 为真时,被渲染的 HTML 会是:
<p class="foo bar active">Hi!</p>
如果组件有多个根元素,你需要指定哪个根元素来接收这个 class。可以通过组件的$attrs 属性来实现指定:
<!-- MyComponent 模板使用 $attrs 时 -->
<p :class="$attrs.class">Hi!</p>
<span>This is a child component</span>
<MyComponent class="baz" />
这会被渲染为:
<p class="baz">Hi!</p>
<span>This is a child component</span>
2.5.2 绑定内联样式
- 绑定对象
<script setup>
const activeColor = ref('red');
const fontSize = ref(30);
</script>
<div :style="{ color: activeColor, fontSize: fontSize + 'px' }"></div>
-
绑定数组
可以给:style
绑定一个包含多个样式对象的数组。这些对象会被合并后渲染到同一元素上:<div :style="[baseStyles, overrideStyles]"></div>
-
自动前缀
Vue 在运行时检查该属性是否支持在当前浏览器中使用,如果浏览器不支持某个属性,那么将尝试加上各个浏览器特殊前缀。 -
样式多值
可以对一个样式属性提供多个(不同前缀的)值:<div :style="{ display: ['-webkit-box', '-ms-flexbox', 'flex'] }"></div>
数组仅会渲染浏览器支持的最后一个值,在支持不需要特别前缀的浏览器中都会渲染为 display: flex。
2.6 条件渲染
-
v-if
<h1 v-if="awesome">Vue is awesome!</h1>
-
v-else
<button @click="awesome = !awesome">Toggle</button> <h1 v-if="awesome">Vue is awesome!</h1> <h1 v-else>Oh no 😢</h1>
-
v-else-if
<div v-if="type === 'A'">A</div> <div v-else-if="type === 'B'">B</div> <div v-else-if="type === 'C'">C</div> <div v-else>Not A/B/C</div>
-
<template>
上的v-if
<template v-if="ok"> <h1>Title</h1> <p>Paragraph 1</p> <p>Paragraph 2</p> </template>
-
v-show
切换元素名为display
的 css 属性,值为none
或block
。<h1 v-show="ok">Hello!</h1>
-
v-if
vsv-show
v-if
为 false 时,元素不会被渲染,v-show
为 false 时,元素会被渲染,但不会显示。如果需要频繁切换元素的显示与隐藏,
v-show
会比v-if
更高效,因为v-show
不会重新创建元素。 -
v-if
和v-for
警告同时使用
v-for
和v-if
是不推荐的,二者优先级不明显。当
v-if
和v-for
同时在一个元素上的时候,v-if
会首先被执行。
2.7 列表渲染
-
v-for
<template> <li v-for="item in items">{{ item.message }}</li> </template> <script setup> const items = ref([{ message: 'Foo' }, { message: 'Bar' }]); </script>
在
v-for
的变量别名中使用解构,和解构函数参数类似:<li v-for="{message} in items">{{ message }}</li> <!-- 带索引时 --> <li v-for="({message}, index) in items">{{ message }}</li>
也可以用
of
作为分隔符代替in
:<div v-for="item of items">{{ item.message }}</div>
-
v-for
与对象
可以用v-for
来遍历一个对象的所有属性,便利的顺序会基于该对象调用Object.keys()
的返回值来决定。<li v-for="(value, key, index) in object"> {{index}}. {{ key }}: {{ value }} </li>
-
在
v-for
里使用范围值<span v-for="n in 10">{{ n }}</span>
-
<template>
上的v-for
在<template>
标签上使用v-for
来渲染一个包含多个元素的块。<ul> <template v-for="item in items"> <li>{{ item.msg }}</li> <!-- 分割线 --> <li class="divider" role="presentation"></li> </template> </ul>
-
v-for
与v-if
当它们同时存在于一个节点上时,v-if
比v-for
优先级更高。这意味着v-if
无法访问到v-for
作用域内定义的变量别名。<!-- 会抛出一个错误,因为属性todo此时没有在该实例上定义 --> <li v-for="item in items" v-if="item.visible">{{ item.name}}</li>
在外面新包装一层
<template>
再在其上使用v-for
可以解决这个问题:<template v-for="item in items"> <li v-if="item.visible">{{ item.name }}</li> </template>
-
使用 key 管理状态
Vue 默认按照“就地更新”的策略来更新通过v-for
渲染的元素列表。当数据项的顺序改变时,Vue 不会随之移动 DOM 元素的顺序,而是就地地更新每个元素,确保他们在原本指定的索引位置上渲染。默认模式是高效的,但只适用于列表渲染输出结果不依赖于子组件状态或者临时 DOM 状态(例如表单输入值)的情况。
为了给 Vue 一个提示,一边跟踪每个节点的标识,从而重用和重新排序现有元素,需要为每个元素提供一个唯一的
key
属性。<div v-for="(item, index) in items" :key="item.id"></div>
当使用
<template v-for>
时,key
应该被放置在这个<template>
容器上:<template v-for="todo in todos" :key="todo.name"> <li>{{dodo.name}}</li> </template>
key 绑定的值期望是一个基础类型的值,例如字符串或者 number 类型,不要用对象作为
v-for
的 key。 -
数组变化侦测
变更方法,顾名思义,就是会对调用他们的数组进行变更。
Vue 能够侦听响应式数组的变更方法,这些变更方法包括:push()
pop()
shift()
unshift()
splice()
sort()
reverse()
-
替换一个数组
变更方法,顾名思义,就是会对调它们的原数组进行变更。相对的,也有一些不可变的方法,例如filter()
,concat()
和slice()
,这些都不会更改原数组,总是返回一个新数组。items.value = items.value.filter(item => item.message.match(/Foo/));
替换数组并不一会重新渲染整个列表,Vue 实现了一些巧妙地方法来最大化 DOM 元素的重用,因此用另一个包含部分重叠对象的数组来做替换,仍会是一种高效操作。
-
展示过滤或排序后的结果
<template> <li v-for="number in evenNumbers">{{ number }}</li> </template> <script setup> const numbers = ref([1, 2, 3, 4, 5]); const evenNumbers = computed(() => numbers.value.filter(number => number % 2 === 0) ); </script>
注意
在计算属性中使用
reverse()
和sort()
的时候务必小心!这两个方法将变更原始数组,计算函数不应该这么做。
2.8 事件处理
- 内联事件处理器:事件被触发时执行内联 JavaScript 语句。
- 方法事件处理器:一个指向组件上定义的方法的属性名或是路径。
-
内联事件处理器
所谓“内联”,通俗来讲就是将代码写在 html 元素的属性里面,而不是 js 里,比如“内联样式”和“内联事件”,这种只适用于代码复杂度不高的场景。<script setup> const count = ref(0); </script> <template> <button @click="count++">Add 1</button> <p>Count is: {{ count }}</p> </template>
-
方法事件处理器
随着代码逻辑的增加,内联的方式使代码显得有些臃肿和不够灵活,所以 v-on:eventName 或@eventName 可以绑定一个方法或者对某个方法的调用。
举例来说:<button @click="greet">Greet</button> <script setup> const name = ref('Vue.js'); function greet(event) { alert(`Hello ${name.value}!`); // `event` 是DOM原生事件 if (event) { alert(event.target.tagName); } } </script>
- 方法与内联事件的判断
模板编译器会通过检查v-on
的值是否是合法的 JavaScript 标识符或属性访问路径来断定是何种形式的事件处理器。举例来讲,foo
、foo.bar
和foo['bar']
会被视为方法事件处理器,而foo()
和count++
会被视为内联事件处理器。
- 方法与内联事件的判断
-
事件修饰符
Vue 为v-on
提供了事件修饰符。修饰符是用.
表示的指令后缀,包含以下这些:.stop
:阻止事件冒泡.prevent
:阻止默认行为.capture
:使用捕获模式监听事件.self
:只对事件代理有效,即只对v-on
修饰的元素有效,表示只对绑定元素自身触发的事件有效.once
:只触发一次.passive
:passive 会告诉浏览器不阻止事件的默认行为(即使调用了 preventDefault 也不会生效),可以用于改善滚屏性能,如果监听了 window 的 scroll 或者 touchmove 事件,应该把 passive 设置为 true,这样滚动就会流畅多了。
修饰符也可以链式书写:elem.addEventListener('touchmove', () => {}, { passive: true });
<form @submit.stop.prevent="onSubmit"></form>
-
按键修饰符
<input @keyup.enter="onEnter" />
可以直接使用
KeyboardEvent.key
暴露的按键名称作为修饰符,但需要转为 kebab-case 形式。<input @keyup.page-down="onPageDown" />
-
按键别名
.enter
.space
.tab
.esc
.up
.down
.left
.right
.delete
(捕获“Delete”和“Backspace”两个按键)
-
系统按键修饰符(通常与其他按键修饰符组合使用)
.ctrl
.shift
.alt
.meta
举例来说:
<!-- Alt + Enter --> <input @keyup.alt.enter="clear" /> <!-- Ctrl + 点击 --> <div @click.ctrl="onClick" />
-
.exact
修饰符
exact 意为准确的,使用该修饰符后只有按下指定的组合键才会触发事件,按下了其他的键不会触发事件。<!-- 当按下 Ctrl 时,即使同时按下 Alt 或 Shift 也会触发 --> <button @click.ctrl="onClick">Ctrl + 点击</button> <!-- 当按下 Ctrl 时,即使同时按下 Alt 或 Shift 也不会触发 --> <button @click.ctrl.exact="onClick">Ctrl + 点击</button> <!-- 仅当没有按下任何系统按键时触发 --> <button @click.exact="onClick">点击</button"
-
-
鼠标按键修饰符
.left
:左键.middle
:中键.right
:右键
2.9 表单输入绑定
前端处理表单时,常常需要将表单输入框的内容同步给 JavaScript 中相应的变量。手动连接值绑定和更改事件监听器会很麻烦:
<input :value="text" @input="text = $event.target.value" />
v-model
指令帮我们简化了这一步骤:
<input v-model="text" />
- 文本类型的
<input>
和<textarea>
元素会绑定value
property 并侦听input
事件; <input type="checkbox">
和<input type="radio">
元素会绑定checked
property 并侦听change
事件;<select>
元素会绑定value
property 并侦听change
事件;
2.9.1 基本用法
捡特殊的记录一下:
-
复选框
将多个复选框绑定到同一个数组或集合的值:<template> <input type="checkbox" id="jack" value="Jack" v-model="selected" /> <label for="jack">Java</label> <input type="checkbox" id="john" value="John" v-model="selected" /> <label for="jack">Java</label> <input type="checkbox" id="mike" value="Mike" v-model="selected" /> <label for="jack">Java</label> </template> <script setup> const selected = ref([]); </script>
-
单选按钮
<template> <div>Picked: {{ picked }}</div> <input type="radio" id="one" value="One" v-model="picked" /> <label for="one">One</label> <input type="radio" id="two" value="Two" v-model="picked" /> <label for="two">Two</label> </template> <script setup> const picked = ref('One'); </script>
2.9.2 值绑定
对于单选按钮,复选框和选择器选项,v-model
绑定的值通常是静态的字符串(或者复选框是布尔值):
<template>
<!-- 字符串 -->
<input type="radio" v-model="picked" value="a" />
<!-- 布尔值 -->
<input type="checkbox" v-model="checked" />
<!-- 第一项被选中时,是字符串 -->
<select v-model="selected">
<option value="abc">ABC</option>
</select>
<input />
</template>
-
复选框
<input type="checkbox" v-model="checked" true-value="yes" false-value="no" />
true-value
和false-value
是 Vue 特有的 attributes,仅支持和v-model
配套使用。这里toggle
属性的值会在选中时被设为yes
,取消选择是设为“no”。同样也可以通过v-bind
为其绑定为其他动态值:<input type="checkbox" v-model="toggle" :true-value="dynamicTrueValue" :false-value="dynamicFalseValue" />
2.9.3 修饰符
.lazy
默认情况下,v-model
在input
事件触发后更新。如果添加.lazy
修饰符,v-model
将在change
事件触发后更新。<input v-model.lazy="msg" />
.number
如果添加.number
修饰符,v-model
将绑定值自动转为Number
。<input v-model.number="age" />
.trim
如果添加.trim
修饰符,v-model
将自动去除输入框内容的首尾空格。<input v-model.trim="msg" />
2.10 生命周期
每个 Vue 组件实例在创建的时候都要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。
-
注册周期钩子
举例来说,onMounted
钩子可以用来在组件完成初始渲染并创建 DOM 节点后运行代码:<script setup> import { onMounted } from 'vue'; onMounted(() => { console.log(`the component is now mounted.`); }); </script>
-
生命周期图示
2.11 侦听器
-
基本示例
在
watch
函数每次响应式状态发生变化时触发回调函数:<template> <p> Ask ys/no question: <input v-model="question" :disabled="loading" /> </p> <p>{{ answer }}</p> </template> <script setup> import { watch, ref } from 'vue'; const question = ref(''); const answer = ref('Questions usually contain a question mark.'); const loading = ref(false); 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>
-
侦听数据源类型
watch
的第一个参数可以是不同形式的“数据源”:它可以是一个 ref (包括计算属性)、一个响应式对象、一个 getter 函数、或多个数据源组成的数组:const x = ref(0); const y = ref(0); // 单个ref watch(x, newX => { console.log(`x is ${newX}`); }); // getter 函数 watch( () => x.value + y.value, sum => { console.log('sum of x + y is: ' + sum); } ); // 多个来源组成的数组 watch([x, () => y.value], ([newX, newY]) => { console.log(`x is ${newX}, y is ${newY}`); });
不能直接侦听响应式对象的属性值,例如:
const obj = reactive({ count: 0 }); // 错误,因为watch()得到的参数是一个number watch(obj.count, val => { console.log(val); }); // 应该这样写 watch( () => obj.count, val => { console.log(val); } );
-
-
深层侦听器
直接给
watch()
传入一个响应式对象,会隐式地创建一个深层侦听器——该回调函数在所有嵌套的变更时都会被触发。const obj = reactive({ count: 0 }); watch(obj, (nv, ov) => { // 嵌套的属性变更时触发 // 注意:`nv` 此处和 `ov` 是相等的,因为它们是同一个对象。 }); obj.count++;
相比之下,一个返回响应式对象的 getter 函数,只有在返回不同的对象时,才会触发回调:
watch( () => state.someObject, () => { // 仅当 state.someObject 被替换时触发 } );
加上
deep
,强制转成深层侦听器:watch( () => state.someObject, (nv, nv) => {}, { deep: true } );
注意
对象结构庞大的时候,开销很大,只在必要时使用它。
-
即时回调的侦听器
watch
默认是懒执行的:当数据变化时,才会执行回调。可以通过传入immediate:true
选项来强制侦听器的回调立即执行:
watch(
source,
(nv, ov) => {
// 立即执行,且当`source`改变时再次执行
},
{
immediate: true,
}
);
watchEffect()
侦听器的回调使用与源完全相同的响应式状态是很常见的。例如下面的代码,在每当todoId
的引用发生变化时使用侦听器来加载一个远程资源:
const todoId = ref(1);
const data = ref(null);
watch(
todoId,
async () => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todoId.value}`
);
data.value = await response.json();
},
{ immediate: true }
);
特别注意侦听器是如何两次使用todoId
的,一次是作为源,另一次是在回调中。
我们可以用watchEffect
函数来简化上面的代码。watchEffect()
允许我们自动跟踪回调的响应式依赖,上面的侦听器可以重写为:
watchEffect(async () => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todoId.value}`
);
data.value = await response.json();
});
该例子中,回调会立即执行,不需要指定immediate:true
。在执行期间,它会自动追踪todoId.value
作为依赖(和计算属性类似)。每当totoId.value
变化时,回调会再次执行。有了watchEffect()
,我们不再需要明确传递todoId
作为源值。
对于只有一个依赖项的例子来说,watchEffect()
的好处相对较小。但是对于有多个依赖项的侦听器来说,使用watchEffect()
可以消除手动维护列表的负担。此外,如果你需要侦听一个嵌套数据结构中的几个属性,watchEffect()
可能会比深度侦听器更有效,因为它将只跟踪回调中的被使用到的属性,而不是递归地跟踪所有的属性。
注意
watchEffect
仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个await
正常工作前访问到的属性才会被追踪。如上述的todoId.value
,todoId.value
在await
前被访问,所以todoId
被追踪。
-
watch
vs.watchEffect
watch
和watchEffect
都能响应式地执行有副作用的回调。它们之间的主要区别是追踪响应式依赖的方式: -watch
只追踪明确侦听的数据源。它不追踪任何在回调中访问到的除了数据源的其他响应式数据。另外,仅在数据源确实改变时才会触发回调。watch
会避免发生副作用时追踪依赖,因此能更加精确地控制回调函数的触发时机。watchEffect
会在副作用发生期间追踪依赖。它会在同步执行过程中,自动追踪所有能访问到的响应式属性。这更方便,而且代码更简洁,但有时其响应性依赖关系不那么明确。
-
回调的触发时机
当你更改了响应式状态,他可能会同时触发 Vue 组件更新和侦听器回调。类似于组件更新,用户创建的侦听器回调函数也会被批量处理以避免重复调用。例如,如果我们同步将一千个项目推入被侦听器的数组中,我们可能不希望侦听器触发一千次。
默认情况下,侦听器回调会在父组件更新之后,所属组件的 DOM 更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的 DOM,那么 DOM 将处于更新前的状态。
- Post Watchers
如果想在侦听器回调中能访问被 Vue 更新之后的所属组件的 DOM,需要指明
flush: 'post'
选项:watch(source, callback, { flush: 'post' }); watchEffect(callback, { flush: 'post' });
后置刷新的
watchEffect()
有个更方便的别名watchPostEffect()
:import { watchPostEffect } from 'vue'; watchPostEffect(() => { console.log('DOM is updated'); });
- 同步侦听器
在 Vue 进行任何更新之前触发:
watch(source, callback, { flush: 'sync' }); watchEffect(callback, { flush: 'sync' });
同步刷新的
watchEffect()
有个更方便的别名watchSyncEffect()
:import { watchSyncEffect } from 'vue'; watchSyncEffect(() => { console.log('DOM is beforeUpdated'); });
-
停止侦听器
在setup()
或<script setup>
中用同步语句创建的侦听器,会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。因此,在大多数情况下,无需关心怎么停止一个侦听器。
关键点是侦听器必须使用同步语句创建:如果异步创建侦听器,他不会绑定到当前组件上,必须手动停止它,防止内存泄漏。
<script setup>
import { watchEffect } from 'vue';
// 它会自动停止
watchEffect(() => {});
// ...这个则不会!
setTimeout(() => {
watchEffect(() => {});
});
</script>
要手动停止一个侦听器,请调用watch
或watchEffect
返回的函数:
const unwatch = watchEffect(() => {});
unwatch();
注意,需要异步创建侦听器的情况很少,请尽可能选择同步创建。如果需要等到一些异步数据。可以使用条件式的侦听逻辑:
const data = ref(null);
watchEffect(() => {
if (data.value) {
console.log(data.value);
}
});
2.12 模板引用
在模板中,你可以使用ref
来访问 DOM 元素或者组件实例。
- 访问模板引用
为了通过组合式 API 获得该模板引用,需要声明一个匹配模板ref
值:
<script setup>
import { ref, onMounted } from 'vue';
const input = ref(null);
onMounted(() => {});
</script>
<template>
<input ref="input" />
</template>
如果不使用<script setup>
,需确保从setup()
返回 ref:
export default {
setup() {
const input = ref(null);
return {
input,
};
},
};
注意,你只可以在组件挂载后才能访问模板引用。如果想在模板中的表达式上访问 input,在初次渲染是会是 null。这是因为在初次渲染前这个元素还不存在!
v-for
中的模板引用
当在v-for
中使用模板引用时,对应的 ref 中包含的是一个数组,它将在元素被挂载后包含对应整个列表的所有元素:
<script setup>
import { ref, onMounted } from 'vue';
const list = ref([]);
const itemRefs = ref([]);
onMounted(() => {
console.log(itemRefs.value);
});
</script>
<template>
<ul>
<li v-for="item in list" ref="itemRefs">{{item}}</li>
</ul>
</template>
注意
ref 数组并不保证与源数组相同的顺序。
- 函数模板引用
除了用字符串值作为名字,ref
attribute 还可以绑定为一个函数,会在每次组件更新时都被调用。改函数会收到元素引用作为其第一个参数:
<input :ref="(el) => {}"/>
使用动态的:ref
绑定才能传入一个函数。当绑定的元素被卸载时,函数也会被调用一次。
- 组件上的 ref
组件上的 ref 获取到的是组建的实例。
<script setup>
import { ref, onMounted } from 'vue';
import Child from './Child.vue';
const child = ref(null);
onMounted(() => {
console.log(child.value);
});
</script>
<template>
<Child ref="child" />
</template>
有个例外的情况,使用了<script setup>
的组件是默认私有的:一个父组件无法访问到一个使用了<script setup>
的子组件的任何东西,除非子组件在<script setup>
中显式地通过defineExpose
暴露了它。
<script setup>
import { ref } from 'vue';
const a = 1;
const b = ref(2);
defineExpose({
a,
b,
});
</script>
当父组件通过模板引用获取到了该组件的实例时,得到的实例类型为{a: number, b: number} (ref 会自动解包,和一般的实例一样)
2.13 组件基础
- 定义一个组件
将 Vue 组件定义在一个单独的.vue
文件中,这被叫做单文件组件(SFC):
<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 对象来定义:
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'
};
-
使用组件
使用一个子组件,要在父组件中导入它。假设我们吧计数器组件放在了一个叫做ButtonCounter.vue
的文件中,这个组件将以默认导出的形式被暴露给外部。<script setup> import ButtonCounter from './ButtonCounter.vue'; </script> <template> <h1>Here is a child component!</h1> <ButtonCounter /> </template>
通过
<script setup>
,导入的组件可以直接在模板里使用。
当然,也可以全局地注册一个组件,是的他在当前应用中的任何组件上都可以使用,而不需要在额外导入。 -
传递 props
Props 是一种特殊的属性,可以在组件上声明主注册。要传递给博客文章组件的一个标题,我们必须在组件的 props 列表上声明它。这里要用到 defineProps 宏:<script setup> defineProps(['title']); </script> <template> <h4>{{ title }}</h4> </template>
defineProps
是一个仅<script setup>
中可用的编译宏命令,并不需要显式的导入。声明 props 会自动暴露给模板。defineProps
会返回一个对象,其中包含了可以传递给组建的所有 props。const props = defineProps(['title']); console.log(props.title);
如果没有使用
<script setup>
,props 必须以props
选项的方式声明,props 对象会作为setup()
函数的第一个参数被传入:export default { props: ['title'], setup(props) { console.log(props.title); }, };
一个组件可以有任意多个 props,默认情况下,所有 prop 都接收任意类型的值。
当一个 prop 被注册后,可以像这样一自定义 attribute 的像是传递数据给它:<BlogPost title="My journey with Vue" />
-
监听事件
继续关注<BlogPost>
组件。会发现有时候它需要与父组件进行交互。例如,要在此处实现无障碍访问的需求,将博客文章文字放大,页面其余部分使用默认字号。
子父组件中,我们可以添加一个postFontSize
ref 来实现这个效果:const posts = ref([]); const postFontSize = ref(1);
在模板中用它来控制所有博客文章的字体大小:
<div :style="{ fontSize: postFontSize + 'rem' }"> <BlogPost v-for="post in posts" :key="post.id" :title="post.title" /> </div>
然后,给
<BlogPost>
组件添加一个按钮:<!-- BlogPost.vue--> <template> <div class="blog-post"> <h4>{{ title }}</h4> <button @click="$emit('enlarge-text')">Enlarge text</button> </div> </template> <script setup> defineProps(['title']); // 声明需要抛出的事件 defineEmits(['enlarge-text']); </script>
如果没有使用
<script setup>
,可以通过emits
选项定义组件会抛出的事件。可以从setup()
函数的第二个参数,即 setup 上下文对象上访问到emit
函数:export default { emits: ['enlarge-text'], setup(props, ctx) { function enlargeText() { ctx.emit('enlarge-text'); } return { enlargeText, }; }, };
-
通过插槽来分配内容
我们希望和 HTML 元素一样向组件中传递内容:<AlertBox> Something bad happened. </AlertBox>
期望能渲染成这样:
This is an Error for Demo Purposes
Something bad happened.通过 Vue 的自定义
<slot>
元素来实现:<template> <div class="alert-box"> <strong>This is an Error for Demo Purposes</strong> <slot /> </div> </template>
-
动态组件
有些场景会需要在两个组件间来回切换,比如 Tab 界面:
通过 Vue 的<component>
元素和特殊的is
attribute 来实现:<component :is="tabs[currentTab]"></component>
在上面的例子中,被传给
:is
的值可以是以下几种:- 被注册的组件名
- 导入的组件对象
当通过使用<component :is="...">
来在多个组件中做切换时,被切换掉的组件会被卸载。可以通过<KeepAlive>
组件强制被切换掉的组件仍然保持“存活”状态。
3 深入组件
3.1 注册
-
全局注册
我们可以使用 Vue 应用示例的.component()
方法,让组件在当前 Vue 应用中全局使用。import { createApp } from 'vue'; const app = createApp({}); app.component('MyComponent', { /** 组件的实现 */ });
使用单文件组件,可以注册被导入的
.vue
文件:import MyComponent from './App.vue'; app.component('MyComponent', MyComponent);
.component()
方法可以被链式调用:app .component('MyComponent', MyComponent) .component('MyComponent2', MyComponent2) .component('MyComponent3', MyComponent3);
全局注册可以在任意组件的模板中使用:
<MyComponent /> <MyComponent2 /> <MyComponent3 />
所有子组件也可以使用全局注册的组件,这意味着三个组件也都可以在彼此内部使用。
-
局部注册
全局注册虽然很方便,但有以下几个问题:- 全局注册但并没有被使用的组件无法在生产打包时自动移除(tree-shaking 摇树优化)。
- 全局注册在大型项目中使项目的依赖关系变得不那么明确。在父组件中使用子组件是,不太容易定位子组件的实现,影响维护。
相比之下,局部注册避开了上述问题:它的优点是对 tree-shaking 比较友好,也使得组件之间的依赖关系更加明确。
在使用
<script setup>
的单文件组件中,导入的组件可以直接在模板中使用,无需注册:<script setup> import ComponentA from './ComponentA.vue'; </script> <template> <ComponentA /> </template>
如果没有使用
<script setup>
,则需要使用<components>
选项来显式注册:import ComponentA from './ComponentA.vue'; export default { components: { ComponentA }, setup() {}, };
对于每个
components
对象的属性,它们的 key 名就是注册的组件名,而值就是相应组件的实现。等价于:export default { components: { ComponentA: ComponentA, }, };
-
组件名格式
一般使用 PascalCase,但也可以使用 kebab-case。
3.2 Props
-
Props 声明
一个组件需要显示声明它所接受的 props,这样 Vue 才能知道外部传入的哪些是 props,哪些是透传的 attribute。
在<script setup>
中,props 可以通过defineProps()
函数声明:<script setup> const props = defineProps(['foo']); console.log(props.foo); </script>
没有使用
<script setup>
的组件中,props 可以使用props
选项来声明:export default { props: ['foo'], setup(props) { console.log(props.foo); }, };
除了使用字符串数组来声明 prop 外,可以使用对象的形式:
defineProps({ title: String, likes: Number, });
export default { props: { title: String, likes: Number, }, };
对于以对象形式声明的每个属性,key 是 prop 的名称,而值则是该 prop 预期类型的构造函数,比如,如果要求一个 prop 的值是
number
类型,则可使用Number
构造函数作为其声明的值。对象形式的 props 声明不仅可以一定程度上作为组件的文档,而且如果其他开发者在使用你的组件时传递了错误的类型,也会在浏览器控制台中抛出警告。
使用
<script setup>
,也可以使用类型标注来声明 props:<script setup lang="ts"> defineProps<{ foo: string; bar: number }>(); </script>
-
传递 prop 的细节
Prop 名字格式
如果一个 prop 名字很长,应使用 camelCase 形式。defineProps({ greetingMessage: String, });
<span>{{ greetingMessage }}</span>
虽然理论上你也可以向子组件传递 props 时使用 camelCase 形式,但为了和 HTML attribute 对齐,一般会将其写为 kebab-case 形式:
<MyComponent greeting-message="hello" />
对于组件名推荐使用 PascalCase,能帮助我们区分 Vue 组件和原生 HTML 元素。对于传递 props 来说,使用 camelCase 并没有太多优势,因此我们更贴近 HTML 的书写风格。
-
静态 vs. 动态 Prop
<!-- 静态 Prop --> <BlogPost title="My journey with Vue" />
<!-- 动态 Prop --> <BlogPost :title="post.title" />
-
传递不同的值类型
这里只提及一个特殊的类型——布尔类型<BlogPost is-published="true" /> <!-- 可以简写为下面的形式 --> <BlogPost is-published />
-
使用一个对象绑定多个 prop
如果你想要将一个对象的树有属性都当做 props 传入,可以使用没有参数的v-bind
,即只使用v-bind
而非:prop-name
。例如,这里有个post
对象:const post = { id: 1, title: 'My journey with Vue', };
以及下面的模板:
<BlogPost v-bind="post" />
实际上等价于:
<BlogPost :id="post.id" :title="post.title" />
-
-
单向数据流
搜有的 props 都遵循这单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改了父组件的数据的情况,不然应用的数据流将很容易变得混乱而难以理解。
更改一个 prop 的需求通常来源于以下两种场景:- prop 被用于传入初始值,而子组件想在之后将其作为一个局部数据属性。。在这种情况下,最好是新定义一个局部数据属性,从 props 上获取初始值,然后在组件中对这个属性进行修改。
const props = defineProps(['initialCounter']); const counter = ref(props.initialCounter);
- 需要对传入的 prop 值做进一步的转换。这种情况下,最好基于该 prop 创建一个计算属性:
const props = defineProps(['size']); const normalizeSize = computed(() => props.size.trim().toLowerCase());
- 更改对象/数组类型的 props
当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为对象和数组是引用传递,对于 Vue 来说,禁止这样的改动,虽然可能生效,但有很大的性能损耗,得不偿失。
-
Prop 校验
defineProps({
propA: Number,
propB: [String, Number],
propC: {
type: String,
required: true,
},
propD: {
type: Number,
default: 100,
},
propE: {
type: Object,
// 对象或数组的默认值
// 必须从一个工厂函数返回。
// 该函数接收组件所接收到的原始 prop 作为参数。
default(rawProps) {
return { message: 'hello' };
},
},
// 在 3.4+ 中完整的 props 作为第二个参数传入
propF: {
validator(value, props) {
return ['success', 'warning', 'danger'].includes(value);
},
},
});
一些细节补充:
-
所有 prop 默认都是可选的,除非声明了
required:true
。 -
除
Boolean
外地未传递的可选 prop 将会有一个默认值undefined
。 -
Boolean
类型的未传递 prop 将被转换为false
。这可以为他设置default
来更改——例如:设置为default:undefined
将与非布尔类型的 prop 的行为保持一致。 -
如果声明了
default
值,那么在 prop 的值被解析为undefined
时,无论 prop 是未被传递还是显式指明的undefined
,都会改为default
值。
当 prop 的校验失败后,Vue 会抛出一个控制台警告。 -
运行时类型检查
校验选项中的type
,可以是下列这些原生构造函数:- String
- Number
- Boolean
- Array
- Object
- Date
- Function
- Symbol
另外,
type
也可以是自定义的类或构造函数,Vue 将会通 过instanceof
来检查类型是否匹配。class Person { constructor(name) { this.name = name; } }
可以将其作为一个 prop 的类型:
defineProps({ author: Person });
Vue 会通过
instanceof Person
来检查传入的值是否为Person
的实例。` -
Boolean 类型转换
为了更贴近原生 boolean attributes 的行为,声明为Boolean
类型的 props 有特别的类型转换规则。以带有如下声明的<MyComponent>
组件为例:defineProps({ disable: Boolean, });
该组件可以被这样使用:
<!-- 等同于传入:disabled="true" --> <MyComponent disable /> <!-- 等同于传入:disabled="false" --> <MyComponent />
当一个 prop 被声明为允许多种类型是,
Boolean
的转换规则也将被应用。然而,String
和Boolean
时,Boolean
转换规则才适用:// disabled 将被转换为 true defineProps({ disabled: [Boolean, String], }); // disabled 将被转换为 true defineProps({ disabled: [Boolean, Number], }); // disabled 将被转换为 true defineProps({ disabled: [Number, Boolean], }); // disabled 将被解析为空字符串(disabled="") defineProps({ disabled: [String, Boolean], });
3.3 事件
-
触发与监听事件
在组件模板中,直接使用$emit
方法触发自定义事件(例如:在v-on
的处理函数中):<!-- MyComponent --> <button @click="$emit('someEvent')" />
父组件可以通过
v-on
(缩写为@
)来监听事件:<MyComponent @some-event="doSomething" />
同样,组件的事件监听器也支持
.once
修饰符:<MyComponent @some-event.once="doSomething" />
在模板中推荐使用 kebab-case 形式来编写监听器。
TIP和原生 DOM 事件不一样,组件触发的事件没有冒泡机制。你只能监听直接子组件触发的事件,平级组件或者是跨级多层嵌套的组件之间通信,应使用一个外部的事件总线,或是使用一个全局状态管理。
-
事件参数
有时候会需要在触发事件的时候附带一个特定的值。举例:<button @click="$emit('close', 1)" /> Increase by 1 </button>
在父组件中监听事件,可以先简单写一个内联的箭头函数作为监听器,此函数会接收到事件附带的参数:
<MyButton @increase-by="(n) => count += n" />
或者,也可以用一个组件方法来作为事件处理函数:
<MyButton @increase-by="increaseCount" />
function increaseCount(n) { count.value += n; }
TIP
所有传入
$emit()
的额外参数都会被直接传向监听器。$emit('foo', 1, 2, 3)
触发后,监听器函数将会收到这三个参数值。 -
声明触发的事件
组件可以显式地通过defineEmits
宏来声明触发的事件:
<script setup>
defineEmits(['close', 'submit']);
</script>
在<template>
中的$emit
不能在组件的<script setup>
部分使用,但defineEmits()
会返回一个相同作用的函数供我们使用:
<script setup>
const emit = defineEmits(['inFocus', 'submit']);
function buttonClick() {
emit('submit');
}
</script>
defineEmits()
宏不能在子函数中使用。如上所示,必须放在<script setup>
的顶级作用域下。
如果显式地使用了setup
,则事件需要通过emits
选项来定义,emit
函数也被暴露在setup()
的上下文对象上:
export default {
emits: ['inFocus', 'submit'],
setup(props, ctx) {
ctx.emit('inFocus');
},
};
与setup()
上下文对象中的其他属性一样,emit
可以安全地被解构:
export default {
emits: ['inFocus', 'submit'],
setup(props, { emit }) {
emit('submit');
},
};
这个emits
选项和defineEmits()
宏还支持对象语法,它允许我们对触发事件的参数进行验证:
<script setup>
const emit = defineEmits({
submit(payload: { email: string, password: string }) {
// 通过返回值为 `true` 还是为 `false` 来判断
// 验证是否通过
}
})
</script>
<script setup>
const emit = defineEmits({
submit(payload: { email: string, password: string }) {},
});
</script>
- 事件校验
要为事件添加校验,那么事件可以被赋值为一个函数,接受的参数就是抛出事件是传入 emit 的内容,返回一个布尔值来表明事件是否合法。
<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>
3.4 组件 v-model
-
基本用法
v-model
可以在组件上使用来实现双向绑定。
从 Vue 3.4 开始,推荐的实现方式是使用defineModel()
宏:<!-- Child.vue --> <script setup> const model = defineModel(); function update() { model.value++; } </script> <template> <div>parent bound v-model is: {{ model }}</div> </template>
父组件用
v-model
绑定一个值:<!-- Parent.vue --> <Child v-model="count" />
defineModel()
返回的值是一个 ref。它可以像其他 ref 一样被访问以及修改,不过它能起到在父组件和当前变量之间的双向绑定的作用:- 它的
.value
和父组件的v-model
的值同步; - 当他被子组件变更了,会触发父组件绑定的值一起更新。
-
底层机制
defineModel
是一个便利宏。编译器将其展开为一下内容:- 一个名为
modelValue
的 prop,本地 ref 的值与其同步; - 一个名为
update:modelValue
的事件,当本地 ref 的值与其同步;
在 3.4 版本之前,会按照如下的方式来实现上述相同的子组件:
<script setup> const props = defineProps(['modelValue']); const emit = defineEmits(['update:modelValue']); </script> <template> <input :value="props.modelValue" @input="emit('update:modelValue', $event.target.value)" /> </template>
因为
defineModel
声明了一个 prop,可以通过给defineModel
传递选项,来声明底层 prop 选项:// 使 v-model 必填 const model = defineModel({ required: true }); // 提供一个默认值 const model = defineModel({ default: 0 });
- 一个名为
-
v-model
的参数<MyComponent v-model:title="bookTitle" />
在子组件中,将字符串作为第一个参数传递给
defineModel()
来支持相应的参数:<!-- MyComponent.vue --> <script setup> const title = defineModel('title'); </script> <template> <input type="text" v-model="title" /> </template>
如果需要额外的 prop 选项,应该在 model 名称之后传递:
const title = defineModel('title', { required: true });
3.4 之前的用法
<!-- MyComponent.vue --> <script setup> defineProps({ title: { required: true, }, }); defineEmits(['update:title']); </script> <template> <input type="text" :value="title" @input="emit('update:title', $event.target.value)" /> </template>
- 它的
-
多个
v-model
绑定
利用刚才在v-model
参数小节中学到的指定参数和事件名的技巧,可以再单个组件实例上创建多个v-model
双向绑定。组件上的每个
v-model
都会同步不同的 prop,而无需额外的选项:<UserName v-model:first-name="first" v-model:last-name="last" />
<!-- UserName.vue --> <script setup> const firstName = defineModel('firstName'); const lastName = defineModel('lastName'); </script> <template> <input type="text" v-model="firstName" /> <input type="text" v-model="lastName" /> </template>
3.4 之前的用法
<script setup> defineProps({ firstName: String, lastName: String, }); defineEmits(['update:firstName', 'update:lastName']); </script> <template> <input :value="firstName" @input="emit('update:firstName', $event.target.value)" /> <input :value="lastName" @input="emit('update:lastName', $event.target.value)" /> </template>
-
处理
v-model
修饰符
v-model
有一些内置修饰符,如:.trim
,.number
和.lazy
。在某些场景下,可能要一个自定义组件的v-model
支持自定义的修饰符。
创建一个自定义的修饰符capitalize
,他会自动将v-model
绑定输入的字符串值第一个字母转为大写:<MyComponent v-model.capitalize="name" />
通过想这样解构
defineModel()
的返回值,可以再子组件中访问添加到组件v-model
的修饰符:<script setup> const [model, modifiers] - defineModel(); console.log(modifiers); // { capitalize: true } </script> <template> <input type="text" v-model="model" /> </template>
为了能够基于修饰符选择性地调节值的读取和写入方式,可以给
defineModel()
传入get
和set
这两个选项。这两个选项再从模型引用中读取或设置时会接收到当前的值,并且他们都应该返回一个经过处理的新值。下面利用set
选项来应用capitalize
修饰符:<script setup> const [model, modifiers] = defineModel({ set(value) { if (modifiers.capitalize) { return value.chartAt(0).toUpperCase() + value.slice(1); } return value; }, }); </script> <template> <input type="text" v-model="model" /> </template>
3.4 之前的用法
<script setup> const props = defineProps({ modelValue: String, modelModifiers: { default: () => ({}) }, }); const emit = defineEmits(['update:modelValue']); function emitValue(e) { let value = e.target.value; if (props.modelModifiers.capitalize) { value = value.charAt(0).toUpperCase() + value.slice(1); } emit('update:modelValue', value); } </script> <template> <input type="text" :value="modelValue" @input="emitValue" /> </template>
- 带参数的
v-model
修饰符
<UserName v-model:first-name.capitalize="first" v-model:last-name.capitalize="last" /> <script setup> const [firstName, firstNameModifiers] = defineModel('firstName'); const [lastName, lastNameModifiers] = defineModel('lastName'); console.log(firstNameModifiers); // { capitalize: true } console.log(lastNameModifiers); // { uppercase: true} </script>
3.4 之前的用法
<script setup> const props = defineProps({ firstName: String, lastName: String, firstNameModifiers: { default: () => ({}) }, lastNameModifiers: { default: () => ({}) }, }); defineEmits(['update:firstName', 'update:lastName']); console.log(props.firstNameModifiers); // { capitalize: true } console.log(props.lastNameModifiers); // { uppercase: true } </script>
- 带参数的
3.5 透传 Attributes
“透传 attribute”指的是传递给一个组件,却没有被该组件声明为 props 或 emits 的 attribute 或者v-on
事件监听器。最常见的例子就是class
、style
和id
。
当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上。举例来说,假如有一个<MyButton>
组件,它的模板长这样:
<button>click me</button>
一个父组件使用了这个组件,并且传入了 class:
<MyButton class="large" />
最后渲染出的 DOM 结果是:
<button class="large">click me</button>
这里,<MyButton>
并没有将class
声明为一个它所接受的 prop,所以class
被视作透传 attribute,自动透传到了<MyButton>
的根元素上。
-
对
class
和style
的合并
如果子组件的根元素有了class
和style
attribute,它会和从父组件上继承的值合并。如果我们将之前的<MyButton>
组件的模板改成这样:<button class="btn">click me</button>
最后渲染出的 DOM 结果是:
<button class="btn large">click me</button>
-
v-on
监听器继承
同样的规则也适用于v-on
事件监听器:<MyButton @click="onClick" />
click
监听器会被添加到<MyButton>
的根元素,即那个原生的<button>
元素之上。当原生的<button>
被点击,会触发父组件的onClick
方法。同样的,如果原生button
元素自身也通过v-on
绑定了一个事件监听器,则这个监听器和从父组件继承的监听器都会被触发。 -
深层组件继承
有些情况下一个组件会在根节点上渲染另一个组件。如,重构<MyButton>
,让它在根节点上渲染<BaseButton>
:<BaseButton />
此时
<MyButton>
接收的透传 attribute 会直接继续传给<BaseButton>
。
注意:- 透传的 attribute 不会包含
<MyButton>
上声明过的 props 或是针对emits
声明事件的v-on
侦听函数,换句话说,声明过的 props 和侦听函数被<MyButton>
“消费”了。 - 透传的 attribute 若符合声明,也可作为 props 传入
<BaseButton>
。
- 透传的 attribute 不会包含
-
禁用 Attribute 继承
如果不想自动继承 attribute,可以在组件选项中设置inheritAttrs
为false
。
从 3.3 开始可以直接在<script setup>
中使用defineOptions
:<script setup> defineOptions({ inheritAttrs: false, }); </script>
常见的要禁用 attribute 继承的场景就是 attribute 需要应用在根节点之外的其他元素上。通过设置
inheritAttrs
选项为 false,可以完全控制透传进来的 attribute 被如何使用。
透传进来的 attribute 可以在模板的表达式中直接用$attrs
访问到。<span>Fallthrough attributes: {{$attrs}}</span>
这个
$attrs
对象包含了除了组件所声明的props
和emits
之外的所有其他 attribute,例如class
,style
,v-on
监听器等等。
注意:- 和 props 不同,透传 attribute 在 JavaScript 中保留了它们原始的大小写,所以像
foo-bar
这样的一个 attribute 需要通过$attrs['foo-bar']
来访问。 - 像
@click
这样的一个v-on
事件监听器将在此对象下被暴露为一个函数$attrs.onClick
。
再次使用
<MyButton>
组件,有时候我们可能为了样式,需要在<button>
元素包装一层<div>
:<div class="btn-wrapper"> <button class="btn">click me</button> </div>
想要让
class
和v-on
监听器这样的透传 attribute 都应用在内部的<button>
上而不是外层的<div>
上。可以通过设定inheritAttrs: false
和使用$attrs
来实现:<div class="btn-wrapper"> <button class="btn" v-bind="$attrs">click me</button> </div>
- 和 props 不同,透传 attribute 在 JavaScript 中保留了它们原始的大小写,所以像
-
多根节点的 Attributes 继承
和单根节点组件有所不同,有着多个根节点的组件没有自动 attribute 透传行为。如果$attrs
没有被显式绑定,将会抛出一个运行时警告。<CustomLayout id="custom-layout" @click="changeValue" />
如果
<CustomLayout>
有下面这样的多根节点模板,由于 Vue 不知道将 attribute 透传到哪里,所以会抛出一个警告。<header>...</header> <main>...</main> <footer>...</footer>
如果
$attrs
被显式绑定,则不会有警告:<header>...</header> <main v-bind="$attrs">...</main> <footer>...</footer>
-
在 JavaScript 中访问透传 Attributes
如果需要,可以在<script setup>
中使用useAttrs()
API 来访问一个组件的所有透传 attribute:<script setup> import { useAttrs } from 'vue'; const attrs = useAttrs(); </script>
如果没有使用
<script setup>
,attrs
会作为setup()
上下文对象的一个属性暴露:export default { setup(props, ctx) { console.log(ctx.attrs); }, };
注意:
这里的attrs
对象总是反映为最新的透传 attribute,但它并不是响应式的。不能通过侦听器去监听他的变化。如果需要响应性,可以使用 prop。或者可以使用onUpdated()
使得每次更新时结合最新的attrs
执行副作用。
3.6 插槽
- 插槽内容与出口
在之前的章节中,已经了解到组件能够接收任意类型的 JavaScript 值作为 props,但组件要如何接收模板内容呢?在某些场景中,我们可能想要为子组件传递一些模板片段,让子组件在他们的组件中渲染这些片段。
举例来说,有个<FancyButton>
组件,可以像这样使用:
<FancyButton>Click me!</FancyButton>
而<FancyButton>
的模板是这样的:
<button class="fancy-btn">
<!-- 插槽出口 -->
<slot></slot>
</button>
<slot>
元素是一个插槽出口,标示了父元素提供的插槽内容将在哪里被渲染。
最终渲染出的 DOM 是这样:
<button class="fancy-btn">Click me!</button>
插槽的内容可以是任意合法的模板内容,不限于文本。例如可以穿入多个元素,甚至是组件:
<FancyButton>
<span style="color:red">Click me!</span>
<AwesomeIcon name="plus" />
</FancyButton>
通过使用插槽,<FancyButton>
组件更加灵活和更有可复用性。现在组件可以用在不同的地方渲染各异的内容,同时保证都具有相同样式。
Vue 组件的插槽机制是受原生 Web Component 的 <slot>
元素的启发而诞生,还做了一些功能拓展。
-
渲染作用域
插槽内容可以访问到父组件的数据作用域,因为插槽内容本身是在父组件中定义的,举例来说:<span>{{ message }}</span> <FancyButton>{{ message }}</FancyButton>
两个
{{ message }}
的插值表达式渲染的内容是一样的。插槽内容无法访问子组件的数据。Vue 模板中的表达式只能范文定义时所处的作用域,这个 JavaScript 的词法作用域规则一致。换言之:
父组件模板的表达式在智能范文父组件的作用域,子组件模板中的表达式只能访问子组件的作用域。
在外部没有提供任何内容的情况下,可以为插槽指定默认内容。比如有这样一个
<SubmitButton>
组件:<button type="submit"> <slot> <!-- 默认内容 --> Submit </slot> </button>
-
具名插槽
有时在一个组件中包含多个插槽出口,在一个<BaseLayout>
组件中,有如下模板:<div class="container"> <header> <slot name="header"></slot> </header> <main> <slot></slot> </main> <footer> <slot name="footer"></slot> </footer> </div>
要为具名插槽传入内容,要使用含有
v-slot
指令的<template
元素,并将目标插槽的名字传给该指令:<BaseLayout> <template v-slot:header>Header</template> </BaseLayout>
v-slot
有对应的简写#
,因此<template v-slot:header>
可以简写为<template #header>
下面给出完整的、向
<BaseLayout>
传递插槽内容的代码,指令均使用的是缩写形式:<BaseLayout> <template #header> <h1>Header</h1> </template> <template #default> <p>Default</p> </template> <template #footer> <p>Footer</p> </template> </BseLayout>
当一个组件同时接收默认插槽和具名插槽是,所有位于顶级的非
<template>
的节点都被隐式地视为默认插槽的内容。所有上面可以写成:<BaseLayout> <template #header> <h1>Header</h1> </template> <p>Default</p> <template #footer> <p>Footer</p> </template> </BaseLayout>
-
动态插槽名
动态指令参数在v-slot
上也是有效的,即可以定义下面这样的动态插槽名:<base-layout> <template v-slot:[dynamicSlotName]>...</template> <!-- 缩写为 --> <template #[dynamicSlotName]>...</template> </base-layout
-
作用域插槽
在上述的渲染作用域中提到,插槽内容无法访问到子组件的状态。然而在某些场景下查找到内容可能想要通透式使用父组件域内和子组件域内的数据。要做到这一点,需要一种方法让子组件渲染时将一部分数据提供给插槽。
<div> <slot name="greeting" :text="greetingMessage" :count="1"></slot> </div>
当需要接插槽 props 时,默认插槽和具名插槽的使用方式有一些小区别。
<MyComponent v-slot="slotProps"> {{ slotProps.text }} {{ slotProps.count }} </MyComponent>
-
具名作用域插槽
插槽的 props 可以作为v-slot
指令的值被访问到:v-slot:name="slotProps"
。当使用缩写时是这样:<MyComponent> <template #header="headerProps"> {{ headerProps }} </template> <template #default="defaultProps"> {{ defaultProps }} </template> <template #footer="footerProps"> {{ footerProps }} </template> </MyComponent>
向具名插槽中传入 props:
<slot name="header" message="hello"></slot>
插槽的
name
是保留的 attribute,不会作为 props 传递给插槽,因此最终 headerProps 的结果是{message:'hello'}
。
如果同时使用了具名插槽和默认插槽,且都传递了 props,则需要为默认插槽使用显式的<template>
标签。尝试直接为组件添加v-slot
指令将导致编译错误。这是为了避免因默认插槽的 props 的作用域而困惑,举例:<!-- 该模板无法编译 --> <template> <MyComponent v-slot="{ message }"> <p>{{ message }}</p> <template #footer> <p>{{message}}</p> </template> </template>
为默认插槽使用显示的
<template>
标签有助于更清晰地指出message
属性在其他插槽中不可用:<template> <MyComponent> <!-- 使用显式的默认插槽 --> <template #default="{ message }"> <p>{{ message }}</p> </template> </template> <template #footer="{ message }"> <p>Here's some contact info</p> </template> </MyComponent> </template>
3.7 依赖注入
-
Provide
要为组件后代提供数据,需要用到provide()
函数:<script setup> import { provide } from 'vue'; provide('message', 'hello!'); </script>
如果不使用
<script setup>
,请确保provide()
是在setup()
同步调用的:import { provide } from 'vue'; export default { setup() { provide('message', 'hello!'); }, };
provide()
函数接收两个参数。第一个参数被称为注入名,可以是一个字符串或是一个symbol
。后代组件会用注入名来查找期望注入的值。一个组件可以多次调用provide()
,使用不同的注入名,注入不同的依赖值。
第二个参数是提供的值,值可以是任意类型,包括响应式的状态,比如一个 ref:import { ref, provide } from 'vue'; const count = ref(0); provide('key', count);
提供的响应式状态使后代组件可以由此和提供者建立响应式的联系。
-
应用层 Provide
在整个应用层提供依赖:import { createApp } from 'vue'; const app = createApp({}); app.provide('message', 'hello!');
-
Inject
要注入上层组件提供的数据,需要使用inject()
函数:<script setup> import { inject } from 'vue'; const message = inject('message'); </script>
同样的,如果没有使用
<script setup>
,inject()
需要在setup()
内同步调用:import { inject } from 'vue'; export default { setup() { const message = inject('message'); return { message }; }, };
-
注入默认值
默认情况下,inject
假设传入的注入名会被某个祖先链上的组件提供。如果该注入名的确没有任何组件提供,会抛出一个运行时警告。如果在注入一个值时不要求必须有提供者,那么我们应该声明一个默认值,和 props 类似:
const value = inject('message', '这是默认值');
在一些场景中,默认值可能需要通过一个函数或者初始化一个类来取得。为了避免在用不到默认值的情况下进行不必要的计算或产生副作用,可使用工厂函数来创建默认值:
const value = inject('key', () => new ExpensiveClass(), true);
第三个参数表示默认值应当被当做是一个工厂函数。(起到了一个延迟加载的效果)
-
-
和响应式数据配合使用
当提供/注入响应式的数据时,建议尽可能将任何对响应式状态的变更都保持在供给方组件中。有些时候,可能需要在注入组建中更改数据。这种情况下,推荐在供给方组件内声明并提供一个更改数据的方法函数:
<!-- 在供给方组件内 --> <script setup> import { provide, ref } from 'vue'; const location = ref('North Pole'); function updateLocation() { location.value = 'South Pole'; } provide('location', { location, updateLocation, }); </script>
<!-- 在注入方组件 --> <script setup> import { inject } from 'vue'; const { location, updateLocation } = inject('location'); </script> <template> <button @click="updateLocation">{{ location }}</button> </template>
如果想要注入的数据是只读的,不可以被修改的。可以使用
readonly()
来包装提供的值。<script setup> import { ref, provide, readonly } from 'vue'; const count = ref(0); provide('count', readonly(count)); </script>
-
使用 Symbol 作注入名
如果正在编写提供给其他开发者使用的组件库,建议最好使用 Symbol 来作为注入名以避免潜在的冲突。// keys.js export const myInjectionKey = Symbol();
// 在供给方组件中 import { provide } from 'vue'; import { myInjectionKey } from './keys.js'; provide(myInjectionKey, 'hello!');
// 在注入方组件中 import { inject } from 'vue'; import { myInjectionKey } from './keys.js'; const message = inject(myInjectionKey);
3.8 异步组件
-
基本用法
在大型的项目中,可能需要将应用拆分为更小的块,在需要时从服务器加载它。
Vue 提供了defineAsyncComponent
函数来实现这个功能:import { defineAsyncComponent } from 'vue'; const AsyncComp = defineAsyncComponent(() => { return new Promise((resolve, reject) => { // ...从服务器获取组件 resolve(/** 获取到的组件 */); }); });
defineAsyncComponent
返回一个组件,当组件被渲染时,会异步加载提供的 Promise。ES 模块动态导入也会返回一个 Promise,所以也可以用它来导入 Vue 单文件组件:
import { defineAsyncComponent } from 'vue'; const AsyncComp = defineAsyncComponent(() => import('./MyComponent.vue'));
与普通组件一样,异步组件可以使用
app.component()
全局注册:app.component( 'MyComponent', defineAsyncComponent(() => import('./MyComponent.vue')) );
-
加载与错误状态
异步操作不可避免会涉及到加载和错误状态,因此defineAsyncComponent
也支持在高级选项中处理这些状态:const AsyncComp = defineAsyncComponent({ // 加载函数 loader: () => import('./Foo.vue'), // 加载异步组件时使用的组件(占位组件) loadingComponent: LoadingComponent, // 展示加载组件签的延迟时间,默认 200 ms delay: 200, // 加载失败后展示的组件 errorComponent: ErrorComponent, // 如果提供了一个timeout时间限制,并超时了 // 也会展示这里配置的错误组件,默认值是:Infinity timeout: 3000, });
- 搭配 Suspense 使用
异步组件可以搭配内置的Suspense
(后面介绍)组件一起使用。
- 搭配 Suspense 使用
4 逻辑复用
4.1 组合式函数
-
什么是“组合式函数”
在 Vue 应用的概念中,“组合式函数”是一个利用 Vue 的组合式 API 来封装和复用有状态逻辑的函数。当构建前端应用时,我们常常需要复用公共任务的逻辑。例如,在不同地方格式化时间,我们可能会抽取一个可以复用的日期格式化函数。这个函数封装了无状态的逻辑:它在接收一些输入后立刻返回所期望的输出。
-
鼠标跟踪器示例
如果我们需要直接在组建中使用组合式 API 实现鼠标跟踪功能,它会是这样子:<script setup> import { onMounted, onUnmounted, ref } from 'vue'; const x = ref(0); const y = ref(0); function update(event) { x.value = event.pageX; y.value = event.pageY; } onMounted(() => window.addEventListener('mousemove', update)); onUnmounted(() => window.addEventListener('mousemove', update)); </script> <template> Mouse position is at: {{x}}, {{y}} </template>
但是,如果想要在多个组件中服用者相同的逻辑呢?可以把这个逻辑以一个组合式函数的形式提取到外部文件中:
// mouse.js import { ref, onMounted, onUnmounted } from 'vue'; // 按照惯例,组合式函数名以“use”开头 export function useMouse() { const x = ref(0); const y = ref(0); function update() { x.value = event.pageX; y.value = event.pageY; } // 一个组合式函数也可以挂靠在所属组件的生命周期上 // 来启动和卸载副作用 onMounted(() => window.addEventListener('mousemove', update)); onUnmounted(() => window.addEventListener('mousemove', update)); return { x, y }; }
下面是它在组件中使用的方式:
<script setup> import { useMouse } from './mouse.js'; const { x, y } = useMouse(); </script> <template> Mouse position is at: {{x}}, {{y}} </template>
-
异步状态示例
useMouse()
组合函数没有接收任何参数,因此看一个需要接受一个参数的组合式函数。在做异步数据请求时,常常需要处理不同的状态:加载中,加载成功和加载失败。<script setup> import { ref } from 'vue'; const data = ref(null); const error = ref(null); fetch('...') .then(res => res.json()) .then(json => (data.value = json)) .catch(err => (error.value = err)); </script> <template> <div v-if="error">{{ error.message }}</div> <div v-else-if="data">{{ data }}</div> <div v-else>Loading...</div> </template>
如果在每个需要获取数据的组件中都要重复这种模式,那就太繁琐了。把它抽取成一个组合式函数:
// fetch.js import { ref } from 'vue'; export function useFetch(url) { const data = ref(null); const error = ref(null); fetch(url) .then(res => res.json()) .then(json => (data.value = json)) .catch(err => (error.value = err)); return { data, error }; }
现在在组件里只需要:
<script setup> import { useFetch } from './fetch.js'; const { data, error } = useFetch('...'); </script>
-
接收响应式状态
useFetch()
接受一个静态的 URL 字符串作为输入——因此他只会执行一次 fetch 并且就此结束。如果我们想要在 URL 改变时重新 fetch 呢?为了实现这一点,我们需要将响应式的状态传入组合式函数,并让它基于传入的状态来创建执行操作的侦听器。
useFetch()
应该能够接收一个 ref:const url = ref('/initial-url'); const { data, error } = useFetch(url); // 重新触发fetch url.value = '/new-url';
或者接收一个 getter 函数:
// 当 props.id 改变时重新 fetch const { data, error } = useFetch(() => `/api/${props.id}`);
我们可以用
watchEffect()
和toValue()
API 来重构我们现有的实现:// fetch.js import { ref, watchEffect, toRef } from 'vue'; export function useFetch(url) { const data = ref(null); const error = ref(null); const fetchData = () => { // reset state before fetching.. data.value = null; error.value = null; fetch(toValue(url)) .then(res => res.json()) .then(json => (data.value = json)) .catch(err => (error.value = err)); }; watchEffect(() => fetchData()); return { data, error }; }
toValue()
是 3.3 版本中新的 API。设计目的试讲 ref 或者 getter 规范化为值。如果参数是 ref,会返回 ref 的值;如果参数是函数,它会调用这个函数并返回其返回值。否则,它会返回原值。
-
-
组合式函数的约定和最佳实践
- 命名
组合式函数约定用驼峰命名法命名,并以“use”作为开头。 - 输入参数
即便不依赖于 ref 或者 getter 的响应性,组合式函数也可以接收它们作为参数。如果你正在编写一个可能被其他开发者使用的组合式函数,最好处理一下输入参数是 ref 或者 getter 而非原始值的情况。可以利用toValue()
工具函数来实现:
如果你的组合式函数在输入参数是 ref 或 getter 的情况下创建了响应式 effect,为了让它能够被正确追踪,请确保要么使用import { toValue } from 'vue'; function useFeature(maybeRefOrGetter) { // 如果 maybeRefOrGetter 是 ref 或 getter,将返回它的规范化值,否则返回原值。 const value = toValue(maybeRefOrGetter); }
watch()
显式地监视 ref 或 getter,要么在watchEffect()
中调用toValue()
。 - 返回值
推荐约定是组合式函数始终返回一个包含多个 ref 的普通的非响应式对象,这样该对象在组件中被结构为 ref 后仍可以保持响应性:// x 和 y 是两个 ref const { x, y } = useMouse();
- 副作用
在组合式函数中可以执行副作用(例如:添加 DOM 事件监听器或者请求数据),但要注意以下规则:- 如果用到了 ssr,要确保组件挂载后才调用生命周期钩子中执行 DOM 相关的副作用,例如:
onMounted()
。这些钩子仅会在浏览器中被调用,可以确保能访问到 DOM。 - 确保在
onUnmounted()
时清理副作用。如果在组合式函数中设置了一个事件监听器,就应该在onUnmounted()
中移除它。
- 如果用到了 ssr,要确保组件挂载后才调用生命周期钩子中执行 DOM 相关的副作用,例如:
- 使用限制
组合式函数只在<script setup>
或setup()
钩子中被调用。在这些上下文中,他们也只能被同步调用,你也可以像onMounted()
这样的生命周期钩子中调用。
- 命名
-
通过抽取组合式函数改善代码结构
抽取组合式函数不仅是为了复用,也是为了代码组织。随着组件复杂度的增高,可能会最终发现组件多得难以理解和查询。组合式 API 会给予你足够灵活性,让你可以基于逻辑问题将组件代码拆分成更小函数:<script setup> import { useFeatureA } from './featureA.js'; import { useFeatureB } from './featureB.js'; import { useFeatureC } from './featureC.js'; const { foo, bar } = useFeatureA(); const { baz } = useFeatureB(foo); const { qux } = useFeatureC(baz); </script>
-
在选项式 API 中使用组合式函数
使用选项式 API,组合是函数必须在setup()
中调用。且返回的绑定必须在setup()
中返回,一边暴露给this
及其模板:import { useMouse } from './mouse.js'; import { useFetch } from './fetch.js'; export default { setup() { const { x, y } = useMouse(); const { data, error } = useFetch('/api/data'); return { x, y, data, error }; }, mounted() { // setup() 暴露的属性可以在通过`this`访问到 console.log(this.x); }, };
-
与其他模式比较
- 和 mixin 的对比
mixins 有三个主要的短板:- 不清晰的数据来源:使用了多个 mixin 时,实例上的数据属性来自哪个 mixin 变得不清晰,这使得追溯实现和理解组件行为变得困难。这也是推荐在组合式函数中使用 ref+结构模式的理由:让属性的来源在消费组件时一目了然。
- 命名空间冲突:多个来自不同作者的 mixin 可能定义了同名的属性和方法,造成命名冲突。若使用组合式函数,可以在解构变量是对变量进行重命名来避免相同的键名。
- 隐式的跨 mixin 交流:多个 mixin 需要依赖共享的属性名来进行相互作用,这使得他们隐性地耦合在一起。而一个组合式函数的返回值可以作为另一个组合式函数的参数被传入,像普通函数那样。
基于上述理由,不在推荐在 Vue3 中继续使用 mixin。保留该功能只是为了项目迁移的需求和照顾熟悉它的用户。
-
和无渲染组件的对比
组合式函数相对于无渲染组件的主要优势是:组合式函数不会产生额外的组件实例开销。在整个应用中使用时,无渲染组件产生的额外组件实例会带来无法忽视的性能开销。 -
和 React Hooks 的对比
如果有 React 的开发经验,组合式函数和自定义 React hooks 非常相似。组合式 API 的一部分灵感来自于 React hooks,Vue 的组合式函数也的确在逻辑组合能力上与 React hooks 相近。
- 和 mixin 的对比
4.2 自定义指令
除了 Vue 内置的一系列指令(比如v-model
或者v-show
)之外,Vue 还允许注册自定义指令。
一个自定义指令有一个包含类似组件生命周期的对象来定义。钩子函数会接收到指令所绑定元素作为其参数。
<script setup>
// 在模板中启用 v-focus
const vFocus = {
mounted: el => el.focus(),
};
</script>
<template>
<input v-focus />
</template>
在没有使用<script setup>
中,自定义指令需要通过directives
选项注册:
export default {
setup() {},
directives: {
// 在模板启用 v-focus
focus: {},
},
};
将一个自定义指令全局注册到应用层级也是一种常见的做法:
const app = createApp({});
app.directive('focus', {
/* ... */
});
- 指令钩子
一个指令的定义对象可以提供几种钩子函数:const myDirective = { // 在绑定元素的 attribute 前或事件监听器应用前调用 created(el, binding, vnode, prevNode) {}, // 在元素被插入到DOM前调用 beforeMount(el, binding, vnode, prevNode) {}, // 在绑定元素的父组件以及他自己的所有子节点都挂载完成后调用 mounted(el, binding, vnode, prevNode) {}, // 绑定元素的父组件更新前调用 beforeUpdate(el, binding, vnode, prevNode) {}, // 在绑定元素的父组件以及他自己的所有子节点都更新后调用 updated(el, binding, vnode, prevNode) {}, // 绑定元素的父组件卸载前调用 beforeUnmount(el, binding, vnode, prevNode) {}, // 绑定元素的父组件卸载后调用 unmounted(el, binding, vnode, prevNode) {}, };
- 钩子参数
el
:指令所绑定的 DOM 元素。binding
: 一个对象,包含以下属性。value
: 传递给指令的值。例如在v-my-directive="1 + 1"
中,值是2
。oldValue
: 之前的值,仅在beforeUpdate
和updated
中可用。无论值是否更改,都可用。arg
: 传递给指令的参数。例如在v-my-directive:foo
中,参数foo
。modifiers
: 一个包含修饰符的对象。例如在v-my-directive.foo.bar
中,修饰符对象是{ foo: true, bar: true }
。instance
: 使用该指令的组件实例。dir
: 指令的定义对象。
vnode
:代表绑定元素的底层 VNode。prevNode
: 代表之前渲染中指令所绑定元素的 VNode。仅在beforeUpdate
和updated
钩子中可用。
- 钩子参数
举例来说,像下面这样使用指令:
<div v-example:foo.bar="baz"></div>
binding
参数会是这样的一个对象:
{
arg: 'foo',
modifiers: { bar: true },
value: /* 'baz' 的值 */,
oldValue: /* 上一次更新时 `baz` 的值 */
}
和内置指令类似,自定义指令的参数也可以是动态的,举例来说:
<div v-example:[arg]="baz"></div>
这里指令的参数会基于组件的arg
数据属性响应式地更新。
- 简化形式
对于自定义指令来说,一个很常见的情况是仅仅需要在mounted
和updated
上实现相同的行为,除此之外并不需要其他钩子。这种情况下可以直接用一个函数来定义指令,如下所示:<div v-color="color"></div>
app.directive('color', (el, binding) => { // 这会在`mounted` 和 `updated`时都调用 el.style.color = binding.value; });
- 对象字面量
如果你的指令需要多个值,可以向它传递一个 JavaScript 对象字面量。别忘了,指令也可以接收任何合法的 JavaScript 表达式。<div v-demo="{ color: 'red', text: 'hello' }"></div>
app.directive('demo', (el, binding) => { console.log(binding.value.color); // 'red' console.log(binding.value.text); // 'hello' });
- 在组件上使用
在组件上使用自定义指令时,它始终应用于组件的根节点,和透传 attributes 类似。<MyComponent v-demo="test" />
需要注意的是组件可能含有多个根节点。当应用到一个多根组件时,指令将被忽略并抛出警告。<!-- MyComponent 的模板 --> <div> <!-- v-demo 指令会被应用到这里 --> <span>My component content</span> </div>
4.3 插件
-
介绍
插件(Plugins)是一种能为 Vue 添加全局功能的工具代码。示例:import { createApp } from 'vue'; const app = createApp({}); app.use(myPlugin, {});
一个插件可以是一个拥有
install()
方法的对象,也可以直接是一个安装函数本身。安装函数会接收到它的应用实例和传递app.use()
的额外选项作为参数:const myPlugin = { install(app, options) { // 配置此应用 }, };
插件没有严格定义的使用范围,但是插件发挥作用的常见场景主要包括以下几种:
- 通过
app.component()
和app.directive()
注册一到多个全局组件或自定义指令。 - 通过
app.provide()
使一个资源可被注入进整个应用。 - 向
app.config.globalProperties
添加一些全局实例属性或方法。 - 一个可能上述三种都包含了的功能库(例如 vue-router)。
- 通过
-
编写一个插件
写一个翻译函数,这个函数接收一个以.
作为分隔符的key
字符串,用来在用户提供的翻译字典中查找对应语言的文本。<h1>{{ $translate('greeting.hello') }}</h1>
这个函数应当能够在任意模板中被全局调用。这一点可以通过在插件中将它添加到
app.config.globalProperties
上来实现:// 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); }; }, };
我们的
$translate
函数会接受一个例如greeting.hello
的字符串,在用户提供的翻译字典中查找,并返回翻译得到的值。
用于查找的翻译字典对象则应当在插件被安装时作为app.use()
的额外参数被传入:import i18nPlugin from './plugins/i18n.js'; app.use(i18nPlugin, { greetings: { hello: 'Bonjour!', }, });
这样,一开始的表达式
$translate('greetings.hello')
就会在运行时被替换为Bonjour!
了。- 插件中的 Provide/Inject
在插件中,可以通过provide
来为插件用户提供一些内容。举例来说,可以将插件接受到的options
参数提供给整个应用,让任何组件都能使用整个翻译字典对象。
现在,插件用户就可以在他们的组件中以// plugins/i18n.js export default { install: (app, options) => { app.provide('i18n', options); }, };
i18n
为 key 注入并访问插件的选项对象了。<script setup> import { inject } from 'vue'; const i18n = inject('i18n'); console.log(i18n.greetings.hello); </script>
- 插件中的 Provide/Inject
5 内置组件
5.1 Transition
Vue 提供了两个内置组件,可以帮助制作基于状态变化的过度和动画:
transition
:会在一个元素或组件进入和离开 DOM 时应用动画。transitionGroup
: 会在一个v-for
列表中的元素或组件被插入、移动,或移除时应用动画。
Transition
组件
可以在任意别的组件中被使用,无需注册。可以将进入和离开动画应用到通过默认插槽传递给它的元素或者组件上。进入或离开可以由以下的条件之一触发:- 由
v-if
所触发的切换 - 由
v-show
所触发的切换 - 由特殊元素
<component>
切换的动态组件 - 改变特殊的
key
属性
- 由
以下是最基本的用法示例:
<button @click="show = !show">Toggle</button>
<Transition>
<p v-if="show">hello</p>
</Transition>
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
<Transition>
仅支持单个元素或组件作为其插槽内容。如果内容是一个组件,这个组件必须仅有一个根元素。
当一个<Transition>
组件中的元素被插入或者移除时,会发生下面这些事情:
- Vue 会自动检测目标元素是否应用了 css 过渡或动画。如果是,则一些 CSS 过渡 class 会在适当的时机被添加和移除。
- 如果有作为监听器的 JavaScript 钩子,这些钩子会在适当时机被调用。
- 如果没有探测到 css 过渡或动画,也没有提供 JavaScript 钩子,那么 DOM 的插入、删除操作将在浏览器的下一个动画帧后执行。
-
基于 CSS 的过渡效果
CSS 过渡 class
一共有 6 个应用与进入与离开过渡效果的 CSS class。
v-enter-from
: 进入动画的起始状态。在元素插入之前添加,在元素插入完成后的下一帧移除。v-enter-active
: 进入动画的生效状态。应用于整个进入动画阶段。在元素被插入之前添加,在过渡或动画完成之后移除。整个 class 可以被用来定义进入动画的持续事件、延迟和速度曲线类型。v-enter-to
: 进入动画的结束状态。在元素插入完成后的下一帧被添加(也就是v-enter-from
被移除的同时), 在过渡或动画完成之后移除。v-leave-from
: 离开动画的起始状态。在离开过渡效果被触发时立即添加,在一帧后被移除。v-leave-active
: 离开动画的生效状态。应用于整个离开动画阶段。在离开过渡效果被触发时立即添加,在过渡或动画完成之后移除。这个 class 可以被用来定义离开动画的持续时间、延迟与速度曲线类型。v-leave-to
: 离开动画的结束状态,在一个离开动画被触发后的下一帧被添加(也就是v-leave-from
被移除的同时)在过渡或动画完成之后移除。
v-enter-active
和 v-leave-active
给我们提供了为进入和离开动画不同速度曲线的能力。
-
为过渡效果命名
可以给<Transition>
组件传一个name
prop 来声明一个过渡效果名:<Transition name="fade"> ... </Transition>
对于一个有名字的过渡效果,对它起作用的过渡 class 会以其名字而不是 v 作为前缀。比如,上方例子中被应用的 class 将会是
fade-enter-active
而不是v-enter-active
。这个fade
过渡的 class 应该是这样:.fade-enter-active, .fade-leave-active { transition: opacity 0.5s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; }
-
css 的 transition
<Transition>
一般会搭配原生 css 过渡使用。这个transition
css 属性是一个简写形式。一次定义一个过渡的各个方面,包括需要执行动画的属性、持续时间和速度曲线。<Transition name="slide-fade"> <p v-if="show">hello</p> </Transition>
.slide-fade-enter-active { transition: all 0.3s ease-out; } .slide-fade-leave-active { transition: all 0.8s cubic-bezier(1, 0.5, 0.8, 1); } .slide-fade-enter-from, .slide-fade-leave-to { transform: translateX(10px); opacity: 0; }
-
css 的 animation
原生 css 动画和 css transition 的应用方式基本上是相同的,只有一点不同,那就是*-enter-from
不是在元素插入后立即移除,而是在一个animationend
事件触发时被移除。
对于大多数 css 动画,可以简单地在*-enter-active
和*-leave-active
class 下声明它们。示例:<Transition name="bounce"> <p v-if="show" style="text-align: center"> Hello here is some bouncy text! </p> </Transition>
.bounce-enter-active { animation: bounce-in 0.5s; } .bounce-leave-active { animation: bounce-in 0.5s reverse; } @keyframes bounce-in { 0% { transform: scale(0); } 50% { transform: scale(1.5); } 100% { transform: scale(1); } }
-
自定义过渡 class
可以向<Transition>
传递以下的 props 来指定自定义的过渡 class:enter-from-class
enter-active-class
enter-to-class
leave-from-class
leave-active-class
leave-to-class
传入的这些 class 会覆盖默认的 class。这个功能对于继承其他第三方 css 动画库是非常有用的。
<!-- 假设引入了 Animate.css --> <Transition name="custom-classes" enter-from-class="animate__animated animate__tada" enter-active-class="animate__animated animate__bounceOutRight"> <p v-if="show">hello</p> </Transition>
-
同时使用 transition 和 animation
Vue 需要附加事件监听器,以便知道过渡何时结束。可以是transitionend
或animationend
,这取决所应用的 css 规则。如果仅用到二者的其中之一,Vue 可以自动探测到正确的类型。
在某些场景中,想要在同一元素上同时使用它们两个。举例来说,Vue 触发了一个 css 动画,同时鼠标悬停触发另一个 css 过渡。此时需要显示地传入type
prop 来声明,告诉 Vue 需要关心哪种类型,传入的值是animation
或transition
:<Transition type="animation">...</Transition>
-
深层级过渡与显式过渡时长
尽管过渡 class 仅能应用在<Transition>
的直接子元素上,还是可以使用深层级的 css 选择器,在深层级的元素上触发过渡效果。<Transition name="nested"> <div v-if="show" class="outer"> <div class="inner">Hello</div> </div> </Transition>
/* 应用于嵌套元素的规则 */ .nested-enter-active .inner, .nested-leave-active .inner { transition: all 0.3s ease-int-out; } .nested-enter-from .inner, .nested-leave-to .inner { transform: translateX(10px); opacity: 0; }
-
性能考量
css 属性大多是transform
和opacity
之类的,用这些属性制作动画非常高效,因为:- 他们在动画过程中不会影响到 DOM 结构,因此不会每一帧都触发昂贵的 css 布局重新计算。
- 大多数的现代浏览器都可以在执行
transform
动画时利用 GPU 进行硬件加速。
相比之下,像
height
或者margin
这样的属性会触发 css 布局变动,因此执行他们的动画效果更昂贵,需要谨慎使用。
-
-
JavaScript 钩子
可以通过监听<Transition>
组件事件的方式在过渡过程中挂上钩子函数:<Transition @before-enter="beforeEnter" @enter="enter" @after-enter="afterEnter" @enter-cancelled="onEnterCancelled" @before-leave="beforeLeave" @leave="onLeave" @after-leave="onAfterLeave" @leave-cancelled="onLeaveCancelled"> <!-- ... --> </Transition>
// 在元素被插入到DOM之前被调用 function onBeforeEnter(el) {} // 在元素被插入到DOM之后的下一帧被调用 // 用这个来开始进入动画 function onEnter(el, done) { // 调用回调函数 done 表示过渡结束 // 如果与css结合使用,这个回调是可选参数 done(); } // 当进入过渡完成时调用 function onAfterEnter(el) {} // 当进入过渡在完成之前被取消时调用 function onEnterCancelled(el) {} // 在leave钩子之前调用 // 大多数时候,应该只会用到leave钩子 function onBeforeLeave(el) {} // 在离开过渡开始时调用 // 用这个来开始离开动画 function onLeave(el, done) { // 调用回调函数 done 表示过渡结束 // 如果与css结合使用,则这个回调是可选参数 done(); } // 在离开过渡完成 // 且元素已从DOM中移除时调用 function onAfterLeave(el) {} // 仅在 v-show 过渡中可用 function onLeaveCancelled(el) {}
这些钩子可与 css 过渡或动画结合使用,也可以单独使用。
在使用仅由 JavaScript 执行的动画时,最好是添加一个
:css="false"
prop。这显示地向 Vue 表明可以跳过 CSS 过渡的自动探测。除了性能好点外,可以防止 css 规则干扰过渡效果。<Transition ... :css="false"> ... </Transition>
在有了
:css="false"
后,就自己全权负责控制什么时候过渡结束了。对于@enter
和@leave
钩子来说,回调函数done
就是必须的。否则,钩子将被同步调用,过渡将立即完成。 -
可复用过渡效果
过渡效果是可以被封装复用的,要创建一个可被复用的过渡,我们需要为<Transition>
组件创建一个包装组件,并向内传入插槽内容:<!-- MyTransition.vue --> <script> // JavaScript 钩子逻辑... </script> <template> <Transition name="my-transition" @enter="onEnter" @leave="onLeave"> <slot></slot ><!-- 向内传递插槽内容 --> </Transition> </template> <style> /* 必要的 CSS... 注意:避免在这里使用 <style scoped> 因为那不会应用到插槽内容上 */ </style>
现在
MyTransition
可以在导入后像内置组件那样使用了:<MyTransition> <div v-if="show">Hello</div> </MyTransition>
-
出现时过渡
在某个节点初次渲染时应用一个过渡效果,可以添加appear
prop:<Transition appear> ... </Transition>
-
元素间过渡
除了通过v-if
/v-show
切换一个元素,还可以通过v-if
/v-else
/v-else-if
在几个组件间进行切换,只要确保任意时刻只会有一个元素被渲染即可:<Transition> <button v-if="docState==='saved'">Edit</button> <button v-else-if="docState==='edited'">Save</button> <button v-else-if="docState==='editing'">Cancel</button> </Transition>
-
过渡模式
在之前的例子中,进入和离开的元素都是在同时开始动画的,因此不得不将它们设为 position: absolute。以避免二者同时存在时出现的布局问题。
然而,很多情况下这可能并不符合需求。我们可能想要先执行离开动画,然后在其完成之后再执行元素的进入动画。手动编排这样的动画是非常复杂的,好在我们可以通过向<Transition>
传入一个mode
prop 来实现这个行为:<Transition mode="out-in"> ... </Transition>
将之前的例子改为
mode="in-out"
,虽然这并不常用。 -
组件间过渡
<Transition>
也可以作用于动态组件之间的切换:<Transition name="fade" mode="out-in"> <component :is="activeComponent"></component> </Transition>
-
动态过渡
<Transition>
的 props(比如name
)也可以是动态的!我们可以更具状态变化动态应用不同类型的过渡:<Transition :name="transitionName"> <!-- ... --> </Transition>
5.2 TransitionGroup
<TransitionGroup>
是一个内置组件,用于对v-for
列表中的元素或组件的插入、移除和顺序改变添加动画效果。
-
和
<Transition>
的区别
<TransitionGroup>
支持和<Transition>
基本相同的 props、CSS 过渡 class 和 JavaScript 钩子监听器,但有以下几点区别:- 默认情况下,它不会渲染一个容器元素。但可以通过传入
tag
prop 来指定一个元素作为容器元素来渲染。 - 过渡模式在这里不可用,因为我们不再是互斥的元素之间进行切换。
- 列表中的每个元素都必须有一个独一无二的
key
attribute。 - CSS 过渡 class 会被应用在列表内的元素上,而不是容器元素上。 -进入/离开动画
这里是<TransitionGroup>
对一个v-for
列表添加进入/离开动画的示例:
<TransitionGroup name="list" tag="ul"> <li v-for=item in items" :key="item"> {{ item }} </li> </TransitionGroup>
.list-enter-active, .list-leave-active { transition: all 0.5s ease; } .list-enter-from, .list-enter-to { opacity: 0; transform: translateX(30px); }
- 默认情况下,它不会渲染一个容器元素。但可以通过传入
-
移动动画
上面的示例有一些明显的缺陷:当某一项被插入或移除时,它周围的元素立即发生“跳跃”而不是平稳地移动。可以通过添加额外的 css 规则来解决这问题:.list-move, .list-enter-active, .list-leave-active { transition: all 0.5s ease; } .list-enter-from, .list-leave-to { opacity: 0; transform: translateX(30px); } /* 确保将离开的元素从布局流中删除 以便能够正确地计算移动的动画。 */ .list-leave-active { position: absolute; }
-
渐进延迟列表动画
<TransitionGroup tag="ul" :css="false" @before-enter="onBeforeEnter" @enter="onEnter" @leave="onLeave"> <li v-for="(item, index) in computedList" :key="item.msg" :data-index="index"> {{item.msg}} </li> </TransitionGroup>
接着,在 JavaScript 钩子中,就当前元素的 data attribute 对该元素的进场动画添加一个延迟。下面是一个基于 GreenSock library 的动画示例:
function onEnter(el, done) { gasp.to(el, { opacity: 1, height: '1.6em', delay: el.dataset.index * 0.15; onComplete: done }) }
5.3 KeepAlive
<KeepAlive>
是一个内置组件,它的功能是在多个组件间动态切换时缓存被移除的组件实例。
-
基本使用
<KeepAlive> <component :is="activeComponent" /> </KeepAlive>
组件切换状态也能被保留了。
-
包含/排除
<KeepAlive>
默认会缓存内部的所有组件实例,但可以通过include
和exclude
prop 来定制该行为。这两个 prop 的值都可以是一个以英文逗号分割的字符串、一个正则表达式,或是包含这两种类型的一个数组:<!-- 以英文逗号分隔的字符串 --> <KeepAlive include="a,b"> <component :is="view" /> </KeepAlive> <!-- 正则表达式 --> <KeepAlive :include="/a|b/"> <component :is="view" /> </KeepAlive> <!-- 数组 --> <KeepAlive :include="['a', 'b']"> <component :is="view" /> </KeepAlive>
它会根据组件的
name
选项来进行匹配,所以组件如果想要条件性地被KeepAlive
缓存,就必须显式声明一个name
选项。 -
最大缓存实例数
可以通过传入max
prop 来限制可被缓存的最大组件实例数。<KeepAlive>
的行为在指定了max
后类似一个 LRU 缓存:如果缓存的实例数量即将超过指定的那个最大数量,则醉酒没有被访问到缓存实例将被销毁,以便为新的实例腾出空间。<!-- 如果传了10 --> <keepAlive :max="10"> <component :is="activeComponent" /> </keepAlive>
-
缓存实例的生命周期
当一个组件实例从 DOM 上移除但因为被<KeepAlive>
缓存作为组件树的一部分时,它将变为不活跃状态而不是被卸载。当组件实例作为缓存树的一部分插入到 DOM 中时,它将重新被激活。一个持续存在的组件可以通过
onActivated()
和onDeactivated()
注册相应的两个状态的生命周期钩子:<script setup> import { onActivated, onDeactivated } from 'vue'; onActivated(() => { // 调用时机为首次挂载 // 以及每次从缓存中被重新插入时 }); onDeactivated(() => { // 在从 DOM 上移除,进入缓存 // 以及组件卸载时调用 }); </script>
这两个钩子不仅适用于
<KeepAlive>
缓存的根组件,也适用于缓存树中的后代组件。
5.4 Teleport
<Teleport>
是一个内置组件,他可以将一个组件内部的一部分模板“传送”到该组件的 DOM 结构外层的位置去。
-
基本用法
有时可能会遇到一个场景:一个组件木板的一部分在逻辑上从属于该组件,但从整个应用视图的角度来看,它在 DOM 中应该被渲染在整个 Vue 应用外的其他地方。
最常见的例子就是全屏的模态框。理想情况下,我们希望触发模态框的按钮和模态框本身是在同一个组件中,因为它们都与组件的开关状态有关。但这意味着该模态框将与按钮一起渲染在应用 DOM 结构很深的地方。这会导致该模态框的 CSS 布局代码很难写。试想下面这样的 HTML 结构:
<div class="outer"> <h3>Tooltips with Vue 3 Teleport</h3> <div> <MyModal /> </div> </div>
接下来我们看看
<MyModal>
的实现:<script setup> import { ref } from 'vue'; const open = ref(false); </script> <template> <button @click="open = true">Open Modal</button> <div v-if="open" class="modal"> <p>Hello from the modal!</p> <button @click="open = false">Close</button> </div> </template> <style scoped> .modal { position: fixed; z-index: 999; top: 20%; left: 50%; width: 300px; margin-left: -150px; } </style>
当在初始 HTML 结构中使用整个组件时,会有一些潜在的问题:
- position: fixed 能够相对于浏览器窗口放置有一个条件,那就是不能有任何祖先元素设置
transform
、perspective
或者filter
样式属性。也就是说如果想要用 CSS
transform
为祖先节点<div class="outer"
> 设置动画,就会不小心破坏模态框的布局! - 该模态框的
z-index
受限于它的容器元素。如果有其他元素与<div class="outer">
重叠并有更高的z-index
,则他会覆盖该模态框。
<Teleport>
提供了一个更简单的方式来解决此类问题,让我们不需要再顾虑 DOM 结构的问题。用<Teleport>
改写一下<MyModal>
:<button @click="open = true">Open Modal</button> <Teleport to="body"> <div v-if="open" class="modal"> <p>Hello from the modal!</p> <button @click="open = false">Close</button> </div> </Teleport>
<Teleport>
接受一个to
prop 来指定传送的目标。to
的值可以是一个 CSS 选择器字符串,也可以是一个 DOM 元素对象。这段代码的作用就是告诉 Vue "把"以下木板片段传送到body
标签下。
<Teleport>
也可以和其他内置组件一起使用,如:<Transition>
。 - position: fixed 能够相对于浏览器窗口放置有一个条件,那就是不能有任何祖先元素设置
-
搭配组件使用
<Teleport>
只改变了渲染的 DOM 结构,它不会影响组件间的逻辑关系。如果<Teleport>
包含了一个组件,那么该组件始终和这个使用了<Teleport>
组件保持逻辑上的父子关系。传入的 props 和触发的事件也会照常工作。 -
禁用 Teleport
在某些场景下可能需要视情况禁用<Teleport>
。举例来说,想要在桌面端将一个组件当做浮层来渲染,但在移动端则当做行内组件。可以通过对<Teleport>
动态传入一个disabled
prop 来处理。
<Teleport :disabled="isMobile"> ... </Teleport>
- 多个 Teleport 共享目标
一个可冲用的模态框组件可能同时存在多个实例。多个<Teleport>
组件可以将其内容挂载在同一个目标元素上,而顺序就是简单的顺次追加,后挂载点将排在目标元素下更后面的位置上。
<Teleport to="#modals">
<div>A</div>
</Teleport>
<Teleport to="#modals">
<div>B</div>
</Teleport>
渲染的结果为:
<div id="modals">
<div>A</div>
<div>B</div>
</div>
5.5 Suspense
是个实验性功能
<Suspense>
是一个内置组件,用来在组件树中协调异步依赖的处理。让我们在组件树上层等待下层的多个嵌套异步依赖项解析完成,可以在等待时渲染一个加载状态。
-
异步依赖
要了解<Suspense>
所解决的问题和他是如何与异步依赖进行交互的,需要想象这样一种组件层级结构:<Suspense> |_<Dashboard> |_<Profile> | |_<FriendStatus>(组件有异步的setup()) |_<Content> |_<ActivityFeed>(异步组件) |_<Stats>(异步组件)
在这个组件树中有多个嵌套组件,要渲染出它们,首先的解析一些异步资源。如果没有
<Suspense>
,则它们每个都需要处理自己的加载、报错和完成状态。在最坏的情况下,可能会在页面上看到三个旋转的加载态,在不同的时间显示出内容。有了
<Suspense>
组件后,就可以在等待整个多层级组件树中的各个异步依赖获取结果,在顶层展示出加载中或者加载失败的状态。<Suspense>
组件后,就可以在等待整个多层级组件树中的各个异步依赖获取结果是,在顶层展示出加载失败的状态。<Suspense>
可以等待的异步依赖有两种:- 带有异步
setup()
钩子组件。这也包含了使用<script setup>
时有顶层await
表达式的组件。 - 异步组件。
-
async setup()
组合式 API 中组件的setup()
钩子可以是异步的:export default { async setup() { const res = await fetch(...) const posts = await res.json() return { posts } } }
如果使用
<script setup>
,那么顶层await
表达式会自动让该组件成为一个异步依赖:<script setup> const res = await fetch(...) const posts = await res.json() </script> <template> {{posts}} </template>
-
异步组件
异步组件是suspensible
的。这意味着如果组件关系链上有一个<Suspense>
,那么这个异步组件就会当做这个<Suspense>
的一个异步依赖。在这个情况下,加载状态是由<Suspense>
控制,而该组件自己的加载、报错、延时和超时等选项都将被忽略。异步组件也可以通过在选项中指定
suspensible:false
表明不用Suspense
控制,并让组件始终自己控制其加载状态。
-
- 带有异步
-
加载中状态
<Suspense>
组件有两个插槽:#default
和#fallback
。两个插槽都只允许一个直接子节点。在可能的时候都将显式默认槽中的节点。否则将显示后备槽中的节点。<Suspense> <!-- 具有深层异步依赖的组件 --> <Dashboard /> <!-- 在#fallback 插槽中显式“正在加载中” --> <template #fallback> Loading... </template> </Suspense>
在初始渲染时,
<Suspense>
将在内存中渲染其默认的插槽内容。如果在这个过程中遇到任何异步依赖,则会进入挂起状态。在挂起状态期间,展示的是后备内容。当所有遇到的异步依赖都完成后,<Suspense>
会进入完成状态,并将展示出默认插槽的内容。如果在初次渲染时没有遇到异步依赖,
<Suspense>
会直接进入完成状态。进入完成状态后,只有当默认插槽的根节点被替换时,
<Suspense>
才会回到挂起状态。 -
和其他组件结合
<RouterView v-slot="{ Component }"> <template> <Transition mode="out-in"> <KeepAlive> <Suspense> <!-- 主要内容 --> <component :is="Component"></component> <!-- 加载中状态 --> <template #fallback> 正在加载... </template> </Suspense> </KeepAlive> </Transition> </template> </RouterView>
6 应用规模化
6.1 介绍
Vue 的单文件组件(即*.vue
)文件,英文 Single-File Component,简称 SFC)是一种特殊的文件格式,使我们能够将一个 Vue 组件的模板、逻辑与样式封装在单个文件里。下面是示例:
<script setup>
import { ref } from 'vue';
const greeting = ref('Hello World!');
</script>
<template>
<p class="greeting">{{ greeting }}</p>
</template>
<style>
.greeting {
color: red;
font-weight: bold;
}
</style>
Vue 的单文件组件是网页开发中 HTML、CSS 和 JavaScript 三种语言经典组合的自然延伸。<template>
、<script>
和 <style>
三个快在同一个文件里封装、组合了组件的视图、逻辑和样式。
- 为什么要用 SFC
使用 SFC 必须使用构建工具,但作为回报带来了一下优点:- 使用熟悉的 HTML、CSS 和 JavaScript 语法编写模块化组件
- 让本来就强相关的关注点自然内聚
- 预编译模板,避免运行时的编译开销
- 组件作用域的 CSS
- 在使用组合式 API 的语法更简单
- 通过交叉分析模板和逻辑代码能够进行更多编译时优化
- 更好地 IDE 支持,提供自动不全和对模板中表达式的类型检查
- 开箱即用的模块热更新(HMR)支持
SFC 是 Vue 框架提供的一个功能,并且在下列场景中都是官方推荐的项目组织方式:
- 单页面应用(SPA)
- 静态站点生成(SSG)
- 任何值得引入构建步骤一伙的更好地开发体验(DX)的项目
当然,在一些轻量级场景下使用 SFC 会显得有些杀鸡用牛刀。如果你的用例只需要给静态的 HTML 添加一些简单的交互,可以看看 petite-vue。
- SFC 如何工作
SFC 文件交由@vue/compiler-sfc 编译为 JavaScript 和 CSS,一个变异后的 SFC 是一个标准的 JavaScript(ES)模块,这也意味着在构建配置正确的前提下,可像导入其他 ES 模块一样导入 SFC:
import MyComponent from './MyComponent.vue';
export default {
components: {
MyComponent,
},
};
SFC 中的<style>
标签一般会在开发时注入成原生的<style>
标签以支持热更新,而生产环境下它们会被抽取、合并成单独的 CSS 文件。
-
如果看待关注点分类
传统 Web 开发背景的用户可能会因为 SFC 讲不通的关注点集合在一处而有所顾虑,觉得 HTML/CSS/JS 应当是分离开的!要回答这个问题,必须对这一点达成共识:前端开发的关注点不是完全基于文件类型分离的。前端工程化的最终目的都是为了更好地维护代码,关注点分离不应该是教条式地将其视为文件类型的区分和分离,仅仅这样并不能够帮我们在日益复杂的前端应用背景下提高开发效率。
6.2 工具链
-
项目脚手架
-
Vite
Vite 是一个轻量级的、速度极快的构建工具,对 Vue SFC 提供第一优先级支持。
要使用 Vite 来创建一个 Vue 项目,非常简单:npm create vue@latest
yarn create vue@latest
-
Vue CLI
Vue CLI 是官方提供的基于 Webpack 的 Vue 工具链,他现在处于维护模式,建议使用 Vite 开始新的项目,除非你伊莱特成都 Webpack 的特性。在大多数情况下,vite 将提供更优秀的开发体验。 -
浏览器内模板编译注意事项
当以无构建步骤方式使用 Vue 时,组件模板要么是写在页面的 HTML 中,要么是内联的 JavaScript 字符串。在这些场景中,为了执行动态模板编译,Vue 需要将模板编译器运行在浏览器中。相对的,如果我们使用了构建步骤,由于提前编译了模板,那么就无须在浏览器中运行了。为了减小打包出的客户端代码体积,Vue 提供了多种格式的“构建文件”以适配不同场景下的优化需求。- 前缀为
vue.runtime.*
的文件是只包含运行时的版本:不包含编译器,当使用这个版本时,所有的模板都必须由构建步骤预先编译。 - 名称中不包含
.runtime
的文件则是完全版:既包含了编译器,并支持在浏览器中直接编译模板。然而,1 体积也会因此增长大约 14kb。
默认的工具链中都会使用仅含运行时的版本,因为所有的 SFC 中的模板已经被预编译过了。
- 前缀为
-
-
代码规范
Vue 团队维护着 eslint-plugin-vue 项目,是一个 ESLint 插件,提供 SFC 相关规则的定义。之前使用 Vue CLI 的用户可能习惯于通过 webpack loader 来配置规范检查器。然而,若基于 Vite 构建,我们一般推荐:
npm install -D eslint-plugin-vue
,然后遵循eslint-plugin-vue
的指引进行配置。- 启用 ESLint IDE 插件,比如 ESLint for VSCode,然后就可以在开发时活动规范检查器的反馈。这同时避免了启动开发服务器是不必要的规范检查。
- 将 ESLint 格式检查作为一个生产构建的步骤,保证可以在最终大伯啊是会的完整的规范检查反馈。
- 启用类似 lint-staged 一类的工具在 git commit 提交时自动执行规范检查。
-
格式化
-
SFC 自定义块集成
-
底层库
6.3 路由
-
客户端 vs. 服务端路由
服务端路由指的是服务器根据用户访问的 URL 路径返回不同的响应结果。当我们在一个传统的服务端渲染的 web 应用中点击一个链接时,浏览器会从服务端获得全新的 HTML,然后重新加载整个页面。然而,在单页面应用中,客户单的 JavaScript 可以拦截页面的跳转请求,动态获取新的数据,然后在无需重新加载的情况下更新当前页面。这样通常可以带来更顺滑的用户体验,尤其是在更偏向“应用”的场景下,因为这类场景下用户通常会在很长的一段时间中做出多次交互。
在这类单页应用中,“路由”是在客户端执行的。一个客户端路由器的职责就是利用诸如 History API 或是 hashchange 事件这样的浏览器 API 来管理应用当前应该渲染的视图。
-
官方路由
-
从头开始实现一个简单的路由
如果只需要一个简单的页面路由,而不想为此引入一整个路由库,可以通过动态组件的方式,监听浏览器 hashchange 事件或使用 History API 来更新当前组件。
下面是一个简单的例子:<script setup> import { ref, computed } from 'vue'; import Home from './Home.vue'; import About from './About.vue'; import NotFound from './NotFound.vue'; const routes = { '/': Home, '/about': About, }; const currentPath = ref(window.location.hash); window.addEventListener('hashchange', () => { currentPath.value = window.location.hash; }); const currentView = computed(() => { return routes[currentPath.value.slice(1) || '/'] || NotFound; }); </script> <template> <a href="#/">Home</a> | <a href="#/about">About</a> | <a href="#/non-existent-path">Broken Link</a> <component :is="currentView" /> </template>
6.3 状态管理
-
什么是状态管理?
理论上来说,给一个 Vue 组件实例都已经在“管理”它自己的响应式状态了。我们以一个简单的计数器组件为例:<script setup> import { ref } from 'vue'; // 状态 const count = ref(0); function increment() { count.value++; } </script> <!-- 视图 --> <template>{{ count }}</template>
它是一个独立的单元,由以下几个部分组成:
- 状态:驱动整个应用的数据源
- 视图:对状态的一种声明式映射
- 交互:状态根据用户在视图中的输入而作为相应变更的可能方式
下面是“单向数据流”这一概念的简单图示:
然而,当我们有多个组件共享一个共同的状态时,就没有这么简单了:
- 多个视图可能都依赖于同一份状态
- 来自不同时的交互也可能需要更改同一份状态
对于情景 1,一个可行的办法是将共享状态“提升”到共同的祖先组件上去,在通过 props 传递下来。然而在深层次的组件树结构中这么做的话,很快就会是的代码变得繁琐冗长。这会导致另一个问题:Prop 逐级透传问题。
对于情景 2,我们经常发现自己会直接通过模板引用获取父/子实例,或者通过出发点事件尝试改变和同步多个状态的副本。但这些模式的健壮性都不甚理想,很容易就会导致代码难以维护。
一个更简单直接的解决方案是抽取出组件间的共享状态,放在一个全局单例中来管理。这样我们的组件树就变成了一个打到“视图”,而任何位置上的组件都可以访问其中的状态或触发动作。
-
用响应式 API 做简单状态管理
如果你有一部分状态需要再多个组件实例间共享,可以使用reactive()
来创建一个响应式对象,并将它导入到多个组件中:// store.js import { reactive } from 'vue'; export const store = reactive({ count: 0, });
<!-- ComponentA.vue --> <script setup> import { store } from './store.js'; </script> <template> From A: {{ store.count }} </template>
<!-- ComponentB.vue --> <script setup> import { store } from './store.js'; </script>
现在每当 state 对象被更改时,
<ComponentA>
与<ComponentB>
都会自动更新它们的视图。
然而,这也意味着任意一个导入了store
的组件都可以随意修改它的状态:<template> <button @click="store.count++">From B: {{store.count}}</button> </template>
虽然这在简单情况下可行,但从长远来看,可悲任何组件任意改变的全局状态是不太容易维护的,为了确保改变状态的逻辑像状态本身一样集中,建议在 store 上定义方法,方法名称应该要能表达出行动意图:
// store.js import { reactive } from 'vue'; export const store = reactive({ count: 0, increment() { this.count++; }, });
<template> <button @click="store.increment()">From B: {{ store.count }}</button> </template>
除了单个响应式对象作为一个 store 之外,还可以使用其他响应式 API 例如
ref()
或是computed()
,或是甚至通过一个组合式函数来返回一个全局状态:import { ref } from 'vue'; // 全局状态,创建在模块作用域下 const globalCount = ref(1); export function useCount() { // 局部状态,每个组件都会创建 const localCount = ref(1); return { globalCount, localCount, }; }
事实上,Vue 的响应性系统与组件层是解耦的,这使得它非常灵活。
-
SSR 相关细节
-
Pinia
6.4 测试
- 测试的类型
当设计的 Vue 应用的测试策略是,你应该利用一下集中测试类型:- 单元测试:检查给定函数、类或组合式函数的输入是否产生预期的输出或副作用。
- 组件测试:检查你的组件是否正常挂载和渲染、是否可以与之互动,以及表现是否符合预期。这些测试比单元测试导入了更多的代码,更复杂,需要更多时间来执行。
- 端到端测试:检查跨越多个页面的功能,并对生产构建的 Vue 应用进行实际的网络请求。这些测试通常涉及到建立一个数据库或其他后端。
7 最佳实践
7.1 生产部署
- 开发环境 vs. 生存环境
在开发的过程中,Vue 提供了许多功能来提升开发体验:- 对常见错误和隐患的警告
- 对组件 props / 自定义事件的校验
- 响应性调试钩子
- 开发工具集成
然而,这些功能在生产环境中并不会被使用,一些警告检查也会产生少量的性能开销。当部署到生产环境是,我们应该移除所有未使用的、仅用于开发环境的代码分支,来获得更小的包体积和更好的性能。
-
不使用构建工具
如果不使用构建工具,而是从 CDN 或其他来源加载 Vue,确保部署时使用的是生产环境版本(以.prod.js 结尾的文件)。生产环境版本会被最小化,并移除了所有仅用于开发环境的代码分支。- 如果需要使用全局变量版本(通过 Vue 全局变量访问):使用
vue.global.prod.js
。 - 如果需要使用 ESM 版本(通过原生 ESM 导入访问):请使用
vue.esm-browser.prod.js
。
- 如果需要使用全局变量版本(通过 Vue 全局变量访问):使用
-
使用构建工具
通过create-vue
(基于 Vite)或是 Vue CLI(基于 webpack)搭建的项目都已经预先做好了针对生产环境的配置。
如果使用了自定义的构建,请确保:vue
被解析为vue.runtime.esm-bundler.js
。- 编译时功能标记已被正确配置。
process.env.NODE_ENV
会在构建市被替换为 “production”。
-
运行时追踪错误
import { createApp } from 'vue' const app = createApp(...) app.config.errorHandler = (err, instance, info) => { // 向追踪服务报告错误 }
7.2 优化性能
-
概述
web 应用性能的两个主要方面:- 页面加载性能:首次访问时,应用展示出内容与达到可交互状态的速度。这通常会 Google 所定义的一些列 Web 指标(Web Vitals)来进行衡量,如最大内容绘制(Largest Contentful Paint, 缩写为 LCP)和首次输入延迟(First Input Delay, 缩写为 FID)。
- 更新性能:应用响应用户输入更新的速度。比如当用户在搜索框中输入时结果列表的更新速度,或者用户在一个单页面应用(SPA)中点击连接跳转页面时的切换速度。
-
分析选项
… -
页面加载优化
-
选用正确的架构
如果用例对页面加载性能很敏感,请避免将其部署位纯客户端的 SPA,而是让服务器直接发送包含用户想要查看的内容的 HTML 代码。纯客户端渲染存在首屏加载缓慢问题,这可以通过服务器端渲染 SSR 或静态站点生成 SSG 来缓解。查看 SSR 指南以了解如何使用 Vue 实现 SSR。如果应用对交互性要求不高,开可以使用传统的后端服务器来渲染 HTML,并在客户端使用 Vue 对其进行增强。 -
包体积和 Tree-shaking 优化
一个最有效的提升页面加载速度的方法就是压缩 JavaScript 打包产物的体积。当使用 Vue 时有下面一些办法来减小打包产物体积:- 尽可能地采用构建步骤
- 如果使用的是相对现代的打包工具,许多 Vue 的 API 都是可以被 tree-shake 的。举例来说,如果你根本没有用到内置的
<Transition>
组件,它将不会被打爆进入最总的产物里。Tree-shaking 也可以移除你源代码中其他未使用到的模块。 - 当使用了构建步骤时,模板会被预编译,因此无须在浏览器中载入 Vue 编译器。这在同样最小化加上 gzip 优化项会相对缩小 14kb 并避免运行时的编译开销。
- 如果使用的是相对现代的打包工具,许多 Vue 的 API 都是可以被 tree-shake 的。举例来说,如果你根本没有用到内置的
- 在引入新的依赖项是要小心包体积膨胀!在现实的应用中,包体积膨胀通常因为无意识地引入了过重的依赖导致的。
- 如果使用了构建步骤,应当尽量选择提供 ES 模块格式的依赖,它们地 tree-shaking 更友好。举例来说,选择 lodash-es 比 lodash 更好。
- 查看依赖的体积,并评估与其提供的功能之间的性价比。如果依赖对 tree-shaking 友好,实际增加的体积大小将取决于你从它中导入的 API。像 bundlejs.com 这样的工具可以用来做快速的检查,但是根据实际的构建设置来评估总是最准确的。
- 如果你只在渐进式增强的场景下使用 Vue,并想要避免使用构建步骤,考虑使用 petite-vue 代替。
- 尽可能地采用构建步骤
-
代码分割
代码分割指构建工具将构建后的 JavaScript 包拆分为多个较小的,可以按需或者并行加载的文件。通过适当的代码分割,页面加载时需要的功能可以立即下载,而额外的块只在需要时才加载,从而提高性能。像 Rollup(vite 就是基于它之上开发的)或者 webpack 这样的打包工具可以通过分析 ESM 动态导入的语法来自动进行代码分割:
// lazy.js 及其依赖会被拆分到一个单独的文件中 // 并只在`loadLazy()`调用时才加载 function loadLazy() { return import('./lazy.js'); }
懒加载对于页面初次加载时代优化帮助极大,它帮助应用暂时略过了那些不是立即需要的功能。在 Vue 应用中,这可以与 Vue 的异步组件搭配使用,为组件树创建分离的代码块:
import { defineAsyncComponent } from 'vue'; // 会为 Foo.vue 及其依赖创建单独的一个块 // 它只会按需加载 // (即该异步组件在页面中被渲染时) const Foo = defineAsyncComponent(() => import('./Foo.vue'));
对于使用了 Vue Router 的应用,强烈建议使用异步组件作为路由组件。Vue Router 已经显性地支持了独立于
defineAsyncComponent
的懒加载。
-
-
更新优化
-
Props 稳定性
在 Vue 之中,一个子组件只会在其至少一个 props 改变时才会更新。<ListItem v-for="item in list" :id="item.id" :active-id="activeId" />
在
<ListItem>
组件中,它使用了id
和activeId
两个 props 来确定它是否当前活跃的那一项。虽然这是可行的,但问题是每当activeId
更新时,列表中的每一个<ListItem>
都会跟着更新!
理想情况下,只有活跃状态发生改变的项才应该更新。我们可以将活跃状态比对的逻辑移入父组件来实现这一点,然后让<ListItem>
改为接收一个active
prop:<ListItem v-for="item in list" :id="item.id" active="item.id === activeId" />
-
v-once
v-once
是一个内置的指令,可以用来渲染依赖运行时数据但无需再更新的内容。它的整个子树都会在未来的更新中被跳过。 -
v-memo
v-memo
是一个内置指令,可以用来有条件地跳过某些大型子树或者v-for
列表的更新。 -
计算属性稳定性 3.4+
从 3.4 开始,计算属性仅在其计算值较前一个值发生更改时才会触发副作用。例如,以下isEven
计算属性仅在返回值从true
更改为false
时才会触发副作用,反之亦然:const count = ref(0); const isEven = computed(() => count.value % 2 === 0); watchEffect(() => console.log(isEven.value)); // true // will not trigger new logs because the computed value stays `true` count.value = 2; count.value = 4;
这减少了非必要副作用的触发。但不幸的是,如果计算属性在每次计算式都创建一个新对象,则不起作用:
const computedObj = computed(() => { return { isEven: count.value % 2 === 0, }; });
由于每次都会创建一个新对象,因此从技术上将,新旧值始终不同。即使
isEven
保持不变,Vue 也无法知道,除非他对旧值和新值进行深度比较。这种比较可能高昂,并不值得。相反,我们可以通过手动比较新旧值来优化。如果我们知道没有变化,则有条件地返回旧值:
const computedObj = computed(oldValue => { const newValue = { isEven: count.value % 2 === 0, }; if (oldValue && oldValue.isEven === newValue.isEven) { return oldValue; } return newValue; });
-
-
通用优化
-
大型虚拟列表
所有的前端应用中最常见的性能问题就是渲染大型列表。无论一个框架性能有多好,渲染成千上万的列表项都会变得很慢,因为浏览器需要处理大量的 DOM 节点。但是,我们并不需要立刻渲染出全部的列表。在大多数场景中,用户的屏幕尺寸只会展示这个巨大列表的一小部分。可以通过列表虚拟化来提升性能,这项技术使我们只需要渲染用户视口中能看到的部分。
- vue-virtual-scroller
- vue-virtual-scroll-grid
- vueuc/VVirtaulList
-
减少大型不可变数据的响应性开销
Vue 的响应性系统默认是深度的。虽然这让状态管理变得更直观,但在数据量巨大时,深度响应性也会导致不晓得性能负担,因为每个属性访问都将触发代理的依赖追踪。好在这种性能负担通常只有在处理超大型数组或层级很深的对象时,例如一次渲染需要访问 100000+个属性是,才会变得比较明显。因此,它只会影响少数特定的场景。Vue 确实也为此提供了一种解决方案,通过使用
shallowRef()
和shallowReactive()
来绕开深度响应。浅层式 API 创建的状态只在其顶层是响应式的,对所有深层的对象不会做任何处理。这使得深层级属性的访问变得更快,但代价是,我们现在必须将所有深层级对象视为不可变的,并且只能通过替换整个状态来触发更新:const shallowArray = shallowRef([ // 巨大的列表,里面包含深层的对象 ]); // 这不会触发更新... shallowArray.value.push(newObject); // 这才会触发更新 shallowArray.value = [...shallowArray.value, newObject];
-
避免不必要的组件抽象
有些时候我们会去创建无渲染组件或者高阶组件(用来渲染具有额外 props 的其他组件)来实现更好的抽象或者代码组织。这并没有什么问题,但请记住,组件实例比普通 DOM 节点要昂贵得多,而且为了逻辑抽象创建太多组件实例将会导致性能损失。需要提醒的是,只减少几个组件实例对于性能不会有明显的改善,所以如果一个用于抽象的组件在应用中只会渲染几次,就不用操心去优化它了。考虑这种优化的最佳场景还是在大型列表中。想象以下有个 100 项的列表,没想的组件都包含许多子组件。在这里去掉一个不必要的组件抽象,可能会减少数百个组件实例的无所谓性能消耗。
-
7.3 无障碍访问
7.4 安全
-
首要规则:不要使用无法信赖的模板
使用 Vue 时最基本的安全规则就是不要将无法信赖的内容作为你的组件模板。使用无法新来的模板相当于允许任意的 JavaScript 在你的应用中执行。更糟糕的是,如果在服务端渲染时执行了这些代码,可能会导致服务器被攻击,例如:Vue.createApp({ template: `<div>` + userProvideString + `</div>`, }).mount('#app');
-
Vue 自身的安全机制
-
HTML 内容
无论是使用模板还是渲染函数,内容都是自动转义的。这意味着在这个模板中:<h1>{{ userProvideString }}</h1>
如果
userProvideString
包含了:'<script> alert('hi')</script>'
那么他将被转以为如下的 HTML:
<script>alert("hi")</script>
从而防止脚本注入。这种转义是使用
textContent
这样的浏览器原生 API 完成的,所以只有当浏览器本身存在漏洞时,才会存在漏洞。 -
Attribute 绑定
同样的,动态 attribute 的绑定也会被自动转义。这意味着在这个模板中:
<h1 :title="userProvideString">hello</h1>
如果
userProvideString
包含了:'"οnclick="alert(\'hi\')'
那么他将被转以为如下的 HTML:
"οnclick="alert('hi')
从而防止在
title
attribute 解析时,注入任意的 HTML。这种转义是使用setAttribute
这样的浏览器原生 API 完成的,只有当浏览器本身存在漏洞时,才会存在漏洞。 -
-
潜在的危险
在任何 Web 应用中,允许以 HTML、CSS 或 JavaScript 形式执行未经无害化处理的、用户提供的内容都有潜在的安全隐患,因此这应尽可能避免。不过,有时候一些风险或许是可以接受的。
例如,像 CodePen 和 JSFiddle 这样的服务允许执行用户提供的内容,但这是在 iframe 这样一个可预期的沙盒环境中。当一个重要的功能本身伴随某种程度的漏洞时,就需要你自行权衡该能带重要性和该漏洞所带来的最坏情况。-
注入 HTML
我们现在已经知道 Vue 会自动转义 HTML 内容,防止你意外地将可执行的 HTML 注入到你的应用中。然而,在你知道 HTML 安全的情况下,你还是可以显式地渲染 HTML 内容。- 使用模板:
<div v-html="userProvideHtml"></div>
- 使用渲染函数:
h('div', { innerHTML: this.userProvidedHtml, });
- 以 JSX 形式使用渲染函数:
<div innerHTML="{this.userProvidedHtml}"></div>
- 使用模板:
-
URL 注入
在这样一个使用 URL 的场景中:<a :href="userProvidedUrl">click me</a>
如果这个 URL 允许通过
javascript:
执行 JavaScript,即没有进行无害化处理,那么就会有一些潜在的安全问题。可以使用一些库来解决此类问题,比如 sanitize-url,但请注意:如果你发现你需要在前端做 URL 无害化处理,你的应用已经存在一个更严重的安全问题了。任何用户提供的 URL 再被保存到数据库之前都应该先在后端无害化处理。这样,连接你 API 的每一个客户端都可以避免这个问题,包括原生移动应用。另外,即使是经过无害化处理的 URL,Vue 也不能保证他们执行安全的目的地。 -
样式注入
例子:<a :href="sanitizedUrl" :style="userProvidedStyles">click me</a>"
我们假设
sanitizedUrl
已经进行无害化处理,是一个正常 URL 而非 JavaScript。然而,由于userProvidedStyles
的存在,恶意用户仍然能利用 CSS 进行“点击劫持”,例如,在“登录按钮”上方覆盖一个透明的链接。如果用户控制点页面https://user-controlled-website.com/
专门放早了你应用得登录页,那么它们就有可能捕获用户的真实登录信息。你可以想象,如果允许
<style>
元素中插入用户提供的内容,会造成更大的漏洞,因为这使得用户能控制整个页面的样式。因此 Vue 阻止了在模板中像这样渲染 style 标签:<style> {{ userProvidedStyles }} </style>
-
JavaScript 注入
强烈建议任何时候都不要在 Vue 中渲染<script>
,因为模板和渲染函数不应有其他副作用。但是,渲染<script>
并不是插入在运行时执行的 JavaScript 字符串的唯一方法。每个 HTML 元素都有能接受字符串形式 JavaScript 的 attribute,例如
onlclick
、onfocus
和onmouseenter
。绑定任何用户提供的 JavaScript 给这些事件 attribute 具有潜在风险,因此需要避免这么做。
-
-
最佳实践
-
后端协调
-
服务端渲染(SSR)
9 进阶主题
9.3 深入响应式系统
- 什么是响应式
本质上,响应性是一种可以使我们声明式地处理变化的编程范式。比如 Excel 表格:
A | B | C | |
---|---|---|---|
0 | 1 | ||
1 | 2 | ||
2 | 3 |
这里单元格 A2 中的值是通过公式=A0+A1
来定义的,因此最终得到的值为 3,正如所料。如果试着更改 A0 或者 A1,A2 的值也随机自动更新了。
那怎么在 JavaScript 中做到这一点呢?,为了能重新运行计算代码来更新 A2,我们需要将其包装为一个函数:
let A2;
function update() {
A2 = A0 + A1;
}
- 这个
update()
函数会产生一个副作用,或者就简称为作用(effect),因为它会更改程序里的状态。 A0
和A1
被视为这作用的依赖,因为他们的值被用来执行这个作用。因此这次作用可以说是它的依赖的的订阅者。
我们需要一个魔法函数,能够在A0
或A1
(这两个依赖)变化时调用update()
(产生作用)
whenDepsChange(update);
这个whenDepsChange()
函数有如下的任务:
- 当一个变量被读取时进行追踪。例如我们执行了表达式
A0 + A1
的计算,则A0
和A1
都被读取到了。 - 如果一个变量在当前运行的副作用中被读取到了,就将该副作用设为此变量的一个订阅者。例如由于
A0
+A1
在update()
执行时被访问到了,则update()
需要在第一次调用成为A0
和A1
的订阅者。 - 探测一个变量的变化。例如当我们给
A0
赋了一个新的值后,应该通知所有订阅了的副作用重新执行。
-
Vue 中的响应性是如何工作的
我们无法直接追踪对上述示例中局部变量的读写,原生 JavaScript 没有提供任何机制能做到这一点。但是,我们可以追踪对象属性的读写。在 JavaScript 中有两种劫持 property 访问的方式:getter/setters 和 Proxies。Vue2 使用 getter/setters 完全是出于支持旧版本浏览器的限制。而在 Vue3 中则使用了 Proxy 来创建响应式对象,仅将 getter/setter 用于 ref。下面的伪代码将会说明他们是如何工作的:
function reactive(obj) { return new Proxy(obj, { get(target, key) { track(target, key) return target[key] } set(target, key, value) { target[key] = value trigger(target, key) } }) } function ref(value) { const refObject = { get value() { track(refObject, 'value') return value; }, set value(newValue) { value = newValue; trigger(refObject, 'value') } } return refObject }
在 track()
内部,我们会检查当前是否有正在运行的副作用。如果有,会查找到一个存储了所有追踪了改属性的订阅者的 Set,然后将当前这个副作用作为邢丁月这添加到该 Set 中。
let activeEffect;
// 追踪对象属性
function track(target, key) {
if (activeEffect) {
const effects = getSubscribersForProperty(target, key);
effects.add(activeEffect);
}
}
副作用订阅将被存储到一个全局的WeakMap<target, Map<key, set<effect>>>
数据结构中。如果第一次追踪时没有找到对相应属性订阅的副作用集合,它将会在这里新建。这就是getSubscribersForProperty()
函数所做的事。为了简化描述,我们跳过了它其中的细节。
在trigger()
中,我们会再查到该属性的所有订阅副作用。这一次我们需要执行它们:
function trigger(target, key) {
const effects = getSubscribersForProperty(target, key);
effects.forEach(effect => effect());
}
现在让我们回到whenDepsChange()
函数中:
function whenDepsChange(update) {
const effect = () => {
activeEffect = effect;
update();
activeEffect = null;
};
effect();
}
它将原本的update
函数包装在了一个副作用函数中。在运行实际的更新之前,这个外部函数会将自己设为当前活跃的副作用。这使得在更新期间的track()
调用都能定位到这个当前活跃的副作用。
Vue 提供了一个 API 来让你创建响应式副作用watchEffect()
,事实上,它的使用方式和上面示例中的魔法函数whenDepsChange()
非常相似。可以用真正的 Vue API 改写上面的例子:
import { ref, watchEffect } from 'vue';
const A0 = ref(0);
const A1 = ref(1);
const A2 = ref();
watchEffect(() => {
// 追踪 A0 和 A1
A2.value = A0.value + A1.value;
});
// 触发副作用
A0.value = 1;
-
运行时 vs. 编译时响应性
Vue 的响应式系统是基于运行时的。追踪和触发都是在浏览器中运行时进行的。运行时响应性的优点是,他可以在没有构建步骤的情况下工作,而且边界情况较少。另一方面,这使得他受到了 JavaScript 语法的制约,导致需要使用一些例如 Vue ref 这样的值的容器。
一些框架,如 Svelte,选择通过编译时实现响应性来克服这种限制。它对代码进行分析和转换,来模拟响应性。该编译步骤允许框架改变 JavaScript 本身的语义——例如,隐式地注入执行依赖性分析的代码,以及围绕对本地定义的变量的访问进行作用触发。这样做的缺点是,该转换需要一个构建步骤,而改变 JavaScript 的语义实质上是在创造一种新语言,看起来像 JavaScript 但编译出来的东西是另外一回事。 -
响应性调试
-
组件调试钩子
可以在一个组件渲染时使用onRenderTracked
生命周期钩子来调试查看哪些依赖正在被使用,或是用onRenderTriggered
来确定哪个依赖正在触发更新。这些钩子都会收到一个调式事件,其中包含了触发相关事件的依赖的信息。推荐在回调中放置一个debugger
语句,是的可以在开发者工具中交互式地查看依赖:<script setup> import { onRenderTracked, onRenderTriggered } from 'vue'; onRenderTracked(e => { debugger; }); onRenderTriggered(e => { debugger; }); </script>
调试对象有如下的类型定义:
type DebuggerEvent = { effect: ReactiveEffect; target: object; type: | TrackOpTypes /* 'get' | 'has' | 'iterate' */ | TriggerOpTypes /* 'set' | 'add' | 'delete' | 'clear' */ key: any newValue?: any oldValue?: any oldTarget?: Map<any, any> | Set<any> }
TIP
组件调试钩子仅会在开发模式下工作
-
计算属性调试
可以向computed()
传入第二个参数,是一个包含了onTrack
和onTrigger
两个回调函数的对象:onTrack
: 将在响应属性或引用作为依赖项被跟踪时被调用。onTrigger
将在侦听器回调被依赖项的变更出发时被调用。
这两个回调都会作为组件调试的钩子,接受相同格式的调试事件:
const plusOne = computed(() => count.value + 1, { onTrack(e) { debugger } onTrigger(e) { debugger } }); // 访问 plusOne,会触发 onTrack console.log(plusOne.value); // count 值改变,会触发 onTrigger count.value ++
-
侦听器调试
和computed()
类似,侦听器也支持onTrack
和onTrigger
选项:watch(source, callback, { onTrack(e) { debugger; }, onTrigger(e) { debugger; }, }); watchEffect(callback, { onTrack(e) { debugger; }, onTrigger(e) { debugger; }, });
-
-
与外部状态系统集成
Vue 的响应式系统是通过转换普通 JavaScript 对象为响应式代理来实现的。这种深度转换在一些情况下是不必要的,在和一些外部状态管理系统集成时,甚至是需要避免的(例如,当一个外部的解决方案也用了 Proxy 时)。将 Vue 的响应性系统与外部状态管理方案集成到大致思路是:将外部状态放在一个
shallowRef
中。一个浅层的 ref 中只有它的.value
属性本身被访问时才是有响应性的,而不关心它内部的值,当外部状态改变时,替换此 ref 的.value
才会触发更新。不可变数据
如果你正在实现一个撤销/重做的功能,可能想要对用户编辑时应用的状态进行快照记录。然而,如果状态树很大的话,Vue 的可变响应性系统没法很好地处理这种情况,因为每次更新时都序列化整个状态对象对 CPU 和内存开销来说都是非常昂贵的。不可变数据结构通过永不更改状态对象来解决这个问题。与 Vue 不同的是,它会创建一个新对象,保留旧的对象未发生改变的一部分。在 JavaScript 中有多种不同的方式来使用不可变数据,但我们推荐使用 Immer 搭配 Vue,因为它使你可以在保持原有直观、可变的语法的同时,使用不可变数据。
我们可以通过一个简单的组合式函数来继承 Immer:
import produce from 'immer'; import { shallowRef } from 'vue'; export function useImmer(baseState) { const state = shallowRef(baseState); const update = updater => { state.value = produce(state.value, updater); }; return [state, update]; }
-
状态机
状态机是一种数据模型,用于描述应用可能处于的所有可能状态,以及从一种状态转换到另一种状态的所有可能方式。虽然对于简单的组件来说,这可能有些小题大做了,但他的确可以使复杂的状态流更加健壮和易于管理。XState 是 JavaScript 中一个比较常用的状态机实现方案。这里是集成它的一个例子:
import { createMachine, interpret } from 'xstate'; import { shallowRef } from 'vue'; export function useMachine(options) { const machine = createMachine(options); const state = shallowRef(machine.initialState); const service = interpret(machine) .onTransition(newState => (state.value = newState)) .start(); const send = event => service.send(event); return [state, send]; }
RxJS
RxJS 是一个用于处理异步事件流的库。VueUse 库提供了@vueuse/rxjs
扩展来支持连接 RxJS 流与 Vue 的响应式系统。 -
-
与信号(signal)的联系
很多其他框架已经引入了与 Vue 组合 API 中的 ref 类似的响应性基础类型,并称之为“信号”:- Solid 信号
- Angular 信号
- Preact 信号
- Qwik 信号
从根本上说,信号是与 Vue 中 ref 相同的响应性基础类型。它是一个在访问时跟踪依赖、在变更时触发副作用的值容器。这种基于响应性基础类型的范式在前段领域中并不是一个特别的概念:它可以追溯到十多年前端 Knockout observables 和 Meteor Tracker 等实现。Vue 的选 项式 API 和 React 的状态管理库 MobX 也是基于同样的原则,只不过将基础类型这部分隐藏在了对象属性背后。
虽然这并不是信号的必要特征,但如今这概念经常与细粒度订阅和更新的渲染模型一起讨论。由于使用了虚拟 DOM,Vue 目前依靠编译器来实现类似的优化。然而,我们也在探索一种新的受 Solid 启发的编译策略,它不依赖于虚拟 DOM,而是更多地利用 Vue 的内置响应性系统。
-
API 设计权衡
Preact 和 Qwik 的信号设计与 Vue 的 shallowRef 非常相似:三者都通过.value
属性提供了一个更改接口。Solid Signals
Solid 的createSignal()
API 设计强调了读/写隔离。信号通过一个只读的 getter 和另一个单独的 setter 暴露:const [count, setCount] = createSignal(0); count(); // 访问值 setCount(1); // 更新值
注意到
count
信号在没有 setter 的情况下也能传递。这就保证了除非 setter 也被明确暴露,否则状态永远不会被改变。import { shallowRef, triggerRef } from 'vue'; export function createSignal(value, options) { const r = shallowRef(value); const get = () => r.value; const set = v => { r.value = typeof v === 'function' ? v(r.value) : v; if (options?.equals === false) triggerRef(r); }; return [get, set]; }
- Angular 信号
Angular 正在经历一些底层的变化,它放弃了脏检查,并引入了自己的响应性基础类型实现。
Angular 的信号 API 看起来像这样:const count = signal(0); count(); // 访问值 count.set(1); // 设置值 count.update(v => v + 1); // 通过前值更新
- Angular 信号
同样,我们可以轻易地在 Vue 中复制这个 API:
import { shallowRef } from 'vue'; export function signal(initialValue) { const r = shallowRef(initialValue); const s = () => r.value; s.set = value => { r.value = value; }; s.update = updater => { r.value = updater(r.value); }; return s; }
9.4 渲染机制
-
虚拟 DOM
虚拟 DOM 是一种编程概念,意为将目标所需要的 UI 通过数据结构“虚拟”地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。与其说是一种技术,不如说是一种模式,所以并没有一个标准的实现。我们可以用一个简单的例子说明:
const vnode = { type: 'div', props: { id: 'hello', }, children: [ /** 更多 vnode */ ], };
这里所说的 vnode 即一个纯 JavaScript 的对象(一个“虚拟节点”),它代表这一个
<div>
元素。它包含我们创建实际元素所需的所有信息。它还包含更多地子节点,这使得它成为虚拟 DOM 树的根节点。一个运行时渲染器将会遍历整个虚拟 DOM 树,并据此构建真实的 DOM 树。这个过程被称为挂载(mount)。
如果我们有两份虚拟 DOM 树,渲染器将会比较地遍历它们,找出它们之间的区别,并应用这其中的变化到真实的 DOM 上。整个过程被称为更新(patch),又被成为“比对”(diffing)或“协调”(reconciliation)。
虚拟 DOM 带来的主要收益是它让开发者能够灵活、声明式地创建、检查和组合所需的 UI 的结构,同时只需要把具体的 DOM 操作留给渲染器去处理。
-
渲染管线
从高层面的视角看,Vue 组件挂载时会发生如下几件事:- 编译: Vue 模板被编译为渲染函数:即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。
- 挂载: 运行时渲染器调用功能渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为响应式副作用执行,因此它会追踪其中所用到的所有响应式依赖。
- 更新: 当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。
-
模板 vs. 渲染函数
Vue 模板会被预编译成虚拟 DOM 渲染函数。Vue 也提供了 API 使我们不使用模板编译,直接手写渲染函数。在处理高度动态的逻辑是,渲染函数相比于模板更加灵活,因为你可以完全地使用 JavaScript 来构造你想要的 vnode。- 模板更贴近实际的 HTML。这使得我们能够更方便地重用一些已有的 HTML 代码片段。能够带来更好的可访问性体验、能更方便地使用 CSS 应用样式,并且更容易使设计师理解修改。
- 由于期切丁的语法,更容易对模板做静态分析。这使得 Vue 的模板编译器能够应用许多编译时优化来提升虚拟 DOM 的性能表现。
在实践中,模板对大多数的应用场景都是够用且高效的。渲染函数一般只会在需要处理高度动态渲染逻辑的可重用组件中使用。
-
带编译时信息的虚拟 DOM
虚拟 DOM 大多数是纯运行时的:更新算法无法预知新的虚拟 DOM 树会是怎样,因此他总是需要遍历整棵树、比较每个 vnode 上 props 的区别来确保正确性。另外,即使一棵树的某个部分从未改变,还是在每次重新渲染时创建新的 vnode,带来了大量不必要的内存压力。这也是虚拟 DOM 最受诟病的地方之一:这种有点暴力的更新过程通过牺牲效率来换取声明式的写法和最终的正确性。但实际上我们并不需要这样。在 Vue 中,框架同时控制着编译器和运行时。这使得我们可以为紧密耦合的模板渲染器应用许多编译时优化。编译器可以静态分析模板并在生成的代码中留下标记,使得运行时尽可能地走捷径。于此同时,我们仍旧保留了边界情况时用户想要使用底层渲染函数的能力。我们称这种混合解决方案为带编译时信息的虚拟 DOM。
讨论一些 Vue 编译器用来提高虚拟 DOM 运行时性能的主要优化:
-
静态提升
在模板中常常有部分内容是不带任何动态绑定的:<div> <!-- 需提升 --> <div>foo</div> <!-- 需提升 --> <div>bar</div> <div>{{ dynamic }}</div> </div>
foo
和bar
这两个 div 是完全静态的,没有必要再重新渲染时再次创建和比对它们。Vue 编译器自动会提升这部分 vnode 创建函数到这个模板的渲染函数之外,并在每次渲染时都是用这份相同的 vnode,渲染器知道新旧 vnode 在这部分是完全相同的,所以会完全跳过对他们的差异比对。此外,当有足够多连续的静态示例是,他们还会被压缩为一个“静态 vnode”,其中包含的是这些节点相应的纯 HTML 字符串。这些静态节点会直接通过
innerHTML
来挂载。同时还会在初次挂载后缓存相应的 DOM 节点。如果这部分内容在应用中其他地方被重用,那么将会使用原生的cloneNode()
方法来克隆新的 DOM 节点,这会非常高效。 -
更新类型标记
对于单个有动态绑定的元素来说,我们可以在编译时推断出大量信息:<!-- 仅含 class 绑定 --> <div :class="{ active: isActive }"></div> <!-- 仅含 id 和 value 绑定 --> <input :id="id" :value="value" /> <!-- 仅含文本子节点 --> <div>{{ dynamic }}</div>
在为这些元素生成渲染函数是,Vue 在 vnode 创建调用中直接编码了每个元素所需的更新类型:
createElementVNode( 'div', { class: _normalizeClass({ active: _ctx.active }), }, null, 2 /* CLASS */ );
最后这个参数 2 是更新类型标记(patch flag)。一个元素可以有多个更新类型标记,会被合并成一个数字。运行时渲染器也将会使用位运算来检查这些标记,确定相应的更新操作:
if (vnode.patchFlag & PatchFlags.CLASS /* 2 */) { // 更新节点的 CSS class }
位运算检查是非常快的。通过这样的更新类型标记,Vue 能够在更新带有动态绑定的元素时做最少的操作。
Vue 也为 vnode 的子节点标记了类型。举例来说,包含多个根节点的模板被标识为一个片段,大多数情况下,我们可以确定其顺序是永远不变的,所以这部分信息就可以提供给运行时作为一个更新类型标记。
export function render() { return ( _openBlock(), _createElementBlock( _Fragment, null, [ /* children */ ], 64 /* STABLE_FRAGMENT */ ) ); }
运行时会完全跳过对这个根片段中子元素顺序的重新协调过程。
-
树结构打平
这里会引入一个概念“区块”,内部结构是最稳定的一个部分可被称之为一个区块。在这个用例中,整个模板只有一个区块,因为这里没有用到任何结构性指令(比如v-if
或者v-for
)。每个块都会追踪其所有带更新类型标记点的后代节点,举例来说:
<!-- root block --> <div> <!-- 不会追踪 --> <div>...</div> <!-- 要追踪 --> <div :id="id"></div> <!-- 不会追踪 --> <div> <!-- 要追踪 --> <div>{{ bar }}</div> </div> </div>
编译的结果会被打平为一个数组,仅包含所有动态的后代节点:
div (block root) - div 带有 :id 绑定 - div 带有 {{ bar }} 绑定
当这个组件需要重渲染时,只需要遍历这个打平的树而非整棵树。这也是我们所说的树结构打平,这大大减少了我们在虚拟 DOM 协调时需要遍历的节点数量。模板中任何的静态不分都会被高效地略过。
v-if
和v-for
指令会创建新的区块节点:<!-- 根区块 --> <div> <idv> <!-- if 区块 --> <div v-if>...</div> </div> </div>
一个子区块会在父区块的动态子节点数组中被追踪,这为他们的父区块保留了一个稳定的结构。
-
9.5 渲染函数 & JSX
在绝大数情况下,Vue 推荐使用模板语法来创建应用。然而在某些使用场景下,我们真的需要用到 JavaScript 完全的编程能力,这时渲染函数就派上用场了。
-
基本用法
创建 Vnodes
Vue 提供了一个h()
函数用于创建 vnodes:import { h } from 'vue'; const vnode = h('div', { id: 'foo', class: 'bar' }, [ /* children */ ]);
h()
是 hyperscript 的简称——意思是“能生成 HTML(超文本标记语言)的 JavaScript”。这个名字来源于许多虚拟 DOM 实现默认形成的约定。一个更准确的名称应该是createVnode()
,但当你需要多次使用渲染函数时,一个简短的名字会更省力。h()
函数使用方式非常的灵活:// 除了类型必填以外,其他的参数都是可选的 h('div'); h('div', { id: 'foo' }); // attribute 和 property 都能在 prop 中书写 // Vue 会自动将他们分配到正确的位置 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')]);
得到的 vnode 为如下形式:
const vnode = h('div', { id: 'foo' }, []); vnode.type; // 'div' vnode.props; // { id: 'foo' } vnode.children; // [] vnode.key; // null
声明渲染函数
当组合式 API 与模板一起使用时,setup()
钩子的返回值适用于报录数据给模板。然而当我们使用渲染函数是,可直接把渲染函数返回:import { ref, h } from 'vue'; export default { props: { /* ... */ }, setup(props) { const count = ref(1); // 返回渲染函数 return () => h('div', props.msg + count.value); }, };
在
setup()
内部声明的渲染函数天生能够访问在同一范围内声明的 props 和许多响应式状态。除了返回一个 vnode,你还可以返回字符串或数组:
export default { setup() { return () => 'hello world!'; }, };
import { h } from 'vue'; export default { setup() { // 使用数组返回多个根节点 return () => [h('div'), h('div'), h('div')]; }, };
如果一个渲染函数组件不需要任何示例状态,为了简洁起见,它们也可以直接被声明为一个函数:
function Hello() { return 'hello world!'; }
- Vnodes 必须唯一
组件树中的 vnodes 必须唯一,下面是错误示例:
如果要渲染多个重复的元素或组件,可以使用一个工厂来做这件事。function render() { const p = h('p'); return h('div', [ // 啊哦,重复的vnodes是无效的 p, p, ]); }
function render() { return h( 'div', Array.from({ length: 20 }).map(() => { return h('p', 'hi'); }) ); }
- Vnodes 必须唯一
-
JSX / TSX
JSX 是一个 JavaScript 的语法扩展,它允许在 JavaScript 中使用类似 XML 的语法来描述虚拟 DOM。const vnode = <div id='foo' class='bar'></div>;