Skip to content

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 来实现上述逻辑:

js
// 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 };
}

上面代码定义了一个工具函数,在函数内声明了响应式状态和修改状态的方法。接着我们在组件中导入:

vue
<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。

vue
<script setup>
import { reactive } from "vue";
const state = reactive({ count: 0 });
</script>

定义好状态之后,就可以直接在模版中使用了:

vue
<template>
  <span>{{ state.count }}</span>
</template>

使用组合式 API,JavaScript 代码中声明的变量、函数和导入的模块都可以直接在模版中使用,可以理解为模板中的表达式和 <script setup> 中的代码处在同一个作用域中。

vue
<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() 函数很强大,但它也有局限性。两条限制如下:

  1. 仅对对象类型有效,对基础类型(String,Number)无效。
  2. 重新赋值会丢失引用,导致响应式失效。
js
// 响应性失效案例
const state = reactive({ count: 0 });
let n = state.count;
let { count } = state;
// 变量 n 和 count 都不会有响应性,必须使用 state.count

ref()

reactive() 函数的两条限制决定了它难以被广泛应用。为了解决问题,Vue3 提供了更好的替代方案 ——— ref() 函数。它可以创建任意类型的响应式状态:

js
import { ref } from "vue";
const count = ref(0);
const array = ref(["牛", "羊"]);

ref() 创建状态的方式比较特别,它并不直接指向源数据,而是创建一个对象,让对象的 .value 属性指向源数据,如下:

js
import { ref } from "vue";
const count = ref("烤肉");
console.log(count); // { value: '烤肉' }
console.log(count.value); // 烤肉

这种看起来略显“繁琐”的方式,正是 ref() 函数的妙处。因为 reactive() 函数的根本问题是引用丢失。而 ref() 函数将所有状态都挂在了 .vulue 属性上,之后不管这个对象如何被传递、解构,源数据的引用也不会变。

再者因为 ref() 函数本身创建了一个对象,因此源数据可以是任意类型。

ref() 函数虽然强大,不过从使用体验角度来看,频繁的通过 .value 获取状态有些不太友好。Vue3 当然明白你的顾虑,于是它提供了一个“响应性语法糖”,如下:

vue
<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() 函数。

js
import { onMounted } from "vue";

onMounted(() => {
  console.log("组件初始化完成");
});

这个生命周期主要用于执行一些初始化的操作,比如发起 API 请求,获取页面参数等。

onUpdated()

注册一个回调函数,在响应式状态变化、且组件的 DOM 更新之后调用。对标选项式 API 的 updated() 函数。

这个生命周期主要用于组件更新之后获取到最新的状态和 DOM,如下:

js
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() 函数。

这个也很好理解,在组件全部卸载之后做一些事情。比如重置某个全局状态:

js
import { onUnmounted } from "vue";
import store from "./store";

onUnmounted(() => {
  store.dispatch("reset");
});

Vue3 提供的生命周期钩子都是指定一个回调函数,在特定时机执行这个回调函数。

4.4.4 计算属性与监听器

选项式 API 中分别使用 computed 属性和 watch 属性来定义计算属性与监听器,组合式 API 则提供了同名函数实现相同的功能。

Vue3 抛出一个 computed() 函数用于定义计算属性,如下:

vue
<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 函数定义:

vue
<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 属性来实现:

js
import { ref, watch } from "vue";
const state = ref("bug");
watch(
  state,
  (val, oldval) => {
    console.log(val);
  },
  { deep: false }
);

watchEffect()

监听器常常与初始化函数有着一样的操作。比如,要在组件初始化后请求一个 API,在监听某个状态变化后再请求一次,这时候请求 API 的逻辑就要写两份。

如果有监听器可以在初始化时执行一次,那么就会减少多余逻辑。Vue3 提供了 watchEffect() 函数来实现这个功能。举例如下:

js
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 属性:

js
watch(source, callback, {
  flush: "post",
});
watchEffect(callback, {
  flush: "post",
});

4.4.5 渲染方式:模板与 JSX

大多数情况下,Vue 使用模版来创建页面。模版最接近 html 语法,并支持灵活的数据绑定与交互。但在某些场景下,我们更需要 JavaScript 完全的编程能力,这时就需要渲染函数。

渲染函数

渲染函数的作用是创建虚拟 DOM。在 Vue 中,模版也会被编译成渲染函数,只不过模版是自定义的一套语法规则,使用起来更方便,但创建虚拟 DOM 的核心能力还是由渲染函数实现。

Vue3 暴露了一个 h 函数表示渲染函数,允许我们直接用函数创建页面。如下:

js
import { h } from "vue";
const vnode = h("div", { id: "foo", class: "bar" }, ["哈哈哈"]);

上面代码中,使用渲染函数创建了虚拟 DOM。相同的虚拟 DOM 用模版表示如下:

vue
<template>
  <div id="foo" class="bar">哈哈哈</div>
</template>

很显然,使用模版更直观。虽然渲染函数是纯粹的 JavaScript,但使用起来较为繁琐,可读性比较差。

在单文件组件(SFC)中使用渲染函数不可以用 <script setup> 语法糖来简化代码。必须导出组件选项,并在 setup() 方法中返回虚拟 DOM,如下:

vue
<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 的灵活性和模版的可读性,如下:

js
render() {
  return <div id="box">渲染内容</div>
}

在 Vue3 中使用 JSX 需要单独安装插件:

sh
$ yarn add -D @vitejs/plugin-vue-jsx

安装后在配置文件 vite.config.ts 中导入并添加配置:

js
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 语法,如下:

jsx
import { ref } from "vue";

export default {
  setup() {
    const text = ref("哈利波特");
    return () => <div>{text.value}</div>;
  },
};

上面代码中直接导出一个组件选项对象,这与声明式 API 的选项对象一致。在 setup() 选项函数内可以使用组合式 API,并直接返回 JSX。

但要注意:setup() 中不能直接返回 JSX,必须借助返回函数的方式返回 JSX,如下:

js
// 正确
return () => <div>哈利波特</div>;
// 错误
return <div>哈利波特</div>;

defineComponent() 函数的作用仅仅是为组件自动推导类型,这对用 TypeScript 编写组件非常友好。当然如果你不需要类型,直接导出组件选项对象也可以:

4.4.6 与 TypeScript 集成

Vue3 天生支持 TypeScript。在单文件组件中,使用 TypeScript 只需要加标识 lang="ts",如下:

vue
<script lang="ts">
var name: string = "哈利波特";
</script>

当然了,让项目支持 TypeScript 也需要安装相关的依赖。这些依赖在使用 create-vue 脚手架创建项目时我们已经安装好了。

添加 lang="ts" 后,JavaScript 和模版表达式都会执行严格的类型验证。组合式 API 对 TypeScript 有更可靠的支持,因此若要使用 TypeScript 则首选组合式 API。

使用 ref() 定义响应式状态时,TypeScript 会根据默认值自动推导出类型。当然我们也可以指定类型,这在定义一个对象时非常有用:

vue
<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 的类型:

vue
<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 的类型:

jsx
// 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:

vue
<script setup lang="ts">
defineProps({
  user_id: Number,
  user_name: String,
});
</script>
<template>
  <div>{{ user_name }}</div>
</template>

定义后的 props 无须导出,可直接在模版中使用。除了使用 Vue 的方式定义 props,defineProps() 函数更常用的方式是使用泛型定义。泛型定义更符合 TypeScript 标准,如下:

vue
<script setup lang="ts">
const props = defineProps<{
  user_id: number;
  user_name: string;
}>();
console.log(props.user_name);
</script>

emits 类型

除了 props 需要类型,自定义事件的类型也很关键。当不使用 setup 语法糖时,defineComponent() 函数同样能推导出自定义事件的类型。如下:

js
import { defineComponent } from "vue";
export default defineComponent({
  emits: ["change"],
  setup(props, { emit }) {
    let params = { id: 1 };
    emit("change", params); // 调用事件并传参
  },
});

当使用 setup 语法糖时,事件类型通过宏函数 defineEmits() 定义:

vue
<script setup lang="ts">
var emit = defineEmits("change", "update");
</script>
<template>
  <div>{{ user_name }}</div>
</template>

使用 Vue 的方式定义自定义事件,只能指定一个事件名称。如果需要更加严格的类型定义,比如指定参数类型、返回类型,这时需要使用泛型来定义事件类型:

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 中大部分的函数都会自动推导类型,别忘了还有编辑器的类型提示。