바닐라 JS로 SPA 만들기3
View & Controller
이번 포스팅에서는 View 및 Controller에 대해 구체적으로 설명하겠다.
1. View의 공통 메서드
먼저 View의 공통 메서드를 정의한 View.js에 대해 살펴보자.
View.js는 하나의 객체로 정의되며, 그 안에 4개의 메서드를 포함하고 있다.
1) init
export default {
init(element) {
if (!element) {
throw new Error(`element가 존재하지 않습니다.`);
}
this.element = element;
return this;
},
첫 번째 메서드는 init이다.
init은 element 파라미터로 html 요소를 전달받는다.
html 요소가 존재하지 않으면 에러를 발생시키고, 존재하면 this.element에 할당한다.
예를 들어, html 요소인 #page가 전달되는 경우,
this.element = #page
이렇게 할당되는 식이다.
이 this.element의 역할은 매우 중요하다.
this.element는 View의 최상위 요소로, 해당 View가 화면에 그려지는 기준점이라고 할 수 있다.
View가 어떤 방식으로 렌더링, 즉 화면에 그려지는지 간단히 언급하겠다.
View는 하위 html 요소들을 생성한 뒤에 this.element의 자식으로 추가한다.
this.element는 DOM 요소이므로 자식 요소들을 추가하면
DOM 트리가 다시 생성되고 브라우저의 화면이 새롭게 렌더링된다.
이것은 뒤에 나오는 replaceChildren 파트에서 다시 설명하겠다.
2) on
on(event, handler) {
this.element.addEventListener(event, handler);
return this;
},
on은 this.element에 이벤트 핸들러를 등록한다.
특히 커스텀 이벤트 핸들러를 등록하는 용도로 자주 사용할 것이다.
on은 this를 반환하는데, 이는 앞서 설명한 ‘메서드 체이닝’을 사용하기 위함이다.
특히 on은 사용 빈도가 높으므로 이 기법이 유용하다.
혹시 Vue를 다루어봤다면, 이 on이라는 메서드가 익숙하게 느껴질 것이다.
그렇다. 이 on이라는 메서드의 이름은
Vue에서 이벤트를 핸들링하는 디렉티브인 v-on
에서 빌려온 것이며, 기능도 유사하다.
3) dispatch
dispatch(event, data) {
const customEvent = new CustomEvent(event, { detail: data });
this.element.dispatchEvent(customEvent);
return this;
},
dispatch는 커스텀 이벤트 객체를 생성한 뒤, 이를 디스패치(발생)시킨다.
두 번째 인수로 이벤트와 함께 전달하고 싶은 정보인 data를 전달받으며,
이것을 detail 프로퍼티를 포함하는 객체에 포함시킨다.
여기서 잠깐!
"왜 기존의 dispatchEvent 메서드를 두고 임의의 dispatch 메서드를 만들었을까?"
우선 커스텀 이벤트에 대한 설명이 필요하다.
기본적으로 커스텀 이벤트는 다음의 세 단계를 거쳐 발생시킬 수 있다.
- 커스텀 이벤트 핸들러 등록 =>
on
이 담당한다. - 커스텀 이벤트 객체 생성
- 커스텀 이벤트 디스패치
기존의 dispatchEvent는 기본적으로 3번 단계를 담당하는 메서드다.
즉 커스텀 이벤트 객체를 생성하고 난 뒤에야, dispatchEvent를 사용할 수 있다는 뜻이다.
또는 인자로 전달할 수도 있지만 코드가 복잡해진다.
다음을 보자.
// 기존의 dispatchEvent를 사용한 커스텀 이벤트의 디스패치
View.dispatchEvent(
new customEvent('@click', {
detail: { message: 'Hello' },
})
);
반면에 나의 dispatch 메서드는 2, 3번 단계를 통합한 메서드이다.
그리고 데이터를 전달하는 로직을 내부적으로 처리해 사용이 편리하다.
다음을 보자.
// dispatch를 사용한 커스텀 이벤트의 디스패치
View.dispatch('@click', { message: 'Hello' });
보다시피 코드가 훨씬 간결해지고 사용이 편리해졌다.
반복적인 코드의 사용을 제거하였기 때문이다.
이것이 dispatch라는 임의의 메서드를 만든 이유이다.
4) replaceChildren
replaceChildren(html) {
const template = document.createElement('template');
template.innerHTML = html;
this.element.replaceChildren(template.content);
}
};
replaceChildren은 html 문자열을 template 요소로 감싼 뒤,
this.element의 새로운 자식 요소로 교체하는 메서드다.
먼저 임의의 template 요소를 만든 후,
전달받은 html 문자열을 template 요소 내부에 포함시킨다.
template.content에는 DocumentFragment가 담겨 있다.
DocumentFragment는 Template이 담고 있는 DOM의 하위 트리를 나타낸다.
DOM에 추가하면 자신은 제거되고 자식 엘리먼트만 DOM에 추가되는 특성이 있다.
this.element의 자식으로 이 DocumentFragment를 추가 및 교체한다.
여기서 이름은 같지만 다른 메서드인 replaceChildren을 사용한다.
이를 도식으로 간단히 나타내면 다음과 같다.
그러면 this.element의 자식으로 html 요소들이 새롭게 추가된다.
바로 이러한 방식으로 View가 화면에 그려지게 된다.
한편, 내가 만든 replaceChildren은 View의 메서드이다.
this.element의 메서드로 사용한 replaceChildren과 다르다는 것에 주의하기 바란다.
this.element의 메서드 replaceChildren은 WEB APIs의 정규 메서드이다.
그럼 또 의문이 생길 것이다.
"기존의 replaceChildren 메서드와의 차이점이 뭔가요? 왜 만들었죠?"
그것은 기존의 replaceChildren이 허용하는 인자의 타입 때문이다.
기존의 replaceChildren은 인자로 ‘노드 또는 문자열 객체의 집합’을 요구한다.
mdn에서는 다음과 같이 명시하고 있다.
Parameters
A set of Node or string objects to replace the Element’s existing children with.
해석: 파라미터는 요소의 기존 자식을 대체할 노드 또는 문자열 객체의 집합입니다.
이것이 무슨 말이냐하면…
html 문자열, 예컨대 <h1>hello</h1>
은 replaceChildren의 인자로 전달할 수 없다는 뜻이다.
왜냐하면 이것은 단지 문자열이기 때문이다.
결국 이 html 문자열을 노드(또는 문자열 객체)의 집합으로 변환하는 과정이 필요하다.
이 변환 과정을 내부적으로 처리하라고 replaceChildren라는 임의의 메서드를 만들었다.
template 요소를 만들어 html 문자열을 포함시킨 것이 그것이었다.
결과적으로 무엇이 가능해졌는가?
이제 나의 replaceChildren은 html 문자열도 인자로 받을 수 있게 되었다.
노드로의 변환 과정을 내부적으로 처리해주기 때문에, 사용자(나)는 그걸 신경 쓸 필요 없다.
덕분에 replaceChildren의 사용이 간편해졌다는 것이 얻은 효과이다.
지금까지 설명한 init, on, dispatch, replaceChildren은
다른 모든 View 객체들이 사용할 수 있는 공통 메서드라는 것을 말해두겠다.
이것은 View.js의 객체를 다른 모든 View 객체들이 프로토타입으로 상속받기 때문이다.
이러한 사실을 염두에 두고,
이제 본격적으로 View와 Controller에 대해서 설명하겠다.
참고로 우리는 앱의 다음 지점을 살펴보고 있는 것이다.
2. 네비게이션 View
먼저 NavigationView에 대해서 살펴보자.
NavigationView는 하단 네비게이션 바를 (화면에) 그리는 것을 담당한다.
1) 객체 생성
import View from './View.js';
const NavigationView = Object.create(View);
먼저 View.js의 객체를 불러온 후,
이 객체를 프로토타입으로 갖는 NavigationView 객체를 생성한다.
상술하였듯, NavigationView는 View.js 객체의 메서드를 상속받아 사용할 수 있다.
2) setup
NavigationView.setup = function (element) {
this.init(element);
this.render();
this.setEvent();
return this;
};
그리고 setup 메서드를 정의한다.
setup은 html 요소인 element를 전달받는다.
앞서 MainController에서 살펴봤듯이, 이 element는 #navigation 요소가 될 것이다.
// MainController.js
const navigation = document.getElementById('navigation');
// ...
init() {
// ...
NavigationView.setup(navigation);
// ...
}
이 #navigation 요소를 init으로 전달하여 호출한다.
그러면 결과적으로 다음과 같이 할당된다.
NavigationView.element = #navigation 요소
NavigationView의 렌더링 기준점을 설정한 셈이다.
이어 render와 setEvent 메서드를 차례로 호출한다.
3) render
NavigationView.render = function () {
const html = this.getHtml();
this.replaceChildren(html);
};
NavigationView.getHtml = function () {
return `
<ul class="navigation__tabs">
<li class="navigation__tab">
<a href="/">
<i class="fa-solid fa-plus"></i>
</a>
</li>
<li class="navigation__tab">
<a href="/book">
<i class="fa-solid fa-book-open-reader"></i>
</a>
</li>
<li class="navigation__tab">
<a href="/note">
<i class="fa-solid fa-pen-to-square"></i>
</a>
</li>
<li class="navigation__tab">
<a href="/setting">
<i class="fa-solid fa-square-poll-horizontal"></i>
</a>
</li>
</ul>
`;
};
render는 NavigationView를 화면에 그리는 역할을 담당한다.
먼저 getHtml 메서드를 통해 html 문자열을 가져온다.
html 문자열은 ul 및 li의 리스트 구성이며, 리스트마다 a 태그와 i 태그로 구성된다.
그리고 이 html 문자열을 replaceChildren 메서드에 전달한다.
결과적으로 this.element에 html 요소들이 자식으로 추가될 것이다.
이렇게 NavigationView의 렌더링이 완료되었다.
4) setEvent
NavigationView.setEvent = function () {
this.element.addEventListener('click', (e) => this.onClick(e));
};
NavigationView.onClick = function (e) {
if (e.target.matches('#navigation > a')) {
e.preventDefault();
const page = e.target.getAttribute('href');
this.dispatch('@click', { page });
}
};
setEvent는 이벤트 핸들러를 등록하는 함수다.
this.element에서 click 이벤트가 발생하면 onClick 메서드를 실행한다.
onClick을 살펴보자. a 태그가 클릭되었다면,
우선 e.preventDefault 메서드로 a 태그의 기본 동작(새로고침)을 막는다.
그리고 a 태그의 href 속성에서 클릭된 페이지 정보를 가져 온다.
커스텀 이벤트 @click을 발생시키면서 페이지 정보를 전달한다.
커스텀 이벤트를 발생시키는 것만이 View의 역할이다.
커스텀 이벤트 핸들러를 등록 및 처리하는 것은 Controller에서 맡는다.
이전 포스팅의 MainController 코드를 다시 살펴보자.
// MainController.js
init() {
// @click 이벤트 핸들러 등록
NavigationView.on('@click', (e) => this.onClick(e.detail.page));
// ...
}
// ...
// @click 이벤트 핸들링
onClick(page) {
history.pushState(null, null, page);
this.route();
}
MainController의 onClick 메서드에서 page를 전달받고,
page로 url 경로를 변경시킨 후, 라우팅을 실행한다.
결과적으로 클릭된 page로 화면이 전환된다.
혹시 이해가 잘 되지 않는다면, 이전 포스팅을 함께 참고해 주시길 부탁드린다.
북레스트 2
5) hide & show
NavigationView.hide = function () {
this.element.classList.add('hide');
};
NavigationView.show = function () {
this.element.classList.remove('hide');
};
hide는 NavigatoinView를 화면에서 안 보이게 한다.
this.element에 ‘hide’ 클래스가 추가되면 display: none
이 적용되어 감춰진다.
css 코드는 생략하겠다.
show는 NavigationView를 화면에서 보이게 한다. 기본 상태이다.
display: none
의 적용을 제거함으로써 가능하다.
상황에 따라 NavigationView를 이렇게 보이거나 감추는 것이 필요하다.
추후 다시 설명하겠다.
export default NavigationView;
이렇게 만든 NavigationView를 export한다.
3. 화면 View
화면 View란 각각의 화면을 구성하는
HomePageView, BookPageView, NotePageView, SettingPageView를 말한다.
구현은 동일하므로 여기서는 HomePageView만을 설명하겠다.
그 전에 먼저, MainController에서 route 메서드를 다시 살펴보자.
route() {
const path = window.location.pathname;
if (path === '/') {
HomePageView.setup(page);
HomeController.init();
return;
}
//...
}
route에서 URL의 경로를 읽은 뒤,
그와 일치하는 View와 Controller를 차례로 실행하였다.
HomePageView의 코드는 아래와 같다.
import View from './View.js';
const HomePageView = Object.create(View);
HomePageView.setup = function (element) {
this.init(element);
this.render();
this.bindElement();
this.setEvent();
};
HomePageView.render = function () {
const html = this.getHtml();
this.replaceChildren(html);
};
HomePageView.getHtml = function () {
return /* html */ `
<header class="header">
<h1 class="header__title">북레스트</h1>
<h3 class="header__message">책과 함께 휴식을 취하세요 :)</h3>
</header>
<div class="content content--home">
<a class="home__tab home__search-tab" href="/home/search">
<h2>책을 추가해 보세요.</h2>
<h3>읽고 있는 책이 있나요?</h3>
</a>
<a class="home__tab home__tab--calendar" href="/home/calendar">
<div>
<h2>독서 달력</h2>
<h3>이번 달은 얼마나 읽었나요?</h3>
</div>
<i class="fa-solid fa-calendar-days"></i>
</a>
</div>
`;
};
HomePageView.bindElement = function () {
};
HomePageView.setEvent = function () {
};
export default HomePageView;
NavigationView와 거의 동일하므로 구체적인 설명은 줄이겠다.
bindElement와 setEvent가 비어있는데,
이는 다음 포스팅에서 구현할 부분이라서 잠시 비워두었다.
4. 화면 Controller
화면 Controller란 각각의 화면을 구성하는
HomeController, BookController, NoteController, SettingController를 말한다.
화면 Controller의 전체적인 로직은 다음과 같다.
let isInit = false;
export default {
init() {
// 한 번만 호출되는 코드
if (!isInit) {
this.setupInnerPage();
this.addCustomEvent();
isInit = true;
}
// 중복 호출되는 코드 (예: fetch 등)
// ...
},
setupInnerPage() {
},
addCustomEvent() {
},
// View에서의 사용자 입력을 처리하는 메서드
// ...
};
너무 짧고 군데군데 비어있지 않은가?
당연하다. 아직 어떤 기능도 추가하지 않았기 때문이다.
앞으로 기능을 추가함에 따라 비어 있는 영역들을 채울 것이다.
여기서는 전체적인 로직을 간단히 설명하겠다.
Controller는 하나의 객체이며, 그 안에 여러 메서드를 소유하고 있다.
가장 중요한 메서드는 init이다.
init은 컨트롤러를 초기화하는 역할을 하며,
‘단일 호출되는 영역’과 ‘중복 호출되는 영역’으로 나뉜다.
1) 단일 호출
단일 호출되는 것은 setupInnerPage와 addCustomEvent 메서드이다.
이들은 컨트롤러가 처음 초기화될 때 단 한번 호출된다.
setupInnerPage는 내부페이지를 초기 설정(Setup)하는 역할이다.
‘내부 페이지’란 용어가 이해가 잘 안 갈 수 있는데…
예를 들면 다음과 같다.
‘홈 화면’에서 추가 탭을 눌렀을 때, ‘책 검색 화면’으로 이동한다.
이때 ‘책 검색 화면’을 ‘홈 화면’의 내부 페이지라고 부를 수 있다.
이것을 내부 페이지라고 명명하겠다.
HomePageView, BookPageView, NotePageView, SettingPageView는
각각 내부 페이지를 소유하고 있다.
이러한 내부 페이지의 초기 설정을 setupInnerPage 메서드에서 수행한다.
=> 이 부분이 이해가 어려울 수 있을 것 같다.
다음 포스팅에서 내부 페이지를 실제로 구현하면서 더욱 설명하겠다.
한편, addCustomEvent 메서드는 View에 커스텀 이벤트 핸들러를 등록한다.
앞서 살펴 본 on 메서드를 사용할 것이다.
2) 중복 호출
중복 호출되는 것은 대표적으로 fetch 관련 함수가 있다.
예를 들어, View에서 특정 영역을 그리기 위해서는
데이터베이스(Model)에서 데이터를 동적으로 불러와야 한다.
만약 데이터가 업데이트된 경우,
데이터를 새롭게 불러와야하므로 fetch를 다시 호출한다.
이처럼 컨트롤러가 초기화될 때마다 다시 호출되는 영역이다.
3) 사용자 입력 처리
View에서 사용자의 입력이 발생한 경우, 그 처리를 담당하는 메서드들이 들어간다.
예를 들어, 사용자가 검색 폼에서 책 제목을 입력한 경우
“데이터베이스에서 저장된 책 데이터를 가져오는 메서드”가 필요할 것이다.
이처럼 사용자의 입력을 처리하는 메서드들이 포함된다.
여기까지 View 및 Controller의 핵심적인 사항들에 대해 모두 설명하였다.
다시 한 번 지금까지의 결과물을 보도록 하자.
이번 포스팅은 다소 지루한 내용이었을 수 있을 것 같다.
다음 포스팅에서는 좀 더 재미있는 내용,
“검색 API의 활용과 무한 스크롤”에 관해 다루어보겠다.
참고자료
드리는 말
학습용 프로젝트를 진행하며 작성한 글이며, 부정확한 내용이 있을 수 있습니다.
참고로만 읽으실 것을 권장드립니다. 감사합니다.