Skip to content

4편. Vue render 과정 이해

VNode 모델 → h()createApp/render/patch → 엘리먼트/컴포넌트 마운트/업데이트 → props/style/event/children diff → 인보커(Invoker) 전략까지 흐름으로 설명한다. 핵심 메시지는 간단하다: 렌더러는 반응성 엔진(effect/track/trigger) 위에서 "상태를 읽는 render 함수"를 실행하며, 그 결과를 DOM으로 투영한다.

0. 렌더러의 역할

  • 입력: 컴포넌트의 render(ctx)가 반환하는 VNode 트리
  • 작업: 이전 VNode 트리와 현재 트리를 비교(patch)하여 DOM 변경 최소화
  • 출력: DOM 업데이트

렌더러 자신은 상태 관리하지 않는다. 상태는 반응성 엔진이 관리하고, 상태가 바뀌면 trigger → effect(updateComponent)가 실행되어 렌더러가 다시 일한다.

1. VNode 모델과 h()

ts
export type VNode = {
  type: string | Component<any, any>;
  props: Record<string, any> | null;
  children: string | VNode[] | null;
  key?: any;
  el: Node | null; // 실제 DOM 참조(마운트 후)
  component: ComponentInstance | null;
};

export function h(type, props = null, children?): VNode {
  const key = props && "key" in props ? props.key : undefined;
  const norm =
    children == null
      ? null
      : typeof children === "string"
      ? children
      : Array.isArray(children)
      ? children
      : [children];
  return { type, props, children: norm, key, el: null, component: null };
}
  • VNode는 DOM(혹은 컴포넌트)을 설명하는 가벼운 객체.
  • key는 자식 재정렬 시 힌트로 활용(간단 구현이라면 부분 사용만 해도 OK).

2. createApp/render/patch의 라이프사이클

ts
export function render(vnode: VNode, container: Element) {
  patch(null, vnode, container, null);
}

function patch(
  n1: VNode | null,
  n2: VNode,
  container: Element,
  anchor: Node | null
) {
  if (typeof n2.type === "string") {
    n1 ? patchElement(n1, n2, container) : mountElement(n2, container, anchor);
  } else {
    n1
      ? updateComponent(n1, n2, container, anchor)
      : mountComponent(n2, container, anchor);
  }
}
  • 최초 렌더: n1nullmount* 경로.
  • 업데이트: n1이 존재 → patch* 경로. 엘리먼트냐 컴포넌트냐에 따라 분기.

컴포넌트는 effect(updateComponent)로 구동된다. 즉, 렌더도 effect다.

3. 엘리먼트 마운트: mountElement

ts
function mountElement(vnode: VNode, container: Element, anchor: Node | null) {
  const el = document.createElement(vnode.type as string);
  vnode.el = el;

  // props 적용
  const props = vnode.props;
  if (props) for (const k in props) setProp(el, k, null, props[k]);

  // children 적용
  const c = vnode.children;
  if (typeof c === "string") el.textContent = c;
  else if (Array.isArray(c))
    for (const child of c) patch(null, child, el, null);

  container.insertBefore(el, anchor);
}
  • 첫 생성 시 속성/스타일/이벤트를 한 번에 세팅하고, 자식 트리를 재귀적으로 마운트한다.

4. 엘리먼트 패치: patchElement

ts
function patchElement(n1: VNode, n2: VNode, container: Element) {
  const el = (n2.el = n1.el!);
  patchProps(el, n1.props || {}, n2.props || {});
  patchChildren(n1, n2, el);
}

4.1 props diff: patchProps

ts
function patchProps(el: Element, oldProps: any, newProps: any) {
  // 변경/추가
  for (const k in newProps) setProp(el, k, oldProps[k], newProps[k]);
  // 제거
  for (const k in oldProps)
    if (!(k in newProps)) setProp(el, k, oldProps[k], null);
}

4.2 prop 적용 규칙: setProp

ts
function setProp(el: Element, key: string, prev: any, next: any) {
  if (key === "class") {
    (el as HTMLElement).className = next ?? "";
    return;
  }

  if (key === "style") {
    const style = (el as HTMLElement).style;
    if (next) for (const n in next) style.setProperty(n, next[n]);
    if (prev)
      for (const p in prev) if (!next || !(p in next)) style.removeProperty(p);
    return;
  }

  if (/^on[A-Z]/.test(key)) {
    // 이벤트 인보커
    const name = key.slice(2).toLowerCase();
    let invoker = (el as any)._vei?.[name];
    if (next) {
      if (!invoker) {
        invoker = (e: Event) => invoker.value && invoker.value(e);
        invoker.value = next; // 핸들러 교체만
        ((el as any)._vei ||= {})[name] = invoker;
        el.addEventListener(name, invoker);
      } else {
        invoker.value = next; // add/remove 없이 핫스왑
      }
    } else if (invoker) {
      el.removeEventListener(name, invoker);
      (el as any)._vei[name] = undefined;
    }
    return;
  }

  // DOM prop 직설 쓰기 케이스
  if (key === "value" && "value" in (el as any)) {
    (el as any).value = next ?? "";
    return;
  }
  if (key === "checked" && "checked" in (el as any)) {
    (el as any).checked = !!next;
    return;
  }

  // 나머지: attribute
  if (next == null || next === false) el.removeAttribute(key);
  else el.setAttribute(key, String(next));
}
  • class: 문자열로 통째 교체(간단/빠름)
  • style: key별 set/remove로 미세 diff
  • event: 인보커(invoker)로 리스너 add/remove 최소화 + 핸들러만 교체
  • DOM prop: value, checked처럼 DOM 프로퍼티는 속성(Attribute) 대신 프로퍼티
  • 기타: attribute set/remove

인보커 패턴은 비용이 큰 removeEventListener / addEventListener를 줄이고, 핸들러 함수만 교체해서 빠른 업데이트를 보장한다.

5. 자식 패치: patchChildren

ts
function patchChildren(n1: VNode, n2: VNode, el: Element) {
  const c1 = n1.children,
    c2 = n2.children;

  if (typeof c2 === "string") {
    if (Array.isArray(c1)) el.textContent = c2; // 배열 → 텍스트
    else if (c1 !== c2) el.textContent = c2; // 다른 텍스트 → 텍스트
    return;
  }

  if (Array.isArray(c2)) {
    if (typeof c1 === "string" || c1 == null) {
      // 초기나 텍스트에서 배열로
      el.textContent = "";
      for (const child of c2) patch(null, child, el, null);
    } else {
      // 간단 순차 diff (같은 길이 앞부분 patch, 나머지 mount/unmount)
      const len = Math.min(c1.length, c2.length);
      for (let i = 0; i < len; i++)
        patch(c1[i] as VNode, c2[i] as VNode, el, null);
      if (c2.length > c1.length) {
        for (let i = len; i < c2.length; i++)
          patch(null, c2[i] as VNode, el, null);
      } else if (c1.length > c2.length) {
        for (let i = len; i < c1.length; i++) unmount(c1[i] as VNode);
      }
    }
    return;
  }

  // 새 children이 null/undefined라면 전부 제거
  if (Array.isArray(c1)) for (const child of c1) unmount(child as VNode);
  else if (typeof c1 === "string") el.textContent = "";
}
  • 키 기반 고급 diff(LIS)는 생략한 단순 순차 비교. 학습용으로 충분하고, 실제 프로젝트에선 키 기반 최적화를 추가하면 된다.
ts
function unmount(vnode: VNode) {
  const el = vnode.el as Node;
  el.parentNode && el.parentNode.removeChild(el);
}

6. 컴포넌트 마운트/업데이트

ts
function mountComponent(vnode: VNode, container: Element, anchor: Node | null) {
  const comp = vnode.type as Component<any, any>;
  const instance: ComponentInstance = {
    vnode,
    isMounted: false,
    subTree: null,
    el: null,
    props: vnode.props || {},
    state: {},
    update: null,
  };

  // setup 결과(state) 준비
  const setupResult = comp.setup?.(instance.props);
  instance.state = (setupResult as any) ?? {};

  const updateComponent = () => {
    const ctx = proxyRefs({ ...instance.props, ...instance.state });
    const nextTree = comp.render(ctx);
    if (!instance.isMounted) {
      patch(null, nextTree, container, anchor);
      instance.isMounted = true;
    } else {
      patch(instance.subTree!, nextTree, container, anchor);
    }
    instance.subTree = nextTree;
    vnode.el = instance.el = nextTree.el!;
  };

  // 핵심: 렌더는 effect로 구동 → 상태가 바뀌면 자동 재렌더
  instance.update = effect(updateComponent);
  vnode.component = instance;
}
  • 컨텍스트: proxyRefs로 ref 자동 언래핑. 템플릿처럼 ctx.count로 접근 가능.
  • 업데이트 경로: 상태/props 변경 → triggerinstance.update 재실행 → patch로 DOM 반영.

props 변경 시 updateComponent에서 새로운 props를 병합하거나, 부모 VNode 비교 과정에서 shouldUpdateComponent 같은 판정 함수를 둘 수 있다(간단 구현이면 생략 가능).

7. 텍스트/주요 노드 케이스

  • 텍스트 노드: children이 문자열이면 textContent로 관리(간단/빠름).
  • 입력 요소: value, checked 등은 attribute가 아니라 DOM 프로퍼티로 다루어 사용자 입력과 동기화 문제를 피한다.
  • Fragment/Comment: 학습 단계에선 생략해도 무방. 필요 시 가상 루트와 앵커 노드를 두고 범위를 관리한다.

8. 실무 감각으로 보는 포인트

  • 이벤트 인보커: 대량의 리스트에서 이벤트 핸들러가 자주 바뀌어도 add/remove를 반복하지 않으니 비용이 확 줄어든다.
  • 배치 업데이트: 렌더 effect는 보통 마이크로태스크에 모아 실행된다(엔진 쪽 queueJob). 상태를 여러 번 바꿔도 한 번만 재렌더.
  • 키 기반 diff: 실제 앱에선 reorder가 잦다. 여기엔 LIS(Longest Increasing Subsequence) 최적화가 필요하다. 학습 구현에선 순차 비교로 감만 잡고, 이후 확장 포인트로 삼자.
  • 메모리 안전성: VNode의 el/component 참조가 남아 있으면 GC가 못 가져간다. 언마운트 시 참조를 정리해주는 습관을 들여라.

요약

  • 렌더러는 VNode를 DOM으로 투영하는 역할을 한다.
  • patch는 엘리먼트/컴포넌트를 분기해 마운트/업데이트 경로를 태운다.
  • props/style/event/children은 각자 규칙에 따라 최소 변경만 수행한다.
  • 컴포넌트 렌더는 effect로 등록되어, 상태 변화가 곧 재렌더 트리거가 된다.

이제 반응성 엔진과 렌더러가 어떻게 맞물려 돌아가는지, 파일 단위로 전체 지도가 완성됐다. 다음 번엔 key 기반 children diff나 템플릿→render 변환(간단 컴파일러)을 추가해 미니 프레임워크를 한 단계 더 키워보자.