Vanilla javascript와 Redux (Web component)

앞서 Vanilla javascript로 web component를 통해서 어떻게 Component화 하는지와 어떻게 상태관리를 할 수 있는지 알아보았다.

이제 library를 활용해서 상태관리(state management)를 해보고자 한다.

본 내용을 이해하기 위해서는 아래 내용을 꼭, 읽어 봐야한다.

2021.08.16 - [Web] - 기존 web site를 components 로 다시 만들기 (No State management)

앞서 만들어진 todo 웹사이트를 이용해서 redux를 적용하고자 한다.

기본 source는 아래

https://github.com/theyoung/webcomponents/tree/webComWithStateManagement/statementforwebcomponent

를 사용한다.

 

Redux의 이해

Redux는 Facebook에서 Flux라는 개념을 실체화 한것이다.

Flux? 어디서 많이 들어본 이름인데, 가장 유명한것이 Web-flux 일것 같다. 

 

Spring의 MVVM을 실체화시키는 Core library이자 Observer Tool 이다.

https://www.youtube.com/watch?v=nYkdrAPrdcw&t=15s

자세한 내용은 위의 youtube와 아래 내용을 읽어보자.

2021.08.20 - [Web] - MVC, MVP, MVVM 패턴의 이해

이전 개발방식을 보면 View에서 활용하는 Model에 대한 수정이 자체적으로 이루어 진다.

    addItem(){
        let item = this.querySelector('#new-item-field').value.trim();
        this.items.push(item);
        this.renderList();
    }

위의 코드를 보면 해당 class의 new-item-field tag에 있는 값을 자신의 this.items에 push하는 코드이다.

this.items를 model이라고 보았을때 이 model이 영향을 줄수있는 범위는 자신의 class 범위이다. 이것을 벗어나기 위해서는 custom event등을 통한 dispatch를 사용해야 한다.

    dispatchItems(){
        this.dispatchEvent(new CustomEvent('changed',{
            detail : {data : this.items.length},
            bubbles : true
        }));
    }

위 내용을 보면 특정 item array의 size가 변화하였다는 event를 외부로 공지하는 형태이다.

이렇게 tightly하게 묶여있던 model과 business logic 그리고 view를 분리하는 것이다.

간단한 도식으로 보자면 위와 같다. 하나의 view안에 있었던 model과 logic을 view와 완전한 분리를 시킨다. 이를 통해서 관점의 분리가 이루어지는데 여기까지는 concept가 된다. 이것을 실체화 시킨 library가 Redux이고

Redux에서는

  • Model을 state로
  • logic(action)을 reduce로 나타내고 있다.

길게 어려운 소리를 썻는데 

Single directional data Flow

이것만 이해하자. data의 흐름은 한 방향으로만 진행된다.

 

Redux의 기능

redux를 이해하기 위해서 가장 먼저 만나봐야할 웹사이트는 당연히 redux 공식 사이트이다.

https://redux.js.org/tutorials/essentials/part-1-overview-concepts

그런데 이 tutorial이라는게 하나하나 읽자니 잘 읽히지도 않고,

가장 문제점은

  • nodejs를 이용한 개발환경 구성을 전제하고 있다
  • 그리고 react를 쓰라고 한다. 왜? 난 상태관리만 쓰고 싶은데... 왜?

react와 가장 잘 어울린다. vue와도 잘 어울린다 뭐 그런 목적이겠지만,

그것이 가장 접근하기 힘들게 만드는 요인이 된다.

redux를 쓰기 위해서 난왜 nodejs, react, vue, angular등을 같이 봐야하는가?

이것을 꼭 이해하자. Redux는 Redux자체로 작동하는 library이다.

Redux의 주요 API 몇개만 이해하고, 어떻게 Redux를 사용할 수 있는지 알아보자.

https://redux.js.org/api/api-reference

그 외에도 몇가지 api가 더 있는데, 위의 api만 알면 redux를 사용하는데 전혀 문제가 없다.

 

Redux import하기

nodejs를 이용한 redux를 사용하지 않을 예정이기 때문에

index.html에 다음과 같이 javascript를 import하자.

    <script src="https://cdnjs.cloudflare.com/ajax/libs/redux/4.0.0/redux.js"></script>

해당 javascript는 global하게 Redux라고 하는 변수로 접근 가능하다.

 

Store.js 작성

위의 기본 내용과, 소스를 이해한다는 전제를 하고 내용을 작성해나가고자 한다. 

본 서비스는 몇가지 기능이 있는 웹사이트인지 생각해 보자. 

 

3가지 이다. 

  • DELETE_ITEM : TODO를 삭제한다
  • ADD_ITEM : TODO를 추가한다
  • Counter : TODO 숫자를 센다

이 3가지는 각각을 action이라고 명명할 수 있다.

View에서 사용자의 입력을 통해서 logic이 작동하고 state를 업데이트 한다. 그리고 그 업데이트된 정보로 화면을 rerendering하게 되는 것이다.

간단한 도식은 아래와 같다.

이 도식을 기초로 해서 Store를 만들어 보자.

 

State 만들기

가장 중요한것은 state이다. 어떻게 state를 관리 할 것인가?

let initStatus = {
    todos : ['test123','test456'],
    counter :2
}

이것이 내가 생각하는 기본 state이다.

todos에 item을 관리한다.

그리고 그 item의 갯수를 counter에 입력해주고 이 값이 view에 표현될 수 있도록 한다.

 

Reducer만들기

위의 도식에서 reducer를 2개 그려놨는데 이 reducer가 business logic을 관리한다고 생각하면 된다.

나는 2개의 reducer를 디자인했다. 

  1. todos라는 items을 추가하거나 삭제하는 reducer
  2. items의 갯수를 업데이트하는 reducer
function todosdd(state = [], action){
    switch(action.type){
        case 'ADD_ITEM':
            state.push(action.data);
            action.counter = state.length;
            break;
        case 'DELETE_ITEM':
            state.splice(action.idx,1);
            action.counter = state.length;
            break;
    }
    
    return state;
}

function counterdd(state = 0, action){
    state = action.counter?? state;
    return state;
}

여기서 주의해야 할 것은

  • 위에 입력되는 state는 reducer별로 다르다. 동일한 state가 아니다
  • action값은 모든 reducers가 공유한다.
  • reducers간에 순서가 있다. Object.keys()의 순서에 따른다.

이점을 주의해서 상위 두개의 reducer를 하나로 합쳐보자.

const reducers = Redux.combineReducers({
    todos : todosdd,
    counter : counterdd
});

위의 코드는 간단해 보이지만 여러가지 의미를 담고 있다.

1. State의 구조를 확정했다.

initStatus로 확정된것이 아닌가? 라는 의문이 있겠지만, 아니다! State의 구조는 reducer의 형태 define을 통해서 확정하는 것이다.

{
    todos : todosdd,
    counter : counterdd
}

바로 이부분이다. todos라는 키를 갖고 그 키가 갖는 값은 Array이다.

function todosdd(state = [], action){
    switch(action.type){
        case 'ADD_ITEM':
            state.push(action.data);
            action.counter = state.length;
            break;
        case 'DELETE_ITEM':
            state.splice(action.idx,1);
            action.counter = state.length;
            break;
    }
    
    return state;
}

그 이유는 위의 기본 state가 Array이기 때문이다.

 

그럼 counter는?

function counterdd(state = 0, action){
    state = action.counter?? state;
    return state;
}

기본 state값이 Number형태인 값이 기본 state 구조이다.

그래서 각 reducer가 나타내는 state라는 입력값은 모두 각자의 형태를 갖고 있다. 동일한 것이 아니다.

 

2. Reducer의 실행 순서를 확정했다.

combine하는 시점에 Object.keys()에 따른 reducer의 순서를 결정한다.

{
    todos : todosdd,
    counter : counterdd
}

todos에 정의된 reducer가 실행되고, counter의 reducer가 실행 된다.

 

3. action은 공유된다.

counter의 값을 counterdd라고 하는 pure function은 어떻게 얻어낼 수 있을가?

Store의 todos Array를 접근 가능한가? 아니.. 불가하다

action을 통해서 공유하자.

function todosdd(state = [], action){
    switch(action.type){
        case 'ADD_ITEM':
            state.push(action.data);
            action.counter = state.length;
            break;
        case 'DELETE_ITEM':
            state.splice(action.idx,1);
            action.counter = state.length;
            break;
    }
    
    return state;
}

action이라고 하는 payload에 action.counter를 추가해서 todos의 길이를 주입해 준다.

action.counter = state.length;

이 주입된 값은

function counterdd(state = 0, action){
    state = action.counter?? state;
    return state;
}

counterdd에서 사용가능하다.

 

이제 진짜  store를 만들어내는 부분이다.

Create Store

export default Redux.createStore(reducers,initStatus);

export default 를 해줌으로써 이 Store.js파일을 import하는 모든 모듈은 Singleton화가 된 동일한 Store를 접근 할 수밖에 없게 된다.

let initStatus = {
    todos : ['test123','test456'],
    counter :2
}

function todosdd(state = [], action){
    switch(action.type){
        case 'ADD_ITEM':
            state.push(action.data);
            action.counter = state.length;
            break;
        case 'DELETE_ITEM':
            state.splice(action.idx,1);
            action.counter = state.length;
            break;
    }
    
    return state;
}

function counterdd(state = 0, action){
    state = action.counter?? state;
    return state;
}


const reducers = Redux.combineReducers({
    todos : todosdd,
    counter : counterdd
});

export default Redux.createStore(reducers,initStatus);

이렇게 해서 Store.js가 완성되었다.

 

이제 각 모듈에서 이 Store를 import해서 사용을 시작하자.

import Store from "../Store.js";

 

AppInput.js 에 ADD_ITEM 반영

    addItem(){
        let item = this.querySelector('#new-item-field').value.trim();
        this.items.push(item);
        this.dispatchItems();
        this.renderList();
    }

기존에는 item(todo) 입력을 요청받으면 그에 따라서, local items를 업데이트 시키고 외부로 todos가 변경되었음을 알리고, 현 화면을 rerendering시키는 행위를 진행했다.

이것을 redux를 통해 한방향으로 flow가 흐르게 바꾸어 줘야 한다.

    addItem(){
        let item = this.querySelector('#new-item-field').value.trim();
        // this.items.push(item);
        // this.dispatchItems();
        // this.renderList();
        Store.dispatch({type:'ADD_ITEM',data:item});
    }

Store에 ADD_ITEM이라고 하는 action실행을 요청한다. 

이를 요청하게 되면, 

Store에 reducer인 todosadd가 실행된다.

function todosdd(state = [], action){
    switch(action.type){
        case 'ADD_ITEM':
            state.push(action.data);
            action.counter = state.length;
            break;
        case 'DELETE_ITEM':
            state.splice(action.idx,1);
            action.counter = state.length;
            break;
    }
    
    return state;
}

이를 통해서 state의 todos가 변경되게 된다. 

그럼 이 변경된 값을 어떻게 view에 적용시킬 것인가?

    connectedCallback(){
        let that = this;
        this.querySelector('form').addEventListener('submit',(e)=> {
            e.preventDefault();
            that.addItem();
        });

        const unsub = Store.subscribe(()=> {
            this.items = Store.getState()['todos'];
            this.renderList();
        })

        this.subscribes.push(unsub);
    }

Dom이 최초 html페이지에 적용될때 불리워지는 method이다. 이때 Store의 subscribe를 진행 함으로써, state가 변경됨과 동시에 renderList를 실행 시키도록 만들었다.

 

AppInput.js 에 DELETE_ITEM 반영

기존 코드는 아래와 같다.

    renderList(){
        let that = this;
        this.querySelector('.js-items').innerHTML = `
            <ul>
                ${this.items.map((item,idx)=> `<li> ${item} <button class="rm" aria-label="Delete this item">×</button></li>`).join('')}
            </ul>
        `;

        this.querySelectorAll('.rm').forEach((btn,idx)=>{
            btn.addEventListener('click',(e)=>{
                that.items.splice(idx, 1);
                that.renderList();
                that.dispatchItems();
            });
        });
    }

특정 item에 있는 x button을 클릭하면 현 class에 있는 items를 삭제하는 방식이다.

이것을 Redux를 이용해서 data flow를 변경해 주겠다.

        this.querySelectorAll('.rm').forEach((btn,idx)=>{
            btn.addEventListener('click',(e)=>{
                // that.items.splice(idx, 1);
                // that.renderList();
                // that.dispatchItems();
                Store.dispatch({type:'DELETE_ITEM',idx:idx});
            });
        });

DELETE_ITEM action과 삭제할 위치를 reducer에서 넘겨준다.

function todosdd(state = [], action){
    switch(action.type){
        case 'ADD_ITEM':
            state.push(action.data);
            action.counter = state.length;
            break;
        case 'DELETE_ITEM':
            state.splice(action.idx,1);
            action.counter = state.length;
            break;
    }
    
    return state;
}

그럼 todosdd에서 DELETE ITEM이 실행되고 state를 변화시킨다.

이것 역시 앞서 subscribe를 해 놓았기 때문에 자동으로 view가 update되게 된다.

 

APPStatus.js Counter 업데이트

    getTemplate(){
        return `
            <aside class="app__status">
            <p role="status" class="visually-hidden">You have done <span class="js-status">1 thing</span> today!</p>
            <div class="[ app__decor ] [ js-count ]" aria-hidden="true">
                <small>You've done</small>
                <span>${this.count}</span>
                <small>things today 😢</small>
            </div>
            </aside>
        `;
    }

기존에는 local에 정의한 this.count를 이용해서 해당 화면을 업데이트 시켰다면, 이제는 Store를 직접 연결해서 해당 value를 표현할 수 있다.

    getTemplate(){
        return `
            <aside class="app__status">
            <p role="status" class="visually-hidden">You have done <span class="js-status">1 thing</span> today!</p>
            <div class="[ app__decor ] [ js-count ]" aria-hidden="true">
                <small>You've done</small>
                <span>${Store.getState()['counter']}</span>
                <small>things today 😢</small>
            </div>
            </aside>
        `;
    }

물론 가장 중요한 것은

    connectedCallback(){
        const unsubscribe = Store.subscribe(()=>{
            this.render();
        });

        this.subscribes.push(unsubscribe);
    }

Store에 subscribe하는 것을 잊지 말아야 한다.

 

위에까지 개발이 되었다면, Redux를 이용해서 해당 서비스가 잘 작동하는 것을 확인할 수 있다.

하지만 아직끝은 아니다,

 

Unsubscribe하기

사용하지 않는 class의 dom은 삭제가 되어야 한다.

Redux는 기본적으로 state의 변화가 일어나면, 어떤 값이 변화 되었는지와 상관없이 call하는 방식이기 때문이다.

꼭 사용하지 않을 때는 subscribe를 해지시켜 줘야 한다.

이를 위해서 

    connectedCallback(){
        const unsubscribe = Store.subscribe(()=>{
            this.render();
        });

        this.subscribes.push(unsubscribe);
    }

위와 같이 subscribe의 결과로 return되는 unsubscribe 객체를 갖고 있다가

    disconnectedCallback(){
        this.subscribes.forEach((unsubscribe)=>{
            unsubscribe();
        });
    }

Dom이 삭제되는 시점에 앞서 subscribe되어있던 모든 값들을 unsubscribe시켜 주면 된다.

 

다시 말하지만 위의 내용들은 앞선 선행내용 webcomponent를 이해하고 있어야 한다.

이해가 잘 안가는 부분은 아래 코드를 비교해보면서 공부하면 좋을 것 같다.

https://github.com/theyoung/webcomponents/tree/WebCompWithRedux/statewithredux

728x90
반응형