VueJS/VueJS

[VueJS] watch 와 watchEffect 비교

썸머워즈 2022. 12. 26. 22:31
반응형

vue3 공식문서를 기반으로 watchwatchEffect를 비교해보고자 한다.

 

사실 이 게시글을 작성하기 이전에 [VueJS] computed와 watch 속성 비교라는 게시글을 작성한 적이 있는데, 여기서 watch를 다룰 때 지금 보다 공식문서가 좀 허접(?) 했던 시절에 작성하다 보니 꼼꼼하게 담아내질 못했던 기억이 있다.

 

마침 이번에 vue3에서 새롭게 생긴 watchEffect와 같이 비교할 겸 정리하려고 한다.


watch()

watch 속성은 "특정 데이터"의 변화를 감지하여 자동으로 특정 로직을 수행해주는 속성이다.

watch라는 속성은 vue2에서부터 계속해서 존재했던 속성이기에 익숙하지 좀 더 개선되었으며,

options api, composition api 어느 곳에서나 사용이 가능하나 공식문서에 정의된 타입이 일부분 다르게 선언되어 있다.

 

각각 Options API와 Composition API에서 어떻게 다른지 확인해 보자.

1. watch from Options API

공식문서에서는 watch를 아래와 같이 타입을 정의하였다.

interface ComponentOptions {
  watch?: {
    [key: string]: WatchOptionItem | WatchOptionItem[]
  }
}

type WatchOptionItem = string | WatchCallback | ObjectWatchOptionItem

type WatchCallback<T> = (
  value: T,
  oldValue: T,
  onCleanup: (cleanupFn: () => void) => void
) => void

type ObjectWatchOptionItem = {
  handler: WatchCallback | string
  immediate?: boolean // default: false
  deep?: boolean // default: false
  flush?: 'pre' | 'post' | 'sync' // default: 'pre'
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}

타입스크립트 문법이지만 보는데 크게 어려움은 없을 것이다.

근데 여기서 하나 신기한 것은, WatchoptionItem을 보면 WatchCallback을 하는 것은 당연히 알겠고, ObjectWatchOptionItem을 사용해 좀 더 다양한 옵션을 줄 수 있는 것도 알겠으나 string이 무엇인지 의아하다.

 

좀 더 자세히 봐보면 ObejctWatchOptionItem의 hadler에도 string을 넣을 수 있게 되어있는데,

이는 그냥 문자열을 넣으면 되는 게 아니라 callback method name을 선언하는 것이다.

 

ObjectWatchOptionItem의 속성들을 살펴보자.

  • handler : handler는 위에 정의된 타입을 보면 알겠지만 콜백 함수를 선언하는 부분이다. 보통 watch를 사용할 때 추가적인 옵션들을 사용하지 않고 그냥 특정 데이터를 대상으로만 콜백함수가 작동하도록 선언하기 때문에 바로 콜백함수를 작성하면 되겠지만 다양한 옵션들을 사용하려면 객체구조 형식으로 사용해야 하기 때문에 handler라는 속성을 사용하여 콜백을 선언해 두는 것이다. (Options API에서만 사용되는 옵션이다.)
  • immediate : 속성명 그대로 "즉시" 실행하도록 하는 옵션이다. 이게 옵션으로 있는 이유는 기본적으로 watch는 즉시 실행되는 게 아니라 변경이 감지된 후 이전값과 변경된 값을 같이 불러오기 때문에 "지연된 호출"이라고도 볼 수 있는데, 이 옵션을 사용하게 되면 즉시 실행되며 처음에는 초기값이 없다면 oldValue가 undefined로 호출될 것이다.
  • deep : 배열 혹은 객체일 경우 깊은 참조를 할 수 있게 하는 옵션이다. 당연하겠지만 성능에 유의하여 사용해야 하는 옵션이다. (Deep Watchers)
  • flush : 콜백의 flush 타이밍을 조절한다. (대충 콜백 함수의 실행 시점을 제어한다고 생각하면 편하다.) (Callback Flush Timing)
  • onTrack / onTrigger : watcher 종속성을 디버깅한다. (Watcher Debugging)

일단 이 정도 옵션들이 존재하고, 실제 예시는 다음과 같다.

export default {
  data() {
    return {
      a: 1,
      b: 2,
      c: {
        d: 4
      },
      e: 5,
      f: 6
    }
  },
  watch: {
    // watching top-level property
    a(val, oldVal) {
      console.log(`new: ${val}, old: ${oldVal}`)
    },
    // string method name
    b: 'someMethod',
    // the callback will be called 
    // whenever any of the watched object properties change regardless of their nested depth
    c: {
      handler(val, oldVal) {
        console.log('c changed')
      },
      deep: true
    },
    // watching a single nested property:
    'c.d': function (val, oldVal) {
      // do something
    },
    // the callback will be called immediately after the start of the observation
    e: {
      handler(val, oldVal) {
        console.log('e changed')
      },
      immediate: true
    },
    // you can pass array of callbacks, they will be called one-by-one
    f: [
      'handle1',
      function handle2(val, oldVal) {
        console.log('handle2 triggered')
      },
      {
        handler: function handle3(val, oldVal) {
          console.log('handle3 triggered')
        }
        /* ... */
      }
    ]
  },
  methods: {
    someMethod() {
      console.log('b changed')
    },
    handle1() {
      console.log('handle 1 triggered')
    }
  },
  created() {
    this.a = 3 // => new: 3, old: 1
  }
}

 

 

2. watch from Composition API

공식문서를 살펴보면 Compsotion API와 Options API를 따로 정의해 두었는데, Composition API에서는 watch()를 다음과 같이 타입을 정의하였다.

// watching single source
function watch<T>(
  source: WatchSource<T>,
  callback: WatchCallback<T>,
  options?: WatchOptions
): StopHandle

// watching multiple sources
function watch<T>(
  sources: WatchSource<T>[],
  callback: WatchCallback<T[]>,
  options?: WatchOptions
): StopHandle

type WatchCallback<T> = (
  value: T,
  oldValue: T,
  onCleanup: (cleanupFn: () => void) => void
) => void

type WatchSource<T> =
  | Ref<T> // ref
  | (() => T) // getter
  | T extends object
  ? T
  : never // reactive object

interface WatchOptions extends WatchEffectOptions {
  immediate?: boolean // default: false
  deep?: boolean // default: false
  flush?: 'pre' | 'post' | 'sync' // default: 'pre'
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}

공식문서에 있는 걸 가져왔는데 이상하게 코드블록이 작동을 안 한다. 아마 함수를 선언해두고 블록으로 안 닫아서 그런 거 같다. (좀 고쳐줬으면 가독성 떨어지는데 끙;)

 

일단 코드를 보면 척 보기에도 Options API와 다르지만, 내부에서 사용하는 옵션에 경우에는 비슷한 것도 볼 수 있다.

그래서 사용법은 대략 비슷하고 단지 Composition API를 사용하는 것과 handler 옵션이 없고, StopHandle라는 반환값이 있는 등 좀 더 차이가 나는 부분에 대해서는 watchEffect와 동일하니 거기서 설명을 이어나가도록 하며 예제먼저 살펴보고 넘어가도록 하자.

// Watching a getter
const state = reactive({ count: 0 })
watch(
  () => state.count,
  (count, prevCount) => {
    /* ... */
  }
)

// Watching a ref
const count = ref(0)
watch(count, (count, prevCount) => {
  /* ... */
})

// When watching multiple sources
watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
  /* ... */
})


// When use options
const state = reactive({ count: 0 })
watch(
  () => state,
  (newValue, oldValue) => {
    // newValue === oldValue
  },
  { deep: true }
)

// When directly watching a reactive object, the watcher is automatically in deep mode
const state = reactive({ count: 0 })
watch(state, () => {
  /* triggers on deep mutation to state */
})

watchEffect()

watchEffect()의 경우에는 Composition API에서만 사용이 가능하다.

우선 공식문서에서 정의하는 타입은 다음과 같다.

function watchEffect(
  effect: (onCleanup: OnCleanup) => void,
  options?: WatchEffectOptions
): StopHandle

type OnCleanup = (cleanupFn: () => void) => void

interface WatchEffectOptions {
  flush?: 'pre' | 'post' | 'sync' // default: 'pre'
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}

type StopHandle = () => void

얼핏 보면 watch와 비슷해 보이는데 도대체 뭐가 다른 걸까?

 

우선 가장 큰 차이점은 watchEffect는 대상을 지정하는 부분이 존재하지 않는다.

그 이유는 effect를 지정하는 함수에서 선언된 대상들을 감시하기 때문이다.

쉽게 말하면 computed()처럼 안에 사용된 대상들의 변경을 감지하여 실행된다는 이야기다.

 

그리고 watch와 다르게 watchEffect는 "즉시" 실행된다는 점이다.

 

Composition API에서는 Options API와는 다르게 watch와 watchEffect에 반환값 StopHanlde 타입이 존재하는데 이는 해당 watch와 watchEffect가 더 이상 실행되지 않도록 종료시키는 역할을 한다.

 

이제 실제 사용 예제를 살펴보자.

// basic example
const count = ref(0)

watchEffect(() => console.log(count.value))
// -> logs 0

count.value++
// -> logs 1

// side effect cleanup
watchEffect(async (onCleanup) => {
  const { response, cancel } = doAsyncWork(id.value)
  // `cancel` will be called if `id` changes
  // so that previous pending request will be cancelled
  // if not yet completed
  onCleanup(cancel)
  data.value = await response
})

// stopping the watcher
const stop = watchEffect(() => {})

// when the watcher is no longer needed:
stop()

// use options
watchEffect(() => {}, {
  flush: 'post',
  onTrack(e) {
    debugger
  },
  onTrigger(e) {
    debugger
  }
})

사용법은 대충 watch와 computed를 짬뽕해놓은 것처럼 생겨서 크게 어려워 보이지는 않는다.

 

예제를 살펴보면 side effect cleanup이라는 부분이 있는데, 이는 Options API에서 watch를 사용할 때와 동일하게 의아했던 onCleanup에 대한 것이다.

 

onCleanup은 문서에도 제대로 뭔가 설명이 안되어있고 찾아봐도 솔직히 잘 모르겠다.

그래서 일단 내가 직접 테스트를 해보면서 대략 이럴 것이다라는 내용으로 채울까 한다.

(나는 이렇게 알고 있지만 아닐 수도 있다는 것이다.)

 

우선 구글링을 해본 결과 StopHandle을 통해 정지했을 때 실행되는 것으로 판단하여 실험을 해보았다.

그래서 watch와 watchEffect를 선언해두고 바로 정지를 시켜보았다.

결과는 watch에서는 onCleanup이 실행되지 않았으며, watchEffect에서는 실행되는 것을 확인했다.

 

그다음 테스트는 watch에서 onCleanup이 동작하도록 하는 테스트다.

watchEffect에서는 실행이 됐는데 왜 watch에서는 실행이 안 됐을까?

일단정지를 하지 않고 특정 데이터를 감시하게 만들고 데이터를 변경해 보았다.

그랬더니 재밌는 게 watch가 "두 번째" 실행 됐을 때 onCleanup이 실행되었다는 점이다.

 

여기서 한 가지 가정을 해보면, onCleanup이 실행되기 위해서는 watch나 watchEffect가 "최소 1회"는 실행되어야 한다는 점이다.

사실 이것은 별거 아니기 한데 콘솔에 onCleanup 부분에 해당하는 매개변수를 찍어보면 콜백함수를 등록하는 로직이 나오는걸 보아하니 최초 1회는 그 콜백 함수를 등록하는 것으로 판단되며 실행될 때마다 콜백함수가 계속해서 재등록되는 것으로 판단된다.

 

그럼 왜 watchEffect는 멈추자마자 실행 됐을까?

그건 watchEffect는 "즉시" 실행되는 녀석이기 때문에 이미 1회 실행된 이후로 바로 멈춰버렸기 때문에 onCleanup이 실행됐던 것이다.

 

근데 테스트를 하던 도중 또 이상한 걸 발견했는데,

이게 멈출 때만 실행되는 게 아니라 watch나 watchEffect가 실행될 때마다 onCleanup이 실행된다는 점이다.

이게 어떤 식이냐면 1회 때 등록된 콜백함수가 2회 때 실행되며, 2회때 등록된 콜백함수가 3회 때 실행되는 방식으로 보인다.

그리고 이전에 등록되었던 콜백함수라 그런지 다음 회차 때 가장 먼저 실행되는 것을 확인할 수 있었다.

 

일단 이렇게 동작원리에 대해 어느 정도 파악은 했는데, 사실 언제 이걸 활용할 수 있을지는 잘 모르겠다.


watch와 watchEffect의 특징과 차이점에 대해 알아봤는데,

그냥 대략적으로 알고 넘어가야 되는 부분은 다음과 같다.

  • watch는 이전값과 변경된 값을 알 수 있지만 watchEffect는 알 수 없다.
  • watch는 감시 대상을 지정해야만 하지만 watchEffect는 함수 안에서 참조되는 변수만 감시한다.

몇 가지 차이점만 알고 있다면 필요에 따라 구분해서 사용하면 된다고 생각한다.

좀 더 자세한 내용은 공식문서를 살펴보는 것을 추천한다.

반응형