Skip to content

Instantly share code, notes, and snippets.

@AlexandrHoroshih
Last active November 6, 2022 07:53
Show Gist options
  • Save AlexandrHoroshih/7f48c8351ff290b1a31af74a56113735 to your computer and use it in GitHub Desktop.
Save AlexandrHoroshih/7f48c8351ff290b1a31af74a56113735 to your computer and use it in GitHub Desktop.
effector-protocol-symbol

RFC: Универсальный контракт для экосистемы эффектора

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

Многие библиотеки в экосистеме вводят свои абстракции: Query из farfetched, Route из atomic-router и Factory из factorio, которые обычно представляют собой объект со сторами

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

import { useUnit } from "effector-react"
import { createRoute } from "atomic-router"

const postsRoute = createRoute()

// ...

const { query, params } = useUnit(postsRoute)

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

Поскольку самыми базовыми примитивами являются сторы и ивенты, то и сводится всё тоже должно к ним. Достичь этого можно было бы вводом специального универсального символа контракта, подобно тому, как работает Symbol.observable:

import { unitSymbol } from "effector"

const $unit = SomeAbstractUnit[unitSymbol]()
  • Сам контракт должен быть некой функцией, которая возвращает юнит.
  • Эту функцию могут вызывать сколько угодно раз и откуда угодно - поэтому функция не может быть фабрикой юнитов и, в идеале, должна только читать что-то из внешнего скоупа.
  • Аргументов у функции, в общем случае, быть не должно.

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

Кейс: переиспользование базовых биндингов

Сейчас под все основные, поддерживаемые в орге эффектора, UI-фреймворки (React, Vue, Solid.js) уже есть хук useUnit

Было бы здорово переиспользовать его же и для более абстрактных юнитов библиотек.

На примере atomic-router

Имеем:

const postRoute = createRoute()
// postRoute.$isOpened
// postRoute.$params
// postRoute.$query

Контракт должен возвращать юнит, поэтому внутри дополнитльно создается подходящий стор:

const $routeState = combine({ isOpened: $isOpened, params: $params, query: $query })

Реализация контракта:

{
 [unitSymbol]: () => {
   return $routeState
 }
}

Теперь Route умеет сконвертировать себя в базовый юнит и отдать его наружу:

const { query, params, isOpened } = useUnit(postsRoute)

Это будет работать с любым UI-слоем, биндинги которого умеют подписываться на стор

Кейс: взаимодействие библиотек между собой

На примере @effector/reflect + effector-factorio

Рефлект ожидает юниты в биндах для пропсов, эффектор-факторио генерирует пару фабрика-вьюха, обе библиотеки для реакта.

Их взаимодействие может выглядеть подобным образом:

import { modelView, modelFactory, selectUnit } from "effector-factorio"
import { reflect } from "@effector/reflect"

const factory = modelFactory(() => {
  // код фабрики
  return {
    $login,
    loginChanged,
  };
});

const Form = modelView(factory, () => {
  return (
    <div className="flex flex-col gap-2">
      <LoginField />
    </div>
})

const LoginField = reflect({
 view: Input,
 bind: {
  value: selectUnit(factory, "$login"),
  onChange: selectUnit(factory, "loginChanged"),
  disabled: $globalBlock,
 }
})

Хелпер selectUnit мог бы производить объект с контрактом такого вида:

const selectUnit = (factory, selectedUnit) => ({
 [unitSymbol]: () => {
   // факторио извлекает конкретный инстанс модели в рантайме реакта из контекста
   const model = factory.useModel()

   return model[selectedUnit]
 }
})

Рефлект мог бы вычислять контракты во время рендера компонента и таким образом получать корректный юнит в рантайме.

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

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

  • Контракт должен производиться отдельным хелпером
  • Контракт должен вызываться в контексте фреймворка

Проблемки

Методы абстрактных юнитов

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

Этим библиотекам, возможно, пригодилось бы подобное:

const route = useUnit(postRoute)

route.isOpened // читаем состояние
route.open() // вызываем метод
// всё в скоупе, по уму

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

Шейпы юнитов

Возможный выход проблемы выше - разрешить возвращать не только один юнит,но и шейп:

{
 [unitSymbol]: () => {
   return { isOpened: $isOpened, params: $params, query: $query, open }
 }
}

Но это создает новую проблему - теперь есть неоднозначность поведения во многих случаях, например, вот в таком:

С контрактом, который всегда возвращает юнит

const {
 value,
 route: { isOpened }
} = useUnit({
  value: $value,
  route: postRoute  // резолвится однозначно 👍 
})

С контрактом, который возвращает либо юнит, либо шейп

const {
 value,
 route: { isOpened }
} = useUnit({
  value: $value,
  route: postRoute  // ❓❓❓ 
})

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

Для взаимодействия не-базовых библиотек между собой это создает ещё больше проблем и неоднозначностей

reflect({
 view: Page,
 bind: {
   value: // в рефлекте идеологически нельзя привязать шейп разнородных юнитов к одному пропу
 }
})

Сейчас и atomic-router и farfetched не дают встроенных хуков-инструментов для вызова методов, в целом подразумевается, что вьюха этого не делает напрямую - только через ивенты в духе buttonClicked, описание этого должно быть в модели, а не вью.

Поэтому, как-будто бы, ни шейпы, ни методы пока не нужны, а одного стора или ивента в качестве универсального стандарта достаточно

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

@igorkamyshev
Copy link

Методы (то есть ивенты) нужны уже сейчас. Пара кейсов.

  1. Не парить пользователя «ну мы не советует стартовать Query в компоненте, поэтому перепили все приложение».
function Comp() {
  const { start: startQuery } = useUnit(locationQuery)

 return <button onClick={startQuery}>load location</button>
}
  1. Комбинация разных библиотек. Например, router при старте запроса по переходу на роут должен не только знать статус запроса, но и уметь стартовать его
const locationLoadedRoute = chainRoute(locationRoute, { beforeOpen: locationQuery })

@AlexandrHoroshih
Copy link
Author

дамп обсуждения из телеги

Мои аргументы против шейпа

Стор/ивент - универсальные примитивы, а вот шейп - уже нет, об этом и проблема, дело не только в типах.
Потому что произвольный шейп - не юнит и его нельзя просунуть везде, где можно просунуть юнит, вызовы useUnit(arg | shape) - исключение

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

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

Вот прилетело в chainRoute.beforeOpen нечто, что определило себе контракт юнита-шейпа
Как чейн-роут поймет, что ему с этим делать?

Если это нечто должно предоставить не просто шейп, а именно ивент start и стор $status - то это уже другой контракт, по сути, "chainRoute.beforeOpen принимает Query"

Это уже не универсальный контракт, а вполне конкретная интеграция между двумя конкретными библиотеками, которая не будет работать ни для кого другого

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment