组合式 API (Composition API) 是一系列 API 的集合,使我们可以使用函数而不是声明选项的方式书写 Vue 组件。它是一个概括性的术语,涵盖了以下方面的 API:
ref()
和 reactive()
,使我们可以直接创建响应式状态、计算属性和侦听器。onMounted()
和 onUnmounted()
,使我们可以在组件各个生命周期阶段添加逻辑。provide()
和 inject()
,使我们可以在使用响应式 API 时,利用 Vue 的依赖注入系统。组合式 API 是 Vue 3 及 Vue 2.7 的内置功能。对于更老的 Vue 2 版本,可以使用官方维护的插件 @vue/composition-api
。在 Vue 3 中,组合式 API 基本上都会配合 `` 语法在单文件组件中使用。
组合式 API 最基本的优势是它使我们能够通过组合函数来实现更加简洁高效的逻辑复用。在选项式 API 中我们主要的逻辑复用机制是 mixins,而组合式 API 解决了 mixins 的所有缺陷。
组合式 API 提供的逻辑复用能力孵化了一些非常棒的社区项目,比如 VueUse,一个不断成长的工具型组合式函数集合。组合式 API 还为其他第三方状态管理库与 Vue 的响应式系统之间的集成提供了一套简洁清晰的机制,例如 RxJS。
许多用户喜欢选项式 API 的原因是因为它在默认情况下就能够让人写出有组织的代码:大部分代码都自然地被放进了对应的选项里。然而,选项式 API 在单个组件的逻辑复杂到一定程度时,会面临一些无法忽视的限制。这些限制主要体现在需要处理多个逻辑关注点的组件中,这是我们在许多 Vue 2 的实际案例中所观察到的。
我们以 Vue CLI GUI 中的文件浏览器组件为例:这个组件承担了以下几个逻辑关注点:
这个组件最原始的版本是由选项式 API 写成的。如果我们为相同的逻辑关注点标上一种颜色,那将会是这样:
你可以看到,处理相同逻辑关注点的代码被强制拆分在了不同的选项中,位于文件的不同部分。在一个几百行的大组件中,要读懂代码中的一个逻辑关注点,需要在文件中反复上下滚动,这并不理想。另外,如果我们想要将一个逻辑关注点抽取重构到一个可复用的工具函数中,需要从文件的多个不同部分找到所需的正确片段。
而如果用组合式 API 重构这个组件,将会变成下面右边这样:
现在与同一个逻辑关注点相关的代码被归为了一组:我们无需再为了一个逻辑关注点在不同的选项块间来回滚动切换。此外,我们现在可以很轻松地将这一组代码移动到一个外部文件中,不再需要为了抽象而重新组织代码,大大降低了重构成本,这在长期维护的大型项目中非常关键。
近几年来,越来越多的开发者开始使用 TypeScript 书写更健壮可靠的代码,TypeScript 还提供了非常好的 IDE 开发支持。然而选项式 API 是在 2013 年被设计出来的,那时并没有把类型推导考虑进去,因此我们不得不做了一些复杂到夸张的类型体操才实现了对选项式 API 的类型推导。但尽管做了这么多的努力,选项式 API 的类型推导在处理 mixins 和依赖注入类型时依然不甚理想。
因此,很多想要搭配 TS 使用 Vue 的开发者采用了由 vue-class-component
提供的 Class API。然而,基于 Class 的 API 非常依赖 ES 装饰器,在 2019 年我们开始开发 Vue 3 时,它仍是一个仅处于 stage 2 的语言功能。我们认为基于一个不稳定的语言提案去设计框架的核心 API 风险实在太大了,因此没有继续向 Class API 的方向发展。在那之后装饰器提案果然又发生了很大的变动,在 2022 年才终于到达 stage 3。另一个问题是,基于 Class 的 API 和选项式 API 在逻辑复用和代码组织方面存在相同的限制。
相比之下,组合式 API 主要利用基本的变量和函数,它们本身就是类型友好的。用组合式 API 重写的代码可以享受到完整的类型推导,不需要书写太多类型标注。大多数时候,用 TypeScript 书写的组合式 API 代码和用 JavaScript 写都差不太多!这也让许多纯 JavaScript 用户也能从 IDE 中享受到部分类型推导功能。
搭配 使用组合式 API 比等价情况下的选项式 API 更高效,对代码压缩也更友好。这是由于
形式书写的组件模板被编译为了一个内联函数,和
中的代码位于同一作用域。不像选项式 API 需要依赖
this
上下文对象访问属性,被编译的模板可以直接访问 中定义的变量,无需一个代码实例从中代理。这对代码压缩更友好,因为本地变量的名字可以被压缩,但对象的属性名则不能。
74_composition.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>74_体验组合式APItitle>
head>
<body>
<div id="app">
<button @click="increment">点击了{
{ count }}次button>
div>
body>
<script src="lib/vue.global.js">script>
<script>
const {
createApp, ref, onMounted, onUpdated } = Vue
const app = createApp({
setup () {
// 组合式API
const count = ref(10)
const increment = () => {
console.log(1)
count.value += 1
}
onMounted(() => {
// 生命周期钩子函数
// console.log(count)
// console.log(`初始点击次数为${count.value}`)
document.title = `点击次数为${ count.value}`
})
onUpdated(() => {
document.title = `点击次数为${ count.value}`
})
return {
count,
increment
}
}
})
app.mount('#app')
script>
html>
体验提取公共部分
DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>75_提取复用代码title> head> <body> <div id="app"> <button @click="increment">点击了{ { count }}次button> div> body> <script src="lib/vue.global.js">script> <script> const { createApp, ref, onMounted, onUpdated } = Vue const useCount = () => { const count = ref(10) const increment = () => { console.log(1) count.value += 1 } return { count, increment } } const useTitle = (count) => { onMounted(() => { // 生命周期钩子函数 // console.log(count) // console.log(`初始点击次数为${count.value}`) document.title = `点击次数为${ count.value}` }) onUpdated(() => { document.title = `点击次数为${ count.value}` }) } const app = createApp({ setup () { // 组合式API const { count, increment } = useCount() // 自定义hooks useTitle(count) // 自定义hooks return { count, increment } } }) app.mount('#app') script> html>
setup()
钩子是在组件中使用组合式 API 的入口,通常只在以下情况下使用:
其他情况下,都应优先使用 语法。
我们可以使用响应式 API 来声明响应式的状态,在 setup()
函数中返回的对象会暴露给模板和组件实例。其它的选项也可以通过组件实例来获取 setup()
暴露的属性
请注意在模板中访问从 setup
返回的 ref 时,它会自动浅层解包,因此你无须再在模板中为它写 .value
。当通过 this
访问时也会同样如此解包。
setup()
自身并不含对组件实例的访问权,即在setup()
中访问this
会是undefined
。你可以在选项式 API 中访问组合式 API 暴露的值,但反过来则不行。
76_composition_setup_base.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>76_setup的基本使用title>
head>
<body>
<div id="app">
{
{ count }}
div>
body>
<script src="lib/vue.global.js">script>
<script>
const {
createApp, ref, onMounted, onUpdated } = Vue
const app = createApp({
setup () {
// 组合式API
// 创建了响应式变量
const count = ref(0)
// 返回值会暴露给模板和其他的选项式 API 钩子
return {
count
}
},
data () {
// 虽然设置了同名的变量,但是显示的是 组合式API中的数据
return {
count: 100
}
},
mounted () {
console.log(this.count) // 0
}
})
app.mount('#app')
script>
html>
setup
函数的第一个参数是组件的 props
。和标准的组件一致,一个 setup
函数的 props
是响应式的,并且会在传入新的 props 时同步更新。
{
props: {
title: String,
count: Number
},
setup(props) {
console.log(props.title)
console.log(props.count)
}
}
请注意如果你解构了
props
对象,解构出的变量将会丢失响应性。因此我们推荐通过props.xxx
的形式来使用其中的 props。
如果你确实需要解构 props
对象,或者需要将某个 prop 传到一个外部函数中并保持响应性,那么你可以使用 toRefs() 和 toRef() 这两个工具函数:
{
setup(props) {
// 将 `props` 转为一个其中全是 ref 的对象,然后解构
const {
title } = toRefs(props)
// `title` 是一个追踪着 `props.title` 的 ref
console.log(title.value)
// 或者,将 `props` 的单个属性转为一个 ref
const title = toRef(props, 'title')
}
}
77_composition_setup_props.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>77_setup访问propstitle>
head>
<body>
<div id="app">
{
{ count }} <button @click="count++">加1button>
<my-com :count="count">my-com>
div>
body>
<template id="com">
<div>
<h1>子组件h1>
{
{ count }} - {
{ doubleCount }}
div>
template>
<script src="lib/vue.global.js">script>
<script>
const {
createApp, ref, computed, toRefs, toRef } = Vue
const Com = {
template: '#com',
props: {
count: Number
},
setup (props) {
console.log(props)
// 1.计算属性 - 响应式
// const doubleCount = computed(() => props.count * 2)
// 2.不要把props中的数据解构,解构会丢失响应式
// const { count } = props // 模板中 doubleCount 始终为 0
// const doubleCount = computed(() => count * 2)
// 3.如果既需要解构,还要保持响应式 toRefs
// const { count } = toRefs(props) // 将 `props` 转为一个其中全是 ref 的对象,然后解构
// // console.log(count)
// const doubleCount = computed(() => count.value * 2) // ref对象访问值通过 value 属性
// 4.保持响应式 toRef
const count = toRef(props, 'count') // 将 `props` 的单个属性转为一个 ref
// console.log(count)
const doubleCount = computed(() => count.value * 2)
return {
doubleCount
}
}
}
const app = createApp({
components: {
MyCom: Com
},
setup () {
const count = ref(0)
return {
count
}
}
})
app.mount('#app')
script>
html>
传入 setup
函数的第二个参数是一个 Setup 上下文对象。上下文对象暴露了其他一些在 setup
中可能会用到的值:
{
setup(props, context) {
// 透传 Attributes(非响应式的对象,等价于 $attrs)
console.log(context.attrs)
// 插槽(非响应式的对象,等价于 $slots)
console.log(context.slots)
// 触发事件(函数,等价于 $emit)
console.log(context.emit)
// 暴露公共属性(函数)
console.log(context.expose)
}
}
该上下文对象是非响应式的,可以安全地解构:
{
setup(props, { attrs, slots, emit, expose }) {
...
}
}
attrs
和 slots
都是有状态的对象,它们总是会随着组件自身的更新而更新。这意味着你应当避免解构它们,并始终通过 attrs.x
或 slots.x
的形式使用其中的属性。此外还需注意,和 props
不同,attrs
和 slots
的属性都不是响应式的。如果你想要基于 attrs
或 slots
的改变来执行副作用,那么你应该在 onBeforeUpdate
生命周期钩子中编写相关逻辑。
expose
函数用于显式地限制该组件暴露出的属性,当父组件通过模板引用访问该组件的实例时,将仅能访问 expose
函数暴露出的内容
78_composition_setup_context.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>78_setup上下文对象title>
head>
<body>
<div id="app">
<my-com ref="child" class="active" style="color: red" id="box" @click="test" @my-event="getData">
<template #default>
111
template>
<template #footer>
222
template>
my-com>
div>
body>
<template id="com">
<div>
<h1>子组件h1>
<button @click="sendData">传值给父组件button>
<slot>slot>
<slot name="footer">slot>
div>
template>
<script src="lib/vue.global.js">script>
<script>
const {
createApp, ref, computed, toRefs, toRef, onMounted } = Vue
const Com = {
template: '#com',
setup (props, context) {
// 1.透传 Attributes(非响应式的对象,等价于 $attrs)
// 调用组件时添加 属性
console.log(context.attrs) // { class: 'active', id: 'box', style: {}, onClick: fn}
// 2.插槽(非响应式的对象,等价于 $slots)
console.log(context.slots) // { default: fn, footer: fn }
// 3. 触发事件(函数,等价于 $emit)
const sendData = () => {
// 选项式组件 this.$emit('my-event', 1000)
// 组合式组件 不可以再setup中访问this
context.emit('my-event', 1000)
}
// 4.暴露公共属性(函数) 父组件通过 ref 获取到子组件的实例的属性和方法
const msg = ref('hello composition api')
const name = ref('吴大勋')
const changeName = () => {
name.value = '田叔'
}
context.expose({
// 父组件可以通过 ref 获取到暴露出去的数据和方法
name, changeName
})
return {
sendData
}
}
}
const app = createApp({
components: {
MyCom: Com
},
setup () {
const test = () => {
}
const getData = (val) => {
console.log(val)
}
// 组合式api 给组件定义ref
const child = ref()
onMounted(() => {
// console.log('msg', this.$refs.child.msg) // ×
// console.log('name', this.$refs.child.name) // ×
console.log('child', child)
console.log('msg', child.value.msg) // undefined 因为子组件没有 expose 这个值
console.log('name', child.value.name) // 吴大勋
})
return {
child,
test,
getData
}
},
// mounted () { // 选项式API获取子组件实例
// console.log('msg', this.$refs.child.msg) // undefined 因为子组件没有 expose 这个值
// console.log('name', this.$refs.child.name) // 吴大勋
// }
})
app.mount('#app')
script>
html>
在父组件通过ref获取子组件的实例的属性和方法的需求中,需要注意:
1.如果子组件是 选项式API组件,基本不需要做任何操作
2.如果子组件是 组合式API组件,需要通过 context.expose 暴露给父组件需要使用的属性和方法
3.如果父组件使用 选项式API, 可以通过 this.$refs.refName 访问到子组件想要你看到的属性和方法
4.如果父组件使用 组合式API,需要在setup中先创建 refName,然后再访问子组件想要你看到的属性和方法(const refName = ref() refName.value.X)
setup
也可以返回一个渲染函数,此时在渲染函数中可以直接使用在同一作用域下声明的响应式状态:
{
setup() {
const count = ref(0)
return () => h('div', count.value)
}
}
返回一个渲染函数将会阻止我们返回其他东西。对于组件内部来说,这样没有问题,但如果我们想通过模板引用将这个组件的方法暴露给父组件,那就有问题
我们可以通过调用 expose()
解决这个问题:
{
setup(props, { expose }) {
const count = ref(0)
const increment = () => ++count.value
expose({
increment
})
return () => h('div', count.value)
}
}
79_composition_setup_render_function.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>79_setup渲染函数title>
head>
<body>
<div id="app">
<button @click="add">加1button>
<my-com ref = "com">my-com>
div>
body>
<script src="lib/vue.global.js">script>
<script>
const {
createApp, ref, h } = Vue
const Com = {
setup (props, {
expose }) {
const count = ref(0)
const increment = () => {
count.value += 1
}
expose({
// 关键
increment
})
return () => h('div', {
class: 'box' }, count.value)
}
}
const app = createApp({
components: {
MyCom: Com
},
setup () {
// 组合式API
const com = ref()
const add = () => {
com.value.increment()
}
return {
com,
add
}
}
})
app.mount('#app')
script>
html>
接受一个内部值,返回一个响应式的、可更改的 ref 对象,此对象只有一个指向其内部值的属性 .value
。
ref 对象是可更改的,也就是说你可以为 .value
赋予新的值。它也是响应式的,即所有对 .value
的操作都将被追踪,并且写操作会触发与之相关的副作用。
如果将一个对象赋值给 ref,那么这个对象将通过 reactive() 转为具有深层次响应式的对象。
将一个 ref 赋值给为一个 reactive 属性时,该 ref 会被自动解包
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
80_composition_ref.html
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>80_composition_reftitle>
head>
<body>
<div id="app">
count: <button @click="add">加1button>{
{ count }} <br />
state.count: <button @click="increment">加1button> {
{ state.count }} <br />
div>
body>
<script src="lib/vue.global.js">script>
<script>
const {
createApp, ref, reactive } = Vue
const app = createApp({
setup () {
// 1.ref 对象是可更改的,也就是说你可以为 `.value` 赋予新的值
const count = ref(0)
const add = () => {
count.value += 1
}
// 2。如果将一个对象赋值给 ref,那么这个对象将通过 reactive() 转为具有深层次响应式的对象。
const state = ref({
count: 10 })
const increment = () => {
state.value.count += 1
}
// 3.将一个 ref 赋值给为一个 reactive 属性时,该 ref 会被自动解包
const obj = reactive({
}) // reactive 属性
obj.count = count // 一个对象赋值给 ref
// 该 ref 会被自动解包
console.log(obj.count) // 0
console.log(obj.count === count.value) // true
// 一定要返回,暴露给页面模板使用
return {
count,
add,
state,
increment
}
}
})
app.mount('#app')
<