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

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

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

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



1. Basic Example

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

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

// Options API ver
<script>
export default{
  data() {
    return {
      question: '',
      answer: '질문에는 일반적으로 물음표가 포함됩니다.'
    }
  },
  watch: {
    // 질문이 변경될 때마다 이 함수가 실행된다.
    question(newQuestion, oldQuestion) {
      if(newQuestion.includes('?')) {
        this.getAnswer()
      }
    }
  },
  methods: {
    async getAnswer() {
      this.answer = '생각 중...'
      try {
        const res = await fetch('https://yesno.wtf/api')
        this.answer = (await res.json()).answer === 'yes' ? '네' : '아니오'
      } catch(error) {
        this.answer = '에러! api에 연결할 수 없습니다. ' + error
      }
    }
  }
};
</script>

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

note! async, fetch?

  • async?!
    • JavaScript에서 비동기적인 동작을 수행하는 함수를 정의한다. async 함수는 항상 Promise를 반환하며, 내부에서 await 키워드를 사용하여 비동기 작업의 완료를 기다린다.
  • fetch?!
    • JavaScript에서 네트워크 요청을 만들기 위한 API 중 하나이다. 서버와 통신하여 데이터를 가져오거나 보낼 수 있다. 또 Promise를 반환하며, HTTP 요청과 관련된 여러 설정을 조절할 수 있다.

watch 옵션은 점으로 구분된 결로도 지원한다.

// Options API ver
export default {
    watch: {
        // 참고 : 단순 경로만 가능, 표현식은 지원되지 않는다.
        'some.nested.key'(newValue) {
            // ...
        }
    }
}



2. Deep Watchers

watch는 기본적으로 얕다. 콜백은 감시되는 프로퍼티에 새 값이 할당되었을 때만 트리거되며 중첩된 프로퍼티 변경에는 트리거되지 않는다. 중첩된 모든 변경에 대해 콜백이 실행되도록 하려면 심층 감시자를 사용해야 한다.

예제

// Options API ver
<script>
export default{
  data() {
    return {
      someObject: {
        nesteProp: '기본값'
      }
    }
  },
  watch: {
    // 질문이 변경될 때마다 이 함수가 실행된다.
    someObject: {
      handler(newValue, oldValue) {
        console.log('값이 변경되었습니다.');
        console.log('새 값: ', newValue);
        console.log('이전 값: ', oldValue);
      },
      deep: true
    }
  },
  methods: {
    updateNestedProp() {
      // 중첩된 프로퍼티를 변경하면 감시자가 트리거된다.
      this.someObject.nesteProp = '새로운 값';
    }
  }
};
</script>

<template>
  <div>
    <p>someObject.nesredProp: {{ someObject.nesteProp }}</p>
    <button @click="updateNestedProp">중첩된 프로퍼티</button>
  </div>
</template>

실행화면

이 코드 실행 후 확인을 해보니 이전 값 표기때 내가 원했던 '기본값'이 아닌 다른 값이 나와서 당황했다.

이유를 찾아보니 Vue의 watch 옵션에서 'deep: true'를 사용하면 내부의 객체 또는 배열이 변경되었을 때에도 감시자가 트리거 된다. 그러나 handler 콜백 함수에 전달되는 oldValue는 해당 객체나 배열이 교체(replace)되었을 때만 이전 값이 된다.

따라서 예제에서 someObject.nesteProp 이 변경되었을 때 oldValue는 이전 값인 '기본값'이 아닌, someObject 객체 전체가 '새로운 값'으로 교체된 객체가 된다. 이것은 객체나 배열의 참조가 변경되었을 때 deep: true 옵션으로 인해 감지되는 특성이다.

객체를 교체(replace)하는 예제는 아래와 같다

// Options API ver
this.someObject = { nestedProp: '새로운 값'};

위 코드를 updateNestedProp()에 교체해서 실행하면 디버깅시 oldValue에 '기본값'이 들어있는 것을 확인 할 수 있다.

참고!

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



3. Eager Watchers

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

handler 함수와 'immediate: true' 옵션으로 구성된 객체를 사용해 감시자를 선언함으로써 콜백이 즉시 실행되도록 할 수 있다.

// Options API ver
<script>
export default{
  data() {
    return {
      question: '최초 질문'
    }
  },
  watch: {
    question: {
      handler(newQuestion) {
        // 컴포넌트 생성 시 'beforeCreate'와 'created' 훅 사이에 한 번 실행된다.
        console.log('값이 변경되었습니다: ', newQuestion);
      },
      // 열성적으로 콜백 실행
      immediate: true
    }
  },
  methods: {
    updateQuestion() {
      this.question = '새로운 질문';
    }
  }
};
</script>

<template>
  <div>
    <p>질문: {{ question }}</p>
    <button @click="updateQuestion">질문 업데이트</button>
  </div>
</template>

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



4. Callback Flush Timing

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

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

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

4-1. flush: post

// Options API ver
<script>
export default{
  data() {
    return {
      key: '최초 키',
      keyBeforeUpdate: ''
    }
  },
  watch: {
    key: {
      handler(newKey) {
        console.log('감시자 콜백에서 업데이트 전 키: ', this.keyBeforeUpdate);
        console.log('감시자 콜백에서 업데이트된 키: ', newKey);
      },
      flush: 'post' // Vue 업데이트 후에 콜백 실행
    }
  },
  methods: {
    updateKey() {
      this.keyBeforeUpdate = this.key;
      this.key = '새로운 키';
    }
  }
};
</script>

<template>
  <div>
    <p>키: {{ key }}</p>
    <button @click="updateKey">키 업데이트</button>
    <p>감시자 콜백에서 업데이트 전 키: {{ keyBeforeUpdate }}</p>
  </div>
</template>

실행화면

위에 설명이 잘 이해가 가지 않아서 하나하나 디버깅 찍어서 다시 확인해봄....
참고로 진행은 버튼 클릭 > updateKey() > watch > handler 순으로 진행된다. 이때 DOM의 업데이트가 언제 진행되는지 보는 것이 관건이다.

  1. updateKey()와 watch > handler에 디버깅 포인트를 찍어두었다. 버튼을 클릭하자 updateKey()가 호출되는 것을 확인 할 수 있다.

  2. updateKey()가 끝난 후 디버깅 커서가 템플릿으로 내려가 변경된 값 '새로운 키'로 바꿔준다.

  3. 화면에 {{ key }} 값이 변경되고 watch > handler가 호출되었다.

  4. 마지막으로 로그가 찍히는 것을 확인 할 수 있다.

비교를 하기 위해 Deep Watchers로 변경 한 후 디버깅을 해보았다.


스크립트의 실행 순서는 Flush와 동일하나 updateKey() > watch > handler까지 왔음에도 화면에서는 '최초 키'가 그대로 남아있다.

이쯤에서 위의 설명을 다시 읽어보니 이해가 되었다.

기본적으로 개발자가 생성한 감시자 콜백은 Vue 컴포넌트가 업데이트되기 전에 실행된다. 따라서 감시자 콜백 내에서 DOM에 접근하면 DOM이 Vue에 의해 업데이트되기 전의 상태이다.
Vue에 의해 업데이트된 후의 DOM을 감시자 콜백에서 접근하려면, 'flush: post' 옵션을 지정해야 한다.



콜백 실행 타이밍 설명으로 돌아가서...

flush에는 총 post, pre, sync 옵션이 있다.

4-2. flush: pre

pre는 감시 대상이 변경되기 전에 처리를 수행하도록 한다. 변경이 발생하기 전에 감시 대상을 기반으로 한 연산을 수행할 수 있는 유용한 옵션이다. 예를 들어, 감시 대상이 변경되기 전에 특정 값에 대한 사전 작업을 수행하려는 경우에 유용하다.

// Options API ver
<script>
export default{
  data() {
    return {
      key: '최초 키',
      keyBeforeUpdate: ''
    }
  },
  watch: {
    key: {
      handler(newKey, oldKey) {
        console.log('새로운 키: ', newKey, '이전 키: ', oldKey);
      },
      flush: 'pre' // Vue 업데이트 후에 콜백 실행
    }
  },
  methods: {
    updateKey() {
      this.keyBeforeUpdate = this.key;
      this.key = '새로운 키';
    }
  }
};
</script>

<template>
  <div>
    <p>키: {{ key }}</p>
    <button @click="updateKey">키 업데이트</button>
    <p>감시자 콜백에서 업데이트 전 키: {{ keyBeforeUpdate }}</p>
  </div>
</template>

실행화면

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

산 너머 산이로구만 틱은 또 뭐야..
모르면 알아봐야지...

note! 틱(tick)

틱은 일반적으로 시간을 작은 단위로 나눈 것을 나타내는 용어이다. Vue.js에서의 틱은 비동기 작업이나 상태 업데이트를 처리하는 작은 시간 단위를 의미한다.

Vue.js의 반응성 시스템은 데이터 변경을 감지하고 상태를 업데이트 할 때, 이를 틱 단위로 처리한다. 각 틱은 비동기적인 작업을 수행하며, 이 때 변경된 상태에 대한 업데이트가 이루어진다.

한 번의 틱 안에서 여러 번의 상태 변경이 있을 경우에는 해당 틱이 끝날 때에 마지막 변경에 대한 콜백만 실행되는 것이 Vue의 기본 동작이다. 즉, flush 옵션은 틱 내에서 콜백이 실행되는 시점을 조절하지만, 여러 번 변경이 있을 때에는 마지막 변경에 대해서만 호출된다.

// Options API ver
<script>
export default{
  data() {
    return {
      count: 0
    }
  },
  watch: {
    count: {
      handler(newValue, oldValue) {
        console.log('Callback called - count: ', oldValue, '-> ', newValue);
      },
      flush: 'post' // 'pre' 값만 바꿔서 테스트
    }
  },
  methods: {
    increment() {
      this.count++;
      this.count++;
      this.count++; // 여러번 상태 변경
    }
  }
};
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

실행화면



count가 여러번 변경되었지만 마지막 변경 값에 대해서만 출력되는 것을 확인 할 수 있다.

4-3. flush: sync

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

// Options API ver
<script>
export default{
  data() {
    return {
      count: 0
    }
  },
  watch: {
    count: {
      handler(newValue, oldValue) {
        console.log('Callback called - count: ', oldValue, '-> ', newValue);
      },
      flush: 'sync'
    }
  },
  methods: {
    increment() {
      this.count++;
      this.count++;
      this.count++; // 여러번 상태 변경
    }
  }
};
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <button @click="increment">Increment</button>
  </div>
</template>

틱 예제와 동일한 코드에 flush의 옵션을 sync로 바꾼 결과이다. 값이 변경 될 때마다 콜백 함수가 호출되어 콘솔 로그가 찍히는 것을 확인 할 수 있다.



5. this.$watch()

$watch() 인스턴스 메서드를 사용하여 감시자를 선언적으로 생성할 수도 있다.
(다시 기억해내자, Vue는 컴포넌트 인스턴스를 통해 기본 제공되는 API를 노출할 때 '$'접두사를 사용한다.)

// Options API ver
export default {
  created() {
    this.$watch('question', (newQuestion) => {
      // ...
    })
  }
}

이는 감시자를 조건부로 설정해야 하거나, 사용자 상호 작용에 대한 응답으로만 무언가를 감시해야 할 때 유용하다. 또한 감시자를 조기에 중지할 수 있다.



6. Stopping a Watcher

watch 옵션이나 $watch() 인스턴스 메서드를 사용하여 선언된 감시자는 해당 컴포넌트가 마운트 해제될 때 자동으로 중지되므로 대부분의 경우 감시자를 직접 중지하는 것에 대해 고민할 필요가 없다.

드물게 해당 컴포넌트가 마운트 해제되기 전에 감시자를 중지해야 하는 경우를 위해 $watch() API는 이 기능을 수행할 수 있게 함수를 반환한다.

// Options API ver
const unwatch = this.$watch('foo', callback)

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




참고

JavaScript async

JavaScript fetch

Vue Docs