본문 바로가기

dev-log

렌더링 렌더링 그놈의 렌더링

렌더링은 리액트에서 컴포넌트를 이루고 있는 요소가 무엇인지 계산하는 과정이다. '브라우저 렌더링' 혹은 'UI 렌더링'의 렌더링은 리액트에서 '페인팅' 이라는 용어로 사용하기 때문에 용어의 혼동에 주의해야 한다. 리액트에서는 다음과 같은 순서를 거쳐 화면에 UI를 보여준다.

 

Triggering - Trigger Render(Queue Render)

렌더링 과정을 트리거 하는 단계로, 그 주체는 앱을 초기화하는 단계인지 초기화된 이후의 단계인지에 따라 나뉜다. 전자의 경우 호스트 라이브러리(ReactDOM, ReactNative)에서 최초 렌더링을 트리거하며 후자의 경우 다음의 시점에서 렌더링을 트리거 한다.

1. useState의 setter를 호출한 시점

2. useReducer의 dispatch를 호출한 시점

3. useSyncExternalStore(이하 uSES)에서 subscribe한 store의 변화가 있을 때 getSnapshot()의 리턴값이 바뀐 시점

 

❁ uSES의 getSnapshot()의 리턴값 비교는 Referential Equality(===) 연산으로 이루어짐

 

Rendering

앞서 설명한대로 리액트 모듈이 특정 시점에서 props와 state를 기반으로 컴포넌트를 이루고 있는 요소가 무엇인지 계산하는 과정이다. 처음에 무엇을 보면서 계산을 하게되는 것일까? 바로 JSX이다. 쉽게 찾을 수 있는 root.render(<App />)나 쉽게 찾을 수 없는 시발점에서 호스트 라이브러리는 JSX를 보고 전체 UI의 구성요소를 계산하기 시작한다. JSX는 _jsx(createElement)를 이쁘게 사용할 수 있는 문법적 설탕이며, 리액트 모듈이 보고싶어하는 것은 이 함수의 리턴값인 JSX.Element이다.

ReactDOM
  .createRoot(document.getElementById('root')!)
  .render(<App />);
  
// <App />은 다음과 같이 컴파일된다.
React.createElement(App)

// 그리고 이 이 함수는 다음의 JSX.element를 반환한다.
const AppReactElement =  {
  $$typeof: Symbol.for('react.element'),
  type: App,
  props: {}
}

 

리액트는 트리의 특정 위치에서 반환된 JSX.Element에 관한 정보를 수집하며, 엘리먼트의 type이 호스트 컴포넌트가 아니라 컴포넌트인 경우 그 컴포넌트를 구성하는 UI에 관한 정보를 수집하기 위하여 type이 가리키는 컴포넌트를 호출한다. 함수형 컴포넌트의 경우 FunctionComponent({...props, children})을, 클래스형 컴포넌트의 경우 ClassComponentInstance.render()을 호출한다.

재귀적인 호출이 끝마쳐지는 타이밍은 트리의 모든 요소가 호스트 컴포넌트로 구성될 때이다.

 

Standard rendering behaviors
When a parent component renders, React will recursively render all child components inside of it. [...] Most of the components in the tree will return the exact same render output as last time, and therefore React won't need to make any changes to the DOM. However, React will still have to do the work of asking components to render themselves and diffing the render output.

출처: https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/#standard-render-behavior

 

UI가 화면에 그려지고 렌더링 과정이 다시 트리거될 때(리렌더링) 리액트 모듈은 최상단에 위치한 Fiber에서부터 렌더링 flags가 트리거된 Fiber으로 빠르게 내려간다. Fiber와 대응되는 타입이 컴포넌트라면 위에서 말하는 과정을 진행하여 지금 시점의 JSX.element에 관한 정보를 수집한다.

 

리액트는 이전 시점의 트리와 대응되는(alternate) 지금 시점의 트리를 비교하고(diffing) 호스트에 반영이 필요한 것들을 계산하는데 이 작업을 Reconcilation이라고 한다. 

 

❁ 컴포넌트 A의 내부에서 다른 컴포넌트 B를 리턴할 때 A와 B의 관계를 중첩관계라 부르며 A를 부모 컴포넌트, B를 자식 컴포넌트라고 부른다. 부모 컴포넌트가 리렌더링이 될 때 자식 컴포넌트들은 모두 같이 리렌더링이 된다. 왜냐하면 리턴되는 JSX.Element의 참조값이 다르기 때문이다. 리액트 모듈은 자식 컴포넌트의 리렌더링 여부를 Reference equality check(===)으로 판단한다. 

 

If a React component returns exact same element reference in its render output as it did the last time, React will skip re-rendering that child. Skipping rendering a component means React will also skip rendering that entire subtree, because it's effectively putting a stop sign up to halt the default "render children recursively" behavior.

출처: https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/#component-render-optimization-techniques

 

function Paragraph() {
  return <p>This is paragraph</p>;
}

type ParentProps = {
  Paragraph: ReactNode;
};
function Parent({ Paragraph }: ParentProps) {
  const [state, setState] = useState('');
  return (
    <div>
      <p>Parent</p>
      <input value={state} onChange={(e) => setState(e.target.value)} />
      {Paragraph}
    </div>
  );
}

function GrandParent() {
  return <Parent Paragraph={<Paragraph />} />;
}

function App() {
  return <GrandParent />;
}

Parent가 리렌더링이 될 때마다 Paragraph에 바인딩되는 JSX.Element의 참조값은 이전 시점이나 지금 시점이나 똑같다. 따라서 Paragraph는 리렌더링되지 않는다. 동일한 논리로 props.children을 사용하면 다음과 같은 이쁜 패턴이 만들어지게 된다.

function Providers({children}: {children: ReactNode}) {
  return (
    <AProvider>
      <BProvider>
        <CProvider>
          <DProvider>
            {children}
          </DProvider>
        </CProvider>
      </BProvider>
    </AProvider>
  )
}

function App() {
  return (
    <Providers>
      <Router />
    </Providers>
  );
}

 

 

❁❁ Reconcilation에서 같은 위치에 있는 이전시점의 엘리먼트 A와 지금시점의 엘리먼트 B를 비교할 때 몇가지 기준이 있다. 먼저 두 엘리먼트의 타입을 Reference equality check(===)으로 비교한다(A.type === B.type). 연산의 결과가 false라면 리액트는 B에 대해서 더이상 비교하지 않고 B의 위치에 있는 subtree를 새로운 것으로 간주하고 B 이하의 모든 트리에 대해 비교 연산을 실시하지 않는다. 이후 Commit Phase에서는 기존의 호스트 노드를 전부 파괴한 다음 새로운 호스트 노드를 만드는 작업을 하게된다. 따라서 컴포넌트 안에 컴포넌트를 만드는 미친짓은 하지 말아야 한다. 퍼포먼스 측면으로 굉장히 좋지 않기 때문이다.

 

Commit - React commit changes to the host

이 단계는 동기적으로 이루어지며 Reconcilation의 결과를 호스트에 반영하는 로직을 담고 있다. 처음으로 렌더링 과정이 트리거가 되었을 때에는 호스트에 부착하는 작업을, 이후에 렌더링 과정이 트리거가 되었을 때에는 호스트 노드를 조정하는 작업을 맡는다.

 

❁ Commit Phase부터는 모든 것들이 전부 동기적으로 실행된다. 실제로 페인팅된 결과가 보여주기 전까지 모종의 이유로 다시 한 번 특정 컴포넌트에서 렌더링이 트리거가 될 때, 리액트는 렌더링 과정을 동기적으로 실행한다. concurrency같은 건 없다.