Vue知识点总结(三)组合式api
该系列文章主要基于官方Vue教程,对Vue使用及特性等方面做一个较为系统的总结
官方文档链接Vue.js
效率提升
写这篇文章时突然意识到一点,Vue3
和Vue2
只是版本号差别,然而我们常常会把Vue2
和选项式api
绑定,把Vue3
绑定组合式api
,这其实是一个单纯的误区,因为Vue3几乎仍然完全兼容选项式api
,所以Vue
版本和api
的使用方式是两个完全独立的概念
澄清了版本并不决定api的使用方式,我们就可以继续本节话题了,Vue3的更新带来了许多构建效率上的提升,在这种提升下,Vue3的客户端构建效率相比Vue2提升了约1.3~2倍,SSR构建效率提升了2~3倍,下面将给出具体的效率提升方法
静态提升
先来回顾下Vue
渲染的机制,我们都知道Vue
渲染节点靠的是虚拟DOM
,也就是构建vnode
,当render
函数运行时会遍历整个DOM
树,并据此构建真实的DOM
树,这个过程被称为”挂载”,而当某个节点发生变化,渲染器会比较虚拟DOM
树并找出它们间的差别,这个过程被称为”更新” 点击查看模板渲染流程图
下面来讲讲什么是静态提升方法,首先我们要知道什么是静态元素,所谓静态元素就是那些固定写死在模板中的元素,这些元素不会在页面中响应式地发生变化,因此,我们不需要实时监听这些元素,这样就能节省下比对这些节点的消耗
1 | <div> |
这段代码中foo
和bar
两个div
都是完全静态的,因此没有必要在渲染时重新比对它们,Vue
编辑器会自动将这两个DOM
节点提取到render
函数之外,从而避免重复比对
1 | import { createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" |
从Vue
源码中我们可以看到foo
和bar
两个div
节点被添加了格外参数-1
用以表示其被提取到渲染函数之外,而动态节点dynamic
则被标识为1
表示其是文本类型
动态标记更新(patch flag)
在静态提升中我们了解到了对于静态节点Vue
编译器会将其提出到渲染函数之外,其实现方法是创建节点时添加一个参数,比如-1
就标识静态提升
然而把参数仅用作判断节点是否为静态节点是否有些太浪费了呢,实际上这个参数的用途确实不止于此,参数可以标识更多信息,比如动态绑定的类、Props
参数,甚至一个片段,根据检查这个参数,Vue
编辑器就知道在生成渲染函数时要检查哪些而可以忽略哪些
最后这些动态标记方法是根据位运算赋予的参数,因为位运算可以最大化提升算法效率(这就是细节,蚊子腿也给你抠出来)
1 | <!-- 仅含 class 绑定 --> |
在这个例子中,分别有三种类型的动态绑定方法,第一个是class
类,第二个是Props
的参数,第三个则是动态插值,而它们也将对于不同的参数
1 | import { normalizeClass as _normalizeClass, createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue" |
从Vue
源码中我们可以看到,class
对应的参数是2
,props
为8
,而动态插值对应文本标识为1
,此外还可以发现,最外层还有一个参数64
,这表示多根节点代码段,这个参数说明这个代码段的结构将不会发生改变
这里的参数表表示可以被识别的动态类型种类,详情见patchflag参数表1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16export const PatchFlagNames: Record<PatchFlags, string> = {
[PatchFlags.TEXT]: `TEXT`,
[PatchFlags.CLASS]: `CLASS`,
[PatchFlags.STYLE]: `STYLE`,
[PatchFlags.PROPS]: `PROPS`,
[PatchFlags.FULL_PROPS]: `FULL_PROPS`,
[PatchFlags.NEED_HYDRATION]: `NEED_HYDRATION`,
[PatchFlags.STABLE_FRAGMENT]: `STABLE_FRAGMENT`,
[PatchFlags.KEYED_FRAGMENT]: `KEYED_FRAGMENT`,
[PatchFlags.UNKEYED_FRAGMENT]: `UNKEYED_FRAGMENT`,
[PatchFlags.NEED_PATCH]: `NEED_PATCH`,
[PatchFlags.DYNAMIC_SLOTS]: `DYNAMIC_SLOTS`,
[PatchFlags.DEV_ROOT_FRAGMENT]: `DEV_ROOT_FRAGMENT`,
[PatchFlags.CACHED]: `HOISTED`,
[PatchFlags.BAIL]: `BAIL`,
}
树结构打平(block tree)
从上述两个小点中我们可以看到返回虚拟代码实际上使用一个特殊的函数createElementBlock
所创建的,这个创建规则其实就是“区块化”创建,使用了一遍该函数,则称这是一块“区块”,每一块“区块”都会跟踪其中的动态元素,返回的结果将是只包含一个动态元素的数组,渲染时只需要遍历这颗“打平”的树而非整颗虚拟DOM
树,由此大大提高编译效率
1 | <div> <!-- root block --> |
以上是一个区块
1 | div (block root) |
上述案例将与这个结果打平
预字符串化
了解了Vue
的渲染机制,我们知道Vue
渲染DOM
节点时封装的vnode
最终会被转化为字符串,而预字符串化则是指当Vue
编译器遇到大量连续的静态节点时会将这些节点打包成一个字符串,于是,这些字符串就相当于一个普通的节点,因此渲染效率就会相应提升
缓存事件处理函数
Vue
中还有一个特性就是在事件处理函数,如data
、method
运行后会对这个函数进行缓存,下次运行时就能直接调用缓存,从而提高效率
组合式api核心
setup配置项
在大多数情况下,setup()
配置项的作用是兼容选项式api
,如果要结合单文件组件使用组合式api
,始终更推荐<script setup>
语法糖的写法
setup()
配置项算得上组合式api
的核心,其使用方式是返回一个对象,而对象中的属性和方法会被暴露给模板和组件实例,这里就体现了“组合式”api
的含义了,即需求逻辑可以被组合在一个setup()
配置项中,后续无论是属性还是方法都可以直接被调用,由于所有逻辑都写在一块,所以修改起来就要方便得多,所以说组合式是面向需求的api
,在大型项目维护上也会更有优势
这里是一个使用setup
配置项的例子 点击查看示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22<script>
import { ref } from 'vue'
export default {
setup() {
const count = ref(0)
// 返回值会暴露给模板和其他的选项式 API 钩子
return {
count
}
},
mounted() {
console.log(this.count) // 0
}
}
</script>
<template>
<button @click="count++">{{ count }}</button>
</template>
此外,setup()
配置项还有一些默认的参数:
第一个参数是props
,它用来接收由父组件传出的参数,使用props.xxx
来调用
与props
配置项一样,传入的参数是默认具有响应式的,但如果对其进行结构,参数就会丢失响应式,这时可以用toRef()
工具函数来恢复响应式
第二个参数是context
,暴露了其他一些组件实例,可以会在setup
中调用,包括透传参数attrs
、插槽slots
、触发事件emit
以及暴露选项expose
,以下是样例:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15export default {
setup(props, context) {
// 透传 Attributes(非响应式的对象,等价于 $attrs)
console.log(context.attrs)
// 插槽(非响应式的对象,等价于 $slots)
console.log(context.slots)
// 触发事件(函数,等价于 $emit)
console.log(context.emit)
// 暴露公共属性(函数)
console.log(context.expose)
}
}
由于context
不具备响应式,所以可以放心解构:1
2
3
4
5export default {
setup(props, { attrs, slots, emit, expose }) {
...
}
}
<script setup>
从上节的setup()
选项中我们已经了解到了<script setup>
是使用组合式api
时的语法糖,默认推荐使用该语法糖,因为它具有多种优势,包括
- 更少的模板内容和更简介的语法
- 能使用纯
Typescript
声明props
和自定义事件- 更好的运行性能(其模板会被编译成同一作用域内的渲染函数,避免了渲染上下文代理对象)
- 更好的 IDE 类型推导性能 (减少了语言服务器从代码中抽取类型的工作)
下面是一些有关该语法模式的特性:
- 启用
setup
语法只需要在<script>
代码块中添加setup
属性即可,在该模块中的代码会被自动编译成setup()
配置项的内容 - 任何在
<script setup>
中声明的顶层绑定都能直接在模板中使用,甚至import
导入的内容也会被同样方式暴露,而不需要写在methods
配置项里,在某种程度上这种模式和mixins
配置项高度重合,甚至这种调用方式将更为简单易懂 - 在模板中使用
ref
会自动解包 - 支持组件的直接引用,无需注册
components
配置项,可以用此方法调用自身或组件命名空间(多个组件的入口) - 简化自定义指令,
vNameOfDirective
格式的属性会被自动识别为自定义指令,以import
导入的可以通过别名改为自定义指令1
2
3
4
5
6
7
8
9
10<script setup>
const vMyDirective = {
beforeMount: (el) => {
// 在元素上做些操作
}
}
</script>
<template>
<h1 v-my-directive>This is a Heading</h1>
</template>
生命周期钩子
在<setup>
中取消了选项式中生命周期的配置项,取而代之的是一系列生命周期函数
生命周期函数一览
onMounted()
:在组件被挂载时调用onUpdated()
:在组件更新时调用onUnmounted()
:在组件被卸载时调用onBeforeMount()
:在组件挂载前调用onBeforeUpdate()
:在组件更新前调用onBeforeUnmount()
:在组件卸载前调用
区别:相对于选项式api
去除了created()
相关的钩子,将destroy()
选项改为了onUnmount()
虽然表现形式不同,但选项式和组合式api
对生命周期钩子的底层实现还是一样的,不必拘泥于这种差别
响应式api
响应式api
是Vue3
中另一个重大变化,在选项式中,无论是data
还是props
,编译器都给你自动配好了响应式,但组合式api
中需要自己设定响应式,一方面是Vue3
对响应式做了优化,另一方面是组合式api
有多种不同类型的响应式需要自己区分
ref()
最基本的响应式设置方法,本质是接收一个参数,并返回一个参数的ref
代理对象(数据被分装在value
属性中),在脚本中需要调用.value
来获取数据,在模板中则会自动解包数据
如果将一个对象赋值给ref
,那么这个对象内部实际上会通过reactive
转化为深度响应式,如果想要避免这种转化,只应用浅层响应式,可以使用shallowRef()
来代替
1 | function ref<T>(value: T): Ref<UnwrapRef<T>> |
对数据使用Ref
进行包装,包装进value
属性中
1 | const count = ref(0) |
computed()
相当于选项式api
中的computed:
配置项,传入一个箭头函数,默认将返回值封装为一个只读的响应式ref
对象
可以配置一个包含get
和set
的对象,用来自定义规则,具体结构见类型源码,示例看代码示例
1 | // 只读 |
默认传入箭头函数参数,返回只读的Ref
,自定义中传入一个对象,包含get
和set
配置项
只读computed
:1
2
3
4
5
6const count = ref(1)
const plusOne = computed(() => count.value + 1)
console.log(plusOne.value) // 2
plusOne.value++ // 错误
自定义computed
:1
2
3
4
5
6
7
8
9
10const count = ref(1)
const plusOne = computed({
get: () => count.value + 1,
set: (val) => {
count.value = val - 1
}
})
plusOne.value = 1
console.log(count.value) // 0
reactive()
reactive()
采用递归的方式将对象转化为响应式,其返回值本质为一个reactive
代理对象
使用reactive()
封装ref
的对象时,会将其自动解包,无需再使用value
调用,但对于原生数组和map
则依然需要手动解包
该响应式是深层的,如果只想保留顶层响应式,可使用shallowReactive()
替代
reactive
不能直接封装原始数据类型(如数字、字符串),只能封装对象,且对对象解构后会丢失响应性
1 | function reactive<T extends object>(target: T): UnwrapNestedRefs<T> |
封装ref
时自动解包1
2
3
4
5
6
7
8
9
10
11
12
13
14
15const count = ref(1)
const obj = reactive({ count })
// ref 会被解包
console.log(obj.count === count.value) // true
// 会更新 `obj.count`
count.value++
console.log(count.value) // 2
console.log(obj.count) // 2
// 也会更新 `count` ref
obj.count++
console.log(obj.count) // 3
console.log(count.value) // 3
对ref
封装的原生数组和Map
类型不会自动解包1
2
3
4
5
6
7const 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)
readonly()
接收一个对象,将其变为只读数据,本质是封装为一个只读的reactive
,所以解包原理等同于reactive
对于深层对象都会是只读的,如果要避免深层转化,使用shallowReadonly()
替代
1 | function readonly<T extends object>( |
返回值对reactive()
再进行了一层只读封装
1 | const original = reactive({ count: 0 }) |
watchEffect()
从某种程度上来说,侦听器和响应式数据非常像,因为它们都是基于数据的变化做出响应式修改,这点在watchEffect
上表现地淋漓尽致
watchEffect
需要传入一个无返回值的函数,api
会自动追踪其中的响应式数据并在每次数据更新后执行一遍该函数,可选第二个参数,用来调整变化的发生时机和调试手段
watchEffect
设置参数后会立即执行一次该函数,相当于watch
配置项的immediate
,且watchEffect
重复触发时会自动清理上一次未完成的副作用(侦听器封装了onCleanup
参数,具体实现需要自己传入相应的执行函数)
默认flush
为pre
,即在数据更新前执行副作用,post
表示数据更新后使用,同名api
为watchPostEffect
,sync
表示响应变化(可能影响性能),同名api
为watchSyncEffect
watchEffect
适合处理简单业务,例如对单个数据的即使副作用处理,如果要分别监听多个数据做不同操作,还是要用watch()
1 | function watchEffect( |
可以看到,传入的第一个参数类型为无返回值的函数(该函数可以使用一个函数控制onCleanup
清除时执行的操作),第二个参数为一个对象,其中的flush
用来控制触发时机,后两个用于调试需求
函数返回值为一个接口对象,该对象完成了对pause()
暂停、resume()
恢复和stop()
结束的封装,可以调用相应函数改变侦听器状态
直接调用1
2
3
4
5
6
7const count = ref(0)
watchEffect(() => console.log(count.value))
// -> 输出 0
count.value++
// -> 输出 1
自定义副作用清理1
2
3
4
5
6
7
8watchEffect(async (onCleanup) => {
const { response, cancel } = doAsyncWork(id.value)
// `cancel` 会在 `id` 更改时调用
// 以便取消之前
// 未完成的请求
onCleanup(cancel)
data.value = await response
})
改变侦听器状态1
2
3
4
5
6
7
8
9
10const { stop, pause, resume } = watchEffect(() => {})
// 暂停侦听器
pause()
// 稍后恢复
resume()
// 停止
stop()
第二参数改变侦听器触发时机1
2
3
4
5
6
7
8
9watchEffect(() => {}, {
flush: 'post',
onTrack(e) {
debugger
},
onTrigger(e) {
debugger
}
})
watch()
watch()
可以说是watchEffect()
的升级版,两者共享清除onCleanup
机制和启动结束机制,但watch
总的来说提供了更多的功能,适合应对更加复杂的场景
watch()
接收两个必选参数,第一个参数是监听的对象,第二个参数是回调函数,可选监听第三个参数,即watch
的配置项
watch()
监听对象包含一个ref
、有返回值的函数
或一个对象,回调函数接收三个值,分别是新值,旧值和onCleanup
清理函数,选项为一个对象包含deep
,immediate
、flush
、调试函数和once
(表示只监听一次)
使用watch
监听多个对象时,可在监听对象和回调函数参数部分传入数组,也可以写两个watch
(这在watchEffect
中是不支持的)
1 | // 侦听单个来源 |
回调清理和暂停/恢复操作等同于watchEffect()
基本使用1
2
3
4
5
6
7const state = reactive({ count: 0 })
watch(
() => state.count,
(count, prevCount) => {
/* ... */
}
)
一个监听器监听多个来源1
2
3watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
/* ... */
})
响应式api工具函数
通过Vue
自带的工具函数,可以帮助我们鉴别某些响应式api
isRef()
:判断某个值是否为ref
类型unref()
:如果某个值是ref
,返回这个值的参数,否则返回这个值本身toRef()
:将一个值规范化为ref
类型toValue()
:将一个对象规范化为它的参数值toRefs()
:将一个对象内部的值都转化为ref
类型(对象值一般会是reactive
类型,解构时会失去响应式,ref
则会保留响应式)isProxy()
:检查一个对象是否是由reactive()
、readonly()
、shallowReactive()
或shallowReadonly()
创建的代理isReactive()
:检查一个对象是否是由reactive()
或shallowReactive()
创建的代理。isReadonly
:检查传入的值是否为只读对象。
组件参数传递
组合式api
中区别于选项式的另一大区别是,在setup()
配置项中,props
、emits
、expose
等配置项是作为参数被放在setup()
函数中的,然而在我们实际开发过程中,绝大多数情况下会使用<script setup>
这个语法糖,那么函数参数的位置就不复存在了,所以我们就另外定义了一套标准(宏)来使用这些功能
使用宏来定义这些选项还有其他一些好处,比如上下文实例参数传递变得更加直观,又比如可以很好地兼容Typescript
进行类型推断
defineProps()
由于使用<script setup>
语法糖之后没有参数空间给props
做引用了,所以在组合式api
中引入了宏的概念,使用defineProps()
宏来声明props
选项,在没有使用<script setup>
的组件中,props
还是可以使用选项的方式来命名
和选项的差别基本只在于外层壳的不同,以及去除了this
指针,调用时使用propsname
宏名称作为前缀
是用typescript
设置泛型类型之后就不需要预先传参了,且这种方式还能提供类型推断,因此更为推荐
即使语法格式不一样,它们底层用的都是props
配置项,实现层面没有什么区别
传参的方式,基本等同于props
配置项1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 使用 <script setup>
defineProps({
title: String,
likes: Number
})
// 等同于
// 非 <script setup>
export default {
props: {
title: String,
likes: Number
}
}
使用ts
泛型做类型推断的方式1
2
3
4
5
6<script setup lang="ts">
defineProps<{
title?: string
likes?: number
}>()
</script>
defineEmits()
和defineProps()
一样,defineEmits()
是基于emits
配置项的宏,且只能在<script setup>
中使用,使用方式几乎和配置项没有区别
组合式api
只取消了this
指针,在模板中还是可以直接使用$emit
进行事件传递
可以使用纯类型标注来使用defineEmits()
直接使用1
2
3
4
5
6
7
8
9
10
11
12
13<script setup>
defineEmits(['inFocus', 'submit'])
</script>
//等同于
<script setup>
const emit = defineEmits(['inFocus', 'submit'])
function buttonClick() {
emit('submit')
}
</script>
增加校验1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20<script setup>
const emit = defineEmits({
// 没有校验
click: null,
// 校验 submit 事件
submit: ({ email, password }) => {
if (email && password) {
return true
} else {
console.warn('Invalid submit event payload!')
return false
}
}
})
function submitForm(email, password) {
emit('submit', { email, password })
}
</script>
使用ts
进行类型标注(触发函数+参数)1
2
3
4
5
6<script setup lang="ts">
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
</script>
defineModel()
defineModel
是组件间v-model
的宏,由于组件式api
的数据响应式和选项式api
有一定的区别,所以这个宏也有一定的差别,它返回的数据是使用ref
包裹的
组合式api
极大地简化了v-model
的写法,原本在选项式api
中需要在子组件中自己配置props
和事件处理函数,有了defineModel()
宏之后只需要用其包裹想要绑定的值即可,在父组件中就可以直接v-model
绑定了
defineModel()
宏还可以接收参数,用于多个v-model
绑定,用v-model:paramsName
指定绑定对象,此外还可传入第二个对象参数,用来处理props
配置项
子组件1
2
3
4
5
6
7
8
9
10
11
12<!-- Child.vue -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
</script>
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
父组件1
2
3
4
5<!-- Parent.vue -->
<Child
:modelValue="foo"
@update:modelValue="$event => (foo = $event)"
/>
直接使用1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16<!-- Child.vue -->
<script setup>
const model = defineModel()
function update() {
model.value++
}
</script>
<template>
<div>Parent bound v-model is: {{ model }}</div>
<button @click="update">Increment</button>
</template>
//支持父组件绑定
<!-- Parent.vue -->
<Child v-model="countModel" />
多个v-model
绑定1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 父组件
<UserName
v-model:first-name="first"
v-model:last-name="last"
/>
// 子组件
<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>