항해 플러스 4-5주차

지리·2025년 11월 23일

2,3주차도 적었어야 했는데 시간이 훅 가버림..ㅠ
매번 계획대로 나름 잘 진행하는 것 같은데 항상 목요일이 되면 급박하게 하게되는 것 같다ㅠ

react의 동작방식은 항상 공부해야지...해야지...하다가 미뤄둔 주제인데 이번기회에 공부를 해볼 수 있었다. 아래 내용은 공부해서 더 추가될 예정...

KPT

Keep

일단 어찌저찌 과제 제출하는건 유지하기.

Problem

여전히 개념에 대해 깊이 이해하지 못하고 먼저 코드를 작성하게 되는것 같다.

개념먼저 이해 -> 과제 = 시간부족,,,
일단 과제 -> 이후 개념 이해 = 개념 공부를 미뤄버림,,,
적당한 타협점을 찾아봐야 할 것 같다.

그리고 이제 목요일 밤새기 금지..!

Try

이번 코드 리뷰때 AI를 다양하게 활용해서 과제를 푸시는 분들을 봤는데 AI사용에 있어서 나도 좀더 활용할 수 있는 방안에 대해 공부해보면 좋을 것 같다.


이번 4,5주차 과제는 vanillaJS를 이용한 react 만들기였다.
이를 위해서는 react의 virtualDom, reconciliation, hook의 구조를 이해할 수 있어야 했다.

VirtualDom이란 실제 Dom을 좀더 가볍게 나타낸 Dom복사본이라 생각하면 될것 같다.
이 VirtualDom과 실제 DOM을 비교해서 업데이트하는 과정을 Reconciliation(재조정)이라 한다.

[Component render] 
       ↓
   Virtual DOM 생성
       ↓
[이전 VDOM] — diff —> [새 VDOM]
       ↓
필요한 DOM 변경만 실제 DOM에 반영 (patch)
       ↓
Memoization ← 렌더 최적화

createElement
createElement 함수는 react에서 JSX를 VirtualDOM 객체로 변환할 때 babel이 호출하는 함수이다. @babel/plugin-transform-react-jsx(React 17이후: @babel/preset-react) 플러그인에 정의된 해당 함수를 사용함.

// JSX
<div>Hello</div>
// vDom
createElement("div", null, "Hello");

babel을 사용하면 위의 jsx를 아래의 vDom으로 변경시켜 준다.

context의 구조

react의 hook정보와 이전 render정보가 저장되는 context

image

RootContext (렌더링 루트 관리)
렌더링의 최상위 정보를 관리

  • container: DOM 컨테이너
  • node: 루트 VNode
  • instance: 루트 Instance

HooksContext (훅 상태 관리)
각 컴포넌트의 훅 상태를 경로 기반으로 격리하여 관리

  • state: 컴포넌트별 훅 상태 배열 (경로를 키로 사용)
  • cursor: 컴포넌트별 훅 커서 (다음 실행될 훅 인덱스)
  • visited: 이번 렌더링에서 방문한 컴포넌트 경로
  • componentStack: 현재 실행 중인 컴포넌트 경로 스택

EffectsContext (이펙트 큐 관리)
렌더링 완료 후 비동기로 실행

  • queue: 예약된 useEffect 이펙트들의 큐

hook과 render 관계

image

reconcile 동작 방식

Instance 구조
Instance는 VNode와 실제 DOM을 연결하는 데이터 구조

kind: 노드 타입 (HOST, TEXT, COMPONENT, FRAGMENT)
dom: 실제 DOM 노드 참조
node: 가상 DOM 노드 (VNode)
children: 자식 인스턴스 배열
key: 리스트 렌더링용 키
path: 컴포넌트의 고유 경로 (hooks 관리용)

useState 동작 방식

useState가 호출될때 cursor(컴포넌트에서 hook의 순서)와 path(컴포넌트의 path)값을 클로저로 저장한다.

  • 최초실행시엔 init 값을 hooks배열에 저장한다 hooksState[cursor] = init
  • setState호출시
    • 이전의 state값과 새로운 state값이 같으면 render안함
    • 이전의 state값과 새로운 stater값이 다르면 hook의 배열을 업데이트render()
    • cursor값 증가
image

useEffect 동작 방식

  • 이전 훅의 의존성 배열과 현재 의존성 배열을 비교(shallowEquals)
  • 의존성이 변경되었거나 첫 렌더링일 경우, 이펙트 실행을 예약(context.effects.queue.push)
  • cursor값 증가
    (reconcile 호출후)
  • 이펙트 실행 전, 이전 클린업 함수가 있다면 먼저 실행
  • 이펙트 함수 실행후 클린업 함수가 있다면 hooksState[cursor]에 cleanup함수 추가
  • hooksState[cursor] 업데이트
image

마주친 문제들

eventHandler 중복 저장

it("상태가 변경되면 다시 렌더링한다", async () => {
      const container = document.createElement("div");
      let setCount: ((updater: (value: number) => number) => void) | undefined;

      function Counter() {
        const [count, update] = useState(0);
        setCount = update;
        return <button onClick={() => update((value) => value + 1)}>{count}</button>;
      }

      setup(<Counter />, container);

      let button = container.querySelector("button") as HTMLButtonElement;
      expect(button).not.toBeNull();
      expect(button?.textContent).toBe("0");

      setCount!((value) => value + 1);
      await flushMicrotasks();
      button = container.querySelector("button") as HTMLButtonElement;
      expect(button?.textContent).toBe("1");

      button?.click();
      await flushMicrotasks();
      button = container.querySelector("button") as HTMLButtonElement;
      expect(button?.textContent).toBe("2");
    });

여기서 toBe가 3으로 나와서 테스트가 통과가 안됐는데 원인은 addEventlistner가 중복으로 들어가서 였음

export const updateDomProps = (
  dom: HTMLElement,
  prevProps: Record<string, any> = {},
  nextProps: Record<string, any> = {},
): void => {
  // 이벤트 핸들러 처리: 변경되거나 제거된 이벤트 핸들러 제거
  Object.keys(prevProps).forEach((key) => {
    if (key === "children") return;

    const prevValue = prevProps[key];
    const nextValue = nextProps[key];

    // 이벤트 핸들러인 경우
    if (key.startsWith("on") && typeof prevValue === "function") {
      const eventName = key.slice(2).toLowerCase();

      // 이벤트 핸들러가 제거되었거나 변경된 경우
      if (nextValue === undefined || nextValue !== prevValue) {
        dom.removeEventListener(eventName, prevValue as EventListener);
      }
    }
  });

  // nextProps에 없는 일반 속성 제거
  Object.keys(prevProps).forEach((key) => {
    if (key === "children") return;
    if (key.startsWith("on")) return; // 이벤트 핸들러는 위에서 처리됨

    if (nextProps[key] === undefined) {
      // className은 "class" 속성으로 제거
      if (key === "className") {
        dom.removeAttribute("class");
      } else if (key === "style" && typeof prevProps[key] === "object") {
        // 스타일 객체의 경우 모든 속성 제거
        Object.keys(prevProps[key] || {}).forEach((styleKey) => {
          (dom as HTMLElement).style.removeProperty(styleKey);
        });
      } else {
        dom.removeAttribute(key);
      }
    }
  });

  // 새 속성 설정 (변경된 속성 포함)
  setDomProps(dom, nextProps);
};

updateDomProps실행시 변경되거나 제거된 이벤트 핸들러 제거후 새롭게 추가된 props 추가하게 변경

컴포넌트 삭제로인해 경로가 변경된 경우

// 중첩된 컴포넌트에서 useState가 각각 독립적으로 동작한다[test]
<div>
    <h1>Dynamic List</h1>
    {Array.from({ length: itemCount }, (_, i) => (
      <Item id={i} />
    ))}
    <Footer />
  </div>

context.hooks.state

Map(5) {
  '0' => [ 2 ],
  '0.c0.c1' => [ 1 ],
  '0.c0.c2' => [ 2 ],
  '0.c0.c3' => [ 0 ],
  '0.c0.c4' => [ 101 ]
}

위의 코드에서 length값이 3->2로 바뀌면 저장되었던 Footer의 상태값이 바뀌면서 '0.c0.c4'에 저장된 Footer의 상태값을 쓰지못하고 '0.c0.c3'에 저장된 값인 0이 상태값으로 나오면서 테스트가 실패

  1. createChildPath 함수 수정: 타입 정보를 경로에 포함하도록 변경
  • key가 없을 때: ${parentPath}.c${index}.t${typeName} 형식으로 경로 생성
  • 같은 타입의 컴포넌트는 타입 이름이 같아 경로가 유지됨
  1. reconciler.ts의 자식 재조정 로직 수정: 타입 기반 매칭 구현
  • key가 있으면 key로 매칭
  • key가 없으면 타입 기반 매칭
  • 같은 타입의 컴포넌트는 기존 인스턴스와 경로를 재사용

변경후 context.hooks.state

Map(4) {
  '0' => [ 2 ],
  '0.c0.tdiv.c1.tItem' => [ 1 ],
  '0.c0.tdiv.c2.tItem' => [ 2 ],
  '0.c0.tdiv.c4.tFooter' => [ 101 ]
}
profile
공부한것들, 경험한 것들을 기록하려 노력합니다✨

0개의 댓글