사용자에게 짧은 정보를 전달하기 위한 창이다. 웹에만 있던 개념은 아니다.
이처럼 윈도우에서도 볼 수 있다. FE진영에선 react-toastify라는 라이브러리가 유명하다.
사진 출처 : https://www.npmjs.com/package/react-toastify
이러한 Toast를 한 번 구현해 볼 것이다. 그전에 짤막한 사전지식이 필요하다.
observer라고 불리우는 특별한 객체들을 다른 객체인 Observable에 subscribe(구독)시킬 수 있다.
이때 observable 객체들은 자신에게 구독된 모든 observer객체들에게 notify메서드를 이용하여 특정한 동작을 행할 수 있다.
보통 주요 메서드와 객체는 다음과 같다.
observers
: observable
객체들에게 알림을 받고싶은 객체들이다. subscribe()
: observable
객체를 구독하고싶습니다. 유튜브를 열심히 뒤져보는 observer
객체들이 심심하면 구독을 누른다.unsubscribe()
: observable
객체를 구독 취소한다. 아마 사고를 쳤나보다.notify()
: 구독, 알림 좋아요!중 알림에 해당한다. 구독자들에게 알림을 보내준다.짤막하게 구현하면 다음과 같을 것이다.
const listeners = [...]; //수많은 옵저버 객체들
const notify = (data) => listeners.forEach((observer) => observer(data));
//이번에 사용할 observer들은 보통 함수다. 사실 함수도 객체니까 괜찮다.
예시로 보기 좋은 영상 링크를 첨부해놓겠음!
출처 : https://www.patterns.dev/vanilla/observer-pattern/
간단하게는 아래처럼 사용한다.
//사용하는 컴포넌트
const notify = () => toast('토스트에 보낼 메시지');
<button onClick={notify}>토스트용 버튼</button>
//App.tsx
//이렇게 꼭 ToastContainer를 어딘가에 렌더링 해주어야한다. 위치는 상관 없을 것 같기도하고?
<>
<App/>
<ToastContainer/>
</>
toast
라는 함수에 문자열을 전달한 뒤, notify()
로 호출하면 토스트 메시지가 발생한다.
어떻게 이런 원리가 가능한 것일까? 추측을 기반으로 사고과정을 나열해보겠다.
처음에는 Node의 EventEmitter
이나, 커스텀 이벤트를 생성하는 CustomEvent
생성자를 이용하는 것 같았다.
왜냐하면
1. toast()
함수에서 반환된 함수가 특정 이벤트를 발생시킨다.
2. 특정 이벤트에 반응하여, ToastContainer
를 화면에 표시한다.
3. 끝! 간단하다.
그래서 처음에 제작하게 된게, 커스텀 이벤트 기반 토스트 방식이었다.
//toast.tsx
const toast = (message) => {
const event = new CustomEvent('showToast', { detail: message });
window.dispatchEvent(event);
};
// ToastComponent.tsx
import React, { useEffect, useState } from 'react';
const ToastContainer = () => {
const [toasts, setToasts] = useState([]);
useEffect(() => {
const handler = (e) => {
const message = e.detail;
setToasts((prev) => [...prev, message]);
// 자동으로 3초 후 토스트 삭제
setTimeout(() => {
setToasts((prev) => prev.slice(1));
}, 3000);
};
window.addEventListener('showToast', handler);
return () => {
window.removeEventListener('showToast', handler);
};
}, []);
return (
<div>
{toasts.map((toast, index) => (
<div key={index} style={...}>
{toast}
</div>
))}
</div>
);
};
export default ToastComponent;
// App.jsx
const notify = () => toast('커스텀이벤트 토스트')
const App = () => {
return (
<div>
<button onClick={notify}>Show Toast</button>
<ToastComponent />
</div>
);
};
export default App;
이미 존재하는 CustomEvent
생성자를 이용하니, 생각보다 간단했다. 다만 렌더링 하는 부분에서 애를 먹었다. 안보였다가 어떻게 보여줄까 심히 고민했는데...그냥 ToastContainer
의 첫번째 div
만 보여주고, 그 아래 자식들은 상태에 map
을 돌려 상태기반으로 렌더링해주면 되었다.
사실 커스텀 이벤트 방식을 사용하면 훨씬 쉽지만, 쉬운 길도 돌아가고 싶을 때가 있다. 시간이 넘쳐 흐르는 건 아닌데 예전에 한 번 Observer pattern을 적용하고나서 확실히 이해하지 못한 듯 하여 이번 기회에 다시 제작해보았다.
이전에 고양이 사진첩 과제당시 Observer pattern을 이용할땐, 클래스를 사용했다.
거의 모든 예시가 클래스기도 하였고, 객체지향에서 자주 사용되던 패턴이라 더욱 그랬을 것 같다.
이번에는 함수만을 이용해 Observer pattern을 구현하였다.
interface NotifyProps{
eventType:ToastEventType;
message:string;
duration?:number
}
type Observer = ({eventType,message,duration}:NotifyProps) => void;
const createObserver = () => {
const listeners = new Set<Observer>();
const observe = (_notify:Observer) => {
listeners.add(_notify);
return () => listeners.delete(_notify);
}
const notify = ({eventType, message, duration}:NotifyProps) => {
listeners.forEach((observer) => observer({eventType,message,duration}));
}
return {observe,notify}
}
export const observerInstance = createObserver();
export type ToastEventType = 'default'| 'warning' | 'error' | 'success';
const showToast = (eventType:ToastEventType, message:string, duration = 2500) => {
observerInstance.notify({eventType,message,duration})
}
const toast = (message:string,duration?:number) => {
const notify = () => showToast('default', message, duration);
const warning = () => showToast('warning', message, duration);
const error = () => showToast('error', message, duration);
const success = () => showToast('success', message, duration);
return {notify,warning,error,success}
}
export default toast;
toast
함수 부분은 리팩토링이 필요해 보인다. 굳이 함수가 함수를 리턴하지 않아도 되는데...😥
새롭게 배운건 observe
의 리턴 부분이다.
react-toastify의 github를 둘러보다가 발견한 코드였는데, 왜 이렇게 했을까 하고 고민해본 결과 조금있다 설명할 ToastContainer
에서 unobserve
즉, 구독 해제 효과를 한다.
"use client";
import { ReactNode, useEffect, useRef, useState } from "react";
import { ToastEventType, observerInstance } from "./createObserver";
import Portal from "../Portal/Portal";
import CloseIcon from "./Icon/CloseIcon";
import ToastDefaultIcon from "./Icon/ToastDefaultIcon";
import ToastErrorIcon from "./Icon/ToastErrorIcon";
import ToastSuccessIcon from "./Icon/ToastSuccessIcon";
import ToastWarningIcon from "./Icon/ToastWarningIcon";
interface Toast {
id: number;
message: string;
duration?: number;
eventType: ToastEventType;
}
const ToastContainer = () => {
const [toasts, setToasts] = useState<Toast[]>([]);
useEffect(() => {
const handleAddToast = ({
eventType,
message,
duration,
}: {
eventType: ToastEventType;
message: string;
duration?: number;
}) => {
const id = new Date().getTime();
setToasts((prev) => [...prev, { id, message, duration, eventType }]);
setTimeout(() => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, duration);
};
const unsubscribe = observerInstance.observe(handleAddToast);
return () => {
unsubscribe();
};
}, []);
return (
<Portal>
<div className="fixed top-0 right-0 z-[1000] m-4 flex flex-col gap-2">
{toasts.map((toast) => {
//...대충 렌더링 로직
}}
<CloseIcon />
</button>
</div>
);
})}
</div>
</Portal>
);
};
export default ToastContainer;
커스텀 이벤트로 제작했을 때보다 flow가 조금 더 복잡해졌다. 특히 observe
로 넘어간 handler
가 notify
할때 eventType,message,duration
을 주입받는 부분이 조금 복잡하여 뚝딱 처리하지 못했다.
그래도 잘 작동하니 기분은 좋다!
아이콘과 스타일 출처 : https://flowbite.com/docs/components/toast/
생각보다 언어 자체의 기능이 좋은것 같다. 만약 다음에 Observer pattern을 JS로 사용해야 될 것 같으면, 그냥 CustomEvent
생성자를 이용하는게 훨씬 좋아보인다. 극명한 차이점은 보이지 않는데, 어딘가 놓친게 있는걸까?
그래도 바닥부터 구현하면, 구현 숙련도가 차츰 오르는게 느껴져서 기분이가 좋다🙄
이상한부분은 리팩토링하고, Props로 다양한 위치에서 나올수 있게 하기 + 애니메이션 추가까지 하면 그럴듯하게 완성 될 것 같다!