- Published on
Build your own React (Step 6) : 리액트는 컴포넌트를 어떻게 재조정 하는가?
- Authors
- Name
- sulmo
Build your own React (Step 6) AUSG 5기 스터디 FE-Deep-Dive 중 Build your own react를 참고해 작성된 포스트입니다.
먼저 스장님의 조언에 따라 리액트 문서를 먼저 읽고 Build your own react를 공부해보기로 합니다.
리액트 공식문서의 내용을 아주 간단히 정리하면,
리액트는 컴포넌트의 상태나 props가 변한다면 render함수를 통해 변화한 부분을 다시 렌더링합니다.
이때 리액트는 실제 돔트리와 가상 돔트리를 비교해 다른 부분을 수정하는 방식인데 이때 두 트리의 비교연산에대해 알아봅니다.
일반적인 유명한 비교 알고리즘으로는 O(n^3)의 복잡도를 보이는데, 리액트에서 아래 두가지 가정을 기반으로 O(n)의 휴리스틱한 알고리즘을 구현했습니다. 포괄적이고 일반적인 비교가아닌 돔트리를 비교하기 때문에 그에 맞춘 비교 알고리즘을 만드는 것 같습니다.
서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다. 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.
비교 알고리즘 (Diffing Algorithm)
두개의 트리를 비교할 때, React는 두 엘리먼트의 루트 엘리먼트 부터 비교하고 이후 동작은 루트 엘리먼트의 타입에 따라 다릅니다.
- 두 트리의 루트 엘리먼트를 비교한다.
- 루트 엘리먼트의 타입이 다를 시 기존의 트리를 모두 버리고 새로 돔트리를 구성한다. (모든 요소를 Unmount시키고 새로 다 mount한다.)
- DOM 노드의 비교와 컴포넌트의 비교
- DOM노드의 비교
- Dom 엘리먼트의 타입이 같은 경우 속성을 비교하는데 속성은 다른 부분만 새로 갱신한다.
- style이 갱신될 때는, 스타일 속성 중에서도 변경된 부분만 갱신한다.
- 이후 해당 DOM노드의 처리가 끝나면 해당 노드의 자식들을 재귀적으로 처리합니다.
- 컴포넌트의 비교
- 같은 타입의 컴포넌트가 갱신되면, 인스턴스는 동일하게 유지되고 state가 유지됩니다. 업데이트된 새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신합니다.
- DOM노드의 비교
자식에 대한 재귀적 처리
React는 기본적으로 동시에 두 리스트를 순회하고 차이점이 있으면 변경을 생성합니다.
그런데 변경을 감지하는 과정중에 리스트에 마지막에 추가시 문제가 없으나 첫번째 위치로 추가시 모든 리스트가 변경되었다고 인식합니다.
그래서 key props를 지정해주면 위치에 상관없이 key값을 통해 비교함으로 형제 돔노드 범위에서 구분되도록 키값을 지정해주면 좀더 빠른 비교에 도움이 됩니다.
Step 6: Reconciliation
지금까지는 DOM에 추가하는 방식만 생각해왔습니다. 이제 돔트리에 수정과 삭제에대해 봅시다.
이전까지 작업을 생각해보면 다음 작업단위가 없는 상태가 될때 즉, 일단락 완성된 파이버 트리를 commitRoot함수를 통해 돔에 그렸습니다.
이렇게 마지막 파이버 트리와 새로운 변경사항이 생긴 render를 통해 수신한 요소들을 비교해야 합니다.
function commitRoot() {
commitWork(wipRoot.child)
currentRoot = wipRoot // 최신 파이버트리를 실제 Dom트리에 커밋 완료 후 따로 저장
wipRoot = null
}
function render() {
// ...
}
let currentRoot = null
그래서 우리는 currentRoot라는 변수를 추가하고, 가장 최근에 추가된 파이버트리를 저장합니다.
function render(element, container) {
wipRoot = {
dom: container,
props: {
children: [element],
},
alternate: currentRoot, // 이전 커밋단계의 파이버 트리와 연결을 위함
}
nextUnitOfWork = wipRoot
}
그리고 alternate 속성을 모든 파이버의 속성으로 추가합니다. 이전 커밋단계의 파이버 트리와의 연결을 위한 속성입니다.
다음으로 performUnitOfWork함수에서 새로운 파이버를 생성하는 코드들만 reconcileChildren함수로 추출합니다.
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
// TODO compare oldFiber to element
```
이전 파이버와 파이버의 자식요소를 새로온 엘리먼트와 같이 재조정 하게됩니다.
```js
const sameType =
oldFiber &&
element &&
element.type == oldFiber.type
if (sameType) {
// TODO update the node
}
if (element && !sameType) {
// TODO add this node
}
if (oldFiber && !sameType) {
// TODO delete the oldFiber's node
}
이전 파이버트리와 새로변경 또는 추가될 엘리먼트를 유형을 나눠 비교합니다.
- 이전 파이버와 새 요소의 유형이 같으면 DOM 노드를 유지하고 새 props로 업데이트
- 유형이 다르고 새 요소가 있으면 새 DOM 노드를 추가
- 유형이 다르고 이전 광섬유가 있는 경우 이전 노드를 제거
(여기서 React는 위에서 봤던 key props를 활용해 리액트는 더 빠른 비교를 합니다.)
if (element && !sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
alternate: null,
effectTag: 'PLACEMENT',
}
}
타입이 다르고 새로운 요소가 필요한경우, PLACEMENT유형으로 추가하고 새로운 파이버를 생성합니다.
function reconcileChildren() {
// ...
if (oldFiber && !sameType) {
oldFiber.effectTag = 'DELETION'
deletions.push(oldFiber)
}
}
function render(element, container) {
//...
deletions = []
}
let deletions = null
이전 파이버가 존재하고 새로운 유형에 없다면, DELETION 유형으로 이전 파이버에 추가하고 deletions라는 리스트에 해당 파이버를 추가합니다.
deletions라는 배열에 추가했기 때문에 추적을 할수 있도록 전역변수 생성과 렌더시 초기화 되도록 작성합니다.
function commitRoot() {
deletions.forEach(commitWork)
// ...
}
그리고 위와 같이 커밋시 한번에 지워주도록 합니다.
if (sameType) {
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
alternate: oldFiber,
effectTag: 'UPDATE',
}
}
이전 파이버와 타입이 같을 때, 일단 UPDATE 속성을 추가하고, 이전의 DOM노드와 타입을 유지하고, 비교한 엘리먼트의 props를 넣어 새로운 파이버를 생성합니다.
function commitWork(fiber) {
// ...
if (fiber.effectTag === 'PLACEMENT' && fiber.dom != null) {
domParent.appendChild(fiber.dom)
} else if (fiber.effectTag === 'UPDATE' && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props)
} else if (fiber.effectTag === 'DELETION') {
domParent.removeChild(fiber.dom)
}
// ...
}
이와같이 commit시 추가와 삭제는 appendChild, removeChild함수를 사용하여 처리합니다.
UPDATE는 삭제나 추가처럼 으로 간단하게 끝나고 직접 작성해 줘야합니다.
updateDom함수를 작성합니다.
// updateDom에서 사용할 메소드
const isEvent = (key) => key.startsWith('on') // 이벤트 리스너 분리
const isProperty = (key) => key !== 'children' && !isEvent(key)
const isNew = (prev, next) => (key) => prev[key] !== next[key]
const isGone = (prev, next) => (key) => !(key in next)
// 추가와 삭제는 appendChild, removeChild 함수를 쓰면 되지만 업데이트는 프롭스를 변경시켜줘야 한다.
function updateDom(dom, prevProps, nextProps) {
// 안쓰거나 수정된 이벤트 리스너 제거
Object.keys(prevProps)
.filter(isEvent)
.filter((key) => !(key in nextProps) || isNew(prevProps, nextProps)(key))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2)
dom.removeEventListener(eventType, prevProps[name])
})
// 필요 없어진 props 제거
Object.keys(prevProps)
.filter(isProperty)
.filter(isGone(prevProps, nextProps))
.forEach((name) => {
dom[name] = ''
})
// 새로 생성되거나 수정된 props 적용
Object.keys(nextProps)
.filter(isProperty)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
dom[name] = nextProps[name]
})
// 이벤트 리스너 추가 로직
Object.keys(nextProps)
.filter(isEvent)
.filter(isNew(prevProps, nextProps))
.forEach((name) => {
const eventType = name.toLowerCase().substring(2)
dom.addEventListener(eventType, nextProps[name])
})
}
- 이전 파이버의 props와 새 파이버의 props를 비교해 사라진 props는 제거하고 변경되거나 추가된 props를 추가
- “on”이라는 접두사가 붙은 prop은 이벤트 리스너생성 또는 제거로 분리
- 분리된 이벤트 리스너 prop에대해 - 변경시 : 이벤트 리스너 제거 => 변경된걸로 추가
- 새로 생성시 : 추가
위의 기능들을 구현하면 재조정에대한 기능 작성 부분들이 완료됩니다.