Skip to content

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 } 같은 객체가 생기는데, 읽기/쓰기 순간에 tracktrigger를 걸어둔다.
  • 그래서 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의 작은 앱이 “데이터 ↔ 화면”을 자동으로 이어주는 것이다.