React에서는 useEffect나 useMemo 등에서 deps를 등록하여 해당 값의 변경 유무를 판단하고, 그에 따라 내부 함수를 실행합니다. 반면, Vue에서는 computed나 watchEffect에서 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.value를 computed 내부에서 참조하는 시점에 track을 통해 값이 변경됐을 때 전파할 대상을 등록합니다. 이때 computed 값을 참조하는 과정에서 내부적으로 refreshComputed가 실행되고, refreshComputed는 전역적으로 값을 사용하는 activeSub를 해당 computed로 설정합니다. 그런 상태에서 ref의 track이 실행되면서, 해당 computed가 ref의 subs에 등록되는 구조로 동작합니다.
결론
이번 게시글에서는 Vue의 computed를 기준으로, deps를 명시적으로 등록하지 않고도 값의 변경을 어떻게 감지하는지를 알아보았습니다. 말로 설명하면 간단하지만, 실제 코드를 따라가는 과정에서는 ref의 deps에 computed가 어떻게 자연스럽게 등록되는지 이해하는 데 시간이 걸렸습니다. 핵심은 전역 변수인 activeSub를 활용해 이러한 동작이 가능하다는 점이었습니다.
혹시 제가 설명한 부분에서 이해가 잘 되지 않거나, 잘못된 부분이 있다면 댓글로 알려주세요! 😊