Pull to refresh

Сервис уведомлений в NextJS или ReactJS-приложении с помощью RxJS

Reading time19 min
Views7.6K

В данной заметке я бы хотел поделиться опытом реализации простого, но достаточно функционального сервиса уведомлений, который можно легко реализовать в своем React (или NextJS, как в моем случае) приложении. Приложение будет написано на TypeScript, базисом для него послужит фреймворк NextJS 11-й версии (версию 12, каюсь, пока не изучал и в бою не испытывал). Для связи между страницей и алертами будет использоваться библиотека RxJS.

Вдохновением для написания данной статьи послужила, во-первых, производственная необходимость (а все мы знаем, как данный зверь умеет придать вдохновения), а во-вторых - прошлогодняя статья Jason Watmore. Однако переводом данная заметка не является, поскольку итоговый результат сильно отличается от результата Джейсона. Кроме того, наш инструмент будет написан на TypeScript.

Почему RxJS?

Да, все знают, что одна лишняя зависимость проекта - минус 10 очков кармы для его архитектора. Но, с моей точки зрения, использование RxJS для данной задачи весьма оправдано, ибо позволяет написать решение простое, читабельное, масштабируемое и приятное глазу. А если RxJS потом заиспользовать еще для чего-нибудь - хотя бы для того же фетчинга API, то будет двойной профит. Поэтому...

Постановка задачи

Необходимо реализовать инструмент, с помощью которого можно отображать на странице всплывающие уведомления нескольких разных типов (ошибка, успех, предупреждение, нейтральная информация), при этом:

  • Уведомления не должны занимать на странице место, портя верстку;

  • Уведомления должны уметь автоматически закрываться через определенный интервал времени;

Самое важное: сервисом должно быть удобно пользоваться! Никакого копипаста, никакого бойлерплейт-кода. Так, чтобы даже стажер, вчера пришедший в компанию, умел выводить уведомления на странице.

Начнем с создания базовой структуры проекта (структура проекта - шаблонная, реализованная примерно по шагам, описанным в моей прошлой статье)

Итоговый результат можно взять в репозитории. Ветки соответствуют подразделам статьи.

Поиграться с демо можно тут (если хабраэффект не убьет)

Базовая структура проекта

Стартовую структуру проекта можно подглядеть в ветке

Несколько моментов:

  • Проект настроен на достаточно высокий уровень строгости. Люблю, когда линтер не дает мне спуску в вопросах вроде забытых точек с запятой, неодинаковых кавычек и обязательных экспортов. Если такое поведение линтера и компилятора для вас слишком жестокое - подкрутить их можно в файлах .eslintrc и tsconfig.json

  • В проекте настроена работа с SVG, хотя самих SVG в проекте не используется. Это просто привычка - настроить определенный скоуп шаблонных нюансов "на вырост"

  • Я не являюсь специалистом в чистой верстке, и знанию различных фич верстки я предпочитаю использование флекс-боксов. Или гридов. Или еще чего-то такого же, простого и рабочего. Для того же выравнивания блока по центру экрана, к примеру. Прошу понять и простить.

Подготовка страницы

Итоговое состояние проекта после данного шага - в ветке

Первым делом нам необходимо создать экспортируемые типы и интерфейсы, которые будут повсеместно использоваться далее в коде. Первый - тип TColors, который будет у нас одновременно указывать на цвет кнопки и на тип алерта. Всего цветов-типов четыре - successerrorwarning и info.

Создадим файл src/types/colors.ts

export type TColors = 'success'|'error'|'warning'|'info';

Вторым библиотечным объектом будет интерфейс алерта.

Создадим файл src/types/alert.ts

import { TColors } from './colors';

export interface IAlert {
  id: number,
  status: TColors,
  message: string,
  timeout: number,
}

Данный интерфейс содержит всю информацию, необходимую для отображения алерта:

  • id: уникальный идентификатор алерта. Его необходимость будет проиллюстрирована далее

  • status: тип алерта. Фактически - его цвет, поскольку никаких иных функциональных различий между алертами разных типов нет

  • message: текст уведомления

  • timeout: таймаут закрытия алерта. Если передано значение 0, алерт самостоятельно закрываться не будет. В ином случае число в данном поле указывает на количество секунд до автозакрытия

Далее нам необходимо указать все SCSS-переменные с цветами, которые мы будем использовать в дальнейшем.

Изменим файл src/styles/variables.scss, приведя его к следующему состоянию:

$danger: #FD726A;
$danger-light: #FFDEDD;

$success: #44CB7F;
$success-light: #D4FFE7;

$warning: #F39C12;
$warning-light: #FDEBD0;

$info: #3498DB;
$info-light: #D6EAF8;

$white: #FFF;

Для того, чтобы в дальнейшем было удобно импортировать в коде различные объекты, немного изменим секцию compilerOptions/paths в файле конфигурации компилятора TypeScript - tsconfig.json:

  ...
  "paths": {
    "pages/*": ["./src/pages/*"],
    "components/*": ["./src/components/*"],
    "layout": ["./src/layout/index.tsx"],
    "types/*": ["./src/types/*"],
    "services/*": ["./src/services/*"],
  }
  ...

Директории layout и services мы будем использовать чуть дальше.

Теперь нам необходимо создать большую часть компонентов, которые будут у нас использоваться в приложении. В частности, это кнопки (Button), алерт (Alert), контейнер для отображения алертов (AlertingService), контейнер для отображения содержимого страницы по центру самой страницы (CenteredVerticalForm). Последний компонент необязателен, какой-либо функциональной нагрузки не несет, и служит чисто декоративным элементом

Button

Начнем с Button. Начнем создавать в src/components/Button файлы компонента:

button.props.ts - интерфейс пропсов (свойств компонента). Нам необходимо использовать некоторые пропсы базового <button>, поэтому унаследуем пропсы нашего компонента от DetailedHTMLProps

import { ButtonHTMLAttributes, DetailedHTMLProps, ReactNode } from 'react';
import { TColors } from 'types/colors';

export interface ButtonProps extends
  DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> {
  children: ReactNode;
  color: TColors;
}

button.module.scss - SCSS-модуль компонента. Все стили, которые будут использовать кнопки:

@import '../../styles/variables.scss';

.default {
  width: 250px;
  height: 40px;

  letter-spacing: 1px;

  font-size: 16px;

  font-weight: 500;

  &.success {
    color: $success-light;
    background-color: $success;

    &:active {
      color: $success;
      background-color: $success-light;
    }
  }

  &.error {
    color: $danger-light;
    background-color: $danger;

    &:active {
      color: $danger;
      background-color: $danger-light;
    }
  }

  &.warning {
    color: $warning-light;
    background-color: $warning;

    &:active {
      color: $warning;
      background-color: $warning-light;
    }
  }

  &.info {
    color: $info-light;
    background-color: $info;

    &:active {
      color: $info;
      background-color: $info-light;
    }
  }
}

button.tsx - собственно, сам компонент. Мы собираем имена классов с помощью classnames, и пробрасываем в базовый <button> событие onClick:

import cl from 'classnames';

import { ButtonProps } from './button.props';
import styles from './button.module.scss';
export const Button = (props: ButtonProps): JSX.Element => {
  return (
    <button
      className={cl(
        styles.default,
        styles[props.color],
      )}
      onClick={props.onClick}
    >
      {props.children}
    </button>
  );
};

index.ts:

export * from './button';

CenteredVerticalForm

Далее создадим компонент CenteredVerticalForm, единственной задачей которого будет "сделать красиво". Создадим в src/components/CenteredVerticalForm необходимые файлы.

centered_vertical_form.props.ts - пропсы. Заметьте, в отличие от Button мы самостоятельно создаем children, поскольку данные пропсы мы ни от чего не наследуем. Можно унаследовать от базовых интерфейсов React, но объем кода не изменится, так что смысла особого в этом нет, потому как ничего кроме children мы все равно использовать не будем:

import { ReactNode } from 'react';

export interface CenteredVerticalFormProps {
  children: ReactNode,
}

centered_vertical_form.module.scss - SCSS-модуль

@import '../../styles/variables.scss';

.container {
  display: flex;
  align-items: center;
  justify-content: center;

  width: 100%;
  height: 100vh;
}

.form {
  display: grid;

  width: 350px;
  grid-template-rows: 1fr;
  gap: 20px;
  justify-items: center;
}

centered_vertical_form.tsx - сам компонент

import { CenteredVerticalFormProps } from './centered_vertical_form.props';
import styles from './centered_vertical_form.module.scss';

export const CenteredVerticalForm = (props: CenteredVerticalFormProps): JSX.Element => {
  return (
    <div className={styles.container}>
      <div className={styles.form}>
        {props.children}
      </div>
    </div>
  );
};

index.ts - экспорт во внешний мир

export * from './centered_vertical_form';

Alert

Alert - самый главный компонент текущей системы. Виновник торжества, так сказать. Создадим файлы компонента в директории src/components/Alert.

alert.props.ts - пропсы компонента. Здесь нам необходимо пронаследовать интерфейс пропсов от интерфейса IAlert, потому что все поля IAlert должны стать пропсами компонента Alert. Почему в качестве пропсов сразу не использовать IAlert? Ну а вдруг нам в будущем понадобится передавать в объект Alert еще что-то? Колбэк для onClick, например, или внешний className

import { IAlert } from 'types/alert';

export interface AlertProps extends IAlert {}

alert.module.scss - SCSS-модуль. Здесь у нас так же созданы все классы, необходимые для раскрашивания алертов в цвета, соответствующие типам:

@import '../../styles/variables.scss';

.default {
  display: flex;
  align-items: center;
  justify-content: space-between;

  width: 100%;
  min-height: 40px;
  padding: 8px;

  button {
    background-color: transparent;

    font-size: 12px;
    font-weight: 700;
  }

  &.success {
    color: $success-light;
    background-color: $success;

    button {
      color: $success-light;
    }
  }

  &.error {
    color: $danger-light;
    background-color: $danger;

    button {
      color: $danger-light;
    }
  }

  &.warning {
    color: $warning-light;
    background-color: $warning;

    button {
      color: $warning-light;
    }
  }

  &.info {
    color: $info-light;
    background-color: $info;

    button {
      color: $info-light;
    }
  }
}

alert.tsx - файл компонента. В нем мы, также как и в Button, конструируем с помощью classnames классы компонента из базового и класса цвета. Кроме того, мы создаем отдельную кнопку для закрытия алерта, оживлять которую будем чуть позже:

import cl from 'classnames';

import { AlertProps } from './alert.props';
import styles from './alert.module.scss';

export const Alert = (props: AlertProps): JSX.Element => {
  return (
    <div className={cl(
      styles.default,
      styles[props.status],
    )}>
      {props.message}
      <button>X</button>
    </div>
  );
};

В index.ts добавляем экспорт компонента наружу:

export * from './alert';

AlertingService

Ну и, собственно, компонент сервиса алертинга. Технически он представляет собой контейнер, размещаемый где-то на странице, в который помещаются все активные в данный момент алерты. Дополнительной фичей можно сделать пропсы, которые позволят менять местоположение контейнера:

alerting_service.props.ts - пропсы компонента

export interface AlertingServiceProps {
  vertical: 'top'|'bottom';
  horizontal: 'left'|'right';
}

alerting_service.module.scss - SCSS-модуль:

@import '../../styles/variables.scss';

.default {
  position: absolute;

  display: flex;
  align-items: flex-start;
  flex-direction: column;
  justify-content: flex-start;

  width: 350px;

  &.left {
    left: 16px;
  }

  &.right {
    right: 16px;
  }

  &.top {
    top: 16px;
  }

  &.bottom {
    bottom: 16px;
  }

  div {
    margin-bottom: 8px;
  }
}

alerting_service.tsx - сам компонент. Здесь уже происходит несколько интересных вещей. В частности, мы создаем массив алертов alerts (который чуть позже заменим на стейт), и добавляем в него один тестовый элемент, чтобы протестировать верстку и расположение компонента. Кроме того, с помощью метода map мы рендерим компоненты Alert, чтобы отобразить их на странице. В качестве пропса key пока воспользуемся индексом элемента в map, чуть позже поменяем на id алерта.

import cl from 'classnames';

import { IAlert } from 'types/alert';

import { Alert } from 'components/Alert';

import { AlertingServiceProps } from './alerting_service.props';
import styles from './alerting_service.module.scss';

export const AlertingService = (props: AlertingServiceProps): JSX.Element => {
  const alerts: IAlert[] = [
    {
      id: 0,
      message: 'Success message',
      status: 'success',
      timeout: 5,
    },
  ];
  
  const alertsContent = alerts.map((alert, idx) => {
    return <Alert 
      key={idx}
      {...alert}
    />;
  });

  return (
    <div className={cl(
      styles.default,
      styles[props.horizontal],
      styles[props.vertical],
    )}>
      {alertsContent}
    </div>
  );
};

Ну и, как всегда, экспортируем компонент во внешний мир в index.ts.

Сборка

Соберем итоговую страницу. В файл src/pages/index.tsx добавим все созданные выше компоненты:

import { AlertingService } from 'components/AlertingService';
import { Button } from 'components/Button';
import { CenteredVerticalForm } from 'components/CenteredVerticalForm';

const Home = (): JSX.Element => {
  return (
    <CenteredVerticalForm>
      <AlertingService 
        horizontal={'right'}
        vertical={'top'}
      />
      NextJS+RxJS Alerting service
      <Button color={'success'}>Success</Button>
      <Button color={'error'}>Error</Button>
      <Button color={'warning'}>Warning</Button>
      <Button color={'info'}>Info</Button>
    </CenteredVerticalForm>
  );
};

export default Home;

Мы должны увидеть центрированный вертикальный блок разноцветных кнопок и один зеленый алерт в правом верхнем углу страницы. Алерт пока закрыть нельзя, на нажатие кнопок приложение пока тоже не реагирует. Пришло время для RxJS.

Пишем уведомления в console.log

Итоговое состояние проекта после данного шага - в ветке

Первое, что мы попытаемся сделать - это просто поймать события вызова алертов и вывести в лог. Время для настоящих уведомлений придет в следующем разделе.

Но для начала надо установить в проект библиотеку RxJS и реализовать всю логику взаимодействия компонентов с сабджектами RxJS. То есть - написать ту часть alerting_service, которая работает поверх RxJS.

Установим библиотеку:

npm i rxjs

Создадим файл src/services/alerting_service.ts. Далее нам необходимо импортировать все требуемые типы и создать объект Subject:

import { Observable, Subject } from 'rxjs';

import { IAlert } from 'types/alert';
import { TColors } from 'types/colors';

const alertsSubject = new Subject<IAlert>();

Дальше мы будем использовать alertsSubject в качестве стрима для пересылки алертов между компонентами.

Теперь создадим в этом же файле первый хелпер, который позволит генерировать событие отправки алерта:

...
const alert = (status: TColors, message: string, timeout: number): void => {
  alertsSubject.next({
    id: Math.round(window.performance.now()*10),
    status, message, timeout
  });
};
...

Вызвав в любом месте кода хелпер с необходимыми аргументами (цвет, сообщение, таймаут закрытия), мы отправляем в стрим alertsSubject объект, имеющий, кроме всего прочего, еще и id (соответствующий округленной отметке времени жизни страницы). Благодаря этому мы получаем алерты с гарантировано уникальными id, которые нам, к тому же, нигде не надо хранить.

Однако, выбирать явно цвет алерта каждый раз неудобно. Напишем пачку из четырех хелперов, позволяющих создавать каждый вид алертов:

...
const success = (message: string, timeout = 0): void => {
  alert('success', message, timeout);
};

const error = (message: string, timeout = 0): void => {
  alert('error', message, timeout);
};

const warning = (message: string, timeout = 0): void => {
  alert('warning', message, timeout);
};

const info = (message: string, timeout = 0): void => {
  alert('info', message, timeout);
};
...

Почти готово. Осталось реализовать функцию, возвращающую Observable- объект, чтобы получатели алертов могли на него подписаться:

...
const onAlert = (): Observable<IAlert> => {
  return alertsSubject
    .asObservable();
};
...
Итоговый вид файла
import { Observable, Subject } from 'rxjs';

import { IAlert } from 'types/alert';
import { TColors } from 'types/colors';

const alertsSubject = new Subject<IAlert>();

const success = (message: string, timeout = 0): void => {
  alert('success', message, timeout);
};

const error = (message: string, timeout = 0): void => {
  alert('error', message, timeout);
};

const warning = (message: string, timeout = 0): void => {
  alert('warning', message, timeout);
};

const info = (message: string, timeout = 0): void => {
  alert('info', message, timeout);
};

const alert = (status: TColors, message: string, timeout: number): void => {
  alertsSubject.next({
    id: Math.round(window.performance.now()*10),
    status, message, timeout
  });
};

const onAlert = (): Observable<IAlert> => {
  return alertsSubject
    .asObservable();
};

export {
  success,
  warning,
  error,
  info,
  onAlert,
};

Отлично. Самое время допилить получателя сообщений - компонент AlertingService.

В файл компонента добавляем необходимый код:

import { useEffect } from 'react';
...
import { onAlert } from 'services/alerting_service';
...

useEffect(() => {
  onAlert().subscribe(v => {
    console.log(v);    
  });
}, []);
...
Итоговый вид файла
import cl from 'classnames';
import { useEffect } from 'react';

import { IAlert } from 'types/alert';

import { Alert } from 'components/Alert';

import { onAlert } from 'services/alerting_service';

import { AlertingServiceProps } from './alerting_service.props';
import styles from './alerting_service.module.scss';

export const AlertingService = (props: AlertingServiceProps): JSX.Element => {
  const alerts: IAlert[] = [
    {
      id: 0,
      message: 'Success message',
      status: 'success',
      timeout: 5,
    },
  ];
  
  const alertsContent = alerts.map((alert, idx) => {
    return <Alert 
      key={idx}
      {...alert}
    />;
  });

  useEffect(() => {
    onAlert().subscribe(v => {
      console.log(v);    
    });
  }, []);

  return (
    <div className={cl(
      styles.default,
      styles[props.horizontal],
      styles[props.vertical],
    )}>
      {alertsContent}
    </div>
  );
};

Осталось подцепить к onClick кнопок на странице вызов rxjs-хелпера, приведя src/pages/index.ts к следующему виду:

import { success, error, warning, info } from 'services/alerting_service';

import { AlertingService } from 'components/AlertingService';
import { Button } from 'components/Button';
import { CenteredVerticalForm } from 'components/CenteredVerticalForm';

const Home = (): JSX.Element => {
  return (
    <CenteredVerticalForm>
      <AlertingService 
        horizontal={'right'}
        vertical={'top'}
      />
      NextJS+RxJS Alerting service
      <Button color={'success'} onClick={() => success('Success message', 3)}>Success</Button>
      <Button color={'error'} onClick={() => error('Error message', 10)}>Error</Button>
      <Button color={'warning'} onClick={() => warning('Warning message', 5)}>Warning</Button>
      <Button color={'info'} onClick={() => info('Info message')}>Info</Button>
    </CenteredVerticalForm>
  );
};

export default Home;

Можно запустить проект и попробовать понажимать на кнопки. Внешне ничего не изменилось, но в консоль страницы должны начать падать объекты IAlert.

Настоящие уведомления

Итоговое состояние проекта после данного шага - в ветке

Итак, теперь у нас уже точно все готово для того, чтобы отображать настоящие алерты.

Для начала нам необходимо убрать в компоненте AlertingService фиктивный массив алертов и создать полноценный стейт:

...
export const AlertingService = (props: AlertingServiceProps): JSX.Element => {
  const [alerts, setAlerts] = useState<IAlert[]>([]);
...

Теперь необходимо изменить эффект, в котором мы подписываемся на onAlert:

...
useEffect(() => {
  onAlert().subscribe(v => {
    setAlerts([
      ...alerts,
      v,
    ]);
  });
}, [alerts]);
...

Что мы здесь делаем: мы получаем из стрима объект IAlert, и кладем его в конец стейта. Всё, остальное доделает уже написанный ранее код.

Итоговый вид файла
import cl from 'classnames';
import { useEffect, useState } from 'react';

import { IAlert } from 'types/alert';

import { Alert } from 'components/Alert';

import { onAlert } from 'services/alerting_service';

import { AlertingServiceProps } from './alerting_service.props';
import styles from './alerting_service.module.scss';

export const AlertingService = (props: AlertingServiceProps): JSX.Element => {
  const [alerts, setAlerts] = useState<IAlert[]>([]);
  
  const alertsContent = alerts.map((alert, idx) => {
    return <Alert 
      key={idx}
      {...alert}
    />;
  });

  useEffect(() => {
    onAlert().subscribe(v => {
      setAlerts([
        ...alerts,
        v,
      ]);
    });
  }, [alerts]);

  return (
    <div className={cl(
      styles.default,
      styles[props.horizontal],
      styles[props.vertical],
    )}>
      {alertsContent}
    </div>
  );
};

Теперь нажатие на любую из кнопок на странице приведет к появлению соответствующего алерта. Они полностью функциональны, за исключением одной мелочи - они не закрываются. Пришло время их этому научить.

Создаем дополнительный стрим, учим уведомления закрываться

Итоговое состояние проекта после данного шага - в ветке

Для того, чтобы уведомления у нас закрывались по нажатию на кнопку с крестиком, нам необходимо реализовать в файле src/services/alerting_service.ts еще один стрим, хелпер и возврат Observable:

...
const closedAlertsSubject = new Subject<number>();
...
const close = (id: number): void => {
  closedAlertsSubject.next(id);
};
...
const onClosed = (): Observable<number> => {
  return closedAlertsSubject
    .asObservable();
};
...

При этом по этому новому стриму нам нет необходимости отправлять весь объект IAlert. Достаточно сообщить id алерта, который необходимо закрыть.

Итоговый вид файла
import { Observable, Subject } from 'rxjs';

import { IAlert } from 'types/alert';
import { TColors } from 'types/colors';

const alertsSubject = new Subject<IAlert>();
const closedAlertsSubject = new Subject<number>();

const success = (message: string, timeout = 0): void => {
  alert('success', message, timeout);
};

const error = (message: string, timeout = 0): void => {
  alert('error', message, timeout);
};

const warning = (message: string, timeout = 0): void => {
  alert('warning', message, timeout);
};

const info = (message: string, timeout = 0): void => {
  alert('info', message, timeout);
};

const close = (id: number): void => {
  closedAlertsSubject.next(id);
};

const alert = (status: TColors, message: string, timeout: number): void => {
  alertsSubject.next({
    id: Math.round(window.performance.now()*10),
    status, message, timeout
  });
};

const onAlert = (): Observable<IAlert> => {
  return alertsSubject
    .asObservable();
};

const onClosed = (): Observable<number> => {
  return closedAlertsSubject
    .asObservable();
};

export {
  success,
  warning,
  error,
  info,
  close,
  onClosed,
  onAlert,
};

Теперь добавляем обработчик onClick для кнопки с крестиком в компоненте Alert:

import cl from 'classnames';

import { close } from 'services/alerting_service';

import { AlertProps } from './alert.props';
import styles from './alert.module.scss';

export const Alert = (props: AlertProps): JSX.Element => {
  return (
    <div className={cl(
      styles.default,
      styles[props.status],
    )}>
      {props.message}
      <button onClick={() => close(props.id)}>X</button>
    </div>
  );
};

Осталось только научить компонент AlertingService удалять алерты, id которых он получил по новому стриму. Создадим подписку и код удаления алерта из стейта компонента:

Итоговый вид файла
import cl from 'classnames';
import { useEffect, useState } from 'react';

import { IAlert } from 'types/alert';

import { Alert } from 'components/Alert';

import { onAlert, onClosed } from 'services/alerting_service';

import { AlertingServiceProps } from './alerting_service.props';
import styles from './alerting_service.module.scss';

export const AlertingService = (props: AlertingServiceProps): JSX.Element => {
  const [alerts, setAlerts] = useState<IAlert[]>([]);
  
  const alertsContent = alerts.map((alert, idx) => {
    return <Alert 
      key={idx}
      {...alert}
    />;
  });

  useEffect(() => {
    onAlert().subscribe(v => {
      setAlerts([
        ...alerts,
        v,
      ]);
    });
    onClosed().subscribe(id => {
      setAlerts(
        alerts.filter(alert => alert.id !== id),
      );
    });
  }, [alerts]);

  return (
    <div className={cl(
      styles.default,
      styles[props.horizontal],
      styles[props.vertical],
    )}>
      {alertsContent}
    </div>
  );
};

Теперь нажатие на кнопку закрытия алерта должно его закрывать. Осталось немного - научить алерты закрываться по таймауту, исправить несколько ошибок и навести общего лоску.

Создаем обработку автоматического закрывания уведомления, устраняем ошибки

Итоговое состояние проекта после данного шага - в ветке

Первое, что надо сделать на этом этапе - это решить, кто будет закрывать алерты по таймауту. В оригинальной статье (указанной в начале данной заметки) алерты закрываются со стороны компонента-контейнера. Мне эта логика показалась не вполне обоснованной. Например, если мы хотим реализовать такую логику, как "таймаут приостанавливается, пока на алерт наведен курсор мыши" достаточно сложно было бы реализовать со стороны контейнера. Посему закрытие по таймауту я унес в компонент Alert (в конце концов, компонент алерта знает, с каким таймаутом он запущен. Почему бы ему самому не рулить этим процессом?)

Добавим в компонент Alert код, вызывающий таймаут, если значение timeout его пропсов больше нуля (в обратном случае алерт считается перманентным, и закрывается только кнопкой):

import cl from 'classnames';
import { useEffect } from 'react';

import { close } from 'services/alerting_service';

import { AlertProps } from './alert.props';
import styles from './alert.module.scss';

export const Alert = (props: AlertProps): JSX.Element => {
  useEffect(() => {
    if (props.timeout > 0){
      const timer = setTimeout(() => {
        close(props.id);
      }, props.timeout * 1_000);

      return () => {
        clearTimeout(timer);
      };
    }
    
  }, [props.id, props.timeout]);

  return (
    <div className={cl(
      styles.default,
      styles[props.status],
    )}>
      {props.message}
      <button onClick={() => close(props.id)}>X</button>
    </div>
  );
};

Обратите внимание на строку:

return () => {
  clearTimeout(timer);
};

Это очистка таймаута, вызываемая при размонтировании компонента. Про нее забывать нельзя.

Все, теперь компонент сам при создании запускает таймер, и отправляет в стрим закрытия сигнал со своим id, совершая роскомнадзор.

Осталось исправить пару неочевидных ошибок в компоненте AlertingService.

Во-первых, необходимо поменять ключ, устанавливаемый на алерты в методе map. При текущем значении, с учетом того, что алерты приходят снизу, а уходят сверху, каждый перерендер списка алертов вызывает сброс таймеров в компонентах (что логично, ибо компоненты перегружаются). Необходимо поменять key с idx на alert.id:

...
const alertsContent = alerts.map((alert) => {
  return <Alert
    key={alert.id}
    {...alert}
  />;
});
...

Во-вторых, необходимо отписываться от стримов при перерендере компонента AlertingService (который происходит каждый раз, когда меняется стейт alerts):

...
useEffect(() => {
  const onAlertSubscription$ = onAlert().subscribe(v => {
    setAlerts([
      ...alerts,
      v,
    ]);
  });
  const onClosedSubscription$ = onClosed().subscribe(id => {
    setAlerts(
      alerts.filter(alert => alert.id !== id),
    );      
  });

  return () => {
    onAlertSubscription$.unsubscribe();
    onClosedSubscription$.unsubscribe();
  };
}, [alerts]);
...

В противном случае это будет создавать неприятные фантомные сайд-эффекты (на поиск которых я потратил некоторое количество бесценного времени).

Всё. Теперь сервис алертинга полностью функционален. Он в использовании не требует практически никаких телодвижений (взгляните на код страницы index.tsx), никаких дополнительных стейтов и коллбеков для него создавать не нужно. Управление через несколько хелперов, и один компонент, который нужно обязательно разместить где-то на странице. Последнее мы как раз можем легко исправить, сделав компонент еще более удобным.

Прячем AlertingService в общий layout

Итоговое состояние проекта после данного шага - в ветке

Создадим HOC, в который мы будем оборачивать компоненты страниц (в него потом можно будет добавить и такие нужные повторяющиеся штуки, как навигацию с хедерами и футерами).

В первую очередь необходимо создать файлы компонента layout. Создадим директорию src/layout/BaseLayout с файлами:

base_layout.props.ts - пропсы компонента. В них ничего особенного, просто стандартные children

import { ReactNode } from 'react';

export interface BaseLayoutProps {
  children: ReactNode
}

base_layout.module.scss - SCSS-модуль

@import '../../styles/variables.scss';

.default {
  background-color: $white;
}

base_layout.tsx - сам компонент

import { BaseLayoutProps } from './base_layout.props';
import styles from './base_layout.module.scss';

export const BaseLayout = ({ children }: BaseLayoutProps): JSX.Element => {
  return (
    <div className={ styles.default }>
      {children}
    </div>
  );
};

Теперь в src/layout/index.tsx создадим HOC (High Order Component), в который добавим и наш компонент AlertingService:

import { FunctionComponent } from 'react';

import { AlertingService } from 'components/AlertingService';

import { BaseLayout } from './BaseLayout';

export const withLayout = <T extends Record<string, unknown>>(Component: FunctionComponent<T>) => {
  return function withLayoutComponent(props: T): JSX.Element {
    return (
      <BaseLayout>
        <AlertingService 
          horizontal={'right'}
          vertical={'top'}
        />
        <Component {...props} />
      </BaseLayout>
    );
  };
};

HOC готов. Фактически - это функция, которая принимает в себя компонент страницы (или любой другой компонент) и возвращает его обернутую версию. Обогащенную (в нашем случае) еще и дополнительным компонентом.

Теперь пришла пора изменить код компонента страницы, убрав из него AlertingService и добавив использование HOC:

import { success, error, warning, info } from 'services/alerting_service';

import { withLayout } from 'layout';
import { Button } from 'components/Button';
import { CenteredVerticalForm } from 'components/CenteredVerticalForm';

const Home = (): JSX.Element => {
  return (
    <CenteredVerticalForm>
      NextJS+RxJS Alerting service
      <Button color={'success'} onClick={() => success('Success message', 3)}>Success</Button>
      <Button color={'error'} onClick={() => error('Error message', 10)}>Error</Button>
      <Button color={'warning'} onClick={() => warning('Warning message', 5)}>Warning</Button>
      <Button color={'info'} onClick={() => info('Info message')}>Info</Button>
    </CenteredVerticalForm>
  );
};

export default withLayout(Home);

Теперь уже - совсем всё. Теперь каждая страница, обернутая в HOC (а в хотя бы относительно большом приложении это - вообще каждая страница), в неявном виде теперь содержит AlertingService, отправлять алерты в который можно простым вызовом из любого обработчика события или эффекта хелперы successerrorwarning и info, устанавливая для каждого отдельного кейса необходимое сообщение и таймаут. А сервис алертингов позаботится обо всем остальном.

Заключение

Сегодня мы реализовали достаточно полезную штуку, и даже выполнили все Acceptance Criteria, что очень даже неплохо. Данный подход можно использовать в любых ситуациях, когда необходимо создать канал связи между компонентами, без необходимости проброса пропсов и событий. Способ не претендует на silver bullet, лишь является одним из возможных вариантов решения определенной задачи.

Спасибо за внимание!

Tags:
Hubs:
Total votes 4: ↑3 and ↓1+2
Comments2

Articles