보통 반응형을 지원하는 상태관리 라이브러리의 동작 방식은 pub/sub 패턴을 사용하며 다음과 같습니다.
view가 store를 갱신시키고 store가 view를 갱신시키는 구조인데, 이 구조에 dispatcher를 끼워서 단방향으로만 액션이 흐르게 만든게 Flux 패턴을 사용하는 상태관리 라이브러리가 됩니다.
문제는 익스텐션의 동작 환경은 View 영역과 Background 영역이 분리되어 위와 같은 방식이 동작하지 않습니다.
View가 위치하는 Popup (혹은 Content)와 Background가 서로 직접 통신할 수 없고 chrome.runtime
또는 MessageChannel
,BroadcastChannel
로 연결을 맺어 간접적으로 통신을 해야합니다.
실제로 원하는대로 상태관리가 동작하지 않는지 확인해봅시다. 간단한 counter 스토어를 만들어 테스트 합니다.
// modules/createCountStore.ts
export default function createCountStore() {
let _count = 0;
const _listeners: Function[] = [];
function runListeners(newCount: number) {
_listeners.forEach((l) => l(newCount));
}
return {
get count() {
return _count;
},
set count(newCount: number) {
_count = newCount;
runListeners(_count);
},
$subscribe(listener: Function) {
_listeners.push(listener);
},
};
}
$subscribe
함수를 통해 count
상태가 변경될 경우 수행할 리스너를 등록합니다.
이후 count
상태가 변경되는 경우 runListeners
를 통해 리스너를 수행합니다.
실제 상태가 되는 _count
그리고 리스너 배열은 클로저를 통해 보호했습니다.
인터페이스에 대한 테스트를 작성해보면 아래와 같습니다.
const countStore = createCountStore();
test("read Count", () => {
expect(countStore.count).toBe(0);
});
test("how listeners work", () => {
let flag = false;
countStore.$subscribe(() => {
flag = true;
});
// 카운터 증가
countStore.count = 1;
expect(countStore.count).toBe(1);
// 등록한 리스너가 동작하면서 flag가 true가 됨
expect(flag).toBe(true);
});
// components/MyCounter.ts
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators.js";
@customElement("my-counter")
export default class MyCounter extends LitElement {
@state()
countStore!: Record<string, any>;
constructor() {
super();
this.#created();
}
render() {
return html`
<button @click=${() => this.#increase()}>
count: ${this.countStore.count}
</button>
`;
}
#created() {
chrome.runtime.sendMessage("get-store", (store) => {
this.countStore = store;
console.log("store in MyCounter", store);
this.countStore.$subscribe(() => {
// 2. 리스너 동작, 리랜더링
this.requestUpdate();
});
});
}
#increase() {
// 1. count 증가
this.countStore.count = this.countStore.count + 1;
}
static styles = css``;
}
view(my-counter)에선 get-store
요청으로 Background에 존재하는 스토어를 가져오고 구독, 상태 갱신이 일어나면 view를 리렌더링 하도록 했습니다.
가져온 스토어는 console.log("store in MyCounter",store)
로 출력합니다.
// background.ts
import createCountStore from "@/modules/createCountStore";
const countStore = createCountStore();
console.log("store in background", countStore);
chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
switch (message) {
case "get-store": {
sendResponse(countStore);
return false;
}
}
return true;
});
background에선 스토어를 생성하고 get-store
메세지를 받으면 생성한 스토어를 전달하도록 했습니다.
background에서 생성한 스토어도 출력해봅시다.
$subscribe
함수가 가라져버렸습니다. getter
,setter
도 같이 사라진 것 같네요.
BroadcastChannel을 통해 스토어를 전달 받는 방식도 같은 에러를 볼 수 있습니다. 왜 메소드가 사라지는걸까요? 이는 sendMessage, BroadcastChannel에서 데이터를 전송할 때 원본을 전송하는 것이 아닌 복사된 데이터를 전송하기 때문입니다.
https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel/postMessage
위 MDN 글을 보면 내부적으로 structured clone algorithm을 사용해 복사된 JSON을 전송하고 따라서 Function은 복사되지 않고 사라진다는 것을 알 수 있습니다.
따라서 기존과 다른 방식을 사용하는 상태관리를 직접 구현해 사용할 필요가 있습니다.
라디오가 브로드캐스트를 통해 통신을 주고 받는 것을 밴치마킹해 Radio와 BroadcastStation이 서로 통신하는 방식의 아키텍처를 설계해봤습니다.
View - Action을 발생시킴
Radio - Action을 브로드캐스팅 시킴, 상태 갱신 확인시 View에게 알림
BroadcastingStation - Action을 받아 Store에게 전달, 상태 갱신 확인시 Radio에게 브로드캐스팅
Store - 액션 발생시 상태를 갱신함
2개의 View, 1개의 Store가 있는 상태입니다. 각 View는 BroadcastStation과 통신할 수 있는 Radio를 갖습니다.
BroadcastStation는 1개의 Store를 가집니다.
popup과 background간의 통신은 브로드캐스팅을 통해 서로 통신을 주고 받습니다.
BroadcastChannel
를 사용할 계획입니다.
View1에서 액션이 발생하면 Radio1에게 액션을 전달합니다.
Radio1은 액션을 브로드캐스팅 시킵니다. 브로드 캐스팅 된 Action은 BroadcastStation으로 전달되며 BroadcastStation이 다시 Store에게 액션을 전달해 스토어를 갱신합니다.
브로드캐스트된 액션은 Radio2가 Radio1을 구독하지 않기 때문에 전달되지 않습니다.
BroadcastStation에선 Store에 액션을 전달한 뒤 Store를 확인합니다. 만약 Store가 변경되었다면 갱신된 상태를 브로드캐스트합니다. Radio1과 Radio2는 BroadcastStation을 구독하고 있어 브로드캐스팅된 상태를 받을 수 있습니다.
Radio가 View에게 상태를 전달하며 상태 갱신을 알리고 View는 상태를 기반으로 리렌더링을 수행합니다.
Radio와 BroadcastStation은 2개의 Channel을 갖습니다. Sender(발신용 채널), Receiver(수신용 채널)입니다.
2개의 Radio가 있다면 이런 구조로 통신하게 됩니다.