먼저 기존 reconciler와 fiber reconciler를 비교하여 보여주는 페이지가 있기에 공유해봅니다.
https://claudiopro.github.io/react-fiber-vs-stack-demo/
Fiber vs Stack Demo
This demo shows the differences between Stack and Fiber by rendering a Sierpinski triangle that constantly shrinks and grows, and whose nodes have a value that increments by one every second.
claudiopro.github.io
위 예시에서, stack reconciler(기존 reconciler)는 왜 뚝 뚝 끊기는 현상이 있었을까요?
먼저 업데이트를 분석해봅시다.
1. 좌우로 넓어지고 좁아지는 모션
2. 모든 동그라미 인스턴스 숫자 업데이트
해당 애니메이션들이 자연스럽게 보이기 위해서는, 적어도 60fps(16ms)에 맞추어 업데이트가 일어나야합니다.
위 그래프를 보시다시피, 기존 reconciler의 경우, 이전 업데이트들에 의해 처리해야할 업데이트들이 트랩당하는 모습을 볼 수 있습니다.
위 상황의 경우, 콜스택이 쌓이고, 전부 끝나기 전까지 계속 다른 DOM 이벤트 및 업데이트들이 미뤄지는 상황인데, 비동기적으로, 업데이트
진행 도중이어도, 너무 길어진다 싶다면 다른 DOM 이벤트 혹은 태스크를 메인스레드에서 처리하게 한 후, 다시 이어서 처리할 수 있게 하면 좋을듯합니다.
아래처럼 말이죠.
Phases
비동기적으로 업데이트를 처리해야 메인스레드의 병목을 막을 수 있다는것은 알겠으나, 그럴 경우 DOM 데이터가 일관되지 않을 수 있습니다.
데이터 값을 제곱하는 업데이트를 하는 도중 잠시 업데이트를 중단한다면 위와 같은 상황이 발생하겠죠?
이를 막기 위해 업데이트를 2가지 페이즈로 나눕니다.
Phase 1 : Render / Reconcilation
-> work-in-progress 트리를 형성함
-> 이펙트 리스트를 형성함
-> 인터럽트 가능 (비동기)
Phase 2 : Commit
-> DOM에 변화를 반영함
-> 인터럽트 불가 (동기)
Scheduling & Phase 1
스케줄링 과정을 설명 전, 짤막하게 Work loop 와 Fiber 자료구조에 대해 짚고 넘어가겠습니다.
Work Loop
- 다음 태스크(fiber)를 추적함
- react 업데이트가 메인스레드를 점유한 시간을 추적하는 타이머를 가짐
Fiber
- 컴포넌트 인스턴스와 1:1 관계를 갖음
- child( 컴포넌트 인스턴스의 첫번째 자식의 fiber), sibling (후 인접 형제 컴포넌트의 fiber) , return (부모 컴포넌트의 파이버) 프로퍼티를 통해, fiber 트리를 형성함
- 태스크의 단위
import { useState } from 'react';
const List = () => {
const [itemValues, setItemValues] = useState([1, 2, 3]);
function onClick(){
setItemValues(current => {
return current.map((val) => val ** 2)
})
}
return (
<>
<button>square</button>
{itemValues.map((itemValue) => (
<Item value={itemValue} />
))}
</>
);
};
const Item = ({ value }) => {
return <div>{value}</div>;
};
//// 다른 파일...
const InvokeLayoutCalcButton = () => {
return <button onClick={() => // 폰트 굵기 증가}/>
}
위와 같은 코드가 있고, squre 버튼과, invokeLayoutCalcButton 버튼 둘다 눌렀다고 가정해보겠습니다. (편의상 invokeLayoutCalc는 시뮬레이션에서 제외했습니다.)
먼저 아래와 같이 기존 fiber 트리가 존재합니다.
button을 예시로 든다면, button은 List와 return 관계, child관계는 없고, Item과 sibling관계입니다.
Work loop에서 현재 잔존 시간을 확인해 보니 13ms 남았습니다.
처리해야할 fiber는 root군요.
root엔 변화가 없으므로 current 트리 root를 재사용합니다
Root fiber의 자식 fiber, 없다면 sibling 순으로 순회합시다.
root fiber는 list를 자식 fiber로 가지므로, 다음 태스크는 List fiber입니다.
시간도 11ms나 남았네요, 바로 진행해도 될것같습니다.
업데이트 큐에 버튼 클릭에 따른 'setState' 가 대기하고 있습니다.
List 컴포넌트는 변경점이 있는 컴포넌트이기에 마킹을 해둡니다.
자식 컴포넌트인 button은 변경점이 없으므로, current 트리의 button fiber를 참조합니다. ( 원래대로라면 List fiber와 분리된 과정입니다! 내용이 얼마 없어서 편의상 List와 같이 서술햇습니다)
1번째 Item의 경우, 원래 props 값이 1 -> 1으로 변경이 없습니다. 따라서 current 트리의 fiber를 그대로 참조합니다만,
2번째 Item의 경우, props값이 2-> 4로 변경이 있습니다. 따라서 변경됬다는 태그를 설정한 후, 새로운 파이버 노드를 생성합니다.
마지막 Item fiber를 처리하려보 보니, react 태스크에 할당된 시간이 지났으므로, 다른 네이티브 태스크에 메인스레드를 이양합니다.
이벤트 루프가 idle 상태가 될 때, 다시 메인스레드에 react 태스크를 할당하기 위하여 requestIdleCallback 함수를 실행합니다.
이 시점에 아까 시뮬레이션에 초기에 대기하고 있던 폰트 굵기 증가 (레이아웃 재계산)와 같은 DOM 이벤트들을 메인스레드가 처리합니다.
마지막 Item fiber 태스크가 끝난 후, 최종 이펙트 리스트 형성을 위해, 각 fiber의 변경점들(이펙트 리스트)을 부모 fiber의 이펙트 리스트에 병합합니다.
최종적인 이펙트 리스트는 아래와 같은 꼴이겠네요
"" [ div , item , div , item , List] ""
Phase 2
해당 페이즈는 앞서 설명하였듯, 동기적으로 진행합니다.
- effect list를 순회하며 본격적으로 DOM에 변화를 반영합니다.
- 이제 work-in-progress트리가 더 최신 버전이므로, current 포인터를 work-in-progress 트리를 가르키도록 할당합니다.
- 모든 변화를 반영한 후, 다시한번 effect list를 재 순회하며 ComponentDidUpdate 실행합니다. 이 시점에 마냥 해당 컴포넌트가 ref를 사용 중이었다면, ref 값을 재할당합니다.
Priority
스케줄링 및 fiber 자료구조를 활용한 비동기성을 통해, 커밋 하나에 의해 발생하는 병목은 막을 수 있으나, 커밋 때문에 다른 커밋이 병목되는것을 막을 순 없습니다.
따라서 각 태스크 별로 우선순위를 매겨 처리합니다.
다음과 같이 구분합니다.
- Synchronous: 즉시 실행
- Task : 다음 이벤트루프 태스크로써 실행
- animation: 다음 프레임 전까지 실행
- High
- low : data fetching etc...
- offscreen
high, low , offscreen은 requestIdleCallback에 의해 처리되도록 합니다.
한가지 특이점은, react update 도중, 고순위 작업이 들어올 시, 기존 저순위 작업은 폐기하고 고순위 작업을 마친 후, 저순위 작업을 처음부터 다시 시작합니다.
'React' 카테고리의 다른 글
[React] Reconciliation - (feat, Diffing 알고리즘) (0) | 2023.06.26 |
---|---|
React Element vs Component vs Component Instance vs React Node (0) | 2023.06.25 |
[React] Pie Chart 만들기 (0) | 2022.12.04 |
[React] Bar chart 만들기 (0) | 2022.12.03 |