티스토리 뷰

 이전에 알아본 React reconciliation (heuristic) 알고리즘은 재귀적으로 동작하므로 도중에 멈출수가 없다. 만약 알고리즘 실행 작업이 오래 걸린다면 화면이 끊기는 현상이 발생할것이다.

 페이스북에서 이러한 불합리적인 알고리즘을 개선하기위해 2년 넘게 연구한 결과 react v16부터 fiber알고리즘을 도입하였다.

 Recursion

간단한 재귀호출 함수이다.

function recursion(num: number): number {
  if (num > 10) return number;
  return recursion(num + 1);
}

recursion(0);

재귀호출함수가 실행 될 때 Call stack에 차례대로 쌓이게 된다.

reconciliation 알고리즘은 재귀 알고리즘으로, 어떤 노드가 업데이트 될 경우 (tag이름이 바뀌는 등) 하위 노드들은 재랜더링이 된다.

이러한 알고리즘은 제한사항이 있다.

  • UI에서는 모든 업데이트를 반영할 필요가 없다. 실제로 변경사항이 발생할 때마다 업데이트를 반영하는 것은 리소스 낭비이며 Frame drop이 발생하여 사용자 경험이 저하될 수 있다.
  • 업데이트 종류마다 우선순위가 다르다. 애니메이션 업데이트는 데이터 저장소 업데이트보다 빨리 완료되어야 한다.

Andrew Clack의 메모

https://github.com/acdlite/react-fiber-architecture

 

GitHub - acdlite/react-fiber-architecture: A description of React's new core algorithm, React Fiber

A description of React's new core algorithm, React Fiber - GitHub - acdlite/react-fiber-architecture: A description of React's new core algorithm, React Fiber

github.com

 

Frame : 연속적인 이미지가 화면에 나타나는 빈도

 컴퓨터 화면에 보이는 모든 것은 눈에 즉시 나타나는 속도로 화면에서 재생되는 이미지 또는 프레임으로 구성된다.

일반적으로 영상이 사람 눈에 자연스럽게 느껴지려면 최소 30FPS의 속도로 재생되어야 한다.

요즘 대부분의 장치는 60FPS로 화면을 재생한다. 즉 1/60 = 16.67ms로 새 프레임이 16ms마다 나타난다.

React renderer가 화면에 랜더링할 때 16ms이상 걸리게 되면 브라우저가 해당 프레임을 제거하므로 이 숫자를 지키는 것은 매우 중요하다. 만약 작업이 이를 초과한다면 프레임 속도가 떨어지고, 화면의 내용이 흔들리게 된다.

 

 내용이 정적이거나 텍스트 위주일 경우 큰 우려는 없지만, 애니메이션을 나타낸다면 이 숫자는 중요해질 수 있다.

React reconciliation 알고리즘이 업데이트가 있을 때 마다 전체 App트리를 순회하고, 다시 렌더링할 때 16ms이상 걸린다면 프레임이 삭제되어 사용자 경험에 문제가 생길것이다. 이러한 문제를 해결하기 위해 업데이트를 우선순위별로 분류하고, reconciler에 모든 업데이트를 무조건 전달하지 않는것이 좋다.

 

React는 이러한 요소들을 고려하여 Fiber라는 새로운 reconciliation 알고리즘을 만들었다.

 

Fiber

 위에서 알아본 React reconciliation 알고리즘의 문제점을 해결하기 위한 기능을 요약하자면 

  • 작업 별 우선순위 지정
  • 작업을 일시 중지하고 나중에 다시 시작
  • 더이상 필요하지 않은 경우 작업 중단
  • 이전에 완료된 작업 재사용

위의 기능에 대하여 구현이 어려운 점 중 하나는 Javascript 엔진이 동작하는 방식과 언어 자체에 thread가 부족하다는 점이다.

 

Javascript execution stack

  JS 엔진이 실행될 때 마다 global execution context가 생성된다.

예를 들어 브라우저는 window 객체이고, node.js는 global객체이다. 이러한 context는 execution stack이라고 하는 stack 구조로 처리된다.

function b() {
  // ...
}

function a() {
  // ...
  b();
}

a();

위의 코드로 예시를 들었을 때, javascript 엔진은 먼저 global execution context를 생성한다. 그리고 이를 Execution stack으로 push한다. 그 다음 a()함수에 대한 function execution context를 생성하여 push하고, b()함수에 대한 function execution context를 생성하여 push한다.

이때 브라우저가 http request와 같은 비동기 이벤트를 만들 때 JS 엔진은 execution stack 말고 event queue라는 또다른 자료구조를 이용한다. event queue는 브라우저로 들어오는 http 또는 네트워크 이벤트와 같은 비동기 호출을 처리한다.

JS엔진은 Execution stack이 비어있다면 queue의 작업을 처리합니다.

https://kmj24.tistory.com/128

 

Event Loop

Javascript는 run code, event collecting & processing, Queue의 하위 작업들을 담당하는 event loop에 기반한 동시성(concurrency)모델을 가지고 있다. Javascript Engine javascript 엔진은 javascript코드를..

kmj24.tistory.com

JS엔진은 Execution stack이 비어있거나, global execution context만 있을 때, 이벤트 큐를 확인한다. (event loop)

이벤트가 대기열에 도착하는 시점은 비동기지만, 실제로 처리될 때는 동기적으로 처리된다.

 

다시 stack reconciler로 돌아와서, React가 트리를 순회할 때마다 execution stack은 위와 같은 형태로 동작한다.

따라서 업데이트가 도착하면 event 대기열로 추가된다. 그리고, 실행 스택이 비워질 때만 업데이트가 처리되며, 이 형태가 바로 Fiber가 intelligent기능(일시중지, 재개, 중단)으로 스택을 다시 구현하여 해결하는 문제이다.

"Fiber는 React 컴포넌트에 특화된 stack의 재구현이다. 단일 fiber를 가상의 stack frame으로 생각할 수 있다.

stack을 다시 구현할 때 장점은 stack frame을 메모리에 유지하고 언제든지 원하는대로 실행할 수 있다. 이것은 우리의 목표를 달성하는데 중요하다.

스케줄링 외에도 stack frame을 수동으로 처리하면 동시성 및 error boundaries와 같은 잠재적인 문제가 발생할 수 있다." - Andrew Clark - 

간단히 말해 Fiber는 그 자체로 작업 단위의 가상 스택을 나타낸다 이전에 사용한 Heuristic 알고리즘에서 React는 immutable 트리를 재귀적으로 순회하도록 만들었다.

 

fiber의 구현에서 React는 mutable fiber node 트리를 생성한다. fiber노드는 컴포넌트의 state, props 그리고 기본적으로 나타낼 DOM요소를 효율적으로 가지고 있다.

fiber 노드는 변경될 수 있으므로, React는 업데이트를 위해 모든 노드를 다시 만들 필요가 없다. 업데이트가 있다면 간단히 노드를 복사하고 업데이트 할 수 있고, React는 fiber트리를 재귀적으로 순회하지 않는다. 대신 single linked list를 만들고 부모우선 깊이 순회를 진행한다. (parent-first, depth-first)

 

Singly linke list of fiber nodes

React 컴포넌트의 인스턴스를 나타낸다. 

fiber노드는 스택프레임 뿐만 아니라 React 컴포넌트의 인스턴스를 나타낸다.

 

fiber 노드를 구성하는 속성

Type

<div>, <span> 과 같은 JSX 속성을 반환하는 컴포넌트

Key

React에서 전달하는 key와 동일한 속성

Child

컴포넌트에서 render()를 호출할 때 반환되는 요소.

const Component = () => {
  return (
    <div>child</div>
  );
}
// 여기서는 div를 반환하므로 child는 div

Sibling

render가 반환하는 element목록

Return

부모 요소를 나타냄, 논리적으로 부모 fiber 노드인 스택 프레임에 반환을 의미

pendingProps and memoizedProps

pendingProps는 컴포넌트로 전달 된 props이고, memoizedProps는 노드의 props를 저장하고 실행 스택의 끝에서 초기화 된다.

Memoization은 주어진 입력값에 대한 결과를 저장함으로써 같은 입력값에 대하여,
함수가 한번만 실행하도록 보장한다. (dictionary와 같은 ds에 저장하는 형태로 구현할 수 있음)

PendingWorkPriority

fiber에서 숫자로 작업의 우선순위를 나타낸다. ReactPriorityLevel 모듈은 다양한 우선순위 레벨과 해당 숫자를 나열한다. 값이 0인 NoWork를 제외하고, 숫자가 클수록 우선순위가 낮다.

 

Alternate

컴포넌트 인스턴스는 최대 2개의 fiber를 가질 수 있다.

현재 fiber, 진행 중 fiber

현재 fiber와 진행 중 fiber는 서로 대체될 수 있다.

현재 fiber는 이미랜더링 된것이며, 진행 중인 fiber는 아직 반환되지 않은 스택프레임이다.

 

Output

React Application의 leaf노드이다.

leaf 노드 : 자식 노드가 없는 노드

렌더링 환경에 따라 노드는 달라질 수 있다. 브라우저에서는 div, span 등,

fiber 출력물은 함수의 반환값이다. 모든 fiber는 출력물을 가지기는 하지만, 실제 출력물은 host컴포넌트의 leaf노드에서만 생성된다. 그 다음 출력물은 트리의 위로 전달된다.

 

최종적으로 출력은 React renderer가 렌더링 환경에서 변경 사항을 flush할 수 있도록 renderer에 전달된다.

const Parent1 = () => {
  return [<div>child1</div>, <div>child2</div>];
};
const Parent2 = () => {
  return <div>child3</div>;
};

class App extends React.Component {
  constructor(props: any) {
    super(props);
  }
  
  render() {
    return (
      <div>
        <Parent1 />
        <Parent2 />
      </div>
    );
  }
}

위의 코드는 아래와 같이 동작한다.

fiber 트리는 자식 요소들 간 단순 연결 리스트와 부모-자식 관계의 연결리스트로 이루어져 있다.

트리는 DFS로 순회한다.

 

Render phase

'use strict'

let React;
let ReactDom;

describe('ReactUnderStanding', () => {
    beforeEach(() => {
        React = require('react');
        ReactDOM = require('react-dom');
    });
    
    it('works', () => {
        let instance;
        
        class App extends React.Component {
            constructor(props) {
                super(props);
                this.state = {
                    text: 'hello'
                }
            }
            
            handleClick = () => {
                this.props.logger('before-setState', this.state.text);
                this.setState({text: 'hi'});
                this.props.logger('after-setState', this.state.text);                
            }
            
            render() {
                instance = this;
                this.props.logger('render', this.state.text);
                if (this.state.text === 'hello') {
                    return (
                        <div>
                            <div>
                                <button onClick={this.handleClick.bind(this)} >
                                    {this.state.text}
                                </button>
                            </div>
                        </div>
                    )
                } else {
                    return (
                        <div>
                            hello
                        </div>
                    )
                }
            }
        }
        
        const container = document.createElement('div');
        const logger = jest.fn();
        ReactDOM.render(<App logger={logger}/>, container);
        console.log('clicking');
        instance.handleClick();
        console.log('clicked');
        
        expect(container.innerHTML).toBe(
            '<div>Hello</div>'
        )
        
        expect(logger.mock.calls).toEqual(
            [
                ["render", "hello"],
                ["before-setState", "hello"],
                ["render", "hi"],
                ["after-setState", "hi"],
            ]
        )
    })
})

 

위의 코드를 React로 테스트를 돌려본다고 해보자.

React는 먼저 초기 렌더링 되는 현재 트리를 생성한다.

createFiberFromTypeAndProps()는 React element로 부터 data를 받아와 React fiber를 생성한다.

 콜스택은 다시 render()를 호출하며 createFiberFromTypeAndProps()로 들어간다.

여기서 workLoopSync(), performUnitOfWork(), beginWork() 함수를 주위깊게 살펴보자.

 

workLoopSync()

// The work loop is an extremely hot path. Tell Closure not to inline it.
/** @noinline */
function workLoopSync() {
  // 이미 시간이 초과되었다면, yield 확인 없이 바로 실행한다.
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

workLoopSync()는 React가 <App> 노드부터 시작해서 자식 노드인 <div> -> <button> 재귀적으로 이동하며 트리를 구축하는 부분입니다. workInProgress는 수행할 다음 노드가 있다면 다음 fiber 노드를 참조한다.

 

performUnitOfWork(), beginWork()

function performUnitOfWork(unitOfWork: Fiber): void {
  // The current, flushed, state of this fiber is the alternate. Ideally
  // nothing should rely on this, but relying on it here means that we don't
  // need an additional field on the work in progress.
  const current = unitOfWork.alternate;
  setCurrentDebugFiberInDEV(unitOfWork);

  let next;
  if (enableProfilerTimer && (unitOfWork.mode & ProfileMode) !== NoMode) {
    startProfilerTimer(unitOfWork);
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
    stopProfilerTimerIfRunningAndRecordDelta(unitOfWork, true);
  } else {
    next = beginWork(current, unitOfWork, subtreeRenderLanes);
  }

  resetCurrentDebugFiberInDEV();
  unitOfWork.memoizedProps = unitOfWork.pendingProps;
  if (next === null) {
    // If this doesn't spawn new work, complete the current work.
    completeUnitOfWork(unitOfWork);
  } else {
    workInProgress = next;
  }

  ReactCurrentOwner.current = null;
}

 

performUnitOfWork()는 fiber 노드를 인수로 받으며 노드의 대체를 가져온 후 beginWork()를 호출한다.

실행 스택에서 실행 컨텍스트를 실행하는 것과 같다.

React가 트리를 빌드할 때, beginWork()는 createFiberFromTypeAndProps()로 이어지고 fiber노드를 생성한다. React는 재귀적으로 이 작업을 수행한 후 performUnitOfWork()는 null을 반환하여 트리의 끝에 도달했음을 전달한다.

 

이제 버튼을 클릭하고 상태를 업데이트 하는 handleClick() 함수를 실행하면 React는 fiber 트리를 순회하며, 각 노드를 복제하고, 노드에 수행할 작업이 있는지 확인한다. 

이때의 call stack은 다음과 같다.

첫번째 call stack 사진에서 보지 못한 completeWork()completeUnitOfWork()는 performUnitOfWork()나 beginWork()와 같이 현재 실행의 완료 부분을 수행하며, 효율적으로 스택에 다시 돌아가는 것을 의미한다.

 

다음의 네가지 함수는 실행하는 작업을 수행하고, 현재 수행중인 작업을 작업단위로 제어할 수 있다.

fiber에서는 해당 기본 작업을 수행하는데 4단계가 있다.

여기서 각 노드는 completeWork() 가 자식요소를 전부 반환할 때 까지 completeUnitOfWork()로 이동하지 않는다.

예를 들어 <App />은 performUnitOfWork() 및 beginWork()로 시작한 다음 Parent1의 performUnitOfWork()와 beginWork()로 이동한다. <App />의 모든 자식 컴포넌트를 실행 후 완료한다.

이는 React가 렌더링 단계를 완료하는 순간이다 click() 업데이트를 기반으로 새로 구축된 트리를 workingInProgress트리라고 한다.

기본적으로 렌더링 대기중인 초안 트리이다.

 

Commit Phase

rendering단계가 완료되면, React는 커밋 단계로 이동하여 현재 트리와 workingInProgress트리의 루트 포인터를 교체하여 효과적으로 업데이트가 반영된 트리로 교체한다.

뿐만 아니라 React는 포인터를 Root에서 workingInProgress 트리로 변경한 이후 이전 트리의 노드들을 재사용한다.

이러한 최적화된 프로세스의 가장 큰 효과는 앱의 이전 상태와 다음 상태, 또 다음 상태의 전환이 원활하게 전환되는 것 이다.

 

React는 각 수행중인 작업 단위에 대하여 내부 타이머를 두고 시간 제한을 모니터링 한다. 시간이 다 되면 React는 현재 진행중인 작업 단위를 일시 중지하고 컨트롤을 다시 메인 스레드에 다시 넘기며 그 시점에 완료된 트리를 렌더링 한다.

이후 다음 프레임에서 React는 중단된 부분을 선택 후 트리를 계속 구축한다.

시간내 완료되면 workInProgress 트리를 커밋하고 렌더링을 완료한다.

 

 

아래의 글을 보고 학습하였습니다. :)

https://bumkeyy.gitbook.io/bumkeyy-code/frontend/a-deep-dive-into-react-fiber-internals

 

[번역] A deep dive into React Fiber internals - Bumkeyy Code

다시 stack reconciler로 돌아와서, React가 트리를 순회할 때마다 실행 스택은 이렇게 동작합니다. 따라서 업데이트가 도착하면 이벤트 대기열로 추가됩니다. 그리고 실행 스택이 비워질때만 업데이

bumkeyy.gitbook.io

 

'front-end > react' 카테고리의 다른 글

React useState, useEffect Mechanism(with. closure)  (0) 2023.05.03
Redux 구현  (0) 2022.02.16
React Diffing algorithm1 - Heuristics algorithm  (0) 2021.12.13
Next.js 맛보기  (0) 2021.08.02
[React-Redux] ducks pattern  (0) 2021.07.20
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
글 보관함