리액트 19의 베타 버전이 출시되었습니다. 리액트 18과 비교하면 사용자 친화적인 API가 많이 추가되었지만, 핵심 원칙은 크게 바뀌지 않았습니다. 여러분은 리액트를 꽤 오랫동안 사용해 왔을 텐데, 내부적으로 어떻게 작동하는지 알고 계신가요?
이 글에서는 비동기 업데이트를 지원하고 작업을 중단할 수 있는 리액트를 약 400줄의 코드로 구현하는 방법을 알아보겠습니다. 이는 리액트의 많은 고수준 API가 사용하는 핵심 기능입니다. 최종 결과는 다음과 같습니다.
저는 리액트 공식 사이트에서 제공되는 틱택토 실습 예제를 사용했고, 잘 작동하는 것을 확인할 수 있습니다.
현재 제 깃허브 에 호스팅되어 있으며, 온라인 버전 에서 직접 시도해 볼 수도 있습니다.
JSX와 createElement
mini-react.ts 의 원리를 살펴보기 이전에, JSX가 무엇을 나타내는지 이해하는 것이 중요합니다. 우리는 JSX를 사용하여 DOM을 그리고, 자바스크립트 로직을 쉽게 적용할 수 있습니다. 하지만 브라우저는 JSX를 이해하지 못하기 때문에, JSX는 브라우저가 이해할 수 있는 자바스크립트로 컴파일됩니다.
저는 이 예제에서 바벨을 사용하였지만, 여러분은 물론 다른 빌드 도구들을 사용할 수 있고 생성되는 내용은 비슷할 것입니다.
보시다시피 React.createElement
를 호출하며, 다음과 같은 옵션을 제공합니다.
- type:
div
와 같은 현재 노드의 타입을 나타냅니다. - config:
{id "test"}
와 같은 현재 요소 노드의 속성을 나타냅니다. - children: 자식 요소로, 여러 요소일 수도 있고, 단순 텍스트이거나
React.createElement
로 생성된 더 많은 노드일 수 있습니다.
리액트를 오래 사용해 오신 분들은 리액트 18 이전에는 JSX를 올바르게 작성하기 위해 import React from 'react';
가 필요했다는 것을 기억하실 겁니다. 리액트 18부터는 이것이 더 이상 필요하지 않아 개발자 경험이 향상되었지만, 내부적으로는 여전히 React.createElement
가 호출됩니다.
간소화된 리액트를 구현하기 위해, Vite를 react({ jsxRuntime: 'classic' })
로 설정하여 JSX를 직접 React.createElement
구현으로 컴파일해야 합니다.
그 다음 우리의 리액트를 구현할 수 있습니다.
// Text 요소는 특별한 처리가 필요합니다.
const createTextElement = (text: string): VirtualElement => ({
type: 'TEXT',
props: {
nodeValue: text,
},
});
// 자체 자바스크립트 자료구조를 만듭니다.
const createElement = (
type: VirtualElementType,
props: Record<string, unknown> = {},
...child: (unknown | VirtualElement)[]
): VirtualElement => {
const children = child.map((c) =>
isVirtualElement(c) ? c : createTextElement(String(c)),
);
return {
type,
props: {
...props,
children,
},
};
};
Render
다음으로, 앞서 만든 데이터 구조를 기반으로 JSX를 실제 DOM에 렌더링하는 간소화된 버전의 렌더 함수를 구현합니다.
// DOM 프로퍼티들을 업데이트합니다.
// 단순하게 하기 위해, 모든 이전의 프로퍼티들을 제거하고 다음 프로퍼티들을 추가합니다.
const updateDOM = (DOM, prevProps, nextProps) => {
const defaultPropKeys = 'children';
for (const [removePropKey, removePropValue] of Object.entries(prevProps)) {
if (removePropKey.startsWith('on')) {
DOM.removeEventListener(
removePropKey.substr(2).toLowerCase(),
removePropValue
);
} else if (removePropKey !== defaultPropKeys) {
DOM[removePropKey] = '';
}
}
for (const [addPropKey, addPropValue] of Object.entries(nextProps)) {
if (addPropKey.startsWith('on')) {
DOM.addEventListener(addPropKey.substr(2).toLowerCase(), addPropValue);
} else if (addPropKey !== defaultPropKeys) {
DOM[addPropKey] = addPropValue;
}
}
};
// 노드 타입에 기반하여 DOM을 생성합니다.
const createDOM = (fiberNode) => {
const { type, props } = fiberNode;
let DOM = null;
if (type === 'TEXT') {
DOM = document.createTextNode('');
} else if (typeof type === 'string') {
DOM = document.createElement(type);
}
// 생성 이후 프로퍼티에 기반하여 업데이트합니다.
if (DOM !== null) {
updateDOM(DOM, {}, props);
}
return DOM;
};
const render = (element, container) => {
const DOM = createDOM(element);
if (Array.isArray(element.props.children)) {
for (const child of element.props.children) {
render(child, DOM);
}
}
container.appendChild(DOM);
};
여기에 온라인 구현 링크 가 있습니다. 여전히 JSX를 한 번만 렌더링하므로 상태 업데이트를 처리하지 않습니다.
Fiber 아키텍처와 동시성 모드
Fiber 아키텍처와 동시성 모드는 주로 다음 문제를 해결하기 위해 개발되었습니다. 전체 요소 트리를 재귀적으로 순회하는 과정이 시작되면 이를 중단할 수 없어, 메인 스레드가 장시간 차단될 가능성이 있었습니다. 이로 인해 사용자 입력이나 애니메이션 같은 우선순위가 높은 작업들이 제때 처리되지 못할 수 있었습니다.
소스 코드에서 작업은 작은 단위로 나뉩니다. 브라우저가 유휴 상태가 될 때마다 이런 작은 작업 단위들을 처리하고, 메인 스레드의 제어권을 반환해서 브라우저가 우선 순위 높은 작업에 빠르게 반응할 수 있도록 합니다. 작업의 모든 작은 단위가 완료되면 결과를 실제 DOM에 반영합니다.
실제 리액트에서는 useTransition
이나 useDeferredValue
같은 제공된 API를 사용해서 업데이트의 우선순위를 명시적으로 낮출 수 있습니다.
요약하자면, 여기서 핵심은 메인 스레드의 제어권을 반환하는 방법과 작업을 관리 가능한 단위로 나누는 방법입니다.
requestIdleCallback
requestIdelCallback
은 실험적인 API로 브라우저가 유휴 상태일 때 콜백을 호출합니다. 이 API는 모든 브라우저 에서 지원되지는 않습니다. 리액트는 이 API를 스케쥴러 패키지 에서 사용되는데, 이 패키지는 작업 우선순위 업데이트 기능 등을 포함하여 requestIdleCallback보다 복잡한 스케쥴링 로직을 가지고 있습니다.
하지만 이 아티클에서는 비동기 중단 가능성만 고려할 것이므로, 이것이 리액트를 모방한 기본적인 구현입니다.
// 보강된 requestIdleCallback.
((global: Window) => {
const id = 1;
const fps = 1e3 / 60;
let frameDeadline: number;
let pendingCallback: IdleRequestCallback;
const channel = new MessageChannel();
const timeRemaining = () => frameDeadline - window.performance.now();
const deadline = {
didTimeout: false,
timeRemaining,
};
channel.port2.onmessage = () => {
if (typeof pendingCallback === 'function') {
pendingCallback(deadline);
}
};
global.requestIdleCallback = (callback: IdleRequestCallback) => {
global.requestAnimationFrame((frameTime) => {
frameDeadline = frameTime + fps;
pendingCallback = callback;
channel.port1.postMessage(null);
});
return id;
};
})(window);
주요 포인트들에 대해 간단하게 설명하겠습니다.
왜 MessageChannel을 사용하나요?
주로 각 라운드의 단위 작업을 처리하기 위해 매크로 태스크를 사용합니다. 하지만 왜 매크로 태스크일까요?
이는 메인 스레드의 제어권을 넘겨주기 위해 매크로 태스크를 사용해야 하기 때문입니다. 이렇게 하면 브라우저가 이 유휴 시간 동안 DOM을 업데이트하거나 이벤트를 받을 수 있습니다. 브라우저는 DOM 업데이트를 별도의 작업으로 처리하므로, 이 시간 동안에는 자바스크립트가 실행되지 않습니다.
메인 스레드는 한번에 하나의 작업만 할 수 있습니다. (예: 자바스크립트 실행, DOM 계산, 스타일 계산, 인풋 이벤트 처리 등) 반면 마이크로 태스크(예: Promise.then
)는 메인 스레드의 제어권을 반환하지 않습니다.
왜 setTimeout을 사용하지 않나요?
왜냐하면 모던 브라우저는 5번 이상의 중첩된 setTimeout 호출을 차단하려 하고 최소 지연을 4ms로 설정하므로 정확하지 않기 때문입니다.
알고리즘
리액트는 계속 진화하고 있고, 제가 설명하고 있는 이 알고리즘은 최신이 아닐 수 있습니다. 하지만 핵심 원리를 이해하기엔 충분합니다.
다음은 작업 단위들 간의 연결을 보여주는 다이어그램입니다.
리액트에서 각각의 작업 단위는 Fiber 노드라고 불립니다. 이 노드들은 연결 리스트 구조를 사용하여 서로 연결되어 있습니다.
- child: 부모 노드로부터 첫 번째 자식 요소로 향하는 포인터
- return/parent: 모든 자식 요소들은 부모 요소로 향하는 포인터를 가짐
- sibling: 첫 번째 자식 요소에서 다음 형제 요소로의 포인터
데이터 구조가 준비되었으니, 구체적인 구현을 살펴보겠습니다.
단순히 render 로직을 확장하여 호출 순서를 workLoop
-> performUnitOfWork
-> reconcileChildren
-> commitRoot
순으로 재구성합니다.
- workLoop :
requestIdleCallback
을 계속적으로 호출하면서 유휴 시간을 확보합니다. 현재 여유가 있고 실행할 단위 작업이 있다면 각 단위 작업을 실행합니다. - performUnitOfWork : 수행되는 구체적인 단위 작업입니다. 이는 연결 리스트 아이디어의 구현체입니다. 구체적으로, 한 번에 하나의 fiber 노드만 처리하고 다음에 처리할 노드를 반환합니다.
- reconcileChildren: 현재 fiber 노드를 조정(Reconcile)합니다. 이는 실제로 가상 DOM을 비교하고, 수행할 변경 사항을 기록합니다. 각 fiber 노드를 직접 수정하고 저장한 것을 볼 수 있습니다. 이는 단지 자바스크립트 객체에 대한 수정일 뿐이며 실제 DOM에는 아직 영향을 주지 않습니다.
- commitRoot: 현재 업데이트가 필요하고(
wipRoot
에 따라) 처리할 다음 단위 작업이 없다면(!nextUnitOfWork
에 따라), 가상의 변경 사항을 실제 DOM에 반영해야 한다는 뜻입니다.commitRoot
는 fiber 노드의 변경 사항에 따라 실제 DOM을 수정합니다.
이를 통해 우리는 중단 가능한 DOM 업데이트를 위해 fiber 아키텍처를 제대로 사용할 수 있게 되었지만, 아직 트리거가 부족합니다.
업데이트 트리거
리액트에서 일반적인 트리거는 가장 기본적인 업데이트 메커니즘인 useState
입니다. 이를 구현하여 우리의 Fiber 엔진을 가동해 보겠습니다.
구체적인 구현은 다음과 같습니다.
// fiber 노드와 훅을 연결합니다.
function useState<S>(initState: S): [S, (value: S) => void] {
const fiberNode: FiberNode<S> = wipFiber;
const hook: {
state: S;
queue: S[];
} = fiberNode?.alternate?.hooks
? fiberNode.alternate.hooks[hookIndex]
: {
state: initState,
queue: [],
};
while (hook.queue.length) {
let newState = hook.queue.shift();
if (isPlainObject(hook.state) && isPlainObject(newState)) {
newState = { ...hook.state, ...newState };
}
if (isDef(newState)) {
hook.state = newState;
}
}
if (typeof fiberNode.hooks === 'undefined') {
fiberNode.hooks = [];
}
fiberNode.hooks.push(hook);
hookIndex += 1;
const setState = (value: S) => {
hook.queue.push(value);
if (currentRoot) {
wipRoot = {
type: currentRoot.type,
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
};
nextUnitOfWork = wipRoot;
deletions = [];
currentRoot = null;
}
};
return [hook.state, setState];
}
이 방식은 훅의 상태를 fiber 노드에 영리하게 저장하고, 큐를 통해 상태를 수정합니다. 이를 통해 리액트 훅 호출 순서가 변경되면 안되는 이유도 알 수 있습니다.
결론
우리는 비동기와 중단 가능한 업데이트를 지원하며 의존성이 없이 최소한의 리액트 모델을 구현했습니다. 주석과 타입을 제외하면 약 400줄 미만의 코드가 될 것 입니다. 이 코드가 도움이 되었기를 바랍니다.