반응형

SPA(Single Page Application)이란? 

 

과거에는 웹 페이지를 클릭하면 하나의 문서를 전달하고, <a href="페이지 주소"> 같은 하이퍼 링크를 누르면

새로운 페이지를 서버에서 전송받아 보여주고는 했습니다.

즉, 새로운 페이지를 요청할때마다 정적 리소스가 다운로드 되고 (새로운) 전체 페이지가 전부 다 렌더링 됩니다.

 

SPA와 기존 방식(MPA)

SPA는 기존 방식과 달리 웹 페이지에 필요한 리소스(HTML,CSS,JS)를 한번에 다 다운로드 받은 뒤

새로운 페이지 요청이 있을 경우 페이지 갱신에 필요한 데이터만 서버에서 전달받아 페이지를 갱신하는 방식(AJAX)으로 구현이 가능합니다.

 

즉, 빈 도화지(HTML)에 그림(JS로 동적 렌더링)을 그리는 식으로 작동합니다.


JS로 간단하게 구현해봅시다.

 

폴더 구조

└─src
|   ├─pages
|   |   ├─ RouterButton.js
|   |   ├─ A.js
|   |   ├─ B.js
|   |   └─ C.js
|   ├─BaseComponent.js 
|   └─App.js
└─ index.html

page는 A,B,C 페이지가 있고

요청에 따라 index.html에 동적으로 A,B,C 페이지를 각각 그려줄 것입니다.

하얀 도화지에 그림(렌더링)을 그린다고 보시면 이해가 편합니다.

 

HTML

 

<html>
  <head>
    <title>SPA</title>
  </head>
  <body>
    <main class="app">
    </main>
    <script type="module" src="./src/app.js"></script>
  </body>
</html>

우선 기본 base html입니다. 

우리는 기본 html에 JS로 동적으로 화면을 그려줄 것 입니다.

 

(*type="module"을 쓰면 global scope가 아니고 독립적인 module scope가 생기고, ES6 module(import, export)를 사용 가능합니다.) 

 

간단히 렌더링 되기 이전까지의 과정을 요약해보자면

 

1. www.홈페이지 요청

2. 서버에서 리소스(HTML, JS, CSS)등을 클라이언트에 보내줌

3. HTML,CSS, JS 다운로드 완료

4. JS 동작 

5. HTML에 렌더링

 

BaseComponent.js

 

A,B,C 페이지 javascript폴더에 공통적으로 쓸 부모 클래스를 정의해줍니다.

 

아래에서 쓰이는 예시는 템플릿 메소드 패턴이라고 디자인 패턴을 사용한 것인데

부모 클래스에서 기본적인 로직을 짜고 아래 부분(자식 클래스)에서 구체화하는식으로 동작하게 구현하는 

방식입니다.

 

(예시는 객체지향 패러다임을 썼지만 객체지향적인 컨셉으로 짜도 되고 함수 컨셉으로 짜도 됩니다.)

export default class BaseComponent {

    constructor({$parent, setState, state}){
        this.$parent = $parent;
        this.state = state;
        this.setState = setState;
    }
    
    template(){
        return '';
    }

    #render(){
        const template = this.template();
        this.$parent.insertAdjacentHTML('beforeend', template);
    }

    setEvent(){

    }

    renderSequence(state){
        this.state = state;
        this.#render();
        this.setEvent();
    }
}

constructor에서 주입받는건 3가지인데 

이 중 $parent는 HTML의  <main class="app"></main> 값을 가지고 있는 변수(querySelector)입니다.

setState, state 두가지는 이 후에 차차 알아봅시다.

 

저런식으로 구현한다면 만약 class="app"을 가진 root element가 아니라

다른 element값을 주입해서 그 아래에 렌더링되는식으로 부모클래스가 자식 클래스를 제어할 수 있는 유연성을 가질 수 있습니다.

 

이것을 유식한 말로 DI(Dependency Injection)이라 합니다.

 

상속받은 자식 클래스들 (A.js, B.js, C.js)

 

우리가 자식 클래스에서 구현할것은 

template() 함수 와 setEvent() 함수입니다.

 

template() 함수는 안에 넣을 뼈대(string)를,

setEvent()는 렌더링 이후 페이지에 붙여줄 이벤트만 작성해주면 됩니다. (간단 예시니 event는 붙이지 않았습니다)

 

그럼 부모 클래스가 로직을 알아서 제어해서 렌더링 시켜줄 것입니다.

 

>> 자식 클래스들 코드 

더보기
import BaseComponent from '../BaseComponent.js';

export default class A extends BaseComponent{

    template(){
        return `IM A!
        ${RouterButton()}
        `;
    }

    setEvent(){

    }
}

export default class B extends BaseComponent{


    template(){
        return `IM B!
        ${RouterButton()}
        `;
    }


    setEvent(){
    }
}
export default class B extends BaseComponent{



    template(){
        return `IM B!
        ${RouterButton()}
        `;
    }


    setEvent(){
    }
}

export default function RouterButton(){
    return `
        <button type="button" class="A">
            A
        </button>
        <button type="button" class="B">
            B
        </button>
        <button type="button" class="C">
            C
        </button>
    `
};

 

이제 SpaRouter를 작성해 봅시다.

 

import A from './pages/A.js';
import B from './pages/B.js';
import C from './pages/C.js';

class app{

    constructor(){
        this.state = { 'locate' : window.location.pathname};
        this.root = document.querySelector('.app');
        
        const ObjectForDI = {$parent:this.root, setState : this.setState.bind(this), state : this.state};

        this.A = new A(ObjectForDI);
        this.B = new B(ObjectForDI);
        this.C = new C(ObjectForDI);

        this.render();
        this.setDummyEvent();
    }

    setState (newState){
        const { locate } = this.state;
        this.state = {...this.state, ...newState};
        this.render();
    }
    
    render(){
        this.root.innerHTML = '';

        let { locate } = this.state;

        console.log(locate);

        if(locate === '/A'){
            this.A.renderSequence(this.state);
        } 
        else if(locate === '/B'){
            this.B.renderSequence(this.state);
        }
        else{
            this.C.renderSequence(this.state);
        }

        this.historyRouterPush(locate);
    }

    historyRouterPush(locate) {
        window.history.pushState({}, locate, window.location.origin + locate);
    }
}

new app();

 

코드를 나눠서 살펴 봅시다.

 

라우팅 처리 방식 

 

    render(){
        this.root.innerHTML = '';

        let { locate } = this.state;

        console.log(locate);

        if(locate === '/A'){
            this.A.renderSequence(this.state);
        } 
        else if(locate === '/B'){
            this.B.renderSequence(this.state);
        }
        else{
            this.C.renderSequence(this.state);
        }
    }

지금 페이지의 경로(window.location.pathname) 에 따라 A, B, C 페이지가 라우팅됩니다.

경로가 /A면 A 페이지, /B면 B 페이지, 그외엔 C 페이지가 렌더링 되고 있습니다.

 

 

경로 변경 방식

 

    setState (newState){
        // newState = {locate : 'newPath' }
        this.state = {...this.state, ...newState};
        this.render();
    }

 

 특정 이벤트가 일어나면 setState를 호출하고, 다시 렌더링되게(위에서 보았던 render 함수 호출)됩니다.

 

그리고 state와 setState는 아까 BaseComponent.js의 생성자에서 본 state와 setState의 정체입니다.

부모 클래스(app.js)에서 자식 클래스(BaseCompoent 상속받은 A.js, B.js 등)에게 함수와 공통 객체를 주입해주고,

페이지를 바꿀 (정보 갱신) 타이밍이 오면 setState 함수를 자식 클래스에서 호출해서 해당 페이지를 전부 rerender 하게 됩니다.

 

리엑트에서 상태 변경때 쓰는 방식과 비슷하지 않나요?

내부 구현은 매우 다를거라 예상되지만... 비슷하게 작동합니다.

(이 코드에서 엄밀히 따지면 setState보다는 setRouterPath 등 다른 이름이 어울릴꺼 같긴 해요) 

 

뒤로가기, 앞으로 가기 구현

 

    historyRouterPush(locate) {
        window.history.pushState({}, locate, window.location.origin + locate);
    }

브라우저 API인 히스토리 API를 사용해서 매우 쉽게 구현 가능합니다.

 

이상 간단하게 SPA 구현 방식을 알아봤습니다.

 

 

ref

 

https://devopedia.org/single-page-application

 

Single Page Application

A web application broadly consists of two things: data (content) and control (structure, styling, behaviour). In traditional applications, these are spread across multiple pages of HTML, CSS and JS files. Each page is served in HTML with links to suitable

devopedia.org

 

참고)

 

클릭 이벤트 처리방법

 

    dummyClickEvent = ({target}) => {
        if(target.classList.contains('A')){
            this.setState({...this.state, locate : '/A'});
        }
        if(target.classList.contains('B')){
            this.setState({...this.state, locate: '/B'});
        }
        if(target.classList.contains('C')){
            this.setState({...this.state, locate: '/C'});
        }
    }


    setDummyEvent(){
        this.root.addEventListener('click', this.dummyClickEvent);
    }

 

반응형

+ Recent posts