재조정 및 최적화

WooBuntu·2021년 4월 4일
0

알고 쓰자 리액트

목록 보기
6/11

https://ko.reactjs.org/docs/reconciliation.html

재조정

state나 props의 변화는 함수형 컴포넌트를 '실행'하고, 이 결과 새로운 React element가 반환된다. 리액트는 이 새로운 React element와 이전 React element를 비교하여 다른 부분만 실제 DOM에 반영하게 된다.

결론부터 말하자면 리액트는 다음의 두 가지 가정에 기반하여 O(n)의 시간 복잡도로 두 React element를 비교한다.

  1. 서로 다른 타입을 가진 두 element는 서로 다른 트리를 만들어낸다.

  2. 개발자가 key값을 부여함으로써 어떤 자식 element가 변경되면 안 되는지를 알려줄 수 있다.

element의 타입이 다른 경우

a태그에서 div태그로 바뀌는 등, element의 타입이 다르면 리액트는 이전 React element를 unmount시키고 새로운 React element를 mount시킨다.(즉, 이전 React element에 붙어 있던 state는 사라진다)

element의 타입이 같은 경우

div와 같은 DOM 엘리먼트의 타입이 같은 경우

변경된 속성만 갱신한다.

<div className="before" title="stuff" />

<div className="after" title="stuff" />

위와 같은 경우에는 className만을 변경하고,

<div style={{color: 'red', fontWeight: 'bold'}} />

<div style={{color: 'green', fontWeight: 'bold'}} />

위와 같은 경우에는 color만 변경하는 것이다.

사용자 정의 컴포넌트의 타입이 같은 경우

DOM 엘리먼트와 마찬가지로 속성, 즉 props가 변경된다. 다만 이전 컴포넌트를 언마운트시키고 새 컴포넌트를 마운트시키는 것이 아니기 때문에 state는 제 값을 유지한다.

변경된 props를 인자로 자식 React element를 반환할 것이고 이 자식 React element에 대해서 다시 재귀적으로 비교 알고리즘을 수행한다.

자식 요소의 재귀적 처리

  • 자식 요소에 key가 없는 경우
{/* 이전 React element */}
<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

{/* 새 React element */}
<ul>
  <li>Connecticut</li>{/* 대체 */}
  <li>Duke</li>{/* 대체 */}
  <li>Villanova</li>{/* 삽입 */}
</ul>

만약 위 사례의 Duke와 Villanova가 DOM 엘리먼트가 아니라 state를 지닌 컴포넌트라면 단순히 렌더링 효율성의 문제를 넘어서 state 유실의 문제까지 동반하게 된다.

  • 자식 요소에 키가 있는 경우
{/* 이전 React element */}
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

{/* 새 React element */}
<ul>
  <li key="2014">Connecticut</li>{/* 삽입 */}
  <li key="2015">Duke</li>{/* 이동 */}
  <li key="2016">Villanova</li>{/* 이동 */}
</ul>

최적화

  • 클래스형 컴포넌트가 shouldComponentUpdate에서 수행했던 최적화를 hook은 다음과 같이 수행한다.

    • useCallback으로 감싼 콜백을 자식 컴포넌트로 전달하여 참조 비교를 수행한다.
    • useMemo를 통해 개별 자식들의 렌더링을 최적화한다.
    • context와 useReducer를 사용하면 콜백을 props로 깊이 전달할 필요가 없다.

useMemo

side effect가 아닌, 렌더링 중에 실행되는 고비용 계산을 불필요하게 반복하지 않기 위해 사용하는 hook이다. (useMemo에 전달된 콜백은 렌더링 중에 실행되므로, 렌더링과 상관없는 side effect는 useEffect로 넘겨줘야 한다)

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
function Parent({ a, b }) {
  // 'a'가 변경된 경우에만 다시 렌더링 됩니다:
  const child1 = useMemo(() => <Child1 a={a} />, [a]);
  // 'b'가 변경된 경우에만 다시 렌더링 됩니다:
  const child2 = useMemo(() => <Child2 b={b} />, [b]);
  return (
    <>
      {child1}
      {child2}
    </>
  )
}

React.memo

React.memo는 고차 컴포넌트이다.

만약 컴포넌트가 같은 props로 같은 결과를 렌더링한다면(컴포넌트는 순수 함수여야 하는데 안 그런 경우도 있나...?) props의 변화가 있을 때만 재렌더링하도록 React.memo로 감쌀 수 있다.

어디까지나 props의 비교에만 한정된 것으로, React.memo로 감싸진 컴포넌트 내부 state가 변하거나 해당 컴포넌트가 구독하고 있는 context가 변한다면 재렌더링을 하게 된다.

기본적으로는 props에 대해 얕은 비교를 수행하는데, 아래와 같이 두 번째 인자로 함수를 전달하여 비교 방식을 커스텀할 수도 있다.

function MyComponent(props) {
  /* props를 사용하여 렌더링 */
}
function areEqual(prevProps, nextProps) {
  /*
  nextProp가 prevProps와 동일한 값을 가지면 true를 반환하고, 그렇지 않다면 false를 반환
  */
}
export default React.memo(MyComponent, areEqual);

useCallback

불필요한 렌더링을 방지하기 위해 shouldComponentUpdate등을 사용하는 자식 컴포넌트에 콜백을 전달할 때 유용하다.

function ProductPage({ productId }) {
  // ✅ 모든 렌더링에서 변경되지 않도록 useCallback으로 래핑
  const fetchProduct = useCallback(() => {
    // ... productId로 무언가를 합니다 ...
  }, [productId]); // ✅ 모든 useCallback 종속성이 지정됩니다

  return <ProductDetails fetchProduct={fetchProduct} />;
}

function ProductDetails({ fetchProduct }) {
  useEffect(() => {
    fetchProduct();
  }, [fetchProduct]); // ✅ 모든 useEffect 종속성이 지정됩니다
  // ...
}

useCallback을 사용하지 않고 콜백 함수를 생성하여 ProductDetails로 전달하면, 해당 콜백 함수는 ProductPage가 재랜더링 될 때마다 생성될 것이다. 이 경우 새로 생성된 콜백 함수는 메모리 주소값이 달라져 ProductDetails 컴포넌트가 참조 비교를 할 경우 이전과 내용적으로 달라진 것이 없음에도 재렌더링을 하게 되는 비효율이 발생하기 때문에 useCallback으로 함수의 재생성을 최소화하는 것이다.

useReducer

복수의 하위값을 포함하거나 이전 값에 의존적인 state의 경우 useState보다 useReducer가 유용하다.

const [state, dispatch] = useReducer(reducer, initialArg, init);
  • reducer : (state, action) => newState 형태의 함수

  • initialArg : 초기 값

  • init : 초기 값을 지연 초기화하기 위한 함수(옵션 값임)

function init(initialCount) {
  return {count: initialCount};
}

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return {count: state.count + 1};
    case 'decrement':
      return {count: state.count - 1};
    case 'reset':
      return init(action.payload);
    default:
      throw new Error();
  }
}

function Counter({initialCount}) {
  const [state, dispatch] = useReducer(reducer, initialCount, init);
  return (
    <>
      Count: {state.count}
      <button
        onClick={() => dispatch({type: 'reset', payload: initialCount})}>
        Reset
      </button>
      <button onClick={() => dispatch({type: 'decrement'})}>-</button>
      <button onClick={() => dispatch({type: 'increment'})}>+</button>
    </>
  );
}

useState와 마찬가지로 이전 state와 동일한 값을 반환하는 경우 리렌더링하지 않는다.

아래와 같이 context와 조합해서 사용하면 callback을 몇 단계씩 아래로 넘겨주지 않아도 된다.

const TodosDispatch = React.createContext(null);

function TodosApp() {
  // Note: `dispatch` won't change between re-renders
  const [todos, dispatch] = useReducer(todosReducer);

  return (
    <TodosDispatch.Provider value={dispatch}>
      <DeepTree todos={todos} />
    </TodosDispatch.Provider>
  );
}

재렌더링해도 dispatch가 바뀌지 않는다는 것은 아마 setState를 포함한 callback함수를 useCallback으로 감싼 것과 같은 효과를 내는 의미인 것 같다.

가상화

  • 추후 추가 예정

0개의 댓글