Vanilla JS로 컴포넌트 만들기
배경
- 리액트에서는 컴포넌트를 기반으로 코드를 작성할 수 있었기에, 반복되는 부분을 컴포넌트로 분리할 수 있었다.
- Vanilla JS로 구현한다면 반복되는 태그들을 어떻게 처리할 수 있을까?
- 프로젝트에 필요한 커스텀 컴포넌트를 만들어 보기로 했다.
컴포넌트?
- 컴포넌트가 갖춰야 할 것
- 고유한 자바스크립트 클래스
- 외부코드가 접근할 수 없으며 해당 클래스에서만 관리되는 DOM 구조 (캡슐화 원칙)
- 구성요소에 적용되는 CSS 스타일
- 다른 구성요소와 상호작용하기 위한 이벤트, 클래스, 메서드 등을 일컫는 API
- 컴포넌트의 역할
- 컴포넌트 구현을 위해, 익숙한 리액트의 컴포넌트 클래스를 참고했다.
- DOM Element를 출력할 수 있어야 함 (render)
- 부모에게서 읽기 전용 데이터를 상속받을 수 있어야 함 (props)
- 컴포넌트 내에서 사용되는 데이터를 입력받고, 입력 데이터의 변경에 따라 출력 결과를 다르게 할 수 있어야 함 (state)
컴포넌트 구현 - 생성 및 렌더링
1. props와 tag를 전달받아, 새로운 요소를 생성한다.
2. setup()에서 상태를 초기화하거나, 스타일을 적용하는 작업을 한다.
3. template()에서 렌더링할 HTML 템플릿을 작성한다.
4. render()로 생성한 요소를 렌더링한다.
5. mounted()로 렌더링 이후 추가 작업을 실행한다.
/**
*
* @param {any} [props] - 부모에서 컴포넌트에 전달할 데이터
* @param {HTMLElement} [tag] - 생성할 HTML 태그 종류
*/
constructor(props = {}, tag = "div") {
this.props = props;
this.element = document.createElement(tag);
this.setup();
this.render();
}
/**
* @description 컴포넌트의 상태를 초기화합니다.
*/
setup() {
this.state = {};
}
/**
* @description 템플릿 메서드 (상속받은 클래스가 구현)
* @returns {string} 컴포넌트의 HTML 문자열을 반환
*/
// this.state를 사용하여 템플릿을 구성합니다.
// eslint-disable-next-line class-methods-use-this
template() {
return "";
}
/**
* @description 컴포넌트를 렌더링합니다.
*/
render() {
this.element.innerHTML = this.template();
this.mounted();
}
/**
* @description 컴포넌트 렌더링 이후 실행할 작업을 정의합니다.
*/
// 주로 자식 렌더링이 이루어지므로 this가 상속받은 클래스에서 사용됩니다.
// eslint-disable-next-line class-methods-use-this
mounted() {}
컴포넌트 구현 - 상태변경
6. 상태 변경 시 state 객체를 재할당, 리렌더링한다.
/**
* @description 컴포넌트의 상태를 변경하고, 변경된 상태를 반영하여 다시 렌더링합니다.
* @param {any} newState - 변경된 상태
*/
setState(newState) {
this.state = { ...this.state, ...newState };
this.render();
}
회고
해결해야할 문제/남아있는 의문은 무엇인가?
- 외부에서 변수들에 직접 접근할 수 있어, 데이터의 무결성이 깨지고 캡슐화 원칙을 지키지 못함.
const button = new Button({ content: "로그인" });
form.appendChild(button.element);
- 이벤트 관련 처리의 필요성을 아직 느끼지 못해, 컴포넌트 외부에서 이벤트 처리를 하고 있음.
- 해결하지 못한 innerHTML의 문제점...
- 비어 있는 생명주기 메서드
- 상태 업데이트를 꼭 객체 재할당으로 처리해야 할까?
어떻게 개선할 것인가?
- 외부 변수 접근 제한, 부모 컴포넌트 지정을 컴포넌트 내부에서 처리하도록 리팩토링. 이때 컴포넌트 인스턴스를 생성 시 바로 부모 컴포넌트 지정을 하지 않을 수도 있기 때문에 생성자가 아닌 메서드에서 처리하는 것이 좋을 것 같음.
- 기능을 구현하면서 이벤트 핸들러가 언제 부착되고 해제되는지 확인하기
- 자바스크립트 클래스 복습, 개선할 수 있는 부분 찾아보기
- insertAdjacentHTML 등 innerHTML의 대안 찾아보기
- 기능을 구현하면서 상태가 어떻게 관리되고 있으며, 어떤 생명주기 메서드가 추가되어야 하는지 살펴보기
- 리액트 클래스 컴포넌트에서 상태 관리 어떻게 구현했는지 살펴보기
Vanilla JS로 SPA 구현하기
배경
- 컨텐츠의 양이 많지 않기 때문에 사용자 경험을 향상시킬 수 있는 SPA 방식으로 프로젝트를 구현하려고 한다.
싱글 페이지 애플리케이션(SPA)이란?
- 렌더링과 라우팅에 필요한 대부분의 기능을, 서버가 아닌 브라우저의 자바스크립트에 의존하는 방식
- 최초에 첫 페이지에서 데이터를 모두 불러온 이후에는, 페이지 전환을 위한 모든 작업이 자바스크립트와 브라우저에서 일어남
- 최초 로딩해야 할 자바스크립트 리소스가 커지는 단점이 있지만 한번 로딩된 이후에는 서버를 거쳐 리소스를 받아올 일이 없기 때문에 사용자에게 훌륭한 UI/UX를 제공할 수 있음
History API로 클라이언트 사이드 라우팅 구현하기
History API?
- History API의 History 인터페이스는 현재 페이지가 로드된 탭이나 프레임 내에서 브라우저 세션 히스토리(방문한 페이지들)를 조작할 수 있게 해줌
- history라는 전역 객체를 통해 단일 인스턴스로 제공
- 메인 스레드에서만 사용 가능
- 인스턴스 메서드
- back() - 이전 페이지로 이동
- forward() - 다음 페이지로 이동
- go() - 현재 페이지와의 상대 위치로 지정된 세션 히스토리의 페이지를 비동기로 로드
- pushState() - 새로운 데이터를 세션 히스토리 스택에 추가
- replaceState() - 세션 히스토리 스택의 가장 최근 항목을 업데이트
History API로 SPA 구현
- move 함수
- 해당 pathname에 해당하는 페이지가 존재하고, 초기화 경우가 아니라면 history.pushState() 해준다.
- 페이지 클래스를 가져와 params를 전달하여 인스턴스를 생성한다.
- content 태그 하위에 리렌더링한다.
- 페이지가 존재하지 않는 경우, fallback으로 location.href를 이용하여 페이지를 이동한다.
/**
*
* @param {string} url - 이동할 URL
* @param {boolean} isInit - 초기화 여부
*/
const move = (url, isInit = false) => {
const { pathname, params } = usePathName(url);
if (routes[pathname]) {
if (!isInit) history.pushState({}, "", url);
const Page = routes[pathname];
const page = new Page({ searchParams: params });
const content = document.getElementById("content");
content.innerHTML = "";
content.appendChild(page.element);
return;
}
location.href = url;
};
- push, init, back 함수
- push - move 함수를 이용하여 라우팅
- init - 초기 진입 시 routes 등록 및 pushState 하지 않고 렌더링
- back - history.back()
/**
* @param {string} url - 이동할 URL
*/
const push = (url) => {
move(url);
};
/**
*
* @param {object} param - 초기화할 라우터 정보
*/
const init = (param) => {
routes = param.routes;
move(location.pathname + location.search, true);
};
const back = () => {
history.back();
};
- 사용
const router = useRouter();
router.push("/");
History API의 단점
- iframe 내 히스토리 변경 내역이 최상위 프레임의 히스토리를 오염시킴
- 또한 상태에 있어서 불안정함
Navigation API로 클라이언트 사이드 라우팅 구현하기
Navigation API?
- Navigation API는 브라우저 탐색 동작을 시작, 가로채기, 관리하는 기능을 제공
- History API 및 와 같은 이전 웹 플랫폼 기능의 후속 기능
- 특히 SPA 요구 사항에 부합
- 전역 객체인 Navigation의 참조를 반환하는 Window.navigation 속성으로 접근
- 각 window마다 navigation 인스턴스 존재
- 인스턴스 메서드
- back() - 한 entry 뒤로 이동
- forward() - 한 entry 앞으로 이동
- navigate() - 특정 URL로 이동하며 히스토리 entries list의 모든 상태를 업데이트
- updateCurrentEntry() - CurrentEntry의 상태를 업데이트. 상태 변경이 탐색 또는 새로고침과 독립적인 경우에 사용
- 이벤트
- navigate - 모든 유형의 탐색이 시작될 때 실행되어 필요에 따라 가로챌 수 있음
- navigatesuccess navigateerror - 탐색 성공, 실패 시 실행
- currententrychange - CurrentEntry가 변경되었을 때 실행
Navigation API로 SPA 구현
- 진입점에서 navigation 객체의 navigate 이벤트 핸들러 부착
- 페이지가 존재한다면 핸들러에 가로챘을 때 실행할 페이지 컴포넌트 할당
- 이후 가로채기로 해당 페이지 실행
- 최초 페이지 로드 시 동작하지 않기 때문에 이벤트리스너 외부에 페이지 함수 실행
// index.js
navigation.addEventListener("navigate", (event) => {
const url = event.destination.url.split(location.origin)[1];
const pathname = url.split("?")[0];
const params = Object.fromEntries(new URLSearchParams(url.split("?")[1]));
if (routes[pathname]) {
const routeHandler = routes[pathname]({
searchParams: params,
});
event.intercept({ handler: routeHandler });
}
});
routes[location.pathname]({
searchParams: Object.fromEntries(new URLSearchParams(location.search)),
});
- 사용
navigation.navigate("/");
Navigation API의 단점
아직 실험적 기능으로, 지원하지 않는 브라우저가 많음 → 사파리에서 안되는게 좀 크리티컬한듯..
웹 컴포넌트의 Custom elements를 활용하여 next/link 따라하기
위와 같은 Navigation API의 단점 때문에 History API를 채택하게 되면서, 모든 탐색을 감시할 수 없게 되었다. 따라서 기존의 a태그를 사용하면 클라이언트 사이드 라우팅이 불가능했다. 이를 개선하기 위해 커스텀 요소를 만들어보자.
웹 컴포넌트?
- 웹 컴포넌트란
- 모든 브라우저에서 지원하는 웹 표준 기반의 컴포넌트
- 웹 컴포넌트의 기능들
- Custom elements - 사용자 정의 HTML 요소를 정의하는 데 사용
- Shadow DOM - 다른 컴포넌트에 대해 숨겨져 있는 내부 DOM을 생성하는 데 사용
- CSS Scoping - 컴포넌트의 Shadow DOM 내부에만 적용되는 스타일을 선언하는 데 사용
- Event retargeting
Custom elements로 링크 구현
- a태그를 대신해서 사용할 Link 컴포넌트 구현
- 클릭 이벤트 발생 시 기본 동작을 막음
- href 속성으로 넘어온 경로를 router.push() 에 넘겨주어 클라이언트 사이드 라우팅 유도
// Link.js
import { useRouter } from "../utils";
const router = useRouter();
/**
* @description SPA에서 사용할 Link 컴포넌트
*/
export class Link extends HTMLAnchorElement {
/**
*
*/
constructor() {
super();
this.style.color = "pink";
this.addEventListener("click", (event) => {
event.preventDefault();
router.push(this.getAttribute("href"));
});
}
}
- 커스텀 요소 등록
// index.js
customElements.define("my-link", Link, { extends: "a" });
회고
잘한 것은 무엇인가?
- "Vanilla JS SPA 구현" 검색 시 나오는 방법을 무조건적으로 따라하지 않고, History API와 Navigation API 방식 양쪽으로 구현해보고 라우팅 방식을 채택한 것
해결해야할 문제/남아있는 의문은 무엇인가?
- 진입점에서 아래 코드들이 꼭 처리되어야 할까?
const router = useRouter();
router.init({ routes });
customElements.define("my-link", Link, { extends: "a" });
어떻게 개선할 것인가?
- react-router-dom 뜯어보기
- 웹 컴포넌트 Custom elements에 대해 더 알아보기
'프로젝트 > FRONTLINE' 카테고리의 다른 글
[9월 3주차 주간회고] 나만의 eslint 규칙 만들기, Hello Node, Hello Express! (1) | 2024.09.21 |
---|