3편. ref / computed / watch, 그리고 render까지
앞에서 effect / track / trigger라는 엔진의 톱니바퀴를 봤다. 이제 이 엔진을 실제로 쓰는 도구(ref, reactive, computed, watch) 까지 연결해보자. 이게 다 합쳐져서 main.ts의 Counter와 TodoList가 동작된다.
1. ref: 값 하나만 반응형으로
ref는 말 그대로 값 하나를 반응형으로 감싸는 껍질이다.
ts
function trackRefValue(ref: RefImpl<any>) {
if (!activeEffect) return; // 지금 실행 중인 effect가 없으면 의존성 수집을 하지 않는다.
(ref.dep ||= new Set()).add(activeEffect); // ref가 관리하는 구독자 집합(dep) 을 준비하고(||=로 없으면 생성), 현재 실행 중인 activeEffect를 추가
(activeEffect.deps ||= []).push(ref.dep); // 역방향 링크. 현재 effect 입장에서 내가 들어가 있는 dep 집합들을 배열로 들고 있어, 다음 재실행 때 cleanup으로 빠르게 연결 해제할 수 있게 한다.
// 결과: stale dep 방지 + 메모리/불필요 트리거 감소.
}ts
function triggerRefValue(ref: RefImpl<any>) {
const dep = ref.dep;
if (!dep) return; // 이 ref를 구독한 effect가 하나도 없으면 바로 종료
const effects = new Set(dep); // 순회 도중 어떤 effect가 실행되며 cleanup으로 dep에서 자기 자신을 제거할 수 있는데, 원본 Set을 돌면 이터레이션이 깨질 수 있음. 복사해두면 안전
for (const e of effects) e.scheduler ? e.scheduler() : queueJob(e); // scheduler가 있으면 사용자 정의 스케줄러를 우선(예: flush: 'pre'|'sync'
// 없으면 마이크로태스크 배치 큐로(queueJob) 밀어 넣어 중복 제거 + 한 번에 실행.
}ts
export function ref<T>(v: T): Ref<T> {
return new RefImpl<T>(v);
}
class RefImpl<T> {
private _value: T;
dep?: Set<EffectFn>;
constructor(v: T) {
this._value = v;
}
get value(): T {
trackRefValue(this); // 읽을 때 track
return this._value;
}
set value(next: T) {
if (!Object.is(this._value, next)) {
this._value = next;
triggerRefValue(this); // 쓸 때 trigger
}
}
}ref(1)을 만들면{ value: 1 }같은 객체가 생기는데, 읽기/쓰기 순간에track과trigger를 걸어둔다.- 그래서
count.value++같은 코드가 자동으로 effect를 깨운다.
실제로는 proxyRefs라는 걸 써서 템플릿 안에서는 .value를 안 붙여도 되게 해준다.
2. reactive: 객체 전체를 반응형으로
ts
export function reactive<T extends object>(target: T): T {
return new Proxy(target, {
get(t, key, recv) {
const res = Reflect.get(t, key, recv);
track(t, key);
return isObject(res) ? reactive(res) : res; // 중첩 객체도 지연 반응화
},
set(t, key, val, recv) {
const oldVal = (t as any)[key];
const ok = Reflect.set(t, key, val, recv);
if (ok && !Object.is(oldVal, val)) trigger(t, key);
return ok;
},
});
}- Proxy 덕분에
state.count처럼 평범하게 접근해도 내부적으로 track/trigger가 돌아간다. - 중첩 객체는 실제로 접근했을 때만 다시 reactive로 감싸는 지연 처리(lazy) 를 쓴다.
3. computed: 계산된 값, 필요할 때만
computed는 단순히 getter에 effect를 씌운 것 같지만, 캐싱과 무효화라는 트릭이 숨어 있다.
ts
export function computed<T>(getter: () => T): ComputedRef<T> {
let cached!: T;
let dirty = true;
const runner = effect(getter, {
lazy: true,
scheduler: () => {
// 의존 값이 바뀌면 dirty만 true로
if (!dirty) {
dirty = true;
trigger(holder, "value");
}
},
});
const holder: any = {
get value() {
if (dirty) {
cached = runner(); // 실제 계산은 이때만
dirty = false;
}
track(holder, "value");
return cached;
},
};
return holder;
}count * 2같은 계산을 매번 다시 하지 않고, 값이 바뀌었을 때만 새로 계산한다.- 접근할 때까지 계산을 미뤄두니 성능적으로도 효율적이다.
4. watch: 값의 변화를 지켜본다
watch는 “값이 바뀔 때 사이드이펙트를 실행하라”는 도구다.
ts
export function watch<T>(
source: WatchSource<T>,
cb: (newVal: T, oldVal: T) => void,
options?
) {
const getter = typeof source === "function" ? source : () => traverse(source);
let oldVal: T;
const job = () => {
const newVal = runner();
if (!Object.is(newVal, oldVal)) {
cb(newVal, oldVal);
oldVal = newVal;
}
};
const runner = effect(getter, { lazy: true, scheduler: () => queueJob(job) });
if (options?.immediate) job();
else oldVal = runner();
}getter는 단일 값 함수거나, 객체 전체면traverse로 깊게 읽는다.- 새 값과 옛 값을 비교해서 달라졌을 때만 콜백을 실행한다.
flush: 'pre' | 'post' | 'sync'옵션을 주면 실행 타이밍도 조절할 수 있다.
요약
- ref: 단일 값 반응화.
- reactive: 객체 반응화.
- computed: 캐싱되는 파생 값.
- watch: 값의 변화를 지켜보고 콜백 실행.
이제 puzzle이 다 맞춰졌다. 작은 엔진(effect/track/trigger) 위에 ref·reactive·computed·watch가 올라가고, 그 위에 렌더러가 얹힌다. 그 결과 main.ts의 작은 앱이 “데이터 ↔ 화면”을 자동으로 이어주는 것이다.