원문 보기 : https://pomb.us/build-your-own-react/
Rodrigo Pombo
An overengineer building tools for better code reading comprehension
pomb.us
0단계 : 리뷰
리액트 코드를 바닐라 js로 바꿔봅시다.
리액트
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
바닐라 JS
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
}
const container = document.getElementById("root")
const node = document.createElement(element.type)
node["title"] = element.props.title
const text = document.createTextNode("")
text["nodeValue"] = element.props.children
node.appendChild(text)
container.appendChild(node)
1단계 : createElement 함수
리액트를 나만의 리액트로 바꿔봅니다.
JSX를 JS로 바꿉니다.
React는 children이 없을 때 기본 값을 래핑하거나 빈 배열을 만들지 않지만
간단한 코드를 위해 이렇게 합니다.
텍스트 노드는 "TEXT_ELEMENT"로 따로 처리합니다.
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.map(child =>
typeof child === "object"
? child
: createTextElement(child)
),
},
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
JSX는 컴파일러 지정이 필요합니다.
아래와 같이 하면 바벨은 리액트 대신 우리가 정의한 함수를 이용해 JSX를 변환합니다.
const Didact = {
createElement,
}
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
2단계 : render 함수
function render(element, container) {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
const isProperty = key => key !== "children";
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name];
});
element.props.children.forEach(child => render(child, dom));
container.appendChild(dom);
}
const Didact = {
createElement,
render
};
// .....
const container = document.getElementById("root");
Didact.render(element, container);
3단계 : concurrent 모드 (동시성 UI 패턴)
- 각 children 파이버 자체를 스택 프레임처럼 처리하여 한번에 단 하나의 프레임만 처리하며, 프레임은 쌓이지 않음.
- 링크드 리스트를 이용해 중간에 작업을 yield 할 수 있음.
루프를 만들기 위해 requestIdleCallback을 사용합니다.
requestIdleCallback을 setTimeout으로 생각할 수 있습니다.
다른 점은 실행 시점을 알려주는 대신, 브라우저는 메인 스레드가 유휴 상태일 때 콜백을 실행합니다.
React는 더 이상 requestIdleCallback을 사용하지 않습니다.
이제 스케줄러 패키지를 사용합니다. 그러나 개념적으로 동일합니다.
requestIdleCallback은 또한 데드라인 파라미터 제공합니다.
브라우저가 스레드를 다시 제어해야 할 때까지 남은 시간을 확인하는 데 사용할 수 있습니다.
let nextUnitOfWork = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
// Todo : performUnitOfWork
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
4단계 : Fiber (리액트 내부 UI 표현 자료구조)
파이버는 작업 단위를 의미하는 자료구조 입니다.
작업 단위를 구성하려면 데이터 구조인 파이버 트리가 필요합니다.
- 각 엘리먼트에 대해 하나의 파이버가 존재합니다.
- 각 파이버는 작업 단위입니다.
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
- DOM에 엘리먼트 추가
- children을 위한 파이버 만들기
- 다음 작업 단위 선택 (select the next unit of work- 첫번째 자식 > 형제 > 부모의 형제(삼촌))
파이버의 목표중 하나는 다음 작업 단위를 쉽게 찾는 것입니다.
따라서 각 파이버는 첫번째 자식, 다음 형제, 부모에 대한 링크를 갖고 있습니다.
function performUnitOfWork(fiber) {
// DOM에 엘리먼트 추가 : 마운트
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
// 부모에 연결
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
// 자식을 위한 파이버 만들기
const elements = fiber.props.children
let index = 0
// 형제끼리 연결하기 위해
let prevSibling = null
// 자식 파이버로 만들기
while (index < elements.length) {
const element = elements[index]
const newFiber = {
type: element.type,
props: element.props,
parent: fiber,
dom: null,
}
if (index === 0) {
fiber.child = newFiber
} else {
// 형제끼리 >>>로 연결. 첫번째 자식만 연결 갖고 있음
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
// 다음 작업 단위 선택 (select the next unit of work- 자식 > 형제 > 부모의 형제(삼촌))
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) { // 자식
return nextFiber.sibling
}
nextFiber = nextFiber.parent // 부모로 이동
}
}
function render(element, container) {
nextUnitOfWork = {
dom: container,
props: {
children: [element],
},
}
}
let nextUnitOfWork = null
5단계 : 렌더와 커밋 페이즈
이전 코드는 엘리먼트 작업 시마다 DOM에 새 노드를 추가하고 있습니다
이 경우 브라우저가 작업을 중단하면 불완전한 UI를 보게 됩니다.
커밋은 한번에 이루어저야 하는 이유입니다.
따라서 돔 조작 코드를 제거하고
// 이전의 이부분 제거
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
}
파이버의 루트를 추적합니다.
이를 wipRoot라 부릅시다.
그리고 모든 작업을 마치면(다음 unit of work-작업 단위가 없으면)
전체 파이버 트리를 DOM에 한꺼번에 커밋합니다.
AS-IS
let nextUnitOfWork = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
// Todo : performUnitOfWork
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
TO-BE
function commitRoot() {
// TODO add nodes to dom
}
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
}
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let wipRoot = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
shouldYield = deadline.timeRemaining() < 1
}
// 다음 작업 단위가 없고,아직 커밋 안헀으면
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
6단계 : 조정(Reconciliation)
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
const elements = fiber.props.children
// new! 자식들을 위한 파이버를 만듭니다.
reconcileChildren(fiber, elements)
// 다음 작업 단위 선택 : 첫번째 자식
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
// 다음 작업 단위 선택 : 형제 > 삼촌
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
function reconcileChildren(wipFiber, elements) {
let index = 0
let oldFiber =
wipFiber.alternate && wipFiber.alternate.child
let prevSibling = null
while (
index < elements.length ||
oldFiber != null
) {
const element = elements[index]
let newFiber = null
const sameType =
oldFiber &&
element &&
element.type == oldFiber.type
// 이전 파이버와 새 엘리먼트의 타입이 같으면 DOM 노드를 유지하고 새 props로 업데이트하면 됩니다.
if (sameType) {
// TODO update the node
}
// 타입이 다르고 엘리먼트가 있으면 새 돔 노드를 만듭니다.
if (element && !sameType) {
// TODO add this node
}
// 타입이 다르고 이전 노드가 있으면 노드를 삭제해야 합니다.
if (oldFiber && !sameType) {
// TODO delete the oldFiber's node
}
if (oldFiber) {
oldFiber = oldFiber.sibling
}
if (index === 0) {
wipFiber.child = newFiber
} else if (element) {
prevSibling.sibling = newFiber
}
prevSibling = newFiber
index++
}
}
- 이전 파이버와 새 엘리먼트의 타입이 같으면 DOM 노드를 유지하고 새 props로 업데이트하면 됩니다.
- 타입이 다르고 새 엘리먼트가 있으면 새 DOM 노드를 만들어야 함을 의미합니다.
- 타입이 다르고 이전 파이버가 있는 경우 이전 노드를 제거해야 합니다.
이전 Fiber와 엘리먼트 타입이 같으면 이전 Fiber의 DOM 노드와 엘리먼트의 props를 유지하는 새 Fiber를 만듭니다.
또한 파이버에 새로운 속성인 effectTag를 추가합니다. 나중에 커밋 단계에서 이 속성을 사용할 것입니다.
const sameType =
oldFiber &&
element &&
element.type == oldFiber.type
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
}
}
타입이 다르고 새 엘리먼트가 있으면 새 DOM 노드를 만듭니다.
effectTage는 PLACEMENT 입니다.
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
}
}
노드를 삭제하는 경우는 이전 파이버에 이펙트 태그를 추가합니다.
새로운 파이버가 없기 때문입니다.
if (oldFiber && !sameType) {
oldFiber.effectTag = "DELETION"
deletions.push(oldFiber)
}
삭제는 DOM에 파이버 트리를 커밋할 때, wipRoot에서 수행합니다. (commitRoot 함수)
해당 wipRoot에는 삭제에 대한 fiber 정보가 없으므로 배열을 이용해 해당 정보를 수집합니다.
fiber에는 부모의 참조가 있고 부모의 돔에 접근할 수 있습니다.
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot,
}
deletions = []
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let currentRoot = null
let wipRoot = null
let deletions = null
루트에 커밋할 때, 삭제를 먼저 수행합니다.
function commitRoot() {
deletions.forEach(commitWork)
commitWork(wipRoot.child)
currentRoot = wipRoot
wipRoot = null
}
구현은 그냥 아래에서 보시는게 편할것 같습니다.
업데이트의 경우 이전 prop의 업데이트, 추가, 삭제를 해야해서 좀 복잡합니다만, 나머지는 쉽습니다.
스텝 7 : 함수 컴포넌트
이전에 엘리먼트 하나 당 파이버 하나라고 했는데요,
함수형 컴포넌트도 엘리먼트지만 약간 다릅니다.
function App(props) {
return Didact.createElement(
"h1",
null,
"Hi ",
props.name
)
}
// 타입이 함수?
const element = Didact.createElement(App, {
name: "foo",
})
- 돔 노드를 갖고 있지 않습니다.
- children이 prop이 아니라 함수 실행을 통해 얻어집니다.
파이버 타입이 함수인지 확인하고 이에 따라 다른 업데이트 함수로 이동합니다.
function performUnitOfWork(fiber) {
const isFunctionComponent =
fiber.type instanceof Function
if (isFunctionComponent) {
updateFunctionComponent(fiber)
} else {
updateHostComponent(fiber)
}
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
updateHostComponent는 이전 그 함수입니다.
함수형 컴포넌트는 children을 얻기 위해 함수를 호출합니다.
function updateFunctionComponent(fiber) {
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
그리고 commitWirk 함수를 변경해줘야 하는데요
파이버에 돔 노드가 없기 떄문입니다.
DOM 노드의 부모를 찾으려면 DOM 노드가 있는 섬유를 찾을 때까지 파이버 트리 위로 올라가야 합니다.
let domParentFiber = fiber.parent
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.parent
}
const domParent = domParentFiber.dom
function commitDeletion(fiber, domParent) {
if (fiber.dom) {
domParent.removeChild(fiber.dom)
} else {
commitDeletion(fiber.child, domParent)
}
}
스텝 8 : 훅
마지막 단계입니다.
함수 컴포넌트에 상태를 추가합니다.
// 현재 작업중인 파이버 전역상태
let wipFiber = null
// 작업중 파이버의 훅 인덱스 전역상태
let hookIndex = null
// 함수 컴포넌트 업대이트 시 전역 상태 초기화
function updateFunctionComponent(fiber) {
wipFiber = fiber
hookIndex = 0
wipFiber.hooks = []
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
function useState(initial) {
const oldHook =
wipFiber.alternate &&
wipFiber.alternate.hooks &&
wipFiber.alternate.hooks[hookIndex]
// 이전 상태가 있으면 복사 else 초기화
const hook = {
state: oldHook ? oldHook.state : initial,
queue: []
}
// 액션은 다음 렌더링 시에 호출하여 반영함.....
const actions = oldHook ? oldHook.queue : []
actions.forEach(action => {
hook.state = action(hook.state)
})
const setState = action => {
// setState 호출 시 액션을 푸시함
hook.queue.push(action)
wipRoot = {
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot,
}
// 다음 업데이트 시 액션 실행하도록!
nextUnitOfWork = wipRoot
deletions = []
}
wipFiber.hooks.push(hook)
// 다음 훅을 위해 인덱스 증가
hookIndex++
return [hook.state, setState]
}
이게 끝입니다.
실제 리액트와의 차이점 요약
- 리액트는 렌더링을 건너뛰기 위한 휴리스틱과 힌트를 사용합니다.
- 리액트는 커밋 단계에서 링크드 리스트를 이용해 이펙트가 있는 파이버들만 방문합니다.
- 리액트는 이전 트리의 파이버를 재활용합니다.
- 리액트는 expiration timestamp를 이용해 여러 업데이트틀 수신해도 가장 높은 우선순위의 업데이트를 결정할 수 있습니다.
- 즉, 항상 현재 진행 중인 작업 트리를 전부 버리지 않습니다.
- 그 외에도 많지만 이정도면 충분합니다.
참고 : 버츄얼 돔
공식문서에서 버츄얼 돔이라는 용어는 단 두 페이지에서만 사용됨.
https://reactjs.org/docs/faq-internals.html
Virtual DOM and Internals – React
A JavaScript library for building user interfaces
reactjs.org
https://reactjs.org/docs/implementation-notes.html
Implementation Notes – React
A JavaScript library for building user interfaces
reactjs.org
리뷰 : 전역 변수
// 다음 처리할 작업 단위
let nextUnitOfWork = null;
// 현재 돔에 반영된 파이버 트리
let currentRoot = null;
// work in progress - 반영 위해 작업 중인 트리의 루트
let wipRoot = null;
// 삭제 대상 파이버 리스트
let deletions = null;
// 훅 처리를 위한 작업 중 파이버 (함수형 컴포넌트)
let wipFiber = null;
// 훅 인덱스
let hookIndex = null;
좀 더 알아보기
The how and why on React’s usage of linked list in Fiber to walk the component’s tree - React inDepth
This article explores the main the work loop in React’s new reconciler implementation called Fiber. It compares and explains the differences between browser's call stack and the implementation of the stack in React's Fiber architecture.
indepth.dev
https://indepth.dev/posts/1501/exploring-how-virtual-dom-is-implemented-in-react
https://indepth.dev/posts/1009/in-depth-explanation-of-state-and-props-update-in-react
'FrontEnd' 카테고리의 다른 글
디자인 시스템 만들기 시리즈 1편 : 디자인 토큰 소개 (0) | 2022.07.21 |
---|---|
리액트 국제화(i18n, internationalization) translation key 설계 (0) | 2022.07.21 |
리액트 디자인 패턴 : 선언적 컴포넌트 설계 (declarative component design) (0) | 2022.07.19 |
Valtio의 프록시 상태관리가 어떻게 동작할까? (React Part) (1) | 2022.07.18 |
Valtio의 프록시 상태관리가 어떻게 동작할까? (Vanila Part) (0) | 2022.07.18 |