안녕하세요 🙌 본 포스팅은 AUSG 프론트엔드 Deep Dive 스터디에서 Build your own React 라는 글을 공부하며 작성하였습니다.
이전에 짰던 Didact render
함수는 내부적으로 재귀호출을 하고 있습니다.
Didact에서 렌더링을 시작하면, 모든 엘리먼트 트리를 렌더링하는 걸 마치기 전까지는 이를 멈출 수 없고 렌더링할 요소가 크다면, 메인 스레드의 동작이 너무 오랫동안 멈춰있을 것 입니다.
이때 브라우저가 유저의 입력이나 애니메이션을 부드럽게 하는 등의 작업들에 우선순위를 두고 있더라도, 이러한 작업들은 모두 렌더링이 마친 이후까지 대기해야 합니다.
다음은 What is React Concurrent Mode? 라는 동시성 모드를 설명하는 글에 나온 예시입니다.
아침에 차를 마시려고 하는데, 따뜻한 차와 잼이 발라진 따뜻한 식빵을 먹고 싶은데, 이를 다음 그림과 같은 순서로
- 물을 끓이고 따뜻한 차를 내려온다.
- 식빵을 굽고 잼을 바른다.
만들게 되면, 식빵의 잼이 다 발라졌을 때는 이미 차가 어느정도 식은 상태일 것입니다.
하지만, 다음과 같이 더 작은 단위의 태스크로 일을 나눠서 진행하면 따뜻한 차와 따뜻한 식빵을 모두 지켜낼 수 있었습니다.
이 예시가 말하고자 하는건, 동시성 입니다. 2개의 태스크를 각각 독립적으로 실행될 수 있는 여러 조각으로 나눠서 프로그램을 구조화 하여 2개의 작업을 동시에 진행할 수 있었습니다.
React에서는 React 18을 출시할 때 해당 기능을 추가하여 발표할 예정이며 공식문서에서는 해당 기능에 대한 소개만 기재하고 있습니다.
이러한 방식을 우리의 Didact에도 적용해보려고 합니다.
재귀호출 대신 작업을 더 작은 단위로 쪼갠 다음, 각각의 단위마다 브라우저가 어떤 작업이 추가적으로 필요한 경우 렌더링 도중에 끼어들 수 있도록 구현할 것 입니다.
let nextUnitOfWork = null
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
function performUnitOfWork(nextUnitOfWork) {
// TODO
}
function workLoop(deadline) {
let shouldYield = false
while (nextUnitOfWork && !shouldYield) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
shouldYield = deadline.timeRemaining() < 1
}
requestIdleCallback(workLoop)
}
requestIdleCallback(workLoop)
반복문을 만들기 위해 requestIdleCallback
함수를 사용합니다. requestIdleCallback 은 Web API에 속한 함수로써 브라우저의 메인 스레드가 비어있으면 지정한 콜백함수를 실행하도록 지시할 수 있는 함수입니다. (관련해서 흥미로운 글이 있으니 읽어보셔도 좋을 것 같아요! requestIdleCallback으로 초기 렌더링 시간 14% 단축하기)
하지만, 리액트에선 더 이상
requestIdleCallback
함수를 사용하지 않고 scheduler package를 사용한다고 합니다. (개념적으로는 같은 역할을 수행합니다)
현재는 말씀드렸듯이, React에서 아직 Concurrent Mode를 배포하지 않았기 때문에 React 17에서의 반복문은 다음과 같습니다.
while (nextUnitOfWork) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork)
}
function performUnitOfWork(nextUnitOfWork) {
// TODO
}
위에서 작업 단위들을 구조화한다 라는 표현을 사용했었습니다. 이를 위해서 사용하는 자료구조로 fiber tree를 사용합니다. 엘리먼트마다 하나의 fiber를 가지며 각각의 fiber가 여기서 작업 단위가 되겠습니다.
다음과 같은 트리를 렌더링하고 싶다고 합시다.
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
render 함수 내부에 root fiber를 생성하고, 이를 nextUnitOfWork로 설정합니다. 남은 작업들은 perfromUnitOfWork 함수에서 일어나는데, 각각의 fiber에서 다음과 같은 작업을 합니다.
- DOM에 엘리먼트를 추가한다.
- 각 엘리먼트의 children에 대해 fiber를 생성한다.
- 다음 작업 단위를 선택한다.
fiber는 3번
작업인 다음에 필요한 작업 단위를 찾는다 를 효율적으로 하기 위해 도입한 자료구조입니다. 이는 child
, parent
, sibling
의 세 가지 관계를 가집니다.
어떤 엘리먼트에서 작업을 끝마쳤을때, 해당 엘리먼트가 자식 엘리먼트가 있을 경우 자식 엘리먼트가 다음 작업 단위가 됩니다.
만약 자식이 없다면 형제 자매가 다음 작업의 대상이 되겠지요. 이도 없다면 <a>
나 <h2>
의 경우와 같이 부모 fiber로 이동하게 됩니다.
이러한 과정을 반복했을 때, root fiber에 도달했을때 render가 모두 수행되었음을 의미하게 됩니다.
function performUnitOfWork(fiber) {
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++
}
if (fiber.child) {
return fiber.child
}
let nextFiber = fiber
while (nextFiber) {
if (nextFiber.sibling) {
return nextFiber.sibling
}
nextFiber = nextFiber.parent
}
}
바로 위의 fiber를 추가하는 코드에서 동시성 모드를 고려한다면, 브라우저가 렌더링이 진행되고 있는 중간에 들어올 경우를 생각해야 합니다.
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
if (fiber.parent) {
fiber.parent.dom.appendChild(fiber.dom)
} // TODO: remove
const elements = fiber.props.children
...
다음의 코드에선 엘리먼트에서 작업을 수행할 때 마다 각각의 DOM에 새로운 노드를 추가하고 있습니다. 이때 렌더링 중에 작업이 들어올 경우 유저는 미완성된 UI를 볼 수 있으니, 이를 방지하도록 코드를 수정해야 합니다.
function render(element, container) {
wipRoot = {
...
nextUnitOfWork = wipRoot
}
let nextUnitOfWork = null
let wipRoot = null
돔을 변형시키는 부분을 제거하는 대신 fiber 트리의 루트를 추적합니다. 이를 작업중인 루트(work in progress root)라는 뜻으로 wipRoot
라고 하겠습니다.
function commitRoot() {
// TODO add nodes to dom
}
function render() {
...
function workLoop(deadline) {
...
if (!nextUnitOfWork && wipRoot) {
commitRoot()
}
requestIdleCallback(workLoop)
}
...
모든 작업이 끝나고 나면, !nextUnitOfWork && wipRoot
전체 fiber 트리를 DOM에 커밋합니다.
function commitRoot() {
commitWork(wipRoot.child)
wipRoot = null
}
function commitWork(fiber) {
if (!fiber) {
return
}
const domParent = fiber.parent.dom
domParent.appendChild(fiber.dom)
commitWork(fiber.child)
commitWork(fiber.sibling)
}
이 과정은 commitRoot
함수에서 이루어지며 여기서 모든 노드를 재귀적으로 DOM에 추가하게 됩니다.
What is React Concurrent mode
Build your own react
requestIdleCallback으로 초기 렌더링 시간 14% 단축하기