4.4 组合式 API
前面介绍的所有 Vue 相关的知识点,大部分都是 Vue2 和 Vue3 通用。接下来我们介绍 Vue3 专有部分,也是 Vue3 的最大亮点 —— 组合式 API。
4.4.1 选项式 API 与组合式 API
在 Vue2 中,我们在一个 JSON 对象下定义一系列功能。如 data 属性定义响应式状态,methods 属性定义方法,这种方式被称为选项式 API。
选项式 API 的局限是:不管组件需要状态、方法还是生命周期,必须在当前组件中定义。这使得组件的功能单元无法单独抽取,造成大量的重复定义。
组合式 API 是 Vue3 提供的一种函数式的功能集合,每一个功能都有一个单独的函数来实现。这样的好处是:一来更好的与 TypeScript 集成,二来可以使功能独立于组件存在,更有利于组件的逻辑单元复用。
比如:使用组合式 API 可以定义一组通用的状态和方法,将它单独提取到一个文件中。当组件需要时,这组逻辑可以被随时导入。这样这组逻辑就是独立的,不依赖于组件的存在而存在。
我们使用组合式 API 来实现上述逻辑:
// util.js
import { ref } from "vue";
export function useMouse() {
const x = ref(0);
const y = ref(0);
const setValue = () => {
x.value++;
y.value++;
};
return { x, y, setValue };
}
上面代码定义了一个工具函数,在函数内声明了响应式状态和修改状态的方法。接着我们在组件中导入:
<script setup>
import { useMouse } from "./util.js";
const { x, y, setValue } = useMouse();
</script>
<!-- 模版 -->
<template>
<div class="box">
<h1>{{ x + y }}</h1>
<button @click="setValue">测试</button>
</div>
</template>
从这段组件代码中可以看出,导入的状态和方法可以直接在模版中使用,而不需要重新定义。显然这样的代码更精简,更符合 JavaScript 语法风格,这也是为什么组合式 API 可以提高代码逻辑可复用性的原因。
4.4.2 理解响应式状态
上面用组合式 API 写了一个代码片段,看不懂没关系,我们从基础介绍。
reactive()
组合式 API 使用 reactive() 函数创建一个响应式对象或数组。在组件的 script 部分,我们用 setup 来标识该组件使用组合式 API。
<script setup>
import { reactive } from "vue";
const state = reactive({ count: 0 });
</script>
定义好状态之后,就可以直接在模版中使用了:
<template>
<span>{{ state.count }}</span>
</template>
使用组合式 API,JavaScript 代码中声明的变量、函数和导入的模块都可以直接在模版中使用,可以理解为模板中的表达式和 <script setup>
中的代码处在同一个作用域中。
<script setup>
import { num } from "./xxx.js";
const count = 1;
const fun = () => {
alert(1);
};
</script>
<template>
<span>{{ count + num }}</span>
<button @click="fun"></button>
</template>
相比于选项式 API,reactive() 函数创建的状态默认是深层响应式。也就是说,即便状态是复杂的数组,修改数组项时也能检测到状态变化,这是 Vue2 的选项式 API 无法做到的。
深层响应式可以使我们放心的修改状态,不必担心在 Vue2 中总能遇到的状态修改但视图不更新的麻烦。
reactive() 函数返回的是一个 Proxy 代理对象(Proxy 在第三章的 ES6 部分有介绍),它包裹了原始对象,Vue3 的响应式状态就是这个代理对象实现的。因此更改原始对象不会触发视图更新。
尽管 reactive() 函数很强大,但它也有局限性。两条限制如下:
- 仅对对象类型有效,对基础类型(String,Number)无效。
- 重新赋值会丢失引用,导致响应式失效。
// 响应性失效案例
const state = reactive({ count: 0 });
let n = state.count;
let { count } = state;
// 变量 n 和 count 都不会有响应性,必须使用 state.count
ref()
reactive() 函数的两条限制决定了它难以被广泛应用。为了解决问题,Vue3 提供了更好的替代方案 ——— ref() 函数。它可以创建任意类型的响应式状态:
import { ref } from "vue";
const count = ref(0);
const array = ref(["牛", "羊"]);
ref() 创建状态的方式比较特别,它并不直接指向源数据,而是创建一个对象,让对象的 .value 属性指向源数据,如下:
import { ref } from "vue";
const count = ref("烤肉");
console.log(count); // { value: '烤肉' }
console.log(count.value); // 烤肉
这种看起来略显“繁琐”的方式,正是 ref() 函数的妙处。因为 reactive() 函数的根本问题是引用丢失。而 ref() 函数将所有状态都挂在了 .vulue 属性上,之后不管这个对象如何被传递、解构,源数据的引用也不会变。
再者因为 ref() 函数本身创建了一个对象,因此源数据可以是任意类型。
ref() 函数虽然强大,不过从使用体验角度来看,频繁的通过 .value 获取状态有些不太友好。Vue3 当然明白你的顾虑,于是它提供了一个“响应性语法糖”,如下:
<script setup>
let count = $ref(0);
function increment() {
count++;
}
</script>
<template>
<button @click="increment">{{ count }}</button>
</template>
上述代码中的 $ref 就是 ref() 函数的语法糖,它屏蔽了 .value 的使用,我们可以假设它不存在,直接将变量当作普通状态使用。
4.4.3 生命周期钩子
在组合式 API 中,Vue3 提供了许多生命周期钩子函数,作用与选项式 API 中的生命周期基本一致。生命周期代表着组件从创建到销毁的完整流程,详细过程请看下图:
在众多的生命周期中,最常用的生命周期钩子有三个,详细介绍如下。
onMounted()
注册一个回调函数,在组件挂载完成后执行,对标选项式 API 的 mounted() 函数。
import { onMounted } from "vue";
onMounted(() => {
console.log("组件初始化完成");
});
这个生命周期主要用于执行一些初始化的操作,比如发起 API 请求,获取页面参数等。
onUpdated()
注册一个回调函数,在响应式状态变化、且组件的 DOM 更新之后调用。对标选项式 API 的 updated() 函数。
这个生命周期主要用于组件更新之后获取到最新的状态和 DOM,如下:
import { ref, onMounted, onUpdated } from 'vue'
const text = ref('张三')
onMounted(() => {
text.value = '李四'
})
onUpdated(() => {
let dom = document.getElementById('text')
console.log(dom.innerText) // 李四
})
<div id="text">{{text}}</div>
代码中可见,该生命周期会获取到最新的状态和 DOM 元素。
onUnmounted()
注册一个回调函数,在组件实例被卸载之后调用。对标选项式 API 的 destroyed() 函数。
这个也很好理解,在组件全部卸载之后做一些事情。比如重置某个全局状态:
import { onUnmounted } from "vue";
import store from "./store";
onUnmounted(() => {
store.dispatch("reset");
});
Vue3 提供的生命周期钩子都是指定一个回调函数,在特定时机执行这个回调函数。
4.4.4 计算属性与监听器
选项式 API 中分别使用 computed 属性和 watch 属性来定义计算属性与监听器,组合式 API 则提供了同名函数实现相同的功能。
Vue3 抛出一个 computed() 函数用于定义计算属性,如下:
<script setup>
import { ref, computed } from "vue";
const paper = ref({
width: 123,
height: 57,
});
const acreage = computed(() => {
return paper.value.width * paper.value.height;
});
</script>
<template>
<span>{{ acreage }}</span>
</template>
computed() 方法返回一个 ref 对象,也就是说,实际的值需要用 acreage.value 获取,和响应式状态的结构是一样的。只不过在模版中会将 ref 对象自动解包,因此无需指定 .value 属性。
监听器使用 watch 函数定义:
<script setup>
import { ref, watch } from "vue";
const text = ref("");
watch(text, (val, oldval) => {
console.log(val, oldval);
});
</script>
<template>
<input v-model="text" />
</template>
代码中为文本框双向绑定一个响应式状态,并用 watch() 函数监听这个状态。当状态改变时,watch 的回调函数会执行,可以在该函数内执行一些副作用操作。
注意:监听器针对不同的数据有“深层监听”和“浅层监听”的区别。当监听一个响应式状态时,默认是深层监听,也就是说如果监听一个数组,当数组项发生变化时,监听器也会被触发。
当然,我们也可以手动指定需要深层监听还是浅层监听,通过 deep 属性来实现:
import { ref, watch } from "vue";
const state = ref("bug");
watch(
state,
(val, oldval) => {
console.log(val);
},
{ deep: false }
);
watchEffect()
监听器常常与初始化函数有着一样的操作。比如,要在组件初始化后请求一个 API,在监听某个状态变化后再请求一次,这时候请求 API 的逻辑就要写两份。
如果有监听器可以在初始化时执行一次,那么就会减少多余逻辑。Vue3 提供了 watchEffect() 函数来实现这个功能。举例如下:
import { ref, watchEffect } from "vue";
const tag = ref("all");
watchEffect(async () => {
let res = await fetch("http://xxx?tag=" + tag);
console.log(res);
});
代码中可以看出,watchEffect() 函数并没有明确指定监听哪个状态,他会自动追踪响应式依赖,在依赖更新时自动执行回调函数(代码中的依赖为变量 tag)。
当监听一个响应式状态时,默认会在状态变更后立即触发监听器,此时 DOM 还未更新。如果想在监听器的回调函数中访问最新的 DOM,需要手动配置 flush 属性:
watch(source, callback, {
flush: "post",
});
watchEffect(callback, {
flush: "post",
});
4.4.5 渲染方式:模板与 JSX
大多数情况下,Vue 使用模版来创建页面。模版最接近 html 语法,并支持灵活的数据绑定与交互。但在某些场景下,我们更需要 JavaScript 完全的编程能力,这时就需要渲染函数。
渲染函数
渲染函数的作用是创建虚拟 DOM。在 Vue 中,模版也会被编译成渲染函数,只不过模版是自定义的一套语法规则,使用起来更方便,但创建虚拟 DOM 的核心能力还是由渲染函数实现。
Vue3 暴露了一个 h 函数表示渲染函数,允许我们直接用函数创建页面。如下:
import { h } from "vue";
const vnode = h("div", { id: "foo", class: "bar" }, ["哈哈哈"]);
上面代码中,使用渲染函数创建了虚拟 DOM。相同的虚拟 DOM 用模版表示如下:
<template>
<div id="foo" class="bar">哈哈哈</div>
</template>
很显然,使用模版更直观。虽然渲染函数是纯粹的 JavaScript,但使用起来较为繁琐,可读性比较差。
在单文件组件(SFC)中使用渲染函数不可以用 <script setup>
语法糖来简化代码。必须导出组件选项,并在 setup() 方法中返回虚拟 DOM,如下:
<script>
import { h, ref } from "vue";
export default {
setup() {
// 组合式API必须写在当前函数内
let text = ref("哈利波特");
return () =>
h("div", { class: "wrap" }, [h("span", { class: "text" }, [text.value])]);
},
};
</script>
为什么不能在 <script setup>
语法糖下使用渲染函数?因为 <script setup>
语法糖会把顶层变量和函数自动返回,便于在模版中使用。但渲染函数需要返回虚拟 DOM 而不是返回数据,因此不适用。
JSX
直接使用渲染函数不够友好,业界比较流行的替代方案是 JSX。JSX 允许在 JavaScript 中使用模版语法,集成了 JavaScript 的灵活性和模版的可读性,如下:
render() {
return <div id="box">渲染内容</div>
}
在 Vue3 中使用 JSX 需要单独安装插件:
$ yarn add -D @vitejs/plugin-vue-jsx
安装后在配置文件 vite.config.ts 中导入并添加配置:
import vue from "@vitejs/plugin-vue";
import vueJsx from "@vitejs/plugin-vue-jsx";
export default defineConfig({
plugins: [vue(), vueJsx()],
});
配置完毕,我们在组件目录(src/components)下新建一个 DemoText.jsx 文件 ——— 没错,Vue3 支持用 .jsx 文件表示组件,在组件中可以直接使用 JSX 语法,如下:
import { ref } from "vue";
export default {
setup() {
const text = ref("哈利波特");
return () => <div>{text.value}</div>;
},
};
上面代码中直接导出一个组件选项对象,这与声明式 API 的选项对象一致。在 setup() 选项函数内可以使用组合式 API,并直接返回 JSX。
但要注意:setup() 中不能直接返回 JSX,必须借助返回函数的方式返回 JSX,如下:
// 正确
return () => <div>哈利波特</div>;
// 错误
return <div>哈利波特</div>;
defineComponent() 函数的作用仅仅是为组件自动推导类型,这对用 TypeScript 编写组件非常友好。当然如果你不需要类型,直接导出组件选项对象也可以:
4.4.6 与 TypeScript 集成
Vue3 天生支持 TypeScript。在单文件组件中,使用 TypeScript 只需要加标识 lang="ts",如下:
<script lang="ts">
var name: string = "哈利波特";
</script>
当然了,让项目支持 TypeScript 也需要安装相关的依赖。这些依赖在使用 create-vue 脚手架创建项目时我们已经安装好了。
添加 lang="ts" 后,JavaScript 和模版表达式都会执行严格的类型验证。组合式 API 对 TypeScript 有更可靠的支持,因此若要使用 TypeScript 则首选组合式 API。
使用 ref() 定义响应式状态时,TypeScript 会根据默认值自动推导出类型。当然我们也可以指定类型,这在定义一个对象时非常有用:
<script setup lang="ts">
import { ref } from "vue";
interface JsonType {
id: number;
name: string;
}
const info = ref<JsonType | null>(null);
</script>
<template>
<div>
<span v-if="info">{{ info.name }}</span>
</div>
</template>
props 类型
组件中比较重要的是 props 的类型定义。当不使用 setup 语法糖时,通常用 defineComponent() 函数包裹组件选项对象,此时会根据 props 选项的定义自动推导出 props 的类型:
<script lang="ts">
import { ref, defineComponent } from "vue";
export default defineComponent({
props: {
user_id: Number,
user_name: String,
},
setup(props) {
console.log(props.user_name);
},
});
</script>
<template>
<div>{{ user_name }}</div>
</template>
在 setup() 函数中、或者被外部组件导入使用时,组件的 props 就会有类型提示和类型验证。
使用 JSX 组件时,同样在组件内用 defineComponent() 函数包裹组件选项对象,此时会自动推导 props 的类型:
// DemoText.jsx
import { ref } from "vue";
export default defineComponent({
props: {
user_id: Number,
user_name: String,
},
setup(props, ctx) {
const text = ref("哈利波特");
return () => <div>{text.value}</div>;
},
});
当使用 setup 语法糖时,代码中无处可用 defineComponent(),此时需要专门定义 props 的函数 ——— defineProps() 宏函数来实现。
所谓宏函数,就是不需要导入可直接使用的函数。下面来定义一组 props:
<script setup lang="ts">
defineProps({
user_id: Number,
user_name: String,
});
</script>
<template>
<div>{{ user_name }}</div>
</template>
定义后的 props 无须导出,可直接在模版中使用。除了使用 Vue 的方式定义 props,defineProps() 函数更常用的方式是使用泛型定义。泛型定义更符合 TypeScript 标准,如下:
<script setup lang="ts">
const props = defineProps<{
user_id: number;
user_name: string;
}>();
console.log(props.user_name);
</script>
emits 类型
除了 props 需要类型,自定义事件的类型也很关键。当不使用 setup 语法糖时,defineComponent() 函数同样能推导出自定义事件的类型。如下:
import { defineComponent } from "vue";
export default defineComponent({
emits: ["change"],
setup(props, { emit }) {
let params = { id: 1 };
emit("change", params); // 调用事件并传参
},
});
当使用 setup 语法糖时,事件类型通过宏函数 defineEmits() 定义:
<script setup lang="ts">
var emit = defineEmits("change", "update");
</script>
<template>
<div>{{ user_name }}</div>
</template>
使用 Vue 的方式定义自定义事件,只能指定一个事件名称。如果需要更加严格的类型定义,比如指定参数类型、返回类型,这时需要使用泛型来定义事件类型:
<script setup lang="ts">
const emit = defineEmits<{
(e: "fun1", id: number): void;
(e: "fun2", value?: string): string;
}>();
emit("fun1", (id: number) => {
console.log(id);
});
emit("fun2", (value?: string) => {
if (value) {
return "没有值";
} else {
return value;
}
});
</script>
Vue3 中大部分的函数都会自动推导类型,别忘了还有编辑器的类型提示。