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
를 활용해 이러한 동작이 가능하다는 점이었습니다.
혹시 제가 설명한 부분에서 이해가 잘 되지 않거나, 잘못된 부분이 있다면 댓글로 알려주세요! 😊