본문 바로가기
Vue

Vue.js / computed의 변경 감지 방법 알아보기

by East-K 2024. 11. 24.

React에서는 useEffectuseMemo 등에서 deps를 등록하여 해당 값의 변경 유무를 판단하고, 그에 따라 내부 함수를 실행합니다. 반면, Vue에서는 computedwatchEffect에서 deps를 별도로 등록하지 않아도, 내부에서 사용한 반응형 값이 변경되었을 때 이를 자동으로 감지하고 내부 함수를 실행합니다.

deps를 등록하지 않고도 이러한 방식이 가능한 이유를 지금부터 내부 코드를 보며 알아보겠습니다.

Vue의 변경 감지 방법

Vue에서 ref를 사용해 반응형 값을 생성하면, 내부적으로 다음과 같은 클래스 형태로 동작합니다. 여기서 주목할 부분은 get 메서드의 this.dep.track()set 메서드의 this.dep.trigger()입니다.

get 메서드에서는 해당 ref 값에 접근하는 곳을 내부적으로 등록하고, set 메서드에서는 dep.trigger()를 호출하여 등록된 모든 곳에 변경 사실을 전파합니다.

// packages/reactivity/src/ref.ts
class RefImpl<T = any> {
  _value: T
  private _rawValue: T

  dep: Dep = new Dep()

  public readonly [ReactiveFlags.IS_REF] = true
  public readonly [ReactiveFlags.IS_SHALLOW]: boolean = false

  constructor(value: T, isShallow: boolean) {
    this._rawValue = isShallow ? value : toRaw(value)
    this._value = isShallow ? value : toReactive(value)
    this[ReactiveFlags.IS_SHALLOW] = isShallow
  }

  get value() {
    this.dep.track()
    return this._value
  }

  set value(newValue) {
    const oldValue = this._rawValue
    const useDirectValue =
      this[ReactiveFlags.IS_SHALLOW] ||
      isShallow(newValue) ||
      isReadonly(newValue)
    newValue = useDirectValue ? newValue : toRaw(newValue)
    if (hasChanged(newValue, oldValue)) {
      this._rawValue = newValue
      this._value = useDirectValue ? newValue : toReactive(newValue)
      this.dep.trigger()
    }
  }
}

즉, 아래와 같은 computed가 있을 때, msgComputed에서 msg.value를 참조하면 dep.track()을 통해 해당 computed를 Vue 내부에서 관리하는 deps에 등록합니다. 이후, msg.value가 변경(set)되면 trigger를 호출하여, msgComputed에 등록된 함수를 다시 실행하는 방식으로 동작합니다.

const msg = ref('Hello World!');
const msgComputed = computed(() => `${msg.value} msg computed`);

// packages/reactivity/src/dep.ts -> class dep의 내부
track(debugInfo?: DebuggerEventExtraInfo): Link | undefined {
  if (!activeSub || !shouldTrack || activeSub === this.computed) {
    return
  }

  let link = this.activeLink
  if (link === undefined || link.sub !== activeSub) {
    link = this.activeLink = new Link(activeSub, this)

    if (!activeSub.deps) {
      activeSub.deps = activeSub.depsTail = link
    } else {
      link.prevDep = activeSub.depsTail
      activeSub.depsTail!.nextDep = link
      activeSub.depsTail = link
    }

    addSub(link)
  } else if (link.version === -1) {
    link.version = this.version
    if (link.nextDep) {
      const next = link.nextDep
      next.prevDep = link.prevDep
      if (link.prevDep) {
        link.prevDep.nextDep = next
      }

      link.prevDep = activeSub.depsTail
      link.nextDep = undefined
      activeSub.depsTail!.nextDep = link
      activeSub.depsTail = link

      // this was the head - point to the new head
      if (activeSub.deps === link) {
        activeSub.deps = next
      }
    }
  }

  return link
}

trigger(debugInfo?: DebuggerEventExtraInfo): void {
this.version++
  globalVersion++
  this.notify(debugInfo)
}

notify(debugInfo?: DebuggerEventExtraInfo): void {
  startBatch()
  try {
    for (let link = this.subs; link; link = link.prevSub) {
      if (link.sub.notify()) {
        ;(link.sub as ComputedRefImpl).dep.notify()
      }
    }
  } finally {
    endBatch()
  }
}

거듭 설명했지만, 다시 요약하자면 msg.valuecomputed 내부에서 참조하는 시점에 track을 통해 값이 변경됐을 때 전파할 대상을 등록합니다. 이때 computed 값을 참조하는 과정에서 내부적으로 refreshComputed가 실행되고, refreshComputed는 전역적으로 값을 사용하는 activeSub를 해당 computed로 설정합니다. 그런 상태에서 reftrack이 실행되면서, 해당 computedrefsubs에 등록되는 구조로 동작합니다.

결론

이번 게시글에서는 Vue의 computed를 기준으로, deps를 명시적으로 등록하지 않고도 값의 변경을 어떻게 감지하는지를 알아보았습니다. 말로 설명하면 간단하지만, 실제 코드를 따라가는 과정에서는 refdepscomputed가 어떻게 자연스럽게 등록되는지 이해하는 데 시간이 걸렸습니다. 핵심은 전역 변수인 activeSub를 활용해 이러한 동작이 가능하다는 점이었습니다.

혹시 제가 설명한 부분에서 이해가 잘 되지 않거나, 잘못된 부분이 있다면 댓글로 알려주세요! 😊