Vue 3.5 更新,onWatcherCleanup 與 onCleanup

vue-3-5-update-on-watcher-cleanup-with-the-forgotten-on-cleanup

Vue 3.5 更新了!除了其他改進與實用功能外,有一個很有趣的新 API,叫做 onWatcherCleanup

不過原本的 watch 已經有 onCleanup 了,所以到底差在哪呢?

文件 Side Effect Cleanup 一節中提到的用法為:

import { watch, onWatcherCleanup } from 'vue'

watch(id, (newId) => {
  const controller = new AbortController()

  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // callback logic
  })

  onWatcherCleanup(() => {
    // abort stale request
    controller.abort()
  })
})

不過 watch 本身就有 onCleanup,所以應該可以這樣寫。

watch(id, (newId, oldId, onCleanup) => {
  const controller = new AbortController()

  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // callback logic
  })

  onCleanup(() => {
    // abort stale request
    controller.abort()
  })
})

文件 onWatcherCleanup 章節也沒有其他說明。

說不定實際效果不一樣?讓我們來實測看看吧。(๑•̀ㅂ•́)و✧

這裡我們使用一個免費的 API 來測試,順便取得貓貓冷知識 XD。

如果 API 壞了,大家可以換成其他 API 測試

實際測試

<script setup>
import { ref, watch, onWatcherCleanup } from 'vue'

const id = ref('cod')
const data = ref([])

watch(id, (newId, oldId, onCleanup) => {
  const controller = new AbortController()

  fetch(
    `https://cat-fact.herokuapp.com/facts?id=${newId}`,
    { signal: controller.signal }
  )
    .then((res) => res.json())
    .then((value) => {
      data.value = value;
    })
    .catch((error) => {
      console.error(error);
    })

  onWatcherCleanup(() => {
    console.log('[ onWatcherCleanup ] : ', controller);
    controller.abort()
  }) // */

  /* onCleanup(() => {
    console.log('[ onCleanup ] : ', controller);
    controller.abort()
  }) // */
})

function getData() {
  id.value = crypto.randomUUID();
}
</script>

<template>
  <button @click="getData()">取得貓貓小知識</button>

  <p v-for="datum, i in data">
    {{ i + 1 }}. {{ datum.text }}
  </p>
</template>

範例程式碼

就一個簡單的按鈕,點擊後會取得貓貓小知識,並顯示在畫面上。

測試程式

每次 watch 觸發時,會呼叫上一次的 onCleanup 或 onWatcherCleanup,這樣就可以清理上一次的副作用。

所以讓我們快速點擊按鈕兩下,觀察一下 DevTools 之 Network 的結果。

Network

可以注意到上次的 fetch 順利取消了!

現在讓我們把 onWatcherCleanup 註解掉,改用 onCleanup,再來觀察一下。

見證奇蹟的時刻到了!

結果一模一樣!╮(╯▽╰)╭

...嗯,以上例子的確看不出差別,但是不代表 onWatcherCleanup 沒有用途,讓我們再來看看其他情境吧。

onWatcherCleanup 使用情境

感謝 Line Vue.js Taiwan 社群裡的 dog 大大提供的資訊,忽然茅舍頓開。ᕕ( ゚ ∀。)ᕗ

目前來看 onWatcherCleanup 比較類似 getCurrentScope、onScopeDispose 這類底層 API,平常不會特別使用,一般業務邏輯用 onCleanup 就好。(想統一用 onWatcherCleanup 也是可以 XD)

不過如果你希望「在 watch 中重新執行時,會自動清理上一次動作」的實用 API,這時候 onWatcherCleanup 就派上用場了。

概念上有點類似 VueUse 的 useEventListener,此 API 可以在元件中註冊事件,並在元件被銷毀時自動移除。

import { useEventListener } from '@vueuse/core'

// 像這樣註冊事件,不用手動 removeEventListener
useEventListener(document, 'visibilitychange', (evt) => {
  console.log(evt)
})

只是 useEventListener 的目標是「整個元件的 effect scope」,而 onWatcherCleanup 的目標是「單一 watcher scope」。

讓我們延伸剛剛的例子,設計一個在 watch 使用時會自動取消前一次請求,並在元件解除後也會自動取消請求的 API。

新增一個 useCatFact。

import { onWatcherCleanup, shallowRef, onBeforeUnmount } from 'vue'

export function useCatFact() {
  const abortController = shallowRef();

  /** 元件解除前,自動取消請求 */
  onBeforeUnmount(() => {
    abortController.value?.abort()
  });

  /** 取得貓貓小知識 */
  function getFact(id) {
    return new Promise((resolve, reject) => {
      const controller = new AbortController()

      fetch(
        `https://cat-fact.herokuapp.com/facts?id=${id}`,
        { signal: controller.signal }
      )
        .then((res) => res.json())
        .then((value) => resolve(value))
        .catch((error) => {
          // 忽略終止錯誤
          if (error.name === 'AbortError') return;
          reject(error);
        })

      /** 
       * 在 watcher 範圍內會自動取消前一次請求
       * 
       * 第二個參數 true 表示忽略不在 watcher 範圍內警告
       */
      onWatcherCleanup(() => {
        controller.abort()
      }, true)

      abortController.value = controller;
    })
  }

  return {
    getFact,
  }
}

接著在元件中使用。

<script setup>
import { ref, watch } from 'vue'
import { useCatFact } from './useCatFact.js';

const id = ref('cod')
const data = ref([])

const { getFact } = useCatFact();

watch(id, async (newId) => {
  data.value = await getFact(newId);
})

async function getData() {
  id.value = crypto.randomUUID();
}
</script>

<template>
  <button @click="getData()">取得貓貓小知識</button>

  <p class="tip" v-for="datum, i in data">
    {{ i + 1 }}. {{ datum.text }}
  </p>
</template>

現在連續點擊按鈕,可以看到前次請求都會取消,而且元件解除時也會自動取消請求。

範例程式碼

這個時候可能有人會有疑問:「如果把 onCleanup 傳到 useCatFact 裡,不也可以嗎?」

的確可以,就看設計需求與風格了,像是剛剛提到的 useEventListener 就是自動處理的方式。( •̀ ω •́ )✧

可以看到程式碼變得相當乾淨,不需要手動 onCleanup,也不用擔心忘記取消請求,非常方便。∠( ᐛ 」∠)_

除了發送 HTTP 請求,這在設計動畫也十分實用,可以有效防止同一物件上的動畫重複執行。

舉一個動畫的例子,這裡使用 anime.js

設計一個名為 useBoxAnimation 的動畫工具,可以對指定元素執行各類預先定義好的動畫。

import anime from 'animejs'

export function useBoxAnimation() {
  /** 動畫目標元素 */
  const boxRef = ref<HTMLElement>();

  /** 元件解除前,刪除動畫 */
  onBeforeUnmount(() => {
    anime.remove(boxRef.value)
  });

  function translate(x, y) {
    anime({
      targets: boxRef.value,
      translateX: x,
      translateY: y,
      easing: 'spring(1, 80, 10, 0)'
    });

    onWatcherCleanup(() => {
      anime.remove(boxRef.value)
    }, true)
  }

  function rotate(deg) {
    anime({
      targets: boxRef.value,
      rotate: deg
    });

    onWatcherCleanup(() => {
      anime.remove(boxRef.value)
    }, true)
  }

  return {
    boxRef,
    translate,
    rotate,
  }
}

以上只是我目前的心得與理解,如果有更多的使用情境或是更深入的解釋,歡迎大家一起討論!(´▽`ʃ♡ƪ)

總結 🐟

  • 認識 onCleanup 與 onWatcherCleanup
  • onWatcherCleanup 適合用在封裝後的功能,一般業務邏輯用 onCleanup 就好
  • 官方文件的 onWatcherCleanup 例子舉的不太好,看不出與 onCleanup 的應用差異,害我一開始看得一頭霧水 XD

最後再次感謝 dog 大大提供的資訊,也歡迎大家加入 Vue.js 社群

一起討論 Vue 相關議題!(/≧▽≦)/