우리는 React를 처음부터 다시 작성할 것입니다. 실제 React 코드의 아키텍처를 따르지만 모든 최적화 및 비필수 기능은 제외합니다.
처음부터 시작하여 다음은 React 버전에 하나씩 추가할 모든 것입니다.
- Step 1: createElement 함수
- Step 2: render 함수
- Step 3: Concurrent Mode
- Step 4: Fibers
- Step 5: Render and Commit Phases
- Step6: Reconciliation
- Step7: Function Components
- Step8: Hooks
const element = <h1 title="foo">Hello</h1>
const container = document.getElementById("root")
ReactDOM.render(element, container)
Step Zero: Review
먼저 몇가지 기본 개념을 검토해보겠습니다. React, JSX 및 DOM 요소가 어떻게 작동하는지 이미 잘 알고 있다면 이 단계를 건너뛸 수 있습니다. 우리는 단 세줄의 코드로 된 이 React 앱을 사용할 것입니다. 첫 번째는 React 요소를 정의합니다. 다음은 DOM에서 노드를 가져옵니다. 마지막 것은 React 요소를 컨테이너에 렌더링합니다.
모든 React 특정 코드를 제거하고 바닐라 JavaScript로 대체합니다.
const element = React.createElement(
"h1",
{ title: "foo" },
"Hello"
)
React.createElement는 인수에서 객체를 생성합니다. 일부 유효성 검사 외에도 그게 전부입니다. 따라서 함수 호출을 출력으로 안전하게 대체할 수 있습니다.
const element = {
type: "h1",
props: {
title: "foo",
children: "Hello",
},
}
그리고 이것이 바로 요소입니다. type과 props라는 두 가지 속성을 가진 객체입니다. type은 생성하려는 DOM 노드의 유형을 지정하는 문자열이며 HTML 요소를 생성할 때 document.createElement에 전달하는 tagName입니다.
props는 또 다른 객체이며 JSX 속성의 모든 키와 값을 가지고 있습니다. 그것은 또한 children이라는 특별한 속성을 가지고 있습니다. 이 경우 children은 문자열이지만 일반적으로 더 많은 요소가 있는 배열입니다.
lemen
교체해야하는 다른 React 코드는 ReactDOM.render에 대한 호출입니다.
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)
먼저 h1을 사용하여 노드를 생성합니다. 그런 다음 모든 요소 props를 해당 노드에 할당합니다. 여기에서는 그냥 제목힙니다.
혼동을 피하기 위해 React Element를 element로, DOM 요소를 node로 지칭하겠습니다.
이제 자식 노드를 만듭니다. 자식으로 문자열만 있으므로 텍스트 노드를 만듭니다. innerText를 설정하는대신 textNode를 사용하면 나중에 모든 요소를 동일한 방식으로 처리할 수 있습니다. 또한 우리가 h1 제목으로 했던 것처럼 nodeValue를 설정하는 방법에 주목하세요. 마치 문자열에 Props: {nodeValue: "hello"}가 있는 것과 거의 같습니다. 마지막으로 textNode를 h1에 추가하고 h1을 컨테이너에 추가합니다. 이제 이전과 동일한 앱이 있지만 React를 사용하지 않습니다.
Step 1: createElement 함수
다른 앱으로 다시 시작하겠습니다. 이번에는 React 코드 자체 버전의 React로 교체합니다. 우리는 자신의 createElement를 작성하는 것으로 시작할 것입니다. createElement의 호출을 볼 수 있다로고 JSX를 JS로 변환해 보겠습니다.
const element = React.createElement(
"div",
{ id: "foo" },
React.createElement("a", null, "bar"),
React.createElement("b")
)
이전 단계에서 보았듯이 요소는 type과 props가 있는 객체입니다. 함수가 해야할 유일한 일은 해당 객체를 생성하는 것입니다.
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children,
},
}
}
props에는 spread 연산자를 사용하고 children에는 나머지 매개변수 구문을 사용합니다. 이렇게 하면 children prop은 항상 배열이 됩니다. 예를 들어 createElement("div")는 다음을 반환합니다.
{
"type": "div",
"props": { "children": [] }
}
createElement("div", null, a)는 다음을 반환합니다.
{
"type": "div",
"props": { "children": [a] }
}
createElement("div", null, a, b)는 다음을 반환합니다.
{
"type": "div",
"props": { "children": [a, b] }
}
children 배열은 문자열이나 숫자와 같은 기본 값을 포함할 수도 있습니다. 따라서 객체가 아닌 모든 요소를 자체 요소 안에 래핑하고 특수 유형인 TEXT_ELEMENT를 생성합니다.
type,
props: {
...props,
children: children.map(child =>
typeof child === "object"
? child
: createTextElement(child)
),
},
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
}
}
React는 기본값을 래핑하거나 자식이 없을 때 빈 배열을 생성하지 않지만 코드를 단순화하고 라이브러리의 경우 성능 보다 간단한 코드를 선호하기 때문에 그렇게 합니다.
우리는 여전히 React의 createElement를 사용하고 있습니다. 교체하기 위해 라이브러리에 이름을 지정해보겠습니다. React 처럼 들리지만 교훈적인 목적을 암시하는 이름이 필요합니다.
const Didact = {
createElement,
}
/** @jsx Didact.createElement */
const element = (
<div id="foo">
<a>bar</a>
<b />
</div>
)
render 함수
다음으로 우리는 ReactDOM.render 함수를 작성해보아야합니다. 지금은 DOM에 항목을 추가하는 것에만 관심이 있습니다. 업데이트 및 삭제는 나중에 처리합니다.
타입을 사용하여 DOM 노드를 생성하는 것으로 시작한 다음 새 노드들 컨테이너에 추가합니다.
각 자식에 대하여 재귀적으로 동일한 작업을 수행합니다.
또한 텍스트 요소를 처리해야합니다. TEXT_ELEMENT 유형인 경우 일반 노드 대신 텍스트 노드를 생성합니다.
여기서 마지막으로 해야할 일은 요소 prop을 노드에 할당하는 것 입니다. 이게 전부입니다. 이제 JSX를 DOM으로 렌더링할 수 있는 라이브러리를 만들었습니다.
Concurrent Mode
코드를 추가하기전에 리팩터링을 해봅시다.
우리가 만든 render의 재귀호출에는 문제가 있습니다. 렌더링을 시작하면 완전한 요소 트리를 렌더링하는데까지 멈추지 못한다는 점입니다. 만약 요소 트리가 엄청 크다면 브라우저 렌더러 프로세스의 메인스레드를 너무 오랫동안 차단할 수 있습니다. 그리고 브라우저가 사용자 입력을 처리하거나 애니메이션을 부드럽게 유지하는 것과 같은 우선 순위가 높은 작업을 수행하는 경우 렌더링이 완료될 때까지 기다려야 합니다.
따라서 작업을 작은 단위로 나누고 각 단위를 완료한 후 다른 작업이 필요한 경우 브라우저가 렌더링을 중단하도록 할 것입니다.
우리는 requestIdleCallback을 사용하여 루프를 만듭니다. requestIdleCallback을 setTimeout으로 생각할 수 있지만 실행 시점을 알려주는 대신 브라우저는 기본 스레드가 유휴 상태일 때 콜백을 실행합니다.
React는 더이상 requestIdleCallback을 사용하지 않습니다. 이제 스케줄러 패키지를 사용합니다. 그러나 이 사용 사례의 경우 개념적으로 동일합니다. requestIdleCallback은 deadline 매개변수도 제공합니다. 이를 사용하여 브라우저가 다시 제어해야 할 때까지 남은 시간을 확인할 수 있습니다.
2019년 11월 기준으로 Concurrrent 모드는 아직 React에서 안정적이지 않습니다. 루프의 안정적인 버전은 다음과 같습니다.
while (nextUnitOfWork) {
nextUnitOfWork = performUnitOfWork(
nextUnitOfWork
)
}
루프 사용을 시작하려면 첫 번째 작업 단위를 설정한 다음 작업을 수행할 뿐만 아니라 다음 작업을 반환하는 performUnitOfWork 함수를 작성해야 합니다.
Fibers
작업 단위를 구성하려면 Fiber 트리라는 데이터 구조가 필요합니다. 우리는 각 요소에 대해 하나의 fiber를 가지며 각 fiber는 작업 단위가 됩니다.
예제를 봅시다. 우리가 아래와 같은 요소트리를 렌더링하고 싶다고 해보겠습니다.
Didact.render(
<div>
<h1>
<p />
<a />
</h1>
<h2 />
</div>,
container
)
렌더링에서 루트 fiber를 만들고 nextUnitOfWork로 설정합니다. 나머지 작업은 performUnitOfWork 함수에서 발생하며 각 fiber에 대해 세가지 작업을 진행합니다.
1. add the element to the DOM
2. create the fibers for the element's children
3. select the next unit of work
이 데이터 구조의 목표 중 하나는 다음 작업 단위를 쉽게 찾을 수 있도록 하는 것입니다. 그렇기 때문에 각 fiber에는 첫번째 자식, 다음 형제 및 부모에 대한 링크가 있습니다.
fiber에 대한 작업 수행을 마칠 경우 그 fiber의 자식이 있다면 그 자식이 다음 작업의 수행 대상이 됩니다. 우리의 예제에서 div 파이버 작업을 마치면 다음 작업 단위는 h1 fiber가 됩니다 .
fiber에 자식이 없으면 형제를 다음 작업 단위로 사용합니다. 예를 들어, p fiber에는 자식이 없으므로 종료 후 a fiber로 이동합니다. 그리고 fiber에 자식이나 형제가 없으면 "삼촌", 즉 부모의 형제로 이동합니다. 예제의 a및 h2 fiber와 같습니다. 또한 부모에게 형제가 없으면 형제가 있는 부모를 찾거나 루트에 도달할 때까지 부모를 통해 계속 올라갑니다. 루트에 도달했자면 이 렌더링에 대한 모든 작업 수행을 완료했음을 의미합니다.
이제 구현해봅시다.
렌더 함수에서 우리는 nextUnitOfWork를 파이버 트리의 루트로 설정합니다. 그리고 브라우저가 준비되면 workLoop를 호출하고 루트에서 작업을 시작합니다.
먼저 새 노드를 만들어 DOM에 추가합니다. 우리는 fiber.dom 속성에서 DOM 노드를 추적합니다. 그리고 각각의 children에 대하여 새로운 fiber를 만듭니다. 그리고 첫번째 자식인지 아닌지에 따라 자식 또는 형제로 설정하여 fiber 트리에 추가합니다. 마지막으로 다음 작업 단위를 검색합니다. 우리는 먼저 아이와 함께 시도한 다음 형제 자매와 삼촌과 함께 시도합니다.
이게 performUnitOfWork 함수입니다.
Render and Commit Phases
여기에는 또 다른 문제가 있습니다.
작업할 때마다 DOM에 새 노드를 추가하고 있습니다. 그리고 전체 트리 렌더링을 완료하기 전에 브라우저가 작업을 중단할 수 있음을 기억하십시오. 이 경우 사용자에게 불완전한 UI가 표시됩니다. 그리고 우리는 그것을 원하지 않습니다.
우리는 DOM이 계속 추가되지 않도록 하기위해 파이버의 루트를 추적하려고 합니다. 이를 wipRoot라고 하겠습니다. 그리고 모든 작업을 마치면(다음 작업 단위가 없기 때문에 알고 있음) 전체 파이버 트리를 DOM에 Commit합니다.
이를 commitRoot 함수에서 수행합니다. 여기에서 모든 노드를 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);
}
Reconciliation
지금까지는 DOM에 항목만 추가했지만 노드를 업데이트하거나 삭제하는 것은 어떨까요? 렌더링 함수에서 받은 요소를 DOM에 커밋한 마지막 fiber 트리와 비교하는 작업을 해야합니다. 따라서 커밋을 마친 후 "DOM에 커밋한 마지막 파이버 트리"에 대한 참조를 저장해야 합니다. 이를 currentRoot라고 합니다. 또한 모든 fiber에 대체 속성을 추가합니다. 이 속성은 이전 커밋 단계에서 DOM에 커밋한 이전 파이버에 대한 링크입니다. 이제 새로운 파이버를 생성하는 performUnitOfWork에서 코드를 추출해 보겠습니다.
우리는 이전 파이버(wipFiber.alternate)의 자식과 조정하려는 요소 배열을 동시에 순회합니다.
우리가 배열과 연결리스트를 동시에 순회하는데 필요한 모든 boilderplate를 무시하면 이 안에 가장 중요한 것이 남습니다.
oldFiber는 마지막으로 렌더링한 항목이고 element는 DOM에 렌더링하려는 항목입니다.
DOM에 적용해야 하는 변경 사항이 있는지 확인하기 위해 비교해야합니다.
- 유형이 동일한 경우 DOM 노드의 props만 업데이트하면 됩니다.
- 유형이 다르고 새 요소가 있으면 새 DOM 노드를 만들어야 함을 의미합니다.
- 유형이 다르고 오래된 파이버가 있는 경우 이전 노드를 제거해야합니다.
여기서 React는 더 나은 조정을 만드는 키도 사용합니다. 예를 들어 요소 배열에서 자식이 위치를 변경하는 경우를 감지합니다. 이전 파이버와 요소가 동일한 유형을 가질 때 이전 파이버의 DOM노드와 prop은 element로 부터 가져옵니다. 또한 fiber에 새로운 속성인 effectTag를 추가합니다. 나중에 커밋 단계에서 이 속성을 사용할 것 입니다.
그런 다음 요소에 새 DOM 노드가 필요한 경우 PLACEMENT 효과 태그로 새 fiber에 태그를 지정합니다. 그리고 노드를 삭제해야 하는 경우에는 새 fiber가 없기 때문에 이전 fiber에 effect 태그를 추가합니다.
그러나 fiber 트리를 DOM에 커밋할 때 이전 fiber가 없는 작업 중인 루트에서 수행합니다. 따라서 제거하려는 노드를 추적하려면 배열이 필요합니다.(deletions)
그런 다음 DOM에 대한 변경 사항을 커밋할 때 해당 배열의 파이버도 사용합니다. 이제 새 effectTags를 처리하도록 commitWork 함수를 변경해 보겠습니다. fiber에 PLACEMENT 효과 태그가 있는 경우 이전과 동일하게 DOM 노드를 부모 fiber노드에 추가합니다. DELETION이면 반대로 자식을 제거합니다. 그리고 UPDATE라면 변경된 props로 기존 DOM 노드를 업데이트해야 합니다. 이를 updateDom 함수에서 할 것입니다.
이전 fiber의 props와 새 fiber의 props를 비교하여 사라진 props를 제거하고 새롭거나 변경된 props를 설정합니다. 업데이트해야하는 특별한 종류의 prop은 이벤트 리스너이므로 prop이름이 "on" 접두사로 시작하면 다르게 처리합니다. 이벤트 핸들러가 변경된 경우 노드에서 제거합니다.