Project/JavaScript Project

[React] 전역 상태 관리 라이브러리 제작기 (2)

Paeng 2020. 12. 1. 01:58
728x90

[React] 전역 상태 관리 라이브러리 제작기 (2)

React

이전 글 보기

[React] 전역 상태 관리 라이브러리 제작기 (1)

개선되어야 하는 부분

이전 작업의 내용만 갖고도 전역 상태 관리를 할 수 있습니다. 하지만, 복잡한 데이터를 다루는 것에 용이하지는 않습니다. 그 이유는 Context API와 같이 객체 또는 배열 데이터를 저장한다면 === 연산자로 비교할 수 없어 데이터가 변경되지 않았음에도 리렌더링이 발생하기 때문입니다.

이 이유는 제가 Context API를 사용하지 않고 전역 상태 관리 라이브러리를 만들기로 결심한 이유가 되기도 합니다.

또한, useStatesetState를 사용하면 함수를 넘길 수 있는데, 함수의 첫 번째 인자로 현재 값을 받아올 수 있습니다. 이를 이용해 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 코드입니다.

여기에서 setValuenewValue 타입이 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;
}

그리고, createStoresetValue 또한 다음과 같이 수정이 필요합니다.

const setValue = (newValue: T | SetValueFunction<T>) => {
  value = typeof newValue === "function" ? (newValue as Function)(value) : newValue;
  callbackList.forEach((callback) => callback(value));
};

이제 setValue의 인자로 함수가 넘어온다면 전달된 함수에 현재 값을 인자로 넘겨 실행한 뒤, 그 반환값을 storevalue에 저장합니다.

즉, 아래와 같이 사용할 수 있게 된 것입니다.

const [count, setCount] = useState(store);

// count 값의 경우 dependency 배열에서 생략될 수 없습니다.
const countAdd = React.useCallback(() => setCount(curr => curr + 1), []);

useGlobalStore의 리렌더링 최적화

useGlobalStgore의 리렌더링 최적화를 진행하면서 어떠한 설계 구조를 가져갈지에 대해 고민을 정말 많이 했습니다.

결론은 useContextAPIReduxuseSelector를 합치는 구조를 갖고 가게 되었습니다. 왜 그렇게 구조를 설계하게 되었는지 아래에서 서술하도록 하겠습니다.

state를 바로 반환하는 것이 아닌, useSelector Hook을 반환

처음 한 생각은 useGlobalStore의 반환 값으로 [state: any, setState: function]을 반환하는 것이 아닌, [useSelector: function, setState: function]을 반환하는 것이었습니다.

전체 객체 데이터는 useGlobalStore에서 저장하고 있다가 useSelector에서 selector 함수를 받으면 그에 맞는 값을 전달해준다는 것인데, 이 말은 Hook에서 Hook을 반환해서 반환된 Hook을 통해 selector로 특정 데이터를 가져온다는 말이 됩니다.

이것이 빛을 보려면 하나의 컴포넌트에서 store를 조회하고, store의 값을 useSelector를 통해 두 개의 변수로 각각 반환해야 한다는 것인데 사실상 비효율적인 구조라고 생각합니다.

useContextAPI + useSelector

useContextAPIstore를 가져오는 형태와 useSelectorselector, 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 값에 새롭게 전달받은 값을 저장한 뒤, callbackListcallback 함수의 인자로 stateprevState를 전달합니다.

이를 통해 useEffectstore.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 함수의 인자 부분이 크게 달라졌는데, selectorequalityFn이 추가되었습니다. selector의 경우 인자로 들어오면 해당 selector에 해당하는 값을 반환하고 selector가 없다면 변수를 그대로 반환합니다.

equalityFnuseEffect 내에서 사용되는데, prevStatenewState의 값이 다른 경우에만 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 내의 euailityFntrue 값이 반환되어 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 비교 함수로 비교하는 것은 자원 낭비이므로 필요한 경우에 호출해서 사용할 수 있도록 따로 함수를 작성하였습니다.

728x90
728x90