저번 포스팅에 이어 이번에는 stateManager.ts
구현부에 대해 다뤄보고자 한다.
stateManager.ts
(이하 상태 매니저)에서는 상태를 읽고 업데이트하는 직관적인 인터페이스를 제공한다. 인터페이스 구조는 react
의 useState
를 참고했다.
상태 매니저 코드는 createStateManager
라는 함수 내부에 정의되어있다. createStateManager
는 인자로 store
를 받기 때문에 유저가 임의로 createStore
함수를 호출해서 새로 스토어를 만들면 상태 매니저도 하나 새로 만들어야 한다.
임상태에서는 defaultStateManager
를 제공한다.
상태 매니저 내부에서는 상태를 읽고 수정하는 메서드와 상태 변화를 구독하는 메서드를 외부에 제공하고 내부적으로는 해당 구독자 배열과 배칭 프로세스를 처리하는 로직이 캡슐화되어있다.
내부 메서드로는 상태를 읽는 atomValue
상태를 쓰는 setAtomState
이 둘의 튜플인 atomState
가 있고 상태를 구독하는 subscribe
메서드가 있다.
function atomValue<Value>(atom: AtomOrSelectorType<Value>) {
return () => store.readAtomValue(atom);
}
스토어로부터 인자로 들어온 아톰의 현재 값을 읽어오는 함수이다. useState
와의 차이는 반환값이 함수라는 것인데, 이유는 상태가 변할때마다 atomValue
의 리턴값이 바껴야 하기 때문에 값을 읽어올때마다 매번 함수를 실행해줘야 하기 때문이다.
function setAtomState<Value>(atom: AtomOrSelectorType<Value>) {
let newValue: Value | Awaited<Value>;
const result = (argument: setStateArgument<Value>) => {
if (typeof argument === "function") {
const setter = argument as (prevValue: Value | Awaited<Value>) => Value | Awaited<Value>;
const prevValue = store.readAtomValue(atom);
newValue = setter(prevValue);
} else {
newValue = argument;
}
if (!atomBatchingQueue[atom.key]) atomBatchingQueue[atom.key] = [];
atomBatchingQueue[atom.key].push({ atom, newValue });
batching((last) => {
store.writeAtomState(last.atom, last.newValue);
render(last.atom);
});
};
return result;
}
setAtomState
는 인자로 들어온 아톰의 값을 새로운 값으로 수정하는 함수이다. 반환값은 새로운 값을 인자로 받는 함수이다. useState
의 setState
처럼 인자로 함수를 받을수도 있으며 해당 함수의 인자는 이전 상태값이다. 또한 배칭 프로세스가 적용되어있는데, 이는 동시에 같은 상태를 수정하는 경우, 마지막에 일어난 수정만 일어나도록 하기 위함이다. 리액트의 useState
도 이와 유사한 배칭 프로세스가 적용되어있다.
또한 상태가 업데이트 되면 render 함수를 실행하여 상태 변화를 즉각 반영한다.
function render<Value>(atom: AtomOrSelectorType<Value>) {
const listeners = subscriptions.get(atom.key);
if (!listeners) return;
listeners.forEach((callback) => callback());
}
render 함수는 subscriptions
에 저장된 함수를 꺼내 실행하는 역할이다. subscription
에는 리액트 기준으로 컴포넌트가 저장된다고 생각하면 된다.
const atomBatchingQueue: { [key: string]: { atom: AtomOrSelectorType; newValue: any }[] } = {};
let processingQueueFlag = false;
function batching(process: (last: { atom: AtomOrSelectorType; newValue: any }) => void) {
if (processingQueueFlag) return;
processingQueueFlag = true;
Promise.resolve().then(() => {
const atomKeyList = Object.keys(atomBatchingQueue);
const promises = atomKeyList.map((key) => {
const batchList = atomBatchingQueue[key];
atomBatchingQueue[key] = [];
const last = batchList.pop();
if (!last) return Promise.resolve();
return Promise.resolve(process(last)).catch((error) => {
console.error(`Failed to process for ${last.atom.key}:`, error);
});
});
Promise.all(promises).finally(() => {
processingQueueFlag = false;
});
});
}
이것은 배칭을 처리하는 실제 코드이다. 배칭 프로세스가 아직 실행되지 않고 있을때에만 동작한다. 우선 배칭 프로세스가 한번 시작되면, 배칭이 진행되고 있는지 여부에 대한 플래그를 true로 바꾼다. 그 후 상태를 새로 쓰는 로직을 Promise
를 사용하여 배칭 큐에 저장된 상태 업데이트 요청들을 마지막에 들어온 요청들만 모아놓은 비동기 처리 리스트로 만들고, 기존 배칭 큐에 저장된 값들을 비워준다. 그 후 비동기 처리를 마치면 배칭 프로세스 플래그를 false로 바꾼다.
function atomState<Value>(
atom: AtomOrSelectorType<Value>
): [
() => Value,
(newValue: Value | Awaited<Value> | ((prevValue: Value | Awaited<Value>) => Value | Awaited<Value>)) => void
] {
return [atomValue(atom), setAtomState(atom)];
}
atomValue
와 setAtomState
의 튜플이다.
function subscribe(
targetAtom:
| (AtomOrSelectorType | ((param: any) => AtomOrSelectorType))
| (((param: any) => AtomOrSelectorType) | AtomOrSelectorType)[],
callback: () => void
) {
if (Array.isArray(targetAtom)) {
targetAtom.forEach((atom) => {
subscribeCallbackToSingleAtom(typeof atom === "function" ? atom(null) : atom, callback);
});
} else {
subscribeCallbackToSingleAtom(typeof targetAtom === "function" ? targetAtom(null) : targetAtom, callback);
}
}
function subscribeCallbackToSingleAtom(atom: AtomOrSelectorType, callback: () => void) {
const existingSubscriptions = subscriptions.get(atom.key) || [];
subscriptions.set(atom.key, [...existingSubscriptions, callback]);
}
subscribe
함수는 구독할 상태값 혹은 상태값의 배열과, 상태가 업데이트 되면 실행될 콜백함수를 인자로 받는다. 콜백함수에는 리액트 기준으로 컴포넌트가 들어간다고 생각하면 된다.