10. 리액트 17과 18의 변경 사항 살펴보기

영근·2024년 4월 15일
0

10.1 리액트 17 버전 살펴보기

리액트의 점진적 업그레이드

  • 리액트 17부터는 점진적인 버전 업이 가능하고, 한 애플리케이션 내에 여러 버전의 리액트가 존재한다.
  • 리액트 16 애플리케이션은 내부에서 리액트 16을 게으르게(lazy) 불러온다.
  • 불러오는 과정에서 리액트 16을 위한 별도의 루트 요소를 만들고, 여기에 불러온 리액트 16 모듈을 렌더링하는 구조

이벤트 위임 방식의 변경

  • 리액트가 이벤트를 추가하는 방법
    : 이벤트 핸들러를 해당 이벤트 핸들러를 추가한 각각의 DOM 요소에 부탁하는 것이 아니라, 이벤트 타입(click, change) 당 하나의 핸들러를 루트에 부착한다 = 이벤트 위임
  • 이벤트 단계 구성
    • 캡처(capture) : 이벤트 핸들러가 트리 최상단 -> 실제 이벤트가 발생한 타깃 요소까지 내려가는 것
    • 타깃(target) : 이벤트 핸들러가 타깃 노드에 도달하는 단계
    • 버블링(bubbling) : 이벤트가 발생한 요소에서부터 시작해 최상위 요소까지 다시 올라감

      이벤트 위임 : 이러한 이벤트 단계의 원리를 이용해 이벤트를 상위 컴포넌트에만 붙이는 것

  • 리액트16에선 이벤트가 document에서 수행되었으나, 17부터는 리액트 컴포넌트 루트 요소로 바뀌었다.
  • document.addEventListener를 활용해 리액트 모든 이벤트를 document에서 확인 -> 이벤트가 전파되지 않은 경우가 있을 수 있다.

새로운 JSX transform

  • JSX는 브라우저가 이해하지 못함 -> 바벨이나 ts를 활용해 js로 변환해야 한다.
  • 리액트 16까지는 JSX 변환을 사용하기 위해 import React from 'react' 가 필요했다.
// 구 버전
const Component = (
  <div>
    <span>hello world</span>
  </div>
);

var Component = React.createElement(
  // React를 import해야 한다.
  "div",
  null,
  React.createElement("span", null, "hello world")
);

// 17 버전
("use strict");

var _jsxRuntime = require("react/jsx-runtime"); // React를 import하지 않아도 된다.

var Component = (0, _jsxRuntime.jsx)("div", {
  children: (0, _jsxRuntime.jsx)("span", {
    children: "hello world",
  }),
});

그 밖의 주요 변경 사항

  • 이벤트 풀링 제거

    • SyntheticEvent : 브라우저의 기본 이벤트를 한 번 더 감싼 이벤트 객체
      • 따라서 이벤트가 발생할 때마다 이 이벤트를 새로 만들어야 함 -> 메모리가 할당됨
      • 메모리 누수 방지를 위해 주기적으로 이벤트를 해제해야 함
    • 이벤트 풀링 : SyntheticEvent 풀을 만들어서 이벤트가 발생할 때마다 가져오는 것
        1. 이벤트 핸들러가 이벤트를 발생시킴
        1. 합성 이벤트 풀에서 합성 이벤트 객체에 대한 참조를 가져옴
        1. 이 이벤트 정보를 합성 이벤트 객체에 넣음
        1. 유저가 지정한 이벤트 리스너가 실행됨
        1. 이벤트 객체가 초기화되고 다시 이벤트 풀로 돌아감
    • 서로 다른 이벤트 간에 이벤트 객체를 재사용하고, 재사용하는 사이 모든 이벤트 필드를 null로 초기화
    • 비동기 코드에서 e에 접근하기 위해서는 e.persist()가 필요했음
    export default function App() {
      const [value, setValue] = useState("");
      function handleChange(e: ChangeEvent<HTMLInputElement>) {
        e.persist(); // e에 접근하기 위해 필요
        setValue(() => {
          return e.target.value;
        });
      }
    
      return <input onChange={handleChange} value={value} />;
    }
    • 이러한 이벤트 풀링 개념이 삭제되었다.

  • useEffect 클린업 함수의 비동기 실행
    • 리액트16 버전까지는 동기적으로 처리됨 -> 완료되기 전까지 다른 작업을 방해해 불필요한 성능 저하
    • 리액트 17 버전 부터는 화면이 완전히 업데이트된 이후 클린업 함수가 비동기적으로 실행된다.

  • 컴포넌트의 undefined 반환에 대한 일관적인 처리
    • 리액트 16 : forwardRef나 memo에서 undefined 반환하면 에러가 발생하지 않았다.
    • 리액트 17 : 정상적으로 에러가 발생한다.

10.2 리액트 18 버전 살펴보기

새로 추가된 훅 살펴보기

useId

  • 컴포넌트별로 유니크한 값을 생성하는 새로운 훅
  • 서버 사이드 렌더링
    • 서버 사이드에서의 유니크 값과 클라이언트에서의 유니크값이 달라 에러가 발생할 수 있다.
    • useId를 사용하면 클라이언트와 서버 불일치를 피하면서 컴포넌트 내부 고유한 값을 생성할 수 있다.
  • 같은 컴포넌트여도 서로 인스턴스가 다르면 랜덤한 값을 만들어낸다.
import {useId} from 'react'

function Child(){
    const id = useId()
    return <div>child: {id}</div>
}

function SubChild(){
    const id = useId()

    return (
        <div>
            SubChild : {id}
            <Child/>
        </div>
    )
}


// 서버사이드렌더링 HTML
<div>
    <div>
    SubChild :Ram:
    <div/>
    <div>
        child :R7am:
    </div>
</div>
  • CSS 선택자나 querySelector에서 작동하지 않도록 :로 감싸져있다.
  • id는 현재 트리에서의 자신의 위치를 나타내는 32글자의 이진 문자열로 이루어진다.(왼쪽 5자리가 부모의 트리)
    • 앞자리가 R이면 서버 생성, r이면 클라이언트 생성

useTransition

  • UI 변경을 가로막지 않고 상태를 업데이트할 수 있는 리액트 훅
  • 상태 업데이트를 긴급하지 않은 것으로 간주해 무거운 렌더링 작업을 조금 미룰 수 있다.
const [isPending, startTransition] = useTransition();
  • isPending : 상태 업데이트가 진행중인지
  • startTranstion : 긴급하지 않은 상태 업데이트로 간주할 set함수를 넣어둘 수 있는 함수를 인수로 받음
  • 주의할 점
    • startTransition 내부는 반드시 setState같은 상태를 업데이트하는 함수와 관련된 작업만 넘길 수 있다.(props나 사용자 정의 훅에서 반환하는 값 사용 -> useDefferedValue 사용)
    • startTransition으로 넘겨주는 상태 업데이트는 다른 모든 동기 상태 업데이트로 인해 실행이 지연될 수 있다.
    • startTransition으로 넘겨주는 함수는 반드시 동기 함수여야 한다.

useDeferredValue

  • 리렌더링이 급하지 않은 부분을 지연할 수 있게 도와주는 훅
  • 디바운스와의 차이
    • 고정된 지연시간 없이 첫 번째 렌더링이 완료된 이후에 useDeferredValue로 지연된 렌더링을 수행한다. -> 중단할 수 있고, 인터랙션을 차단하지 않는다.
  • state 값 자체만을 감싸서 사용한다.
  • 컴포넌트의 props와 같이 상태 업데이트에 관여할 수 없고 오로지 값만 받는 상황에서 사용하는 것이 타당하다.
export default function Input() {
  const [text, setText] = useState("");
  const deferredText = useDeferredValue(text);

  const list = useMemo(() => {
    const arr = Array.from({ length: deferredText.length }).map(
      (_) => deferredText
    );
    return (
      <ul>
        {arr.map((str, index) => (
          <li key={index}>{str}</li>
        ))}
      </ul>
    );
  }, [deferredText]);

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setText(e.target.value);
  }

  return (
    <>
      <input value={text} onChange={handleChange} />
      {list}
    </>
  );
}

useSyncExternalStore

  • 리액트 17버전의 useSubscription을 대체하는 훅
  • 테어링(tearing)
    • 하나의 state 값이 있음에도 서로 다른 값을 기준으로 렌더링되는 현상
    • 리액트 18에서는 useTransition, useDeferredValue의 훅처럼 렌더링을 일시 중지하거나 뒤로 미루는 등의 최적화가 가능해지면서 동시성 이슈가 발생할 수 있게 되었다.
    • 특히 리액트에서 관리할 수 없는 외부 데이터 소스(글로벌 변수, document.body, window.innerWidth, DOM, 외부 상태 관리 라이브러리 등)에서 발생할 수 있다.
  • parameters
    • subscribe : 콜백 함수를 받아 스토어에 등록하는 용도로 사용된다. 스토어에 있는 값이 변경되면 이 콜백이 호출되어야 한다. 이 훅을 사용하는 컴포넌트가 리렌더링된다.
    • getSnapshot : 컴포넌트에 필요한 현재 스토어의 데이터를 반환하는 함수. 스토어에서 Object.is로 비교해 값이 변경되었다면 컴포넌트를 리렌더링한다.
    • 마지막 인수(optional) : SSR 시에 내부 리액트를 하이드레이션하는 도중에 사용된다.
useSyncExternalStore(
    subscribe: (callback) => Unsubscribe,
    getSnapshot: () => State
) => State
  • 예시
import { useSyncExternalStore } from "react";

function subscribe(callback: (this: Window, ev: UIEvent) => void) {
  window.addEventListener("resize", callback);
  return () => {
    window.removeEventListener("resize", callback);
  };
}

export default function App() {
  const windowSize = useSyncExternalStore(
    subscribe,
    () => window.innerWidth,
    () => 0 // 서버 사이드 렌더링 시 제공되는 기본값. 서버 사이드에서는 해당 값을 추적할 수 없다.
  );

  return <>{windowSize}</>;
}

useInsertionEffect

  • CSS-in-js 라이브러리를 위한 훅
  • 리액트 17, styled-components에서는 서버 사이드에서 스타일 코드를 삽입했다.(_document.tsx)
  • useEffect와 기본적인 구조는 동일하나, useInsertionEffect는 DOM이 실제로 변경되기 전에 동기적으로 실행된다.
  • 이 훅 내부에 스타일을 삽입하는 코드를 집어넣어 브라우저가 레이아웃을 계산하기 전에 실행될 수 있게끔 한다.
  • useLayoutEffect는 모든 DOM의 변경 작업이 다 끝난 후 실행 / useInserionEffect는 DOM 변경 작업 이전에 실행

react-dom/client

리액트 18 버전에서는 리액트 트리를 만들 때 사용되는 API 가 변경되었다.

createRoot

  • 기존 render를 대체할 새로운 메서드
  • 리액트 18의 기능을 사용하기 위해서는 createRoot와 render를 함께 사용해야 한다.
// before
import ReactDOM from "react-dom";
import App from "App";

const container = document.getElementById("root");

ReactDOM.render(<App />, container);

// after
import ReactDOM from "react-dom";
import App from "App";

const container = document.getElementById("root");

const root = ReactDOM.createRoot(container);
root.render(<App />);

hydrateRoot

SSR 애플리케이션에서 하이드레이션을 하기 위한 새로운 메서드

// before
import ReactDOM from "react-dom";
import App from "App";

const container = document.getElementById("root");

ReactDOM.hydrate(<App />, container);

// after
import ReactDOM from "react-dom";
import App from "App";

const container = document.getElementById("root");

const root = ReactDOM.hydrateRoot(container, <App />);

react-dom/server

renderToPipeableStream

  • 리액트 컴포넌트를 HTML로 렌더링하는 메서드
  • 스트림을 지원하는 메서드. HTML을 점진적으로 렌더링하고 클라이언트에서는 중간에 script를 삽입하는 등 작업 가능. 서버에서는 Suspense를 사용해 렌더링 순서를 조절할 수 있다.
  • hydrateRoot로 서버에서 HTML을 렌더링하고, 클라이언트에서는 이벤트만 추가해 첫 로딩을 빠르게 수행할 수 있다.
import * as React from 'react'

function render(url, res){
    let didError = false

    const data = createServerData() // 서버에서 필요한 데이터를 불러온다.(오랜시간이 걸린다고 가정)
    const stream = renderToPipeableStream(
        // 데이터를 contextAPI로 넘긴다.
        <DataProvider data={data}>
            <App assets={asstes}>
        </DataProvider>,
        {
            // 렌더링 시에 포함시켜야 할 JS 번들
            bootstrapScripts: [asstes['main.js']],
            onShellReady(){
                // 에러 발생 시 처리 추가
                res.statusCode = didError ? 500 : 200
                res.setHeader('Content-type', 'text/html')
                stream.pipe(res)
            },
            onError(x){
                didError = true
                console.error(x)
            },
        },
    )
    // 렌더링 시작 이후 일정 시간이 흐르면 렌더링에 실패한 것으로 간주하고 취소한다.
    setTimeout(() => stream.abort(), ABORT_DELAY)
}

export default function App ({assets}){
    return (
        <Html assets={assets} title="Hello">
            <Suspense fallback={<Spinner/>}>
                <ErrorBoundary FallbackComponent={Error}> // 아직 데이터를 불러오는 중이라면 fallback으로 받음
                    <Content/>
                </ErrorBoundary>
            </Suspense>
        </Html>
    )
}
  • renderToNodeStream : 무조건 렌더링을 순서대로 해야하고, 이전 렌더링이 끝나지 않으면 이후 렌더링도 끝나지 않는다.
  • renderToPipeableStream : 순서나 오래걸리는 렌더링에 영향받을 필요 없이 빠르게 렌더링이 수행된다.

renderToReadableStream

  • renderToPipeableStream : Node.js 환경에서의 렌더링을 위해 사용된다.
  • renderToReadableStream : 웹 스트림을 기반으로 작동한다.(클라우드플레어, 디노 등 모던 엣지 런타임 환경)

자동 배치(Automatic Batching)

리액트가 여러 상태 업데이트를 하나의 리렌더링으로 묶어서 성능을 향상시키는 방법

  • 리액트 17 이하 버전 : 이벤트 핸들러 내부에서는 자동 배치 작업이 이뤄지지만, Promise, setTimeout 등 비동기 이벤트에서는 이뤄지고 있지 않았다.
  • 리액트 18 : 모든 업데이트가 배치 작업으로 최적화된다.
  • 자동 배치를 하고 싶지 않다면 flushSync를 사용한다.
import { flushSync } from "react-dom";

function handleClick() {
  flushSync(() => {
    setCounter((c) => c + 1);
  });
  flushSync(() => {
    setFlag((f) => !f);
  });
}

더욱 엄격해진 엄격 모드

엄격 모드

  • 리액트가 제공하는 컴포넌트 중 하나
  • 리액트 애플리케이션에서 발생할 수도 있는 잠재적인 버그를 찾는 데 도움이 되는 컴포넌트
  • 개발자 모드에서만 작동한다.

  • 더 이상 안전하지 않은 특정 생명주기를 사용하는 컴포넌트에 대한 경고

    • componentWillMount, componentWillReceiveProps, componentWillUpdate는 더 이상 사용할 수 없어 경고가 표시된다.
  • 문자열 ref 사용 금지

    • createRef가 없어도 컴포넌트 내부에서 문자열로 ref 생성 -> DOM 노드를 참조하는 것

    • class UnsafeClassComponent extends Component {
        componentDidMount() {
          console.log(this.refs.myInput);
        }
      
        render() {
          return (
            <div>
              <input type="text" ref="myInput" />
            </div>
          );
        }
      }
    • 문제점

      • 문자열로 값을 주는 것은 여러 컴포넌트에 걸쳐 사용될 수 있어 위험하다.
      • 단순히 문자열 -> 실제로 어떤 ref에서 참조되고 있는지 파악하기 어렵다.
      • 리액트가 계속해서 현재 렌더링되고 있는 컴포넌트의 ref 값을 추적해야 함 -> 성능 이슈
  • findDOMNode 경고 출력

    • findDOMNode : 클래스 컴포넌트 인스턴스에서 실제 DOM 요소에 대한 참조를 가져올 수 있는 메서드

    •   class UnsafeClassComponent extends Component {
            componentDidMount(){
                const node = ReactDOM.findDOMNode(this)
                if(node){
                    ;(node as HTMLDivElement).style.color = 'red'
                }
            }
      
            render(){
                return <div>UnsafeClassComponent</div>
            }
        }
    • 문제점

      • 부모가 특정 자식만 별도로 렌더링할 수 있다. -> 리액트가 추구하는 트리 추상화 구조를 무너뜨린다.
      • findDOMNode는 항상 첫 번째 자식을 반환 -> Fragment를 사용할 때 어색해진다.
      • 일회성 -> 자식 컴포넌트가 특정 시점에서 다른 노드를 렌더링할 경우 변경 사항을 추적할 수 없다.
  • 구 ContextAPI 사용 시 발생하는 경고

  • 예상치 못한 부작용(side-effects) 검사

    • 두 번씩 기록된다.
      • 클래스 컴포넌트의 constructor, render, shouldComponentUpdate, getDerivedStateFromProps
      • 클래스 컴포넌트의 setState 첫 번째 인수
      • 함수 컴포넌트의 body
      • useState, useMemo, useReducer에 전달되는 함수
    • 함수형 프로그래밍의 원칙에 따라 리액트 모든 컴포넌트는 항상 순수하다고 가정 -> 항상 순수한 결과물을 내고 있는지 확인시켜주기 위해

리액트 18에서 추가된 엄격모드

  • 컴포넌트가 최초에 마운트될 때 자동으로 모든 컴포넌트를 마운트 해제하고 두 번째 마운트에서 이전 상태를 복원

Suspense 기능 강화

컴포넌트를 동적으로 가져올 수 있게 도와주는 기능

export default function SampleComponent(){
    return <>동적으로 가져오는 컴포넌트</>
}

// app.tsx
import {Suspense, lazy} from 'react'

const DynamicSampleComponent = lazy(() => import ('./SampleComponent')) // 컴포넌트를 최초 렌더링 이후 지연시켜 불러오는 역할. lazy와 Suspense는 한 쌍으로 사용되었다.

export default function App(){
    return (
        <Suspense fallback={<>로딩중</>}>
            <DynamicSampleComponet/>
        </Suspense>
    )
}
  • 기존 Suspense의 문제점
    • 컴포넌트가 보이기도 전에 useEffect가 실행되었다.
    • 서버에서 사용할 수 없었다.
  • 리액트 18에서의 Suspense
    • 마운트되기 직전임에도 effect가 빠르게 실행되는 문제 수정
    • Suspense로 인해 컴포넌트가 보이거나 사라질 때도 effect가 정상적으로 실행된다. 노출되면 effect가, 가려진다면 cleanUp이 실행된다.
    • 서버에서도 실행할 수 있다. fallback 상태의 트리를 클라이언트에 제공 -> 준비가 되면 자연스럽게 렌더링
    • 스로틀링 추가

인터넷 익스플로러 지원 중단에 따른 추가 폴리필 필요

  • 리액트는 아래의 기능을 사용할 수 있다는 가정 하에 배포된다.
    • Promise
    • Symbol
    • Object.assign
  • 지원되지 않는 브라우저에서 서비스해야 한다면 폴리필을 반드시 추가해야 한다.
profile
Undefined JS developer

0개의 댓글