노션클론 리팩토링기(4) - useSelector, 함수 비교, isEqual 수정, 옵저버 구독해제

김영현·2023년 11월 18일
0

useSelector

useSelector는 선택자함수를 받아와, 선택한 상태를 구독(변경시 리렌더)해주고 반환해줌.
이렇다면 각 상태마다 옵저버블한 인스턴스를 생성해야하는데, 어떻게 해야할지 고민됐다.
받아온 선택자 함수로 얻은 값(상태)로는 고유하게 구분할 수 없어보임.

몇시간동안 고민하다가 공식문서를 봤음.

내용이 길지만 요약하자면 useCallback으로 selector를 감싸서 useSyncExternalStoreWithSelector로 전달했다.
useSyncExternalStoreWithSelector는 React18에서 생긴 외부저장소 구독 훅임.
useCallback의 기능은 함수를 받아 이전 실행값을 저장후, 다음값과 비교해줌
=> 이전 값을 계속 메모리상에 띄워놓는다.
아하!이 기능덕분에 상태가 변경되면 리-렌더를 일으키는군.
그러면 이 값을 어떻게 구분하지?
순환 미궁에빠졌다...라고 생각했지만, 놀랍게도 selector로 전달된 값은 객체 멤버 참조다.
따라서 구분하지 않고 값이 바뀌면 알아챌수 있다...!!!!!!!

유레카!인가?
아무튼 재밌는 사실을 알게되었다. 구독한 상태를 계속 메모리상에 참조하고있구나?
변경될때만 알림을 전달해줄줄 알았다. 이런 기능이 더 효율적인걸까?
다시 생각해보니 어차피 옵저버블 인스턴스를 생성해도 메모리를 잡아먹는다.
어떻게 관리할지의 차이였다. 클래스냐, 함수냐.
오우!


함수의 비교

객체를 구독했을때 전달한 callback을 계속 추가될까봐 set에 넣어놨었다.
그런데, 렌더링이 계속 발생햇었음.
=> 함수끼리의 비교는 다른 참조이므로 false를 반환한다...
결국 observe는 한번만 적용해야한다.

이렇게 못생긴 코드가 되버림...


isEqual 수정

객체끼리 비교하는 부분에서, 길이체크를 안했다...!

    const value1Keys = Object.keys(value1);
    const value2Keys = Object.keys(value2);
    if (value1Keys.length !== value2Keys.length) {
      return false;
    }
    const longObj =
      value1Keys.length > value2Keys.length ? value1 : value2;
    for (const key in longObj) {
      if (!isEqual(value1[key], value2[key])) {
        return false;
      }
    }
...

꼼꼼히 생각해봐야하는 부분이었는데.ㅠㅠ
파일이 많아질수록 어디서 오류난지 찾기가 어려워진다. 처음 만드는 부분은 더욱 그렇다.
이래서 테스트 코드를 작성해야하는구나!


DocumentPage 리-렌더링 횟수 증가 현상


이런식으로 하위문서를 생성하면, 렌더함수가 observe로 넘어갈때 set에 중복처리가 안되고 새로운 함수로 들어간다.
Router에서 새로운 주소를 받았을때 new component로 만드니까, 이전에 있던 this.render메서드와 다시 생긴this.render메서드가 다르다고 판단하는 것 같다.
하지만 같은 메서드라서 set내부로 들어간 this.render메서드 갯수만큼 정직하게 리-렌더 발생함.
고쳐봐야겠지?

문제점 파악

  1. this바인딩때문에 this.render.bind(this)로 넘겨줄시 bind새로운 함수를 반환하기에 새로운 함수 취급되어서 새로생김
  2. 화살표함수는 새로운 참조를 생성해서 다른 함수로 취급.

해결법 제안

  1. Map사용 => 사용하지 않는 렌더메서드가 메모리상에 계속 남아있음
  2. 컴포넌트가 사라질때 구독 취소! => 이건 라우터에서 this.$target.innerHTML = ""로 관리중...
  3. 옵저버패턴 구현체에서 unobserve생성.

3번이 나아보인다. 결국 구독을 취소하는 기능은 옵저버 기능에 있어야하니...

import { isEqual } from "../isEqual.js";

let currentObserver = null;
let currentUnObserver = null;
const observers = {};

export const observe = (fn) => (currentObserver = fn);
export const unobserve = (fn) => (currentUnObserver = fn);
export const observable = (obj) => {
  //상태마다 돌면서 get,set 지정
  const stateKeys = Object.keys(obj);
  stateKeys.forEach((key) => {
    let _value = obj[key];
    if (observers[key] === undefined) {
      observers[key] = new Set();
    }
    Object.defineProperty(obj, key, {
      get() {
        if (currentObserver) {
          observers[key].add(currentObserver);
          currentObserver = null;
        }
        return _value;
      },
      set(value) {
        if (isEqual(_value, value)) {
          return;
        }
        _value = value;
        observers[key].forEach((fn) => fn());
        if (currentUnObserver) {
          observers[key].delete(currentUnObserver);
          currentUnObserver = null;
        }
      },
    });
  });
  return obj;
};

구현체 자체는 황준일 개발자님의 글을 보고 많은 도움을 얻었다.

이렇게 해서


2번으로 줄여버림.

라우팅시 새로운 인스턴스 생성때문에 한번 렌더링되고 가져온상태가 바뀌어서 한 번 더 렌더링 되는게 아닌가 싶다.


useSelector를 사용하는 이유를 알았다.

정확히 상태를 타겟해서 그 상태만 변경되었을때 컴포넌트를 리-렌더링하면 리소스 낭비가 적다.
또한 잡다하게 useState, setState를 선언할 필요 없이 받아와서 자식에게 필요하다면 props로 넘겨주면 됨.
하다보니 실제로 코드가 그렇게 짜여진다ㅎㅎ
물론 클래스 컴포넌트니까 클래스를 적극활용해야하는건 맞는데, 함수쪽도 건들여보고싶어서 해봄!
그리고 useMemouseCallback도 왜 필요한지 리덕스를 만들면서 점점 알게된다.
전혀 써보지 않았던 함수들인데 말이다 ㅋㅋ
실제 코드와 전혀 다르겠지만 구현하는 재미가 있구만!

나중에 시간나면 함수로 만들어진 리액트도 구현해보고싶다. JSX까지 직접!!


다음주내에 진행할 리팩토링 사항

  1. 컴포넌트 클래스 잘 다듬기
  2. 나머지 생성자 함수들로 만들어진 컴포넌트 클래스화
  3. 폴더 및 파일 정리...

이후 시간 남으면 documentList의 title과 documentPage의 title 낙관적 업데이트 실시!
그러면 문서를 수정했을때 한번에 변함 + api요청 줄일수 있음🙂

profile
모르는 것을 모른다고 하기

0개의 댓글