[React] 전역 상태 관리 라이브러리 제작기 (2)
[React] 전역 상태 관리 라이브러리 제작기 (2)
이전 글 보기
[React] 전역 상태 관리 라이브러리 제작기 (1)
개선되어야 하는 부분
이전 작업의 내용만 갖고도 전역 상태 관리를 할 수 있습니다. 하지만, 복잡한 데이터를 다루는 것에 용이하지는 않습니다. 그 이유는 Context API와 같이 객체 또는 배열 데이터를 저장한다면 ===
연산자로 비교할 수 없어 데이터가 변경되지 않았음에도 리렌더링이 발생하기 때문입니다.
이 이유는 제가 Context API를 사용하지 않고 전역 상태 관리 라이브러리를 만들기로 결심한 이유가 되기도 합니다.
또한, useState
의 setState
를 사용하면 함수를 넘길 수 있는데, 함수의 첫 번째 인자로 현재 값을 받아올 수 있습니다. 이를 이용해 Hook의 dependency 최적화를 진행할 수 있는데, 현재 작업한 구조 상으로는 그것이 힘들다는 단점이 있습니다.
useState, useGlobalStore
현재 저장된 값에 1을 더하는 countAdd
함수를 useState
를 사용할 때와 이전 포스트에서 작성한 useGlobalStor
를 사용할 때를 각각 비교해보겠습니다.
// useState
const [count, setCount] = useState(0);
// setState의 경우, dependency 배열에서 생략될 수 있습니다.
const countAdd = React.useCallback(() => setCount(curr => curr + 1), []);
// useGlobalStore
const [count, setCount] = useState(store);
// count 값의 경우 dependency 배열에서 생략될 수 없습니다.
const countAdd = React.useCallback(() => setCount(count + 1), [count]);
위와 같은 차이가 존재합니다. 불필요한 dependency
를 막기 위해 useState
와 같이 함수를 전달할 수 있도록 하는 것이 필요합니다.
useGlobalStore setState
export interface CreateStoreReturnValue<T> {
getValue: () => T;
setValue: (newValue: T) => void;
onChange: (callback: (newValue: T) => void) => void;
}
const createStore = <T = any>(defaultValue: T): CreateStoreReturnValue<T> = {
let value = defaultValue;
const callbackList: Array<(newValue: T) => void> = [];
const getValue = () => value;
const setValue = (newValue: T) => {
value = newValue;
callbackList.forEach((callback) => callback(newValue));
}
// value 값이 변경될 때 실행되는 callbackList를 관리합니다.
const onChange = (callback: (newValue: any) => void) => {
callbackList.push(callback);
// 추가된 callback을 제거하는 함수를 반환합니다.
return () => {
const idx = callbackList.findIndex(callback);
callbackList.splice(idx, 1);
};
};
return { getValue, setValue, onChange };
};
위 함수는 이전 포스트에서 작성한 createStore
코드입니다.
여기에서 setValue
에 newValue
타입이 T
가 아닌 (currValue: T) => T
타입이 올 수도 있음을 명시해줘야 합니다.
type SetValueFunction<T = any> = (currValue: T) => T;
export interface CreateStoreReturnValue<T> {
getValue: () => T;
setValue: (newValue: T | SetValueFunction<T>) => void;
onChange: (callback: (newValue: T) => void) => void;
}
그리고, createStore
의 setValue
또한 다음과 같이 수정이 필요합니다.
const setValue = (newValue: T | SetValueFunction<T>) => {
value = typeof newValue === "function" ? (newValue as Function)(value) : newValue;
callbackList.forEach((callback) => callback(value));
};
이제 setValue
의 인자로 함수가 넘어온다면 전달된 함수에 현재 값을 인자로 넘겨 실행한 뒤, 그 반환값을 store
의 value
에 저장합니다.
즉, 아래와 같이 사용할 수 있게 된 것입니다.
const [count, setCount] = useState(store);
// count 값의 경우 dependency 배열에서 생략될 수 없습니다.
const countAdd = React.useCallback(() => setCount(curr => curr + 1), []);
useGlobalStore의 리렌더링 최적화
useGlobalStgore
의 리렌더링 최적화를 진행하면서 어떠한 설계 구조를 가져갈지에 대해 고민을 정말 많이 했습니다.
결론은 useContextAPI
와 Redux
의 useSelector
를 합치는 구조를 갖고 가게 되었습니다. 왜 그렇게 구조를 설계하게 되었는지 아래에서 서술하도록 하겠습니다.
state를 바로 반환하는 것이 아닌, useSelector Hook을 반환
처음 한 생각은 useGlobalStore
의 반환 값으로 [state: any, setState: function]
을 반환하는 것이 아닌, [useSelector: function, setState: function]
을 반환하는 것이었습니다.
전체 객체 데이터는 useGlobalStore
에서 저장하고 있다가 useSelector
에서 selector
함수를 받으면 그에 맞는 값을 전달해준다는 것인데, 이 말은 Hook
에서 Hook
을 반환해서 반환된 Hook
을 통해 selector
로 특정 데이터를 가져온다는 말이 됩니다.
이것이 빛을 보려면 하나의 컴포넌트에서 store
를 조회하고, store
의 값을 useSelector
를 통해 두 개의 변수로 각각 반환해야 한다는 것인데 사실상 비효율적인 구조라고 생각합니다.
useContextAPI + useSelector
useContextAPI
의 store
를 가져오는 형태와 useSelector
의 selector
, equalityFn
을 인자로 넣어주는 방법을 선택했습니다.
useGlobalStore
Hook이 useSelector
의 역할을 하면 되는데, Redux와 달리 store
가 하나일 것이라는 보장이 없어서 useContextAPI
에서 store
를 인자로 받는 것과 같이 store
정보를 알고 있어야 합니다.
구현
먼저, useEffect
에서 이전 값과 현재 값을 비교하기 위해서는 이전 값의 데이터를 저장하고 있어야 합니다. 이 데이터는 store
에 저장한 뒤, 필요할 때 불러와서 사용하도록 하겠습니다.
type SetStateFunction<T = any> = (currValue: T) => T;
export interface CreateStoreReturnValue<T> {
getPrevState: () => T;
getState: () => T;
setState: (newValue: T | SetStateFunction<T>) => void;
onChange: (callback: (newState: T, prevState: T) => void) => void;
}
위 타입을 보면 getPrevState
가 추가된 것 말고도 다른 함수들의 이름이 value
에서 state
로 변경된 것을 확인할 수 있습니다. state
로 작성하는 것이 가독성에 도움이 될 것 같아서 이름 변경 작업 또한 진행하였습니다.
const createStore = <T = any>(defaultState: T): CreateStoreReturnValue<T> => {
const callbackList: Array<(newState: T, prevState: T) => void> = [];
let state = defaultState;
const getState = () => state;
const setState = (nextState: T | SetStateFunction<T>) => {
const prevState = state;
state = typeof nextState === "function" ? (nextState as Function)(state) : nextState;
callbackList.forEach((callback) => callback(state, prevState));
};
// state changed callback
const onChange = (callback: (nextState: any, prevState: any) => void) => {
callbackList.push(callback);
return () => {
const idx = callbackList.findIndex(callback);
callbackList.splice(idx, 1);
};
};
return { getState, setState, onChange };
};
위와 같이 작성할 수 있는데, 간단히 설명하면 다음과 같은 부분이 변경되었습니다.
setState
함수에서prevState
에 현재 저장되어 있는state
값을 저장합니다.state
값에 새롭게 전달받은 값을 저장한 뒤,callbackList
의callback
함수의 인자로state
와prevState
를 전달합니다.
이를 통해 useEffect
의 store.onChange
에서 prevState
값을 가져올 수 있게 되었습니다.
type EqualityFunction<T> = (prev: T, next: T) => boolean;
const defaultEqualityFn = (prev: any, next: any) => prev === next;
const useGlobalStore = <T = any>(
store: CreateStoreReturnValue<T>,
selector?: (value: T) => any,
equalityFn: EqualityFunction<T> = defaultEqualityFn,
): [ T, (state: any) => any ] => {
const [, forceUpdate] = React.useReducer((curr: number) => curr + 1, 0) as [never, () => void]
const selectedState = React.useCallback((value: T) => (
selector ? selector(value) : value
), [selector]);
React.useEffect(() => {
// change callback
const stateChange = store.onChange((newState: T, prevState: T) => {
if ( !equalityFn( selectedState(newState), selectedState(prevState) ) ) {
forceUpdate();
}
});
return stateChange;
}, [selectedState, equalityFn, store]);
return [ selectedState(store.getState()), store.setState ];
};
먼저, useGlobalStore
함수의 인자 부분이 크게 달라졌는데, selector
와 equalityFn
이 추가되었습니다. selector
의 경우 인자로 들어오면 해당 selector
에 해당하는 값을 반환하고 selector
가 없다면 변수를 그대로 반환합니다.
equalityFn
은 useEffect
내에서 사용되는데, prevState
와 newState
의 값이 다른 경우에만 forceUpdate
를 실행해서 리렍더링을 발생시킵니다. forceUpdate
로 변경된 것은 더이상 state
의 정보를 useGlobalStore
에서 저장하지 않고, 반환 값에서 store.getState()
를 사용하고 있기 때문에 useState
구문을 강제 리렌더링 시키는 useReducer
구문으로 변경하였습니다.
위 결과물만 놓고 보면 selectedState
즉, selector로 가리키고 있는 state
만 비교를 하고 있기 때문에 해결된 것으로 보일 수도 있습니다.
const store = createStore({
number: 0.
string: '',
});
...
const [number, setStore] = useGlobalStore(store, state => state.number);
위 코드에서 setStore
이 동작할 때, number
값이 변화하지 않는다면 useEffect
내의 euailityFn
에 true
값이 반환되어 forceUpdate
가 일어나지 않습니다. 하지만, selector를 통해 객체나 배열이 반환된다면, 즉 객체가 객체를 포함하고 있는 구조라면 ===
연산자로 비교가 불가능하기 떄문에 forceUpdate
함수가 실행될 것입니다.
이를 방지하기 위해 equalityFn
을 함수로 넘기고 있는 것이며, 객체, 배열 등의 값을 비교할 수 있는 shallowEqual
함수를 작성하고 이번 포스팅을 마무리하겠습니다.
shallowEqual
function is(prev: any, next: any) {
if (prev === next) {
return prev !== 0 || next !== 0 || 1 / prev === 1 / next;
} else {
return prev !== prev && next !== next;
}
}
function shallowEqual(prev: any, next: any) {
if (is(prev, next)) return true;
if (
typeof prev !== 'object' ||
prev === null ||
typeof next !== 'object' ||
next === null
) {
return false;
}
const keysA = Object.keys(prev);
const keysB = Object.keys(next);
if (keysA.length !== keysB.length) return false;
for (let i = 0; i < keysA.length; i++) {
if (
!Object.prototype.hasOwnProperty.call(next, keysA[i]) ||
!is(prev[keysA[i]], next[keysA[i]])
) {
return false;
}
}
return true;
}
위 shallowEqual
함수를 equalityFn
인자로 넘겨준다면 객체나 배열 값을 비교할 수 있어 동일한 값에 대해 리렌더링이 발생하지 않을 것입니다.
숫자, 문자열 등의 데이터에 shallowEqual
비교 함수로 비교하는 것은 자원 낭비이므로 필요한 경우에 호출해서 사용할 수 있도록 따로 함수를 작성하였습니다.
'Project > JavaScript Project' 카테고리의 다른 글
sagen - 상태 관리 라이브러리 (0) | 2021.02.10 |
---|---|
[React] 전역 상태 관리 라이브러리 제작기 (3) (0) | 2020.12.02 |
[React] 전역 상태 관리 라이브러리 제작기 (1) (0) | 2020.11.29 |
댓글
이 글 공유하기
다른 글
-
sagen - 상태 관리 라이브러리
sagen - 상태 관리 라이브러리
2021.02.10 -
[React] 전역 상태 관리 라이브러리 제작기 (3)
[React] 전역 상태 관리 라이브러리 제작기 (3)
2020.12.02 -
[React] 전역 상태 관리 라이브러리 제작기 (1)
[React] 전역 상태 관리 라이브러리 제작기 (1)
2020.11.29