[Vue 빠른시작] watchers(감시자) 예제 추가(Composition API ver)

[Vue 빠른시작] watchers(감시자) 예제 추가(Composition API ver)

감시자 항목은 생각보다 정리가 길어져서 Composition API ver, Composition API ver으로 나누어서 작성함

이전글 : [Vue 빠른시작] watchers(감시자) 예제 추가(Options API ver)



1. Basic Example

computed(계산된 속성)은 계산되어 파생된 값을 선언적으로 사용할 수 있게 한다. 그러나 상태 변경에 대한 반응으로 "사이드 이펙트"(ex. DOM을 변경하거나 비동기 작업의 결과를 기반으로 다른 상태를 변경하는 것)를 수행해야 하는 경우가 있다.

Composition API를 사용하는 경우, watch 함수를 사용하여 반응형 속성이 변경될 때마다 함수를 실행할 수 있다.

watch 함수는 watch(source, callback, options?) 형식을 갖는다.

  • watch 함수의 매개변수
    • source (감시할 데이터 또는 계산된 속성):
      • 타입: Ref | () => any | string | Array<Ref | () => any | string>
      • 데이터를 감시할 대상
      • 단일 소스일 수도 있고, 여러 소스를 배열로 전달할 수도 있다.
    • callback (감시 대상이 변경될 때 실행할 콜백 함수):
      • 타입: (newValue: any, oldValue: any) => void
      • 감시 대상이 변경될 때 실행되는 콜백 함수(newValue: 변경 후의 값, oldValue: 변경 전의 값)
    • options (옵션 객체, 선택 사항):
      • 타입: Object
        • deep (boolean): 객체 또는 배열의 경우 내부 속성까지 깊게 감시할지 여부를 나타낸다.
        • immediate (boolean): 감시자가 등록될 때 즉시 콜백 함수를 실행할지 여부를 나타낸다.
// Composition API ver
<script setup>
import { ref, watch } from 'vue'

const question = ref('')
const answer = ref('질문에는 일반적으로 물음표가 포함됩니다.')

// watch는 ref에서 직접 작동한다.
watch(question, async (newQuestion, oldQuestion) => {
  if(newQuestion.indexOf('?') > -1) {
    answer.value = '생각 중...'
    try {
      const res = await fetch('https://yesno.wtf/api')
      answer.value = (await res.json()).answer === 'yes' ? '네' : '아니오'
    } catch(error) {
      answer.value = '에러! API에 연결할 수 없습니다. ' + error
    }
  }
})
</script>

<template>
  <p>
    예/아니오 질문 : <input v-model="question"/>
  </p>
  <p>{{ answer }}</p>
</template>

1-1. Watch Source Types

watch의 첫 번째 인자는 다양한 유형의 반응형 소스 일 수 있다. 참조(계산된 참조 포함), 반응형 객체, 게터 함수 또는 여러 소스의 배열이 될 수 있다.

// Composition API ver
<script setup>
import { ref, watch } from 'vue'

const x = ref(0)
const y = ref(0)

// 단일 ref
watch(x, (newX) => {
  console.log(`값: ${newX}`)
})

// getter
watch(
  () => Number(x.value) + Number(y.value),
  (sum) => {
    console.log(`x + y: ${sum}`)

  }
)

// 여러 소스의 배열
watch([x, () => y.value], ([newX, newY]) => {
  console.log(`x는 ${newX}이고, y는 ${newY} 입니다.`)
})
</script>

<template>
  <input v-model="x"/>
  <input v-model="y"/>
</template>

실행화면



🔺 초기화면



🔺 x값을 입력했을 때



🔺 y값을 입력했을 때


다음과 같이 반응형 객체의 속성을 감시할 수는 없다.

// Composition API ver
<script setup>
import { reactive, ref, watch } from 'vue'

const obj = reactive({ count: 0 })

// watch()에 숫자를 전달하기 때문에 작동하지 않는다.
watch(obj.count, (count) => {
  console.log(`count 값: ${count}`);

})
</script>

<template>
  <input v-model="obj.count"/>
</template>

실행화면



🔺 오류화면

- [Vue warn]: Invalid watch source:  0 A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these types.

🔺 오류 메세지


대신 getter를 사용해라

// Composition API ver
<script setup>
import { reactive, ref, watch } from 'vue'

const obj = reactive({ count: 0 })

watch(
  () => obj.count,
  (count) => {
    console.log(`count 값: ${count}`)
  }
)
</script>

<template>
  <input v-model="obj.count"/>
</template>



2. Deep Watchers

반응형 객체에서 watch()를 직접 호출하면 암시적으로 심층 감시자(객체의 내부 속성이 변경될 때를 감시)가 생성되며, 콜백은 중첩된 모든 변경에서 트리거된다.

// Composition API ver
<script setup>
import { reactive, ref, watch } from 'vue'

const obj = reactive({ count: 0 })

// newValue, oldValue는 모두 같은 객체를 가리킨다
watch(obj, (newValue, oldValue) => {
  console.log(`${newValue.count}, ${oldValue.count}`);
})
</script>

<template>
  <button @click="obj.count++">숫자 증가</button>
</template>

실행화면



🔺 newValue, oldValue는 모두 같은 객체를 가리킨다


이러한 심층 감시자(Deep Watchers)를 사용하면 객체의 어떤 부분이 변경되더라도 콜백 함수가 호출되어 특정 동작을 수행할 수 있다.

반응형 객체를 반환하는 getter와 구별해야 한다. 후자의 경우 콜백은 getter가 다른 객체를 반환하는 경우에만 실행된다.

// Composition API ver
<script setup>
import { reactive, watch } from 'vue'

const state = reactive({ 
  someObject: { count: 0 }
 })

watch(
  () => state.someObject,
  () => {
    //state.someObject가 교체될 때만 실행한다.
    console.log(`감시자 콜백, ${state.someObject.count}`);

  }
)

function increment() {
  state.someObject.count++
  console.log(`increment, ${state.someObject.count}`);
}

function replace() {
  state.someObject = {count: 99}
  console.log(`replace, ${state.someObject.count}`);
}
</script>

<template>
  <button @click="increment">아무 일도 일어나지 않는다.</button><br><br>
  <button @click="replace">교체</button>
</template>

실행화면



🔺 increment()내에서 state.someObject.count++으로 변경을 시켰음에도 watch의 콜백함수는 호출되지 않는다.



🔺 state.someObject가 교체될 때만 실행한다.


그러나 deep 옵션을 명시적으로 사용하여 두 번째 경우를 깊은 감시자로 강제할 수 있다.

// Composition API ver
<script setup>
import { reactive, watch } from 'vue'

const state = reactive({ 
  someObject: { count: 0 }
 })

watch(
  () => state.someObject,
  (newValue, oldValue) => {
    //state.someObject가 교체되지 않는 한 new와 old 값은 같다.
    console.log(`감시자 콜백 new: ${newValue.count}, old: ${oldValue.count}`);
  },
  {deep: true}
)

function increment() {
  state.someObject.count++
  console.log(`increment, ${state.someObject.count}`);
}

function replace() {
  state.someObject = {count: 99}
  console.log(`replace, ${state.someObject.count}`);
}
</script>

<template>
  <button @click="increment">increment</button><br><br>
  <button @click="replace">replace</button>
</template>

실행화면



첫번째 버튼을 눌렀을 때에도 콜백 함수가 실행된다. 다만, state.someObject가 교체되지 않는 한 new와 old 값은 같기 때문에 increment를 실행 했을땐 동일한 값을 확인 할 수 있고 replace를 눌렀을 경우에 교체전과 후 값을 확인 할 수 있다.

주의!

deep watche는 감시된 객체의 모든 중첩 속성을 탐색하므로, 큰 데이터 구조에서 사용할 때 비용이 많이 들 수 있다. 성능에 영향을 주는지 고려해서 필요한 경우에만 사용해라



3. Eager Watchers(열성적인 감시자)

watch는 기본적으로 게으르다(lazy). 콜백은 감시된 소스가 변경되기 전까지 호출되지 않는다. 그러나 어떤 경우에는 동일한 콜백 로직이 열성적으로 실행되기를 원할 수 있다. 예를 들어 최초 데이터가 구성된 후 콜백이 실행되기를 원할 수 있다.

'immediate: true' 옵션을 전달하여 워처의 콜백이 즉시 실행되도록 강제할 수 있다.

// Composition API ver
<script setup>
import { ref, watch } from 'vue'

const msg = ref('좋은 아침!')

watch(
  msg,
  () => {
    console.log('콜백', msg.value);
  },
  // 즉시 실행된 다음 소스가 변경되면 다시 실행된다.
  {immediate: true}
)

function textReplace() {
  msg.value = document.getElementById('msg').value;
}
</script>

<template>
  <p>{{ msg }}</p>
  <input id="msg" :value="msg"/>
  <button @click="textReplace">변경</button>
</template>

실행화면



페이지를 실행하자마자 watch의 콜백 함수가 동작하여 '좋은 아침!'이 출력되고 변경 버튼을 누르면 다시 실행되는 것을 확인 할 수 있다.

핸들러 함수의 초기 실행은 created 훅 직전에 발생한다. Vue는 이미 data, computed, method 옵션을 처리했을 것이므로 첫 번째 호출에서 해당 속성을 사용할 수 있다.



4. watchEffect()

watch()는 게으르므로(lazy) 감시 소스가 변경될 때까지 콜백이 호출되지 않는다. 그러나 어떤 경우에는 동일한 콜백 로직이 열성적으로 실행되기를 원할 수 있다. 예를 들어 초기 데이터를 가져온 다음 관련 상태가 변경될 때마다 데이터를 다시 가져오기를 원할 수 있다. 이는 다음과 같이 구현할 수 있다.

// Composition API ver
<script setup>
import { ref, watch, watchEffect } from 'vue'

const url = ref('https://yesno.wtf/api') // Vue 가이드 문서에 나오는 api
const data = ref(null)

watchEffect(async () => {
  const res = await fetch(url.value)
  data.value = await res.json()
})
</script>

<template>
  <img v-if="data && data.image" :src="data.image">
</template>

실행화면



ㅋㅋㅋㅋㅋㅋㅋ yes/no의 따라서 여러 짤방이 응답된다. 뭔가 재미없는 일 사이에 작은 행복을 넣는건 다 똑같구나 싶었음. 사람 사는 거 다 똑같다😂

잠깐!

<'img v-if="data && data.image" :src="data.image">에 조건문을 걸어두었는데, data의 초기값을 null로 선언했더니 "Unhandled error during execution of render function", "Uncaught TypeError: Cannot read properties of null (reading 'image')" 오류가 발생해서 데이터의 값이 있을때만 그려지도록 조건문을 추가함

아무튼 watchEffect()를 사용하면 반응형 의존성을 자동으로 감시하면서, 최초에 즉시 사이드 이펙트를 한 번 실행한다.

실행되는 동안에도 자동으로 의존성인 url.value를 추적한다(computed와 유사). url.value가 변경될 때마다 콜백이 다시 실행된다.


TIP!

watchEffect는 동기적 실행 중에만 의존성을 추적한다. 비동기 콜백과 함께 사용할 때 첫번째 await 틱 이전에 접근한 속성들만 추적한다. 이 말 잘 이해가 안가서 코드를 다시 돌려봄

// Composition API ver
<script setup>
import { ref, watchEffect } from 'vue'

const url = ref('https://yesno.wtf/api')
const data = ref(null)
const count = ref(0)

watchEffect(async () => {
  console.log('watchEffect 시작');

  // 비동기 작업 시작
  const res = await fetch(url.value)

  // count의 변경을 의존성으로 감지하지 않음
  console.log('count: ', count.value);

  // 비동기 작업 완료 후 결과 할당
  data.value = await res.json()

  // data의 변경을 의존성으로 감지
  console.log('data: ', data.value);
})

function increment() {
  count.value++;
}
</script>

<template>
  <img v-if="data && data.image" :src="data.image">
  <p>{{ count }}</p>
  <p>{{ data }}</p>
  <button @click="increment">count 증가</button>
</template>

실행화면



count 증가를 몇번이나 실행시켜도 콜백은 최조만 호출되고 그 후엔 반응이 없다. count.value에 접근한것이 await 다음이기 때문이다.

그럼 코드 순서를 바꿔서 테스트를 해보자

// Composition API ver
<script setup>
import { ref, watchEffect } from 'vue'

const url = ref('https://yesno.wtf/api')
const data = ref(null)
const count = ref(0)

watchEffect(async () => {
  console.log('watchEffect 시작');

  // await 틱 이전에 count.value에 접근하였기 때문에 의존성으로 간주된다.
  console.log('count: ', count.value);

  // 비동기 작업 시작
  const res = await fetch(url.value)

  // 비동기 작업 완료 후 결과 할당
  data.value = await res.json()

  // data의 변경을 의존성으로 감지
  console.log('data: ', data.value);
})

function increment() {
  count.value++;
}
</script>

<template>
  <img v-if="data && data.image" :src="data.image">
  <p>{{ count }}</p>
  <p>{{ data }}</p>
  <button @click="increment">count 증가</button>
</template>

실행화면



count 증가를 실행하면 값이 증가 된 후 콜백함수가 실행되는 것을 확인 할 수 있다.


4-1. watch vs watchEffect

watch와 watchEffect 둘 다 사이드 이펙트를 반응적으로 실행 할 수 있게 해준다. 주요 차이점은 반응형 의존성을 추적하는 방식이다.

  • watch는 명시적으로 감시된 소스만 추적한다. 콜백 내에서 조회하는 항목은 추적하지 않는다. 또한 콜백 소스가 실제로 변경된 경우에만 트리거 된다. watch는 의존성 추적을 사이트 이펙트와 분리하여, 콜백이 실행되어야 하는 시기를 보다 정확하게 제어할 수 있다.
  • watchEffect는 의존성 추적과 사이드 이펙트를 하나의 단계로 결합한다. 동기적(sync) 실행 중에 조회되는 모든 반응형 속성을 자동으로 추적한다. 이것은 더 편리하고 일반적으로 더 간결한 코드를 생성하지만, 콜백이 실행되어야 하는 시기가 덜 명시적이다.



5. Callback Flush Timing

반응형 상태를 변경하면 Vue 컴포넌트 업데이트와 사용자가 만든 감시자 콜백이 모두 실행된다.

기본적으로 개발자가 생성한 감시자 콜백은 Vue 컴포넌트가 업데이트되기 전에 실행된다. 따라서 감시자 콜백 내에서 DOM에 접근하면 DOM이 Vue에 의해 업데이트되기 전의 상태이다.

Vue에 의해 업데이트된 후의 DOM을 감시자 콜백에서 접근하려면, 'flush: post' 옵션을 지정해야 한다.

5-1. flush: post

// Composition API ver
watch(source, callback, {
  flush: 'post'
})

watchEffect(callback, {
  flush: 'post'
})

flush: 'post'옵션이 적용된 watchEffect()를 보다 간편하게 사용하기 위해서 watchPostEffect()를 사용할 수 있다.

// Composition API ver
import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  /* Vue가 업데이트 된 후 실행됩니다 */
})

기본적으로 flush: 'pre'|'post' 옵션은 콜백을 버퍼링하여, 동일한 틱에서 여러 번 상태 변경이 되더라도, 마지막에 한 번만 호출된다.

동일한 틱 내에 여러 번 상태 변경 시 마다 동기적으로 콜백을 호출해야 하는 경우, flush: 'sync' 옵션을 사용해야 한다. 단, 일반적으로 이러한 동작은 비효율적이므로 사용하려는 경우, 정말 필요한지 다시 한번 고민해봐야 한다.

// Composition API ver
<script setup>
import { ref, watch, watchEffect } from 'vue'

const count = ref(0)
const callback = (val, preVal) => console.log('변경이 감지됨!', val, preVal);
const options = { flush: 'sync' }

watch(count, callback, options)

count.value++
// 콜백 실행
count.value++
// 콜백 실행2
count.value++
// 콜백 실행3
</script>

<template>
  <p>{{ count }}</p>
</template>



6. Stopping a Watcher

setup() 또는 <'script setup> 내부에서 동기적으로 선언된 감시자는 해당 컴포넌트 인스턴스에 바인딩되며, 해당 컴포넌트가 마운트 해제되면 자동으로 중지된다. 대부분의 경우 감시자를 직접 중지하는 것에 대해 고민할 필요가 없다.

여기서 핵심은 동기적(synchronously) 으로 생성되어야 한다는 것이다. 감시자가 비동기 콜백에서 생성된 경우, 감시자는 해당 컴포넌트에 바인딩되지 않으며 메모리 누수를 발지하기 위해 수동으로 중지해야 한다.




note! 동기적 생성? 비동기적 생성?

짜증나게 진도 하나 나가려면 모르는게 두세개 더 튀어 나오지 진짜...😭

watcher가 동기적으로 생성되는지 비동기적으로 생성되는지에 대한 차이는 감시자가 생성되는 시점과 어떤 문맥에서 생성되는지에 따라 결정된다. 감시자가 동기적으로 생성되면 해당 감시자는 현재 코드 플로우에 속하게 되고, 감시자가 비동기적으로 생성되면 현재 코드 플로우와는 별개로 비동기적으로 실행된다.

// Composition API ver
<script setup>
import { ref, watch, watchEffect } from 'vue'

// 동기적으로 생성된 감시자
// 이 감시자는 컴포넌트가 마운트 해제되면 자동으로 중지된다
watchEffect(() => {
  console.log('동기적으로 생성된 감시자');
})

// 비동기적으로 생성된 감시자
// 자동으로 중지되지 않는다
// setTimeout 타이머 설정, 1초 후 실행
setTimeout(() => {
  watchEffect(() => {
    console.log('비동기적으로 생성된 감시자');
  })
}, 1000)

console.log('감시자 이후의 코드');

</script>

<template>
  <p>동기 비동기 그것이 무엇인가</p>
</template>

실행화면




(다시 Stopping a Watcher 이어서...)

감시자를 수동으로 중지하려면 반환된 함수를 사용하면 된다.(watch와 watchEffect 모두에서 작동한다.)

// Composition API ver
const unwatch = watchEffect(() => {})

// ...나중에 감시자가 더 이상 필요하지 않을 때:
unwatch()

감시자를 비동기식으로 생성해야 하는 경우는 거의 없으며, 가능하면 동기식 생성을 해야 한다. 일부 비동기 데이터를 기다려야 하는 경우, 감시자 로직을 조건부로 만들 수 있다.

// Composition API ver
<script setup>
import { ref, watch, watchEffect } from 'vue'

const url = ref('https://yesno.wtf/api') // Vue 가이드 문서에 나오는 api
// 비동기적으로 로드할 데이터
const data = ref(null)

watchEffect(async () => {
  if(data.value === null) {
    const res = await fetch(url.value)
    data.value = await res.json()
  }
})

function reset() {
  data.value = null
}
</script>

<template>
  <img v-if="data && data.image" :src="data.image"><br>
  <button @click="reset">변경</button>
</template>

변경 버튼을 누르면 data가 null이 되고 콜백 함수 내에서 data 변수의 값이 null인 경우에만 비동기 데이터를 로드하고 할당한다.




참고

Vue Docs