앞서 만든 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를 통해 복사되어 전달되기 때문에 참조를 통해 원본을 회손당할 걱정을 하지 않아도 됩니다. 물론 이 때문에 함수는 상태로 저장할 수 없다는 단점이 생기긴 합니다.