Русский
Русский
English
Статистика
Реклама

Использование Effector в стеке React TypeScript

Всем привет! Меня зовут Елизавета Добрянская, я frontend-разработчик в компании ДомКлик. Моя команда занимается разработкой сервисов, предназначенных для коммуникаций с клиентом.

В этой статье я поделюсь своим кратким обзором внедрения стейт-менеджера Effector в продуктовый проект на стеке React + TypeScript, а также покажу на примере, как легко это можно сделать.

Содержание:

  1. Немного предыстории

  2. Первая встреча с Effector

  3. Боль как начало

  4. Выходим на новый уровень получаем удовольствие

  5. Best practices

  6. Итоги

  7. Вместо послесловия

Немного предыстории

Моя команда занимается разработкой разных видов сервисов коммуникаций отдельных виджетов, npm-пакетов, SSR, полностраничных сайтов. У всех этих продуктов есть одно важное требование: интерфейс должен быстро реагировать на действия пользователя, при этом сам сервис должен выдерживать большую нагрузку. А это значит, что на нас, как на разработчиках, лежит большая ответственность за то, как мы проектируем frontend.

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

Выбирали между Redux, Mobx и Effector. Первые два мы пробовали, и впечатления остались очень неоднозначные. И как ясно из статьи, выбрали последний, потому что любопытно было узнать, что же за зверь такой этот Effector и чем он может помочь нам. К тому же новый проект создавался для внутренних нужд и на нем вполне можно было поэкспериментировать.

ATTENTION: приведенные в статье размышления являются сугубо субъективными, поэтому ваше мнение может отличаться от моего. Они носят обозревательный характер и позволяют познакомиться с Effector на моем примере.

Все примеры с кодом доступны в тестовом проекте на GitHub, который при необходимости можно запустить и лично познакомиться с Effector.

Первая встреча с Effector

Что есть Effector? Модный, молодежный реактивный стейт-менеджер :) А потому понять его базовые принципы оказалось довольно просто. В его основе лежат три простых базовых сущности:

  • Хранилище (Store) это место, где мы храним наши данные.

  • Событие (Event) это действие, которое каким-то образом модифицирует хранилище.

  • Эффект (Effect) это асинхронное действие, связанное с хранилищем.

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

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

// Создаем хранилище, в котором будет лежать массив пользователей// IUser  интерфейс, описывающий пользователя (имя, фамилия и т.п.)export const $users = createStore<IUser[]>([]);// Создаем событие, принимающее параметр IUserexport 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;  },});

Небольшие пояснения по коду.

Эффекты имеют следующий формат типизации:

  1. Тип передаваемого в эффект значения.

  2. Тип возвращаемого из эффекта значения.

  3. Тип ошибки для случая, если что-то пошло не так.

Про функцию serializeDataBeforeFetch расскажу ниже, а пока стоит обратить внимание на типы метода attach, предоставляемого Effector:

  1. Тип передаваемого значения.

  2. Тип данных хранилища.

  3. Тип эффекта, используемого внутри 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, у меня было очень странное взаимодействие компонента и хранилища:

  1. Из компонента посылаем запрос на бек (потому что нужно отслеживать статус запроса).

  2. Здесь же получили данные и положили их в хранилище.

Т.е. компонент используется как прокси в этом случае. И это кажется очень странным, потому что зачем? Нам нужно просто положить данные из ответа на запрос в хранилище, без взаимодействия с компонентом.

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,});

Пояснения по коду.

Стек вызова упрощенно будет выглядеть следующим образом:

  1. update(...) вызываем событие на обновление хранилища из компонента.

  2. updateStore хранилище обновляется согласно переданному хендлеру.

  3. serializeDataBeforeFetch после обновления хранилища вызывается функция преобразования его данных в пейлоад. В ней используется метод Effector attach, позволяющий сделать forward с модификацией.

  4. updateUserFx вызываем эффект на обновление.

  5. 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
К списку статей
Опубликовано: 09.12.2020 12:08:12
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании домклик

Javascript

Интерфейсы

Reactjs

Typescript

Effector

React

Frontend

Разработка веб-приложений

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru