노션클론 리팩토링(3) - 리덕스와 비동기

김영현·2023년 11월 18일
0

리덕스와 비동기

리덕스의 흐름은 다음과 같다

  1. action을 dispatch로 넘겨준다.
  2. reducer가 이를 토대로 새로운 상태를 생성해서 전역 스토어에 저장한다.
    2-1. 전역스토어의 key값은 reducer의 이름임
  3. 뷰에서 이 상태를 가져오려면 getState를 사용한다.

그런데, 2번의 작업이 await-async처럼 비동기처리라면 문제가 생긴다.
getState로 가져올때 작업이 끝나지 않았기 때문이다.
또한 reducer내부에서 프라미스 상태를 반환한다면...dispatchawait으로 받아와하는 불상사가 생긴다.
공식문서에는 비동기를 이렇게 처리한다.

// Thunk 액션 생성자
const fetchData = () => (dispatch, getState) => {
  // 비동기 작업 수행 전에 동기적인 액션을 디스패치할 수 있습니다.
  dispatch(requestData());

  // 예시: 비동기 작업을 수행하고 나서 결과를 받아와서 액션을 디스패치
  fetch('https://api.example.com/data')
    .then(response => response.json())
    .then(data => dispatch(receiveData(data)));
};

// Thunk 액션 디스패치
store.dispatch(fetchData());

dispatch에 함수를 실행하여 넘겨준다.
그러면 함수를 넘겨받은 미들웨어가 쿵짝쿵짝 처리하여 결과를 반환함.
이때 액션이 함수라면 한번 더 실행시키고, 아니라면 dispatch에 액션을 던져서 반환한다.

나만의 비동기 처리 미들웨어

//thunk.js / currying기법 활용해서 고차함수를 만든다.
const thunk =({ dispatch, getState }) => (next) => (action) => {
    //AsyncFunction태그도 검사해야해서 includes사용.
    if (getTag(action).includes("Function")) {
      return action(dispatch, getState);
    }
    return next(action);
  };

export default thunk;
//createStore.js
//action이 함수라면 middleware로 넘겨준다.
  const dispatch = (action) => {
    if (getTag(action).includes("Function")) {
      return middleware({ dispatch, getState })(dispatch)(action);
    }
    const nextState = reducer(getDeepCopy(state[reducer.name]), action);
    state[reducer.name] = getDeepCopy(nextState);
    observable.notify();
  };

//thunk액션 생성자 함수
export const fetchDocumentsAsync = () => async (dispatch) => {
  try {
    const documents = await request("/documents");
    dispatch({ type: FETCH_DOCUMENTS, payload: documents });
  } catch (e) {
    console.log(e);
  }
};

//Nav코드의 일부
store.dispatch(fetchDocumentsAsync()).then((res) => console.log(res));


잘 작동한다!

여기까지는 완료다. currying개념을 이해하는데 시간이 좀 소요됐다.
그 다음은 리덕스의 상태를 구독해야한다. 이때 각 컴포넌트마다 필요한 상태가 다름.

useSelector

이전 클래스형 컴포넌트에서는 connect라는 기능을 사용했었다.
지금까지 이전 기능을 많이 만들었으니 비교적 최신(?)기술인 useSelector를 넣어보겠따.

 const yourReduxState = useSelector((state) => state.yourReduxState);
  1. 함수를 받아와 정해진 상태를 리턴해준다.
    1-1. 이때 정해진 상태를 구독한다.
  2. 상태가 바뀌면, 리-렌더가 일어난다. render메서드를 넘겨주면 될것 같다.
let observable = null;
const useSelector = (func, callback) => {
    const selectedState = func(state);
    console.log(selectedState);
    observable = Object.freeze(new Observable(selectedState));
    observable.subscribe(callback);
    return selectedState;
};

밖에서 선언했던 observablenull로 만들고 다시 할당하는 방식으로 바꾸었다.
이렇게 만든 useSelector를 Nav컴포넌트 렌더 내부에서 사용한다.
밖에서 사용할 시 바뀐 상태를 가져올 수 없기 때문이다.
참고로 함수 컴포넌트는 함수 자체를 새로 실행하기에 상관없다.

this.render = () => {
    const data = store.useSelector(
      (state) => state.documentsReducer,
      this.render
    );
    $nav.innerHTML = "";
    new DocumentListHeader({ $target: $nav });
    documentList = new DocumentList({
      $target: $nav,
      initialState: data.documents,
      createDocument,
      removeDocument,
    });
  ....

이제 Nav컴포넌트를 Component클래스를 상속받아 사용해보자


역시나 트러블 슈팅

1) this 바인딩(화살표함수, this)

useSelector로 this.render를 보낼때 문제가 생겼다.
this.render를 useSelector에 콜백으로 전달해서, this바인딩 문제가 생김.
간단하게 화살표 함수로 구현하려 했는데...this.render()가 실행은 되는데 렌더링되지 않았다.
콜백이 Observable클래스로 넘어가니 thisundefined가 바인딩 되었음.

결국 this.render.bind(this) 이렇게 강제로 바인딩하니 됨.

render() {
    const data = store.useSelector(
      (state) => state.documentsReducer,
      this.render.bind(this)
    );

아마 useSelector에서 함수째로 전달하는 과정에서 문제가 생긴게 아닐까 싶다.
한번 고쳐보겠음

 const useSelector = (func, callback) => {
    const selectedState = func(state);
    observable = Object.freeze(new Observable(selectedState));
    observable.subscribe(()=>callback);
    return selectedState;
  };

그래도 안되네...일단 넘어가자.
분명 알기로 화살표 함수의 this는 선언 시점의 상위스코프렸다...

2) 프롭스 전달

프롭스를 전달할때 문제가생김. 기본이 되는 Component클래스에선 render를 이미 인스턴스 생성시점에 실행해버림. 따라서 프롭스로 데이터를 전달하게되면, 렌더 된 이후에 프롭스가 전달됨.
=> 첫 렌더 시점시 프롭스들이 undefined나옴

따라서 기본Component클래스를 수정할 필요가 있다.

export default class Component {
  state;
  props;
  constructor({ $target, tagName, props }) {
    this.$target = $target;
    this.wrapper = tagName ? document.createElement(tagName) : null;
    this.props = props;
    this.state = props?.initialState;
    this.wrapper && this.$target.appendChild(this.wrapper);
    this.setEvent();
    this.render();
  }

이렇게 수정완료! 점점 길어지는건 기분탓인데...

3) 라우팅시 화면 지우기 (미해결. 6번에서 일단 해결)

코드 수정중 라우팅시 화면을 지우지 않게되어 에디터 페이지가 2개씩 보이는 현상 발생

라우팅에서 처리해주는 것이 나을것 같음. 하지만 어떻게 처리할까?
생각해본 방법은

  1. fragment에 라우트가 필요한 컴포넌트들을 붙인다.
  2. 주소가 바뀌면, fragment내부를 싹 비움.

이렇게 해도 Editor내부 구조가 좀 복잡해서 2개가 로딩됐다.
일단 다른 페이지가 생겼을때 처리가 가능하니, 처리한건 남겨두고 다음 파트로 고고싱

4) render 이전에 일어나야 하는 일들

생각보다 render시점 이전에 일어나야하는 태스크들이 많다.
따라서 이전에 준비할 수 있게 Component클래스에 추가해주었다.

  constructor({ $target, tagName, props }) {
    this.$target = $target;
    this.wrapper = tagName ? document.createElement(tagName) : null;
    this.props = props;
    this.state = props?.initialState;
    this.wrapper && this.$target.appendChild(this.wrapper);
    this.setEvent();
    this.prepare();
    this.render();
  }

점점 뭐가 많아지는건 기분탓이다..

5) 이벤트가 문서 갯수만큼 늘어나는 현상

문서가 4개있고, 맨 마지막 문서를 클릭했을 때

맨 위의 문서를 클릭했을때

왜 이런결과가 발생할까?
바로 이벤트 버블링때문!

  setEvent() {
    this.addEvent("click", ".document-item-inner", (e) => {
      if (e.target.tagName === "A") {
        e.preventDefault();
      }
      if (!e.target.closest("button")) {
        e.stopPropagation();
        console.log("hi");
        push(
          `/documents/${this.wrapper.dataset.id}`,
          this.props.highlightSelectedDocument
        );
      }
    });
  }

이렇게 e.stopPropagation()으로 버블링 전파를 막아주면 해결된다.
버블링은 참고로 자식 => 부모순으로 이벤트가 전파되는걸 의미함.
따라서 재귀적 렌더링된 컴포넌트들은 모두 클릭 이벤트 감지기능이 있어서, 클릭 이벤트핸들러가 동작했다.

해결!

6) 라우트 변경시 화면깜빡임

이것도 역시 해결했던 일. Component클래스를 상속받아 사용하느라 일관성을 지키려고 appendChild를 사용했다. 덕분에 부모노드의 innerHTML를 비워줘야하는 사태가 발생했고...(중략)

한두시간 헤메다 그냥 replaceChildren을 사용하기로 합의.
시간이 너무 낭비됐다..

7) input value를 상태에 의존하면 값 바뀌는 이벤트마다 re-render

이건 이전에도 해결해봤다. input value를 상태와 분리해서 관리하는 거다.
일단 DocumentPage부터 클래스로 리팩토링 해보자..

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

0개의 댓글