[바닐라 자바스크립트] 함수형 컴포넌트 만들기 시리즈
1편: 컴포넌트 생성하기
2편: useState 만들기
지난 포스트에서 useState
까지 만들어봤습니다. 이번 포스트에서는 현재 프로젝트의 큰 문제점인 전체 컴포넌트 리렌더링 이슈를 개선해보겠습니다.
useState의 setState
가 호출되어 리렌더링이 될 때 어떻게 해당 컴포넌트만 리렌더링할 수 있을까요?
변경이 일어난 컴포넌트를 돔에서 찾아, 새로운 돔으로 갈아끼워주면 다른 컴포넌트들은 리렌더링되지 않을 수 있을 것 같습니다. 변경이 일어난 컴포넌트를 돔에서 쉽게 찾기 위해서 컴포넌트를 생성할때 컴포넌트 이름을 id
로 가진 div
으로 한 번 감싸 렌더링하면 쉽게 접근할 수 있습니다.
아래와 같은 흐름으로 구현하면 컴포넌트 단위 렌더링이 되도록 할 수 있을 것 같습니다.
- 컴포넌트를 생성할 때, 컴포넌트 이름을
id
로 가진div
으로 한 번 감싸 렌더링합니다.
ex)<div id="Todo">${todo.element}</div>
render
함수를 호출할때 현재 변경이 일어난 컴포넌트를 넘깁니다.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;
우선, 변경이 일어난 컴포넌트를 수월하게 찾기 위해 컴포넌트를 생성할 때 컴포넌트 이름을 id
로 가진 div
로 감싸는 것부터 해보겠습니다.
src/core/component.js
현재 생성된 컴포넌트의 이름을 id
로 가진 div
로 감싸 다시 컴포넌트 인스턴스의 element
에 할당해주었습니다.
우선, 기존에 src/index.js
에 있는 render
는 그대로 두고 새롭게 render
함수를 구현해보겠습니다.
src/core/render.js
const render = (component, props) => {
// 여기서 createComponent를 호출
};
export default render;
파라미터로 component
와 props
를 받아 createComponent
를 호출하여 변경이 일어난 컴포넌트를 다시 호출합니다.
변경이 일어나 리렌더링 해야 하는 컴포넌트를 getElementById
로 찾고, 컴포넌트가 존재한다면 갈아 끼워줍니다. innerHTML
을 사용할 경우 <div id="componentId"></div>
가 계속 중첩되어 렌더링 될테니 자신을 포함시켜 갈아끼우는 outerHTML
을 사용합니다.
render
함수는 useState
에서 사용되고 있습니다. useState
는 자신을 호출한 컴포넌트 정보를 currentComponent
를 통해 알 수 있습니다. 상태가 변경됐을때 상태를 사용하는 컴포넌트를 리렌더링 하기 위해서는 currentComponent
를 render
함수의 인수로 넘겨야 합니다.
현재 currentComponent
에 저장되는 컴포넌트 정보는 컴포넌트 id
와 stateIndex
입니다. 여기에 추가로 컴포넌트 함수
자체와 props
를 저장해야 합니다.
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의 상태가 변하더라도 다른 컴포넌트에 영향을 주지 않습니다.
이렇게 간단하게 렌더링 최적화를 해보고, 이벤트 바인딩도 부모로부터 분리하게 되었습니다. 다음 포스트에서는 전역 상태 관리를 직접 구현해보도록 하겠습니다.