본문 바로가기

[리액트] Toast 알람 구현 (1)

현재 챗봇 빌더에 구현되어있는 Toast 메세지는 직접 구현한 부분이다.

 

다만 단점은 여느 라이브러리처럼 여러 개가 뜨는 형식이 아닌 하나만 뜰 수 있는 구조로 구현된 상태였다.

 

그리고 컴포넌트와 기능 부분을 나눈다고 뷰와 컨트롤러 부분이 각각 /components 와 /helpers에 구현했는데 따로 불러와서 쓰는게 어색했다.

 

따라서 이번에 Toast 알람을 개선하는 것을 Task로 잡고 시도했었다.

 

마침 최근에 본 기술 인터뷰 때 받은 테스트에서 유사한 요구사항을 가지고 있어 이것을 집에서 다시 풀면서 어느정도 아이디어를 얻을 수 있었다.

(정작 테스트볼 때는 이렇게 못풀었어서 아쉽다...ㅠ 함수형 프로그래밍에 익숙해져 있던 탓인지 OOP로 풀 생각을 못함..U_U)

 

[요구 사항]

 

1. 여러 개의 Toast 메세지가 뜰 수 있도록 구현

2. 메세지를 클릭하면 클릭한 메세지 제거

3. 한 파일로 뷰와 컨트롤러 해결하기

 

[구현 결과]

이미지를 클릭하면 gif가 실행됩니다.

 

[설계]

설계에 따라 필요한 객체와 메소드를 정의하였다.

 

1. 메세지 객체

- 각 메세지 객체에 type, content, delay를 받아서 setInterval 타이머 등록

- state로 시작/마침 구분

- 타이머 등록과 동시에 컨테이너에 추가

 

2. 컨테이너

- 해당 메세지 객체로 ToastBox 랜더링

- delay가 초과된 컨테이너 내 ToastBox 제거 

 

[코드]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import classnames from 'classnames';
import * as uuidv4 from 'uuid/v4';
import produce from 'immer';
 
// Toast 컴포넌트
const ToastItem = ({ _id, type, content, _timer, deleteToast }) => {
  const setBgColor = _ => {
    switch (type) {
      case "success":
        return "bg-alert-success"
      case "error":
        return "bg-alert-error"
      case "warn":
        return "bg-alert-warn"
      case "info":
        return "bg-alert-info"
      default:
        return "bg-alert-default"
    }
  }
 
  const bgColor = setBgColor();
 
  const removeToast = _ => {
    _timer && clearInterval(_timer);
    deleteToast(_id);
  }
 
  return createPortal(
    <div className={classnames("div-default-alert", bgColor, "show")} onClick={ removeToast }>
      { content }
    </div>,
    document.getElementById('toast-container'),
  );
}
 
// Toast 컨테이너 컴포넌트
const ToastBox = ({ list, deleteToast }) => (
  <div id="toast-container">
  {
    list.map(t => {
      const { type, content, _id, _timer } = t;
      const state = t.getState();
      return state === "SHOW_TOAST"
      ? <ToastItem key={_id} _id={_id} type={type} content={content} _timer={ _timer } deleteToast={deleteToast} />
      : deleteToast(_id)
    })
  }
  </div>
)
 
// Custom Hook
const useToast = _ => {
  const[toastList, setList] = useState([]); // Toast Instance List
  const[trigger, setTrigger] = useState(0); // ToastBox Rendering Trigger
 
  // Toast constructor 함수를 반환
  let Toast = (function () {
    let state = 'SHOW_TOAST';
 
    function hide () {
      state = 'HIDE_TOAST'
    }
  
    function T (type, content, delay) {
      this.type = type;
      this.content = content;
      this.delay = delay;
      this._timer = null;
      this._id = uuidv4();
    }
 
    function show () {
      if (this.delay <= 0) {
        this._timer && clearInterval(this._timer);
        hide();
        setTrigger(prev => !prev);
      } else {
        this.delay -= 1000;
      }
    }
  
    T.prototype = {
      getState: _ => state,
      start: function () {
        const _timer = setInterval(show.bind(this), 1000);
        this._timer = _timer;
 
        setList(list => [...list, this]);
      }
    }
  
    return T;
  })();
 
  // delay가 초과된 ToastItem 제거
  const deleteToast = _id => {
    const nextState = produce(toastList, draft => (go(
        draft,
        d => d.findIndex(t => t._id === _id),
        idx => draft.splice(idx, 1)
      ), draft)
    )
    setList(nextState);
  }
 
  // Toast 생성 콜백 함수
  const toast = (type, content, delay=2000=> {
    let newToast = new Toast(type, content, delay);
    newToast.start();
  }
 
  // ToastBox 컴포넌트 반환하는 함수
  const ToastContainer = _ => <ToastBox list={toastList} trigger={ trigger } deleteToast={ deleteToast } />
  
  return [toast, ToastContainer];
}
 
export default useToast;

 

설계에 따라 Toast 생성자 함수를 반환하는 함수를 만들었는데, 그 이유는 state 식별자와 show, hide function을 private하게 쓰고 싶어서 다음과 같이 구현하게 되었다.

 

오로지 getState() 메소드를 통해서만 state를 전달 받을 수 있도록 하고 내부에서 조작하게끔 구현하였다.

 

show, hide 함수 또한 start 메소드내에서만 사용할수 있도록 하였다. 

 

여기서 show 함수는 어쨌든 함수이기 때문에 this가 전역 객체로 잡힐 것이기 때문에 this를 인스턴스로 바인딩해주어야 한다.

 

그외의 type, content, delay, _timer, _id는 해당 인스턴스에서 호출할 수 있도록 하는게 더 간편할 것 같았다.

 

start 메소드에서는 바깥의 toastList에 타이머를 추가할 수 있도록 하였다.

 

만약 이게 Native Javascript였다면 state가 아니라 컨테이너 객체를 따로 생성해서 함수로 추가했을 듯 하다.

 

찝찝했던 건 delay가 초과되고 타이머가 제거되는 시점에서 ToastBox 컴포넌트를 업데이트 하려고 trigger state를 사용해서 일부러 업데이트를 일으켰는데 저거말고 더 나은 방법이 있을거 같았다.

 

위처럼 설계하고 나니 실제로 Toast 메세지를 생성하는 콜백함수에서는 인스턴스를 생성 후, 해당 인스턴스의 start 메소드만 호출하면 끝!

 

git에서 보기

 

[활용]

 

Custom Hook에서 리턴한 ToastContainer로 ToastBox 컴포넌트를 불러오고, toast 함수를 콜백함수로 사용하고 싶은 부분에 이벤트를 등록해주면 된다.

 

나는 이것을 모든 컴포넌트의 Wrapper인 App.js에서 불러와서 props로 자식 컴포넌트들에게 내려주는 방식을 택했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import useToast from "./helper/useToast";
 
const  App = props => {
  const [toast, ToastContainer] = useToast();
 
  const parentProps = {
    ...props,
    "redirect": redirect,
    "status": status,
    "inactive": inactive,
    "toast": toast
  };
 
  return (
    <>
      <Router>
        <Suspense fallback={Loader}>
          <Switch>
            <LoginRoute
              path="/login"
              name="Login Page"
              parentProps={parentProps}
              component={Login}
            />
            {
             //...
            }
          </Switch>
        </Suspense>
      </Router>
      { ToastContainer() }
    </>
  );
};
 

git에서 보기

 

[개선점]

실제로 오픈소스를 뜯어보니까 이거보다 훨씬 더 복잡하고 정교하게 구성되어 있었다 @_@

 

역시 오픈소스는 다르구나... ㅠㅠ

 

내가 원하는 기능은 그저 Toast 메세지 띄우고, 제거하고 하는 게 전부니까 설계 부분에서 아이디어를 얻을 수 있는 부분만 도입해봐야겠다!

 

 

1. ToastContainer를 함수로 쓰는게 거슬린다!

<ToastContainer />로 쓰고 싶다 ㅠㅠ

 

2. setInterval()이 성능에 영향을 줄 거 같다.

따라서 시각적인 변화는 requestAnimationFrame의 콜백함수에서 해결하고 싶은데 가능할까?

 

3. trigger state말고 ToastBox 업데이트를 할 수 있는 방법을 찾아보자.

 

4. react-toastify 분석해서 설계안 개선하기

- eventManager모듈 객체 분리

- toast.js에서 메세지 객체생성, eventManager에서 emit, ToastContainer에서 emit 이벤트 발생 시, 해당 메세지 객체를 state에 추가 후 랜더링