[바닐라 자바스크립트] 함수형 컴포넌트 만들기: 렌더링 최적화, 컴포넌트 단위 렌더링

jaemin·2023년 12월 13일
0
post-thumbnail

[바닐라 자바스크립트] 함수형 컴포넌트 만들기 시리즈
1편: 컴포넌트 생성하기
2편: useState 만들기

지난 포스트에서 useState까지 만들어봤습니다. 이번 포스트에서는 현재 프로젝트의 큰 문제점인 전체 컴포넌트 리렌더링 이슈를 개선해보겠습니다.

✅ 컴포넌트 단위 렌더링

📍 계획

useState의 setState가 호출되어 리렌더링이 될 때 어떻게 해당 컴포넌트만 리렌더링할 수 있을까요?
변경이 일어난 컴포넌트를 돔에서 찾아, 새로운 돔으로 갈아끼워주면 다른 컴포넌트들은 리렌더링되지 않을 수 있을 것 같습니다. 변경이 일어난 컴포넌트를 돔에서 쉽게 찾기 위해서 컴포넌트를 생성할때 컴포넌트 이름을 id로 가진 div으로 한 번 감싸 렌더링하면 쉽게 접근할 수 있습니다.

아래와 같은 흐름으로 구현하면 컴포넌트 단위 렌더링이 되도록 할 수 있을 것 같습니다.

  1. 컴포넌트를 생성할 때, 컴포넌트 이름을 id로 가진 div으로 한 번 감싸 렌더링합니다.
    ex) <div id="Todo">${todo.element}</div>
  2. render 함수를 호출할때 현재 변경이 일어난 컴포넌트를 넘깁니다.
  3. render 함수는 변경이 일어난 컴포넌트 이름을 id로 가진 돔이 있는지 확인하고, 있다면 새로 컴포넌트를 생성하여 innerHTML로 갈아끼워줍니다.

📍 기존 코드 확인

  • src/index.js
import createComponent from './core/component.js';
import App from './App.js';

const render = () => {
	const $app = document.getElementById('app');
    const appComponent = createComponent(App);
  
  	$app.innerHTML = appComponent.element;
  	appComponent.bindEvents();
};

render();

현재 render 함수는 App 컴포넌트를 생성하고 innerHTML을 사용하여 돔에 그리도록 구현되어 있습니다. 이 함수는 useState 훅에서 setState가 호출되어 상태가 변경되면 호출됩니다. 그렇기 때문에 작은 컴포넌트의 상태가 변경되더라도 전체 App이 다시 생성되고 그려집니다.

  • src/core/hooks/useState.js
import { getCurrentComponent } from '../currentComponent.js';
import render from '../render.js';

const componentsState = {};

function useState(initialValue) {
  // ...생략

  const setState = newValue => {
    const currentState = componentsState[id][stateIndex];

    const updatedState =
      typeof newValue === 'function' ? newValue(currentState) : newValue;

    if (currentState !== updatedState) {
      componentsState[id][stateIndex] = updatedState;
      render();
    }
  };

  return [stateValue, setState];
}

export default useState;

✅ 1. 컴포넌트 생성시 div로 감싸 렌더링

우선, 변경이 일어난 컴포넌트를 수월하게 찾기 위해 컴포넌트를 생성할 때 컴포넌트 이름을 id로 가진 div로 감싸는 것부터 해보겠습니다.

  • src/core/component.js

현재 생성된 컴포넌트의 이름을 id로 가진 div로 감싸 다시 컴포넌트 인스턴스의 element에 할당해주었습니다.

✅ 2. 새로운 render 함수 구현

우선, 기존에 src/index.js에 있는 render는 그대로 두고 새롭게 render 함수를 구현해보겠습니다.

  • src/core/render.js
const render = (component, props) => {
  // 여기서 createComponent를 호출
};

export default render;

파라미터로 componentprops를 받아 createComponent를 호출하여 변경이 일어난 컴포넌트를 다시 호출합니다.

변경이 일어나 리렌더링 해야 하는 컴포넌트를 getElementById로 찾고, 컴포넌트가 존재한다면 갈아 끼워줍니다. innerHTML을 사용할 경우 <div id="componentId"></div>가 계속 중첩되어 렌더링 될테니 자신을 포함시켜 갈아끼우는 outerHTML을 사용합니다.

render 함수는 useState에서 사용되고 있습니다. useState는 자신을 호출한 컴포넌트 정보를 currentComponent를 통해 알 수 있습니다. 상태가 변경됐을때 상태를 사용하는 컴포넌트를 리렌더링 하기 위해서는 currentComponentrender 함수의 인수로 넘겨야 합니다.
현재 currentComponent에 저장되는 컴포넌트 정보는 컴포넌트 idstateIndex입니다. 여기에 추가로 컴포넌트 함수 자체와 props를 저장해야 합니다.

✅ 3. useState에서 사용할 currentComponent에 정보 추가

  • src/core/component.js

기존 render는 App 컴포넌트만 렌더링 하는 반면, 새로 구현된 render는 파라미터로 컴포넌트 함수와 props를 받아 컴포넌트 인스턴스를 생성하고 innerHTML로 렌더링하고 있습니다. 기존 render 함수를 사용하고 있는 곳을 모두 변경하도록 하겠습니다.

  • src/index.js
import createComponent from './core/component.js';
import App from './App.js';

const $app = document.getElementById('app');
const appComponent = createComponent(App);
  
$app.innerHTML = appComponent.element;
appComponent.bindEvents();

기존 render 함수를 제거하고 App 컴포넌트만 생성하여 렌더합니다.

  • src/core/useState.js

core/component.js에서 컴포넌트 함수와 props를 주입해주고 있기 때문에 useState에서 가져와 render 함수의 인수로 주입하고 있습니다.

그럼 이제, 컴포넌트 단위로 렌더링이 이루어지는지 확인해보겠습니다.

✅ 컴포넌트 단위 렌더링 확인

간단하게 제목을 토글하는 컴포넌트와 숫자를 count하는 컴포넌트 두 개를 렌더링 하겠습니다.

  • src/App.js
import createComponent from './core/component.js';
import ToggleTitle from './components/ToggleTitle.js';
import Count from './components/Count.js';

function App() {
  const toggleTitleComponent = createComponent(ToggleTitle);
  const countComponent = createComponent(Count);

  const bindEvents = () => {
    toggleTitleComponent.bindEvents();
    countComponent.bindEvents();
  };

  return {
    element: `
      <main>
        ${toggleTitleComponent.element}
        ${countComponent.element}
      </main>
    `,
    bindEvents,
  };
}

export default App;
  • src/components/ToggleTitle.js
import useState from '../core/hooks/useState.js';

function ToggleTitle() {
  const [showTitle, setShowTitle] = useState(true);

  const bindEvents = () => {
    const toggleTitleButton = document.querySelector('.toggle-title');

    toggleTitleButton.addEventListener('click', () => setShowTitle(!showTitle));
  };

  return {
    element: `
      <h1>컴포넌트 단위 렌더링</h1>
      <button class="toggle-title">제목 토글</button>
    `,
    bindEvents,
  };
}

export default ToggleTitle;
  • src/components/Count.js
import useState from '../core/hooks/useState.js';

function Count() {
  const [count, setCount] = useState(0);

  const bindEvents = () => {
    const addCount = document.querySelector('.add-count');

    addCount.addEventListener('click', () => {
      setCount(count + 1);
    });
  };

  return {
    element: `
      <div>count: ${count}</div>
      <button class="add-count">더하기</button>
    `,
    bindEvents,
  };
}

export default Count;

제대로 렌더링 되는지 확인해보았습니다.

최초 1번의 상태 변화 후에는 이벤트 리스너가 호출되지 않습니다. 그 이유는 현재 bindEvents 함수를 상위 컴포넌트에서 호출해주고 있기 때문입니다. 처음 렌더링 시에는 엔트리 포인트인 App이 생성되면서 하위 컴포넌트의 bindEvents를 호출해주지만, 그 이후에 Count 컴포넌트만 호출될 때에는 bindEvents가 호출되지 않기 때문에 이벤트 리스너가 바인딩 되지 않습니다.

이를 해결하기 위해 컴포넌트가 생성될때 bindEvents를 호출하도록 수정하겠습니다.

✅ 컴포넌트 생성시, 이벤트 바인딩 함수 호출

  • src/core/component.js

requestAnimationFrame을 사용하여 돔이 생성된 후에 이벤트 바인딩 함수가 호출하도록 하였습니다. 컴포넌트가 생성될때 이벤트 바인딩을 해주니 더 이상 상위 컴포넌트가 하위 컴포넌트의 이벤트 바인딩 함수를 호출할 필요가 없습니다

  • src/App.js
import createComponent from './core/component.js';
import ToggleTitle from './components/ToggleTitle.js';
import Count from './components/Count.js';

function App() {
  const toggleTitleComponent = createComponent(ToggleTitle);
  const countComponent = createComponent(Count);

  return {
    element: `
      <main>
        ${toggleTitleComponent.element}
        ${countComponent.element}
      </main>
    `,
  };
}

export default App;

마지막으로 결과를 확인하겠습니다.

첫 렌더링 이후에는 Count 상태나 ToggleTitle의 상태가 변하더라도 다른 컴포넌트에 영향을 주지 않습니다.

이렇게 간단하게 렌더링 최적화를 해보고, 이벤트 바인딩도 부모로부터 분리하게 되었습니다. 다음 포스트에서는 전역 상태 관리를 직접 구현해보도록 하겠습니다.

profile
프론트엔드 개발자가 되기 위해 공부 중입니다.

0개의 댓글