「 Vue 」在 Vue 中使用组合式 API

1,487 阅读9分钟

这是我参与11月更文挑战的10天,活动详情查看:2021最后一次更文挑战

当组件越来越大时,不同的逻辑关注点会相互分离,维护起来非常困难。

Vue 选项式 API: 按选项类型分组的代码

我们可以使用 Composition API 来解决这个问题。

Setup 函数

使用 this

setup 函数是在解析其它组件选项之前被调用的,因此我们在其中使用的 this 并不指向改组件的实例。

结合模板使用

setup 函数返回的内容会暴露在外部,我们可以在模板中直接使用其中的变量或者方法。

const app = Vue.createApp({
    setup(props) {
        return {
            name: 'Jack',
            handleClick() { alert('this is setup') }
        }
    },
    template:/*html*/ `
    <div @click='handleClick'>{{name}}</div>
    `
})

响应式变量

ref

如下代码返回的变量并非响应式,因此 2 秒之后返回的 name 变量不变:

setup(props) {
    let name = 'Jack';
    setTimeout(() => {
        name = 'Joe'
    }, 2000);
    return { name }
}

我们可以使用 ref 响应式变量。它通过 proxy 对数据进行封装,当数据变化时,触发模板等内容的更新。

ref 处理基础类型的数据,如字符串、数字等。下面的例子中,ref 通过 proxy'Jack' 变为 proxy({value: 'Jack'}) 的响应式引用。使用 value property 来修改值,在组件中直接使用变量名:

const app = Vue.createApp({
    setup(props) {
        const { ref } = Vue;
        let name = ref('Jack');
        setTimeout(() => {
            name.value = 'Joe'
        }, 2000);
        return { name }
    },
    template:/*html*/ `
        <div>{{name}}</div>
    `
})

reactive

reactive 可以处理非基础类型的数据,如对象、数组等。下面的例子中,reactive 通过 proxy{name: 'Jack'} 变为 proxy({name: 'Jack'}) 的响应式引用:

const app = Vue.createApp({
    setup(props) {
        const { reactive } = Vue;
        const nameObj = reactive({ name: 'Jack' });
        setTimeout(() => {
            nameObj.name = 'Joe'
        }, 2000);
        return { nameObj }
    },
    template:/*html*/ `
    <div>{{nameObj.name}}</div>
    `
})

我们可以使用 readonly 将响应式引用的对象进行复制,返回一个只读的数据。

const app = Vue.createApp({
    setup(props) {
        const { reactive, readonly } = Vue;
        const nameObj = reactive([123]);
        const copy = readonly(nameObj)
        setTimeout(() => {
            nameObj[0] = '456';
            copy[0] = '456'; // 报错,copy 对象只读
        }, 2000);
        return { nameObj }
    },
    template:/*html*/ `
    <div>{{nameObj[0]}}</div>
    `
})

使用 ES6 解构的数据不具有响应性,因为此时返回的是一个值,而非对象,即 name === 'Jack'

const app = Vue.createApp({
    setup(props) {
        const { reactive } = Vue;
        const nameObj = reactive({ name: 'Jack' });
        setTimeout(() => {
            nameObj.name = 'Joe';
        }, 2000);
        const { name } = nameObj; // ES6 解构
        return { name } // name 的类型是 string,而不是 ref
    },
    template:/*html*/ `
    <div>{{name}}</div>
    `
})

toRefs

解构时使用 toRefs 可以解决这个问题:

const app = Vue.createApp({
    setup(props) {
        const { reactive, toRefs } = Vue;
        const nameObj = reactive({ name: 'Jack' });
        setTimeout(() => {
            nameObj.name = 'Joe';
        }, 2000);
        const { name } = toRefs(nameObj);
        return { name }
    },
    template:/*html*/ `
    <div>{{name}}</div>
    `
})

此时, toRefsproxy({name: 'Jack'}) 转化为 {name: proxy({value: 'Jack'})}。解构之后的 name === proxy({value: 'Jack'})

toRef

toRefs 对象使用 ES6 解构为不存在的 property 时,会返回 undefined。 我们可以使用 toRef 来解决这个问题,为源响应式对象上的某个 property 新创建一个 ref

const app = Vue.createApp({
    setup(props) {
        const { reactive, toRef } = Vue;
        const data = reactive({ name: 'Jack' });
        const age = toRef(data, 'age');
        setTimeout(() => {
            age.value = '18';
        }, 2000);
        return { age }
    },
    template:/*html*/ `
    <div>{{age}}</div>
    `
})

得益于 ES6 的 Proxy 对象,相较于 Vue2 的 Object.defineProperty(),Vue3 使用 Proxy() 成功支持已有和新增的 key 的读取和修改。

Object.defineProperty(data, "count",{
    get() {},
    set() {},
})

Proxy(data, {
    get(key) {},
    set(key, value) {},
})

Setup 参数

setup 函数接收两个参数,分别是 propscontext

props 参数是父组件传来的响应式 prop attribute,不可用 ES6 解构,可以使用 toRefstoRef

context 参数中含有三个 property,分别是attrsslotsemit。这三个属性相当于在组件对象中使用的 this.$attrsthis.$slotsthis.$emit

访问组件的 property

由于 setup 被执行时,组件实例没有被创建,所以我们只能访问父组件传递过来的参数,即 propsattrsslotsemit

使用渲染函数

如果 setup 函数返回的是渲染函数,组件会直接使用这个渲染函数生成 DOM,可结合 slots 实现:

const app = Vue.createApp({
    template:/*html*/ `
    <child><h1>parent</h1></child>
    `
})

app.component('child', {
    setup(props, context) {
        const { h } = Vue;
        const { attrs, slots, emit } = context;
        return () => h('div', {}, slots.default())
    }
})

可以在setup 函数中使用 emit

const app = Vue.createApp({
    methods: { handleChange() { alert('change'); } },
    template:/*html*/ `<child @change='handleChange'></child>`
})

app.component('child', {
    setup(props, context) {
        const { attrs, slots, emit } = context;
        function handleClick() { emit('change') };
        return { handleClick }
    },
    template:/*html*/`<div @click='handleClick'>child</div>`
})

由此可见,setup 函数中的语法可以替代传统的语法。

TodoList

下面的代码通过 setup 函数实现了一个 TodoList:

const app = Vue.createApp({
    setup(props) {
        const { ref, reactive } = Vue;
        const inputValue = ref('');
        const list = reactive([]);
        const handleChange = (e) => {
            inputValue.value = e.target.value
        }
        const handleSubmit = () => {
            list.push(inputValue.value)
        }
        return {
            inputValue,
            handleChange,
            handleSubmit,
            list
        }
    },
    template:/*html*/ `
    <div>
        <input :value='inputValue' @input='handleChange'/>
        <button @click='handleSubmit'>submit</button>
        <ul>
            <li v-for='(item, index) in list' :key='index'>{{item}}</li>
        </ul>
    </div>
    `
})

demo1.gif

但是我们发现实现输入功能和实现提交功能的代码结合到了一起,我们需要把它们拆分开使代码结构更加清晰。

// 对关于 list 的操作进行了封装
const listRelativeEffect = () => {
    const { reactive } = Vue;
    const list = reactive([]);
    const handleSubmit = (item) => {
        list.push(item)
    }
    return {
        list,
        handleSubmit
    }
}

// 对关于 inputValue 的操作进行了封装
const inputRelativeEffect = () => {
    const { ref } = Vue;
    const inputValue = ref('');
    const handleChange = (e) => {
        inputValue.value = e.target.value
    }
    return {
        inputValue,
        handleChange
    }
}

const app = Vue.createApp({
    setup(props) {
        // 流程调度中转
        const { list, handleSubmit } = listRelativeEffect();
        const { inputValue, handleChange } = inputRelativeEffect();
        return {
            inputValue, handleChange,
            handleSubmit, list
        }
    },
    template:/*html*/ `
    <div>
        <input :value='inputValue' @input='handleChange'/>
        <button @click='handleSubmit(inputValue)'>submit</button>
        <ul>
            <li v-for='(item, index) in list' :key='index'>{{item}}</li>
        </ul>
    </div>
    `
})

这样写提升了代码的可维护性。

computed 属性

我们可以使用从 Vue 导入的 computed 方法,传递一个参数,它是一个类似 getter 的回调函数,得到一个只读的响应式引用。为了访问新创建的计算变量的值,我们需要像 ref 一样使用 .value property。

const app = Vue.createApp({
    setup(props) {
        const { ref, computed } = Vue;
        const count = ref(0);
        const handleClick = () => { count.value += 1; };
        const countAdd5 = computed(() => {
            return count.value + 5; 
        })
        return { count, handleClick, countAdd5 }
    },
    template:/*html*/ `
    <div @click='handleClick'>{{count}}--{{countAdd5}}</div>
    `
})

计算属性里也可以接收 setter 和 getter,它们以对象的形式作为参数:

const app = Vue.createApp({
    setup(props) {
        const { ref, computed } = Vue;
        const count = ref(0);
        const handleClick = () => { count.value += 1; };
        const countAdd5 = computed({
            get: () => {
                return count.value + 5;
            },
            set: (param) => {
                count.value = param - 5;
            }
        });
        setTimeout(() => {
            countAdd5.value = 100;
        }, 1000);
        return { count, handleClick, countAdd5 }
    },
    template:/*html*/ `
    <div @click='handleClick'>{{count}}--{{countAdd5}}</div>
    `
})

watch 响应式更改

watch函数接受 3 个参数

  1. 一个想要侦听的 ref 或 reactive 的响应式引用、 getter 或 effect 函数、或这些类型的数组
  2. 一个回调
  3. 可选的配置选项
const app = Vue.createApp({
    setup(props) {
        const { ref, watch } = Vue;
        const name = ref('Jack');
        watch(name, (newValue, oldValue) => {
            console.log(newValue, oldValue)
        })
        return { name }
    },
    template:/*html*/`<input v-model='name'>`
})

watch的第一个参数是 reactive 的 property 时,我们需要先将其转为 getter 函数,否则它就不是响应式引用了:

const app = Vue.createApp({
    setup(props) {
        const { reactive, watch, toRefs } = Vue;
        const nameObj = reactive({ name: 'Jack' });
        // 第一个参数是 () => nameObj.name
        watch(() => nameObj.name, (newValue, oldValue) => {
            console.log(newValue, oldValue)
        })
        const { name } = toRefs(nameObj);
        return { name }
    },
    template:/*html*/`<input v-model='name'>`
})

以数组的方式传参可以用一个侦听器同时监听多个参数:

const app = Vue.createApp({
    setup(props) {
        const { reactive, watch, toRefs } = Vue;
        const nameObj = reactive({ name: 'Jack', age: '18' });
        watch(
            [() => nameObj.name, () => nameObj.age],
            ([newName, newAge], [oldName, oldAge]) => {
                console.log(newName, newAge, '--', oldName, oldAge)
            }
        )
        const { name, age } = toRefs(nameObj);
        return { name, age }
    },
    template:/*html*/`
    name: <input v-model='name'>
    age: <input v-model='age'>
    `
})

watch 函数的惰性的,渲染 DOM 之后不会立即执行。

watchEffect 没有惰性,渲染 DOM 之后立即执行,函数自动检测代码中对外部的依赖,当发生变化时重新执行整段代码。不需要传递很多参数,只需要传递回调函数。watchEffect 无法获取以前的数据。

const app = Vue.createApp({
    setup(props) {
        const { reactive, toRefs, watchEffect } = Vue;
        const nameObj = reactive({ name: 'Jack', age: '18' });
        watchEffect(() => {
            console.log(nameObj.name);
        });
        const { name, age } = toRefs(nameObj);
        return { name, age }
    },
    template:/*html*/`
    name: <input v-model='name'>
    age: <input v-model='age'>
    `
})

可以将 watchwatchEffect 保存到一个函数对象中,调用这个函数可以取消监听。

const stop = watch(
    [() => nameObj.name, () => nameObj.age],
    ([newName, newAge], [oldName, oldAge]) => {
        console.log(newName, newAge, '--', oldName, oldAge);
        setTimeout(() => {
            stop();
        }, 2000);
    }
)

watch 接收第三个参数是可配置选项,可以在这里将设置为非惰性函数:

watch(
    [() => nameObj.name, () => nameObj.age],
    ([newName, newAge], [oldName, oldAge]) => {
        console.log(newName, newAge, '--', oldName, oldAge);
    },
    { immediate: true }
)

结合 TypeScript 使用模块化

在 Vue3 中使用 TypeScript 时,用 defineComponent 方法来定义 component,它并没有实现操作逻辑,直接将传入的 object 返回。它的目的是让传入的对象能获得对应类型。

我们可以将重复的逻辑提取成一个单独的文件,这里我们以发送异步请求的逻辑为例:

// src/hooks/useURLLoader.ts
import { ref } from 'vue'
import axios from 'axios'

function useURLLoader<T>(url: string) {
  // result.value 会推论为 null,使用泛型确定它的类型
  const result = ref<T | null>(null);
  const loading = ref(true);
  const loaded = ref(false);
  const error = ref(null);

  axios.get(url).then((rawData) => {
    loading.value = false;
    loaded.value = true;
    result.value = rawData.data;
  }).catch(e => {
    loading.value = false;
    error.value = e;
  })

  return {
    result, loaded, loading, error
  }
}

export default useURLLoader;

这里请求 Dog API :

{
    "message": "https://images.dog.ceo/breeds/vizsla/n02100583_2086.jpg",
    "status": "success"
}

为了解决不同接口返回结果结构不同的问题,我们使用泛型来确定返回的类型:

<template>
  <h1 v-if="loading">Loading...</h1>
  <img :src="result.message" v-if="loaded" />
</template>

<script lang="ts">
import { defineComponent, watch } from "vue";
import useURLLoader from "./hooks/useURLLoader";
interface DogResult {
  message: string;
  status: string;
}
export default defineComponent({
  name: "App",
  setup() {
    const { result, loaded, loading, error } = useURLLoader<DogResult>(
      "https://dog.ceo/api/breeds/image/random"
    );
    watch(result, () => {
      // 使用 type guard 检查,若类型不为 null,则是 DogResult
      if (result.value) {
        console.log("value", result.value.message);
      }
    });
    return {
      result,
      loaded,
      loading,
      error,
    };
  },
});
</script>

<style>
#app {
  text-align: center;
  margin-top: 60px;
}
</style>

成功后页面如下:

image.png

我们再来使用不同的接口 Cat API ,可以感受到泛型带来的好处:

[{
    "breeds": [],
    "id": "6f0",
    "url": "https://cdn2.thecatapi.com/images/6f0.jpg",
    "width": 960,
    "height": 575
}]
<template>
  <h1 v-if="loading">Loading...</h1>
  <img :src="result[0].url" v-if="loaded" />
</template>

<script lang="ts">
import { defineComponent, watch } from "vue";
import useURLLoader from "./hooks/useURLLoader";
interface CatResult {
  id: string;
  url: string;
  width: number;
  height: number;
}
export default defineComponent({
  name: "App",
  setup() {
    const { result, loaded, loading, error } = useURLLoader<CatResult[]>(
      "https://api.thecatapi.com/v1/images/search?limit=1"
    );
    watch(result, () => {
      if (result.value) {
        console.log("value", result.value[0].url);
      }
    });
    return {
      result,
      loaded,
      loading,
      error,
    };
  },
});
</script>

<style>
#app {
  text-align: center;
  margin-top: 60px;
}
</style>

成功后的页面如下:

image.png

生命周期钩子

在选项式 API 中可以使用各种周期函数。由于 setup 函数啊在组件实例完全被初始化之前执行的函数,所以无需 beforeCreatecreated 钩子。在这些钩子中的代码可以直接在 setup 中编写。

可以通过在生命周期钩子前面加上 “on” 来访问组件的生命周期钩子。例如 onMounted 对应组件的 mounted 钩子。这些钩子接收一个回调函数作为参数。

const app = Vue.createApp({
    setup(props) {
        const { onBeforeMount } = Vue;
        onBeforeMount(() => {
            console.log('beforeMount')
        });
        return {}
    },
    template:/*html*/`
    <div>Hello World</div>
    `
})

选项式 API 中的 renderTracked 钩子在每次渲染 DOM 之后重新收集响应式依赖, renderTriggerd 每次触发页面时自动执行。

Provide / Inject

可以在 setup 中这样使用 provideinject

const app = Vue.createApp({
    setup(props) {
        const { provide } = Vue;
        provide('name', 'Jack')
        return {}
    },
    template:/*html*/`
    <child/>
    `
})

app.component('child', {
    setup(props) {
        const { inject } = Vue;
        const name = inject('name');
        return { name }
    },
    template:/*html*/`
    <div>{{name}}</div>
    `
})

使用 Provide

Provide 函数接收两个参数,一个是 key,一个是 valuekey 值以字符串的形式传递, value 值可以是字符串或者对象。

如果想传递多个值,则可以重构:

provide('location', 'North Pole')
provide('geolocation', {
  longitude: 90,
  latitude: 135
})

使用 Inject

inject 函数接收两个参数,一个是想要接收的 key,一个是默认 value。第二个参数是指当找不到 key 对应的值时,返回第二个参数的值。第二个参数可选。

响应性

我们可以在 provide 值的时候使用 refreactive 为值增加响应性。

const app = Vue.createApp({
    setup(props) {
        const { provide, ref } = Vue;
        provide('name', ref('Jack'))
        return {}
    },
    template:/*html*/`<child/>`
})

app.component('child', {
    setup(props) {
        const { inject } = Vue;
        const name = inject('name');
        const handleClick = () => {name.value = 'Joe'}
        return { name, handleClick }
    },
    template:/*html*/`<div @click='handleClick'>{{name}}</div>`
})

但是我们为了数据的稳定性,对响应式数据的修改一般限制在 provide 的组件内部。

const app = Vue.createApp({
    setup(props) {
        const { provide, ref } = Vue;
        const name = ref('Jack');
        provide('name', name);
        provide('changeName', (value) => { name.value = value; })
        return {}
    },
    template:/*html*/`<child/>`
})

app.component('child', {
    setup(props) {
        const { inject } = Vue;
        const name = inject('name');
        const changeName = inject('changeName')
        const handleClick = () => { changeName('Joe') }
        return { name, handleClick }
    },
    template:/*html*/`<div @click='handleClick'>{{name}}</div>`
})

为了确保 provide 的数据不会被改变,我们可以对提供的数据使用 readonly

const app = Vue.createApp({
    setup(props) {
        const { provide, ref, readonly } = Vue;
        const name = ref('Jack');
        provide('name', readonly(name));
        provide('changeName', (value) => { name.value = value; })
        return {}
    },
    template:/*html*/`<child/>`
})

模板引用

在组合式 API 中,可以这样使用 ref

const app = Vue.createApp({
    setup(props) {
        const { ref, onMounted } = Vue;
        const hello = ref(null);
        onMounted(() => {
            console.log(hello.value) // <div>Hello World</div>
        });
        return { hello }
    },
    template:/*html*/`<div ref='hello'>Hello World</div>`
})

这里我们在渲染上下文中暴露 hello,并通过 ref='hello',将其绑定到 div 作为其 ref。