넘블 챌린지 신년 메시지 주고받기 회고

jinabbb·2023년 1월 19일
0
post-thumbnail

넘블에서 진행한 신년 메시지 주고받기 챌린지 회고

23.01.05 ~ 23.01.19
vanilla js만으로 간단한 게시판 형태의 SPA를 구현하는 챌린지

소스코드 GIT


핵심 로직

Component.ts

class Component<T, K> {
	parentEl: Element;
	state: T;
	props: K;
	constructor({ parentEl, props }: { parentEl: Element; props: K }) {
		this.parentEl = parentEl;
		this.props = props;
		this.setup();
		this.render();
		this.onMounted();
	}
	setup() {}
	template() {
		return ``;
	}
	render() {
		this.parentEl.innerHTML = this.template();
		this.setEvent();
		this.update();
	}
	unMount() {
		this.parentEl.remove();
	}
	setEvent() {}
	setState(newState: Partial<T>) {
		this.state = { ...this.state, ...newState };
		this.render();
	}
	addEvent(eventType: string, selector: string, callback: (event: Event) => void) {
		const component = this.parentEl.children[0];
		const children = [...this.parentEl.querySelectorAll(selector)];
		const isTarget = (target: Element) => children.includes(target) || target.closest(selector);
		component.addEventListener(eventType, (event) => {
			if (!isTarget(event.target as Element)) return false;
			if (eventType === 'input') {
				this.autoFocus(selector, callback, event);
				return;
			}
			callback(event);
		});
	}
	onMounted() {}
	update() {}
	autoFocus(selector: string, callback: (event: Event) => void, event: Event) {
		const prevEl = this.parentEl.querySelector(selector) as HTMLInputElement;
		const cursor = prevEl.selectionStart;
		callback(event);
		const nextEl = this.parentEl.querySelector(selector) as HTMLInputElement;
		nextEl.focus();
		nextEl.setSelectionRange(cursor, cursor);
	}
}
export default Component;

기본적인 라이프싸이클
setup -> render -> setevent -> update -> onMounted

state가 변경되면 render -> setevent -> update가 다시 호출된다.

참고한 블로그

router.ts

import Detail from 'src/pages/Detail';
import Edit from 'src/pages/Edit';
import Main from 'src/pages/Main';
import Post from 'src/pages/Post';

const routes = [
	{ path: /^\/$/, element: Main },
	{ path: /^\/post\/?$/, element: Post },
	{ path: /^\/post\/[\w]/, element: Detail },
	{ path: /^\/edit\/[\w]/, element: Edit },
];

export const navigate = (to: string, isReplace = false) => {
	const historyChangeEvent = new CustomEvent('historychange', {
		detail: {
			to,
			isReplace,
		},
	});

	dispatchEvent(historyChangeEvent);
};

class Router {
	parentEl: Element;
	constructor(parentEl: Element) {
		this.parentEl = parentEl;
		this.init();
		this.route();
	}
	findMatchedRoute() {
		return routes.find((route) => route.path.test(location.pathname));
	}
	route() {
		const matchedRoute = this.findMatchedRoute();
		if (matchedRoute) {
			new matchedRoute.element({ parentEl: this.parentEl, props: null });
		}
	}
	init() {
		window.addEventListener('historychange', (e) => {
			const { detail } = e as CustomEvent;
			const { to, isReplace } = detail;

			if (isReplace || to === location.pathname) history.replaceState(null, '', to);
			else history.pushState(null, '', to);

			this.route();
		});

		window.addEventListener('popstate', () => {
			this.route();
		});
	}
}

export default Router;

CustomEvent를 활용하여 url이 변경되면 감지해서 route를 호출해 새페이지를 렌더링한다.
참고한 블로그


고민했던 부분들

Controlled Component

input 값을 state로 관리하려고 시도했다. 한 글자 한 글자 input 이벤트로 입력을 받아 state를 업데이트하면 render()가 호출되면서 화면이 새로 그려지면서 인풋엘리먼트가 포커스를 잃었다..
Component의 autoFocus 메소드를 구현해 render()전의 커서값을 render()후에 포커싱하고 사용하게 하여 해결하였다.

이벤트 중복

처음에는 Component의 parentEl에 이벤트를 등록해서 사용했는데, 라우팅이 변경되어도 이벤트가 유지되는 버그가 생겼다. 새로 렌더링 될때마다 고유한 엘리먼트에 이벤트를 등록하기 위해 parentEl의 첫번째 자식 엘리먼트에 이벤트를 등록하여 해결하였다. =>때문에 모든 컴포넌트는 단일 엘리먼트로 이루어져야 한다. 리액트에서도 컴포넌트는 꼭 하나의 엘리먼트를 리턴해야하는데 혹시 이부분때문인가..? 추측중이다


후기

취업준비 하면서 틈틈히 했는데 vanillajs로 리액트 동작을 흉내내면서 만들어보는게 재밌어서 꽤 시간을 많이 쓴것같다.
지금 이 코드는 불필요한 연산이 너무 많은데 리액트의 virtual DOM처럼 연산을 줄일 방법을 고민해보는 것도 좋을듯하다.

profile
개발

0개의 댓글