https://rlacodud.github.io/front_7th_chapter2-2
-
core/elements.ts:createElement,normalizeNode,createChildPath -
utils/validators.ts:isEmptyValue -
utils/equals.ts:shallowEquals,deepEquals
-
core/types.ts: VNode/Instance/Context 타입 선언 -
core/context.ts: 루트/훅 컨텍스트와 경로 스택 관리 -
core/setup.ts: 컨테이너 초기화, 컨텍스트 리셋, 루트 렌더 트리거
-
core/dom.ts: 속성/스타일/이벤트 적용 규칙, DOM 노드 탐색/삽입/제거
-
utils/enqueue.ts:enqueue,withEnqueue로 마이크로태스크 큐 구성 -
core/render.ts:render,enqueueRender로 루트 렌더 사이클 구현
-
core/reconciler.ts: 마운트/업데이트/언마운트, 자식 비교, key/anchor 처리 -
core/dom.ts: Reconciliation에서 사용할 DOM 재배치 보조 함수 확인
-
core/hooks.ts: 훅 상태 저장,useState,useEffect, cleanup/queue 관리 -
core/context.ts: 훅 커서 증가, 방문 경로 기록, 미사용 훅 정리
기본 과제 완료 기준: basic.equals.test.tsx, basic.mini-react.test.tsx 전부 통과
-
hooks/useRef.ts: ref 객체 유지 -
hooks/useMemo.ts,hooks/useCallback.ts: shallow 비교 기반 메모이제이션 -
hooks/useDeepMemo.ts,hooks/useAutoCallback.ts: deep 비교/자동 콜백 헬퍼 -
hocs/memo.ts,hocs/deepMemo.ts: props 비교 기반 컴포넌트 메모이제이션
챕터1에 대한 회고를 작성한 게 엊그제같은데 벌써 챕터2에 대한 회고를 작성하고 있다니 시간이 빠르다는 걸 체감하게 됩니다.
챕터2는 건강과의 사투였던 것 같습니다.
하필 챕터2를 시작하면서 눈과 전체적인 체력에 이슈가 생겨서 챕터1 때와 비교했을 때 상대적으로 시간과 체력을 덜 투자하게 되어 아쉬움이 많습니다. 특히 제가 좋아하는 javascript를 활용하는 주차였기에 더욱 아쉬움이 큰 것 같습니다.
그럼에도 아하 모먼트가 많았던, 짜릿한 챕터였기도 한 것 같습니다.
챕터1 테스트코드는 초면이었던 상대를 알아가는 과정이었다면, 챕터2는 이미 알고 있던, 친숙한 친구에 대해 제대로 이해하고 알아가는 과정이었던 것 같습니다.
javascript의 이벤트 위임, react의 가상돔, 재조정 과정 등 단순한 정의만으로 알고 있던 개념들을 과제를 진행하며 체화할 수 있었습니다!
“React는 어떻게 이전 DOM과 현재 DOM의 차이를 알아내고, 필요한 부분만 최소한으로 업데이트할까?”
이 과제를 시작하기 전까지는 막연히 가상돔이 있다는 건 알고 있지만, 실제론 어떻게 구현된 걸까?라는 의문이 있었습니다.
과제를 통해 직접 React를 만들면서 React 구조의 원리를 코드 단위로 체감할 수 있었고 아래는 구현 과정에서 가장 핵심적인 흐름들에 대해 정리한 내용입니다.
브라우저의 실제 DOM은 조작하는 데 비용이 큰 구조이다. 이 때문에 React는 바로 DOM을 조작하지 않고, 우선 메모리 상에 DOM의 **추상화된 형태(VDOM)**를 만든 뒤, 변경점을 계산하여 필요한 부분만 실제 DOM에 반영한다.
React의 기본 렌더 과정
- 상태 변경
- 새로운 Virtual DOM 생성
- 이전 Virtual DOM과 비교(Diff 알고리즘)
- 바뀐 부분만 실제 DOM 업데이트(Reconciliation) => 이 전체 과정이 실제로는 아주 빠르게 일어나며, => React는 이 과정을 통해 DOM 조작 비용을 획기적으로 줄일 수 있다.
다음 HTML이 있다고 하자
<div id="app">
<ul>
<li>
<input type="checkbox" class="toggle" />
todo list item 1
<button class="remove">삭제</button>
</li>
</ul>
</div>React는 이를 JS 객체 형태의 VNode로 표현한다:
{
type: "div",
props: { id: "app" },
children: [
{
type: "ul",
children: [
{
type: "li",
children: [
{ type: "input", props: { type: "checkbox", className: "toggle" } },
"todo list item 1",
{ type: "button", props: { className: "remove" }, children: ["삭제"] }
]
}
]
}
]
}
이처럼 DOM을 순수 JS 객체로 추상화함으로써 브라우저 환경에 종속되지 않고 비교 로직을 효율적으로 수행할 수 있게 된다.
그러나 위와 같이 객체로 표현됐을 때 문제점은 가독성이다. 우리가 그동안 봐왔던 HTML 구조를 표현하는 방식과 다르기에 어떤 depth로, 어떤 방식으로 구성되어있는지 한눈에 파악하기 어렵다.
이를 해결하기 위해 JSX라는 것이 등장한다.
<div>
<span>{count}</span>
</div>위 코드는 컴파일되면 다음과 같은 createElement() 호출로 변환된다:
createElement("div", null,
createElement("span", null, count)
);
그리고 이 createElement가 Virtual DOM 노드를 생성한다.
React의 Diff 알고리즘은 트리를 전체 비교하지 않는다. 대신 몇 가지 단순하지만 강력한 규칙을 사용한다!
<div class="a" />
→ <div class="b" /> => DOM은 유지, props만 업데이트됨.
<div /> → <span />→ 기존 DOM 제거 후 새로운 DOM 생성.
이 규칙이 가장 중요하다.
key가 없으면? 리스트를 단순히 앞에서부터 순서대로 비교한다.
[a, b, c] → [b, c, d]- a ↔ b (다름 → 교체)
- b ↔ c (다름 → 교체)
- c ↔ d (다름 → 교체) 즉, 전부 새로 만든다 => 매우 비효율적
key가 있으면?
[{key:a}, {key:b}, {key:c}]
→
[{key:b}, {key:a}, {key:c}]React는 key를 기준으로 매칭한다:
b 재사용, 위치만 이동
a 재사용, 위치만 이동
c는 그대로=> DOM 재사용 + 위치만 재배치(최적화)
React의 Reconciliation은 다음 단계로 이루어진다.
1️⃣ 루트 비교
- 타입이 같으면 자식 비교로 진행,
- 다르면 전체 노드를 새로 교체.
2️⃣ 자식 비교
- 부모 타입이 같으면 children을 순회하며 비교 시작.
3️⃣ 단일 노드 비교
- 타입이 같으면 → update
- 타입이 다르면 → replace
- 텍스트면 → textContent 변경
4️⃣ 리스트 처리
- key 기반으로 매칭 => 재사용 가능한 인스턴스는 그대로 두고 => 순서만 조정하거나 제거/추가 결정
React 내부에서는 렌더링이 아래 순서로 진행된다:
1️⃣ JSX → createElement() => Virtual DOM 생성
2️⃣ Virtual DOM → Reconciliation => 이전 VDOM과 비교하여 변경점 계산
3️⃣ DOM Patch => 필요한 부분만 실제 DOM에 반영
4️⃣ Hooks 관리
- useState: 상태 저장 및 커서 기반 관리
- useEffect: 렌더 이후에 실행되도록 큐에 저장
- cleanup: 언마운트 시 정리
5️⃣ Effect Flush 렌더가 끝나면 effect queue를 비우고 이펙트와 cleanup을 실행한다.
- VNode 생성(createElement)의 구조화된 처리
- normalizeNode 로직 내에서 명확하게 역할 분리
- reconciler에서 mount/update/unmount 흐름 처리
리팩토링하고 싶은 부분
- Reconciler의 분기가 많아서 추후 가독성 개선이 필요해보입니다.
가상돔을 만드는 데 가장 핵심적인 core/elements.ts 파일의 createElement 함수를 구현할 때 테스트코드를 통한 코드 역추론의 경험을 했습니다.
제공되었던 기본 코드 구조를 확인해보면 type, originProps, rawChildren을 매개변수로 받고 각 매개변수의 type이 명시되어있습니다.
/**
* JSX로부터 전달된 인자를 VNode 객체로 변환합니다.
* 이 함수는 JSX 변환기에 의해 호출됩니다. (예: Babel, TypeScript)
*/
export const createElement = (
type: string | symbol | React.ComponentType<any>,
originProps?: Record<string, any> | null,
...rawChildren: any[]
) => {
// 여기를 구현하세요.
};이 함수는 VNode 객체로 변환하는 역할을 한다고 했고 테스트코드를 확인해보면 해당 함수의 결과가 어떤 방식으로 return되어야 하는지 확인할 수 있습니다.
아래가 가장 기본적인 return 형태의 구조로 보이고 이제 아래의 함수 활용방식과 expect값을 통해 어떤 방식으로 처리해줘야 할지 추론할 수 있습니다.
- 첫번째 인자인 type은 그대로 type으로 들어간다.
- key는 지정하지 않으면 null로 들어간다.
- 두번째 인자였던 originProps는 props 내부에 속성이 적용되고
- 그 이후의 인자인 rawChildren은 props.children 내부에 해당값의 type인 TEXT_ELEMENT로 type이 정의되고 key는 역시나 null값, 그 내부에 보내줬던 string값 "Hello"가 nodeValue로 들어가는 걸 확인할 수 있다.
const vNode = createElement("div", { id: "test" }, "Hello");
expect(vNode).toEqual({
type: "div",
key: null,
props: {
id: "test",
children: [
{
type: TEXT_ELEMENT,
key: null,
props: { children: [], nodeValue: "Hello" },
},
],
},
});위와 같이 추론한 내용을 바탕으로 아래와 같이 코드를 작성할 수 있습니다.
type이나 originProps에 대한 부분은 간단히 처리할 수 있지만 중요한 부분은 rawChildren을 처리하는 부분입니다. rawChildren은 세번째 인자부터 몇개가 넘어오든 처리해야 하는 값이므로 우선 배열의 평탄화가 필요하고 flat으로 평탄화 처리를 한 뒤 배열을 순회하며 각 요소를 VNode 형식으로 정규화합니다.
이 때 Fragment에 대한 처리가 필요한데, 그 외의 노드는 그대로 push해주면 되지만 Fragment는 wrapper 없이 children만 전달하는 컨테이너이기에 children을 넘겨줘야 하기 때문입니다.
/**
* JSX로부터 전달된 인자를 VNode 객체로 변환합니다.
* 이 함수는 JSX 변환기에 의해 호출됩니다. (예: Babel, TypeScript)
*/
export const createElement = (
type: string | symbol | React.ComponentType<any>,
originProps?: Record<string, any> | null,
...rawChildren: any[]
) => {
// originProps에서 key만 분리하고,
// key를 제외한 나머지 속성만 props에 남김
const { key = null, ...props } = originProps ?? {};
// children을 담을 배열
const normalizedChildren: VNode[] = [];
// 배열 평탄화
const flat = rawChildren.flat(Infinity);
// 배열 순회
for (const child of flat) {
// VNode 형식으로 정규화
const normalized = normalizeNode(child);
// null(렌더링 불가 값)인 경우 continue
if (!normalized) continue;
// Fragment type일 경우
// children 요소 push
if (normalized.type === Fragment) {
const fragmentChildren = normalized.props?.children ?? [];
normalizedChildren.push(...fragmentChildren);
} else {
// 그 외, 본인 node push
normalizedChildren.push(normalized);
}
}
// children이 없으면 props에 children을 넣지 않음
if (normalizedChildren.length === 0) {
return {
type,
key,
props: {
...props,
},
};
}
return {
type,
key,
props: {
...props,
children: normalizedChildren,
},
};
};- React의 동작 흐름을 이해할 수 있었다!
- 테스트 코드가 있어서 구현 방향을 잡는 데 큰 도움이 되었다!
- 기본 => 심화 흐름이 자연스럽고 학습 곡선이 잘 맞춰져 있었다!
- reconciler는 로직 분기가 많아 처음 진입 장벽이 있었다!
- hooks의 cursor/visited/state 구조를 이해하는 데 시간이 조금 걸렸다!
- Reconciler에서 mount/update/unmount 분기 구조의 개선 여지가 있을지 궁금합니다!
- hooks/state 저장 구조(Map 기반 path 관리)가 더 효율적으로 개선될 수 있을지 궁금합니다!
- memo/deepMemo HOC에서 더 안정적이거나 React와 유사한 방식이 있을지 궁금합니다!
자세한 구현 과정과 회고는 아래 블로그에 정리했습니다! 😊 WIL 5주차_Chapter2-2. 나만의 React 만들기 5주차_Vanilla JS로 나만의 React 만들기