[Copy Stack] 스토어 설계 및 구현

dev2820·2022년 12월 31일
0

프로젝트: Copy Stack

목록 보기
22/28

앞서 만든 Radio-BroadcastingStation에 사용할 스토어를 직접 구현합니다.

인터페이스

// modules/broadcast/interfaces/Store.ts
export default interface Store {
  [key: string]: any;
  $dispatch: Function;
  $state: {
    [key: string]: any;
  };
}

스토어는 상태를 직접 참조할 수 있고 혹은 $state를 통해 참조할 수 있습니다.
$dispatch를 통해 액션을 실행하고 상태를 갱신할 수 있습니다.

인터페이스에 대한 테스트는 다음과 같습니다.

import { test, expect } from "@jest/globals";
import { createStore, Action } from "@/modules/broadcast";

const countStore = createStore({
  state: { count: 0 },
  actions: {
    increase() {
      this.count++;
    },
    doNothing() {
      // nothing to do;
    },
    setCount(newCount: number) {
      this.count = newCount;
    },
  },
});

test("read data", () => {
  // 상태는 프로퍼티를 직접 참조할 수 있습니다.
  const count = countStore.count;
  expect(count).toBe(0);
});

test("read $state", () => {
  // 혹은 모든 상태를 $state를 통해 읽어올 수 있습니다.
  expect(countStore.$state).toStrictEqual({
    count: 0,
  });
});

test("use actions", () => {
  // action 또한 직접 호출할 수 있습니다.
  const isChanged = countStore.increase();
  expect(countStore.count).toBe(1);
  // 만약 state에 갱신이 있다면 true를 반환합니다.
  expect(isChanged).toBe(true);
});

test("if action doesn't change state", () => {
  // state에 갱신이 없다면 false를 반환합니다.
  const isChanged = countStore.doNothing();
  expect(isChanged).toBe(false);
});

test("use actions with parameters", () => {
  // 액션에 파라미터를 포함하는 것도 가능합니다.
  const isChanged = countStore.setCount(0);
  expect(isChanged).toBe(true);
  expect(countStore.count).toBe(0);
});

test("how to use $dispatch", () => {
  // $dispatch를 통해서도 액션을 실행할 수 있습니다. 
  const action = new Action("setCount", 3);
  const isChanged = countStore.$dispatch(action);
  expect(isChanged).toBe(true);
  expect(countStore.count).toBe(3);
});

구현

실제 스토어 구현은 클로저를 활용했습니다. 클래스를 사용하지 않은 것은 유동적으로 멤버와 메소드가 정해지기 때문에 defineProperty를 활용하는 것이 구현에 더 편리했기 때문입니다.

구현은 다음과 같습니다.

import Action from "./classes/Action";
import Store from "./interfaces/Store";

/**
 * createStore는 state와 actions를 받는다. 
 */
export default function createStore(storeOption: {
  state: Record<string, any>;
  actions: Record<string, any>;
}): Store {
  const { state, actions } = storeOption;

  /**
   * 클로저로 보호된 _isChanged_ 에서 action을 수행한 결과 state가 변경되었는지
   * 알려줍니다.
   */ 
  let _isChanged_ = false;

  /**
   * 생성될 store의 모체(껍데기?) 입니다.
   */
  const store = {};

  /**
   * state는 Map을 통해 nlogn만에 참조할 수 있게 만듦니다. 
   */ 
  const stateMap = Object.keys(state).reduce((map, key: string) => {
    return map.set(key, state[key]);
  }, new Map<string, any>());
  /**
   * action또한 nlogn만에 참조할 수 있게 Map에 저장합니다. 
   */ 
  const actionMap = Object.keys(actions).reduce((map, key: string) => {
    /**
     * action은 function만 허용하고 function이 아닌 경우엔 패스합니다.
     */ 
    if (typeof actions[key] !== "function") return map;

   /**
     * action으로 등록한 함수를 실행하는 함수를 actionMap에 추가합니다.
     * 일종의 훅을 거는 방법인데, 특정 함수가 실행됬는지 추적하고 실행 전이나 후에
     * 훅을 추가하고 싶다면 이렇게 하면 됩니다.
     * 
     * 이 코드의 경우 action을 실행하기 전 _isChanged_ 를 초기화하고
     * 액션이 끝난 뒤 _isChanged_ 를 반환하도록 훅을 걸었습니다.
     */ 
    return map.set(key, function () {
      _isChanged_ = false;
      const action = actions[key].bind(store);
      action(...arguments);

      return _isChanged_;
    });
  }, new Map<string, Function>());

  /**
   * store에 defineProperty를 통해 state를 추가합니다.
   * store[state이름] 방식으로 상태를 참조하는 경우
   * stateMap에서 상태를 읽고 반환합니다. 
   * store[state이름] = 값 형식으로 store를 변경하는 경우
   * _isChanged_를 true로 바꿉니다. 
   * 이를 통해 action에서 상태를 변경한 경우를 추적할 수 있습니다.
   */
  const stateKeys = [...stateMap.keys()];
  stateKeys.forEach((key: string) => {
    Object.defineProperty(store, key, {
      get: () => {
        if (!stateMap.has(key)) return undefined;
        return stateMap.get(key);
      },
      set: (value: any) => {
        _isChanged_ = true;
        stateMap.set(key, value);
      },
    });
  });

  /**
   * action 또한 defineProperty를 통해 store에 추가합니다.
   */
  const actionKeys = [...actionMap.keys()];
  actionKeys.forEach((key: string) => {
    Object.defineProperty(store, key, {
      get: () => {
        if (!actionMap.has(key)) return undefined;
        return actionMap.get(key);
      },
    });
  });

  /**
   * $dispatch 메소드를 만들어줍니다. 
   * 전달 받은 이름(action.type)으로 액션의 유무를 확인하고 액션을 실행합니다.
   */
  Object.defineProperty(store, "$dispatch", {
    get: () => {
      return (action: Action) => {
        if (!actionMap.has(action.type)) return;

        const act = actionMap.get(action.type);
        const isChanged = act ? act(action.payload) : false;

        return isChanged;
      };
    },
  });
  /**
   * $state 속성을 추가합니다. 
   */
  Object.defineProperty(store, "$state", {
    get: () => {
      return Object.fromEntries(stateMap);
    },
  });

  return store as Store;
}

불변성에 대해

스토어를 설계하다보면 고려할 점이 불변성에 대한 부분입니다. 스토어에서 참조만 가져온 경우 action등을 거치지 않고 상태를 변경할 수 있고, 상태 변경을 감지하지 못하는 경우가 생길 수 있습니다.

운이 좋게도? 익스텐션에서 동작할 이 스토어는 불변성을 크게 고려하지 않아도 됩니다. BroadcastChannel이나 runtime.postMessage를 통해 전달되는 객체는 structuredClone를 통해 복사되어 전달되기 때문에 참조를 통해 원본을 회손당할 걱정을 하지 않아도 됩니다. 물론 이 때문에 함수는 상태로 저장할 수 없다는 단점이 생기긴 합니다.

profile
공부,번역하고 정리하는 곳

0개의 댓글