[바닐라 자바스크립트로 SPA 구현] 1. 컴포넌트와 렌더링
F-Lab 멘토링 과정 중 바닐라 자바스크립트로 SPA를 구현하는 프로젝트를 진행하고 있습니다.
본격적으로 기능을 구현하기 전 틀을 잡기 위해 고민한 과정을 기록하기 위해서, 이번 포스팅에서는 바닐라 자바스크립트로 구현하는 컴포넌트 아키텍처와 렌더링에 대해서 나름대로 고민하고 결정한 내용을 정리해 봅니다.
1. 초기 구현방식
맨 처음 컴포넌트 아키텍처와 렌더링을 구현한 방식은 <프레임워크 없는 프론트엔드 개발> 책의 내용을 참고해서 진행하였습니다. 해당 도서의 렌더링 부분은 다른 글을 통해서 정리하기도 했습니다.
처음 구현했던 내용을 간단하게 요약하자면 아래와 같습니다.
- 컴포넌트는 targetElement(DOM노드)와 상태 데이터를 인자로 받아 새로운 DOM노드를 리턴하는 함수
const Header = (targetElement, state) => {
// 컴포넌트는 보통 targetElement를 state기반으로 수정해서 리턴하는 함수이지만,
// 이 함수는 항상 동일한 데이터를 리턴하므로 매개변수 미사용
const template = document.getElementById("header");
const newNode = template.content.cloneNode(true);
return newNode;
};
export default Header;
- 컴포넌트는 data-attribute로 html에 자리를 잡아두고, root에서 하위에 있는 data-attribute를 모두 찾아 컴포넌트 실행 (컴포넌트들은 레지스트리에 등록되어 있음)
- 만들어진 최종 DOM Node를 applyDiff로 이전 DOM노드와 비교하여 차이가 발생한 부분만 변경
const render = (state) => {
window.requestAnimationFrame(() => {
const main = document.querySelector("#root");
const newMain = registry.renderRoot(main, state);
applyDiff(document.body, main, newMain);
});
};
물론 DOM노드를 화면에 렌더링 하지 않더라도, 들고 다니면서 조작한 뒤에 마지막에 applyDiff를 하는 것이 리액트처럼 객체로 가상 DOM을 관리하다가 마지막에 한 번만 화면에 그리는 것보다 비효율적이라는 생각을 하기는 했습니다. 하지만 손쉬운 구현을 위해 이번 프로젝트에서는 거기까지 신경 쓰지 않기로 결정했습니다.
아래 글을 통해서 jsx를 활용해 비교적 수월하게 객체로 가상 DOM을 관리하는 방법을 접하기는 했지만, 이번 프로젝트에서는 가능한 만큼 자바스크립트에 집중하기 위해 jsx를 도입하는 방안은 배제하였습니다.
https://pomb.us/build-your-own-react/
2. 라우팅 구현 중에 느낀 불편함
1의 내용을 바탕으로 컴포넌트 방식을 만들어두고, 라우팅 기능을 추가하고자 했습니다. (라우팅 관련 내용은 추후 별도 포스팅으로 작성예정입니다.)
라우터를 만들어 경로에 따라 보여주는 컴포넌트를 변경하고자 하니, 라우터가 컴포넌트를 실행했을 때 알아서 해당 컴포넌트가 렌더링이 될 필요가 있었습니다. 그렇지 않으면 라우터에 렌더 하는 기능이 추가되어야 하는데, 이런 방식은 라우팅 로직과 렌더링 로직의 분리되지 않고 섞여 관리가 어려울 것이라 예상했습니다. 라우터는 오직 컴포넌트 호출까지만 제어하고, 렌더링은 컴포넌트 내부에서 알아서 진행하는 것이 관심사의 분리를 통해 유지보수 가능한 코드를 만들어준다고 생각했습니다.
하지만 기존의 컴포넌트는 DOM노드를 리턴하기만 하고, applyDiff로 실제 렌더링을 하는 함수는 오직 root에서만 사용되고 있었습니다. 따라서 각 컴포넌트가 렌더 함수를 가지고 있으면서, 라우터가 필요로 할 때 그 함수를 실행하는 방식이 필요했습니다.
또 다른 문제점은, 상태를 index.js에서 전역으로 관리하고 있었다는 점입니다. 물론 이러한 방식은 손쉽게 컴포넌트 간에 상태를 공유할 수 있다는 장점이 있지만, 각각의 컴포넌트가 자신의 상태를 가지고 있는 것이 조금 더 리액트에 가까운 방식이라고 생각했습니다. 또한 추후에 전역 상태관리를 별도로 만들 예정이었기 때문에, 필요한 경우 전역상태가 필요하다면 다른 방식을 적용할 수 있었습니다.
따라서 자기 자신의 render 함수와 state를 가지는 컴포넌트 구조를 새롭게 만들고자 했습니다.
3. 클래스 컴포넌트 도입
공통적인 기능을 미리 구현해 두고, 여러 컴포넌트가 공용으로 사용하고자 하니 자연스레 상속을 활용해야겠다는 생각이 들었습니다.
자바스크립트에서는 프로토타입을 이용해 상속을 구현하기 때문에 함수로도 충분히 구현 가능하지만, 리액트가 초기에 클래스를 통해 컴포넌트를 만들었기 때문에 클래스로 컴포넌트를 구현하면 리액트의 방식을 유사하게 재현할 수 있을 것이라고 생각했습니다.
개인적인 이유로는, 리액트 훅과 함수 컴포넌트가 보편화되고 난 후에 리액트를 배우기 시작했기 때문에 이번 기회에 클래스 컴포넌트에 대한 이해를 높이고자 하는 마음도 있었습니다.
이쯤, 다른 사람들은 컴포넌트를 어떻게 구현하나 찾아보니 아래 블로그를 발견할 수 있었습니다. 클래스로 컴포넌트를 구현하고 분할한 내용이 상세하게 적혀있어 많은 도움이 되었습니다.
https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Component/
결론적으로 저는 아래와 같이 컴포넌트를 구현하였습니다.
import applyDiff from "../utils/applyDiff.js";
import compressNode from "../utils/compressNode.js";
export default class Component {
state;
parent;
props;
constructor(parent, props) {
this.parent = parent;
this.props = props;
this.render();
}
mount() {
// 렌더링 후 진행할 내용
}
template() {
const name = Object.getPrototypeOf(this).constructor.name.toLowerCase();
const template = document.getElementById(name);
const newNode = template.content.cloneNode(true);
return newNode;
}
render() {
// prevNode와 newNode는 각 노드 하나여야 하므로(리스트X) 노드리스트를 div 하나로 만들어줌
let prevNode = compressNode(this.parent);
const template = this.template();
let newNode = compressNode(template);
applyDiff(this.parent, prevNode, newNode);
this.mount();
this.setEvent();
}
setEvent() {
// 인스턴스에서 렌더링 후 세팅할 이벤트 지정
}
setState(newState) {
this.state = newState;
this.render();
}
}
간단하게 설명하자면, 클래스 컴포넌트는 부모 노드와 props를 받습니다. 그리고 렌더링시에 부모노드에 있는 하위 요소들과, template() 함수를 실행해서 얻은 새롭게 화면에 뿌리고자 하는 내용을 applyDiff에 적용하여, 변경된 부분만 실제 DOM노드를 조작하게 하였습니다. 이때 applyDiff는 하나의 요소를 인자로 받는 함수이므로, compressNode 함수를 만들어 parents나 template이 여러 노드의 리스트인 경우 하나의 div안에 넣어주도록 전처리하였습니다.
개인적으로 applyDiff를 적용해 diffing 알고리즘으로 변경량을 최소화하는 부분이 가장 어려웠습니다.초기에는 this.target으로 이 컴포넌트가 만드는 노드를 가지고 있었는데, 모든 페이지 컴포넌트가
밑에 렌더링 되는 것 처럼 여러 컴포넌트가 하나의 요소를 건드리고 나면 target이 사라져있거나 의미가 없어지는 문제가 있었습니다. 결국 제가 선택한 방식은, parent를 받아오고, parent의 children과 새 템플릿을 비교하는 방식입니다. 이 방식을 적용하면서 element.childNodes와 element.children의 차이도 경험할 수 있었습니다. (childNodes는 NodeList 형태로 가져오고 children은 HTMLCollection 형태로 가져와서, NodeList에는 HTML element가 아닌것이 포함되어있습니다) 리스트로 가져온 노드를 applyDiff에 대응하기 위해 compressNode 함수도 만들어주었습니다. 역시 아키텍처를 결정하는 건 어려운 것 같습니다ㅎㅎ또한, 컴포넌트를 구현하면서 자바스크립트 안에 문자열로 html을 가지고 있는 방식이 불편해 별도 html파일을 컴포넌트와 연결하기 위한 시도를 했습니다. 따라서 template 함수는 인스턴스에서 override 할 필요 없이, 클래스 이름과 동일한 (그러나 소문자인) html파일에서 내용을 가져와 리턴하게 하였습니다. 해당 내용은 웹팩 세팅과 관련된 포스팅에서 추가로 설명하도록 하겠습니다.

이렇게 만들어진 결과는 위의 gif와 같습니다. home컴포넌트와 record 컴포넌트는 마지막 요소에만 차이가 발생하는데, 버튼을 눌러 컴포넌트간에 이동하는 경우 해당 요소만 변경되는 것을 개발자도구를 통해 확인할 수 있었습니다.
새롭게 적용한 방식이 기존의 방식보다 모든 면에서 더 나은 방식이라고 볼 수는 없습니다. root에서 모든 컴포넌트에 대한 치환을 재귀적으로 진행해주었던 기존의 방식과 달리 각 컴포넌트에서 data-attribute로 만든 하위 컴포넌트에 대한 치환을 적용해주어야 한다는 불편함이 있기 때문입니다. 각 프로젝트의 요구사항에 따라 적절한 방식을 선택하는 것이 중요한 것 같습니다.
본 포스팅에 대한 상세한 코드는 여기서 확인하실 수 있습니다.