원문 보기 : https://pomb.us/build-your-own-react/
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
https://reactjs.org/docs/implementation-notes.html
리뷰 : 전역 변수
// 다음 처리할 작업 단위
let nextUnitOfWork = null;
// 현재 돔에 반영된 파이버 트리
let currentRoot = null;
// work in progress - 반영 위해 작업 중인 트리의 루트
let wipRoot = null;
// 삭제 대상 파이버 리스트
let deletions = null;
// 훅 처리를 위한 작업 중 파이버 (함수형 컴포넌트)
let wipFiber = null;
// 훅 인덱스
let hookIndex = null;
좀 더 알아보기
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 |