지난 번에는 requestIdleCallback()
을 이용한 메인 스레드가 비어있을 때 이벤트루프가 유휴 작업을 가져다가 실행할 수 있는 기능을 구현했다.
그리고 fiber의 개념에 대해서 익혔고 재조정을 하기위해 fiber tree의 형태로 우리의 컴포넌트를 변환하는 코드를 작성했다.
이번에는 렌더링 과정에서 브라우저 엔진이 끼어들 수 있는 상황을 처리하고 재조정 알고리즘을 구현해본다.
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber)
}
// if (fiber.parent) {
// fiber.parent.dom.appendChild(fiber.dom);
// }
...
먼저 주석처리된 부분을 제거한다. 이 부분은 DOM을 변형시키기 때문이다.
function render(element, container) {
wipRoot = { // 1
dom: container,
props: {
children: [element],
},
};
nextUnitOfWork = wipRoot; // 2
}
// 수행해야할 작업
let nextUnitOfWork = null;
let wipRoot = null; // 3
1, 2, 3부분을 추가해 줍니다. wipRoot는 fiber tree의 root를 추적하며 work in progress의 약자이다.
function workLoop(deadline) {
...
if (!nextUnitOfWork && wipRoot) {
commitRoot();
}
...
}
그리고 workLoop()
의 반복문이 끝나는 부분에 아래 코드를 추가해 준다. 이 코드는 더이상 작업이 없다면 전체 fiber tree를 DOM에 커밋하기 위한 코드이다. 커밋 과정은 commitRoot()
에서 이루어 진다.
다음으로 재조정 단계를 구현한다. 구현하기에 앞서 재조정 알고리즘이 어떤 역할을 하는 지 알아보자.
render()
함수는 React 엘리먼트 트리를 만드는 것이다. 라고 생각이 드는 순간이 있을 것입니다. state나 props가 갱신되면 render() 함수는 새로운 React 엘리먼트 트리를 반환할 것입니다. 이때 React는 방금 만들어진 트리에 맞게 가장 효과적으로 UI를 갱신하는 방법을 알아낼 필요가 있습니다.
- React 공식 문서
React를 이용해 앱을 만들게 되면 상태의 변경이 일어난 부분만 재 렌더링이 일어나는 것을 확인할 수 있다. 이는 보이지 않는 곳에서 "비교(diffing)"알고리즘이 두 DOM사이의 차이점을 찾아내 변경된 부분만 재 렌더링을 수행하게 하는 것임을 알 수 있다.
비교 알고리즘은 두 가지 경우로 나누어 동작하는 데
엘리먼트의 타입이 다른 경우
이전 트리를 버리고 새로운 트리를 구축한다. 그 아래 컴포넌트들은 모두 unmount되고 관련된 state는 전부 사라진다. 컴포넌트 생명주기의 componentWillUnmount()
와 componentDidMount()
의 동작이 일어난다.
DOM 엘리먼트의 타입이 같은 경우
인스턴스는 그대로 유지되고 state도 유지된다. 다만 새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신한다. 여기서는 componentWillUpdate()
와 componentDidUpdate()
의 동작이 일어난다.
이 항목에서 우리가 map을 사용한 컴포넌트 배열을 만들 때 key값을 넣는 이유를 확인할 수 있었다. 다만 글이 너무 길어질 것 같아서 여기서는 생략하였다.
그리고 DOM 노드의 자식들을 재귀적으로 처리할 때, React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성한다.
이제 재조정에 대해 간단하게 알아봤으니 알고리즘을 구현해보자.
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)
}
이 코드는 요소를 삽입하는 과정이다.
간단히 parent.dom에 자식, 형제 fiber들을 재귀호출을 통해 appendChild()
하는 것이다.
이제 앞선 과정들에서 요소를 삽입하는 것을 구현했기 때문에 update, delete를 구현해야한다.
function commitRoot() {
...
currentRoot = wipRoot; // 1
}
...
// 다음 작업 단위를 설정
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot, // 2
};
nextUnitOfWork = wipRoot;
}
...
// 수행해야할 작업
let nextUnitOfWork = null;
// 오래된 Root, 비교를 위한 Root이다.
let currentRoot = null; // 3
이렇게 3개의 코드를 추가해 주었다.
function reconcileChildren(wipFiber, elements) {
let index = 0;
let prevSibling = null;
// 모든 children을 새로운 fiber로 생성하는 반복문
while (index < elements.length) {
const element = elements[index];
const newFiber = {
type: element.type,
props: element.props,
parent: wipFiber,
dom: null,
};
// 재조정을 위한 조건문 추가
if (index === 0) {
wipFiber.child = newFiber;
} else {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}
그리고 기존에 performUnitOfWork()
가 담당하던 children을 fiber로 생성하는 반복문을 reconcileChildren()
으로 분리하였다.
function reconcileChildren(wipFiber, elements) {
let index = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child; // 1
let prevSibling = null;
// 변경전 fiber의 자식들과 현재 fiber의 자식들을 동시에 순회한다. (재귀적으로 처리)
while (index < elements.length || oldFiber != null) { // 2
...
}
...
}
while (index < elements.length || oldFiber != null) {
const element = elements[index];
let newFiber = null;
// 변경전 fiber가 존재하고, 자식 fiber가 존재하고, 자식의 type과 변경전 fiber의 type이 일치한다면 true
const sameType = oldFiber && element && element.type == oldFiber.type;
if (sameType) {
// 변경 전 fiber.type과 현재 fiber.type이 일치하는 경우
}
if (element && !sameType) {
// 자식 fiber가 존재하지만 둘의 fiber.type이 일치하지 않는 경우
}
if (oldFiber && !sameType) {
// 변경 전 fiber가 존재하지만 둘의 fiber.type이 일치하지 않는 경우
}
...
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props, // element.props를 사용
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE', // update를 의미하는 태그(임의)
};
}
위 코드의 주석처럼 둘의 type이 일치하면 새로운 fiber는 prop만 update하게 된다. (2번)
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: 'PLACEMENT', // 신규 생성을 의미하는 태그(임의)
};
}
여기서 부턴 둘의 type이 다른 경우(1번)이다.
element의 type과 props를 따르고 신규 생성을 의미하는 임의의 태그를 붙인다.
// 삭제 node를 추적하기 위한 배열
let deletions = [];
...
if (oldFiber && !sameType) {
oldFiber.effectTag = 'DELETION'; // 임의의 태그
deletions.push(oldFiber);
}
노드의 삭제가 필요한 경우에는 새로운 생성이 필요없으므로 삭제를 의미하는 임의의 태그로 달아준다.
그리고 작업중인 wipRoot에는 변경 전 fiber가 존재하지 않으므로 삭제를 추적하기 위한 detections array를 추가한다.
function commitRoot() {
deletions.forEach(commitWork) // 추적된 node를 삭제
...
}
그리고 변경 사항을 commit할때 이 배열을 사용한다.
이 함수는 만들어진 fiber트리를 재귀적으로 순회하면서 부모 DOM에 등록, 수정, 삭제를 하게된다. 이전에 작성한 로직에서는 조건문이 없으므로 이전에 달아준 태그를 통해 조건을 확인하도록 수정해야한다.
function commitWork(fiber) {
...
const domParent = fiber.parent.dom;
if (fiber.effectTag === 'PLACEMENT' && fiber.dom != null) { // 1
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === 'UPDATE' && fiber.dom != null) { // 2
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === 'DELETION') { // 3
domParent.removeChild(fiber.dom);
}
// 4
commitWork(fiber.child);
commitWork(fiber.sibling);
}
updateDom()
을 통해 업데이트다음으로 update기능을 수행하기 위한 updateDom()
을 구현해야한다.
// 이벤트 props의 이름이 on으로 시작하는 것을 이용해 구분하는 함수
const isEvent = (key) => key.startsWith('on');
// props의 key값을 가져와 자식 props와 이벤트 props가 아닌 props만 구분하는 함수
const isProperty = (key) => key !== 'children' && !isEvent(key);
// 변경 전 props와 새로운 props를 인자로 받아 새로운 props만 구분하는 함수
const isNew = (prevProps, nextProps) => {
return (key) => prevProps[key] !== nextProps[key];
};
// 변경 전 props만 구분하는 함수
const isGone = (nextProps) => {
return (key) => !(key in nextProps);
};
function updateDom(dom, prevProps, nextProps) {
// 오래된 props를 제거하는 과정
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(nextProps))
.forEach((key) => {
dom[key] = '';
});
// 바뀌거나 새로운 props를 설정하는 과정
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach((key) => {
dom[key] = nextProps[key];
});
}
updateDom()
은 인자로 update할 dom, 오래된 props(prevProps), 변경 후 props(nextProps)를 받는다.
filter메소드의 callback함수를 정의하고 기준에 맞춰 props를 제거한 후 남는 props를 제거하고 새로 설정하는 과정의 로직이다.
그리고 update 과정에서 존재했던 이벤트를 삭제하고 새로운 이벤트를 추가해야한다.
...
// 오래되거나 변경된 이벤트를 제거
Object.keys(prevProps)
.filter(isEvent)
.filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach((key) => {
const eventType = key.toLowerCase().substring(2); // onClick => onclick => click
dom.removeEventListener(eventType, prevProps[key]);
});
// 오래된 props를 제거하는 과정
// 바뀌거나 새로운 props를 설정하는 과정
...
// 이벤트 리스너 추가
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((key) => {
const eventType = key.toLowerCase().substring(2);
dom.addEventListener(eventType, nextProps[key]);
});
}
이렇게 오래되고 낡은 이벤트 리스너마저도 재조정할 수 있게 되었다.
이번에는 React의 핵심 개념인 재조정에 대해서 공부할 수 있었다.
사실 React를 파고들수록 Create-React-App이 너무 사용하고 싶었다. React의 진입 장벽을 크게 낮춰준 것이 왜 이 라이브러리인지 알 수 있었던 공부였다.
useState()
써~가 아닌
이걸 설정해야 JS문법으로 바꿔줄 수 있고,
배포를 위한 설정을 직접 해볼 수 있었고,
생명 주기를 다시 익혀볼 수 있었다.
이제 마지막으로 함수형 컴포넌트 구현과 useState()
훅을 구현해보려 한다.
Reference
재조정(Reconciliation) - React 공식문서
React 톺아보기 - 05. Reconciler_1