💀

Vanilla Javascript로 구현하는 SPA - 상태 관리 도구


불편함 감지하기

프로젝트에서 모달 창을 사용하고 싶었다. 모달은 하나를 두고 내용물을 상태에 따라 바꿔주는 것이 목표다.

현재 컴포넌트 구조는 아래와 같다.

1App.js
2├─ Login.js
3└─ Main.js
4 ├─ Modal.js
5 ├─ Content.js
6 │ └─ ...
7 └─ Sidebar.js
8 ├─ Profile.js
9 ...

여기서 불편한 점이 발생한다.

모달의 상태변화는 Content.jsSidebar.js의 하위 컴포넌트에서 변경된다. 모달의 상태를 변화시키기 위해서는 Modal.jssetState 메서드를 저 두 컴포넌트의 아래까지 전달해야한다. 이 상황은 정말 귀찮다.

이 문제는 react나 vue에서도 똑같이 발생한다. 'Props drilling' 이슈라고 한다.

React나 Vue는 이 문제를 어떻게 해결할까? 바로 '상태 관리 시스템'을 통해서 해결한다. 중앙 집중식 저장소를 둠으로써 어떤 컴포넌트던 특정 상태에 바로 접근이 가능해진다. React는 Redux, Vue는 Vuex를 대표적으로 사용한다.

구현

상태 관리 도구의 구현을 위해서는 '옵저버 패턴'에 대한 이해가 선행되어야 한다.

대부분의 구현을 이 포스트에서 참고했다. 정말 감사합니다!

Vuex?

참고한 구현은 vuex를 모방한 형식이다.

Vuex에 대해 간단히만 알아보자.

state를 오직 mutations로만 변경할 수 있다. actions는 백앤드 api를 가져온 뒤 mutations를 이용할 때 사용한다. 나는 일단 statemutations만 구현해보고자 했다.

Vuex의 가장 간단한 사용 예시를 살펴보자.

1import { createStore } from 'vuex'
2
3const store = createStore({
4 state: {
5 count: 0
6 },
7 mutations: {
8 increment (state) {
9 state.count++
10 }
11 }
12});
13
14store.commit('increment');
15
16console.log(store.state.count);
17// -> 1

createStore를 통해 statemutations를 정의해주고, commit을 통해 mutations에 선언된 메서드를 호출한다.

createStore와 비슷하게 Store.js 클래스를 구성해보자.

stateobservable로 만들어주고, 오직 commit만으로 state에 접근할 수 있게 인터페이스를 만들어야한다.

1import { observable } from "./observer.js";
2
3export default class Store {
4 #state;
5 #mutations;
6 state = {};
7 constructor({ state, mutations }) {
8 this.#state = observable(state);
9 this.#mutations = mutations;
10
11 Object.keys(state).forEach(key => {
12 Object.defineProperty(
13 this.state,
14 key,
15 { get: () => this.#state[key] },
16 );
17 });
18 }
19 commit(action, payload) {
20 this.#mutations[action](this.#state, payload);
21 }
22}

state를 private 프로퍼티로 저장해 외부에서 접근할 수 없게 했다. 하지만 상태 값을 읽을 수는 있게 해야하기 때문에 각 프로퍼티마다 getter를 달아줘 접근 가능하게 했다. set 메서드를 정의하지 않았기 때문에 상태를 직접적으로 할당할 수 없다.

commit을 호출하면 action에 맞는 mutations의 메서드를 호출해 상태를 변경하게 된다.

이제 state를 observable로 만들어 줬으니, state가 변경됐을 때 다시 컴포넌트가 렌더링 될 수 있게 observe에 등록해주면 된다.

1import { observe } from "./observer.js";
2
3export default class Component {
4 constructor(target, props) {
5 this.target = target;
6 this.props = props;
7 this.state = {};
8 this.setup();
9 // 컴포넌트에서 스토어의 상태를 observe해 render와 mounted를 수행한다.
10 observe(() => {
11 this.render();
12 this.mounted();
13 });
14 }
15 template() { return ``; }
16 setup() {};
17 render() { this.target.innerHTML = this.template(); }
18 mounted() {}
19 updated() {}
20 setState(newState) {
21 this.state = { ...this.state, ...newState };
22 this.render();
23 this.updated();
24 }
25};

프로젝트에 적용

프로젝트에 적용된 모습은 아래와 같다.

1.
2├─ index.html
3└─ src
4 ├─ index.js
5 ├─ store.js
6 ├─ App.js
7 ├─ components
8 │ └─ ...
9 └─ core
10 ├─ Component.js
11 ├─ Store.js
12 ├─ observer.js
13 └─ Router.js
  • observer.js
1let requestingObserver = null;
2
3export const observe = (notify) => {
4 requestingObserver = notify;
5 notify();
6 requestingObserver = null;
7};
8
9export const observable = (object) => {
10 const observersPerProps = new Map();
11 return new Proxy(object, {
12 get(target, prop) {
13 if (!observersPerProps.has(prop)) observersPerProps.set(prop, new Set());
14 if (requestingObserver) observersPerProps.get(prop).add(requestingObserver);
15 return target[prop];
16 },
17 set(target, prop, val) {
18 if (target[prop] === val) return true;
19 if (JSON.stringify(target[prop]) === JSON.stringify(val)) return true;
20 target[prop] = val;
21 observersPerProps.get(prop).forEach((notify) => notify());
22 return true;
23 }
24 });
25};
  • store.js
1import Store from "./core/Store.js";
2
3export const store = new Store({
4 state: {
5 modal: null,
6 },
7 mutations: {
8 CHANGE_MODAL(state, payload) {
9 state.modal = payload;
10 },
11 CLOSE_MODAL(state) {
12 state.modal = null;
13 },
14 }
15});
  • Modal.js
1import Component from "../core/Component.js";
2
3import { store } from "../store.js";
4
5export default class Modal extends Component {
6 template() {
7 return `
8 <div class="modal">...</div>
9 <style>
10 .modal {
11 position: absolute;
12 display: ${store.state.modal ? "block" : "none"};
13 justify-content: center;
14 align-items: center;
15 left: 0;
16 top: 0;
17 width: 100%;
18 height: 100vh;
19 background: rgba(0, 0, 0, 0.5);
20 }
21 </style>
22 `;
23 }
24}

이렇게 원하던 대로 모달 창 구현을 할 수 있었다.


참조:
https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Store/#_5-flux-pattern
https://peter-cho.gitbook.io/book/5/5_2
https://vuex.vuejs.org/