Skip to content

Instantly share code, notes, and snippets.

@AlexandrHoroshih
Last active December 18, 2022 11:20
Show Gist options
  • Save AlexandrHoroshih/22c9040994641220db06b56e72c6ece0 to your computer and use it in GitHub Desktop.
Save AlexandrHoroshih/22c9040994641220db06b56e72c6ece0 to your computer and use it in GitHub Desktop.
use-unit-protocol-v2

Протокол для useUnit

Универсальный протокол для переиспользования концепта useUnit из биндингов под разные UI-фреймворки для библиотек из экосистемы.

Контекст

В экосистеме Эффектора есть библиотеки, которые вводят свои кастомные сущности: Query из @farfetched/core, Route из atomic-router или modelFactory из effector-factorio. Эти кастомные сущности представляют собой некий объект, основные свойства которого являются юнитами эффектора.

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

// farfetched/react/src/use_query.ts

import { useUnit } from 'effector-react';

function useQuery(query: Query) {
  const [data, stale, error, pending, start] = useUnit([
    query.$data,
    query.$stale,
    query.$error,
    query.$pending,
    query.start,
  ]);

  return { data, stale, error, pending, start };
}

export { useQuery };

т.е. простой маппинг свойств объекта Query через useUnit.

При этом сам useUnit, как концепция, становится стандартом биндингов в экосистеме - он уже реализован в effector-react, effector-solid и запланирован в effector-vue - во всех местах useUnit ведет себя одинаковым образом:

  • Принимает один юинт или произвольный шейп из нескольких юнитов
  • Для сторов возвращает подходящий для фреймворка способ использования значений из состояния стора в конкретном фреймворке (значение as-is в Реакте, сигнал в Солиде, реф в Vue, etc)
  • Правильно реализует под капотом подписку на сторы вместе с батчингом синхронных апдейты от всех предоставленных сторов
  • Автоматически обрабатывает ивенты и эффекты и также превращает их в подходящие сущности

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

Предложение

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

Реализация

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

Route из atomic-router представляет собой объект с набором свойств-юнитов:

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

Для использования этого postRoute (например, в Реакте) будет удобно отсечь от свойств сторов их префикс $, чтобы показать, что речь уже именно о реактивных значениях/сигналах/рефах/etc, но не о исходных сторах.

Реализация может выглядеть так:

  1. Из effector импортируется специальный символ.
import { unitSymbol } from "effector"
  1. В объект Route добавляется поле с этим символом, в котором находится функция-маппер в любой валидный аргумент для useUnit - например, специальный публичный шейп юнитов
{
 $isOpened,
 $params,
 $query,
 ...,
 [unitSymbol]: () => {
   return {
     isOpened: $isOpened,
     params: $params,
     query: $query,
   }
 }
}

Почему функция?

Функция используется для того, чтобы дать лишнее пространство для маневра для пограничных случаев типа effector-factorio.

Количество вызовов функций должно зависеть от конкретного фреймворка - сколько раз вызван useUnit в конкретной реализации биндингов, столько раз будет вызвана и функция [unitSymbol]: В Реакте на каждый рендер, в Солиде на каждый маунт и так далее.

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

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

  1. В useUnit, если нашелся unitSymbol, используется именно этот шейп вместо прокинутого:
const postRoute = createRoute()
// postRoute.$isOpened
// postRoute.$params
// postRoute.$query

const post = useUnit(postRoute)
// post.isOpened
// post.params
// post.query

Это позволит тому же atomic-router автомагически получить поддержку биндингов сразу ко всем официально поддерживаемым эффектором UI-фреймворкам.

Задачи, которые этот протокол покрыть не может и не собирается

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

Взаимодействие между библиотеками экосистемы

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

Например, возможная интеграция atomic-router и @farfetched/core через chainRoute.beforeOpen подразумевает не произвольный шейп юинтов, а вполне конкретный - Query из farfetched.

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

Кейс factorio

effector-factorio на этот момент - библиотека строго под Реакт. Его апи требует явного получения инстанса модели через Реакт-контекст:

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

const LoginField = () => {
  const model = factory.useModel()
  const login = useStore(model.$login)

  return <input
    value={login}
    placeholder="Login"
    onChange={evt => model.loginChanged(evt.target.value)}
  />
}

Предолагаемое использование useUnit с протоколом может выглядеть так:

const {
 login,
 ...
} = useUnit(factory)

В целом, форма протокола unitSymbol оставляет достаточно пространства для маневра в подобных случаях:

[unitSymbol]: () => {
   const model = factory.useModel()
   return factory.mapModelToView(model)
 }

но при попытке прокинуть factory в useUnit из Solid.js случится ожидаемое исключение - контекст Реакта не взаимозаменяем с аналогичными штуками в Солиде и Vue. По этой же причине не получится абстрагировать все возможные подобные кейсы в рамках unitSymbol.

Подобные адаптации должны остаться в области ответственности авторов библиотеки.

В случае factorio возможен подобный вариант:

import { modelFactory } from 'effector-factorio';
import { modelView as viewSolid } from 'effector-factorio/solid'
import { modelView as viewReact } from 'effector-factorio/react'

const factory = modelFactory(...)
const Solid = viewSolid(factory, ...)
const React = viewReact(factory, ...)

Детали реализации

Лучшим выбором, вместо настоящего символа, будет "магическая" строка вида "@@effectorUnitSymbol". Это будет более надежный выбор со множества сторон и в первую очередь - позволит мягкую миграцию библиотекам-пользователям этого апи.

Так можно будет обойтись без импорта "символа" со стороны основного пакета effector и прост скопипастить строку и всё заработает, как только версия effector-react будет обновлена до подходящей.

Также API на специальном поле будет проще поддержать во всяких легаси-браузерах.

Альтернативные решения

Разработка, публикация и поддержка отдельных пакетов биндингов для каждой библиотеки под каждый UI-фреймворк.

Результат

Реализация реактивной подписки из коробки

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

Не серебрянная пуля

Для специальных случаев - интеграции с особенными API фреймворков, в духе solid.createResource - всё ещё понадобится отдельный пакет.

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