CompositionAPI(一)Mixins、setup、reactive、ref、readonly

108 阅读16分钟

本文整理来自深入Vue3+TypeScript技术栈-coderwhy大神新课,只作为个人笔记记录使用,请大家多支持王红元老师。

Composition API其实就是用来替代Mixin的,我们先来学习一下Mixin。

认识Mixins

目前我们是使用组件化的方式在开发整个Vue的应用程序,但是组件和组件之间有时候会存在相同的代码逻辑,我们希望对相同的代码逻辑进行抽取。

在Vue2和Vue3中都支持的一种方式就是使用Mixins来完成,Mixins提供了一种非常灵活的方式,来分发Vue组件中的可复用功能,一个Mixin对象可以包含任何组件选项,当组件使用Mixin对象时,所有Mixin对象的选项将被混合进入该组件本身的选项中。

Mixins的基本使用

在右侧的文件中,我们定义sayHelloMixin,然后在左侧的组件中引入。

组件中使用Mixins一般通过一个数组,因为组件中可能使用不止一个Mixin对象。

Mixins的合并规则

如果Mixins对象中的选项和组件对象中的选项发生了冲突,那么Vue会如何操作呢?这里分成不同的情况来进行处理:

  • 情况一:如果是data函数的返回值对象
    返回值对象默认情况下会进行合并,如果data返回值对象的属性发生了冲突,那么会优先保留组件自身的数据。
  • 情况二:生命周期钩子函数
    生命周期的钩子函数会被合并到数组中,都会被调用。
  • 情况三:值为对象的选项
    例如 methods、components 和 directives,将被合并为同一个对象,比如都有methods选项,并且都定义了方法,那么它们都会生效,但是如果对象的key相同,那么会取组件对象的键值对。

一句话总结:优先合并,冲突就覆盖,覆盖的时候优先组件

全局混入Mixin

如果组件中的某些选项,是所有的组件都需要拥有的,那么这个时候我们可以使用全局的mixin。全局的Mixin可以使用应用app的方法 mixin 来完成注册,一旦注册,那么全局混入的选项将会影响每一个组件。使用全局mixin之后我们就不用在组件中一个一个写mixins: [sayHelloMixin]了。不推荐在应用代码中使用。

import { createApp } from 'vue';
import App from './01_mixin和extends/App.vue';

const app = createApp(App);

app.mixin({
  data() {
    return {}
  },
  methods: {
  },
  created() {
    console.log("全局的created生命周期");
  }
});

app.mount("#app");

Options API的弊端

在Vue2中,我们编写组件的方式是Options API,Options API的一大特点就是在对应的属性中编写对应的功能模块,比如data定义数据、methods中定义方法、computed中定义计算属性、watch中监听属性改变,也包括生命周期钩子。但是这种代码有一个很大的弊端:当我们实现某一个功能时,这个功能对应的代码逻辑会被拆分到各个属性中。当我们组件变得更大、更复杂时,逻辑关注点的列表就会增长,那么同一个功能的逻辑就会被拆分的很分散,尤其对于那些一开始没有编写这些组件的人来说,这个组件的代码是难以阅读和理解的。

下面我们来看一个非常大的组件,其中的逻辑功能按照颜色进行了划分,这种碎片化的代码使用理解和维护这个复杂的组件变得异常困难,并且隐藏了潜在的逻辑问题,并且当我们处理单个逻辑关注点时,需要不断的跳到相应的代码块中。

大组件的逻辑分散

Options API:

Composition API:

如果我们能将同一个逻辑关注点相关的代码收集在一起会更好,这就是Composition API想要做以及可以帮助我们完成的事情,所以也有人把Vue Composition API简称为VCA。

认识Composition API

为了开始使用Composition API,我们需要有一个可以实际使用它(编写代码)的地方,在Vue组件中,这个位置就是 setup 函数。setup其实就是组件的另外一个选项,只不过这个选项强大到我们可以用它来替代之前所编写的大部分其他选项,比如methods、computed、watch、data、生命周期等等。

setup函数的参数

我们先来研究一个setup函数的参数,它主要有两个参数:props和context。

props非常好理解,它其实就是父组件传递过来的属性会被放到props对象中,我们在setup中如果需要使用,那么就可以直接通过props参数获取:

  • 对于定义props的类型,我们还是和之前的规则是一样的,在props选项中定义,并且在template中依然是可以正常去使用props中的属性,比如message。
  • 如果我们在setup函数中想要使用props,那么不可以通过 this 去获取(后面我会讲到为什么),因为props有直接作为参数传递到setup函数中,所以我们可以直接通过参数来使用即可,比如:props.message。

另外一个参数是context,我们也称之为是一个SetupContext,它里面包含三个属性:

  • attrs:所有的非prop的attribute;
  • slots:父组件传递过来的插槽(这个在以渲染函数返回时才会有用,后面会讲到,用的不多);
  • emit:当我们组件内部需要发出事件时会用到emit(因为我们不能访问this,所以不可以通过 this.$emit发出事件);
// setup(props, context), 下面是对象解构
setup(props, {attrs, slots, emit}) {
  console.log(props.message);
  console.log(attrs.id, attrs.class);
  console.log(slots);
  console.log(emit);
} 

setup函数的返回值

setup函数的返回值可以在模板template中被使用,也就是说我们可以通过setup的返回值来替代data选项。

// setup(props, context), 下面是对象解构
setup(props, {attrs, slots, emit}) {
  console.log(props.message);
  console.log(attrs.id, attrs.class);
  console.log(slots);
  console.log(emit);

  //返回数据对象
  return { 
    title: "Hello Home",
    counter: 100
  }
},

甚至是我们可以返回一个执行函数来代替在methods中定义的方法,如下计数器的案例:

<template>
  <div>
    Home Page
    <h2>{{message}}</h2>

    <h2>{{title}}</h2>
    <h2>当前计数: {{counter}}</h2>
    <button @click="increment">+1</button>
  </div>
</template>

<script>
  export default {
    props: {
      message: {
        type: String,
        required: true
      }
    },
    setup() {
      let counter = 100;

      // 局部函数
      const increment = () => {
        counter++;
        console.log(counter);
      }

      return {
        title: "Hello Home",
        counter,
        increment
      }
    }
  }
</script>

<style scoped>
</style>

但是,如果我们将 counter 进行 increment 操作时,是否可以实现界面的响应式呢?

答案是不可以。这是因为对于一个定义的变量 (let counter = 100) 来说,默认情况下,Vue并不会跟踪它的变化来引起界面的响应式操作。我们以前在data()函数中定义的是响应式的,那是因为Vue内部会通过Object.defineProperty对data中的数据进行响应式监听。

setup不可以使用this

官方关于this有这样一段描述,但是这是错误的。

表达的含义是this并没有指向当前组件实例,并且在setup被调用之前,data、computed、methods等都没有被解析,所以无法在setup中获取this。

其实之前的这段描述是和源码有出入的,coderwhy向官方提交了PR做出了描述的修改,后来coderwhy的PR也有被合并到官方文档中。之前的描述大概含义是不可以使用this是因为组件实例还没有被创建出来。

其实Vue源码是在调用createComponentInstance()方法之后再调用的setup()函数,所以调用setup()函数的时候组件实例肯定已经创建出来了,只不过在setup()函数中没有进行任何this的绑定,所以不可以使用this。

coderwhy之前关于this的描述问题

coderwhy是如何发现官方文档的错误的呢?

在阅读源码的过程中,代码是按照如下顺序执行的:

  1. 调用 createComponentInstance 创建组件实例;
  2. 调用 setupComponent 初始化 component 内部的操作;
  3. 调用 setupStatefulComponent 初始化有状态的组件;
  4. 在 setupStatefulComponent 取出了 setup 函数;
  5. 通过 callWithErrorHandling 的函数执行 setup;

从上面的代码我们可以看出,组件的instance肯定在执行setup函数之前就已经创建出来了,只不过在setup()函数中没有进行任何this的绑定,所以不可以使用this。

reactive()函数

reactive adj. 反应的; 有反应的

接着上面计数器的案例,如果想要为在setup中定义的数据提供响应式的特性,那么我们可以使用reactive()函数。reactive()函数参数要求传入的必须是对象或数组。

<template>
  <div>
    Home Page
    <h2>{{message}}</h2>
    <h2>当前计数: {{state.counter}}</h2>
    <button @click="increment">+1</button>
  </div>
</template>

<script>
  //先导入函数
  import { reactive } from 'vue';

  export default {
    props: {
      message: {
        type: String,
        required: true
      }
    },
    setup() {
      const state = reactive({
        counter: 100
      })

      // 局部函数
      const increment = () => {
        state.counter++;
        console.log(state.counter);
      }

      return {
        state,
        increment
      }
    }
  }
</script>

<style scoped>
</style>

那么这是什么原因呢?为什么就可以变成响应式的呢? 这是因为当我们使用reactive函数处理我们的数据之后,数据再次被使用时就会进行依赖收集,当数据发生改变时,所有收集到的依赖都是进行对应的响应式操作(比如更新界面)。

上面的reactive函数要求传入的必须是对象或数组,所以上面,即使我们只有counter: 100,也需要包裹成对象,就显得很麻烦,这时候我们可以使用Ref API。

ref()函数

reactive()函数对传入的类型是有限制的,它要求我们必须传入的是一个对象或者数组类型,如果我们传入一个基本数据类型(String、Number、Boolean)会报一个警告:

这个时候Vue3给我们提供了另外一个Ref API,ref 会返回一个可变的响应式对象,该对象作为一个响应式的引用维护着它内部的值,这就是 ref 名称的来源,它内部的值是在 ref 的 value 属性中被维护的。

let counter = ref(100);

这里有两个注意事项:

  1. 在模板中引入ref的值时,Vue为了我们使用方便,Vue会自动帮助我们进行解包操作,所以我们并不需要在模板中通过 ref.value 的方式来使用。
  2. 但是在 setup 函数内部,它依然是一个 ref 引用, 所以对其进行操作时,我们依然需要使用 ref.value的方式。
<template>
  <div>
    Home Page
    <h2>{{message}}</h2>
    <!-- 当我们在template模板中使用ref对象, 它会自动进行解包 -->
    <h2>当前计数: {{counter}}</h2>
    <button @click="increment">+1</button>
  </div>
</template>

<script>
  //先导入函数
  import { ref } from 'vue';

  export default {
    props: {
      message: {
        type: String,
        required: true
      }
    },
    setup() {
      // counter编程一个ref的可响应式的引用
      let counter = ref(100);

      // 局部函数
      const increment = () => {
        // 在setup中就要通过.value访问
        counter.value++;
        console.log(counter.value);
      }

      return {
        counter,
        increment
      }
    }
  }
</script>

<style scoped>
</style>

ref的浅层解包

如果ref对象被普通对象包裹,在模板中使用时,不会自动解包,如果被reactive函数包裹,则会自动解包。

<template>
  <div>
    Home Page
    <h2>{{message}}</h2>
    <!-- 当我们在template模板中使用ref对象, 它会自动进行解包 -->
    <h2>当前计数: {{counter}}</h2>
    <!-- 如果ref对象外层包裹的是普通对象,那么不会进行解包 -->
    <h2>当前计数: {{info.counter.value}}</h2>
    <!-- 当如果ref对象最外层包裹的是一个reactive可响应式对象, 那么内容的ref可以解包 -->
    <h2>当前计数: {{reactiveInfo.counter}}</h2>
    <button @click="increment">+1</button>
  </div>
</template>

<script>
  import { ref, reactive } from 'vue';

  export default {
    props: {
      message: {
        type: String,
        required: true
      }
    },
    setup() {
      let counter = ref(100);

      const info = {
        counter
      }

      const reactiveInfo = reactive({
        counter
      })

      // 局部函数
      const increment = () => {
        // 在setup中就要通过.value访问
        counter.value++;
        console.log(counter.value);
      }

      return {
        counter,
        info,
        reactiveInfo,
        increment
      }
    }
  }
</script>

<style scoped>
</style>

readonly()

我们通过 reactive() 或者 ref() 可以获取到一个响应式的对象,但是某些情况下,我们传入给其他地方(组件)的这个响应式对象希望在另外一个地方(组件)能被使用,但是不能被修改,这个时候如何防止这种情况的出现呢?

Vue3为我们提供了 readonly 的方法,readonly会返回原生对象的只读代理(也就是它依然是一个Proxy(n.代理),这个 proxy 的 set 方法被劫持,并且不能对其进行修改)。

在开发中常见的 readonly 方法会传入三个类型的参数:
类型一:普通对象;
类型二:reactive()返回的对象;
类型三:ref()的对象;

<template>
  <div>
    <button @click="updateState">修改状态</button>
  </div>
</template>

<script>
  import { reactive, ref, readonly } from 'vue';

  export default {
    setup() {
      // 1.普通对象
      const info1 = {name: "why"};
      const readonlyInfo1 = readonly(info1);
      // readonlyInfo1.name = "coderwhy"  只读对象,不能修改,会有警告

      // 2.响应式的对象reactive
      const info2 = reactive({
        name: "why"
      })
      const readonlyInfo2 = readonly(info2);
      // readonlyInfo2.name = "coderwhy"  只读对象,不能修改,会有警告

      // 3.响应式的对象ref
      const info3 = ref("why");
      const readonlyInfo3 = readonly(info3);
      // readonlyInfo3.value = "coderwhy"  只读对象,不能修改,会有警告

      const updateState = () => {
        // readonlyInfo3.value = "coderwhy"
        info3.value = "coderwhy";
      }

      return {
        updateState,
      }
    }
  }
</script>

<style scoped>

</style>

readonly返回的对象都是不允许修改的,但是经过readonly处理的原来的对象是允许被修改的。比如 const info = readonly(obj),info对象是不允许被修改的,obj可以被修改,当obj被修改时,readonly返回的info对象也会被修改,所以一般我们会把info传递给其他组件使用。

在我们传递给其他组件数据时,往往希望其他组件只使用我们传递的内容,但是不允许它们修改,这时就可以使用readonly了,而且我们希望子组件使用的数据是响应式的,如果readonly包裹一个普通对象,当父组件的数据发生改变的时候,子组件是不会刷新的,所以我们可以使用readonly包裹一个reactive对象或者ref对象,这样父组件的数据发生改变的时候,子组件也会刷新。

Reactive判断的API

  • isProxy:检查对象是否是由 reactive 或 readonly创建的 proxy。
  • isReactive
    • 检查对象是否是由reactive创建的响应式代理
    • 如果该对象外层是readonly,内层还是reactive,那么返回的也是true。
  • isReadonly:检查对象是否是由 readonly 创建的只读代理。
  • toRaw:返回 reactive 或 readonly 代理的原始对象(不建议保留对原始对象的持久引用,请谨慎使用)。
  • shallowReactive:翻译过来就是浅层响应式,创建一个响应式代理,它跟踪其自身 property 的响应性,但不执行嵌套对象的深层响应式转换(深层还是原生对象)。比如:对象里面还有对象,如果我们希望外面的属性改变才是响应式的,里面深层对象的改变不是响应式的,这时候可以用shallowReactive。
  • shallowReadonly:浅层只读,创建一个 proxy,使其自身的 property 为只读,但不执行嵌套对象的深度只读转换(深层还是可读可写的)。

toRefs()

如果我们使用ES6的解构语法,对reactive返回的对象进行解构获取值,然后在模板中使用解构出来的值,那么之后无论是修改解构后的变量,还是修改解构之前reactive返回的对象,数据都不再是响应式的。

这是因为解构其实就相当于重新声明变量然后赋值,如下:

//解构
const { name, age } = state;
//相当于如下
const name = "why";
const age = 18;

那么有没有办法让我们解构出来的属性是响应式的呢? Vue为我们提供了一个toRefs函数,可以将reactive返回的对象中的属性都转成ref,那么我们再次进行结构出来的 name 和 age 本身就是 ref的。

然后在模板中我们直接使用name、age就行,因为模板中会自动解构,但是在下面的逻辑代码中我们还是要使用 .value 来修改值:

const changeAge = () => {
  age.value++;
}

这种做法相当于已经在 state.name 和 ref.value 之间建立了链接,任何一个修改都会引起另外一个变化。

toRef()

上面的toRefs是将reactive对象中的所有属性都转成ref,建立链接,但是有时候有些属性我们用不到,这就额外增加了不必要的开销。

如果我们只希望转换reactive对象中的某个属性为ref,那么可以使用toRef的方法:

//普通的解构
let { name } = state;
//解构age,并建立连接,第一个参数是对象,第二个参数是对象的属性名
let age = toRef(state, "age");

ref其他的API

  • unref:如果我们想要获取一个ref引用中的value,那么也可以通过unref方法。如果参数是一个 ref,则返回内部值,否则返回参数本身,这是val = isRef(val) ? val.value : val的语法糖函数。最终的结果就是无论是不是ref,我们都可以获取他们的值。
  • isRef:判断值是否是一个ref对象。
  • shallowRef:创建一个浅层的ref对象。
  • triggerRef:手动触发和 shallowRef 相关联的副作用。

默认情况下,不管是reactive还是ref创建的响应式对象都是深层次的,如下:

const info = ref({name: "why"})

const changeInfo = () => {
  //通过value拿到原对象,再修改原对象的值,这时候界面的数据也会改变,这就是深层次的响应式
  info.value.name = "james";
}

如果我们不希望深层次的响应式,只希望改变外面大的对象的时候才是响应式的,改变里面的属性值不是响应式的,我们就可以使用shallowRef。 如果我们又想触发响应式了,就可以调用triggerRef,来手动触发相关联的副作用,这时候界面就又变成响应式的了。

//浅层次的响应式
const info = shallowRef({name: "why"})

const changeInfo = () => {
  //这时候修改里面的属性值,界面就不会变化了,只有修改外面大的对象才是响应式的
  info.value.name = "james";
  //如果我们又想触发响应式,就可以调用triggerRef,来触发相关联的副作用,这时候界面就又变成响应式的了
  triggerRef(info);
}

customRef自定义ref

下面代码,我们在输入框中输入文字,下面显示的文字会立马更新:

<template>
  <div>
    <input v-model="message"/>
    <h2>{{message}}</h2>
  </div>
</template>

<script>
  import ref from 'vue';

  export default {
    setup() {
      const message = ref("Hello World");

      return {
        message
      }
    }
  }
</script>

<style scoped>
</style>

如果我们不想更新这么频繁,比如输入后300ms才更新,那么使用ref就做不到了,所以我们需要自定义ref。

创建一个自定义的ref,并对其依赖项跟踪和更新触发进行显示控制。它需要一个工厂函数,该函数接受 track 和 trigger 函数作为参数,并且应该返回一个带有 get 和 set 的对象。

自定义ref的useDebounceRef.js文件代码如下:

import { customRef } from 'vue';

// 自定义ref
export default function(value, delay = 300) {
  let timer = null;
  return customRef((track, trigger) => {
    return {
      get() {
        //收集依赖
        track();
        return value;
      },
      set(newValue) {
        //如果在300ms内又输入值了,就把定时器清空,也就是取消触发更新
        clearTimeout(timer);
        timer = setTimeout(() => {
          value = newValue;
          //触发更新
          trigger();
        }, delay);
      }
    }
  })
}

然后使用我们自定义的ref,就可以达到我们想要的输入后300ms才更新的效果了,这样做可以提升一点点性能。

<template>
  <div>
    <input v-model="message"/>
    <h2>{{message}}</h2>
  </div>
</template>

<script>
  import debounceRef from './hook/useDebounceRef';

  export default {
    setup() {
      const message = debounceRef("Hello World");

      return {
        message
      }
    }
  }
</script>

<style scoped>
</style>