Лучшая часть Эффектора

Эффектор — это компактный и производительный менеджер состояний. Он независим от UI-фреймворков, предсказуем и удобен. Почти месяц назад мы в Авиасейлс начали мигрировать часть кода с RxJS на Effector. В процессе я обнаружил совершенно неожиданную фичу, которая изменила моё отношение к библиотеке.

It is avaiable in English

Дисклеймер

На мой взгляд, из-за отсутствия реактивных примитивов в JS, писать бизнес-логику без использования сторонних библиотек невозможно. Как следствие, я не изолирую бизнес-логику от стейт-менеджмента. Дальше все примеры будут основаны на этом посыле.

Проблема

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

Тестирование

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

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

describe('User flow', () => {
  test('should set default currency after login', async () => {
    emitEvent('Login', { login, password })

    // ... wait

    expect(userSettings.currency).toBe('THB')
  }) 
})

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

При этом, даже в простых кейсах могут возникнуть ситуации, когда дождаться окончания вычислений простым ожиданием резолва промиса невозможно. Например, вот так 👇

async function fetchCurrency({ token }) {
  const { currency } = await request('/settings', { token })

  return currency ?? 'THB'
}

async function login({ login, password }) {
  const token = await request('/login', { login, password })
  
  // не дожидаемся выполнения операции
  // такая уж логика 🤷‍♂️
  fetchCurrency({ token })
    .then(currency => setLocalCurrency(currency))

  return token
}

Очень часто бизнес-сценарий содержит асинхронные операции, которые могут вызывать другие асинхронные операции и так до бесконечности.

Единственное решение, которое в этом случае помогает дождаться окончания сценария — бросать специальное событие. Тогда тесты можно переписать таким образом 👇

describe('User flow', () => {
  test('should set default currency after login', async () => {
    emitEvent('Login', { login, password })

    await waitForEvent('Login/Finished')

    expect(userSettings.currency).toBe('THB')
  }) 
})

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

На самом деле, в идеальном мире, я хотел бы не менять код бизнес-логики в угоду тестируемости.

describe('User flow', () => {
  test('should set default currency after login', async () => {
    emitEvent('Login', { login, password })

    await waitForAllComputationsFinished()

    expect(userSettings.currency).toBe('THB')
  }) 
})

👆 в контексте этого тест-кейса нет никакой информации, что происходит в процессе логина пользователя. Зато сразу становится понятно, что в итоге у пользователя будет установлена корректная валюта.

SSR

SSR (Server-side rendering) — это процесс создания html-строки, которая отображает текущее состояние приложения. Строка отправляется в браузер и пользователю не требуется ждать загрузки JS, чтобы увидеть первые данные. Это улучшает пользовательский опыт и помогает с SEO.

После того как пользователь попал на страницу, приложению нужно запросить данные для отображения, дождаться завершения всех запросов и потом на основании полученных данных создавать html-строку, которая отправится в браузер. Ситуация очень похожа на написание теста для конкретного бизнес-сценария (в этом случае он начинается с действия «пользователь зашел на страницу»).

async function renderAppOnServer(route) {
  const store = createStore()

  emitEvent('Route/changed', { route })

  // ... wait

  return renderAppToString(store)
}

Все современные UI-фреймворки позволяют представить интерфейс как функцию данных, поэтому я опущу детали конкретных технологий и буду использовать магическую функцию renderAppToString, которая принимает объект стора и возвращает html-строку.

И снова представим себе идеальный мир, в котором не меняя кода приложения можно было бы просто дождаться окончания всех вычислений 👇

async function renderAppOnServer(route) {
  const store = createStore()

  emitEvent('Route/changed', { route })

  await waitForAllComputationsFinished()

  return renderAppToString(store)
}

«Обычные» стейт-менеджеры

Теперь давайте посмотрим, как решены эти проблемы в классических стейт-менеджерах. У меня есть опыт с redux и с MobX, поэтому буду говорить о них. Если в вашей любимой библиотеке это делается легко и удобно — расскажите мне об этом в твиттере.

Redux

Из коробки в нем нет механизма для работы с сайд-эффектами, поэтому обычно берут redux-saga, redux-thunk (сейчас он вошел в состав redux-toolkit) или что-нибудь похожее.

Самый просто способ — добавление экшона «вычисления закончены». Это абсолютно рабочий вариант, у которого есть одна, но фатальная проблема — приходится учитывать это в каждом сценарии и адаптировать бизнес-логику к схеме. В этом случае при усложнении связей между разными сценариями получается каша.

Другой путь — уместить один сценарий в одну сущность (санк, сагу, вот эвер). Тогда достаточно дождаться окончания работы этой сущности. Для санков это сделать совсем просто — ведь эта штука возвращает из dispatch промис, который резолвится в момент окончания выполнения санка. А для саг есть специальная библиотека redux-saga-test-plan.

Короче, проблема решена, но радости и восторга это решение не приносит 🤷‍♂️ да еще и в сложных кейсах не работает.

MobX

В этом случае применяются аналогичные техники. Например, можно просто добавить булево поле, которое изменится после окончания сценария 👇

describe('User flow', () => {
  test('should set default currency after login', async () => {
    userStore.login({ login, password })

    await when(() => userStore.done)

    expect(userStore.currency).toBe('THB')
  }) 
})

Правда в этом случае может быть довольно сложно организовать сложный сценарий, затрагивающий несколько сторов.

Ну и более простой вариант — оформить весь сценарий в одну асинхронную функцию. Тогда тест становится простым 👇

describe('User flow', () => {
  test('should set default currency after login', async () => {
    await userStore.login({ login, password })

    expect(userStore.currency).toBe('THB')
  }) 
})

Но довольно часто сложные кейсы нельзя покрыть в этой парадигме.

Короче, проблема решена, но радости и восторга это решение не приносит 🤷‍♂️ да еще и в сложных кейсах не работает.

Effector-way

Чтобы разобраться с кодом, который я покажу дальше, лучше прочитать документацию Effector. Бонусом можно посмотреть Effector SPb Meetup #1.

Фича, которая изменила мое отношение к этому стейт-менеджеру — Fork API. Чтобы понять его смысл, нужно разобраться с двумя концепциями — домены (domain) и скоупы (scope).

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

// Общий для всего приложения домен
const app = createDomain()

// Специфичный для пользователя домен
const user = app.createDomain()

// Обработка логина пользователя
const loginFx = user.createEffect(/* запрос куда-то */)

// Специфичный для настроек домен
const settings = app.createDomain()

//  Смена валюты
const changeCurrency = settings.createEvent()

// Хранилище валюты
const $currency = settings.createStore()
  .on(changeCurrency, (_, newCurrency) => newCurrency)

sample({
  // После окончания логина
  source: loginFx.doneData,
  // Взять валюту пользователя или тайские баты
  fn: ({ settings }) => settings.currency ?? 'thb',
  // и передать в событие смены валюты
  target: changeCurrency,
})

Теперь при необходимости можно «форкнуть» любой из этих доменов, чтобы получить независимую копию этой части приложения — скоуп.

Unit-тесты

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

describe('User flow', () => {
  test('should set default currency after login', () => {
    loginFx({ login, password })

    expect($currency.getState()).toBe('THB')
  }) 
})

👆этот тест, конечно же, упадет, потому что в нём нет ожидания окончания вычислений.

Решить это можно, если вызывать эффекты и ивенты не напрямую, а через функцию allSettled. Она запускает юнит (ивент или эффект) и дожидается, пока закончатся все вычисления в указанном скоупе. А чтобы получить состояние стора в конкретном скоупе, нужно воспользоваться методом getState.

describe('User flow', () => {
  test('should set default currency after login', async () => {
    // Создаем новый скоуп всего приложения
    const scope = fork(app)

    // Запускаем logixFx в этом скоупе
    // и ожадем окончания всех вычислений
    await allSettled(loginFx, {
      params: { login, password },
      scope,
    })

    // Проверяем значение стора в скоупе
    expect(scope.getState($currency)).toBe('THB')
  }) 
})

Так, не меняя код приложения можно протестировать весь бизнес-сценарий. Я считаю эту возможность главной киллер-фичей Эффектора.

One more thing

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

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

Вся радость от использования Эффектора заключается в том, что возможность подменить обработчики эффектов изначально заложена в библиотеку.

describe('User flow', () => {
  test('should set default currency after login', async () => {
    const scope = fork(app, {
      handlers: new Map([
        // Подменяем обработку эффекта в этом скоупе
        [loginFx, jest.fn(() => ({ settings: null }))]
      ])
    })

    await allSettled(loginFx, {
      params: { login, password },
      scope,
    })

    expect(scope.getState($currency)).toBe('THB')
  }) 
})

Это позволяет без хитрых рантайм-модификаций получить предсказуемо подмененный обработчик в изолированной части приложения.

Обратите внимание, что для использования этой возможности нужно обязательно использовать фирменный babel-плагин.

SSR

Кроме написания тестов, Fork API нужен для серверного рендинга. Тут есть две причины.

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

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

Если приложение должно при переходе на главную загрузить из интернета количество пользователей сайта и показать его на странице, то логика может быть описана примерно так:

// Общий для всего приложения домен
const app = createDomain()

// Специфичный для роутера домен
const routing = app.createDomain()

// Событие обновления роута
const routeChanged = routing.createEvent()

// Специфичный для статистики домен
const stats = app.createDomain()

const fetchUsersFx = stats.createEffect(/* запрос куда-то */)

// Хранилище числа пользователей
const $userCount = stats.createStore()
  .on(fetchUsersFx.doneData, (_, newCount) => newCount)

guard({
  // Когда изменился роут
  clock: routeChanged,
  // если новый роут — главная
  filter: (route) => route === 'main',
  // загрузить данные о пользователях
  target: fetchUsersFx,
})

👆эта логика ничего не знает о контексте использования. Приложению не важно, выполняется оно в браузере пользователя или на сервере в Node.js-среде.

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

async function renderAppOnServer(route) {
  // Создаем скоуп для конкретного запрсоа
  const scope = fork(app)

  // Сообщаем, какой роут открылся
  // и ждем окончания вычислений
  await allSettled(routeChanged, {
    params: route,
    scope,
  })
  
  // В этой функции скрыты особенности UI-фреймворка
  return renderAppToString(scope)
}

Есть некоторые детали использования скопуов с конкретным UI-фреймворком, но там ничего сложного, почитайте документацию.

Так, не меняя код приложения можно настроить для него серверный рендеринг. Я считаю эту возможность второй киллер-фичей Эффектора.

И что?

Изначально мы рассматривали Эффектор по большей части из-за его ориентации на мульти-сторы. Это особенность позволяет лучше структурировать большие приложения и строить более прозрачные связи между разными частями. Но это не было той самой киллер-фичей, которая заставила бы меня стать адептом инструмента.

После месяца активной разработки с использованием Эффектор и подробным изучением Fork API, я уверен, что это самый удобный и логичный способ управлять состоянием в приложении, описывать бизнес логику и не переживать.


Пишите комментарии в телеграм-канал или в твиттер 🤗