CompositionAPI(二)computed、watchEffect、watch、provide、inject、CompositionAPI体验

116 阅读14分钟

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

computed

在前面我们讲解过计算属性computed,当我们的某些属性是依赖其他状态时,我们可以使用计算属性来处理。

先来复习Vue2的computed选项。

computed: {
  // 计算属性(反转字符串) Nihao -> oahiN 
  reverseString: function(){
      // split将字符串分割成数组,reverse反转数组,join重新拼接成字符串
      return this.msg.split('').reverse().join('');
  }
}

// 计算属性默认只有 getter,不过在需要时你也可以提供一个 setter:

// ...
computed: {
  fullName: {
    // getter
    get: function () {
      return this.firstName + ' ' + this.lastName
    },
    // setter
    set: function (newValue) {
      var names = newValue.split(' ')
      this.firstName = names[0]
      this.lastName = names[names.length - 1]
    }
  }
}
// ...

现在再运行 vm.fullName = 'John Doe' 时,setter 会被调用,vm.firstName 和 vm.lastName 也会相应地被更新。

在前面的Options API中,我们是使用computed选项来完成的。在Composition API中,我们可以在 setup 函数中使用 computed() 方法来编写一个计算属性。

如何使用computed呢?
方式一:接收一个getter函数,并为 getter 函数返回的值,返回一个不变的 ref 对象;
方式二:接收一个具有 get 和 set 的对象,返回一个可变的(可读写)ref 对象;

<template>
  <div>
    <h2>{{fullName}}</h2>
    <button @click="changeName">修改firstName</button>
  </div>
</template>

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

  export default {
    setup() {
      const firstName = ref("Kobe");
      const lastName = ref("Bryant");

      // 1.用法一: 传入一个getter函数
      // computed的返回值是一个ref对象
      const fullName = computed(() => firstName.value + " " + lastName.value);

      // 2.用法二: 传入一个对象, 对象包含getter/setter
      const fullName = computed({
        get: () => firstName.value + " " + lastName.value,
        set(newValue) {
          const names = newValue.split(" ");
          firstName.value = names[0];
          lastName.value = names[1];
        }
      });

      const changeName = () => {
        // firstName.value = "James"
        fullName.value = "coder why";
      }

      return {
        fullName,
        changeName
      }
    }
  }
</script>

<style scoped>
</style>

侦听数据的变化

在前面的Options API中,我们可以通过watch选项来侦听data或者props的数据变化,当数据变化时执行某一些操作。在Composition API中,我们可以使用watchEffect和watch来完成响应式数据的侦听。

  • watchEffect用于自动收集响应式数据的依赖;
  • watch需要手动指定侦听的数据源;

watchEffect

当侦听到某些响应式数据变化时,我们希望执行某些操作,这个时候可以使用 watchEffect。

在以前的watch选项中,watch一开始并不会执行一次,只有当监听的数据改变的时候才会执行,并且只会监听某个或者某几个值的改变,它是一个option或者说是一个对象。

watchEffect不一样,watchEffect是个函数,它的参数接收一个函数,watchEffect传入的函数会被立即执行一次,并且在执行的过程中会收集依赖,比如如下我们在回调函数中使用了name和age,所以name和age都会被搜集到依赖中,只有收集的依赖发生变化时,watchEffect传入的函数才会再次执行。

总结:watch是个选项,watchEffect是个函数,会自动搜集需要监听的依赖。

watchEffect的停止侦听

如果在发生某些情况下,我们希望停止侦听,这个时候我们可以获取watchEffect的返回值函数,调用该函数即可。

比如在上面的案例中,我们age达到20的时候就停止侦听:

watchEffect清除副作用

什么是清除副作用呢? 比如在开发中我们需要在侦听函数中执行网络请求,但是在网络请求还没有达到的时候,我们停止了侦听器,或者侦听器侦听函数被再次执行了。那么上一次的网络请求应该被取消掉,这个时候我们就可以清除上一次的副作用。

在我们给watchEffect传入的函数被回调时,其实可以获取到一个参数:onInvalidate,当副作用即将重新执行或者侦听器被停止时会执行该函数传入的回调函数,我们可以在传入的回调函数中,执行一些清除工作。

setup中使用ref元素

在讲解 watchEffect 执行时机之前,我们先补充一个知识:在setup中如何使用ref元素或者组件? 其实非常简单,我们只需要定义一个ref对象,绑定到元素或者组件的ref属性上即可。

watchEffect的执行时机

默认情况下,副作用函数会先执行,然后组件更新,如果我们希望在副作用函数中获取到元素,代码如下:

我们会发现打印结果打印了两次:这是因为setup函数在执行时就会立即执行传入的副作用函数,这个时候DOM并没有挂载,所以打印为null。而当DOM挂载时,会给title的ref对象赋值新的值,副作用函数会再次执行,打印出来对应的元素。

调整watchEffect的执行时机

如果我们希望在第一次的时候就打印出来对应的元素呢? 这个时候我们需要改变副作用函数的执行时机,它的默认值是pre,它会在元素挂载或者更新之前执行,所以我们会先打印出来一个空的,当依赖的title发生改变时,就会再次执行一次,打印出元素。

我们可以设置副作用函数的执行时机,这时候我们就需要给watchEffect传入第二个参数:flush: "post"。

flush v. 冲洗、冲走

<template>
  <div>
    <h2 ref="title">哈哈哈</h2>
  </div>
</template>

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

  export default {
    setup() {
      const title = ref(null);

      watchEffect(() => {
        console.log(title.value);
      }, {
        flush: "post"
      })

      return {
        title
      }
    }
  }
</script>

<style scoped>
</style>
  • flush 选项默认是pre,它会在元素挂载或者更新之前执行,所以我们会先打印出来一个空的。
  • 当传入post就会在DOM挂载完之后再执行。
  • 另外还接受 sync,这将强制效果始终同步触发,然而,这是低效的,应该很少需要,不推荐使用。

总结

  • 传入函数的第一个参数是个函数,它有一个参数onInvalidate()用来清除副作用,比如网络请求
  • 传入函数的第二个参数是个对象,传入flush: "post"可以让组件更新后再执行回调函数
  • watchEffect会返回一个函数,用来停止侦听

Watch的使用

  • watch的API完全等同于组件watch选项的Property,只不过watch选项是个选项,watch的API是个函数,他们的写法不同(自己想想有什么不同)

    • watch需要侦听特定的数据源,并在回调函数中执行副作用;
    • 默认情况下它是惰性的,只有当被侦听的源发生变化时才会执行回调;
  • 与watchEffect的比较,watch允许我们:

    • 懒执行副作用(第一次不会直接执行);
    • 更具体的说明当哪些状态发生变化时,触发侦听器的执行;
    • 访问侦听状态变化前后的值;

第一个参数是侦听器的。这个来源可以是以下几种:

  • 一个函数,返回一个值
  • 一个 ref
  • 一个响应式对象
  • ...或是由以上类型的值组成的数组

第二个参数是在发生变化时要调用的回调函数。这个回调函数接受三个参数:新值、旧值,以及一个用于注册副作用清理的回调函数。该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。

侦听单个数据源

<template>
  <div>
    <h2 ref="title">{{info.name}}</h2>
    <button @click="changeData">修改数据</button>
  </div>
</template>

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

  export default {
    setup() {
      const info = reactive({name: "why", age: 18});

      // 1.侦听watch时,传入一个getter函数
      watch(() => info.name, (newValue, oldValue) => {
        console.log("newValue:", newValue, "oldValue:", oldValue);
        // newValue: kobe  oldValue: why
      })

      // 2.传入一个reactive对象
      // reactive对象获取到的newValue和oldValue本身都是reactive对象
      watch(info, (newValue, oldValue) => {
        console.log("newValue:", newValue, "oldValue:", oldValue);
        // Proxy { xxx }
      })
      // 如果希望newValue和oldValue是一个普通的对象
      watch(() => {
        return {...info}
      }, (newValue, oldValue) => {
        console.log("newValue:", newValue, "oldValue:", oldValue);
        //  newValue: {name: "kobe", age: 18}  oldValue: {name: "why", age: 18}
      })

      // 3.传入一个ref对象
      // ref对象获取newValue和oldValue是value值的本身
      const name = ref("why");
      watch(name, (newValue, oldValue) => {
        console.log("newValue:", newValue, "oldValue:", oldValue);
        // newValue: kobe  oldValue: why
      })

      const changeData = () => {
        info.name = "kobe";
      }

      return {
        changeData,
        info
      }
    }
  }
</script>

<style scoped>
</style>

副作用清理:

watch(id, async (newId, oldId, onCleanup) => {
  const { response, cancel } = doAsyncWork(newId)
  // 当 `id` 变化时,`cancel` 将被调用,
  // 取消之前的未完成的请求
  onCleanup(cancel)
  data.value = await response
})

侦听多个数据源

当侦听多个数据源时,回调函数接受两个数组,分别对应来源数组中的新值和旧值。

<template>
  <div>
    <h2 ref="title">{{info.name}}</h2>
    <button @click="changeData">修改数据</button>
  </div>
</template>

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

  export default {
    setup() {
      // 定义可响应式的对象
      const info = reactive({name: "why", age: 18});
      const name = ref("why");

      // 侦听多个数据源
      watch([info, name], (newValue, oldValue) => {
        console.log("newValue:", newValue, "oldValue:", oldValue);
        // 这时候返回的newValue和oldValue都是数组
        // "newValue:" [Proxy, kobe], "oldValue:" [Proxy, why],
      })

      // 如果不想返回的数组中是Proxy对象,可以在源中对对象进行解构
      // 并且返回值的数组也可以结构
      watch([() => ({...info}), name], ([newInfo, newName], [oldInfo, oldName]) => {
        console.log(newInfo, newName, oldInfo, oldName);
        // Object kobe Object why
      })

      const changeData = () => {
        info.name = "kobe";
      }

      return {
        changeData,
        info
      }
    }
  }
</script>

<style scoped>
</style>

第三个参数

当使用 getter 函数作为源时,回调只在此函数的返回值变化时才会触发。如果你想让回调在深层级变更时也能触发,你需要使用 { deep: true } 强制侦听器进入深层级模式。在深层级模式时,如果回调函数由于深层级的变更而被触发,那么新值和旧值将是同一个对象。

当直接侦听一个响应式对象时,侦听器会自动启用深层模式:

const state = reactive({ count: 0 })
watch(state, () => {
  /* 深层级变更状态所触发的回调 */
})

deep和immediate的使用示例:

<template>
  <div>
    <h2 ref="title">{{info.name}}</h2>
    <button @click="changeData">修改数据</button>
  </div>
</template>

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

  export default {
    setup() {
      // 定义可响应式的对象
      const info = reactive({
        name: "why", 
        age: 18,
        friend: {
          name: "kobe"
        }
      });
      // 默认是深度侦听 friend.name改变会侦听得到
      watch(info, (newValue, oldValue) => {
        console.log("newValue:", newValue, "oldValue:", oldValue);
        // Proxy { xxx }
      })

      // 如果想侦听解构出来的对象,是没有深度侦听的
      // 如果还想深度侦听,加上deep: true,
      // 加上immediate: true会立即执行一次
      watch(() => ({...info}), (newInfo, oldInfo) => {
        console.log(newInfo, oldInfo);
      }, {
        deep: true,
        immediate: true
      })

      const changeData = () => {
        info.friend.name = "james";
      }

      return {
        changeData,
        info
      }
    }
  }
</script>

<style scoped>
</style>

第三个可选的参数是一个对象,支持以下这些选项:

  • immediate:在侦听器创建时立即触发回调。第一次调用时旧值是 undefined
  • deep:如果源是对象,强制深度遍历,以便在深层级变更时触发回调。参考深层侦听器
  • flush:调整回调函数的刷新时机。参考回调的刷新时机及 watchEffect()
  • onTrack / onTrigger:调试侦听器的依赖。参考调试侦听器

watch() 和 watchEffect() 享有相同的刷新时机和调试选项:

watch(source, callback, {
  flush: 'post',
  onTrack(e) {
    debugger
  },
  onTrigger(e) {
    debugger
  }
})

停止侦听器:

const stop = watch(source, callback)

// 当已不再需要该侦听器时:
stop()

生命周期钩子

我们前面说过 setup 可以用来替代 data、methods、computed、watch 等等这些选项,也可以替代生命周期钩子。 那么setup中如何使用生命周期函数呢?可以使用直接导入的 onXXX 函数注册生命周期钩子。

注意: 原来的Vue2是beforeDestoryed和destoryed,在Vue3中改成了onBeforeUnmount和onUnmounted,unmount就是卸载的意思,这样也更合理。

<template>
  <div>
    <button @click="increment">{{counter}}</button>
  </div>
</template>

<script>
  import { onMounted, onUpdated, onUnmounted, ref } from 'vue';

  export default {
    setup() {
      const counter = ref(0);
      const increment = () => counter.value++

      onMounted(() => {
        console.log("App Mounted1");
      })
      onMounted(() => {
        // 生命周期可以使用多次
        console.log("App Mounted2");
      })
      onUpdated(() => {
        console.log("App onUpdated");
      })
      onUnmounted(() => {
        console.log("App onUnmounted");
      })

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

<style scoped>
</style>

Provide函数

事实上我们之前还学习过 Provide 和 Inject,Composition API也可以替代之前的 Provide 和 Inject 的选项。

我们可以通过 provide 方法来定义每个 Property,provide可以传入两个参数:

  • name:提供的属性名称;
  • value:提供的属性值;

为了增加 provide 值和 inject 值之间的响应性,我们可以在 provide 值时使用 ref 或 reactive。但是传给子孙组件的时候还要用readonly包裹一下,因为要防止子孙组件修改。

父组件的代码:

<template>
  <div>
    <home/>
    <h2>App Counter: {{counter}}</h2>
    <button @click="increment">App中的+1</button>
  </div>
</template>

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

  import Home from './Home.vue';

  export default {
    components: {
      Home
    },
    setup() {
      //包裹成响应式的
      const name = ref("coderwhy");
      let counter = ref(100);

      // 为了实现单向数据流,设置只读,防止子组件修改
      provide("name", readonly(name));
      provide("counter", readonly(counter));

      const increment = () => counter.value++;

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

<style scoped>
</style>

注意:Vue官方推荐能用ref的时候尽量用ref,而不要用reactive,因为ref更有利于代码的抽取。那什么时候用reactive比较好呢?比如有些信息关系特别紧密,如用户信息,肯定放一块比较好,如下:

cosnt user = reactive({
  username:"",
  password:""
})

Inject函数

在后代组件中可以通过 inject 方法来注入需要的属性和对应的值,inject方法可以传入两个参数:

  • 要 inject 的 property 的 name;
  • 默认值;

子孙组件的代码:

<template>
  <div>
    <h2>{{name}}</h2>
    <h2>{{counter}}</h2>

    <button @click="homeIncrement">home+1</button>
  </div>
</template>

<script>
  import { inject } from 'vue';

  export default {
    setup() {
      const name = inject("name");
      const counter = inject("counter");

      //这时候子孙组件就无法修改父组件的数据了,这里会有警告
      const homeIncrement = () => counter.value++

      return {
        name,
        counter,
        homeIncrement
      }
    }
  }
</script>

<style scoped>
</style>

修改响应式Property

如果子孙组件需要修改可响应的数据,那么最好是在数据提供的位置来修改,我们可以将修改方法进行共享,在后代组件中进行调用。这也是为了单向数据流的原则,有点像代理方法。

Composition API 初体验

假如我们有计数器、展示鼠标位置、展示滚动位置等功能,如果用以前的Optional API,就是先定义data,再写method,还需要computed,这样的代码有两个问题:

  1. 同一逻辑的代码太分散,以后修改的时候要跳来跳去
  2. 可复用性特别差

这两个问题我们都可以通过Composition API来解决。

如果我们把上面代码都写到一个setup里面,其实也没有抽出来,所以一般我们把一个逻辑抽取到一个函数里面,由于和React里面的hook函数很像,所以一般我们称之为hook函数,一个hook函数是一个js文件,并且以use开头,这也是习惯。

useCounter.js

我们先来对之前的 counter 逻辑进行抽取:

import { ref, computed } from 'vue';

export default function() {
  const counter = ref(0);
  const doubleCounter = computed(() => counter.value * 2);

  const increment = () => counter.value++;
  const decrement = () => counter.value--;

  return {
    counter, 
    doubleCounter, 
    increment, 
    decrement
  }
}

useTitle.js

我们编写一个修改 title 的Hook:

import { ref, watch } from 'vue';

//带有默认的值
export default function(title = "默认的title") {
  const titleRef = ref(title);

  watch(titleRef, (newValue) => {
    document.title = newValue
  }, {
    //要求立即执行一次
    immediate: true
  })

  return titleRef
}

useScrollPosition.js

我们来完成一个监听界面滚动位置的Hook:

import { ref } from 'vue';

export default function() {
  const scrollX = ref(0);
  const scrollY = ref(0);

  document.addEventListener("scroll", () => {
    scrollX.value = window.scrollX;
    scrollY.value = window.scrollY;
  });

  return {
    scrollX,
    scrollY
  }
}

useMousePosition.js

我们来完成一个监听鼠标位置的Hook:

import { ref } from 'vue';

export default function() {
  const mouseX = ref(0);
  const mouseY = ref(0);

  window.addEventListener("mousemove", (event) => {
    mouseX.value = event.pageX;
    mouseY.value = event.pageY;
  });

  return {
    mouseX,
    mouseY
  }
}

useLocalStorage.js

我们来完成一个使用 localStorage 存储和获取数据的Hook:

import { ref, watch } from 'vue';

export default function(key, value) {
  // 将value包装成ref对象
  const data = ref(value);

  if (value) {
    // 设置值
    window.localStorage.setItem(key, JSON.stringify(value));
  } else {
    //取值
    data.value = JSON.parse(window.localStorage.getItem(key));
  }

  //如果别人拿到我们返回的data,然后通过data.value = "kobe";修改数据,所以我们要监听我们返回的data的改变,然后重新保存值到本地
  watch(data, (newValue) => {
    window.localStorage.setItem(key, JSON.stringify(newValue));
  })

  return data;
}

如果传入一个参数,就是取值:

const data = useLocalStorage("name");

如果传入两个参数,就是设置值:

const data = useLocalStorage("name", "coderwhy");

如果别人拿到我们返回的data,然后通过data.value = "kobe";修改数据,所以我们要监听我们返回的data的改变,然后重新保存值到本地:

watch(data, (newValue) => {
  window.localStorage.setItem(key, JSON.stringify(newValue));
})

index.js

如果都在App.vue里面导入,感觉不是很优雅,我们可以把导入的操作抽出来放到index.js里面。

import useCounter from './useCounter';
import useTitle from './useTitle';
import useScrollPosition from './useScrollPosition';
import useMousePosition from './useMousePosition';
import useLocalStorage from './useLocalStorage';

//这个文件的作用就是把一些导入的操作抽进来,使代码更优雅
export {
  useCounter,
  useTitle,
  useScrollPosition,
  useMousePosition,
  useLocalStorage
}

App.vue

使用上面那些hook函数如下:

<template>
  <div>
    <h2>当前计数: {{counter}}</h2>
    <h2>计数*2: {{doubleCounter}}</h2>
    <button @click="increment">+1</button>
    <button @click="decrement">-1</button>

    <h2>{{data}}</h2>
    <button @click="changeData">修改data</button>

    <p class="content"></p>

    <div class="scroll">
      <div class="scroll-x">scrollX: {{scrollX}}</div>
      <div class="scroll-y">scrollY: {{scrollY}}</div>
    </div>
    <div class="mouse">
      <div class="mouse-x">mouseX: {{mouseX}}</div>
      <div class="mouse-y">mouseY: {{mouseY}}</div>
    </div>
  </div>
</template>

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

  // 导入函数, 默认会从index.js文件里面加载
  import {
    useCounter,
    useLocalStorage,
    useMousePosition,
    useScrollPosition,
    useTitle
  } from './hooks';

  export default {
    setup() {
      // counter
      const { counter, doubleCounter, increment, decrement } = useCounter();

      // 使用title
      // 先改成coderwhy,3s后改成kobe
      const titleRef = useTitle("coderwhy");
      setTimeout(() => {
        titleRef.value = "kobe"
      }, 3000);

      // 滚动位置
      const { scrollX, scrollY } = useScrollPosition();

      // 鼠标位置
      const { mouseX, mouseY } = useMousePosition();

      // localStorage
      const data = useLocalStorage("info");
      const changeData = () => data.value = "哈哈哈哈"

      return {
        counter,
        doubleCounter,
        increment,
        decrement,

        scrollX,
        scrollY,

        mouseX,
        mouseY,

        data,
        changeData
      }
    }
  }
</script>

<style scoped>
  .content {
    width: 3000px;
    height: 5000px;
  }

  .scroll {
    position: fixed;
    right: 30px;
    bottom: 30px;
  }
  .mouse {
    position: fixed;
    right: 30px;
    bottom: 80px;
  }
</style>

文件目录如下: