혼자 JS, Serverless 갖고 놀기 - 컴포넌트 심화편
지난 포스팅에선 모든 컴포넌트가 상속받는 공통 컴포넌트의 메서드에 대해서 소개했었다. 이번 포스팅은 일부 메서드에 추가 기능을 더해서 컴포넌트 시스템을 강화하고자 한다.
일단 자식 컴포넌트에 매개변수를 전달할 수 있도록 생성자를 수정한다.
export default class Component {
$target;
state = {};
refs = {};
children = [];
props = {}; /**@new */
constructor(selector, props) {
this.$target = document.querySelector(selector);
if (!this.$target) return;
this.props = { ...props } /**@new */
this.setup();
this.hydrate();
}
props
필드를 하나 만들고 생성할 때 전달받도록 한다.
매개변수는 혹시 모를 변경이 부모 컴포넌트에 영향을 미치는 것을 방지하기 위해 복사본을 저장한다.
위 코드대로라면 매개변수를 갱신하기 위해 전달해서 리렌더링을 시키려고 해도 새로운 인스턴트를 만들어낸다.
mounted
에서 자식 컴포넌트를 마운트할 때, 생성자를 그대로 쓰더라도 기존에 인스턴스가 만들어진 적이 있다면 해당 인스턴스의 render
를 촉발시키는 게 목표이다. 싱글톤 패턴이 해결법이 될 수 있다.
싱글톤은 클래스 외부에 존재하는 변수에 클래스의 인스턴스를 저장하고, 두 번째 생성자 호출부터는 그 인스턴스를 반환해서 한 번만 생성이 이루어지도록 하는 패턴이다.
let singleton;
class OnlyOne {
constructor() {
if (singleton) return singleton;
singleton = this;
}
}
첫 호출 시에 this
, 즉 인스턴스 자체를 singleton
에 저장한다.
이를 응용해서 리렌더링 로직을 구현한다.
const singleton = {}; /**@new */
export default class Component {
$target;
state = {};
refs = {};
children = [];
props = {};
constructor(selector, props) {
if (!selector.startsWith('#')) { /**@new */
console.error('selector is not Id');
return;
}
if (singleton[selector]) { /**@new */
singleton[selector].props = { ...props };
singleton[selector].render();
return singleton[selector];
}
this.$target = document.querySelector(selector);
if (!this.$target) return;
singleton[selector] = this; /**@new */
this.props = { ...props };
this.setup();
this.hydrate();
}
싱글톤을 쓴다고 해서 컴포넌트 당 무조건 하나의 인스턴스만 갖게 만드는 건 컴포넌트의 재사용성을 약화시킨다. 때문에 싱글톤에 저장하는 기준을 선택자로 구분했다.
생성자의 selector
는 HTML 요소의 id
속성값을 사용한다는 규칙을 자체적으로 세웠다. id
속성은 document 내에서 유일한 값을 가져야 한다는 원칙을 기본적으로 가지고 있다. 이 id
속성값을 key로 해서 singleton
객체에 인스턴스를 저장한다.
사실
id
의 유일성이 강제되는 것은 아니다. 이는 약간 불안한 감이 있긴 하다. 거슬린다면 컴포넌트 생성시 uuid 같은 걸 만들어서 사용하는 방법도 가능하리라 생각한다.
싱글톤에 인스턴스가 저장된 클래스를 재 호출되면 매개변수인 props
를 업데이트하고 render
로 흐름이 이동한다.
mounted() {
const slideBarProp = {
curSlideIndex: this.state.slideIndex,
moveSlide: this.moveSlide.bind(this),
};
this.addChild(SlideBar, ID.SLIDE_BAR, slideBarProp);
const scrollIndicatorProp = {
curPathname: this.props.curPathname,
curSlideIndex: this.state.slideIndex,
loadPageData: this.moveSlide.bind(this),
};
this.addChild(
ScrollIndicator,
ID.SCROLL_INDICATOR_HORIZON,
scrollIndicatorProp,
);
super.mounted();
}
addChild(Child, selector, props /**@new */) {
const child = new Child(selector, props /**@new */);
if (!this.children.includes(child)) {
this.children.push(child);
}
return child;
}
mounted
에 자식 컴포넌트 생성 로직이 추상화되어있는 addChild
를 사용하면 생성 및 리렌더링을 구현할 수 있다.
현재는 이벤트 버블링을 이용하긴 하지만 이벤트 핸들러를 이벤트마다 추가하고 있어 메모리 낭비가 있다. 핸들러를 루트 컴포넌트에만 달아 이벤트 위임을 구현한다.
먼저 루트 컴포넌트 지정이 가능하도록 생성자를 수정한다.
const singleton = {};
const eventCallbacks = {}; /**@new */
export default class Component {
$target;
root; /**@new */
state = {};
refs = {};
props = {};
children = [];
events = []; /**@new */
constructor(selector, props, root) {
/**@생략 */
singleton[selector] = this;
this.props = { ...props };
this.root = root || this.id; /**@new */
this.setup();
this.hydrate();
}
/**@생략 */
get id() { /**@new */
return this.$target.id;
}
get idSelector() {
return `#${this.id}`;
}
get isRoot() {
return this.root === this.id;
}
}
생성자 호출시 root
를 지정하지 않으면 자신의 id를 저장해 루트 컴포넌트로서 작동하도록 한다. id와 root를 쉽게 다루기 위한 get 메서드들도 추가한다.
이벤트 콜백을 저장할 변수인 eventCallbacks
와 events
도 추가한다.
addChild
에서는 root
의 기본값을 현재 컴포넌트의 root로 지정해 모든 자식 컴포넌트가 하나의 루트 컴포넌트를 가리킬 수 있도록 한다.
addChild(
Child,
selector,
props,
root = this.root /**@new */
) {
const child = new Child(selector, props, root);
if (!this.children.includes(child)) {
this.children.push(child);
}
return child;
}
addEvent
는 이벤트 핸들러를 등록하지 않고 콜백을 변수에 저장하는 역할로 변경한다.
/**@all new */
addEvent(eventType, selector, callback) {
if (!eventCallbacks[this.root]) return;
const callbackInfo = {
selector,
callback,
};
eventCallbacks[this.root][eventType]?.push(callbackInfo);
this.events.push({
eventType,
callbackInfo,
});
}
eventCallbacks
는 루트 컴포넌트의 이벤트 타입별 콜백을 저장하는 기능을 한다.
events
는 컴포넌트가 추가한 이벤트의 기록을 저장한다.
setup() {
if (this.isRoot) { /**@new */
eventCallbacks[this.id] = {
click: [],
scroll: [],
mousemove: [],
wheel: [],
touchmove: [],
popstate: [],
};
}
}
hydrate() {
if (this.isRoot) this.setEventDeligation(); /**@new */
this.render();
}
setup
에서 현재 컴포넌트가 루트라면 eventCallbacks
를 초기화한다. 현재는 프로젝트에서 사용하는 이벤트 타입만 명시되어 있다.
hydrate
에는 루트 컴포넌트에 이벤트 핸들러를 등록하는 기능을 추가할 것이다.
setEventDeligation
에선 배열에 저장한 콜백을 핸들러에서 실행하기 위해 등록한다.
setEventDeligation() {
if (!this.isRoot) return;
for (const [eventType, targetList] of Object.entries(
eventCallbacks[this.id],
)) {
this.$target.addEventListener(eventType, (event) => {
targetList.forEach(({ selector, callback }) => {
if (!event.target.closest(selector)) return false;
callback(event);
});
});
}
}
addEventListener
에서 등록한 targetList
는 참조값으로 접근하는 배열이기 때문에 등록 이후에 콜백이 추가되더라도 핸들러에서 실행할 수 있다. 물론 이를 위해선 eventCallbacks
의 배열을 업데이트할 때 참조값을 변경해선 안된다.
이제 컴포넌트 트리의 이벤트들을 이벤트 타입 당 핸들러 하나로 관리해서 메모리를 절약할 수 있다.
현재 컴포넌트 설계대로 개발을 진행하면서 컴포넌트 하위에 자식 컴포넌트를 조금씩 추가하다보니 컴포넌트 간 상태가 꼬이면서 디버깅이 어려워지는 걸 느낄 수 있었다.
state 변경을 모아서 처리하는 batching을 통해 문제를 해결해보고자 한다.
현재 setState
는 컴포넌트의 state
에 바로 반영하고 리렌더링을 바로 촉발시키는 방식으로 구현되어있다.
setState(nextState) {
this.state = { ...this.state, ...nextState };
this.render(); // 즉시 렌더링
}
이 방식은 컴포넌트 구조가 조금만 복잡해져도 렌더링 흐름이 꼬인다.
부모 컴포넌트가 자식 컴포넌트에 상태를 변경시키는 함수를 전달하는 상황을 고려해본다.
class Parent extends Component {
setup() {
this.state = {
count: 0,
};
}
mounted() {
const myChildProp = {
increaseCount: this.setState({
count: this.state.count + 1,
}),
};
this.addChild(MyChild, ID.MY_CHILD, myChildProp);
}
}
class MyChild extends Component {
hydrate() {
this.props.increaseCount();
super.hydrate();
}
}
이러면 자식 컴포넌트의 렌더링이 진행되던 중에 부모의 렌더링 흐름이 끼어든다.
🔽
MyChild
컴포넌트의 hydrate 주기 그래프, 첫 렌더링이 일어나기도 전에increaseCount
로 인한 리렌더링이 일어나고 있다.
해결하려면 즉시 렌더링을 촉발하진 말아야 한다. 이 때 유용한 것이 requestAnimationFrame
이다. requestAnimationFrame
은 등록한 콜백을 디스플레이 프레임 변화가 시작되는 시점에 실행시켜주는 기능을 한다. 렌더링은 화면 요소의 변화를 촉발하니 프레임 구간에 맞춰 실행시키는 게 합리적이다.
이왕 미루는 김에 모아서 한번에 하면 효율적일 것 같다. 일정 기간 내의 상태 변화를 병합해서 한번에 처리한다는 게 배칭의 핵심 개념이다.
const stateStore = {};
export default class Component {
raf = null;
/**@ 생략 */
setState(nextState) {
stateStore[this.id] = { ...stateStore[this.id], ...nextState };
cancelAnimationFrame(this.raf);
this.raf = requestAnimationFrame(() => {
this.state = stateStore[this.id];
this.render();
});
}
/**@ 생략 */
}
먼저 상태 변화 내역을 축적시키기 위해 전역에 stateStore
라는 상태 저장소를 만들어 저장한다.
setState
에서는 상태 변화 내역을 컴포넌트의 state
에 반영하고 리렌더링을 촉발하는 콜백을 requestAnimationFrame
에 등록한다. 등록 전에는 이미 대기중인 requestAnimationFrame
을 취소하도록 cancelAnimationFrame
을 실행해서 배칭 및 디바운싱이 이루어질 수 있도록 한다.
이젠 기존 렌더링이 모두 끝난 뒤에 다음 렌더링이 수행되므로 흐름이 중간에 꼬이지 않는다.
상태 변화 전후가 동일하다면 리렌더링을 다시 할 필요가 없다. state
를 비교하는 로직을 구현한다.
setState(nextState) {
stateStore[this.id] = { ...stateStore[this.id], ...nextState };
cancelAnimationFrame(this.raf);
if (this.shallowEqualState()) return; /**@new */
this.raf = requestAnimationFrame(() => {
this.state = stateStore[this.id];
this.render();
});
}
shallowEqualState() {
const keys = Object.keys(this.state);
const nextKeys = Object.keys(stateStore[this.id]);
if (keys.length !== nextKeys.length) return false;
for (const key of keys) {
if (this.state[key] !== stateStore[this.id][key]) return false;
}
return true;
}
현재 state
는 객체인데 깊은 비교는 계산 코스트가 과하기에 얕은 비교만 한다. 배칭 결과로 남은 상태 변화 내역이 기존과 동일하면 setState
를 취소한다.
이제 언마운트할 때 싱글톤과 상태 저장소, 이벤트들을 삭제하고 requestAnimationFrame
을 취소해줘야 오류가 발생하지 않는다.
unmount() {
this.children.forEach((child) => {
child.unmount();
});
singleton[this.idSelector] = null; /**@new */
stateStore[this.id] = {}; /**@new */
cancelAnimationFrame(this.raf); /**@new */
/**@new */
this.events.forEach(({ eventType, callbackInfo }) => {
const targetIndice = [];
eventCallbacks[this.root][eventType].forEach(
(prevCallbackInfo, index) => {
if (prevCallbackInfo === callbackInfo) {
targetIndice.push(index);
}
},
);
targetIndice.reverse().forEach((target) => {
eventCallbacks[this.root][eventType].splice(target, 1);
});
});
this.$target.remove();
this.$target = null;
}