코드스테이츠_S4U4 _2,3W_금,월,화

윤뿔소·2022년 11월 25일
0

CodeStates

목록 보기
39/47

전 리액트 시간에는 기초적인 훅과 상태관리 라이브러리 리덕스에 대해 배웠다. 이번엔 리액트 동작 방식, Hooks의 심화를 배워보자!
면접에서 가상돔도 자주 나온다. 가상 돔은 뭔지, 실제 돔과 차이가 뭔지.

Virtual DOM

VJS에는 DOM 객체를 직접 조작하는 한편(Real DOM)
리액트에는 DOM의 사본 개념인 가상 DOM 객체에 접근해 변화 전과 변화 후를 비교하고 바뀐 부분을 적용한다.(Virtual DOM)

배경

DOM은 문서 객체 모델, 다시 말해 브라우저가 JS 같은 스크립팅 언어로 <html>, <head>, <body>같은 태그들에 접근하게끔 트리 구조로 객체화 시킨 것을 의미한다. 그래서 JS로도 쉽게 DOM을 찾고 조작할 수 있다.

여기서 DOM을 조작하기 시작하는데 정도가 잦다면 성능에 당연히 영향을 미치고 DOM의 렌더링은 브라우저의 파워, 즉 브라우저의 구동 능력에 의존하기 때문에 DOM의 조작 속도는 느려지게 된다.

자세히 말하자면 저장하는 것보다 탐색하기 위한 자료구조인 트리 구조 특성상 탐색, 변경 및 업데이트는 빠르지만 렌더링 부분에서 보면 결국 구조가 바뀌는 것이니 브라우저 엔진도 렌더링돼 '리플로우'가 일어난다. 브라우저의 '리플로우'와 '리페인트' 과정은 다시금 레이아웃 및 페인트에 해당하는 재연산을 해야 하기 때문에 속도가 그만큼 느려지게 된다.(최적화 글 참고)

즉! JS로 조작하는 DOM의 요소가 많을수록 모든 DOM 업데이트에 대하여 리플로우를 해야 하므로 DOM의 업데이트에 대한 비용이 많이 들게 된다. 거기에 더불어 VJS와 대부분 프레임워크에서는 비효율적인 업데이트를 들게 하여 프레임드랍이 걸리게끔 한다. 여기서 “바뀐 부분만 비교해서 그 부분만 렌더링을 할 수는 없을까?“라는 생각에 리액트의 가상 돔이 착안된다.

개념

  • 가상의 UI 요소를 메모리에 유지시키고, 그 유지시킨 가상의 UI 요소를 ReactDOM과 같은 라이브러리를 통해 실제 DOM과 동기화
    • 실제 DOM을 조작하는 것은 실제로 브라우저 화면에 그리기 때문에 느리지만, 가상 DOM을 조작하는 것은 실제 DOM처럼 실제로 브라우저 화면에 그리는 것이 아니기 때문에 훨씬 속도가 빠름
    • 사람으로 치면은 이사할 때 생각만으로 짐을 옮겨봐 계획을 짜는 것임
  • 과정
    1. React는 새로운 요소가 UI에 추가되면 트리 구조로 표현이 되는 가상의 DOM이 만들어짐(추상화된 DOM)
    2. 이러한 요소의 상태가 변경이 되면 다시 새로운 가상의 DOM 트리가 만들어짐
    3. 이전의 가상의 DOM과 이후의 가상의 DOM의 차이를 비교
    4. 이 작업이 완료가 되면 가상의 DOM은 실제 DOM에 변경을 수행할 수 있는 최상의 방법을 계산하기 시작
    5. 실제 DOM은 최소한의 작업만 수행해도 렌더링 가능해져 업데이트 비용을 줄일 수 있게 됨!

React Diffing Algorithm

기본적인 조작 방식 알고리즘은 O(n^3)의 복잡도를 가지고 있었고, 너무 비싼 연산이었다. 그렇다면 어떻게 비교를 하고 바꿔야 제일 효율적일까?

React는 두 가지의 가정을 가지고

  1. 각기 서로 다른 두 요소는 다른 트리를 구축할 것이다.
  2. 개발자가 제공하는 key 프로퍼티를 가지고, 여러 번 렌더링을 거쳐도 변경되지 말아야 하는 자식 요소가 무엇인지 알아낼 수 있을 것이다.

시간 복잡도 O(n)의 새로운 휴리스틱 알고리즘(Heuristic Algorithm)인 비교 알고리즘(Diffing Algorithm)을 사용하고 구현해낸다.

원리

기존 DOM과 가상 DOM을 같은 레벨 순서대로 비교하여 순회한다. 너비 탐색(BFS)랑 비슷한 개념으로 알면 된다.React는 이런 식으로 동일 선상에 있는 노드를 파악한 뒤 다음 자식 세대의 노드를 순차적으로 파악해나간다.

타입별로 나뉘는 알고리즘

  • 다른 타입의 DOM 엘리먼트: HTML 특성상 부모와 자식의 관계가 끈끈한 태그들이 많음, 부모 태그가 바뀌면 자식 태그들은 모조리 파괴 후 재건축, 자식이 아예 바뀌어진다면 것도 파괴
<div>
	<Counter />
</div>
//부모 태그가 div에서 span으로 바뀝니다.
<span>
	<Counter />
</span>
  • 부모 노드였던 <div><span>으로 바뀌어버리면 자식 노드인 <Counter />는 완전히 해제된다. 즉, 이전 <div> 태그 속 <Counter />는 파괴되고 <span> 태그 속 새로운 <Counter />가 다시 실행된다. 새로운 컴포넌트가 실행되면서 기존의 컴포넌트는 완전히 해제(Unmount)되어버리기 때문에 <Counter />가 갖고 있던 기존의 state 또한 파괴된다!
  • 같은 타입의 DOM 엘리먼트: 반대로 타입(태그)이 같고 다른 것(속성, 스타일 등)이 바뀐다면 Virtual DOM 내부의 프로퍼티만 수정한 뒤, 모든 노드에 걸친 업데이트가 끝나면 그때 단 한번 실제 DOM으로의 렌더링을 시도
    • 리액트는 className, color 등 속성만 바뀌었다면 color 스타일만 수정하고 fontWeight 및 다른 요소는 수정하지 않음
    • 끝난 다음 리액트는 아래 자식들을 순회하며 차이가 발견할 때마다 변경한다. 즉! 재귀적으로 처리한다!

자식 엘리먼트의 재귀적 처리

리액트는 위에서 아래로 순차적으로 비교하기 때문에, 만약 <li>같은 리스트가 있고 맨 아래에 리스트가 추가한다면 리액트는 마지막 것만 바뀌었다고 판단하고 맨 아래에 추가하는 식으로 업데이트한다.

하지만 변경된 것이 맨 처음이라면? 처음부터 달라져 자식태그가 완전히 달라졌다고 판단하고 원래 하던 리얼 돔처럼 다시 그린다. 리액트와 맞지 않는다.

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

자식 엘리먼트를 처음에 추가
<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<li>Duke</li><li>Villanova</li>는 그대로기 때문에 두 자식 노드는 유지시켜도 된다는 것을 깨닫지 못하고 전부 버리고 새롭게 렌더링 해버린다. 이는 굉장히 비효율적인 동작 방식.

그래서 여기서 각 리스트에 key를 할당해 기존 트리 자식과 새로운 트리 자식을 더 효율적으로 비교하게끔 만들었다.

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

key가 2014인 자식 엘리먼트를 처음에 추가
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

이로써 리액트는 key 속성을 통해 ‘2014’라는 자식 엘리먼트가 새롭게 생겼고, ‘2015’, ‘2016’ 키를 가진 엘리먼트는 그저 위치만 이동했다는 걸 알게 된다!
그래서 DOM으로 리스트를 배포하기 위해 map()을 쓸 때 key가 필요하고 형제 사이에서 고유한 값이 필요하다. 보통 id를 지정하거나 인덱스를 지정하는데 인덱스는 비추한다! 인덱스가 바껴져도 key 그대로이기에 중복이 생길 수도 있다.
보통 id 라이브러리를 사용해 key를 배정한다. (shortId)

Hook

리액트에는 다양한 hook들이 있다. 무엇인지, 어떤 것이 있는지 등 알아보자!

배경

리액트는 Class Component로 만드는 게 주력이었다.

class Counter extends Component {
  constructor(props) {
    super(props);
    this.state = {
      counter: 0,
    };
    this.handleIncrease = this.handleIncrease.bind(this);
  }

  handleIncrease = () => {
    this.setState({
      counter: this.state.counter + 1,
    });
  };

  render() {
    return (
      <div>
        <p>You clicked {this.state.counter} times</p>
        <button onClick={this.handleIncrease}>Click me</button>
      </div>
    );
  }
}

this 키워드로 JS의 기본 상식도 필요하고 다 붙여줘야한다. 이러한 특성으로 리액트가 발전할수록 복잡해지고 이해하기 어려워졌다. 함수형 컴포넌트를 배운 내가 봐도 복잡한 느낌이 있다. 그래서 함수형 컴포넌트로 넘어갔다.

하지만 클래스보다 함수형은 상태값을 사용하거나 최적화할 수 있는 기능이 좀 떨어졌다. 그래서 여기에서 우리 구세주 'Hook'이 등장했다!

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

    const handleIncrease = () => {
        setCounter(counter + 1)
    }

    return (
        <div>
            <p>You clicked {counter} times</p>
            <button onClick={handleIncrease}>
                Click me
            </button>
        </div>
    )
}

훨씬 간단해진 표현과 hook의 조합으로 훨씬 더 직관적이고, 보기 쉽다는 특징을 가졌다. 나온 hook은 useState()로 상태를 이제 쓸 수 있고, 리렌더링되도 유지되는 값을 쓸 수 있어졌다!

개념 및 규칙

공식 문서에 쓰인 바로는

Hook은 React 16.8에 새로 추가된 기능입니다. Hook은 class를 작성하지 않고도 state와 다른 React의 기능들을 사용할 수 있게 해줍니다.

즉! Hook은 클래스 컴포넌트를 안써도 함수형 컴포넌트에서 상태 값 및 다른 여러 기능을 사용하기 편리하게 해주는 메소드를 의미한다!

하지만 Hook은 이러한 규칙들을 써야 제대로 쓸 수 있다.

  1. 리액트 함수의 최상위에서만 호출해야 함
    • 여러번 쓰일 수 있기에 React는 이 Hook을 호출되는 순서대로 저장을 해놓는다.
    • 조건문, 반복문 등 안에서 쓰면 호출되는 순서대로 저장을 하기 어려워 안에 쓰면 안됨!! 꼭 그 함수 스코프 최상위에서 써야함
  2. 오직 리액트 함수 내에서만 사용되어야 함
    • React의 함수 컴포넌트 내에서 사용되도록 만들어진 메소드이기 때문에 근본적으로 일반 JavaScript 함수 내에서는 정상적으로 돌아가지 않음
// 오류!
...
window.onload = function () {
    useEffect(() => {
        // do something...
    }, [counter]);
}
...

종류

저번에 했던 useState(), useEffect(), useRef()등은 생략하고 이번엔 리액트 특성상 리렌더링이 자주 일어날 때 최적화를 위한 Hook, useCallback과 useMemo를 배워보자!

useMemo

특정 값(value)를 재사용하고자 할 때 사용하는 Hook
알고리즘 개념 중 Memoization을 이용한 훅임! 반갑다!

// props로 넘어온 value값을 calculate라는 함수에 인자로 넘겨서 result 값을 구한 후
// <div> 엘리먼트로 출력하는 함수
function Calculator({ value }) {
  const result = calculate(value);

  return (
    <>
      <div>{result}</div>
    </>
  );
}

위와 같이 계속 계산해야하고 복잡한 계산을 하는데 몇초가 걸리는 calculate 함수가 있다고 가정하자. 그러면 이 컴포넌트는 렌더링을 할 때마다 이 함수를 계속해서 호출하고, 몇 초 이상이 소요된다. 당연이 UX에 치명적! 여기서 useMemo를 쓰면 된다.

/* useMemo를 사용하기 전에는 꼭 import해서 불러와야 함 */
import { useMemo } from "react";

function Calculator({ value }) {
  const result = useMemo(() => calculate(value), [value]);

  return (
    <>
      <div>{result}</div>
    </>
  );
}

위와 똑같은 함수고 만약 value가 값이 계속 바뀌지 않는 컴포넌트라면? useMemo를 써서 어딘가에 저장하고, 리렌더링될 때마다 바로 값을 꺼내 써 로딩을 없애준다! 와우!
사용법은 그냥 콜백으로 useMemo를 호출하여 calculate를 감싸주면 끝!

적용해보자!

핵심은 Input 태그에 onChange를 사용해 글을 입력하면 타이핑을 할 때마다 컴포넌트 전체가 리렌더링된다. 이런 것처럼 그때 useMemo를 호출해 로딩이 걸리는 값에 감싸 쓰면 좋다!

참고: Memoization이란 개념은 중복을 저장해 같은 value가 나왔을 시 그 값의 result를 얻을 수 있는 것이다. 알고리즘 중 재귀에서 했다!

useCallback

useMemo와 더불어 많이 쓰이는 최적화하기 위한 메모이제이션 기법을 이용한 Hook이다.
⭐️useMemo는 값의 재사용을 위해 사용하는 Hook이라면, useCallback은 함수의 재사용을 위해 사용하는 Hook이다.

useMemo처럼 재사용하는 함수에 써도 되지만 어딘가에 함수를 꺼내서 호출하는 Hook인 useCallback 특성상 단순히 컴포넌트 내에서 함수를 반복해서 생성하지 않기 위해서 useCallback을 사용하는 것은 큰 의미가 없거나 오히려 손해인 경우가 종종 있다.

그러면 어디에 써야지 좋을까? ⭐️바로 props로 자식에게 전달할 때 쓰기 좋다! 여기서 알아둬야할 개념이 있다.

JS 참조 동등성

리액트는 JS의 라이브러리이기에 JS의 특성에 따른다. 여기 JS 특성에서 함수는 '참조 데이터'인 객체이다. 즉, 메모리 주소에 의한 참조 비교가 일어난다!

// 참조 비교
const add1 = () => x + y;
// undefined
const add2 = () => x + y;
// undefined
add1 === add2
// false

React는 리렌더링 시 함수를 새로이 만들어서 호출한다. 참조 동등성으로 인해 새로운 함수는 기존 함수와 같은 함수가 아니게 된다.
이러한 특성은 React 컴포넌트 함수 내에서 어떤 함수를 다른 함수의 인자로 넘기거나 자식 컴포넌트의 prop으로 넘길 때 예상치 못한 성능 문제로 이어질 수 있다.
그래서 함수 자체를 저장해서 다시 사용하면 함수의 메모리 주소 값을 저장했다가 다시 사용하는 useCallback을 사용해 이를 막아 성능 문제 변수를 줄여준다.

예제해보자!

input 창에 숫자를 입력해 보면 콘솔에 “아이템을 가져옵니다.” 가 출력되는 것이 보인다. 정상적인 동작이지만, 이번에는 옆의 button dark mode도 눌러보면 button을 눌러도 “아이템을 가져옵니다.”가 콘솔에 출력되는 걸 볼 수 있다.
왜냐면?! 버튼을 누를 때도 앱이 리렌더링 되므로, App 내부의 getItems() 함수가 다시 만들어진다. 즉, 새로이 만들어진 함수는 이전의 함수와 참조 비교시 다른 함수이기 때문에 List 구성 요소 내에서 useEffect Hook은 setItems를 호출하고 종속성이 변경됨에 따라 “아이템을 가져옵니다.”를 출력

Custom Hooks

클래스처럼 개발자가 스스로 커스텀한 훅을 의미
이를 이용해 반복되는 로직을 함수로 뽑아내어 재사용

  1. 상태관리 로직의 재활용이 가능.
  2. 클래스 컴포넌트보다 적은 양의 코드로 동일한 로직을 구현.
  3. 함수형으로 작성하기 때문에 보다 명료. (e.g. useSomething)

여러 url을 fetch할 때, 여러 input에 의한 상태 변경(회원가입) 등 반복되는 로직을 동일한 함수에서 작동하게 하고 싶을 때 커스텀 훅을 주로 사용한다!

규칙

  • Custom Hook을 정의할 때는 함수 이름 앞에 use를 붙이는 것이 규칙. 리액트는 use~로 시작하는 컴포넌트는 다 hook으로 인식함. 그래서 일반 컴포넌트엔 안붙이는 것이 상책
  • 대개의 경우 프로젝트 내의 util(or hooks) 디렉토리에 Custom Hook(use~ .js)을 위치 시킴.
  • Custom Hook으로 만들 때 함수는 조건부 함수가 아니어야 함. 즉 return 하는 값은 조건부여서는 안 된다.

예시

FriendStatus컴포넌트는 사용자들이 온라인인지 오프라인인지 확인하고, FriendListItem 컴포넌트는 사용자들의 상태에 따라 온라인이라면 초록색으로 표시하는 컴포넌트

// FriendStatus : 친구가 online인지 offline인지 return하는 컴포넌트
function FriendStatus(props) {
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

// FriendListItem : 친구가 online일 때 초록색으로 표시하는 컴포넌트
function FriendListItem(props) {
  const [isOnline, setIsOnline] = useState(null);
  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }
    ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
    };
  });

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

두 컴포넌트를 보면 똑같이 해당되는 로직이 있다. 이 로직을 따로 떼와 두 컴포넌트에 공유시켜보자.

function useFriendStatus(friendID) {
  const [isOnline, setIsOnline] = useState(null);

  useEffect(() => {
    function handleStatusChange(status) {
      setIsOnline(status.isOnline);
    }

    ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
    return () => {
      ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
    };
  });

  return isOnline;
}
  • 동일하게 사용되고 있는 로직을 분리하여 함수 useFriendStatus로 만듦.
  • 규칙 3번에서 보자면 위의 이 useFriendStatus Hook은 온라인 상태의 여부를 boolean 타입으로 반환하고 있다. 그래서 커스텀훅으로 적합!
  • 일반 함수 내부에서는 React 내장 Hook을 불러 사용할 수 없지만 Custom Hook 에서는 가능하다!
function FriendStatus(props) {
  const isOnline = useFriendStatus(props.friend.id);

  if (isOnline === null) {
    return 'Loading...';
  }
  return isOnline ? 'Online' : 'Offline';
}

function FriendListItem(props) {
  const isOnline = useFriendStatus(props.friend.id);

  return (
    <li style={{ color: isOnline ? 'green' : 'black' }}>
      {props.friend.name}
    </li>
  );
}

더 직관적이고, 깔끔해졌다.
하지만 같은 Custom Hook을 사용했다고 해서 두 개의 컴포넌트가 같은 state를 공유하는 것은 아니고 로직만 같게 공유하는거지 state는 각 컴포넌트에 존재한다! 같은 상태를 공유하려면 상태 끌올하든가 상태관리 라이브러리를 사용해야한다.

자주 쓰이는 fetch(axios), input

const useFetch = ( initialUrl:string ) => {
	const [url, setUrl] = useState(initialUrl);
	const [value, setValue] = useState('');

	const fetchData = () => axios.get(url).then(({data}) => setValue(data));	

	useEffect(() => {
		fetchData();
	},[url]);

	return [value];
};

export default useFetch;
import { useState, useEffect } from "react";
import axios from "axios";
const url = "~~~";

function useFetch(employer, id) {
  // null설정한 이유: 모든 data가 같진 않기 때문
  const [questionData, setQuestionData] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      setLoading(true);
      try {
        if (employer === "members") {
          const response = await axios.get(`${url}/${employer}`);
          setQuestionData(response.data);
        } else if (id) {
          const response = await axios.get(`${url}/${employer}/${id}`);
          setQuestionData(response.data);
        } else {
          const response = await axios.get(`${url}/${employer}?page=1&size=15`);
          setQuestionData(response.data.content);
        }
      } catch (err) {
        setError(err);
      } finally {
        setLoading(false);
      }
    };
    fetchData();
  }, [url]);

  return { questionData, loading, error };
}

export default useFetch;
import { useState, useCallback } from 'react';

function useInputs(initialForm) {
  const [form, setForm] = useState(initialForm);
  // change
  const onChange = useCallback(e => {
    const { name, value } = e.target;
    setForm(form => ({ ...form, [name]: value }));
  }, []);
  const reset = useCallback(() => setForm(initialForm), [initialForm]);
  return [form, onChange, reset];
}

export default useInputs;

커스텀훅 참고 페이지

공식페이지, Input 만들기1, Input 만들기2, 실습 코드샌드박스1, 실습 코드샌드박스2

React.lazy()와 Suspense

배경

그동안 점점 커지는 JS 파일을 줄이기 위해 번들링도 하고 별 난리를 치고왔다. 여기에서 착안해

“그렇다면 어느 페이지에서 코드를 해석하고 실행하는 정도가 느려졌는지 파악해서 번들을 나눈 뒤에 지금 필요한 코드만 불러오고 나중에 필요한 코드는 나중에 불러올 수 있지 않을까??”

이것이 코드 분할의 핵심 아이디어다. 코드 분할은 런타임 시 여러 번들을 동적으로 만들고 불러오는 것으로 번들러(Webpack, Rollup 등)들은 이미 지원하는 기능이고, 따로 파일에서 코드 분할을 꾀한다면 대규모 프로젝트의 앱인 경우에도 페이지의 로딩 속도를 개선할 수 있게 됩니다.
즉, React는 SPA(Single-Page-Application)이므로 사용하지 않는 모든 컴포넌트까지 한 번에 불러오기 때문에 첫 화면이 렌더링 될때까지의 시간이 오래 걸린다! 그래서 사용하지 않는 컴포넌트는 나중에 불러오기 위해 코드 분할 개념을 도입했다.

// Static Import
/* 기존에는 파일의 최상위에서 import 지시자를 이용해 라이브러리 및 파일을 불러왔다. */
import moduleA from "library";

form.addEventListener("submit", e => {
  e.preventDefault();
  someFunction();
});

const someFunction = () => {
  /* 그리고 코드 중간에서 불러온 파일을 사용했다. */
}
// Dynamic Import
form.addEventListener("submit", e => {
  e.preventDefault();
	/* 동적 불러오기는 이런 식으로 코드의 중간에 불러올 수 있게 된다. */
  import('library.moduleA')
    .then(module => module.default)
    .then(someFunction())
    .catch(handleError());
});

const someFunction = () => {
    /* moduleA를 여기서 사용. */
}

바로 Dynamic Import 기법을 사용해서 중간에 dynamic import를 사용하게 되면 불러온 moduleA 가 다른 곳에서 사용되지 않는 경우, 사용자가 form을 통해 양식을 제출한 경우에만 가져오도록 할 수 있다.
dynamic import는 then 함수를 사용해 필요한 코드만 가져온다. 가져온 코드에 대한 모든 호출은 해당 함수 내부에 있어야 한다. 이 방식을 사용하면 번들링 시 분할된 코드(청크)를 지연 로딩시키거나 요청 시에 로딩할 수 있다.
이 dynamic import는 React.lazy 와 함께 사용할 수 있다.

React.lazy()

React.lazy 함수를 사용하면 dynamic import를 사용해 컴포넌트를 렌더링할 수 있다. React는 SPA로 구동되므로 React.lazy를 통해 컴포넌트를 동적으로 import해 초기 렌더링 지연시간을 어느정도 줄일 수 있게 된다.

import Component from './Component';

/* React.lazy로 dynamic import를 감싼다. */
const Component = React.lazy(() => import('./Component'));

React.lazy로 감싼 컴포넌트는 단독으로 쓰일 수는 없고, React.suspense 컴포넌트의 하위에서 렌더링을 해야 합니다.

React.Suspense

Router로 분기가 나누어진 컴포넌트들을 위 코드처럼 lazy를 통해 import하면 해당 path로 이동할때 컴포넌트를 불러오게 되는데 이 과정에서 로딩하는 시간이 생기게 된다. Suspense는 아직 렌더링이 준비되지 않은 컴포넌트가 있을 때 로딩 화면을 보여주고, 로딩이 완료되면 렌더링이 준비된 컴포넌트를 보여주는 기능.

/* suspense 기능을 사용하기 위해서는 import 해와야 한다. */
import { Suspense, lazy } from "react";

const OtherComponent = React.lazy(() => import("./OtherComponent"));
const AnotherComponent = React.lazy(() => import("./AnotherComponent"));

function MyComponent() {
  return (
    <div>
      {/* 이런 식으로 React.lazy로 감싼 컴포넌트를 Suspense 컴포넌트의 하위에 렌더링. */}
      <Suspense fallback={<div>Loading...</div>}>
        {/* Suspense 컴포넌트 하위에 여러 개의 lazy 컴포넌트를 렌더링시킬 수 있다. */}
        <OtherComponent />
        <AnotherComponent />
      </Suspense>
    </div>
  );
}

Suspense 컴포넌트의 fallback prop은 컴포넌트가 로드될 때까지 기다리는 동안 로딩 화면으로 보여줄 React 엘리먼트를 받아들인다. Suspense 컴포넌트 하나로 여러 개의 lazy 컴포넌트를 보여줄 수도 있다.

실제 적용

앱에 코드 분할을 도입할 곳을 결정하는 것은 사실 까다롭기 때문에, 중간에 적용시키는 것보다는 웹 페이지를 불러오고 진입하는 단계인 ⭐️Route에 이 두 기능을 적용시키는 것이 좋다.

import { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
);

라우터가 분기되는 컴포넌트에서 각 컴포넌트에 React.lazy를 사용하여 import하고 Suspense로 감싼 후 로딩 화면으로 사용할 컴포넌트를 fallback 속성으로 설정하자.
페이지 이동마다 로딩이 걸리니 서비스에 따라서 적용 여부를 결정하자!

과제

먼저 직접 DB를 만들고 서버를 구축할 필요 없이 json 파일을 이용하여 REST API 서버를 구축해주는 라이브러리인 json-server 라이브러리로 서버를 열자.

참고

실제 앱에 사용되는 라이브러리는 아니므로 배포할 땐 꼭꼭 node로 서버 구축해서 사용하자. json-server는 앱의 프로토타입을 만들거나 공부를 위해 서버가 필요할 때 사용하는 용도로만! 이용하자

# react 앱 파일 내에 설치하면 제대로 작동하지 않을 수 있어 전역 설치
npm i -g json-server
# json 있는 폴더로 가서 명령어 입력 후 서버 3001포트로 열기
json-server --watch data.json --port 3001
{
  "blogs": [
    {
      "id": 1,
      ~~
      "likes": 10
    },
    {
      "id": 2,
      ~~
      "likes": 30
    },
    {
      "id": 3,
      ~~
      "likes": 0
    }
  ]
}

저렇게 "blogs"로 지정해주면 /blogs로 url이 생긴다.


로컬호스트:3001 가보면 잘 열린 걸 확인, 포스트맨의 Workspaces에서 HTTP request를 새로이 생성하여, json-server가 만들어준 API에 GET 요청을 보내보자!json 파일 안 "blogs"로 돼있는 데이터들이 나온다! 신기방기!

Bare Minimum 구현

  • App 루트 컴포넌트(App.js)
    • react.lazy()suspense를 사용하여 컴포넌트를 리팩토링합니다.
  • BlogDetail 컴포넌트(BlogDetail.js)
    • 현재는 개별 블로그 내용으로 진입해도 내용이 보이지 않습니다.
    • useParams을 이용하여 개별 id를 받아와 개별 블로그의 내용이 보일 수 있도록 해봅니다.
  • CreateBlog 컴포넌트(CreateBlog.js)
    • import 를 이용하여 Footer 컴포넌트를 연결합니다.
    • 등록 버튼을 누르면 게시물이 등록이 되며 home으로 리다이렉트 되어야 합니다.
    • fetchuseNavigate를 이용하여 handleSubmit 이벤트를 완성해봅니다.
  • UseFetch 컴포넌트(UseFetch.js)
    • GET 메소드를 통해 데이터를 받아오는 useEffect hook은 컴포넌트 내 여기저기 존재하고 있습니다.
    • 해당 hook은 반복이 되는 부분이 있으므로 어떻게 custom hook으로 만들 수 있을 지 고민해봅시다.

lazy(), Suspense 사용!

App.js에서 사용했다.

// const Home = React.lazy (() => import("./Home"));
const Home = lazy(() => import("./Home"));
const CreateBlog = lazy(() => import("./blogComponent/CreateBlog"));
const BlogDetails = lazy(() => import("./blogComponent/BlogDetail"));
const NotFound = lazy(() => import("./component/NotFound"));
...
function App() {
  return (
    <BrowserRouter>
      ...
          <Suspense fallback={<div>로딩...</div>}>
            <Routes>
              <Route exact path="/" element={<Home blogs={blogs} isPending={isPending} />} />
              <Route path="/create" element={<CreateBlog />} />
              <Route path="/blogs/:id" element={<BlogDetails />} />
              <Route path="/blogs/:id" element={<NotFound />} />
            </Routes>
          </Suspense>
        ...
    </BrowserRouter>
  );
}

커스텀훅으로 리팩토링

먼저 fetch를 하는데 여러번 나와서 커스텀훅으로 먼저 리팩토링 해줬다.

// ~/fe-sprint-react-custom-hooks/src/util/useFetch.js
import { useState, useEffect } from "react";

const useFetch = (url) => {
  /* useState를 이용하여 data, isPending, error를 정의 */
  const [blogs, setBlogs] = useState(null);
  const [isPending, setIsPending] = useState(true);
  const [error, setError] = useState(null);

  /* useFetch 안의 중심 로직을 작성. */
  useEffect(() => {
    setTimeout(() => {
      fetch(url)
        .then((res) => {
          if (!res.ok) {
            throw Error("could not fetch the data for that resource");
          }
          return res.json();
        })
        .then((data) => {
          setIsPending(false);
          setBlogs(data);
          setError(null);
        })
        .catch((err) => {
          setIsPending(false);
          setError(err.message);
        });
    }, 1000);
  }, []);

  /* return 문을 작성. */
  return [blogs, isPending, error];
};

export default useFetch;

이걸로 App.js, BlogDetail.js에 커스텀훅 적용해줬다!

// App.js
import useFetch from "../util/useFetch";
const [blog, isPending, error] = useFetch("http://localhost:3001/blogs/");
// BlogDetail.js
const [blog, isPending, error] = useFetch(`http://localhost:3001/blogs/${id}`);

이런 식으로 커스텀 훅으로 받아줬고, 컴포넌트에선 [~]를 써서 디스트럭처링 해줬다. 조아!

useParams 사용기

현재 BlogDetail 컴포넌트(BlogDetail.js)는 개별 블로그 내용으로 진입해도 내용이 보이지 않고있다. useParams을 이용하여 개별 id를 받아와 개별 블로그의 내용이 보일 수 있도록 해보자!

react-router-dom 에서 제공하는 Hook으로 현재 URL에서 path parameter(동적 매개변수)를 추출할 수 있는 훅
기본적으로 객체를 리턴

실사용

const { id } = useParams();
const [blog, isPending, error] = useFetch(`http://localhost:3001/blogs/${id}`);

id로 페이지가 바뀌니 useParams()로 id를 뽑은 다음 위의 useFetch 훅을 사용해서 id를 붙여 렌더링 되게 했음!
참고: json 서버가 자동으로 id와 뒤 매개변수를 연결되게 해줘 id로 추출할 수 있었음!

useNavigate 사용기

제출 컴포넌트에서 fetchuseNavigate를 이용해 리다이렉트하여 handleSubmit 이벤트를 완성해보자!

react-router-dom 에서 제공하는 Hook으로 양식이 제출되거나 특정 event가 발생할 때, url을 조작할 수 있는 interface를 제공

실사용

제출이니 기본적으로 서버에 HTTP 요청을 POST로 하여 옵션을 정해 보낸다. body와 메소드 잘 입력!

const CreateBlog = () => {
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");
  const [author, setAuthor] = useState("윤뿔소");
  const navigate = useNavigate();

  const handleSubmit = (e) => {
    e.preventDefault();
    /* 등록 버튼을 누르면 게시물이 등록이 되며 home으로 리다이렉트 되어야 한다. */
    /* 작성한 내용과 useNavigate를 이용하여 작성. */
    //  console.log(e.type);
    const bodys = { title, body, author, likes: 0 };
    const request = {
      method: "POST",
      body: JSON.stringify(bodys),
      headers: { "Content-Type": "application/json" },
    };

    fetch("http://localhost:3001/blogs", request)
      .then(() => {
        navigate("/");
        window.location.reload();
      })
      .catch((err) => console.log(err));
  };

임시 json 서버를 만들어서 핫로딩이 안돼서 window.location.reload()를 써줘 목록이 바로 반영되고, navigate("/")를 써줘 응답을 받으면 리다이렉트 되게 만들었다!

Advanced

  • 블로그 글 클릭해서 들어갔을 때 스크롤 맨 위로 적용되는 기능 구현하기
    • 적용된 컴포넌트 진입시 페이지 맨 위로 스크롤해주는 기능을 구현해볼 수 있습니다.
    • 이 기능 또한 useEffect를 이용해 구현할 수 있으므로, custom hook으로 만들기 적절합니다.
    • 어떻게 구현할 수 있을 지 페어와 함께 고민하고 custom hook으로 만들어봅시다.
  • 삭제, 하트 버튼 구현하기
    • DELETE, PUT으로 구현
    • isLike state가 false 이고, 0보다 큰 경우 하트 수 감소하고, true면 증가하는 식
  • input.js 로 리팩토링하기
  • 블로그 글 안에 있는 하트를 눌렀을때 한번 누른 하트는 페이지 이동을해도 변하지 않게 하기
    • 이거는 로컬스토리지로 할 수 있지만 API를 수정하여 isLiskes같은 항목을 추가하여 렌더링 되게 할 수 있다!

알게된 것

  • 서버와 통신 HTTP 요청을 다시 복습한 느낌이 강했다! 더 공부해서 자유자재로 놀아보자
  • 생각보다 리액트의 훅에서 유용한 게 많다. 특히 memo랑 라우터중 navigate 등등.. 공부할 게 너무 많아~
  • 리팩토링하는 법을 배웠다. 하드코딩된 것들을 컴포넌트로 떼와서 <Input label={label}>뭐 이런 식으로 해서 한줄로 줄일 수 있다.
  • 최적화는 많이 중요하다. 점점 클라이언트에서 연산해 렌더링을 많이 하기 때문에 열심히 줄이도록 노력하자
    • 참고! 모듈을 불러올 때 지정하는 건 중요하다!
/* 이렇게 lodash 라이브러리를 전체를 불러와서 그 안에 들은 메소드를 꺼내 쓰는 것은 비효율적입니다.*/
import _ from 'lodash';

...

_.find([]);

/* 이렇게 lodash의 메소드 중 하나를 불러와 쓰는 것이 앱의 성능에 더 좋습니다.*/
import find from 'lodash/find';

find([]);
profile
코뿔소처럼 저돌적으로

0개의 댓글