[JavaScript, Интерфейсы, ReactJS, TypeScript] Использование Effector в стеке React + TypeScript

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
09-Дек-2020 14:34

Всем привет! Меня зовут Елизавета Добрянская, я frontend-разработчик в компании ДомКлик. Моя команда занимается разработкой сервисов, предназначенных для коммуникаций с клиентом. В этой статье я поделюсь своим кратким обзором внедрения стейт-менеджера Effector в продуктовый проект на стеке React + TypeScript, а также покажу на примере, как легко это можно сделать.
Содержание:
  • Немного предыстории
  • Первая встреча с Effector
  • Боль как начало
  • Выходим на новый уровень — получаем удовольствие
  • Best practices
  • Итоги
  • Вместо послесловия
Немного предысторииМоя команда занимается разработкой разных видов сервисов коммуникаций — отдельных виджетов, npm-пакетов, SSR, полностраничных сайтов. У всех этих продуктов есть одно важное требование: интерфейс должен быстро реагировать на действия пользователя, при этом сам сервис должен выдерживать большую нагрузку. А это значит, что на нас, как на разработчиках, лежит большая ответственность за то, как мы проектируем frontend.Перед созданием нового проекта мы с командой устроили брейншторм на предмет выбора стейт-менеджера. Нам было важно, чтобы он стал хорошим помощником в разработке, позволял быстро и удобно писать код, и плюс ко всему не «бил» по производительности нового проекта. Его главной задачей стало сохранение данных на клиенте для дальнейшей модификации и отправки на бек (ничего необычного).Выбирали между Redux, Mobx и Effector. Первые два мы пробовали, и впечатления остались очень неоднозначные. И как ясно из статьи, выбрали последний, потому что любопытно было узнать, что же за зверь такой этот Effector и чем он может помочь нам. К тому же новый проект создавался для внутренних нужд и на нем вполне можно было поэкспериментировать.ATTENTION: приведенные в статье размышления являются сугубо субъективными, поэтому ваше мнение может отличаться от моего. Они носят обозревательный характер и позволяют познакомиться с Effector на моем примере.Все примеры с кодом доступны в тестовом проекте на GitHub, который при необходимости можно запустить и лично познакомиться с Effector.Первая встреча с EffectorЧто есть Effector? Модный, молодежный реактивный стейт-менеджер :) А потому понять его базовые принципы оказалось довольно просто. В его основе лежат три простых базовых сущности:
  • Хранилище (Store) — это место, где мы храним наши данные.
  • Событие (Event) — это действие, которое каким-то образом модифицирует хранилище.
  • Эффект (Effect) — это асинхронное действие, связанное с хранилищем.
У каждой из сущностей есть большое количество различных методов, позволяющих изменять входные/выходные параметры, связывать сущности между собой и выполнять другие крутые штуки.Основная идея, лежащая в основе Effector — подписка на события. У нас есть хранилище, мы подписываемся на его обновления, вешая определенный обработчик. Например:
// Создаем хранилище, в котором будет лежать массив пользователей
// IUser — интерфейс, описывающий пользователя (имя, фамилия и т.п.)
export const $users = createStore<IUser[]>([]);
// Создаем событие, принимающее параметр IUser
export const update = createEvent<IUser>();
// Обычный хендлер на обновление. Добавляем или изменяем пользователя
const updateStore = (state: IUser[], data: IUser) => {
  const userIndex = state.findIndex((user) => user.id === data.id);
  // Изменяем стейт
  if (userIndex > -1) {
    state.splice(userIndex, 1, data);
  } else {
    state.push(data);
  }
  // Возвращаем измененный стейт
  return [...state];
};
// Подписываемся на событие в хранилище
$users
  .on(update, updateStore);
Effector позволяет работать с разными типами приложений, таких как React, React Native, Vue, Node.js. Кроме того, он поддерживает TypeScript.Для работы с React есть удобный пакет effector-react, предоставляющий несколько интерфейсов взаимодействия React-компонентов с Effector. Самый простой способ — использовать хук useStore для максимально лаконичной работы с хранилищами Effector. Вот пример работы с описанным выше хранилищем $users, где по нажатию на кнопку мы добавляем в хранилище пользователя-заглушку:
import { useStore } from 'effector-react';
import { $users, update } from 'models/users';
export const UserList = () => {
  const users = useStore($users);
  const mockUser = {
    id: 1111,
    name: 'Peter',
    surname: 'Jonson',
    age: 25,
    gender: 'male',
  };
  const usersItems = users.map((user) => (
    <div key={user.id}>
      <div>Name: {user.name}</div>
      <div>Surname: {user.surname}</div>
      <div>Age: {user.age}</div>
      <div>Gender: {user.gender}</div>
      <br/>
    </div>
  ));
  return (
    <div>
      {usersItems}
      <button onClick={() => update(mockUser)}>
        Add mock user to Effector store
      </button>
    </div>
  );
};
Ради интереса можно попробовать сделать то же самое, но с хуком useList. Он предоставляет упрощенный вариант взаимодействия с хранилищем-массивом. Реализация аналогичной задачи:
import { useList } from 'effector-react';
import { $users, update } from 'models/users';
export const UserList2 = () => {
    // Можно преобразовать в массив нод сразу при подключении.
    // Не нужно использовать пропс key, как было с map()
  const users = useList($users, (user) => (
    <div>
      <div>Name: {user.name}</div>
      <div>Surname: {user.surname}</div>
      <div>Age: {user.age}</div>
      <div>Gender: {user.gender}</div>
      <br/>
    </div>
  ));
  const mockUser = {
    id: 2222,
    name: 'Diana',
    surname: 'Gregory',
    age: 22,
    gender: 'female',
  };
  return (
    <div>
      {users}
      <button onClick={() => update(mockUser)}>
        Add mock user to Effector store
      </button>
    </div>
  );
};
Об этом и многом другом можно почитать в официальной документации Effector. Поэтому долго не будем на этом останавливаться и перейдем к «самому сладенькому». Далее я расскажу про боли и страдания в процессе работы с этим, казалось бы, очень простым и удобным стейт-менеджером. Без купюр.Боль как началоНе стоит думать, что статья про несовершенный, наполненный кучей проблем и багов продукт увидела бы свет. Проблемы, описанные здесь, я встретила будучи полным новичком в Effector. По итогу они были повержены, а автор этой статьи — счастлив :) Если вы встретите похожие проблемы, то можете воспользоваться приведенными решениями или модернизировать их, чтобы создать своё.1) TypeScriptДа, самым сложным для меня оказалась поддержка такого же модного и молодежного, как и Effector, языка программирования TypeScript. В официальной документации Effector-а все примеры приведены на чистом JavaScript. Есть, конечно, маленькая робкая вкладка "TypeScript", которая, в основном, даёт только понимание того, куда нужно добавить типы в описании основных сущностей, но на этом всё. Поэтому сначала я использовала any, а под конец пришлось очень много страдать с расстановкой правильных типов (особенно касательно эффектов).
Так, например, родились следующие интерфейсы функций (слабонервным не смотреть):
// Создаем эффекты для получения и изменения данных о пользователях
// IUserPayload - интерфейс пользователя, приходящий с сервера
export const getUsersFx = createEffect<void, IUserPayload[], Error>();
export const updateUserFx = createEffect<
  IUserPayload,
  IUserPayload,
  Error
>();
// Изменяем формат данных из хранилища в формат, необходимый для отправки запроса
const serializeDataBeforeFetch = attach<
  IUser,
  Store<IUser[]>,
  typeof updateUserFx
  >({
  effect: updateUserFx,
  source: $users,
  mapParams: (params: IUser, data: IUser[]) => {
    const user = data.find((item) => item.id === params.id)!;
    const userCopy = { ...user };
    delete userCopy?.onlineStatus;
    return userCopy;
  },
});
Небольшие пояснения по коду.Эффекты имеют следующий формат типизации:
  • Тип передаваемого в эффект значения.
  • Тип возвращаемого из эффекта значения.
  • Тип ошибки для случая, если что-то пошло не так.
Про функцию serializeDataBeforeFetch расскажу ниже, а пока стоит обратить внимание на типы метода attach, предоставляемого Effector:
  • Тип передаваемого значения.
  • Тип данных хранилища.
  • Тип эффекта, используемого внутри attach.
2) Асинхронные событияПоначалу было очень сложно это понять и принять. Представьте ситуацию, что вы написали код, и при тестировании он выдает неожиданные результаты и ошибку. Вы пытаетесь отладить ошибкоопасное место с помощью точек останова, но видите, что в дебаг-режиме всё работает, как нужно. А вот в обычном режиме (и на самом деле) всё не так, ничего не работает. То есть в режиме отладки вы как бы «притормаживаете» свой код, и поэтому он отрабатывает корректно, а на самом деле есть проблемы. Собственно, это просто нужно принять к сведению торопливому разработчику — действия в Effector происходят асинхронно (подобно setState в React).3) Получение доступа к текущему состояниюЭтот пункт про то, что нужно внимательно смотреть документацию :)Некоторые методы в Effector могут первым параметром принимать текущее состояние хранилища, а некоторые — нет. Поэтому нужно внимательно выбирать методы обработки.4) Четкий интерфейс работы с сущностямиПочему это может быть плохо? Потому что сложно отслеживать результат изменения хранилища в рамках связанного компонента. Интерфейс взаимодействия упрощенно выглядит так:
  • Хранилище — readonly. В компоненте мы на него подписываемся, и все изменения считываем реактивно.
  • Событие — по сути, setter. Мы говорим «измени моё хранилище, добавь в него эти данные и удали те». Событие ничего не возвращает. Поэтому его нельзя использовать как getter и получить отфильтрованные данные из хранилища напрямую (об этом будет далее).
  • Эффект — аналогичен событию, но имеет свойства .done, .fail, .pending и .finally, с которыми можно взаимодействовать (об этом тоже будет далее).
5) Отсутствие геттеровЕсли вы раньше работали с Mobx или Redux, то привыкли, что у модели можно задать геттеры и обращаться к ним для получения, например, отфильтрованных или хитро измененных данных. Как было сказано выше, в Effector такого нет. Но... Зачем нам геттер, если мы можем создать новое хранилище?Для нас привычно, что хранилище относится к модели 1 к 1. Здесь эта логика рушится в пух и прах. Мы можем создавать несколько хранилищ, связанных друг с другом, как нам нужно.Пример нового хранилища, зависимого от основного:
// Учебный пример.
// Предположим, на клиенте нужно дополнительное поле со статусом пользователя.
// Оно не приходит с сервера, и мы добавляем его искусственно.
// Добавляем поле Статус каждому пользователю
const serializeUsers = (state: IUser[]) =>
  state.map((user) => ({ ...user, onlineStatus: true }));
/**
* Новое хранилище, зависимое от хранилища $users.
* Данные из $users прогоняются через функцию serializeUsers
* и сохраняются в новое хранилище, которое можно использовать в компоненте
*/
export const $usersWithStatus = $users.map(serializeUsers);
6) Отслеживание статуса эффектовУ эффектов есть промисоподобные свойства .done, .fail, .pending и .finally. Поэтому кажется, что очень удобно отслеживать статус. Но обычно он важен для отображения данных в компоненте: когда мы послали запрос на данные и ожидаем ответа, нужно показывать лоадер; когда данные загружены с ошибкой — нужно показать ошибку. Поэтому необходимо каким-то образом прокидывать эти статусы в компонент. Как было сказано выше, геттеров нет. Но есть хранилища! Можно создать хранилище, сочетающее в себе все статусы:
/* МОДЕЛЬ В EFFECTOR */
// Создаем эффект, который делает GET-запрос на бек
export const getUsersFx = createEffect<void, IUserPayload[], Error>();
// Создаем хранилище, в котором будет лежать ошибка, если GET-запрос зафейлится
// I вариант
export const $fetchError = restore<Error>(getUsersFx.failData, null);
// Создаем другое хранилище, содержащий всю информацию по GET-запросу
export const $usersGetStatus = combine({
  loading: getUsersFx.pending,
  error: $fetchError,
  data: $users,
});
/* КОМПОНЕНТ, ИСПОЛЬЗУЮЩИЙ ХРАНИЛИЩЕ */
export const UserList3 = () => {
  // Подключаем хранилище в компонент
  const { loading, error, data } = useStore($usersGetStatus);
  // Делаем запрос на бек на didMount
  useEffect(() => {
    getUsersFx();
  }, []);
  if (loading) {
    return (
      <div>Загрузка...</div>
    );
  }
  if (error) {
    return (
      <div>
        <span><b>Произошла ошибка: </b></span>
        <span>{error.message}</span>
      </div>
    );
  }
  const usersItems = data.map((user) => (
    <div key={user.id}>
      <div>Name: {user.name}</div>
      <div>Surname: {user.surname}</div>
      <div>Age: {user.age}</div>
      <div>Gender: {user.gender}</div>
      <br/>
    </div>
  ));
  return (
    <div>
      {usersItems}
    </div>
  );
};
В приведённом выше варианте создания хранилища $fetchError был использован еще один метод Effector — restore. Он позволяет создать хранилище, содержимое которого будет зависеть от события наступления события. Очень удобно использовать для очистки (сброса в начальное состояние) хранилища.Создать хранилище $fetchError можно и через стандартный createStore :
// II вариант
export const $fetchError = createStore<Error | null>(null);
$fetchError
  .on(getUsersFx.fail, (_, { error }) => error)
  .reset(getUsersFx.done);
Выходим на новый уровень - получаем удовольствиеНесмотря на большое количество непоняток, которые может встретить начинающий эффекторец, в процессе ты понимаешь, что он очень удобный. Ниже я выделила основные пункты, которые понравились лично мне.1) Никаких лишних телодвижений для подписки на хранилищеПри грамотно созданных моделях в компоненте не нужно страдать и отслеживать все свои телодвижения по обновлению хранилища. Подключили его в компонент — он всегда актуален и перерисовывается при каждом обновлении хранилища. Никаких тебе Mobx-овых @action, @computed и прочей ручной настройки. Каеф :)2) Меньше кода (и меньше размер)Нет надобности создавать отдельные классы-модели, прописывать им интерфейсы. Создали хранилище, создали событие, подписали событие на хранилище — готово!И да, размер двух подключенных библиотек effector и effector-react составляет около 8 Кб (у Mobx сумма подключенных библиотек — около 15-20 Кб)!
3) Минимальное взаимодействие компонента и хранилищаКогда я решала похожую задачу на Mobx, у меня было очень странное взаимодействие компонента и хранилища:
  • Из компонента посылаем запрос на бек (потому что нужно отслеживать статус запроса).
  • Здесь же получили данные и положили их в хранилище.
Т.е. компонент используется как прокси в этом случае. И это кажется очень странным, потому что зачем? Нам нужно просто положить данные из ответа на запрос в хранилище, без взаимодействия с компонентом.Effector позволяет реализовать работу напрямую: из хранилища послал запрос, в хранилище положил ответ. И наоборот. Это, например, очень удобно делается с помощью метода forward. Мы перенаправляем выход эффекта на вход события.Для примера рассмотрим историю, когда нам нужно обновить хранилище и сразу же отправить запрос на бек. Выше был пример с добавлением искусственного поля onlineStatus в модель пользователя. Перед отправкой удалим это поле из пейлоада, т.к. бек про него ничего не знает. Описанную историю можно реализовать таким образом:
/* СОЗДАНИЕ СОБЫТИЯ */
// Создаем событие на обновление хранилища
export const update = createEvent<IUser>();
// Хендлер на обновление хранилища (был описан выше)
const updateStore = (state: IUser[], data: IUser) => {
  const userIndex = state.findIndex((user) => user.id === data.id);
  // Изменяем стейт
  if (userIndex > -1) {
    state.splice(userIndex, 1, data);
  } else {
    state.push(data);
  }
  // Возвращаем измененный стейт
  return [...state];
};
// Подписываемся на обновление хранилища через хендлер
$users
  .on(update, updateStore)
/**********************************************************/
/* СОЗДАНИЕ ЭФФЕКТА */
// Создаем эффект для изменения данных о пользователе (Запрос на бек)
export const updateUserFx = createEffect<IUserPayload, IUserPayload, Error>();
// Асихронная функция запроса на бек
const updateUser = async (data: IUserPayload): Promise<IUserPayload> => {
  const res = await axios({
    url: `/users/${data.id}`,
    method: 'PATCH',
  });
  return res.data;
}
// Привязываем к эффекту
updateUserFx.use(updateUser);
/**********************************************************/
/* ПРЕОБРАЗОВАНИЕ ДАННЫХ */
// Изменяем формат данных из хранилища в формат, необходимый для отправки запроса
// (Удаляем искусственное поле onlineStatus)
const serializeDataBeforeFetch = attach<
  IUser,
  Store<IUser[]>,
  typeof updateUserFx
  >({
  effect: updateUserFx,
  source: $users,
  mapParams: (params: IUser, data: IUser[]) => {
    const user = data.find((item) => item.id === params.id)!;
    const userCopy = { ...user };
    delete userCopy?.onlineStatus;
    return userCopy;
  },
});
// Связываем событие и функцию-преобразователь
forward({
  from: update,
  to: serializeDataBeforeFetch,
});
Пояснения по коду.Стек вызова упрощенно будет выглядеть следующим образом:
  • update(...) — вызываем событие на обновление хранилища из компонента.
  • updateStore — хранилище обновляется согласно переданному хендлеру.
  • serializeDataBeforeFetch — после обновления хранилища вызывается функция преобразования его данных в пейлоад. В ней используется метод Effector attach, позволяющий сделать forward с модификацией.
  • updateUserFx — вызываем эффект на обновление.
  • updateUser — делаем запрос на бек.
Вуаля!
Да, на первый взгляд это выглядит запутанно. Но если в этом разобраться, можно очень удобно использовать «перебрасывание» данных из одной функции в другую.4) Крутое и отзывчивое сообществоКогда я поняла, что в документации и гугле нужных мне примеров нет от слова совсем, я решила действовать радикально и пойти в сообщество Effector в Telegram. Я задала один вопрос «от хлебушка», на который я получила за один вечер... 5 разных вариантов решений от разных разработчиков! Причём решения были разные по уровню сложности, я могла выбрать любое из них, или скомбинировать и создать своё. Некоторые решения были очень хорошо расписаны и объяснены, некоторые содержали продуктовый код с примерами прямо на GitHub, некоторые содержали ссылки на воркшопы по Effector. В общем, я приятно удивлена, что есть такое классное сообщество, где ребята всячески поддерживают друг друга :)Да и в целом в проекте я использовала версию Effector 21.5.0. То есть ребята мажорно обновляли свой проект 20 раз. Это очень существенно!Best practices[Для тех, кто хочет знать больше] Об этом есть статья в самой документации, но я кратко продублирую.
  • Названия хранилищ содержат символ $. Например, $users.
  • Названия эффектов содержат суффикс Fx. Например, getUsersFx.
  • Файловая структура. В корне исходников создается папка models, внутри которой лежат все модели, работающие с Effector. У каждой модели есть два файла:
    • index.ts — файл, где мы объявляем все хранилища, события, эффекты. Это файл начального объявления;
    • init.ts — файл, где мы описываем все хранилища, события, эффекты и связываем их между собой. Здесь вся бизнес-логика.
ИтогиВ заключение хочу заметить, что Effector выглядит наиболее приятным в использовании стейт-менеджером. Он позволяет легко разделять работу с данными по разным хранилищам и не держать всё в одном (декомпозиция лайк). В нем используется неизменяемый стейт и нет необходимости писать много дублирующегося кода, что повышает производительность вашего проекта. Effector обладает удобным API и прекрасным сообществом разработчиков, поддерживающих проект.Я определенно убеждена, что использование Effector в продуктовой разработке — одно из самых удобных решений. Особенно, если в нем разобраться глубже, чем просто на уровне новичка. Поэтому внедряйте новый стейт-менеджер в свои проекты, пишите комментарии к этой статье и давайте продолжать делать крутой веб вместе ;)
Вместо послесловияПолезные ссылки:
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_javascript, #_interfejsy (Интерфейсы), #_reactjs, #_typescript, #_effector, #_react, #_typescript, #_javascript, #_frontend, #_razrabotka_vebprilozhenij (разработка веб-приложений), #_blog_kompanii_domklik (
Блог компании ДомКлик
)
, #_javascript, #_interfejsy (
Интерфейсы
)
, #_reactjs, #_typescript
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 25-Ноя 15:14
Часовой пояс: UTC + 5