본문 바로가기
프론트엔드

[JS] 전역 상태관리 스토어를 만들어보며.

by ISA(류) 2022. 3. 30.

바닐라 js로 다양한 기능을 간단히 만들어보는 js-util 프로젝트의 9번째 모듈로 전역 상태관리 스토어를 만들어보았다.

 

https://github.com/yoonjonglyu/js-util

 

GitHub - yoonjonglyu/js-util: html, css, js, vanillajs, animation, module & util

html, css, js, vanillajs, animation, module & util - GitHub - yoonjonglyu/js-util: html, css, js, vanillajs, animation, module & util

github.com

바닐라 js로 컴포넌트 단위나 페이지 단위 개발을 진행 할 경우 CSR방식의 SPA 웹애플리케이션의 경우 전역 상태 공유와 상태변화에 따른 부분 리렌더링에 대한 필요성을 꾸준히 느꼈는데 그를 위해서 필요한 요소들을 최대한 추상화해서 간단하게 구현해본 내용이다. 처음 컨셉 자체를 잡을때 v-dom없이 dom조작을 하는 것과 추후 v-dom에 맞게 dom조작을 하는 부분 모두 일정 부분 고려했으며 v-dom이 없이 처리하는 것을 메인으로 삼았다.

 

상태관리를 인터페이스에 대한 아이디어는 React Hooks에서 가져왔고, useSelector, useDispatch, useState 3가지 인터페이스를 참고하여서 개인적으로 쓰기 편하게 구현해보기로 했다. 초기 컨셉은 아래와 같다.

class Store {
    constructor() {
        this._store = {};
        this._observe = {};
    }
    useSelector(key) {
        return this._store[key];
    }
    dispatch(key, state) {
        this._store[key] = state;
    }
    useState(initState) {
        for (const [key, value] of Object.entries(initState)) {
            Object.defineProperty(this._store, key, {
                get() {
                    return this[`_${key}`];
                },
                set(value) {
                    this[`_${key}`] = value;
                }
            });
            this._store[key] = value;
            
            return [
                this.useSelector(key),
                (state) => this.dispatch.call(this, key, state)
            ];
        }
    }
    /**
     * 1. 간단히 selector 와 dispatch를 구현하고
     * 2. 그를 useState훅 처럼 가져다가 쓰는 것을 구현하자.
     * 3. state를 selector하거나 dispatch하는 경우에 해당 Node를 구독하게 만드는 로직이 필요할 거 같다.
     */
}

오브젝트 defineProperty를 이용해서 해당 상태의 getter와 setter를 만들고 그를 통해서 상태를 저장하고 불러오게 만들었다. 실제로 상태의 경우 _key 값으로 저장되는데 해당 부분을 좀 더 캡슐화하고자 한다면 아예 다른 구조에 저장해서 getter setter를 통해서 _store에서 상태를 불러올때 _key가 노출되지 않도록 하는게 더 좋을 것이다.

 

상태관리의 경우 옵저버 패턴(구독-발행)구조에 가깝다는 것을 알고 있었기에 옵저버 패턴을 어찌 적용할지 나름 고민해보니 부분 상태 변화에 따라서 리렌더링할 부분을 콜백함수로 분리하여서 그 함수 내부에서 옵저버를 등록하게 하는 방향으로 정했다. 그렇게 처리하고나니 리렌더링이 발생할 시 구독하는 함수 내부에 state로직이 들어 있어야 최신 상태를 가져와서 리렌더링하게 된다는 점과 그리고 해당 리렌더링이 발생할때 useState로직(즉 상태를 다시 초기화)이 초기화 되는 부분이 있어서 해당 부분을 예외처리 한후 getter setter로 구독-발행을 로직을 추가해줬다.

class Store {
    constructor() {
        this._store = {};
        this._observe = {};
        this.currentObserve = null;
    }
    watch(callback) {
        this.currentObserve = callback;
        callback();
        this.currentObserve = null; 
    }
    useSelector(key) {
        return this._store[key];
    }
    dispatch(key, state) {
        this._store[key] = state;
    }
    useState(initState) {
        const that = this;
        for (const [key, value] of Object.entries(initState)) {
            if (!this._store[key]) { // 첫 생성시 초기화 
                const _key = `_${key}`;
                this._store[_key] = value;
                this._observe[key] = [];
                Object.defineProperty(this._store, key, {
                    get() {
                        if (that.currentObserve) that._observe[key].push(that.currentObserve);
                        return this[_key];
                    },
                    set(value) {
                        this[_key] = value;
                        that._observe[key].forEach((callbak) => callbak());
                    }
                });
            } // useState를 통해서 글로벌 상태를 공유한다.

            return [
                () => this.useSelector(key),
                (state) => this.dispatch(key, state),
            ];
        }
    }
    /**
     * 1. 간단히 selector 와 dispatch를 구현하고
     * 2. 그를 useState훅 처럼 가져다가 쓰는 것을 구현하자.
     * 3. state를 selector하거나 dispatch하는 경우에 해당 Node를 구독하게 만드는 로직이 필요할 거 같다.
     */
}

이렇게 짜고 나니 상태변화 전파나 그를 통한 리렌더링은 정상적으로 watch되었지만, useState를 통해서 상태 API를 불러오는 인터페이스에서 항상 객체 방식으로 key를 전달하는 것에 불편함을 느꼈고 string으로 상태의 key값을 전달해서 불러오는 부분을 처리하고 간단히 예외처리 해줬다. 안정성을 위해서는 추가적으로 기타 타입에 대한 예외처리 역시 하면 좋을 것이다.

 

그외에 복수의 상태가 변화 하는 경우 발생하는 setState에 대한 중복 watch(리렌더링)을 따로 task를 만들어서 비동기적으로 처리하는 방식으로 최적화 시켜줬고, 옵저버를 중복 등록하는 부분에 대한 예외처리 역시 추가해줬다. 

그리고 useSelector의 경우 단순히 state key를 통해서 상태를 가져가는게 아닌 콜백함수를 받아서 그 콜백함수 내부에서 state중 필요한 부분을 가져가는 인터페이스로 변경해줬고, dispatch 역시 type과 payload방식으로 인터페이스를 변경해줬다. 아래는 완성된 코드들이다.

class Store {
    constructor() {
        this._store = {};
        this._observer = {};
        this.currentObserve = null;
        this._observeTask = [];
    }
    watch(callback) {
        this.currentObserve = callback;
        callback();
        this.currentObserve = null;
    }
    useSelector(callback) {
        return callback(this._store);
    }
    dispatch(action) {
        const { type, payload } = action;
        this._store[type] = payload;
    }
    /**
     * 1. 간단히 selector 와 dispatch를 구현하고
     * 2. 그를 useState훅 처럼 가져다가 쓰는 것을 구현하자.
     * 3. state를 selector하거나 dispatch하는 경우에 해당 Node를 구독하게 만드는 로직이 필요할 거 같다.
     */
    useState(initState) {
        if (typeof initState === 'string' && this._store[initState] === undefined) {
            return console.error('스토어에 등록 되지 않은 상태는 호출 할 수 없습니다.');
        }

        let key = initState;
        if (typeof initState !== 'string') {
            for (const [__key, value] of Object.entries(initState)) {
                key = __key;
                if (!this._store[__key]) {
                    const that = this;
                    const _key = `_${key}`;
                    Object.defineProperty(this._store, key, {
                        get() {
                            if (checkObserver()) that._observer[key].push(that.currentObserve);
                            return this[_key];

                            function checkObserver() {
                                return that.currentObserve &&
                                    !that._observer[key].filter((prev) => that.checkSameFunction(prev, that.currentObserve))[0];
                            }
                        },
                        set(value) {
                            if (JSON.stringify(value) !== JSON.stringify(this[_key])) {
                                this[_key] = value;
                                that._observer[key].forEach((callbak) => that._observeTask.push(callbak));
                                setTimeout(() => that.publishObserver(), 0);
                            }
                        }
                    });
                    this._store[_key] = value;
                    this._observer[key] = [];
                }
            }
        }

        return [
            this.useSelector((state) => state[key]),
            (state) => this.dispatch({ type: key, payload: state }),
        ];
    }
    publishObserver() {
        while (this._observeTask.length > 0) {
            const currentTask = this._observeTask.shift();
            this._observeTask = this._observeTask.filter((nextTask) => !this.checkSameFunction(currentTask, nextTask));
            currentTask();
        }
    }
    checkSameFunction(a, b) {
        return a.toString() === b.toString();
    }
}
function Main() {
    const store = new Store();
    function App() {
        const root = document.createElement('main');
        root.appendChild(Index());
        root.appendChild(Sub());

        return root;
    }
    function Index() {
        const root = document.createElement('div');
        root.style.background = 'cyan';
        const renderIndex = () => {
            const [Test, setTest] = store.useState({ test: 'a' });
            const [Test2, setTest2] = store.useState({ test2: 'b' });
            root.innerText = `${Test} : ${Test2}`;
            console.log('인덱스가 렌더링 되었다.');
            root.onclick = () => {
                setTest('sss');
                setTest2(Math.random());
            };
        };
        store.watch(renderIndex);

        return root;
    }
    function Sub() {
        const root = document.createElement('div');
        store.watch(() => {
            const [Test2, setTest2] = store.useState('test2');
            const [Test3, setTest3] = store.useState({ test3: 'c' });
            root.innerText = `${Test3} : ${Test2}`;
            console.log('서브가 렌더링 되었다.');
            root.onclick = () => {
                setTest3('공유 되지 않는 상태 변화');
            };
        });

        return root;
    }

    render(document.querySelector('#app'), App());

    function render(root, components) {
        root.appendChild(components);
    }
}

Main();

사실 추가적으로 watch(subscribe)함수를 자동화하는 부분을 고민해보았으나 v-dom(가상돔) 없이는 크게 2가지 문제가 있다고 여러 시행착오 끝에 파악되어서 추후 v-dom 개발 이후 추가적인 개선을 진행해볼 예정이다.

  • 구독 대상, 즉 컴포넌트를 특정 지을 방법이 마땅히 없다. 객체 내부가 아닌 경우 target을 특정지을 방법이 여러 시도를 해보았으나 찾지 못했다. 리액트 hooks 코드를 분석해본 결과 리액트 hooks 역시 props type이였고 컴포넌트 내부의 기능을 호출하는(v-dom)장치였다. 그렇기에 React Hooks를 components 외부에서 호출 하지 못하는 것(예외 케이스들이 존재하긴하지만 리액트 전체 소스코드 분석의 경우 추후 따로 정리해야할 정도로 방대하기에 여기서 거론하지 않겠다.)
  • 컴포넌트를 특정지었다할지라도 watch를 통해서 리렌더링시 dom이나 events에 대한 중복처리 최적화가 안된다. 이부분의 경우 v-dom으로 diff 비교해서 변경 사항만 수정하고 dom에 반영하는게 아닌한 죄다 수동으로 처리해줘야하는데 이걸 단순히 구독만 자동화 한다고 의미가 있지 않고 오히려 그럴 경우 사용자 인터페이스가 불편해지는 문제점이 존재했다.

이렇게 바닐라 js로 허접한 수준이지만 전역 상태관리 store를 간단히 개발해보았다.  추후 v-dom 개발 이후 추가적인 개선을 진행해보아야겠다.

 

관련 링크

https://yoonjonglyu.github.io/js-util/store/

 

Store

 

yoonjonglyu.github.io

https://github.com/yoonjonglyu/js-util

 

GitHub - yoonjonglyu/js-util: html, css, js, vanillajs, animation, module & util

html, css, js, vanillajs, animation, module & util - GitHub - yoonjonglyu/js-util: html, css, js, vanillajs, animation, module & util

github.com

 

반응형