혼자 JS, Serverless 갖고 놀기 - 컴포넌트 기초편
프로젝트에서 만들려는 건 기본적으로 상태에 따라 DOM이 변화하는 사이트이다. Vanilla JS로 이를 쉽게 구현하고 관리하기 위해선 잘 짜여진 컴포넌트 토대가 필요하다.
자바스크립트로 컴포넌트를 만드는 방법은 클래스형과 함수형 두개로 나뉜다.
리액트도 이전 버전에서 함수형으로 전환했듯이 근본적으로는 함수형 컴포넌트가 이점이 있다. 메모리 사용량이나 가독성 등이 클래스형에 비해 유리하다고 평가받는다.
함수형이라는 명칭의 오개념으로 인한 삭제. 리액트 컴포넌트 포스팅을 참고.
하지만 여기선 클래스형 사용한다. 만들려고 하는 사이트가 데이터 송신도 없고 컴포넌트 간 깊이도 깊지 않아 복잡한 로직이 거의 없어 메모리 사용량이나 가독성이 크게 문제되지 않는다. 또한 클래스는 상속이 가능하기 때문에 부모 클래스에서 설정한 사항을 자식 컴포넌트에 강제할 수 있기에 관리하기 편하다.
함수형도 상속의 아이디어를 활용할 수 있지만 이를 위해선 프로토타입이나 리액트의 useState
같은 추가적 유틸 함수의 구현이 필요하다. 클래스보단 난이도가 높다고 볼 수 있다.
컴포넌트의 생애 주기에 익숙하지 않은 초보 개발자 입장에서의 개발 용이성을 위해 클래스형 컴포넌트를 사용한다.
노션 클로닝 과제에서는 함수형을 썼었는데 기능을 추가해서 복잡해질 수록 유연성이 줄어든다는 느낌을 받았다. 구조 설계에 익숙해지기 전까진 클래스형의 이점도 존재한다고 생각한다.
다만 자바스크립트의 클래스는 다른 언어의 것과는 동작 방식이 다르다. 때문에 예상과는 다르게 실행되는 부분에서 어려움을 느낄 수도 있다.
먼저 모든 컴포넌트가 상속하는 공통 컴포넌트를 구현한다. 공통 컴포넌트는 컴포넌트의 생애 주기에 사용되는 메서드와 상태를 업데이트하고 이벤트를 붙이는 유틸 메서드를 가지고있다. 메서드 간의 실행 순서와 기본 기능을 공통 컴포넌트에 작성해두면 상속을 통해 손쉽게 전체 컴포넌트에 적용할 수 있다.
컴포넌트가 생성되거나 리렌더링될 때 자동으로 실행되도록 연결해둔 메서드들을 의미한다.
export default class Component {
$target;
state = {};
refs = {};
children = [];
constructor(selector) {
this.$target = document.querySelector(selector);
if (!this.$target) return;
this.setup();
this.hydrate();
}
클래스 필드를 이용해 컴포넌트 동작에 필요한 변수들를 컴포넌트 내부에 선언한다.
$target
은 컴포넌트가 연결할 HTML 요소를 저장해서 DOM에 접근할 수 있도록 한다.
state
는 컴포넌트의 상태를 저장하고, refs
는 상태 이외의 값을 저장한다.
children
은 자식 컴포넌트 목록을 기록하는 역할을 한다.
컴포넌트 생성 후에는 setup
과 hydrate
로 이어진다.
setup() {
/**@note state 및 refs 초기값 설정 위치 */
}
hydrate() {
/**@note addEvent 추가 위치 */
this.render();
}
두 메서드 모두 컴포넌트 생성 이후 한 번만 실행되는 메서드이다.
실행 시점이나 횟수는 같지만 책임을 다르게 하기위해 분리했다.
setup
은 데이터의 초기화를 담당한다. 컴포넌트의 state
초기값 등을 설정한다.
hydrate
는 브라우저의 HTML 요소와 상호작용하는 기능을 담당한다. 보통 addEventListener
로 이벤트를 연결하는 용도로 사용한다. 해당 프로젝트의 경우 데이터 설정을 위해 DOM 요소에 접근하는 경우도 모두 hydrate에 포함했다.
이후 render
로 넘어가 렌더링을 수행한다.
render() {
/**@note 렌더링 코드 */
this.mounted();
}
렌더링과 관련된 코드가 위치한다. 보통 컴포넌트 HTML 요소의 classList
를 조작하는 코드를 사용한다.
상태 변경으로 인해 리랜더링이 일어나는 경우 이 메서드로 바로 연결된다.
mounted
메서드로 이어진다.
mounted() {
/**@note children 추가 작업 위치*/
}
자식 컴포넌트의 마운트를 위한 메서드이다.
이후 다른 메서드로 이어지지 않는다.
정식 명칭은 아니고 자동 실행되지 않는다는 의미로 이름을 붙였다. Lifecycle 메서드 내부나 이벤트 핸들러에서 호출해서 사용한다.
setState(nextState) {
this.state = { ...this.state, ...nextState };
this.render();
}
일단은 간단하게 state
를 업데이트하고 리렌더링을 촉발하도록 구현했다.
배칭이나 렌더링 스킵같은 사항은 컴포넌트 심화 포스트에서 추가할 예정이다.
addChild(Child, selector) {
const child = new Child(selector);
if (!this.children.includes(child)) {
this.children.push(child);
}
return child;
}
자식 컴포넌트를 추가하는 코드를 분리하여 추상화하였다. Child
는 자식 컴포넌트 class의 생성자이다.
이후 언마운트 시 연결을 위해 children
에 추가한 자식 컴포넌트들을 저장시켜둔다
addEvent (eventType, selector, callback) {
this.$target.addEventListener(eventType, event => {
if (!event.target.closest(selector)) return false;
callback(event);
})
}
이벤트 버블링을 이용하여 이벤트를 등록한다. event.target.closest
가 이벤트 버블링의 핵심으로 자기 자신부터 최상위 부모 요소까지 타고 올라가며 선택자로 지정한 요소가 존재하는지 확인하는 기능을 한다.
부모 컴포넌트에서 자식 컴포넌트의 이벤트를 처리할 수 있고, 첫 hydrate 시점에는 없다가 동적으로 생성되는 HTML 요소에도 미리 등록한 이벤트가 처리될 수 있도록 한다.
unmount() {
this.children.forEach((child) => {
child.unmount();
});
this.$target.remove();
this.$target = null;
}
요소를 HTML에서 제거하는 기능을 한다.
인스턴스를 메모리에서도 제거하려면 모든 참조를 지워서 가비지 컬렉션되도록 하는 기능이 필요하지만, 일단은 요소를 DOM tree에서 지우고 $target
을 null로 설정한다.
export default class Project extends Component {
setup() {
this.state = {
slideIndex: 0,
};
this.refs = {
imageRefs: [],
descRefs: [],
};
super.setup();
}
hydrate() {
this.refs.imageRefs = this.$target.querySelectorAll(`.project__snapshot`);
this.refs.descRefs = this.$target.querySelectorAll(`.project__description`);
super.hydrate();
}
render() {
this.syncHashIndex();
this.refs.imageRefs.forEach((imageRef, index) => {
imageRef.classList.toggle('active', index === this.state.slideIndex);
});
this.refs.descRefs.forEach((descRef, index) => {
descRef.classList.toggle('active', index === this.state.slideIndex);
});
super.render();
}
위 예시처럼 공통 컴포넌트인 Component
를 extends
로 상속하고 override하는 메서드 마지막에 super
로 부모의 메서드를 호출하면 공통 컴포넌트에서 설정한 lifecycle을 따르게 할 수 있다.
super
의 호출 위치는 렌더링 흐름이 뒤집히지 않도록 각 메서드 마지막으로 정했다.
위 코드를 보면 setup
에서 state
말고도 refs
도 초기화해서 사용하고 있다. state
와는 다르게 렌더링을 촉발하지 않는 변수의 저장용으로 사용한다.
그런데 state
를 공통 컴포넌트에서 클래스 필드로 선언해서 사용한 것 처럼 각개 컴포넌트가 필요한 변수도 클래스 필드를 이용하면 더 깔끔하게 코드를 짤 수 있을 것 같아 보이기도 한다.
하지만 클래스 필드는 현재 컴포넌트의 생애 주기와는 충돌하는 작동 방식을 가지고 있다.
🔽 컴포넌트 생애 주기 그래프
상속을 받는 클래스의 클래스 필드는 부모 생성자가 종료된 직후에 선언된다. 현재 컴포넌트의 생애 주기는 시작점은 공통 컴포넌트의 생성자이고 lifecycle 메서드는 생성자에서 호출되어 쭉 실행된다. 즉, lifecycle 메서드의 실행은 모두 생성자의 종료 시점 이전에 이루어진다. 때문에 lifecycle 메서드 내부에서 자식 컴포넌트의 클래스 필드는 정의된 상태가 아니기에 초기값을 설정해놨다 한들 사용할 수 없다.
class WhyUndefined extends Component {
itemList = [];
hydrate() {
this.itemList.push(document.querySelector("#item"));
/**@error undefined에 push가 없습니다! */
super.hydrate();
}
}
lifecycle 메서드 내부에서 값을 변경하는 것도 적용되지 않는다. 때문에 lifecycle 이외의 메서드에서는 초기값에만 접근하게된다.
class WhyUndefined extends Component {
name;
render() {
this.name = "rabbit";
super.render();
}
sayMyName() {
console.log("my name is...", this.name);
/**@log >> my name is... undefined */
}
}
때문에 아예 부모 클래스 필드에 짬처리 개별 변수 저장용으로 refs
를 정의하고, 각 컴포넌트의 setup
메서트에서 초기화해 사용하는 방식으로 구현했다.
이름은 React의
useRef
에서 따왔다.